Skip to content
Closed
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
6 changes: 6 additions & 0 deletions lumen/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ COPY deploy/05-lumen-env-aliases.envsh /docker-entrypoint.d/05-lumen-env-aliases
COPY deploy/15-lumen-trusted-proxies.sh /docker-entrypoint.d/15-lumen-trusted-proxies.sh
RUN chmod 755 /docker-entrypoint.d/05-lumen-env-aliases.envsh /docker-entrypoint.d/15-lumen-trusted-proxies.sh
COPY --from=build /app/dist /usr/share/nginx/html
# "Lumen settings for everyone" are saved by a small nginx script (njs, shipped with this
# image) into /lumen-data: a folder the installer mounts, writable by user 101 (nginx). It is
# deliberately not created here, so settings are never saved inside a container that an
# update would replace. Without it the admin page offers lumen-config.json as a download.
COPY deploy/lumen-config.js /etc/nginx/lumen/lumen-config.js
RUN sed -i '1i load_module modules/ngx_http_js_module.so;' /etc/nginx/nginx.conf
# The entrypoint renders only proxy configuration. Keep the static app read-only
# while allowing the unprivileged master to write its config, cache and PID.
RUN sed -i 's@/run/nginx.pid@/tmp/nginx.pid@' /etc/nginx/nginx.conf && \
Expand Down
9 changes: 7 additions & 2 deletions lumen/compose.lumen-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ services:
- ALL
security_opt:
- no-new-privileges:true
# Optional admin defaults for every user (see lumen/public/lumen-config.example.json).
# Create the file first, then remove the # from the two lines below.
# Optional admin defaults for every user (You > Lumen settings for everyone).
# To save them from that page, create a folder Lumen may write to:
# mkdir lumen-data && sudo chown 101:101 lumen-data
# then remove the # from "volumes:" and the lumen-data line below.
# Or write lumen-config.json yourself (see lumen/public/lumen-config.example.json),
# create it first, and uncomment the lumen-config.json line instead.
# volumes:
# - ./lumen-data:/lumen-data
# - ./lumen-config.json:/usr/share/nginx/html/lumen-config.json:ro
# - ./branding:/usr/share/nginx/html/branding:ro # optional logo / tab icon
restart: unless-stopped
9 changes: 7 additions & 2 deletions lumen/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ services:
- ALL
security_opt:
- no-new-privileges:true
# Optional admin defaults for every user (see lumen/public/lumen-config.example.json).
# Create the file first, then remove the # from the two lines below.
# Optional admin defaults for every user (You > Lumen settings for everyone).
# To save them from that page, create a folder Lumen may write to:
# mkdir lumen-data && sudo chown 101:101 lumen-data
# then remove the # from "volumes:" and the lumen-data line below.
# Or write lumen-config.json yourself (see lumen/public/lumen-config.example.json),
# create it first, and uncomment the lumen-config.json line instead.
# volumes:
# - ./lumen-data:/lumen-data
# - ./lumen-config.json:/usr/share/nginx/html/lumen-config.json:ro
# - ./branding:/usr/share/nginx/html/branding:ro # optional logo / tab icon
restart: unless-stopped
206 changes: 206 additions & 0 deletions lumen/deploy/lumen-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Saves "Lumen settings for everyone" from the admin page, so nobody has to download
// lumen-config.json and copy it onto the server by hand. Runs inside nginx (njs).
//
// The file goes in /lumen-data, a folder the installer mounts and lets the container write.
// Without that folder the admin page keeps offering the download instead.
//
// Only a SeerrNG admin may save: the person's own SeerrNG session is checked through
// /_lumen_session_check (SeerrNG's /auth/me) before anything is written.
//
// Admins can also upload the logo and tab icon (branding below). Each upload gets a new file
// name in /lumen-data/branding, served at /branding/, so no browser keeps an old copy.
import fs from 'fs';

const DIR = '/lumen-data';
const FILE = DIR + '/lumen-config.json';
const ADMIN = 2; // SeerrNG's Admin permission bit
const MAX_BYTES = 64 * 1024;

// 'ok', 'missing' (no folder mounted) or 'readonly' (mounted, but this container can't write)
function folder() {
try {
fs.accessSync(DIR, fs.constants.F_OK);
} catch (e) {
return 'missing';
}
try {
fs.accessSync(DIR, fs.constants.W_OK);
return 'ok';
} catch (e) {
return 'readonly';
}
}

function send(r, status, body) {
r.headersOut['Content-Type'] = 'application/json';
r.headersOut['Cache-Control'] = 'no-store';
r.return(status, JSON.stringify(body));
}

// A page on another site can't send JSON here without asking first (CORS preflight), which
// nothing here answers, so it can't save with an admin's cookie. (No Origin check: reverse
// proxies often rewrite Host.)
function sameSite(r) {
const type = (r.headersIn['Content-Type'] || '').toLowerCase();
return type.split(';')[0].trim() === 'application/json';
}

// Is the person sending this a SeerrNG admin? Answers the request itself when not.
async function isAdmin(r) {
const me = await r.subrequest('/_lumen_session_check', { method: 'GET' });
if (me.status !== 200) {
send(r, 401, { message: 'Sign in again.' });
return false;
}
let user;
try {
user = JSON.parse(me.responseText);
} catch (e) {
send(r, 502, { message: 'SeerrNG didn’t say who you are.' });
return false;
}
if (!user || ((user.permissions || 0) & ADMIN) === 0) {
send(r, 403, { message: 'Only admins can change settings for everyone.' });
return false;
}
return true;
}

async function save(r) {
if (r.method === 'GET') {
const f = folder();
send(r, 200, { canSave: f === 'ok', folder: f });
return;
}
if (r.method !== 'PUT') {
send(r, 405, { message: 'Use PUT.' });
return;
}
if (!sameSite(r)) {
send(r, 403, { message: 'Send the settings as JSON from Lumen.' });
return;
}
if (!(await isAdmin(r))) return;
const text = r.requestText || '';
if (!text || text.length > MAX_BYTES) {
send(r, 413, { message: 'The settings are empty or too large.' });
return;
}
let cfg;
try {
cfg = JSON.parse(text);
} catch (e) {
send(r, 400, { message: 'The settings aren’t valid JSON.' });
return;
}
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
send(r, 400, { message: 'The settings must be a JSON object.' });
return;
}
if (folder() !== 'ok') {
send(r, 409, {
message:
'Lumen has no writable /lumen-data folder. Download the file instead.',
});
return;
}
try {
// write a copy first, then swap it in, so a reader never sees half a file
const tmp = FILE + '.saving';
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
fs.renameSync(tmp, FILE);
} catch (e) {
send(r, 500, { message: 'Couldn’t write lumen-config.json: ' + e.message });
return;
}
send(r, 200, { saved: true });
}

// ---- logo and tab icon uploads: PUT /lumen/branding/logo or /lumen/branding/icon ----
const BRANDING = DIR + '/branding';
const MAX_IMAGE = 2 * 1024 * 1024;
// what the file really is, from its first bytes (the browser's Content-Type is only a hint)
function imageType(b) {
if (
b.length > 8 &&
b[0] === 0x89 &&
b[1] === 0x50 &&
b[2] === 0x4e &&
b[3] === 0x47
)
return 'png';
if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
return 'jpg';
// (njs has no spread syntax: compare byte by byte)
const at = (i, text) => {
for (let k = 0; k < text.length; k++)
if (b[i + k] !== text.charCodeAt(k)) return false;
return true;
};
if (b.length > 12 && at(0, 'RIFF') && at(8, 'WEBP')) return 'webp';
if (b.length > 4 && b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0)
return 'ico';
const head = b.toString('utf8', 0, Math.min(b.length, 1024)).trimStart();
if (
(head.startsWith('<?xml') || head.startsWith('<svg')) &&
head.includes('<svg')
)
return 'svg';
return null;
}

async function branding(r) {
const kind = r.uri.split('/').pop();
if (kind !== 'logo' && kind !== 'icon') {
send(r, 404, { message: 'Upload a logo or an icon.' });
return;
}
if (r.method !== 'PUT') {
send(r, 405, { message: 'Use PUT.' });
return;
}
if (!(await isAdmin(r))) return;
if (folder() !== 'ok') {
send(r, 409, {
message:
'Lumen has no writable /lumen-data folder, so it can’t keep uploads.',
});
return;
}
const body = r.requestBuffer;
if (!body || !body.length || body.length > MAX_IMAGE) {
send(r, 413, { message: 'Choose an image of up to 2 MB.' });
return;
}
const ext = imageType(body);
if (!ext) {
send(r, 415, { message: 'Use a PNG, JPEG, WebP, SVG or ICO image.' });
return;
}
const name = kind + '-' + Date.now() + '.' + ext;
try {
try {
fs.mkdirSync(BRANDING);
} catch (e) {
// already there
}
const tmp = BRANDING + '/.' + name + '.saving';
fs.writeFileSync(tmp, body);
fs.renameSync(tmp, BRANDING + '/' + name);
// earlier uploads of the same kind are no longer used
fs.readdirSync(BRANDING).forEach(function (f) {
if (f !== name && f.startsWith(kind + '-'))
try {
fs.unlinkSync(BRANDING + '/' + f);
} catch (e) {
// still fine: it's only an unused file
}
});
} catch (e) {
send(r, 500, { message: 'Couldn’t keep the image: ' + e.message });
return;
}
send(r, 200, { url: '/branding/' + name });
}

export default { save, branding };
58 changes: 58 additions & 0 deletions lumen/deploy/nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ map "$lumen_via_proxy:$http_x_forwarded_proto" $lumen_forwarded_proto {

# The same protections full SeerrNG sends with its pages: no framing (clickjacking), scripts only
# from this origin. Outside hosts: Google Fonts, Plex sign-in, and cover art from many CDNs.
# saves "Lumen settings for everyone" from the admin page (see lumen-config.js)
js_import lumen from /etc/nginx/lumen/lumen-config.js;

map "" $lumen_csp {
default "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self' https://plex.tv https://*.plex.tv; manifest-src 'self'; worker-src 'self'; frame-src 'none'";
}
Expand Down Expand Up @@ -58,6 +61,8 @@ server {
internal;
proxy_pass ${SEERR_URL}/api/v1/auth/me;
proxy_method GET;
# lumen-config.js reads the answer (who is signed in), not just the status
subrequest_output_buffer_size 64k;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
Expand All @@ -80,6 +85,59 @@ server {
proxy_set_header X-Forwarded-Proto $lumen_forwarded_proto;
}

# "Lumen settings for everyone": the admin page saves them here (PUT, SeerrNG admins only)
# when /lumen-data is mounted and writable; GET says whether it is
location = /lumen/config {
client_max_body_size 64k;
client_body_buffer_size 64k;
js_content lumen.save;
}
# logo and tab icon uploaded from the admin page (PUT, SeerrNG admins only)
location ^~ /lumen/branding/ {
client_max_body_size 2m;
client_body_buffer_size 2m;
js_content lumen.branding;
}
# /branding/: uploads first, then a branding folder mounted into the app. Images only, so
# nothing here may run scripts (an SVG opened on its own included).
location /branding/ {
add_header Cache-Control "no-cache";
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox";
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy same-origin;
root /lumen-data;
try_files $uri @branding_mounted;
}
location @branding_mounted {
add_header Cache-Control "no-cache";
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox";
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy same-origin;
root /usr/share/nginx/html;
try_files $uri =404;
}
# settings saved from the admin page win over a lumen-config.json mounted into the app
location = /lumen-config.json {
add_header Cache-Control "no-cache";
add_header Content-Security-Policy $lumen_csp;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy same-origin;
root /lumen-data;
try_files /lumen-config.json @lumen_config_mounted;
}
location @lumen_config_mounted {
add_header Cache-Control "no-cache";
add_header Content-Security-Policy $lumen_csp;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy same-origin;
root /usr/share/nginx/html;
try_files /lumen-config.json =404;
}

# "open full SeerrNG" links when lumen-config.json sets no seerrPublicUrl
location = /seerrng {
return 302 ${SEERR_FULL_URL}/;
Expand Down
5 changes: 5 additions & 0 deletions lumen/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Thin client for the SeerrNG API. Everything goes through the same origin (the Lumen
// container or the Vite dev proxy), so SeerrNG's session cookie does the auth.
import { markAllStale } from './apiCache';

export class ApiError extends Error {
constructor(
Expand Down Expand Up @@ -29,6 +30,8 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
}
throw new ApiError(res.status, msg);
}
// a request or other change: saved answers may now be out of date
if (init.method && init.method !== 'GET') markAllStale();
return res.status === 204 ? (undefined as T) : res.json();
}

Expand Down Expand Up @@ -127,6 +130,7 @@ export interface RequestOptions {
serverId?: number;
profileId?: number;
rootFolder?: string;
userId?: number; // request on someone else's behalf (admins)
}
const post = (body: object) =>
api<{ id: number }>('/request', {
Expand Down Expand Up @@ -245,6 +249,7 @@ export async function requestAnotherNarration(
const body = await res.json().catch(() => ({}));
if (!res.ok)
throw new ApiError(res.status, body.error || 'The request failed.');
markAllStale();
return body as {
ok: true;
bookId: number;
Expand Down
Loading
Loading