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 @@
Uploaded and linked images are stored on this server and served from it.