Skip to content

Commit 6b449dd

Browse files
authored
Merge branch 'main' into dependabot/github_actions/home-assistant/actions/hassfest-a7c616ce81ccda50150bf1595786c71b1883fabb
2 parents 4424cff + 18f970c commit 6b449dd

42 files changed

Lines changed: 2507 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,18 @@ Beyond the sensors above, TaskMate also exposes:
18191819

18201820
## Changelog
18211821

1822+
### v5.0.4
1823+
1824+
A fix release. Five bugs, three of them long-standing and invisible: a control that only admins could use, a badge highlight that had never fired for anyone, and a Picture dropdown that discarded what you picked. No configuration changes — upgrade is drop-in.
1825+
1826+
**Fixes**
1827+
- **TaskMate parents can apply bonuses and penalties again** — a non-admin parent tapping **Apply** on the Bonuses or Penalties card got `Failed to perform the action taskmate/apply_bonus. Unauthorized`, while the quick **+points** buttons on the same card worked. When the parent role arrived in v4.5.0 the day-to-day actions moved to the parent gate, but `apply_bonus`, `apply_penalty` and `remove_points` were left admin-only. They now accept parents, like every other day-to-day action. Creating and editing bonuses and penalties stays admin-only — it is configuration, not a daily action. ([#751](https://github.com/tempus2016/taskmate/pull/751), closes [#749](https://github.com/tempus2016/taskmate/issues/749))
1828+
- **The Bonuses and Penalties cards now respect who is looking at them** — the cards had no role check at all and rendered **Apply**, the manage pencil and the add/edit/delete controls for every user, including a child on a shared tablet. Parents and admins see **Apply**; only admins see the manage controls; a child sees the list read-only. ([#751](https://github.com/tempus2016/taskmate/pull/751))
1829+
- **Badge earning no longer spams the Home Assistant log** — every dashboard load by a non-admin logged `Refusing to allow <user> to subscribe to event taskmate_badge_earned`. The Badges and Child cards subscribed to a custom event, which Home Assistant refuses for non-admin users. They now detect a new badge from the badges sensor instead, which works for everyone. ([#753](https://github.com/tempus2016/taskmate/pull/753), closes [#752](https://github.com/tempus2016/taskmate/issues/752))
1830+
- **The "just earned" badge highlight now actually fires** — found while fixing the above: the highlight compared against a field the badges sensor does not publish, so it had never appeared for anyone, admin included. Earning a badge now flashes it on both the Badges card and the Child card's badge strip. ([#753](https://github.com/tempus2016/taskmate/pull/753))
1831+
- **Chore pictures save and show up** — the **Picture** dropdown in the chore editor did nothing: the panel dropped the icon when saving, and no standard chore row rendered it if it had been saved. Classic shows the picture in place of the number inside the existing coloured badge; Playroom, Console, Clean Pro and Accessible show it in place of the guessed emoji. Chores with no picture look exactly as they do today. ([#755](https://github.com/tempus2016/taskmate/pull/755))
1832+
- **The admin panel fills the window instead of stopping halfway** — on every section with little content (Templates, Insights and most others) the navigation column and content area stopped partway down the page, leaving bare background below. The panel sized itself through a chain of percentage heights that only resolves on some Home Assistant versions; it now sizes to the viewport directly. Long sections still scroll their content with the nav column fixed. ([#757](https://github.com/tempus2016/taskmate/pull/757), closes [#754](https://github.com/tempus2016/taskmate/issues/754))
1833+
18221834
### v5.0.3
18231835

18241836
A fix release for the v5.0.2 tap-to-open notification feature.

custom_components/taskmate/__init__.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,13 @@ async def _async_require_admin(hass: HomeAssistant, call: ServiceCall) -> None:
306306
async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
307307
"""Reject user-initiated calls that aren't from an admin or a TaskMate parent.
308308
309-
Day-to-day parent actions (approve/reject, gift/adjust points, confirm
310-
rewards/allowance, award badges, complete-as-parent) accept non-admin HA
311-
users listed in ``parent_user_ids`` (issue #661). Context-less calls
312-
(automations, scripts) pass. Structural config stays on
313-
``_async_require_admin``.
309+
Day-to-day parent actions (approve/reject, gift/adjust points, apply a
310+
bonus or penalty, confirm rewards/allowance, award badges,
311+
complete-as-parent) accept non-admin HA users listed in ``parent_user_ids``
312+
(issue #661). Context-less calls (automations, scripts) pass. Structural
313+
config — including *defining* bonuses and penalties — stays on
314+
``_async_require_admin``; only applying an existing one is a parent action
315+
(issue #749).
314316
"""
315317
if not call.context.user_id:
316318
return
@@ -1324,7 +1326,7 @@ async def handle_rebuild_badges(call: ServiceCall) -> None:
13241326
hass.services.async_register(
13251327
DOMAIN,
13261328
SERVICE_REMOVE_POINTS,
1327-
_admin(handle_remove_points),
1329+
_parent(handle_remove_points),
13281330
schema=vol.Schema(
13291331
{
13301332
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1399,7 +1401,7 @@ async def handle_rebuild_badges(call: ServiceCall) -> None:
13991401
hass.services.async_register(
14001402
DOMAIN,
14011403
SERVICE_APPLY_PENALTY,
1402-
_admin(handle_apply_penalty),
1404+
_parent(handle_apply_penalty),
14031405
schema=vol.Schema({
14041406
vol.Required(ATTR_PENALTY_ID): cv.string,
14051407
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1443,7 +1445,7 @@ async def handle_rebuild_badges(call: ServiceCall) -> None:
14431445
hass.services.async_register(
14441446
DOMAIN,
14451447
SERVICE_APPLY_BONUS,
1446-
_admin(handle_apply_bonus),
1448+
_parent(handle_apply_bonus),
14471449
schema=vol.Schema({
14481450
vol.Required(ATTR_BONUS_ID): cv.string,
14491451
vol.Required(ATTR_CHILD_ID): cv.string,

custom_components/taskmate/coord_chores.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from homeassistant.util import dt as dt_util
1010

11-
from . import photos
11+
from . import images, photos
1212
from .models import Chore, ChoreCompletion, PointsTransaction
1313

1414
if TYPE_CHECKING:
@@ -428,6 +428,7 @@ async def async_update_chore(self, chore: Chore) -> None:
428428
existing = self.storage.get_chore(chore.id)
429429
prev_entities = list(getattr(existing, "publish_calendar_entities", []) or []) if existing else []
430430
prev_name = (existing.name if existing else "") or ""
431+
prev_image = (getattr(existing, "image_url", "") or "") if existing else ""
431432
# Persist the incoming chore so _compute_daily_assignments sees the
432433
# latest pool / mode / etc. when applying group policies.
433434
self.storage.update_chore(chore)
@@ -455,6 +456,9 @@ async def async_update_chore(self, chore: Chore) -> None:
455456
chore, cleanup_entities, today, summary_prefixes=extra_prefixes,
456457
)
457458
self.storage.update_chore(chore)
459+
# Replacing or clearing the picture orphans the old file; delete it.
460+
if prev_image and prev_image != (chore.image_url or ""):
461+
await images.async_delete_image(self.hass, prev_image)
458462
await self._publish_chore_to_calendars(chore, today)
459463
await self.storage.async_save()
460464
await self.async_refresh()
@@ -464,6 +468,9 @@ async def async_remove_chore(self, chore_id: str) -> None:
464468
existing = self.storage.get_chore(chore_id)
465469
if existing is not None and getattr(existing, "publish_calendar_entities", []):
466470
await self._cleanup_chore_from_calendars(existing)
471+
# Nothing sweeps taskmate_images, so the file has to go with the chore.
472+
if existing is not None and getattr(existing, "image_url", ""):
473+
await images.async_delete_image(self.hass, existing.image_url)
467474
self.storage.remove_chore(chore_id)
468475
self.storage.remove_completions_for_chore(chore_id)
469476
self.storage.remove_last_completed_for_chore(chore_id)

custom_components/taskmate/frontend.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ async def async_register_frontend(hass: HomeAssistant) -> None:
118118
from .http_photos import async_register_photo_views
119119
async_register_photo_views(hass)
120120

121+
# Admin-gated upload / authenticated serve for chore pictures (#750).
122+
from .http_images import async_register_image_views
123+
async_register_image_views(hass)
124+
121125
# Token-gated ICS calendar feed (FEAT-10).
122126
from .http_calendar import async_register_calendar_view
123127
async_register_calendar_view(hass)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""aiohttp views for chore images (#750).
2+
3+
Mirrors ``http_photos.py``. The one deliberate difference is that **upload is
4+
admin-only**: chore editing is already ``@_admin_only`` in websocket.py, and
5+
only the admin panel uploads, so allowing any authenticated household member to
6+
write files to disk would weaken the existing posture for no benefit. Serving
7+
stays plain-authenticated, matching photos.
8+
"""
9+
from __future__ import annotations
10+
11+
import logging
12+
import uuid
13+
from http import HTTPStatus
14+
15+
from aiohttp import web
16+
from homeassistant.components.http import HomeAssistantView
17+
from homeassistant.core import HomeAssistant
18+
19+
from . import images
20+
21+
_LOGGER = logging.getLogger(__name__)
22+
23+
IMAGE_HTTP_VIEWS_REGISTERED = "image_http_registered"
24+
25+
26+
class TaskMateImageUploadView(HomeAssistantView):
27+
"""Receive a multipart image upload and store it under the config dir."""
28+
29+
url = images.URL_PREFIX
30+
name = "api:taskmate:image:upload"
31+
32+
def __init__(self, hass: HomeAssistant) -> None:
33+
self.hass = hass
34+
35+
async def post(self, request: web.Request) -> web.Response:
36+
# Admin-only: this writes files and is only ever called by the panel.
37+
user = request.get("hass_user")
38+
if user is None or not user.is_admin:
39+
return self.json_message("Admin required", HTTPStatus.FORBIDDEN)
40+
41+
# Cheap pre-check on the declared length before reading the body.
42+
if request.content_length and request.content_length > images.MAX_UPLOAD_BYTES:
43+
return self.json_message(
44+
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
45+
)
46+
47+
try:
48+
reader = await request.multipart()
49+
except (ValueError, AssertionError):
50+
return self.json_message("Expected multipart form", HTTPStatus.BAD_REQUEST)
51+
52+
field = await reader.next()
53+
while field is not None and field.name != "file":
54+
field = await reader.next()
55+
if field is None:
56+
return self.json_message("No file provided", HTTPStatus.BAD_REQUEST)
57+
58+
# Stream the part, enforcing the size cap as we go.
59+
data = bytearray()
60+
while True:
61+
chunk = await field.read_chunk()
62+
if not chunk:
63+
break
64+
data.extend(chunk)
65+
if len(data) > images.MAX_UPLOAD_BYTES:
66+
return self.json_message(
67+
"File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE
68+
)
69+
70+
ext = images.detect_allowed_ext(bytes(data))
71+
if ext is None:
72+
return self.json_message(
73+
"Not a supported image (JPEG, PNG or WebP)", HTTPStatus.BAD_REQUEST
74+
)
75+
76+
used = await self.hass.async_add_executor_job(
77+
images.total_images_bytes, self.hass
78+
)
79+
if used + len(data) > images.MAX_TOTAL_BYTES:
80+
return self.json_message(
81+
"Image storage full", HTTPStatus.INSUFFICIENT_STORAGE
82+
)
83+
84+
name = f"{uuid.uuid4().hex}.{ext}"
85+
directory = images.images_path(self.hass)
86+
payload = bytes(data)
87+
88+
def _write() -> None:
89+
directory.mkdir(parents=True, exist_ok=True)
90+
(directory / name).write_bytes(payload)
91+
92+
try:
93+
await self.hass.async_add_executor_job(_write)
94+
except OSError as err:
95+
_LOGGER.error("Failed to store chore image: %s", err)
96+
return self.json_message(
97+
"Could not store image", HTTPStatus.INTERNAL_SERVER_ERROR
98+
)
99+
100+
return self.json({"image_url": f"{images.URL_PREFIX}/{name}"})
101+
102+
103+
class TaskMateImageServeView(HomeAssistantView):
104+
"""Serve a stored chore image by its generated filename."""
105+
106+
url = images.URL_PREFIX + "/{filename}"
107+
name = "api:taskmate:image:serve"
108+
109+
def __init__(self, hass: HomeAssistant) -> None:
110+
self.hass = hass
111+
112+
async def get(self, request: web.Request, filename: str) -> web.Response:
113+
if not images.FILENAME_RE.match(filename):
114+
return web.Response(status=HTTPStatus.NOT_FOUND)
115+
116+
path = images.images_path(self.hass) / filename
117+
118+
def _read() -> bytes | None:
119+
try:
120+
return path.read_bytes()
121+
except (FileNotFoundError, OSError):
122+
return None
123+
124+
data = await self.hass.async_add_executor_job(_read)
125+
if data is None:
126+
return web.Response(status=HTTPStatus.NOT_FOUND)
127+
128+
return web.Response(
129+
body=data,
130+
content_type=images.content_type_for(filename),
131+
headers={"Cache-Control": "private, max-age=31536000"},
132+
)
133+
134+
135+
def async_register_image_views(hass: HomeAssistant) -> None:
136+
"""Register the upload + serve views once."""
137+
from .const import DOMAIN
138+
139+
if hass.data.get(DOMAIN, {}).get(IMAGE_HTTP_VIEWS_REGISTERED):
140+
return
141+
hass.http.register_view(TaskMateImageUploadView(hass))
142+
hass.http.register_view(TaskMateImageServeView(hass))
143+
hass.data.setdefault(DOMAIN, {})[IMAGE_HTTP_VIEWS_REGISTERED] = True
144+
_LOGGER.debug("Registered TaskMate image HTTP views")
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Pure storage/validation helpers for chore images (#750).
2+
3+
Deliberately mirrors ``photos.py`` in shape but NOT in lifecycle. Evidence
4+
photos are transient artefacts of a completion and are orphan-swept at midnight
5+
by ``coordinator._async_sweep_orphan_photos``, whose referenced set is built
6+
only from completions. Chore images are configuration referenced by a chore, so
7+
they live in their own directory and are never swept — they are deleted
8+
explicitly when the chore is deleted or its image replaced.
9+
10+
Like ``photos.py`` this module has NO Home Assistant HTTP / aiohttp imports at
11+
module scope, so it can be imported from coordinator modules and unit-tested
12+
without a real HA install. The aiohttp views live in ``http_images.py``.
13+
14+
Images are stored as ``<32 hex>.<ext>`` under ``<config>/taskmate_images`` and
15+
served (auth-gated) at ``/api/taskmate/image/<name>``.
16+
"""
17+
from __future__ import annotations
18+
19+
import logging
20+
import re
21+
from pathlib import Path
22+
23+
from .photos import content_type_for, detect_image_ext
24+
25+
_LOGGER = logging.getLogger(__name__)
26+
27+
# Directory under the HA config dir (survives integration upgrades).
28+
IMAGES_DIR = "taskmate_images"
29+
30+
# Public URL prefix for upload (POST) and serve (GET /<name>).
31+
URL_PREFIX = "/api/taskmate/image"
32+
33+
# The panel downscales to 512px and re-encodes as JPEG before upload, so a real
34+
# chore image is a few tens of KB. This cap only catches abuse/bugs.
35+
MAX_UPLOAD_BYTES = 2 * 1024 * 1024 # 2 MB
36+
37+
# Total disk budget for all stored chore images.
38+
MAX_TOTAL_BYTES = 64 * 1024 * 1024 # 64 MB
39+
40+
# HEIC is deliberately absent: photos.py accepts it because a phone may upload
41+
# one directly, but a chore image is only ever *displayed*, and most browsers
42+
# cannot render HEIC — storing one produces a silently broken image.
43+
ALLOWED_EXTS = ("jpg", "png", "webp")
44+
45+
FILENAME_RE = re.compile(r"^[0-9a-f]{32}\.(jpg|png|webp)$")
46+
47+
# Re-exported so http_images.py has a single import site for its serve view.
48+
__all__ = [
49+
"ALLOWED_EXTS", "FILENAME_RE", "IMAGES_DIR", "MAX_TOTAL_BYTES",
50+
"MAX_UPLOAD_BYTES", "URL_PREFIX", "async_delete_image", "content_type_for",
51+
"detect_allowed_ext", "image_file_for_url", "images_path",
52+
"is_taskmate_image_url", "sign_image_url", "total_images_bytes",
53+
]
54+
55+
56+
def detect_allowed_ext(data: bytes) -> str | None:
57+
"""Sniff image magic bytes, narrowed to the browser-renderable formats."""
58+
ext = detect_image_ext(data)
59+
return ext if ext in ALLOWED_EXTS else None
60+
61+
62+
def images_path(hass) -> Path:
63+
"""Absolute path to the chore-images directory."""
64+
return Path(hass.config.path(IMAGES_DIR))
65+
66+
67+
def is_taskmate_image_url(image_url: str) -> bool:
68+
"""True only for a well-formed ``/api/taskmate/image/<name>`` URL of ours.
69+
70+
Pure (no hass / no filesystem) so it can guard untrusted input at the
71+
websocket boundary. Rejects blanks, the evidence-photo prefix, foreign
72+
URLs, dangerous schemes and anything failing the strict filename pattern.
73+
"""
74+
if not image_url:
75+
return False
76+
prefix = URL_PREFIX + "/"
77+
if not image_url.startswith(prefix):
78+
return False
79+
return bool(FILENAME_RE.match(image_url[len(prefix):]))
80+
81+
82+
def image_file_for_url(hass, image_url: str) -> Path | None:
83+
"""Map a ``/api/taskmate/image/<name>`` URL to its path, or None."""
84+
if not is_taskmate_image_url(image_url):
85+
return None
86+
return images_path(hass) / image_url[len(URL_PREFIX) + 1:]
87+
88+
89+
def sign_image_url(hass, image_url: str, expiration_hours: int = 24) -> str:
90+
"""Return a self-authenticating signed URL for one of our image URLs.
91+
92+
Browsers don't send the HA bearer token on plain ``<img>`` requests, so the
93+
auth-gated serve view 401s on a bare URL. Foreign/blank URLs pass through.
94+
The HA import is local so this module stays importable without a real HA.
95+
"""
96+
if not image_url or not image_url.startswith(URL_PREFIX + "/"):
97+
return image_url
98+
from datetime import timedelta
99+
100+
try:
101+
from homeassistant.components.http.auth import async_sign_path
102+
103+
return async_sign_path(hass, image_url, timedelta(hours=expiration_hours))
104+
except Exception: # noqa: BLE001 - never break state delivery over a signing hiccup
105+
_LOGGER.debug("Could not sign image URL %s", image_url, exc_info=True)
106+
return image_url
107+
108+
109+
async def async_delete_image(hass, image_url: str) -> None:
110+
"""Best-effort delete of the file backing an image URL.
111+
112+
No-op for foreign URLs (including evidence photos) or a missing file.
113+
"""
114+
path = image_file_for_url(hass, image_url)
115+
if path is None:
116+
return
117+
118+
def _unlink() -> None:
119+
try:
120+
path.unlink()
121+
except FileNotFoundError:
122+
pass
123+
except OSError as err: # pragma: no cover - defensive
124+
_LOGGER.debug("Could not delete chore image %s: %s", path, err)
125+
126+
await hass.async_add_executor_job(_unlink)
127+
128+
129+
def total_images_bytes(hass) -> int:
130+
"""Sum of all stored chore-image file sizes (0 if the dir is absent)."""
131+
directory = images_path(hass)
132+
if not directory.is_dir():
133+
return 0
134+
total = 0
135+
for p in directory.iterdir():
136+
if p.is_file() and FILENAME_RE.match(p.name):
137+
try:
138+
total += p.stat().st_size
139+
except OSError: # pragma: no cover - defensive
140+
pass
141+
return total

0 commit comments

Comments
 (0)