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
42 changes: 34 additions & 8 deletions docs/using-seerr/lumen.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,30 @@ and showing books at all. You can set the defaults, and lock any of them, with a
`lumen-config.json` file. The same file sets the sign-in page, the logo and the
browser tab icon.

The easiest way to write it: sign in to Lumen as an admin, open **You → Lumen
settings for everyone**, pick what you want, and press **Download
lumen-config.json**. The page starts from the file you already have. Lumen is
plain files with no database, so it can't save settings itself; you put the
downloaded file in place as below.
The easiest way: sign in to Lumen as an admin, open **You → Lumen settings for
everyone**, pick what you want, and press **Save for everyone**. The page starts
from the settings you already have. For the **Save** button, Lumen needs a folder
it may write to:

To set it up:
1. Next to `compose.yaml`, create the folder and give it to Lumen's user (101):

```sh
mkdir lumen-data && sudo chown 101:101 lumen-data
```

2. In `compose.yaml`, remove the `#` from `volumes:` and the `./lumen-data` line
under `lumen`, then run `docker compose up -d`.

Only SeerrNG admins can save; Lumen checks with SeerrNG before writing. Settings
saved this way win over a `lumen-config.json` mounted as below.

With that folder, the page also has **Upload** buttons for the logo and the tab
icon (PNG, JPEG, WebP, SVG or ICO, up to 2 MB). Lumen keeps the image in
`lumen-data/branding` and fills in its address, so there's no branding folder to
mount.

Without that folder, the page offers **Download lumen-config.json** instead, and
you put the file in place by hand:

1. Save this as `lumen-config.json` next to `compose.yaml`, and change what you like:

Expand All @@ -357,8 +374,10 @@ To set it up:
"branding": {
"name": "Movie Night",
"logo": "/branding/logo.png",
"icon": "/branding/icon.png"
"icon": "/branding/icon.png",
"logoShape": "rounded"
},
"mediaServerName": "Movie Night Server",
"defaults": {
"narratorChoice": true,
"hoverRequest": true,
Expand All @@ -382,14 +401,21 @@ To set it up:
`logo` replaces the round play mark, and `icon` is the browser tab and iOS
home-screen icon. Use files you mount into Lumen (step 2) or `https://`
addresses.
`logoShape` frames the logo: `"rounded"` (a rounded square, the default),
`"circle"`, or `"raw"` (the file as it is: transparent, uncropped, and as
wide as it is).
- `mediaServerName` replaces "Plex", "Jellyfin" or "Emby" in every label,
filter and the sign-in page, for example `"On Movie Night Server"`. Leave it
out to keep the real name.
- `requestOptions`: admins and request managers get a sheet to pick the
Radarr/Sonarr server, quality and folder when they use **Request** on a
poster. Set it to `false` for one-click requests for them too. Everyone else
always gets one-click requests.
- Put setting names in `locked` to stop users changing them, for example
`"locked": ["showBooks"]`.

2. In `compose.yaml`, remove the `#` from the two `volumes:` lines under `lumen`.
2. In `compose.yaml`, remove the `#` from `volumes:` and the `./lumen-config.json`
line under `lumen`.
For your own logo and icon, put the images in a `branding` folder next to
`compose.yaml` and also uncomment the `./branding` line.
3. Run `docker compose up -d`.
Expand Down
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 };
Loading