diff --git a/CHANGELOG.md b/CHANGELOG.md index 986cba32..8fc88711 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 @@ -26,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/image-sniff.js b/api/src/image-sniff.js new file mode 100644 index 00000000..f79a45c4 --- /dev/null +++ b/api/src/image-sniff.js @@ -0,0 +1,38 @@ +/* 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] }, + { 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'; +} + +/** @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; +} + +/** @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' }; + if (isoBrand(buf)) return { type: 'avif', ext: '.avif', mime: 'image/avif' }; + return null; +} + +module.exports = { sniffImageType }; 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.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/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/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..9bd2c309 --- /dev/null +++ b/api/src/routes/wallpaper.js @@ -0,0 +1,204 @@ +const crypto = require('crypto'); +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'); + +const WALLPAPER_DIR = () => path.join(ICONS_PATH, 'wallpaper'); +const WALLPAPER_URL_BASE = '/icons/wallpaper/'; + +/* 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; + +/* Keep proxy_read_timeout on /api/wallpaper/fetch above this. */ +const FETCH_TIMEOUT_MS = 30_000; + +const ACCEPTED = 'JPEG, PNG, WebP, AVIF or GIF'; +const TOO_LARGE = 'image too large (max 16 MB)'; + +/* 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)}-${crypto.randomBytes(4).toString('hex')}${ext}`; +} + +/** 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} */ +function storeWallpaper(data, ext) { + const dir = WALLPAPER_DIR(); + fs.mkdirSync(dir, { recursive: true }); + const saved = wallpaperName(ext); + fs.writeFileSync(path.join(dir, saved), data); + return WALLPAPER_URL_BASE + saved; +} + +/** @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 newest = files[files.length - 1]; + return files.filter(f => f !== newest); +} + +/** @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); +} + +/** 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 = storedByAge(dir); + } catch { + 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 (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 || {}; + 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 (!mayWrite(req, res)) return; + try { + 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 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: TOO_LARGE, kind: KIND.INVALID }); + const kind = sniffImageType(data); + 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 (!mayWrite(req, res)) return; + try { + const body = await readBodyCapped(req, LINK_BODY_MAX_BYTES); + let url = ''; + try { + url = String(JSON.parse(body.toString('utf8') || '{}').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, 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); + const kind = sniffImageType(data); + 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 }); + } +}); + +module.exports = { storeWallpaper, pruneWallpapers, wallpapersToDrop, WALLPAPER_URL_BASE }; 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 new file mode 100644 index 00000000..de7e15b2 --- /dev/null +++ b/api/test/wallpaper-store.test.js @@ -0,0 +1,232 @@ +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, 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]); +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('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'); + 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.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', () => { + 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]+-[a-f0-9]{8}\.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('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('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); + 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/e2e/wallpaper.spec.js b/e2e/wallpaper.spec.js new file mode 100644 index 00000000..dff948b2 --- /dev/null +++ b/e2e/wallpaper.spec.js @@ -0,0 +1,72 @@ +// @ts-check +/* 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'); + +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. A click before + that lands is overwritten. */ +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('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 tiles are built. Wait for the value. */ + 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'); +}); 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 f2150179..8c059541 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -10,8 +10,8 @@ Stackyard Settings - - + + @@ -164,7 +164,24 @@
Collection IDCollection ID
+
+ Image + No image + + +
Image URLImage URL
+
+ Fit +
+ + +
+ +
Color#rrggbb or any CSS color
@@ -179,6 +196,7 @@

Leave Collection ID blank for random.

+

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

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

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index 70c490f3..88b8d8fa 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)} +/* 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 reaches 8.13 and 10.33. The border keeps the accent at full strength for the 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..2c1dada9 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,10 @@ "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}", + "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 07163c91..13aa2e0e 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,10 @@ "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}", + "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 59cda46b..7a7dd60b 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,10 @@ "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}", + "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 031ddb92..ddc47a0f 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,10 @@ "socketUrlBad": "پراکسی سوکت ذخیره نشد: {reason}", "socketUrlUnverified": "ذخیره شد، اما پراکسی سوکت پاسخ نداد: {reason}", "socketHintSharedNetwork": "این نام را داکر تنها برای کانتینرهایی که در یک شبکه مشترک هستند پاسخ می‌دهد. هر دو را در یک شبکه بگذارید یا از نشانی‌ای استفاده کنید که پراکسی منتشر می‌کند.", - "socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید." + "socketHintPublishPort": "هیچ پاسخی از آن نشانی نیامد. پراکسی سوکتی که روی لوپ‌بک میزبان منتشر شده از درون کانتینر در دسترس نیست؛ آن را روی نشانی پل داکر منتشر کنید یا هر دو کانتینر را در یک شبکه مشترک بگذارید و از نام پراکسی استفاده کنید.", + "wallpaperStored": "تصویر زمینه روی این سرور ذخیره شد.", + "wallpaperFailed": "تصویر زمینه ناموفق بود: {err}", + "imageTooLarge": "این تصویر برای سرور بیش از حد بزرگ است." }, "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index e5ac48f7..a9f93454 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,10 @@ "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}", + "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 88ccda30..02ad2c6d 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,10 @@ "socketUrlBad": "未保存套接字代理:{reason}", "socketUrlUnverified": "已保存,但套接字代理没有响应:{reason}", "socketHintSharedNetwork": "该名称由 Docker 解析,且只对处于同一网络的容器生效。请将两者放入同一网络,或改用代理已发布的地址。", - "socketHintPublishPort": "该地址没有任何响应。发布在主机回环上的套接字代理无法从容器内访问;请将其发布到 Docker 网桥地址,或把两个容器放入同一网络并使用代理名称。" + "socketHintPublishPort": "该地址没有任何响应。发布在主机回环上的套接字代理无法从容器内访问;请将其发布到 Docker 网桥地址,或把两个容器放入同一网络并使用代理名称。", + "wallpaperStored": "壁纸已保存到本服务器。", + "wallpaperFailed": "壁纸操作失败:{err}", + "imageTooLarge": "该图片过大,服务器无法接受。" }, "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", diff --git a/ui/index.html b/ui/index.html index 412fc00d..32675d1e 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-app-form.js b/ui/js/admin-app-form.js index 21c848e4..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=f3f87abf'; +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=3d2627a9'; -import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=5fb6a01b'; +} 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 1597716f..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=3d2627a9'; +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 cf242a88..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=3d2627a9'; +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 6f8df4c6..34b7f8c7 100644 --- a/ui/js/admin-logic.js +++ b/ui/js/admin-logic.js @@ -261,3 +261,18 @@ export function toastMs(message) { const len = typeof message === 'string' ? message.length : 0; return Math.min(15000, Math.max(3000, len * 60)); } + +/** 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 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 + @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 0546d40d..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=3d2627a9'; +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=f3f87abf'; +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. */ @@ -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-shared.js b/ui/js/admin-shared.js index ab025c53..607beb5f 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,17 +1,38 @@ /* 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=ddfc6f80'; 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; + +/** @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; + 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..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=3d2627a9'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c8fdc715'; +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=f3f87abf'; +} 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 bb874faa..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=fde6479c'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=76be763e'; +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=f3f87abf'; +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 } from '/js/admin-settings.js?v=e9bec962'; -import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=3d2627a9'; +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=bb1a94d7'; +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=5fb6a01b'; +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 @@ -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; } @@ -1081,7 +1084,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 +1172,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; @@ -1173,6 +1181,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 => { @@ -1189,6 +1199,102 @@ 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'); +} + +/** A body that is not JSON is the web server answering on its own. + + @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}`; +} + +/** @param {string} url an image this server holds @returns {void} */ +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 }); + if (!r.ok) throw new Error(await responseError(r)); + const d = await r.json(); + 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(); +} + +/** 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) { + 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 }), + }); + if (!r.ok) throw new Error(await responseError(r)); + const d = await r.json(); + setWallpaperUrl(d.url); + toast(t('toast.wallpaperStored')); + } catch (e) { + /* A link that failed must not replace the wallpaper already saved. */ + setWallpaperUrl(state._settings?.background?.url || ''); + toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); + } +} + function initLogLevel() { const btn = el('log-level-btn'); const list = el('log-level-list'); @@ -1533,6 +1639,8 @@ initAllInlineEdits(); initSecToggle(); initDockerToggle(); initBgType(); +initBgFit(); +initWallpaperUpload(); initLogLevel(); initLanguage(); initTheme(); diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index a764a070..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=322fb66e'; +} from '/js/label-contrast.js?v=69cbdcb0'; /* Recomputed, never stored: the window can cross the breakpoint after load. */ let MOB = isMobileLayout(); @@ -445,11 +445,14 @@ function syncMobPages() { } } -/** 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. */ +/* Matches --bg-base in tokens.css. It shows wherever a fitted wallpaper does + not reach. */ +const WALLPAPER_BACKDROP = '#0d1117'; + +/* 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)); } @@ -460,23 +463,28 @@ 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) { +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 - it every label sits on. */ +/* The wallpaper is sized against the viewport, so a resize moves which part of + it each 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 +499,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 +525,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..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,6 +54,32 @@ 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. + + @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. + + @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} */ @@ -65,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 @@ -119,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) { @@ -146,31 +166,37 @@ 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 @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. */ + 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; } } -/** 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) { @@ -183,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}} */ @@ -203,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 c9adea11..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=3d2627a9'; -import { renderColorControl } from '/js/admin-color-control.js?v=5fb6a01b'; +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=f3f87abf'; +} 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'; 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); 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')); +});