Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
38 changes: 38 additions & 0 deletions api/src/image-sniff.js
Original file line number Diff line number Diff line change
@@ -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 };
5 changes: 4 additions & 1 deletion api/src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand Down
1 change: 1 addition & 0 deletions api/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ require('./routes/health');
require('./routes/badges');
require('./routes/system');
require('./routes/icons');
require('./routes/wallpaper');
require('./routes/version');
4 changes: 4 additions & 0 deletions api/src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
20 changes: 2 additions & 18 deletions api/src/routes/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
204 changes: 204 additions & 0 deletions api/src/routes/wallpaper.js
Original file line number Diff line number Diff line change
@@ -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<Buffer>} */
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 };
38 changes: 38 additions & 0 deletions api/test/nginx-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading