From 552ee3e0cb291bdaf8b292b43051db9a383a83ee Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:55:14 -0400 Subject: [PATCH 1/5] Feature: upload a wallpaper, and fit it to the screen 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. --- CHANGELOG.md | 5 + api/src/image-sniff.js | 40 +++++++ api/src/routes.js | 1 + api/src/routes/icons.js | 20 +--- api/src/routes/wallpaper.js | 153 +++++++++++++++++++++++++++ api/test/wallpaper-store.test.js | 173 +++++++++++++++++++++++++++++++ docs/security.md | 4 +- ui/admin/index.html | 22 +++- ui/css/tokens.css | 3 +- ui/i18n/de.json | 15 ++- ui/i18n/en.json | 15 ++- ui/i18n/es.json | 15 ++- ui/i18n/fa.json | 15 ++- ui/i18n/fr.json | 15 ++- ui/i18n/zh-Hans.json | 15 ++- ui/index.html | 4 +- ui/js/admin-settings.js | 28 +++++ ui/js/admin.js | 95 ++++++++++++++++- ui/js/dashboard.js | 36 +++++-- ui/js/label-contrast.js | 42 +++++++- ui/test/i18n-coverage.test.mjs | 3 + ui/test/label-contrast.test.mjs | 25 +++++ 22 files changed, 685 insertions(+), 59 deletions(-) create mode 100644 api/src/image-sniff.js create mode 100644 api/src/routes/wallpaper.js create mode 100644 api/test/wallpaper-store.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 986cba32..e084ea4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Wallpapers can be uploaded, and a pasted image link is downloaded to the + server and served from it. + +- Wallpapers can be shown filled or fitted. + - System Summary can read its figures from Glances, Beszel or the Unraid API. - The network row's throughput follows the chosen source, on a selected diff --git a/api/src/image-sniff.js b/api/src/image-sniff.js new file mode 100644 index 00000000..4f55c904 --- /dev/null +++ b/api/src/image-sniff.js @@ -0,0 +1,40 @@ +/* Wallpaper formats, identified by their own bytes. The extension a browser + sends is not evidence of anything. */ + +const SIGNATURES = [ + { type: 'png', ext: '.png', mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, + { type: 'jpeg', ext: '.jpg', mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] }, + { type: 'gif', ext: '.gif', mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] }, +]; + +/** @param {Buffer} buf @param {number[]} sig @param {number} [at] */ +function startsWith(buf, sig, at = 0) { + if (!buf || buf.length < at + sig.length) return false; + return sig.every((b, i) => buf[at + i] === b); +} + +/** @param {Buffer} buf @returns {string|null} */ +function riffBrand(buf) { + if (!startsWith(buf, [0x52, 0x49, 0x46, 0x46]) || !startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) return null; + return 'webp'; +} + +/** ISO base media: the brand follows the 'ftyp' box type. */ +function isoBrand(buf) { + if (!startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) return null; + const brand = buf.slice(8, 12).toString('latin1'); + return brand === 'avif' || brand === 'avis' ? 'avif' : null; +} + +/** The format of an image, or null when it is not one this project accepts. + + @param {Buffer} buf + @returns {{type: string, ext: string, mime: string}|null} */ +function sniffImageType(buf) { + for (const s of SIGNATURES) if (startsWith(buf, s.bytes)) return { type: s.type, ext: s.ext, mime: s.mime }; + if (riffBrand(buf)) return { type: 'webp', ext: '.webp', mime: 'image/webp' }; + if (isoBrand(buf)) return { type: 'avif', ext: '.avif', mime: 'image/avif' }; + return null; +} + +module.exports = { sniffImageType }; diff --git a/api/src/routes.js b/api/src/routes.js index 6a5f0c89..3d2dd97d 100644 --- a/api/src/routes.js +++ b/api/src/routes.js @@ -4,4 +4,5 @@ require('./routes/health'); require('./routes/badges'); require('./routes/system'); require('./routes/icons'); +require('./routes/wallpaper'); require('./routes/version'); diff --git a/api/src/routes/icons.js b/api/src/routes/icons.js index b951387e..1155f1a6 100644 --- a/api/src/routes/icons.js +++ b/api/src/routes/icons.js @@ -2,10 +2,10 @@ const fs = require('fs'); const path = require('path'); const { on, json, checkOrigin, getIp } = require('../router'); const { IS_DEMO, DEMO_READONLY_MSG } = require('../demo'); -const { loadConfig, ICONS_PATH } = require('../config'); +const { ICONS_PATH } = require('../config'); const { fetchUnchecked } = require('../proxy'); const log = require('../log'); -const { fail, KIND, errorBody } = require('../api-error'); +const { fail, KIND } = require('../api-error'); /* A safe, unused filename for an upload. Never reuse the submitted name: it overwrites an icon other apps still reference. Strip backslashes as well as @@ -45,22 +45,6 @@ const { sanitizeSvg } = require('../svg-sanitize'); const { sniffIconType } = require('../icon-sniff'); const { parseMultipartFile } = require('../parse-multipart'); -on('GET', '/api/wallpaper', async (_, res) => { - const cfg = loadConfig(), - bg = cfg.settings?.background || {}; - if (bg.type !== 'unsplash') return json(res, 200, { url: null }); - try { - const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' }); - if (bg.collection) p.set('collections', bg.collection); - const r = await fetchUnchecked(`https://api.unsplash.com/photos/random?${p}`); - const raw = r.data?.urls?.raw; - if (!raw) return json(res, 200, { url: null, error: r.data?.errors?.[0] || 'No image returned' }); - json(res, 200, { url: `${raw}&w=2800&h=1800&q=85&fm=jpg&fit=crop&crop=entropy` }); - } catch (e) { - json(res, 200, Object.assign({ url: null }, errorBody(e))); - } -}); - let _iconCache = null, _iconCacheAt = 0; const ICON_CACHE_TTL = 24 * 60 * 60 * 1000; diff --git a/api/src/routes/wallpaper.js b/api/src/routes/wallpaper.js new file mode 100644 index 00000000..c82bb87a --- /dev/null +++ b/api/src/routes/wallpaper.js @@ -0,0 +1,153 @@ +const fs = require('fs'); +const path = require('path'); +const { on, json, checkOrigin, getIp } = require('../router'); +const { IS_DEMO, DEMO_READONLY_MSG } = require('../demo'); +const { loadConfig, ICONS_PATH } = require('../config'); +const { fetchChecked, fetchUnchecked, SsrfBlockedError } = require('../proxy'); +const { rateLimit } = require('../auth'); +const { sniffImageType } = require('../image-sniff'); +const { parseMultipartFile } = require('../parse-multipart'); +const log = require('../log'); +const { fail, KIND, errorBody } = require('../api-error'); + +/** Wallpapers live under the icons mount, in their own directory: the icon + picker lists the top level, and a wallpaper is not an icon. */ +const WALLPAPER_DIR = () => path.join(ICONS_PATH, 'wallpaper'); +const WALLPAPER_URL_BASE = '/icons/wallpaper/'; + +const UPLOAD_MAX_BYTES = 8 * 1024 * 1024; +const UPLOAD_STREAM_MAX_BYTES = Math.round(UPLOAD_MAX_BYTES * 1.25); + +/** The stored name is generated, never the submitted one: it is served from + this origin, and the extension follows the sniffed bytes. + + @param {string} ext @returns {string} */ +function wallpaperName(ext) { + return `wallpaper-${Date.now().toString(36)}${ext}`; +} + +/** Write the image and drop the one it replaces. One wallpaper is stored at a + time, so the directory cannot grow without bound. + + @param {Buffer} data @param {string} ext @returns {string} the served URL */ +function storeWallpaper(data, ext) { + const dir = WALLPAPER_DIR(); + fs.mkdirSync(dir, { recursive: true }); + const saved = wallpaperName(ext); + fs.writeFileSync(path.join(dir, saved), data); + for (const f of fs.readdirSync(dir)) { + if (f !== saved) { + try { + fs.unlinkSync(path.join(dir, f)); + } catch {} + } + } + return WALLPAPER_URL_BASE + saved; +} + +on('GET', '/api/wallpaper', async (_, res) => { + const cfg = loadConfig(), + bg = cfg.settings?.background || {}; + if (bg.type !== 'unsplash') return json(res, 200, { url: null }); + try { + const p = new URLSearchParams({ orientation: 'landscape', content_filter: 'high', client_id: bg.apiKey || '' }); + if (bg.collection) p.set('collections', bg.collection); + const r = await fetchUnchecked(`https://api.unsplash.com/photos/random?${p}`); + const raw = r.data?.urls?.raw; + if (!raw) return json(res, 200, { url: null, error: r.data?.errors?.[0] || 'No image returned' }); + json(res, 200, { url: `${raw}&w=2800&h=1800&q=85&fm=jpg&fit=crop&crop=entropy` }); + } catch (e) { + json(res, 200, Object.assign({ url: null }, errorBody(e))); + } +}); + +on('POST', '/api/wallpaper/upload', async (req, res) => { + if (IS_DEMO) return json(res, 403, { error: DEMO_READONLY_MSG, kind: KIND.BLOCKED }); + if (!checkOrigin(req, res)) return; + try { + const limited = rateLimit(getIp(req), 'upload', 20, 3_600_000); + if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED }); + const ct = req.headers['content-type'] || ''; + if (!ct.includes('multipart/form-data')) + return json(res, 400, { error: 'multipart/form-data required', kind: KIND.INVALID }); + const bMatch = ct.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i); + if (!bMatch) return json(res, 400, { error: 'missing boundary', kind: KIND.INVALID }); + const buf = await new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on('data', c => { + total += c.length; + if (total > UPLOAD_STREAM_MAX_BYTES) { + req.destroy(); + return reject(new Error('image too large (max 8 MB)')); + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); + const { filename, data, fileParts } = parseMultipartFile(buf, bMatch[1] || bMatch[2]); + if (!filename || !data?.length) return json(res, 400, { error: 'no file found in upload', kind: KIND.INVALID }); + if (fileParts > 1) return json(res, 400, { error: 'only one file per upload', kind: KIND.INVALID }); + if (data.length > UPLOAD_MAX_BYTES) + return json(res, 400, { error: 'image too large (max 8 MB)', kind: KIND.INVALID }); + const kind = sniffImageType(data); + if (!kind) return json(res, 400, { error: 'file is not a JPEG, PNG, WebP, AVIF or GIF image', kind: KIND.INVALID }); + const url = storeWallpaper(data, kind.ext); + log.audit('wallpaper uploaded', { url, type: kind.type, bytes: data.length }); + json(res, 200, { ok: true, url }); + } catch (e) { + fail(res, e, { status: 500 }); + } +}); + +on('POST', '/api/wallpaper/fetch', async (req, res) => { + if (IS_DEMO) return json(res, 403, { error: DEMO_READONLY_MSG, kind: KIND.BLOCKED }); + if (!checkOrigin(req, res)) return; + try { + const limited = rateLimit(getIp(req), 'upload', 20, 3_600_000); + if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED }); + const body = await new Promise((resolve, reject) => { + let s = ''; + req.on('data', c => { + s += c; + if (s.length > 4096) { + req.destroy(); + reject(new Error('request too large')); + } + }); + req.on('end', () => resolve(s)); + req.on('error', reject); + }); + let url = ''; + try { + url = String(JSON.parse(body || '{}').url || '').trim(); + } catch { + return json(res, 400, { error: 'invalid JSON body', kind: KIND.INVALID }); + } + let parsed; + try { + parsed = new URL(url); + } catch { + return json(res, 400, { error: 'that is not a valid URL', kind: KIND.INVALID }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + return json(res, 400, { error: 'only http and https links can be fetched', kind: KIND.INVALID }); + + const r = await fetchChecked(url, { binary: true }); + if (r.status !== 200) + return json(res, 502, { error: `the image could not be fetched (HTTP ${r.status})`, kind: KIND.UPSTREAM }); + const data = Buffer.isBuffer(r.data) ? r.data : Buffer.alloc(0); + const kind = sniffImageType(data); + if (!kind) + return json(res, 400, { error: 'that link is not a JPEG, PNG, WebP, AVIF or GIF image', kind: KIND.INVALID }); + const saved = storeWallpaper(data, kind.ext); + log.audit('wallpaper fetched', { url: parsed.origin + parsed.pathname, type: kind.type, bytes: data.length }); + json(res, 200, { ok: true, url: saved }); + } catch (e) { + if (e instanceof SsrfBlockedError) return json(res, 403, { error: e.message, kind: KIND.BLOCKED }); + fail(res, e, { status: 502 }); + } +}); + +module.exports = { storeWallpaper, WALLPAPER_URL_BASE }; diff --git a/api/test/wallpaper-store.test.js b/api/test/wallpaper-store.test.js new file mode 100644 index 00000000..69c2f9f0 --- /dev/null +++ b/api/test/wallpaper-store.test.js @@ -0,0 +1,173 @@ +const path = require('node:path'); +const fs = require('node:fs'); + +/* Set before anything under src/ is required: ICONS_PATH is read once when + those modules load. */ +const { tmpDir } = require('../test-support/tmp'); +const iconsDir = tmpDir('wallpaper-icons'); +process.env.ICONS_PATH = iconsDir; +process.env.CONFIG_PATH = path.join(tmpDir('wallpaper-cfg'), 'apps.json'); + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +const { sniffImageType } = require('../src/image-sniff'); +const { storeWallpaper, WALLPAPER_URL_BASE } = require('../src/routes/wallpaper'); + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]); +const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPVP8 ')]); +const AVIF = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypavif'), Buffer.alloc(4)]); +const GIF = Buffer.from('GIF89a\0\0\0\0'); + +const dir = () => path.join(iconsDir, 'wallpaper'); + +/* ── the format is read from the bytes ────────────────────────────────────── */ + +test('every accepted format is recognised', () => { + assert.equal(sniffImageType(PNG).type, 'png'); + assert.equal(sniffImageType(JPEG).type, 'jpeg'); + assert.equal(sniffImageType(WEBP).type, 'webp'); + assert.equal(sniffImageType(AVIF).type, 'avif'); + assert.equal(sniffImageType(GIF).type, 'gif'); +}); + +test('anything else is not an image', () => { + assert.equal(sniffImageType(Buffer.from('')), null); + assert.equal(sniffImageType(Buffer.from('')), null); + assert.equal(sniffImageType(Buffer.alloc(0)), null); + /* A cursor and an ICO both start 00 00, and neither is a wallpaper. */ + assert.equal(sniffImageType(Buffer.from([0x00, 0x00, 0x01, 0x00])), null); +}); + +test('an ISO container that is not AVIF is refused', () => { + const mp4 = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypmp42'), Buffer.alloc(4)]); + assert.equal(sniffImageType(mp4), null); +}); + +test('a RIFF container that is not WebP is refused', () => { + const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVEfmt ')]); + assert.equal(sniffImageType(wav), null); +}); + +/* ── storing ──────────────────────────────────────────────────────────────── */ + +test('the stored name is generated, and carries the sniffed extension', () => { + const url = storeWallpaper(JPEG, sniffImageType(JPEG).ext); + assert.ok(url.startsWith(WALLPAPER_URL_BASE), url); + assert.match(url, /\.jpg$/); + assert.ok(fs.existsSync(path.join(iconsDir, url.replace('/icons/', '')))); +}); + +test('storing again replaces the wallpaper rather than piling them up', () => { + storeWallpaper(PNG, '.png'); + storeWallpaper(JPEG, '.jpg'); + storeWallpaper(GIF, '.gif'); + const files = fs.readdirSync(dir()); + assert.equal(files.length, 1); + assert.match(files[0], /\.gif$/); +}); + +test('wallpapers are kept out of the directory the icon picker lists', () => { + storeWallpaper(PNG, '.png'); + const topLevel = fs.readdirSync(iconsDir).filter(f => /\.(svg|png|ico)$/i.test(f)); + assert.deepEqual(topLevel, []); +}); + +/* ── through the routes ───────────────────────────────────────────────────── */ + +let server, base; + +before(async () => { + require('../src/routes'); + const { dispatch } = require('../src/router'); + const { saveConfig } = require('../src/config'); + saveConfig({ items: [], settings: {} }); + server = http.createServer(dispatch); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${server.address().port}`; +}); +after(async () => { + await new Promise(r => { + server.closeAllConnections?.(); + server.close(r); + }); +}); + +function request(pathname, headers, body) { + const u = new URL(base + pathname); + return new Promise((resolve, reject) => { + const r = http.request( + { + hostname: u.hostname, + port: u.port, + path: u.pathname, + method: 'POST', + headers: { ...headers, 'Content-Length': body.length, Origin: base }, + }, + res => { + let b = ''; + res.on('data', c => { + b += c; + }); + res.on('end', () => { + let j = null; + try { + j = JSON.parse(b); + } catch {} + resolve({ status: res.statusCode, body: j }); + }); + }, + ); + r.on('error', reject); + r.end(body); + }); +} + +function upload(filename, contents) { + const boundary = '----sytest'; + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="wallpaper"; filename="${filename}"\r\n` + + 'Content-Type: application/octet-stream\r\n\r\n', + ), + contents, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + return request('/api/wallpaper/upload', { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body); +} + +const fetchLink = url => + request('/api/wallpaper/fetch', { 'Content-Type': 'application/json' }, Buffer.from(JSON.stringify({ url }))); + +test('an uploaded image is stored and answered with its served URL', async () => { + const r = await upload('photo.jpg', JPEG); + assert.equal(r.status, 200); + assert.ok(r.body.url.startsWith(WALLPAPER_URL_BASE), r.body.url); + assert.ok(fs.existsSync(path.join(iconsDir, r.body.url.replace('/icons/', '')))); +}); + +test('a name claiming a format the bytes do not have is refused', async () => { + const r = await upload('photo.png', Buffer.from('')); + assert.equal(r.status, 400); + assert.match(r.body.error, /not a JPEG/); +}); + +test('a name that walks out of the wallpaper directory cannot', async () => { + const r = await upload('../../escape.png', PNG); + assert.equal(r.status, 200); + assert.match(r.body.url, /^\/icons\/wallpaper\/wallpaper-[a-z0-9]+\.png$/); +}); + +test('fetching something that is not a URL is refused before any request', async () => { + const r = await fetchLink('not a url'); + assert.equal(r.status, 400); + assert.match(r.body.error, /valid URL/); +}); + +test('a link on a scheme that is not http is refused', async () => { + const r = await fetchLink('file:///etc/passwd'); + assert.equal(r.status, 400); + assert.match(r.body.error, /http and https/); +}); diff --git a/docs/security.md b/docs/security.md index 7a4dcdff..22c31a90 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,7 +11,9 @@ Three hosts outside the services you configure are contacted: `cdn.jsdelivr.net` Dashboard icons load through `/api/icons/cdn`, which fetches each icon once, sanitizes SVGs, and caches it for 24 hours. The CDN therefore does not learn which services your dashboard shows. The browser contacts the CDN directly in two cases: when the proxy fetch fails, since the direct URL is the last fallback in the chain, and in the admin icon picker, whose previews use the catalogue's own URLs. -Wallpaper works differently. The server calls `api.unsplash.com` with your API key when the wallpaper source is Unsplash, and the browser then loads the chosen image from `images.unsplash.com`. Set the wallpaper to a URL or a solid colour to contact Unsplash never. +Wallpaper works differently. The server calls `api.unsplash.com` with your API key when the wallpaper source is Unsplash, and the browser then loads the chosen image from `images.unsplash.com`. Set the wallpaper to an image or a solid colour to contact Unsplash never. + +An image wallpaper is stored on the server, under `/icons/wallpaper/`, and served from there. A pasted link is downloaded once, when you save it, through the same address guard as widget requests. The browser never loads a wallpaper from another host. The update check runs on the server when the admin About page is opened, at most once an hour. It is a plain read of the public releases endpoint and sends nothing about the install. diff --git a/ui/admin/index.html b/ui/admin/index.html index f2150179..83dae731 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -10,7 +10,7 @@ Stackyard Settings - + @@ -164,7 +164,25 @@
Collection IDCollection ID
+
+ Image + No image + + +
Image URLImage URL
+
+ Fit +
+ + +
+ +
+

Uploaded and linked images are stored on this server and served from it.

Color#rrggbb or any CSS color
@@ -303,6 +321,6 @@

General

- + diff --git a/ui/css/tokens.css b/ui/css/tokens.css index 1bff2831..1e556fa2 100644 --- a/ui/css/tokens.css +++ b/ui/css/tokens.css @@ -259,7 +259,8 @@ html.type-small:not(.is-mobile) { html::before { content:''; position:fixed; inset:0; z-index:0; background-image:var(--bg-image,none); - background-size:cover; background-position:center; + background-size:var(--bg-size,cover); background-position:center; + background-repeat:no-repeat; background-color:var(--bg-color,var(--bg-base)); pointer-events:none; filter:brightness(var(--bg-brightness,1)); transform:translateZ(0); diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 425df273..b7acb630 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -83,9 +83,16 @@ "brightness": "Helligkeit", "collectionTip": "Collection ID leer lassen für ein zufälliges Bild.", "sourceUnsplash": "Unsplash", - "sourceUrl": "Bild-URL", + "sourceUrl": "Bild", "sourceColor": "Volltonfarbe", - "unsplashKeyPh": "Füge deinen Unsplash-API-Schlüssel ein" + "unsplashKeyPh": "Füge deinen Unsplash-API-Schlüssel ein", + "image": "Bild", + "noImage": "Kein Bild", + "fit": "Anpassung", + "fitFill": "Füllen", + "fitContain": "Einpassen", + "uploading": "Wird hochgeladen…", + "imageTip": "Hochgeladene und verlinkte Bilder werden auf diesem Server gespeichert und von ihm ausgeliefert." }, "about": { "documentation": "Dokumentation", @@ -215,7 +222,9 @@ "socketUrlBad": "Socket-Proxy nicht gespeichert: {reason}", "socketUrlUnverified": "Gespeichert, aber der Socket-Proxy hat nicht geantwortet: {reason}", "socketHintSharedNetwork": "Diesen Namen löst Docker nur für Container in einem gemeinsamen Netzwerk auf. Legen Sie beide in dasselbe Netzwerk oder verwenden Sie eine Adresse, die der Proxy veröffentlicht.", - "socketHintPublishPort": "Unter dieser Adresse hat nichts geantwortet. Ein auf dem Loopback des Hosts veröffentlichter Socket-Proxy ist aus einem Container nicht erreichbar; veröffentlichen Sie ihn auf der Docker-Bridge-Adresse oder legen Sie beide Container in ein gemeinsames Netzwerk und verwenden Sie den Namen des Proxys." + "socketHintPublishPort": "Unter dieser Adresse hat nichts geantwortet. Ein auf dem Loopback des Hosts veröffentlichter Socket-Proxy ist aus einem Container nicht erreichbar; veröffentlichen Sie ihn auf der Docker-Bridge-Adresse oder legen Sie beide Container in ein gemeinsames Netzwerk und verwenden Sie den Namen des Proxys.", + "wallpaperStored": "Hintergrundbild auf diesem Server gespeichert.", + "wallpaperFailed": "Hintergrundbild fehlgeschlagen: {err}" }, "confirm": { "deleteFolder": "Ordner „{name}“ löschen? Enthaltene Apps werden nicht gelöscht.", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 07163c91..8e7a16e2 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -83,9 +83,16 @@ "brightness": "Brightness", "collectionTip": "Leave Collection ID blank for random.", "sourceUnsplash": "Unsplash", - "sourceUrl": "Image URL", + "sourceUrl": "Image", "sourceColor": "Solid color", - "unsplashKeyPh": "Paste your Unsplash API key" + "unsplashKeyPh": "Paste your Unsplash API key", + "image": "Image", + "noImage": "No image", + "fit": "Fit", + "fitFill": "Fill", + "fitContain": "Fit", + "uploading": "Uploading…", + "imageTip": "Uploaded and linked images are stored on this server and served from it." }, "about": { "documentation": "Documentation", @@ -215,7 +222,9 @@ "socketUrlBad": "Socket proxy not saved: {reason}", "socketUrlUnverified": "Saved, but the socket proxy did not answer: {reason}", "socketHintSharedNetwork": "That name is resolved by Docker, which answers only for containers on a shared network. Put both on the same network, or use an address the proxy publishes.", - "socketHintPublishPort": "Nothing at that address answered. A socket proxy published on the host’s loopback cannot be reached from inside a container; publish it on the Docker bridge address, or put both containers on a shared network and use the proxy’s name." + "socketHintPublishPort": "Nothing at that address answered. A socket proxy published on the host’s loopback cannot be reached from inside a container; publish it on the Docker bridge address, or put both containers on a shared network and use the proxy’s name.", + "wallpaperStored": "Wallpaper stored on this server.", + "wallpaperFailed": "Wallpaper failed: {err}" }, "confirm": { "deleteFolder": "Delete folder \"{name}\"? Apps inside will not be deleted.", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 59cda46b..3c1d39df 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -83,9 +83,16 @@ "brightness": "Brillo", "collectionTip": "Deja el Collection ID en blanco para elegir una imagen aleatoria.", "sourceUnsplash": "Unsplash", - "sourceUrl": "URL de imagen", + "sourceUrl": "Imagen", "sourceColor": "Color sólido", - "unsplashKeyPh": "Pega tu clave de API de Unsplash" + "unsplashKeyPh": "Pega tu clave de API de Unsplash", + "image": "Imagen", + "noImage": "Sin imagen", + "fit": "Ajuste", + "fitFill": "Rellenar", + "fitContain": "Ajustar", + "uploading": "Subiendo…", + "imageTip": "Las imágenes subidas y enlazadas se guardan en este servidor y se sirven desde él." }, "about": { "documentation": "Documentación", @@ -215,7 +222,9 @@ "socketUrlBad": "Proxy de socket no guardado: {reason}", "socketUrlUnverified": "Guardado, pero el proxy de socket no respondió: {reason}", "socketHintSharedNetwork": "Ese nombre lo resuelve Docker, que solo responde para contenedores en una red compartida. Ponlos en la misma red o usa una dirección que el proxy publique.", - "socketHintPublishPort": "Nada respondió en esa dirección. Un socket proxy publicado en el bucle local del host no es accesible desde un contenedor; publícalo en la dirección del puente de Docker, o pon ambos contenedores en una red compartida y usa el nombre del proxy." + "socketHintPublishPort": "Nada respondió en esa dirección. Un socket proxy publicado en el bucle local del host no es accesible desde un contenedor; publícalo en la dirección del puente de Docker, o pon ambos contenedores en una red compartida y usa el nombre del proxy.", + "wallpaperStored": "Fondo de pantalla guardado en este servidor.", + "wallpaperFailed": "Error con el fondo de pantalla: {err}" }, "confirm": { "deleteFolder": "¿Eliminar la carpeta \"{name}\"? Las aplicaciones que contiene no se eliminarán.", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 031ddb92..987f555a 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -83,9 +83,16 @@ "brightness": "روشنایی", "collectionTip": "برای تصاویر تصادفی، Collection ID را خالی بگذارید.", "sourceUnsplash": "Unsplash", - "sourceUrl": "نشانی تصویر", + "sourceUrl": "تصویر", "sourceColor": "رنگ یکدست", - "unsplashKeyPh": "کلید API آنسپلش را بچسبانید" + "unsplashKeyPh": "کلید API آنسپلش را بچسبانید", + "image": "تصویر", + "noImage": "بدون تصویر", + "fit": "اندازه", + "fitFill": "پرکردن", + "fitContain": "جا دادن", + "uploading": "در حال بارگذاری…", + "imageTip": "تصویرهای بارگذاری‌شده و پیوندشده روی همین سرور ذخیره و از همان‌جا ارائه می‌شوند." }, "about": { "documentation": "مستندات", @@ -215,7 +222,9 @@ "socketUrlBad": "پراکسی سوکت ذخیره نشد: {reason}", "socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}", "socketHintSharedNetwork": "این نام را داکر تنها برای کانتینرهایی که در یک شبکه مشترک هستند پاسخ می‌دهد. هر دو را در یک شبکه بگذارید یا از نشانی‌ای استفاده کنید که پراکسی منتشر می‌کند.", - "socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید." + "socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید.", + "wallpaperStored": "تصویر زمینه روی این سرور ذخیره شد.", + "wallpaperFailed": "تصویر زمینه ناموفق بود: {err}" }, "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index e5ac48f7..c4f87c62 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -83,9 +83,16 @@ "brightness": "Luminosité", "collectionTip": "Laissez le Collection ID vide pour un résultat aléatoire.", "sourceUnsplash": "Unsplash", - "sourceUrl": "URL de l'image", + "sourceUrl": "Image", "sourceColor": "Couleur unie", - "unsplashKeyPh": "Collez votre clé API Unsplash" + "unsplashKeyPh": "Collez votre clé API Unsplash", + "image": "Image", + "noImage": "Aucune image", + "fit": "Cadrage", + "fitFill": "Remplir", + "fitContain": "Ajuster", + "uploading": "Téléversement…", + "imageTip": "Les images téléversées et liées sont stockées sur ce serveur et servies depuis celui-ci." }, "about": { "documentation": "Documentation", @@ -215,7 +222,9 @@ "socketUrlBad": "Proxy de socket non enregistré : {reason}", "socketUrlUnverified": "Enregistré, mais le proxy de socket n’a pas répondu : {reason}", "socketHintSharedNetwork": "Ce nom est résolu par Docker, qui ne répond que pour des conteneurs partageant un réseau. Placez les deux sur le même réseau, ou utilisez une adresse publiée par le proxy.", - "socketHintPublishPort": "Rien n’a répondu à cette adresse. Un socket proxy publié sur la boucle locale de l’hôte est inaccessible depuis un conteneur ; publiez-le sur l’adresse du pont Docker, ou placez les deux conteneurs sur un réseau partagé et utilisez le nom du proxy." + "socketHintPublishPort": "Rien n’a répondu à cette adresse. Un socket proxy publié sur la boucle locale de l’hôte est inaccessible depuis un conteneur ; publiez-le sur l’adresse du pont Docker, ou placez les deux conteneurs sur un réseau partagé et utilisez le nom du proxy.", + "wallpaperStored": "Fond d'écran enregistré sur ce serveur.", + "wallpaperFailed": "Échec du fond d'écran : {err}" }, "confirm": { "deleteFolder": "Supprimer le dossier « {name} » ? Les applications qu'il contient ne seront pas supprimées.", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 88ccda30..21287d13 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -83,9 +83,16 @@ "brightness": "亮度", "collectionTip": "将 Collection ID 留空以随机显示。", "sourceUnsplash": "Unsplash", - "sourceUrl": "图片网址", + "sourceUrl": "图片", "sourceColor": "纯色", - "unsplashKeyPh": "粘贴你的 Unsplash API 密钥" + "unsplashKeyPh": "粘贴你的 Unsplash API 密钥", + "image": "图片", + "noImage": "未设置图片", + "fit": "适应方式", + "fitFill": "填充", + "fitContain": "适应", + "uploading": "上传中…", + "imageTip": "上传和链接的图片都保存在本服务器,并由本服务器提供。" }, "about": { "documentation": "文档", @@ -215,7 +222,9 @@ "socketUrlBad": "未保存套接字代理:{reason}", "socketUrlUnverified": "已保存,但套接字代理没有响应:{reason}", "socketHintSharedNetwork": "该名称由 Docker 解析,且只对处于同一网络的容器生效。请将两者放入同一网络,或改用代理已发布的地址。", - "socketHintPublishPort": "该地址没有任何响应。发布在主机回环上的套接字代理无法从容器内访问;请将其发布到 Docker 网桥地址,或把两个容器放入同一网络并使用代理名称。" + "socketHintPublishPort": "该地址没有任何响应。发布在主机回环上的套接字代理无法从容器内访问;请将其发布到 Docker 网桥地址,或把两个容器放入同一网络并使用代理名称。", + "wallpaperStored": "壁纸已保存到本服务器。", + "wallpaperFailed": "壁纸操作失败:{err}" }, "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", diff --git a/ui/index.html b/ui/index.html index 412fc00d..9dbf8024 100644 --- a/ui/index.html +++ b/ui/index.html @@ -23,7 +23,7 @@ Stackyard - + @@ -53,5 +53,5 @@

Stackyard

- + diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 0546d40d..4130732a 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -162,6 +162,9 @@ export function loadSettings(c) { _sv('ie-bgcol-v', s.background?.collection, 'Collection ID'); _si('bg-col-inp', s.background?.collection || ''); _si('bg-url-inp', s.background?.url || ''); + _si('bg-fit', s.background?.fit === 'fit' ? 'fit' : 'fill'); + showBgFit(s.background?.fit === 'fit' ? 'fit' : 'fill'); + showWallpaperFile(s.background?.url || ''); _si('bg-color-inp', s.background?.color || ''); _sv('ie-bgurl-v', s.background?.url, 'Image URL'); _sv('ie-bgcolor-v', s.background?.color, '#rrggbb or any CSS color'); @@ -236,6 +239,30 @@ async function syncAuthFromServer() { if (pwValEl) pwValEl.textContent = d.passwordSet ? t('common.configured') : t('common.notSet'); syncSessionRows(); } +/** The stored wallpaper, named by its file rather than its full path. + + @param {string} url @returns {void} */ +export function showWallpaperFile(url) { + const node = el('bg-file-v'); + if (!node) return; + const name = url ? decodeURIComponent(String(url).split('/').pop() || '') : ''; + if (name) { + setUserText(node, name); + node.classList.remove('is-ph'); + } else { + node.textContent = t('appearance.noImage'); + node.classList.add('is-ph'); + } +} + +/** @param {string} fit @returns {void} */ +export function showBgFit(fit) { + const btn = el('bg-fit-btn'); + const tn = btn?.childNodes[0]; + if (tn && tn.nodeType === 3) tn.textContent = fit === 'fit' ? t('appearance.fitContain') : t('appearance.fitFill'); + qa('#bg-fit-list li', document).forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === fit))); +} + export function showBgFields(type) { ['unsplash', 'url', 'color'].forEach(t => { const node = el(`bg-${t}-fields`); @@ -286,6 +313,7 @@ async function saveWallpaper() { bg.collection = (inp('bg-col-inp') || inp('bg-col'))?.value?.trim() || ''; } else if (type === 'url') { bg.url = (inp('bg-url-inp') || inp('bg-url'))?.value?.trim() || ''; + bg.fit = inp('bg-fit')?.value === 'fit' ? 'fit' : 'fill'; } else if (type === 'color') { bg.color = (inp('bg-color-inp') || inp('bg-color'))?.value?.trim() || ''; } diff --git a/ui/js/admin.js b/ui/js/admin.js index bb874faa..cf8f0146 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -10,7 +10,7 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=52a970d3'; -import { loadSettings, showBgFields } from '/js/admin-settings.js?v=e9bec962'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=4a5974c9'; import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=3d2627a9'; import { state } from '/js/admin-state.js?v=b7731aa4'; import { buildWidgetForm } from '/js/admin-widget-form.js?v=bb1a94d7'; @@ -1081,7 +1081,12 @@ function initAllInlineEdits() { urlInp.id = 'bg-url-inp'; urlInp.type = 'url'; document.body.appendChild(urlInp); - initInlineEdit('ie-bgurl', 'bg-url-inp', { placeholder: 'https://example.com/photo.jpg' }); + initInlineEdit('ie-bgurl', 'bg-url-inp', { + placeholder: 'https://example.com/photo.jpg', + onCommit(v) { + fetchWallpaperLink(v.trim()); + }, + }); const colorInp = document.createElement('input'); colorInp.id = 'bg-color-inp'; @@ -1164,7 +1169,7 @@ function initBgType() { function setVal(val) { hidden.value = val; - const labels = { unsplash: 'Unsplash', url: 'Image URL', color: 'Solid color' }; + const labels = { unsplash: 'Unsplash', url: 'Image', color: 'Solid color' }; /* Update only the text node. The SVG chevron must survive. */ const textNode = btn.childNodes[0]; if (textNode && textNode.nodeType === 3) textNode.textContent = labels[val] || val; @@ -1189,6 +1194,88 @@ function initBgType() { setVal(hidden.value || 'unsplash'); } +function initBgFit() { + const btn = el('bg-fit-btn'); + const list = el('bg-fit-list'); + const hidden = inp('bg-fit'); + if (!btn || !list || !hidden) return; + function setVal(val) { + hidden.value = val; + showBgFit(val); + list.hidden = true; + } + btn.addEventListener('click', e => { + e.stopPropagation(); + list.hidden = !list.hidden; + }); + list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val || 'fill'))); + document.addEventListener('click', () => { + list.hidden = true; + }); + setVal(hidden.value || 'fill'); +} + +/** Point the wallpaper fields at an image this server now holds. */ +function setWallpaperUrl(url) { + const urlInp = inp('bg-url-inp'); + if (urlInp) urlInp.value = url; + const rv = el('ie-bgurl-v'); + if (rv) { + setUserText(rv, url); + rv.classList.remove('is-ph'); + } + showWallpaperFile(url); +} + +function initWallpaperUpload() { + const input = inp('bg-upload'); + const btn = el('bg-upload-lbl'); + if (!input || !btn) return; + input.onchange = async () => { + const file = /** @type {HTMLInputElement} */ (input).files?.[0]; + if (!file) return; + const orig = btn.textContent; + btn.textContent = t('appearance.uploading'); + try { + const form = new FormData(); + form.append('wallpaper', file, file.name); + const r = await fetch('/api/wallpaper/upload', { method: 'POST', body: form }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); + setWallpaperUrl(d.url); + toast(t('toast.wallpaperStored')); + } catch (e) { + toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); + } finally { + btn.textContent = orig; + /** @type {HTMLInputElement} */ (input).value = ''; + } + }; + btn.onclick = () => /** @type {HTMLInputElement} */ (input).click(); +} + +/** A pasted link is downloaded once and served from this origin: the page's + content policy refuses images from anywhere else. + + @param {string} url @returns {Promise} */ +async function fetchWallpaperLink(url) { + if (!url || url.startsWith('/icons/')) return; + try { + const r = await fetch('/api/wallpaper/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); + setWallpaperUrl(d.url); + toast(t('toast.wallpaperStored')); + } catch (e) { + setWallpaperUrl(''); + toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); + } +} + function initLogLevel() { const btn = el('log-level-btn'); const list = el('log-level-list'); @@ -1533,6 +1620,8 @@ initAllInlineEdits(); initSecToggle(); initDockerToggle(); initBgType(); +initBgFit(); +initWallpaperUpload(); initLogLevel(); initLanguage(); initTheme(); diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index a764a070..bbefc0e2 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -45,7 +45,7 @@ import { relativeLuminance, sampleImage, toneForLuminances, -} from '/js/label-contrast.js?v=322fb66e'; +} from '/js/label-contrast.js?v=2332228f'; /* Recomputed, never stored: the window can cross the breakpoint after load. */ let MOB = isMobileLayout(); @@ -445,6 +445,10 @@ function syncMobPages() { } } +/** What shows behind a wallpaper that does not reach every edge. Matches + --bg-base in tokens.css. */ +const WALLPAPER_BACKDROP = '#0d1117'; + /** The background as the label tones see it: a sampled wallpaper grid, or one tone for a solid colour. Null on both means the labels keep their default. */ let bgTone = { grid: null, tone: null }; @@ -464,19 +468,25 @@ function toneForColor(color) { without fetching again. */ let _bgSample = null; -async function sampleWallpaper(url, brightness) { +async function sampleWallpaper(url, brightness, fit) { const img = await loadSamplingImage(url); - _bgSample = img ? { img, brightness } : null; - bgTone = { grid: img ? sampleImage(img, window.innerWidth, window.innerHeight, brightness) : null, tone: null }; - retone(); + _bgSample = img ? { img, brightness, fit } : null; + resampleBg(); } -/* The wallpaper is sized to cover the viewport, so a resize moves which part of +/* The wallpaper is sized against the viewport, so a resize moves which part of it every label sits on. */ function resampleBg() { if (!_bgSample) return retone(); bgTone = { - grid: sampleImage(_bgSample.img, window.innerWidth, window.innerHeight, _bgSample.brightness), + grid: sampleImage( + _bgSample.img, + window.innerWidth, + window.innerHeight, + _bgSample.brightness, + _bgSample.fit, + WALLPAPER_BACKDROP, + ), tone: null, }; retone(); @@ -491,15 +501,18 @@ async function applyBg() { root.style.setProperty('--bg-image', 'none'); root.style.setProperty('--bg-color', safeColor); root.style.setProperty('--bg-brightness', '1'); + root.style.setProperty('--bg-size', 'cover'); bgTone = { grid: null, tone: toneForColor(safeColor) }; retone(); } else if (bg.type === 'url' && bg.url) { const url = sanitizeCssUrl(bg.url); const brightness = Number(bg.brightness ?? 0.62); + const fit = bg.fit === 'fit' ? 'fit' : 'fill'; root.style.setProperty('--bg-image', `url('${url}')`); - root.style.setProperty('--bg-color', '#0d1117'); + root.style.setProperty('--bg-color', WALLPAPER_BACKDROP); root.style.setProperty('--bg-brightness', String(brightness)); - sampleWallpaper(url, brightness); + root.style.setProperty('--bg-size', fit === 'fit' ? 'contain' : 'cover'); + sampleWallpaper(url, brightness, fit); } else if (bg.type === 'unsplash') { let url = readWallpaperCache(storeGet(WALLPAPER_STORE), bg, Date.now()); if (!url) { @@ -514,9 +527,10 @@ async function applyBg() { const img = new Image(); img.onload = () => { root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`); - root.style.setProperty('--bg-color', '#0d1117'); + root.style.setProperty('--bg-color', WALLPAPER_BACKDROP); root.style.setProperty('--bg-brightness', String(brightness)); - sampleWallpaper(sanitizeCssUrl(shown), brightness); + root.style.setProperty('--bg-size', 'cover'); + sampleWallpaper(sanitizeCssUrl(shown), brightness, 'fill'); }; img.src = shown; } diff --git a/ui/js/label-contrast.js b/ui/js/label-contrast.js index 56a0197d..a2ac717d 100644 --- a/ui/js/label-contrast.js +++ b/ui/js/label-contrast.js @@ -58,6 +58,34 @@ export function coverSourceRect(iw, ih, vw, vh) { return { sx: (iw - sw) / 2, sy: (ih - sh) / 2, sw, sh }; } +/** Where `background-size:contain` puts the whole image, centred, with the + background colour showing on the two edges it does not reach. + + @param {number} iw @param {number} ih @param {number} vw @param {number} vh + @returns {{dx:number, dy:number, dw:number, dh:number}} */ +export function containDestRect(iw, ih, vw, vh) { + if (!(iw > 0 && ih > 0 && vw > 0 && vh > 0)) return { dx: 0, dy: 0, dw: vw, dh: vh }; + const scale = Math.min(vw / iw, vh / ih); + const dw = iw * scale, + dh = ih * scale; + return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh }; +} + +/** How the wallpaper is laid over the viewport, in the units the sampling + canvas uses. + + @param {number} iw @param {number} ih @param {number} vw @param {number} vh + @param {string} fit `'fit'` for contain, anything else for cover + @returns {{sx:number, sy:number, sw:number, sh:number, dx:number, dy:number, dw:number, dh:number}} */ +export function drawPlan(iw, ih, vw, vh, fit) { + if (fit === 'fit') { + const { dx, dy, dw, dh } = containDestRect(iw, ih, vw, vh); + return { sx: 0, sy: 0, sw: iw || 1, sh: ih || 1, dx, dy, dw, dh }; + } + const { sx, sy, sw, sh } = coverSourceRect(iw, ih, vw, vh); + return { sx, sy, sw, sh, dx: 0, dy: 0, dw: vw, dh: vh }; +} + /** CSS `filter:brightness()` multiplies each sRGB channel. @param {number} c 0-255 @param {number} amount @returns {number} */ @@ -151,16 +179,24 @@ export function parseCssColor(value) { @param {HTMLImageElement} img a loaded image @param {number} vw @param {number} vh the viewport it covers @param {number} brightness + @param {string} [fit] `'fit'` for contain, anything else for cover + @param {string} [baseColor] what shows where a fitted image does not reach @returns {number[]|null} null when the image cannot be read */ -export function sampleImage(img, vw, vh, brightness) { +export function sampleImage(img, vw, vh, brightness, fit = 'fill', baseColor = '#0d1117') { try { - const { sx, sy, sw, sh } = coverSourceRect(img.naturalWidth, img.naturalHeight, vw, vh); + const p = drawPlan(img.naturalWidth, img.naturalHeight, vw, vh, fit); const cv = document.createElement('canvas'); cv.width = GRID_COLS; cv.height = GRID_ROWS; const ctx = cv.getContext('2d', { willReadFrequently: true }); if (!ctx) return null; - ctx.drawImage(img, sx, sy, sw, sh, 0, 0, GRID_COLS, GRID_ROWS); + /* Fit leaves two edges showing the colour behind the image, and a label can + sit on them. */ + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, GRID_COLS, GRID_ROWS); + const kx = GRID_COLS / vw, + ky = GRID_ROWS / vh; + ctx.drawImage(img, p.sx, p.sy, p.sw, p.sh, p.dx * kx, p.dy * ky, p.dw * kx, p.dh * ky); return gridFromPixels(ctx.getImageData(0, 0, GRID_COLS, GRID_ROWS).data, GRID_COLS, GRID_ROWS, brightness); } catch { return null; diff --git a/ui/test/i18n-coverage.test.mjs b/ui/test/i18n-coverage.test.mjs index 580cb8e7..d19ccbaa 100644 --- a/ui/test/i18n-coverage.test.mjs +++ b/ui/test/i18n-coverage.test.mjs @@ -62,6 +62,9 @@ const SAME_ON_PURPOSE = { 'fr.json': new Set([ /* Info and the version line are written the same way in French. */ 'general.logInfo', + /* "Image" is the French word too. */ + 'appearance.image', + 'appearance.sourceUrl', 'about.version', 'appearance.sourceUnsplash', 'general.description', diff --git a/ui/test/label-contrast.test.mjs b/ui/test/label-contrast.test.mjs index 1df63330..2c97876c 100644 --- a/ui/test/label-contrast.test.mjs +++ b/ui/test/label-contrast.test.mjs @@ -4,8 +4,10 @@ import { test } from 'node:test'; import { applyBrightness, cellsForRect, + containDestRect, contrastRatio, coverSourceRect, + drawPlan, gridFromPixels, relativeLuminance, toneForLuminances, @@ -82,3 +84,26 @@ test('two labels on one photo can take different tones', () => { assert.equal(toneForRect(grid, 2, 1, 100, 100, left), 'dark'); assert.equal(toneForRect(grid, 2, 1, 100, 100, right), 'light'); }); + +test('fit shows the whole image, centred, with a band on two edges', () => { + const wide = containDestRect(2000, 1000, 1000, 1000); + assert.deepEqual(wide, { dx: 0, dy: 250, dw: 1000, dh: 500 }); + const tall = containDestRect(1000, 2000, 1000, 1000); + assert.deepEqual(tall, { dx: 250, dy: 0, dw: 500, dh: 1000 }); +}); + +test('an image the shape of the viewport has no band in either mode', () => { + assert.deepEqual(containDestRect(1600, 900, 800, 450), { dx: 0, dy: 0, dw: 800, dh: 450 }); + assert.deepEqual(coverSourceRect(1600, 900, 800, 450), { sx: 0, sy: 0, sw: 1600, sh: 900 }); +}); + +test('fill crops the source and fills the viewport; fit keeps the source whole', () => { + const fill = drawPlan(2000, 1000, 1000, 1000, 'fill'); + assert.deepEqual(fill, { sx: 500, sy: 0, sw: 1000, sh: 1000, dx: 0, dy: 0, dw: 1000, dh: 1000 }); + const fit = drawPlan(2000, 1000, 1000, 1000, 'fit'); + assert.deepEqual(fit, { sx: 0, sy: 0, sw: 2000, sh: 1000, dx: 0, dy: 250, dw: 1000, dh: 500 }); +}); + +test('an unknown fit is treated as fill', () => { + assert.deepEqual(drawPlan(2000, 1000, 1000, 1000, 'stretch'), drawPlan(2000, 1000, 1000, 1000, 'fill')); +}); From d323604697692d6fa29d38343047343e775dddd9 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:14:16 -0400 Subject: [PATCH 2/5] Fix: let a wallpaper upload through, and hold error notices open 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. --- CHANGELOG.md | 3 +++ api/src/proxy.js | 5 ++++- api/src/routes/wallpaper.js | 14 ++++++++---- api/test/nginx-config.test.js | 38 ++++++++++++++++++++++++++++++++ api/test/wallpaper-store.test.js | 7 ++++++ nginx/dashboard.conf | 37 +++++++++++++++++++++++++++++++ ui/admin/index.html | 6 ++--- ui/css/admin.css | 4 +++- ui/i18n/de.json | 3 ++- ui/i18n/en.json | 3 ++- ui/i18n/es.json | 3 ++- ui/i18n/fa.json | 3 ++- ui/i18n/fr.json | 3 ++- ui/i18n/zh-Hans.json | 3 ++- ui/js/admin-app-form.js | 6 ++--- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 2 +- ui/js/admin-logic.js | 17 ++++++++++++++ ui/js/admin-settings.js | 4 ++-- ui/js/admin-shared.js | 30 +++++++++++++++++++++++-- ui/js/admin-widget-form.js | 6 ++--- ui/js/admin.js | 34 ++++++++++++++++++++-------- ui/js/widget-config-form.js | 6 ++--- ui/test/admin-logic.test.mjs | 12 ++++++++++ 24 files changed, 212 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e084ea4a..8fc88711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - App titles now take their color from the background behind them, per icon over a wallpaper. +- Error notices in Settings now stay until dismissed, and hovering holds any + notice open. + - An app can now be dropped between two folders, on the top or bottom edge of a folder row. diff --git a/api/src/proxy.js b/api/src/proxy.js index 14f89abb..5f2a4d4d 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -290,9 +290,12 @@ function fetchJSON(raw, opts = {}) { } const bufs = []; let total = 0; + /* A caller that stores what it receives, rather than parsing it, sets + its own ceiling. */ + const sizeLimit = Number(opts.maxBytes) > 0 ? Number(opts.maxBytes) : FETCH_SIZE_LIMIT; res.on('data', c => { total += c.length; - if (total > FETCH_SIZE_LIMIT) { + if (total > sizeLimit) { req.destroy(); return done(reject, new Error('Response too large')); } diff --git a/api/src/routes/wallpaper.js b/api/src/routes/wallpaper.js index c82bb87a..a052d4f5 100644 --- a/api/src/routes/wallpaper.js +++ b/api/src/routes/wallpaper.js @@ -15,9 +15,15 @@ const { fail, KIND, errorBody } = require('../api-error'); const WALLPAPER_DIR = () => path.join(ICONS_PATH, 'wallpaper'); const WALLPAPER_URL_BASE = '/icons/wallpaper/'; -const UPLOAD_MAX_BYTES = 8 * 1024 * 1024; +/* A 4K wallpaper is the size to carry. Keep client_max_body_size on + /api/wallpaper/upload in nginx/dashboard.conf at or above this. */ +const UPLOAD_MAX_BYTES = 16 * 1024 * 1024; const UPLOAD_STREAM_MAX_BYTES = Math.round(UPLOAD_MAX_BYTES * 1.25); +/* A photo over a slow link takes longer than a widget's JSON poll. Keep + proxy_read_timeout on /api/wallpaper/fetch in nginx above this. */ +const FETCH_TIMEOUT_MS = 30_000; + /** The stored name is generated, never the submitted one: it is served from this origin, and the extension follows the sniffed bytes. @@ -79,7 +85,7 @@ on('POST', '/api/wallpaper/upload', async (req, res) => { total += c.length; if (total > UPLOAD_STREAM_MAX_BYTES) { req.destroy(); - return reject(new Error('image too large (max 8 MB)')); + return reject(new Error('image too large (max 16 MB)')); } chunks.push(c); }); @@ -90,7 +96,7 @@ on('POST', '/api/wallpaper/upload', async (req, res) => { if (!filename || !data?.length) return json(res, 400, { error: 'no file found in upload', kind: KIND.INVALID }); if (fileParts > 1) return json(res, 400, { error: 'only one file per upload', kind: KIND.INVALID }); if (data.length > UPLOAD_MAX_BYTES) - return json(res, 400, { error: 'image too large (max 8 MB)', kind: KIND.INVALID }); + return json(res, 400, { error: 'image too large (max 16 MB)', kind: KIND.INVALID }); const kind = sniffImageType(data); if (!kind) return json(res, 400, { error: 'file is not a JPEG, PNG, WebP, AVIF or GIF image', kind: KIND.INVALID }); const url = storeWallpaper(data, kind.ext); @@ -134,7 +140,7 @@ on('POST', '/api/wallpaper/fetch', async (req, res) => { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return json(res, 400, { error: 'only http and https links can be fetched', kind: KIND.INVALID }); - const r = await fetchChecked(url, { binary: true }); + const r = await fetchChecked(url, { binary: true, maxBytes: UPLOAD_MAX_BYTES, timeout: FETCH_TIMEOUT_MS }); if (r.status !== 200) return json(res, 502, { error: `the image could not be fetched (HTTP ${r.status})`, kind: KIND.UPSTREAM }); const data = Buffer.isBuffer(r.data) ? r.data : Buffer.alloc(0); diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index 7843cef3..88938166 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -257,6 +257,44 @@ test('nginx allows at least as much as the API does', () => { assert.ok(nginxLimit >= iconLimit, `nginx allows ${nginxLimit} but icon uploads may be ${iconLimit}`); }); +/* The same trap, one address along: a wallpaper is a photo, so the upload route + allows far more than the site-wide limit. nginx has to allow at least as much + on that address, or the request never reaches the code that knows the rule + and the browser is handed an HTML error page it cannot read. */ + +test('nginx allows a wallpaper upload as large as the API accepts', () => { + const block = /location\s*=\s*\/api\/wallpaper\/upload\s*\{([\s\S]*?)\n \}/.exec(dashboard); + assert.ok(block, 'the wallpaper upload has no location of its own'); + const routeLimit = nginxSize(block[1], 'client_max_body_size'); + const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'wallpaper.js'), 'utf8'); + const m = /UPLOAD_MAX_BYTES = (\d+) \* 1024 \* 1024/.exec(src); + assert.ok(m, 'UPLOAD_MAX_BYTES not found in routes/wallpaper.js'); + const apiLimit = Number(m[1]) * 1024 * 1024; + assert.ok(routeLimit >= apiLimit, `nginx allows ${routeLimit} but the API accepts ${apiLimit}`); +}); + +test('nginx waits for a wallpaper download at least as long as the API does', () => { + const block = /location\s*=\s*\/api\/wallpaper\/fetch\s*\{([\s\S]*?)\n \}/.exec(dashboard); + assert.ok(block, 'the wallpaper link fetch has no location of its own'); + const readTimeout = /proxy_read_timeout\s+(\d+)s;/.exec(block[1]); + assert.ok(readTimeout, 'no proxy_read_timeout on the fetch location'); + const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'wallpaper.js'), 'utf8'); + const m = /FETCH_TIMEOUT_MS = ([\d_]+)/.exec(src); + assert.ok(m, 'FETCH_TIMEOUT_MS not found in routes/wallpaper.js'); + const apiMs = Number(m[1].replace(/_/g, '')); + assert.ok( + Number(readTimeout[1]) * 1000 >= apiMs, + `nginx gives up after ${readTimeout[1]}s while the API waits ${apiMs}ms`, + ); +}); + +test('the wallpaper upload is proxied like the rest of the API', () => { + const block = /location\s*=\s*\/api\/wallpaper\/upload\s*\{([\s\S]*?)\n \}/.exec(dashboard); + assert.match(block[1], /proxy_pass\s+http:\/\/127\.0\.0\.1:3000;/); + assert.match(block[1], /proxy_set_header\s+X-Real-IP\s+\$remote_addr;/); + assert.match(block[1], /proxy_set_header\s+X-Forwarded-For\s+\$proxy_add_x_forwarded_for;/); +}); + /* Headroom, not a blank cheque: a body is buffered before it is forwarded, and this runs on hardware with 512 MB or less. */ test('the limits stay within reach of each other', () => { diff --git a/api/test/wallpaper-store.test.js b/api/test/wallpaper-store.test.js index 69c2f9f0..1b73ede9 100644 --- a/api/test/wallpaper-store.test.js +++ b/api/test/wallpaper-store.test.js @@ -166,6 +166,13 @@ test('fetching something that is not a URL is refused before any request', async assert.match(r.body.error, /valid URL/); }); +test('an over-size upload is refused with a message, not a broken response', async () => { + const huge = Buffer.concat([PNG, Buffer.alloc(17 * 1024 * 1024)]); + const r = await upload('huge.png', huge); + assert.equal(r.status, 400); + assert.match(r.body.error, /16 MB/); +}); + test('a link on a scheme that is not http is refused', async () => { const r = await fetchLink('file:///etc/passwd'); assert.equal(r.status, 400); diff --git a/nginx/dashboard.conf b/nginx/dashboard.conf index ff3b39e6..4378fca7 100644 --- a/nginx/dashboard.conf +++ b/nginx/dashboard.conf @@ -128,6 +128,43 @@ server { add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; font-src 'self'; frame-ancestors 'self';" always; } + # ── Wallpaper upload ── + # A wallpaper is a photo, not an icon: the site-wide body limit is far below + # a 4K image. Exact match, so only this one address accepts a body this + # large. Keep it at or above UPLOAD_MAX_BYTES in api/src/routes/wallpaper.js, + # or nginx refuses the upload with a page the admin cannot read. + location = /api/wallpaper/upload { + client_max_body_size 16m; + proxy_pass http://127.0.0.1:3000; + # $http_host, not $host: nginx strips the port from $host while a + # browser's Origin keeps it, and the API compares the two. + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + include /etc/nginx/http.d/security-headers.conf; + add_header Cache-Control "no-store" always; + proxy_read_timeout 60s; + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + } + + # ── Wallpaper link fetch ── + # The server downloads the image while the request is open. Downloading a + # photo takes longer than the 15s the rest of the API is given. + location = /api/wallpaper/fetch { + proxy_pass http://127.0.0.1:3000; + # $http_host, not $host: nginx strips the port from $host while a + # browser's Origin keeps it, and the API compares the two. + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + include /etc/nginx/http.d/security-headers.conf; + add_header Cache-Control "no-store" always; + proxy_read_timeout 60s; + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + } + # ── API proxy ── location /api/ { proxy_pass http://127.0.0.1:3000; diff --git a/ui/admin/index.html b/ui/admin/index.html index 83dae731..b24df979 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -182,7 +182,6 @@ -

Uploaded and linked images are stored on this server and served from it.

Color#rrggbb or any CSS color
@@ -197,6 +196,7 @@

Leave Collection ID blank for random.

+

Uploaded and linked images are stored on this server and served from it.

@@ -321,6 +321,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index 70c490f3..93915f65 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -428,7 +428,9 @@ html{background:var(--bg-color,var(--bg-outer))} /* ── Toast ── */ #toast{position:fixed;bottom:24px;inset-inline-end:24px;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:12px 16px;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);color:var(--tx);box-shadow:var(--shadow-pop);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;pointer-events:none;z-index:9999;max-width:320px} -#toast.show{opacity:1;transform:translateY(0)} +/* Readable and dismissable while it is up: pointer-events stay off until then, + so a hidden toast cannot swallow a click. */ +#toast.show{opacity:1;transform:translateY(0);pointer-events:auto;cursor:pointer;-webkit-user-select:text;user-select:text} /* Tinted fill, not the raw accent. Near-white text on solid --gn/--rd measures 2.02 and 3.43, under the 4.5 WCAG AA needs; mixed 30% into the page colour it reaches 8.13 and 10.33. The border keeps the accent at full strength for the diff --git a/ui/i18n/de.json b/ui/i18n/de.json index b7acb630..2c1dada9 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -224,7 +224,8 @@ "socketHintSharedNetwork": "Diesen Namen löst Docker nur für Container in einem gemeinsamen Netzwerk auf. Legen Sie beide in dasselbe Netzwerk oder verwenden Sie eine Adresse, die der Proxy veröffentlicht.", "socketHintPublishPort": "Unter dieser Adresse hat nichts geantwortet. Ein auf dem Loopback des Hosts veröffentlichter Socket-Proxy ist aus einem Container nicht erreichbar; veröffentlichen Sie ihn auf der Docker-Bridge-Adresse oder legen Sie beide Container in ein gemeinsames Netzwerk und verwenden Sie den Namen des Proxys.", "wallpaperStored": "Hintergrundbild auf diesem Server gespeichert.", - "wallpaperFailed": "Hintergrundbild fehlgeschlagen: {err}" + "wallpaperFailed": "Hintergrundbild fehlgeschlagen: {err}", + "imageTooLarge": "Dieses Bild ist zu groß für den Server." }, "confirm": { "deleteFolder": "Ordner „{name}“ löschen? Enthaltene Apps werden nicht gelöscht.", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 8e7a16e2..13aa2e0e 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -224,7 +224,8 @@ "socketHintSharedNetwork": "That name is resolved by Docker, which answers only for containers on a shared network. Put both on the same network, or use an address the proxy publishes.", "socketHintPublishPort": "Nothing at that address answered. A socket proxy published on the host’s loopback cannot be reached from inside a container; publish it on the Docker bridge address, or put both containers on a shared network and use the proxy’s name.", "wallpaperStored": "Wallpaper stored on this server.", - "wallpaperFailed": "Wallpaper failed: {err}" + "wallpaperFailed": "Wallpaper failed: {err}", + "imageTooLarge": "That image is too large for the server to accept." }, "confirm": { "deleteFolder": "Delete folder \"{name}\"? Apps inside will not be deleted.", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 3c1d39df..7a7dd60b 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -224,7 +224,8 @@ "socketHintSharedNetwork": "Ese nombre lo resuelve Docker, que solo responde para contenedores en una red compartida. Ponlos en la misma red o usa una dirección que el proxy publique.", "socketHintPublishPort": "Nada respondió en esa dirección. Un socket proxy publicado en el bucle local del host no es accesible desde un contenedor; publícalo en la dirección del puente de Docker, o pon ambos contenedores en una red compartida y usa el nombre del proxy.", "wallpaperStored": "Fondo de pantalla guardado en este servidor.", - "wallpaperFailed": "Error con el fondo de pantalla: {err}" + "wallpaperFailed": "Error con el fondo de pantalla: {err}", + "imageTooLarge": "Esa imagen es demasiado grande para el servidor." }, "confirm": { "deleteFolder": "¿Eliminar la carpeta \"{name}\"? Las aplicaciones que contiene no se eliminarán.", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 987f555a..ddc47a0f 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -224,7 +224,8 @@ "socketHintSharedNetwork": "این نام را داکر تنها برای کانتینرهایی که در یک شبکه مشترک هستند پاسخ می‌دهد. هر دو را در یک شبکه بگذارید یا از نشانی‌ای استفاده کنید که پراکسی منتشر می‌کند.", "socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید.", "wallpaperStored": "تصویر زمینه روی این سرور ذخیره شد.", - "wallpaperFailed": "تصویر زمینه ناموفق بود: {err}" + "wallpaperFailed": "تصویر زمینه ناموفق بود: {err}", + "imageTooLarge": "این تصویر برای سرور بیش از حد بزرگ است." }, "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index c4f87c62..a9f93454 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -224,7 +224,8 @@ "socketHintSharedNetwork": "Ce nom est résolu par Docker, qui ne répond que pour des conteneurs partageant un réseau. Placez les deux sur le même réseau, ou utilisez une adresse publiée par le proxy.", "socketHintPublishPort": "Rien n’a répondu à cette adresse. Un socket proxy publié sur la boucle locale de l’hôte est inaccessible depuis un conteneur ; publiez-le sur l’adresse du pont Docker, ou placez les deux conteneurs sur un réseau partagé et utilisez le nom du proxy.", "wallpaperStored": "Fond d'écran enregistré sur ce serveur.", - "wallpaperFailed": "Échec du fond d'écran : {err}" + "wallpaperFailed": "Échec du fond d'écran : {err}", + "imageTooLarge": "Cette image est trop volumineuse pour le serveur." }, "confirm": { "deleteFolder": "Supprimer le dossier « {name} » ? Les applications qu'il contient ne seront pas supprimées.", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 21287d13..02ad2c6d 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -224,7 +224,8 @@ "socketHintSharedNetwork": "该名称由 Docker 解析,且只对处于同一网络的容器生效。请将两者放入同一网络,或改用代理已发布的地址。", "socketHintPublishPort": "该地址没有任何响应。发布在主机回环上的套接字代理无法从容器内访问;请将其发布到 Docker 网桥地址,或把两个容器放入同一网络并使用代理名称。", "wallpaperStored": "壁纸已保存到本服务器。", - "wallpaperFailed": "壁纸操作失败:{err}" + "wallpaperFailed": "壁纸操作失败:{err}", + "imageTooLarge": "该图片过大,服务器无法接受。" }, "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 21c848e4..05ab1966 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -2,7 +2,7 @@ import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd'; import { state } from '/js/admin-state.js?v=b7731aa4'; -import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=f3f87abf'; +import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=8558624f'; import { t } from '/js/i18n.js?v=d056c9c5'; import { toast, @@ -13,8 +13,8 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=3d2627a9'; -import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=5fb6a01b'; +} from '/js/admin-shared.js?v=7669ed7d'; +import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=4c3b8c98'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 1597716f..10bdf841 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { ag, ap } from '/js/admin-shared.js?v=3d2627a9'; +import { ag, ap } from '/js/admin-shared.js?v=7669ed7d'; import { t } from '/js/i18n.js?v=d056c9c5'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { el, inp as inpById, qa } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index cf242a88..87ec8e37 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,4 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=3d2627a9'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=7669ed7d'; import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index 6f8df4c6..6728f193 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -261,3 +261,20 @@ export function toastMs(message) { const len = typeof message === 'string' ? message.length : 0; return Math.min(15000, Math.max(3000, len * 60)); } + +/** After leaving a notice that was held open. Short: it has been read. */ +const TOAST_RELEASE_MS = 1500; + +/** How long a notice stays up, or null when it must wait to be dismissed. + + An error carries the server's own words, and it is the message a user is + most likely to copy, so it never disappears on its own. + + @param {string} kind `'err'` or `'ok'` + @param {string} message + @param {'show'|'release'} phase shown, or the pointer just left it + @returns {number|null} */ +export function toastHoldMs(kind, message, phase) { + if (kind === 'err') return null; + return phase === 'release' ? TOAST_RELEASE_MS : toastMs(message); +} diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 4130732a..b4a9ebd8 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,7 +1,7 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=3d2627a9'; +import { toast, ag, ap } from '/js/admin-shared.js?v=7669ed7d'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=d056c9c5'; -import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=f3f87abf'; +import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=8558624f'; import { el, inp, q, qa, setUserText } from '/js/utils.js?v=b18c93ed'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index ab025c53..2e3bebc4 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,17 +1,43 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { nextActiveIndex, recoversSession, toastMs } from '/js/admin-logic.js?v=f3f87abf'; +import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=8558624f'; import { el, qa, q } from '/js/utils.js?v=b18c93ed'; import { t } from '/js/i18n.js?v=d056c9c5'; export const API = ''; let tt; +let _toastWired = false; + +/** The notice hides itself on a timer, except while it is being read: an error + carries the server's own words, and losing them mid-sentence costs the one + thing that says what went wrong. An error waits for a click instead. + + @param {string} m @param {'ok'|'err'} [t] @returns {void} */ export const toast = (m, t = 'ok') => { const e = el('toast'); e.textContent = m; e.className = `show ${t}`; clearTimeout(tt); - tt = setTimeout(() => (e.className = ''), toastMs(m)); + if (!_toastWired) { + _toastWired = true; + /* Hover and focus hold it open; leaving starts a fresh, short countdown. */ + const hold = () => clearTimeout(tt); + const release = () => { + clearTimeout(tt); + const ms = toastHoldMs(e.classList.contains('err') ? 'err' : 'ok', e.textContent || '', 'release'); + if (ms != null) tt = setTimeout(() => (e.className = ''), ms); + }; + e.addEventListener('mouseenter', hold); + e.addEventListener('focusin', hold); + e.addEventListener('mouseleave', release); + e.addEventListener('focusout', release); + e.addEventListener('click', () => { + clearTimeout(tt); + e.className = ''; + }); + } + const ms = toastHoldMs(t, m, 'show'); + if (ms != null) tt = setTimeout(() => (e.className = ''), ms); }; /* Carry `kind` and `detail`, so callers branch on data, never on message diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 2a4350b6..0fa630c4 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,6 +1,6 @@ import { state } from '/js/admin-state.js?v=b7731aa4'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=3d2627a9'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c8fdc715'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=7669ed7d'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=599e76ef'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, @@ -8,7 +8,7 @@ import { rejectionLines, refusedNoticeKey, carriesTypedValues, -} from '/js/admin-logic.js?v=f3f87abf'; +} from '/js/admin-logic.js?v=8558624f'; import { t } from '/js/i18n.js?v=d056c9c5'; import { q, qi, qa } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin.js b/ui/js/admin.js index cf8f0146..56901f46 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=fde6479c'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=76be763e'; +import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=6309ee29'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=0be99366'; import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806'; -import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=f3f87abf'; +import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=8558624f'; import { buildAppItem, claimFolderChildren, @@ -10,10 +10,10 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=52a970d3'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=4a5974c9'; -import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=3d2627a9'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=3ff06da1'; +import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=7669ed7d'; import { state } from '/js/admin-state.js?v=b7731aa4'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=bb1a94d7'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=19f5f17e'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5'; import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; @@ -30,7 +30,7 @@ import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef'; import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b18c93ed'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=5fb6a01b'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=4c3b8c98'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788'; /* A class rather than a bare media query. Some phones report a wider CSS @@ -1178,6 +1178,8 @@ function initBgType() { showBgFields(val); const hint = el('bgcol-hint'); if (hint) hint.style.display = val === 'unsplash' ? '' : 'none'; + const imgHint = el('bg-url-hint'); + if (imgHint) imgHint.style.display = val === 'url' ? '' : 'none'; } btn.addEventListener('click', e => { @@ -1215,6 +1217,20 @@ function initBgFit() { setVal(hidden.value || 'fill'); } +/** The error a response carries. A body that is not JSON is the web server + answering on its own, which is what an over-size upload gets. + + @param {Response} r @returns {Promise} */ +async function responseError(r) { + const text = await r.text().catch(() => ''); + try { + const d = JSON.parse(text); + if (d && d.error) return String(d.error); + } catch {} + if (r.status === 413) return t('toast.imageTooLarge'); + return `HTTP ${r.status}`; +} + /** Point the wallpaper fields at an image this server now holds. */ function setWallpaperUrl(url) { const urlInp = inp('bg-url-inp'); @@ -1240,8 +1256,8 @@ function initWallpaperUpload() { const form = new FormData(); form.append('wallpaper', file, file.name); const r = await fetch('/api/wallpaper/upload', { method: 'POST', body: form }); + if (!r.ok) throw new Error(await responseError(r)); const d = await r.json(); - if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); setWallpaperUrl(d.url); toast(t('toast.wallpaperStored')); } catch (e) { @@ -1266,8 +1282,8 @@ async function fetchWallpaperLink(url) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); + if (!r.ok) throw new Error(await responseError(r)); const d = await r.json(); - if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); setWallpaperUrl(d.url); toast(t('toast.wallpaperStored')); } catch (e) { diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index c9adea11..1b318ea1 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -4,8 +4,8 @@ import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=3d2627a9'; -import { renderColorControl } from '/js/admin-color-control.js?v=5fb6a01b'; +import { wireChecklist } from '/js/admin-shared.js?v=7669ed7d'; +import { renderColorControl } from '/js/admin-color-control.js?v=4c3b8c98'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=f3f87abf'; +} from '/js/admin-logic.js?v=8558624f'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; import { qi } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/test/admin-logic.test.mjs b/ui/test/admin-logic.test.mjs index 0096ff17..c08c6730 100644 --- a/ui/test/admin-logic.test.mjs +++ b/ui/test/admin-logic.test.mjs @@ -20,6 +20,7 @@ import { clearsStoredPassword, recoversSession, toastMs, + toastHoldMs, BLOCK, } from '../js/admin-logic.js'; /* The real strength check, so these assert the rule the save actually applies. */ @@ -528,6 +529,17 @@ test('recoversSession tolerates a missing path', () => { assert.equal(recoversSession(null, 200), false); }); +test('an error waits to be dismissed, in both phases', () => { + assert.equal(toastHoldMs('err', 'Wallpaper failed: image too large (max 16 MB)', 'show'), null); + assert.equal(toastHoldMs('err', 'x', 'release'), null); +}); + +test('a confirmation still hides itself, and sooner once it has been read', () => { + assert.equal(toastHoldMs('ok', 'Saved', 'show'), 3000); + assert.equal(toastHoldMs('ok', 'x'.repeat(1000), 'show'), 15000); + assert.equal(toastHoldMs('ok', 'x'.repeat(1000), 'release'), 1500); +}); + test('toastMs holds a long message longer than a short one', () => { assert.equal(toastMs('Saved'), 3000); assert.equal(toastMs(''), 3000); From 90c578c8cab1e723ee33cc7dc35746c7a1d3975b Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:32:58 -0400 Subject: [PATCH 3/5] Fix: keep a wallpaper until the config stops pointing at it 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. --- api/src/routes/config.js | 4 ++ api/src/routes/wallpaper.js | 52 +++++++++++++++++++++----- api/test/wallpaper-store.test.js | 56 +++++++++++++++++++++++++--- e2e/wallpaper.spec.js | 63 ++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 e2e/wallpaper.spec.js diff --git a/api/src/routes/config.js b/api/src/routes/config.js index 621d37df..1dbc4d62 100644 --- a/api/src/routes/config.js +++ b/api/src/routes/config.js @@ -10,6 +10,7 @@ const { scrubAllSecrets, preserveAllSecrets } = require('../config-secrets'); const { firstMalformedRow } = require('../badge-headers'); const backoff = require('../poll-backoff'); const { stripDisabledCredentials } = require('../auth'); +const { pruneWallpapers } = require('./wallpaper'); const DOCK_MAX = 4; @@ -138,6 +139,9 @@ on('POST', '/api/config', async (req, res) => { migrate(data); ensureSystemItems(data); saveConfig(data); + /* Only now: until the config points at it, the file being replaced is still + the wallpaper on screen. */ + pruneWallpapers(data.settings?.background?.url); /* An edited address must be tried at once, not wait out a backoff the previous one earned. */ backoff.reset(); diff --git a/api/src/routes/wallpaper.js b/api/src/routes/wallpaper.js index a052d4f5..79e1c952 100644 --- a/api/src/routes/wallpaper.js +++ b/api/src/routes/wallpaper.js @@ -32,8 +32,9 @@ function wallpaperName(ext) { return `wallpaper-${Date.now().toString(36)}${ext}`; } -/** Write the image and drop the one it replaces. One wallpaper is stored at a - time, so the directory cannot grow without bound. +/** Write the image. The file it replaces stays until a config write says the + new one is the wallpaper: an upload that is never saved must not delete the + wallpaper still on screen. @param {Buffer} data @param {string} ext @returns {string} the served URL */ function storeWallpaper(data, ext) { @@ -41,16 +42,47 @@ function storeWallpaper(data, ext) { fs.mkdirSync(dir, { recursive: true }); const saved = wallpaperName(ext); fs.writeFileSync(path.join(dir, saved), data); - for (const f of fs.readdirSync(dir)) { - if (f !== saved) { - try { - fs.unlinkSync(path.join(dir, f)); - } catch {} - } - } return WALLPAPER_URL_BASE + saved; } +/** Which stored wallpapers a save keeps: the one the config now points at, and + nothing else. A config that names none keeps the newest file, so switching + the source to Unsplash and back does not throw the image away. + + @param {string[]} files names in the wallpaper directory, newest last + @param {string} referenced the file name the config points at, or '' + @returns {string[]} the names to delete */ +function wallpapersToDrop(files, referenced) { + if (referenced && files.includes(referenced)) return files.filter(f => f !== referenced); + const keep = files[files.length - 1]; + return files.filter(f => f !== keep); +} + +/** Remove every stored wallpaper a saved config no longer points at. + + @param {unknown} url `settings.background.url`, whatever it holds + @returns {void} */ +function pruneWallpapers(url) { + const dir = WALLPAPER_DIR(); + let files; + try { + files = fs + .readdirSync(dir) + .map(name => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs })) + /* The name carries the same timestamp, and decides a tie. */ + .sort((a, b) => a.at - b.at || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map(f => f.name); + } catch { + return; /* nothing has ever been stored */ + } + const referenced = typeof url === 'string' && url.startsWith(WALLPAPER_URL_BASE) ? path.basename(url) : ''; + for (const name of wallpapersToDrop(files, referenced)) { + try { + fs.unlinkSync(path.join(dir, name)); + } catch {} + } +} + on('GET', '/api/wallpaper', async (_, res) => { const cfg = loadConfig(), bg = cfg.settings?.background || {}; @@ -156,4 +188,4 @@ on('POST', '/api/wallpaper/fetch', async (req, res) => { } }); -module.exports = { storeWallpaper, WALLPAPER_URL_BASE }; +module.exports = { storeWallpaper, pruneWallpapers, wallpapersToDrop, WALLPAPER_URL_BASE }; diff --git a/api/test/wallpaper-store.test.js b/api/test/wallpaper-store.test.js index 1b73ede9..ed3183b1 100644 --- a/api/test/wallpaper-store.test.js +++ b/api/test/wallpaper-store.test.js @@ -13,7 +13,7 @@ const assert = require('node:assert/strict'); const http = require('node:http'); const { sniffImageType } = require('../src/image-sniff'); -const { storeWallpaper, WALLPAPER_URL_BASE } = require('../src/routes/wallpaper'); +const { storeWallpaper, pruneWallpapers, wallpapersToDrop, WALLPAPER_URL_BASE } = require('../src/routes/wallpaper'); const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]); @@ -60,13 +60,38 @@ test('the stored name is generated, and carries the sniffed extension', () => { assert.ok(fs.existsSync(path.join(iconsDir, url.replace('/icons/', '')))); }); -test('storing again replaces the wallpaper rather than piling them up', () => { - storeWallpaper(PNG, '.png'); +test('an upload leaves the wallpaper still on screen alone', () => { + const inUse = storeWallpaper(PNG, '.png'); storeWallpaper(JPEG, '.jpg'); - storeWallpaper(GIF, '.gif'); + assert.ok(fs.existsSync(path.join(iconsDir, inUse.replace('/icons/', ''))), 'the saved wallpaper was deleted'); +}); + +test('a save keeps only the wallpaper the config points at', () => { + const dropped = storeWallpaper(PNG, '.png'); + const kept = storeWallpaper(JPEG, '.jpg'); + pruneWallpapers(kept); const files = fs.readdirSync(dir()); - assert.equal(files.length, 1); - assert.match(files[0], /\.gif$/); + assert.deepEqual(files, [path.basename(kept)]); + assert.ok(!fs.existsSync(path.join(iconsDir, dropped.replace('/icons/', '')))); +}); + +test('a config naming no wallpaper still keeps the newest, and only that', () => { + assert.deepEqual(wallpapersToDrop(['a.png', 'b.jpg', 'c.gif'], ''), ['a.png', 'b.jpg']); + assert.deepEqual(wallpapersToDrop(['a.png', 'b.jpg'], 'a.png'), ['b.jpg']); + /* A name the directory does not hold decides nothing. */ + assert.deepEqual(wallpapersToDrop(['a.png', 'b.jpg'], 'gone.png'), ['a.png']); + assert.deepEqual(wallpapersToDrop([], 'a.png'), []); +}); + +test('pruning a directory that was never written does nothing', () => { + assert.doesNotThrow(() => pruneWallpapers('/icons/wallpaper/none.png')); +}); + +test('a URL outside the wallpaper directory keeps nothing by name', () => { + storeWallpaper(PNG, '.png'); + const newest = storeWallpaper(GIF, '.gif'); + pruneWallpapers('https://example.invalid/photo.jpg'); + assert.deepEqual(fs.readdirSync(dir()), [path.basename(newest)]); }); test('wallpapers are kept out of the directory the icon picker lists', () => { @@ -173,6 +198,25 @@ test('an over-size upload is refused with a message, not a broken response', asy assert.match(r.body.error, /16 MB/); }); +test('saving a config that names the new wallpaper drops the old file', async () => { + const dropped = storeWallpaper(PNG, '.png'); + const kept = storeWallpaper(JPEG, '.jpg'); + const { loadConfig } = require('../src/config'); + const cfg = loadConfig(); + const body = Buffer.from( + JSON.stringify({ + _schemaVersion: cfg._schemaVersion, + _rev: cfg._rev, + items: [], + settings: { background: { type: 'url', url: kept, brightness: 1, fit: 'fill' } }, + }), + ); + const r = await request('/api/config', { 'Content-Type': 'application/json' }, body); + assert.equal(r.status, 200); + assert.deepEqual(fs.readdirSync(dir()), [path.basename(kept)]); + assert.ok(!fs.existsSync(path.join(iconsDir, dropped.replace('/icons/', '')))); +}); + test('a link on a scheme that is not http is refused', async () => { const r = await fetchLink('file:///etc/passwd'); assert.equal(r.status, 400); diff --git a/e2e/wallpaper.spec.js b/e2e/wallpaper.spec.js new file mode 100644 index 00000000..777b33b4 --- /dev/null +++ b/e2e/wallpaper.spec.js @@ -0,0 +1,63 @@ +// @ts-check +/* The wallpaper fit reaches the dashboard. + + The chain has four links: the dropdown writes a hidden field, the save reads + it, the dashboard maps it to a CSS custom property, and the wallpaper layer + reads that property. A break anywhere leaves the setting looking present and + doing nothing, which is exactly what cannot be seen from the admin page. */ + +const { test, expect } = require('@playwright/test'); +const { seedConfig, readConfig, dismissSetupPrompt } = require('./helpers'); + +const WALLPAPER = { type: 'url', url: '/icons/wallpaper/none.png', brightness: 1, fit: 'fill' }; + +test.beforeEach(async ({ request }) => { + await seedConfig(request, { items: [], settings: { background: { ...WALLPAPER } } }); + await dismissSetupPrompt(request); +}); + +/* The page fills these fields from the config after it loads, so a click before + that lands is overwritten. Waiting for the stored URL to appear is waiting + for the load. */ +async function openAppearance(page) { + await page.goto('/admin/'); + await page.locator('.nl[data-sec="appearance"]').click(); + await page.locator('#bg-fit-btn').waitFor({ state: 'visible' }); + await expect(page.locator('#ie-bgurl-v')).toContainText('/icons/wallpaper/'); +} + +test('choosing Fit and saving stores it', async ({ page, request }) => { + await openAppearance(page); + await page.locator('#bg-fit-btn').click(); + await page.locator('#bg-fit-list li[data-val="fit"]').click(); + await expect(page.locator('#bg-fit-list li[data-val="fit"]')).toHaveAttribute('aria-selected', 'true'); + const saved = page.waitForResponse(r => r.url().includes('/api/config') && r.request().method() === 'POST'); + await page.locator('#bg-save').click(); + expect((await saved).ok()).toBeTruthy(); + + const cfg = await readConfig(request); + expect(cfg.settings.background.fit).toBe('fit'); +}); + +test('the saved choice is shown again when the page is reopened', async ({ page, request }) => { + await seedConfig(request, { items: [], settings: { background: { ...WALLPAPER, fit: 'fit' } } }); + await openAppearance(page); + await expect(page.locator('#bg-fit-list li[data-val="fit"]')).toHaveAttribute('aria-selected', 'true'); +}); + +test('fill covers the viewport and fit shows the whole image', async ({ page, request }) => { + /* The wallpaper is applied after the page has built its tiles, so this is a + value to wait for, not one to read once. */ + const expectSize = async want => { + await page.goto('/'); + await page.locator('#pages').waitFor(); + await expect + .poll(() => page.evaluate(() => getComputedStyle(document.documentElement, '::before').backgroundSize)) + .toBe(want); + }; + + await expectSize('cover'); + + await seedConfig(request, { items: [], settings: { background: { ...WALLPAPER, fit: 'fit' } } }); + await expectSize('contain'); +}); From 1fe39d0395af00c40ef35dbe655e0fc44f357abb Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:36:49 -0400 Subject: [PATCH 4/5] Fix: apply the wallpaper fit on the settings pages too 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. --- e2e/wallpaper.spec.js | 14 ++++++++++++++ ui/admin/index.html | 2 +- ui/js/admin.js | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/e2e/wallpaper.spec.js b/e2e/wallpaper.spec.js index 777b33b4..0bc463e5 100644 --- a/e2e/wallpaper.spec.js +++ b/e2e/wallpaper.spec.js @@ -45,6 +45,20 @@ test('the saved choice is shown again when the page is reopened', async ({ page, await expect(page.locator('#bg-fit-list li[data-val="fit"]')).toHaveAttribute('aria-selected', 'true'); }); +test('the settings pages show the wallpaper the same way the dashboard does', async ({ page, request }) => { + const sizeOnAdmin = async () => { + await page.goto('/admin/'); + await page.locator('.nl[data-sec="appearance"]').click(); + await expect(page.locator('#ie-bgurl-v')).toContainText('/icons/wallpaper/'); + return page.evaluate(() => getComputedStyle(document.documentElement, '::before').backgroundSize); + }; + + expect(await sizeOnAdmin()).toBe('cover'); + + await seedConfig(request, { items: [], settings: { background: { ...WALLPAPER, fit: 'fit' } } }); + expect(await sizeOnAdmin()).toBe('contain'); +}); + test('fill covers the viewport and fit shows the whole image', async ({ page, request }) => { /* The wallpaper is applied after the page has built its tiles, so this is a value to wait for, not one to read once. */ diff --git a/ui/admin/index.html b/ui/admin/index.html index b24df979..4618c44d 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -321,6 +321,6 @@

General

- + diff --git a/ui/js/admin.js b/ui/js/admin.js index 56901f46..208c69bb 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -80,10 +80,12 @@ async function applyBg() { root.style.setProperty('--bg-image', 'none'); root.style.setProperty('--bg-color', String(bg.color).replace(/[^a-zA-Z0-9#(),.\s%]/g, '')); root.style.setProperty('--bg-brightness', '1'); + root.style.setProperty('--bg-size', 'cover'); } else if (bg.type === 'url' && bg.url) { root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(bg.url)}')`); root.style.setProperty('--bg-color', '#0d1117'); root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); + root.style.setProperty('--bg-size', bg.fit === 'fit' ? 'contain' : 'cover'); } else if (bg.type === 'unsplash') { const r = await fetch('/api/wallpaper', { cache: 'no-store' }); const d = await r.json(); @@ -93,6 +95,7 @@ async function applyBg() { root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(d.url)}')`); root.style.setProperty('--bg-color', '#0d1117'); root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); + root.style.setProperty('--bg-size', 'cover'); }; img.src = d.url; } From ea5264c00371418eb525b29a07936cc703f66c41 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:43:18 -0400 Subject: [PATCH 5/5] Refactor: tighten the wallpaper routes and trim their comments 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. --- api/src/image-sniff.js | 12 +-- api/src/routes/wallpaper.js | 159 +++++++++++++++++-------------- api/test/wallpaper-store.test.js | 10 +- e2e/wallpaper.spec.js | 17 ++-- ui/admin/index.html | 4 +- ui/css/admin.css | 4 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 6 +- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 2 +- ui/js/admin-logic.js | 8 +- ui/js/admin-settings.js | 4 +- ui/js/admin-shared.js | 9 +- ui/js/admin-widget-form.js | 6 +- ui/js/admin.js | 26 ++--- ui/js/dashboard.js | 16 ++-- ui/js/label-contrast.js | 60 +++++------- ui/js/widget-config-form.js | 6 +- 18 files changed, 173 insertions(+), 180 deletions(-) diff --git a/api/src/image-sniff.js b/api/src/image-sniff.js index 4f55c904..f79a45c4 100644 --- a/api/src/image-sniff.js +++ b/api/src/image-sniff.js @@ -1,5 +1,4 @@ -/* Wallpaper formats, identified by their own bytes. The extension a browser - sends is not evidence of anything. */ +/* The submitted extension is not evidence. Identify the format by its bytes. */ const SIGNATURES = [ { type: 'png', ext: '.png', mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, @@ -19,17 +18,16 @@ function riffBrand(buf) { return 'webp'; } -/** ISO base media: the brand follows the 'ftyp' box type. */ +/** @param {Buffer} buf @returns {string|null} the brand after the ftyp box */ function isoBrand(buf) { if (!startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) return null; const brand = buf.slice(8, 12).toString('latin1'); return brand === 'avif' || brand === 'avis' ? 'avif' : null; } -/** The format of an image, or null when it is not one this project accepts. - - @param {Buffer} buf - @returns {{type: string, ext: string, mime: string}|null} */ +/** @param {Buffer} buf + @returns {{type: string, ext: string, mime: string}|null} null when the + bytes are not an accepted image */ function sniffImageType(buf) { for (const s of SIGNATURES) if (startsWith(buf, s.bytes)) return { type: s.type, ext: s.ext, mime: s.mime }; if (riffBrand(buf)) return { type: 'webp', ext: '.webp', mime: 'image/webp' }; diff --git a/api/src/routes/wallpaper.js b/api/src/routes/wallpaper.js index 79e1c952..9bd2c309 100644 --- a/api/src/routes/wallpaper.js +++ b/api/src/routes/wallpaper.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { on, json, checkOrigin, getIp } = require('../router'); @@ -10,33 +11,36 @@ const { parseMultipartFile } = require('../parse-multipart'); const log = require('../log'); const { fail, KIND, errorBody } = require('../api-error'); -/** Wallpapers live under the icons mount, in their own directory: the icon - picker lists the top level, and a wallpaper is not an icon. */ const WALLPAPER_DIR = () => path.join(ICONS_PATH, 'wallpaper'); const WALLPAPER_URL_BASE = '/icons/wallpaper/'; -/* A 4K wallpaper is the size to carry. Keep client_max_body_size on - /api/wallpaper/upload in nginx/dashboard.conf at or above this. */ +/* Keep client_max_body_size on /api/wallpaper/upload at or above this. nginx + refuses a larger body before the request reaches this code. */ const UPLOAD_MAX_BYTES = 16 * 1024 * 1024; const UPLOAD_STREAM_MAX_BYTES = Math.round(UPLOAD_MAX_BYTES * 1.25); +const LINK_BODY_MAX_BYTES = 4096; +const UPLOADS_PER_HOUR = 20; -/* A photo over a slow link takes longer than a widget's JSON poll. Keep - proxy_read_timeout on /api/wallpaper/fetch in nginx above this. */ +/* Keep proxy_read_timeout on /api/wallpaper/fetch above this. */ const FETCH_TIMEOUT_MS = 30_000; -/** The stored name is generated, never the submitted one: it is served from - this origin, and the extension follows the sniffed bytes. +const ACCEPTED = 'JPEG, PNG, WebP, AVIF or GIF'; +const TOO_LARGE = 'image too large (max 16 MB)'; - @param {string} ext @returns {string} */ +/* A message only reaches the browser when the route hands it over itself. See + docs/api-errors.md. */ +const oversize = () => Object.assign(new Error('body over the limit'), { oversize: true }); + +/** @param {string} ext @returns {string} */ function wallpaperName(ext) { - return `wallpaper-${Date.now().toString(36)}${ext}`; + return `wallpaper-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}${ext}`; } -/** Write the image. The file it replaces stays until a config write says the - new one is the wallpaper: an upload that is never saved must not delete the - wallpaper still on screen. +/** Writes the image under a generated name and returns the URL it is served at. + Deletes nothing: until a config write names the new file, the file it + replaces is still the wallpaper on screen. - @param {Buffer} data @param {string} ext @returns {string} the served URL */ + @param {Buffer} data @param {string} ext @returns {string} */ function storeWallpaper(data, ext) { const dir = WALLPAPER_DIR(); fs.mkdirSync(dir, { recursive: true }); @@ -45,44 +49,80 @@ function storeWallpaper(data, ext) { return WALLPAPER_URL_BASE + saved; } -/** Which stored wallpapers a save keeps: the one the config now points at, and - nothing else. A config that names none keeps the newest file, so switching - the source to Unsplash and back does not throw the image away. - - @param {string[]} files names in the wallpaper directory, newest last - @param {string} referenced the file name the config points at, or '' +/** @param {string[]} files names in the directory, oldest first + @param {string} referenced the name the config points at, or '' @returns {string[]} the names to delete */ function wallpapersToDrop(files, referenced) { if (referenced && files.includes(referenced)) return files.filter(f => f !== referenced); - const keep = files[files.length - 1]; - return files.filter(f => f !== keep); + const newest = files[files.length - 1]; + return files.filter(f => f !== newest); } -/** Remove every stored wallpaper a saved config no longer points at. +/** @param {string} dir @returns {string[]} names, oldest first */ +function storedByAge(dir) { + return fs + .readdirSync(dir) + .map(name => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs })) + .sort((a, b) => a.at - b.at || a.name.localeCompare(b.name)) + .map(f => f.name); +} - @param {unknown} url `settings.background.url`, whatever it holds - @returns {void} */ +/** Removes every stored wallpaper the saved config no longer points at. + + @param {unknown} url `settings.background.url` @returns {void} */ function pruneWallpapers(url) { const dir = WALLPAPER_DIR(); let files; try { - files = fs - .readdirSync(dir) - .map(name => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs })) - /* The name carries the same timestamp, and decides a tie. */ - .sort((a, b) => a.at - b.at || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) - .map(f => f.name); + files = storedByAge(dir); } catch { - return; /* nothing has ever been stored */ + return; } const referenced = typeof url === 'string' && url.startsWith(WALLPAPER_URL_BASE) ? path.basename(url) : ''; for (const name of wallpapersToDrop(files, referenced)) { try { fs.unlinkSync(path.join(dir, name)); - } catch {} + } catch (e) { + log.warn('wallpaper could not be removed', { name, error: e.message }); + } } } +/** @param {import('http').IncomingMessage} req @param {number} max + @returns {Promise} */ +function readBodyCapped(req, max) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on('data', c => { + total += c.length; + if (total > max) { + req.destroy(); + return reject(oversize()); + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +/** @param {import('http').IncomingMessage} req @param {import('http').ServerResponse} res + @returns {boolean} whether the request may write a wallpaper */ +function mayWrite(req, res) { + if (IS_DEMO) { + json(res, 403, { error: DEMO_READONLY_MSG, kind: KIND.BLOCKED }); + return false; + } + if (!checkOrigin(req, res)) return false; + const limited = rateLimit(getIp(req), 'upload', UPLOADS_PER_HOUR, 3_600_000); + if (limited) { + json(res, 429, { error: limited, kind: KIND.BLOCKED }); + return false; + } + return true; +} + on('GET', '/api/wallpaper', async (_, res) => { const cfg = loadConfig(), bg = cfg.settings?.background || {}; @@ -100,66 +140,38 @@ on('GET', '/api/wallpaper', async (_, res) => { }); on('POST', '/api/wallpaper/upload', async (req, res) => { - if (IS_DEMO) return json(res, 403, { error: DEMO_READONLY_MSG, kind: KIND.BLOCKED }); - if (!checkOrigin(req, res)) return; + if (!mayWrite(req, res)) return; try { - const limited = rateLimit(getIp(req), 'upload', 20, 3_600_000); - if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED }); const ct = req.headers['content-type'] || ''; if (!ct.includes('multipart/form-data')) return json(res, 400, { error: 'multipart/form-data required', kind: KIND.INVALID }); const bMatch = ct.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i); if (!bMatch) return json(res, 400, { error: 'missing boundary', kind: KIND.INVALID }); - const buf = await new Promise((resolve, reject) => { - const chunks = []; - let total = 0; - req.on('data', c => { - total += c.length; - if (total > UPLOAD_STREAM_MAX_BYTES) { - req.destroy(); - return reject(new Error('image too large (max 16 MB)')); - } - chunks.push(c); - }); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); + + const buf = await readBodyCapped(req, UPLOAD_STREAM_MAX_BYTES); const { filename, data, fileParts } = parseMultipartFile(buf, bMatch[1] || bMatch[2]); if (!filename || !data?.length) return json(res, 400, { error: 'no file found in upload', kind: KIND.INVALID }); if (fileParts > 1) return json(res, 400, { error: 'only one file per upload', kind: KIND.INVALID }); - if (data.length > UPLOAD_MAX_BYTES) - return json(res, 400, { error: 'image too large (max 16 MB)', kind: KIND.INVALID }); + if (data.length > UPLOAD_MAX_BYTES) return json(res, 400, { error: TOO_LARGE, kind: KIND.INVALID }); const kind = sniffImageType(data); - if (!kind) return json(res, 400, { error: 'file is not a JPEG, PNG, WebP, AVIF or GIF image', kind: KIND.INVALID }); + if (!kind) return json(res, 400, { error: `file is not a ${ACCEPTED} image`, kind: KIND.INVALID }); + const url = storeWallpaper(data, kind.ext); log.audit('wallpaper uploaded', { url, type: kind.type, bytes: data.length }); json(res, 200, { ok: true, url }); } catch (e) { + if (e.oversize) return json(res, 400, { error: TOO_LARGE, kind: KIND.INVALID }); fail(res, e, { status: 500 }); } }); on('POST', '/api/wallpaper/fetch', async (req, res) => { - if (IS_DEMO) return json(res, 403, { error: DEMO_READONLY_MSG, kind: KIND.BLOCKED }); - if (!checkOrigin(req, res)) return; + if (!mayWrite(req, res)) return; try { - const limited = rateLimit(getIp(req), 'upload', 20, 3_600_000); - if (limited) return json(res, 429, { error: limited, kind: KIND.BLOCKED }); - const body = await new Promise((resolve, reject) => { - let s = ''; - req.on('data', c => { - s += c; - if (s.length > 4096) { - req.destroy(); - reject(new Error('request too large')); - } - }); - req.on('end', () => resolve(s)); - req.on('error', reject); - }); + const body = await readBodyCapped(req, LINK_BODY_MAX_BYTES); let url = ''; try { - url = String(JSON.parse(body || '{}').url || '').trim(); + url = String(JSON.parse(body.toString('utf8') || '{}').url || '').trim(); } catch { return json(res, 400, { error: 'invalid JSON body', kind: KIND.INVALID }); } @@ -177,12 +189,13 @@ on('POST', '/api/wallpaper/fetch', async (req, res) => { return json(res, 502, { error: `the image could not be fetched (HTTP ${r.status})`, kind: KIND.UPSTREAM }); const data = Buffer.isBuffer(r.data) ? r.data : Buffer.alloc(0); const kind = sniffImageType(data); - if (!kind) - return json(res, 400, { error: 'that link is not a JPEG, PNG, WebP, AVIF or GIF image', kind: KIND.INVALID }); + if (!kind) return json(res, 400, { error: `that link is not a ${ACCEPTED} image`, kind: KIND.INVALID }); + const saved = storeWallpaper(data, kind.ext); log.audit('wallpaper fetched', { url: parsed.origin + parsed.pathname, type: kind.type, bytes: data.length }); json(res, 200, { ok: true, url: saved }); } catch (e) { + if (e.oversize) return json(res, 400, { error: 'request too large', kind: KIND.INVALID }); if (e instanceof SsrfBlockedError) return json(res, 403, { error: e.message, kind: KIND.BLOCKED }); fail(res, e, { status: 502 }); } diff --git a/api/test/wallpaper-store.test.js b/api/test/wallpaper-store.test.js index ed3183b1..de7e15b2 100644 --- a/api/test/wallpaper-store.test.js +++ b/api/test/wallpaper-store.test.js @@ -60,6 +60,14 @@ test('the stored name is generated, and carries the sniffed extension', () => { assert.ok(fs.existsSync(path.join(iconsDir, url.replace('/icons/', '')))); }); +test('two images stored in the same millisecond each keep their own file', () => { + const first = storeWallpaper(PNG, '.png'); + const second = storeWallpaper(JPEG, '.jpg'); + assert.notEqual(first, second); + assert.ok(fs.existsSync(path.join(iconsDir, first.replace('/icons/', '')))); + assert.ok(fs.existsSync(path.join(iconsDir, second.replace('/icons/', '')))); +}); + test('an upload leaves the wallpaper still on screen alone', () => { const inUse = storeWallpaper(PNG, '.png'); storeWallpaper(JPEG, '.jpg'); @@ -182,7 +190,7 @@ test('a name claiming a format the bytes do not have is refused', async () => { test('a name that walks out of the wallpaper directory cannot', async () => { const r = await upload('../../escape.png', PNG); assert.equal(r.status, 200); - assert.match(r.body.url, /^\/icons\/wallpaper\/wallpaper-[a-z0-9]+\.png$/); + assert.match(r.body.url, /^\/icons\/wallpaper\/wallpaper-[a-z0-9]+-[a-f0-9]{8}\.png$/); }); test('fetching something that is not a URL is refused before any request', async () => { diff --git a/e2e/wallpaper.spec.js b/e2e/wallpaper.spec.js index 0bc463e5..dff948b2 100644 --- a/e2e/wallpaper.spec.js +++ b/e2e/wallpaper.spec.js @@ -1,10 +1,7 @@ // @ts-check -/* The wallpaper fit reaches the dashboard. - - The chain has four links: the dropdown writes a hidden field, the save reads - it, the dashboard maps it to a CSS custom property, and the wallpaper layer - reads that property. A break anywhere leaves the setting looking present and - doing nothing, which is exactly what cannot be seen from the admin page. */ +/* The fit setting from the dropdown to the wallpaper layer. A break in that + chain leaves the control present and doing nothing, which is what the admin + page cannot show. */ const { test, expect } = require('@playwright/test'); const { seedConfig, readConfig, dismissSetupPrompt } = require('./helpers'); @@ -16,9 +13,8 @@ test.beforeEach(async ({ request }) => { await dismissSetupPrompt(request); }); -/* The page fills these fields from the config after it loads, so a click before - that lands is overwritten. Waiting for the stored URL to appear is waiting - for the load. */ +/* The page fills these fields from the config after it loads. A click before + that lands is overwritten. */ async function openAppearance(page) { await page.goto('/admin/'); await page.locator('.nl[data-sec="appearance"]').click(); @@ -60,8 +56,7 @@ test('the settings pages show the wallpaper the same way the dashboard does', as }); test('fill covers the viewport and fit shows the whole image', async ({ page, request }) => { - /* The wallpaper is applied after the page has built its tiles, so this is a - value to wait for, not one to read once. */ + /* The wallpaper is applied after the tiles are built. Wait for the value. */ const expectSize = async want => { await page.goto('/'); await page.locator('#pages').waitFor(); diff --git a/ui/admin/index.html b/ui/admin/index.html index 4618c44d..8c059541 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -321,6 +321,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index 93915f65..88b8d8fa 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -428,8 +428,8 @@ html{background:var(--bg-color,var(--bg-outer))} /* ── Toast ── */ #toast{position:fixed;bottom:24px;inset-inline-end:24px;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:12px 16px;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);color:var(--tx);box-shadow:var(--shadow-pop);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;pointer-events:none;z-index:9999;max-width:320px} -/* Readable and dismissable while it is up: pointer-events stay off until then, - so a hidden toast cannot swallow a click. */ +/* Only the shown toast takes pointer events. A hidden one would swallow a + click. */ #toast.show{opacity:1;transform:translateY(0);pointer-events:auto;cursor:pointer;-webkit-user-select:text;user-select:text} /* Tinted fill, not the raw accent. Near-white text on solid --gn/--rd measures 2.02 and 3.43, under the 4.5 WCAG AA needs; mixed 30% into the page colour it diff --git a/ui/index.html b/ui/index.html index 9dbf8024..32675d1e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -53,5 +53,5 @@

Stackyard

- + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 05ab1966..a00f059b 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -2,7 +2,7 @@ import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd'; import { state } from '/js/admin-state.js?v=b7731aa4'; -import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=8558624f'; +import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=ddfc6f80'; import { t } from '/js/i18n.js?v=d056c9c5'; import { toast, @@ -13,8 +13,8 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=7669ed7d'; -import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=4c3b8c98'; +} from '/js/admin-shared.js?v=d96fc091'; +import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=89eee5e8'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 10bdf841..c322a86e 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { ag, ap } from '/js/admin-shared.js?v=7669ed7d'; +import { ag, ap } from '/js/admin-shared.js?v=d96fc091'; import { t } from '/js/i18n.js?v=d056c9c5'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { el, inp as inpById, qa } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 87ec8e37..27370254 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,4 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=7669ed7d'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=d96fc091'; import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin-logic.js b/ui/js/admin-logic.js index 6728f193..34b7f8c7 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -262,13 +262,11 @@ export function toastMs(message) { return Math.min(15000, Math.max(3000, len * 60)); } -/** After leaving a notice that was held open. Short: it has been read. */ +/** After a notice has been held open and released. It has been read. */ const TOAST_RELEASE_MS = 1500; -/** How long a notice stays up, or null when it must wait to be dismissed. - - An error carries the server's own words, and it is the message a user is - most likely to copy, so it never disappears on its own. +/** How long a notice stays up, or null when it waits to be dismissed. An error + carries the server's own words, so it never disappears on its own. @param {string} kind `'err'` or `'ok'` @param {string} message diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index b4a9ebd8..a199ae97 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,7 +1,7 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=7669ed7d'; +import { toast, ag, ap } from '/js/admin-shared.js?v=d96fc091'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; import { t } from '/js/i18n.js?v=d056c9c5'; -import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=8558624f'; +import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=ddfc6f80'; import { el, inp, q, qa, setUserText } from '/js/utils.js?v=b18c93ed'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 2e3bebc4..607beb5f 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,5 +1,5 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=8558624f'; +import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=ddfc6f80'; import { el, qa, q } from '/js/utils.js?v=b18c93ed'; import { t } from '/js/i18n.js?v=d056c9c5'; @@ -8,11 +8,7 @@ export const API = ''; let tt; let _toastWired = false; -/** The notice hides itself on a timer, except while it is being read: an error - carries the server's own words, and losing them mid-sentence costs the one - thing that says what went wrong. An error waits for a click instead. - - @param {string} m @param {'ok'|'err'} [t] @returns {void} */ +/** @param {string} m @param {'ok'|'err'} [t] @returns {void} */ export const toast = (m, t = 'ok') => { const e = el('toast'); e.textContent = m; @@ -20,7 +16,6 @@ export const toast = (m, t = 'ok') => { clearTimeout(tt); if (!_toastWired) { _toastWired = true; - /* Hover and focus hold it open; leaving starts a fresh, short countdown. */ const hold = () => clearTimeout(tt); const release = () => { clearTimeout(tt); diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 0fa630c4..588ae887 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,6 +1,6 @@ import { state } from '/js/admin-state.js?v=b7731aa4'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=7669ed7d'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=599e76ef'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=d96fc091'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=71fe9a70'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, @@ -8,7 +8,7 @@ import { rejectionLines, refusedNoticeKey, carriesTypedValues, -} from '/js/admin-logic.js?v=8558624f'; +} from '/js/admin-logic.js?v=ddfc6f80'; import { t } from '/js/i18n.js?v=d056c9c5'; import { q, qi, qa } from '/js/utils.js?v=b18c93ed'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 208c69bb..44a77ebc 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=6309ee29'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=0be99366'; +import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=f87415c7'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=589ad7b9'; import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806'; -import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=8558624f'; +import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=ddfc6f80'; import { buildAppItem, claimFolderChildren, @@ -10,10 +10,10 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=52a970d3'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=3ff06da1'; -import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=7669ed7d'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=b2b67c47'; +import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=d96fc091'; import { state } from '/js/admin-state.js?v=b7731aa4'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=19f5f17e'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=55e0396a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5'; import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; @@ -30,7 +30,7 @@ import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef'; import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b18c93ed'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=4c3b8c98'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=89eee5e8'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788'; /* A class rather than a bare media query. Some phones report a wider CSS @@ -1220,8 +1220,7 @@ function initBgFit() { setVal(hidden.value || 'fill'); } -/** The error a response carries. A body that is not JSON is the web server - answering on its own, which is what an over-size upload gets. +/** A body that is not JSON is the web server answering on its own. @param {Response} r @returns {Promise} */ async function responseError(r) { @@ -1234,7 +1233,7 @@ async function responseError(r) { return `HTTP ${r.status}`; } -/** Point the wallpaper fields at an image this server now holds. */ +/** @param {string} url an image this server holds @returns {void} */ function setWallpaperUrl(url) { const urlInp = inp('bg-url-inp'); if (urlInp) urlInp.value = url; @@ -1273,8 +1272,8 @@ function initWallpaperUpload() { btn.onclick = () => /** @type {HTMLInputElement} */ (input).click(); } -/** A pasted link is downloaded once and served from this origin: the page's - content policy refuses images from anywhere else. +/** Downloads a pasted link to this server. The page's content policy refuses an + image from any other origin. @param {string} url @returns {Promise} */ async function fetchWallpaperLink(url) { @@ -1290,7 +1289,8 @@ async function fetchWallpaperLink(url) { setWallpaperUrl(d.url); toast(t('toast.wallpaperStored')); } catch (e) { - setWallpaperUrl(''); + /* A link that failed must not replace the wallpaper already saved. */ + setWallpaperUrl(state._settings?.background?.url || ''); toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); } } diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index bbefc0e2..90f55e44 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -45,7 +45,7 @@ import { relativeLuminance, sampleImage, toneForLuminances, -} from '/js/label-contrast.js?v=2332228f'; +} from '/js/label-contrast.js?v=69cbdcb0'; /* Recomputed, never stored: the window can cross the breakpoint after load. */ let MOB = isMobileLayout(); @@ -445,15 +445,14 @@ function syncMobPages() { } } -/** What shows behind a wallpaper that does not reach every edge. Matches - --bg-base in tokens.css. */ +/* Matches --bg-base in tokens.css. It shows wherever a fitted wallpaper does + not reach. */ const WALLPAPER_BACKDROP = '#0d1117'; -/** The background as the label tones see it: a sampled wallpaper grid, or one - tone for a solid colour. Null on both means the labels keep their default. */ +/* A sampled wallpaper grid, or one tone for a solid colour. Null on both leaves + the labels as they are. */ let bgTone = { grid: null, tone: null }; -/** Re-tone the labels. Cheap: it re-reads the sampled grid, never the image. */ function retone() { requestAnimationFrame(() => applyLabelTones(bgTone)); } @@ -464,8 +463,7 @@ function toneForColor(color) { return toneForLuminances([relativeLuminance(rgb[0], rgb[1], rgb[2])]); } -/** The readable copy of the current wallpaper, held so a resize re-samples - without fetching again. */ +/* Held so a resize re-samples without fetching the image again. */ let _bgSample = null; async function sampleWallpaper(url, brightness, fit) { @@ -475,7 +473,7 @@ async function sampleWallpaper(url, brightness, fit) { } /* The wallpaper is sized against the viewport, so a resize moves which part of - it every label sits on. */ + it each label sits on. */ function resampleBg() { if (!_bgSample) return retone(); bgTone = { diff --git a/ui/js/label-contrast.js b/ui/js/label-contrast.js index a2ac717d..da104ed7 100644 --- a/ui/js/label-contrast.js +++ b/ui/js/label-contrast.js @@ -1,8 +1,6 @@ // @ts-check -/* Icon label colour, chosen from the background behind each label. - - The pure half of this module has no DOM, no fetch and no module state. The - DOM half is at the bottom and is named so. */ +/* Icon label colour, measured from the background behind each label. Keep the + first half free of the DOM: it is the half under test. */ /** WCAG 2.1 relative luminance of an sRGB triple. @@ -23,17 +21,15 @@ export function contrastRatio(a, b) { return (hi + 0.05) / (lo + 0.05); } -/** Luminance of the two label tones. Both carry a shadow, so the text is not - pure black or pure white. */ +/** The two tones a label can take. */ const TONE_LUM = { light: relativeLuminance(255, 255, 255), dark: relativeLuminance(0, 0, 0), }; -/** The tone that keeps the most contrast against the worst patch it covers. +/** The tone with the best worst-case contrast over the patch a label covers. - @param {ArrayLike} lums the background luminances under the label - @returns {'light'|'dark'} */ + @param {ArrayLike} lums background luminances @returns {'light'|'dark'} */ export function toneForLuminances(lums) { if (!lums || lums.length === 0) return 'light'; let worstLight = Infinity, @@ -45,7 +41,7 @@ export function toneForLuminances(lums) { return worstDark > worstLight ? 'dark' : 'light'; } -/** The part of an image `background-size:cover` actually shows. +/** The part of an image `background-size:cover` shows. @param {number} iw @param {number} ih the image's natural size @param {number} vw @param {number} vh the box it covers @@ -58,8 +54,7 @@ export function coverSourceRect(iw, ih, vw, vh) { return { sx: (iw - sw) / 2, sy: (ih - sh) / 2, sw, sh }; } -/** Where `background-size:contain` puts the whole image, centred, with the - background colour showing on the two edges it does not reach. +/** Where `background-size:contain` puts the whole image. @param {number} iw @param {number} ih @param {number} vw @param {number} vh @returns {{dx:number, dy:number, dw:number, dh:number}} */ @@ -71,8 +66,7 @@ export function containDestRect(iw, ih, vw, vh) { return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh }; } -/** How the wallpaper is laid over the viewport, in the units the sampling - canvas uses. +/** How the wallpaper is laid over the viewport. @param {number} iw @param {number} ih @param {number} vw @param {number} vh @param {string} fit `'fit'` for contain, anything else for cover @@ -93,7 +87,7 @@ export function applyBrightness(c, amount) { return Math.max(0, Math.min(255, c * amount)); } -/** Per-cell luminance of an image already drawn at the grid's own size. +/** Per-cell luminance of an image drawn at the grid's own size. @param {ArrayLike} data RGBA, `cols * rows` pixels @param {number} cols @param {number} rows @@ -147,11 +141,9 @@ export function toneForRect(grid, cols, rows, vw, vh, rect) { export const GRID_COLS = 32; export const GRID_ROWS = 18; -/** Resolve any CSS colour to an sRGB triple. - - A canvas keeps its previous fillStyle when the value does not parse, so an - unreadable value has to be told apart from a real one. Two different seeds - cannot both be the answer. +/** Any CSS colour as an sRGB triple. A canvas keeps its previous fillStyle when + the value does not parse, so two seeds are needed to tell an unreadable value + from a real one. @param {string} value @returns {[number, number, number]|null} */ export function parseCssColor(value) { @@ -174,7 +166,7 @@ export function parseCssColor(value) { return a; } -/** Read an image into a luminance grid. +/** An image as a luminance grid. @param {HTMLImageElement} img a loaded image @param {number} vw @param {number} vh the viewport it covers @@ -190,8 +182,7 @@ export function sampleImage(img, vw, vh, brightness, fit = 'fill', baseColor = ' cv.height = GRID_ROWS; const ctx = cv.getContext('2d', { willReadFrequently: true }); if (!ctx) return null; - /* Fit leaves two edges showing the colour behind the image, and a label can - sit on them. */ + /* Fit leaves two edges showing the colour behind the image. */ ctx.fillStyle = baseColor; ctx.fillRect(0, 0, GRID_COLS, GRID_ROWS); const kx = GRID_COLS / vw, @@ -203,10 +194,9 @@ export function sampleImage(img, vw, vh, brightness, fit = 'fill', baseColor = ' } } -/** Load a second copy of the wallpaper that a canvas is allowed to read. - - Requesting the displayed image with CORS would fail the whole wallpaper on a - host that does not allow it. +/** A second copy of the wallpaper that a canvas may read. Do not request the + displayed image this way. A host that refuses CORS would fail the wallpaper + itself. @param {string} url @returns {Promise} */ export function loadSamplingImage(url) { @@ -219,16 +209,14 @@ export function loadSamplingImage(url) { }); } -/** Every label the dashboard draws over the background. */ const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label'; -/** A folder overlay lays its own dark scrim over the background, so the labels - on it are not reading the background at all. */ +/* A folder overlay lays its own scrim over the background. */ const SCRIM = '.folder-overlay, .folder-overlay-mobile'; /** Where a label sits once its page is the one on screen. Pages are one - viewport wide and slide horizontally, so an off-screen page's labels land on - the same patch of the fixed wallpaper as the page in view. + viewport wide, so an off-screen page's labels land on the same patch of the + fixed wallpaper as the page in view. @param {Element} label @param {Element|null} page @returns {{left:number, top:number, right:number, bottom:number}} */ @@ -239,11 +227,11 @@ function labelViewportRect(label, page) { return { left: r.left - p.left, top: r.top - p.top, right: r.right - p.left, bottom: r.bottom - p.top }; } -/** Tone every label against the current background. +/** Tones every label against the current background. - @param {{grid: number[]|null, tone: 'light'|'dark'|null}} bg the sampled - wallpaper, or a single tone for a solid colour - @param {Document|HTMLElement} [root] */ + @param {{grid: number[]|null, tone: 'light'|'dark'|null}} bg a sampled + wallpaper, or one tone for a solid colour + @param {Document|HTMLElement} [root] @returns {void} */ export function applyLabelTones(bg, root = document) { const vw = window.innerWidth, vh = window.innerHeight; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 1b318ea1..abe930a8 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -4,8 +4,8 @@ import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=7669ed7d'; -import { renderColorControl } from '/js/admin-color-control.js?v=4c3b8c98'; +import { wireChecklist } from '/js/admin-shared.js?v=d96fc091'; +import { renderColorControl } from '/js/admin-color-control.js?v=89eee5e8'; import { seedCarried, applyOptionSet, @@ -13,7 +13,7 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=8558624f'; +} from '/js/admin-logic.js?v=ddfc6f80'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; import { qi } from '/js/utils.js?v=b18c93ed';