Skip to content

Feature: upload a wallpaper, and fit it to the screen - #135

Merged
SandObserver merged 5 commits into
mainfrom
feat/wallpaper-upload
Aug 21, 2026
Merged

SandObserver merged 5 commits into
mainfrom
feat/wallpaper-upload

Conversation

@SandObserver

Copy link
Copy Markdown
Owner

Summary

  • Add POST /api/wallpaper/upload: multipart, 8 MB cap, origin-checked and rate-limited on the existing upload bucket, stored under $ICONS_PATH/wallpaper/ with a generated name.
  • Add POST /api/wallpaper/fetch: downloads a pasted link through fetchChecked, so the SSRF guard and the 4 MB response cap apply, and stores the result identically.
  • Add api/src/image-sniff.js: PNG, JPEG, GIF, WebP (RIFF brand) and AVIF (ISO ftyp brand) by signature; the submitted name decides nothing, and the stored extension follows the bytes.
  • Keep one wallpaper: storeWallpaper removes every other file in the directory after a successful write. The directory sits a level below the one /api/icons/local lists, so a wallpaper never appears in the icon picker.
  • Move GET /api/wallpaper out of routes/icons.js into the new routes/wallpaper.js.
  • Add fit to the background settings: --bg-size becomes cover or contain, and html::before now paints --bg-color with background-repeat:no-repeat, so a fitted image has a defined backdrop.
  • Teach the label sampling about contain: drawPlan returns source and destination rects, and the sampling canvas is filled with the backdrop colour before the image is drawn, so a label on a letterboxed edge is measured against what is really there.
  • Admin: upload button, a link field that saves through the fetch endpoint, and a fill/fit dropdown; the stored wallpaper is shown by file name.
  • Document the storage and the one-time fetch under Third-party requests in docs/security.md.

Testing
All green, including the Docker build. Added 12 backend tests for the sniffer, the storage rules and the two endpoints, and 4 frontend tests for the fit geometry.

Checklist

  • Stays within the project constraints: one container, no runtime dependencies, no build step (see CONTRIBUTING.md).
  • Tests added or updated for new logic, and the full suite, lint, and typechecks pass.
  • Docs updated if behavior or configuration changed.
  • CHANGELOG.md updated under Unreleased if the change is user-visible.

The page's content policy allows images only from this origin, Unsplash and the
icon CDN, so the Image URL background could never load a link to anywhere else:
the browser refused it and the dashboard fell back to the base colour.

Store the image instead of pointing at it. An upload is sniffed by its own
bytes, written under the icons mount in its own directory, and served from this
origin; a pasted link is downloaded once through the guarded fetch and stored
the same way, so the field keeps its meaning without loading anything
cross-origin. One wallpaper is kept at a time.

Add a fill or fit choice for how the image sits on the screen, with the label
contrast measuring the fitted edges as the colour that actually shows there.
@SandObserver SandObserver added the enhancement New feature or request label Aug 21, 2026
The site-wide request body limit is 3 MB, well under a 4K photo, so nginx
refused the upload with an HTML error page before the route's own limit applied.
The admin read every failure as JSON, so what reached the user was the browser
complaining about the response rather than a word about the file.

Give the two wallpaper endpoints their own limits: 16 MB of body on the upload
address, and 60s at the proxy for the link fetch, which now waits 30s for the
download rather than the 8s a widget poll gets. Read a failed response as text
first, so a body that is not JSON still produces a message, and say plainly when
an image is too large.

Error notices now stay until they are dismissed, and hovering or focusing any
notice holds it open, so a message can be read and copied.
An upload deleted every other stored wallpaper at once, so an upload that was
never saved took the wallpaper still on screen with it and left the dashboard on
its base colour.

Store the new file and delete nothing. A config write prunes instead, keeping
the file the saved settings name; a config that names none keeps the newest, so
switching the source and back does not throw the image away. Steady state is one
file either way.

Cover the fit setting end to end: the dropdown writes the field, the save stores
it, and the dashboard's wallpaper layer reports contain rather than cover.
The settings pages draw the same wallpaper as the dashboard, but set only the
image, the colour and the brightness, so a fitted wallpaper was still cropped to
cover there.

Set the size alongside the rest, and cover both pages in the fit spec.
Name a stored file with a random suffix as well as the timestamp, so two
uploads in the same millisecond cannot land on one name. Report an over-size
body as 400 with the limit in it, rather than a 500 whose message the error
layer withholds. Fold the demo, origin and rate-limit checks of both endpoints
into one guard, and read both bodies through one capped reader. Log a wallpaper
that could not be removed instead of discarding the reason.

A link that fails to download now restores the wallpaper already saved, rather
than clearing the field and offering to save nothing.

Cut the comments back to the constraints: the two limits nginx has to agree
with, the reason the upload deletes nothing, and the two-seed colour parse.
@SandObserver
SandObserver merged commit f77afa0 into main Aug 21, 2026
4 checks passed
@SandObserver
SandObserver deleted the feat/wallpaper-upload branch August 21, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant