From edcd42faa2c4a13e54e34f4b651afef3cd2f2f4b Mon Sep 17 00:00:00 2001 From: Ncbudd <209378361+ncbudd@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:48:29 -0500 Subject: [PATCH 1/7] perf(lumen): keep recent answers instead of asking SeerrNG every time Lumen asked SeerrNG again for everything on every visit: going back to Discover repeated all its calls, including one detail lookup per title in "New on ". Besides being slow (SeerrNG's first lookup of a title can take seconds), every call counts against SeerrNG's per-account rate limit, so quick browsing could hit 429s. - apiCache.ts: answers younger than 3 minutes are reused without asking; older ones (up to a day) show at once while a new answer loads. Saved per account in localStorage so a reopened app starts from what it last showed; signing out or switching account drops it. - Any request or other change (POST/PUT/DELETE, another narrator) marks everything stale, so nothing keeps showing a title as not requested. Requests, quota, sign-in and public settings are always re-checked. - useApi, the Discover rows, "New on " (the finished row is saved too) and list pages use it. A failed refresh keeps the saved answer up. - Books, Audiobooks, Movies and Series show placeholder posters while the first page loads, so a slow answer doesn't look like an empty page. Checked in the SeerrNG Android app (emulator, Android 16): Movies and Back, and a full close and reopen, ask SeerrNG for none of Discover's rows again; Back still behaves. --- lumen/src/api.ts | 4 + lumen/src/apiCache.ts | 105 ++++++++++++++++++++++ lumen/src/components/cards.tsx | 20 +++++ lumen/src/hooks.ts | 47 ++++++---- lumen/src/pages/Books.tsx | 7 +- lumen/src/pages/Browse.tsx | 3 +- lumen/src/pages/Discover.tsx | 50 +++++------ lumen/tests/browser/admin-options.spec.ts | 55 ++++++++++++ 8 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 lumen/src/apiCache.ts diff --git a/lumen/src/api.ts b/lumen/src/api.ts index d915b878a..6aa5fc2d5 100644 --- a/lumen/src/api.ts +++ b/lumen/src/api.ts @@ -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( @@ -29,6 +30,8 @@ export async function api(path: string, init: RequestInit = {}): Promise { } 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(); } @@ -245,6 +248,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; diff --git a/lumen/src/apiCache.ts b/lumen/src/apiCache.ts new file mode 100644 index 000000000..dfa8252a2 --- /dev/null +++ b/lumen/src/apiCache.ts @@ -0,0 +1,105 @@ +// Answers from SeerrNG, kept so going back to a page or reopening Lumen doesn't ask for +// everything again. Every call counts against SeerrNG's per-account rate limit, and its +// first lookup of a title can take seconds. +// +// - Fresh (under FRESH_MS): used as is, nothing is asked. +// - Older (up to KEEP_MS): shown at once while a new answer loads in the background. +// - Saved per account in localStorage, so a reopened app starts from what it last showed. +// Signing out or switching account drops it. Requests and other changes mark everything +// stale, so nothing keeps showing a title as not requested. +import { onSessionReset, sessionUserId } from './sessionState'; + +export const FRESH_MS = 3 * 60_000; +const KEEP_MS = 24 * 60 * 60_000; +const MAX_SAVED = 150; +// these change as people use the site: always asked again (still shown at once meanwhile) +const ALWAYS_CHECK = /^\/(request|auth|user\/\d+\/quota|settings\/public)/; + +type Entry = { at: number; data: unknown }; +let entries = new Map(); +let loadedFor: number | null | undefined; +const storeKey = (id: number) => `lumen-cache:${id}`; + +function ensureLoaded() { + const id = sessionUserId(); + if (loadedFor === id) return; + loadedFor = id; + entries = new Map(); + if (id == null) return; + try { + const saved = JSON.parse(localStorage.getItem(storeKey(id)) || '[]'); + const now = Date.now(); + if (Array.isArray(saved)) + for (const item of saved) { + if (!Array.isArray(item) || typeof item[0] !== 'string') continue; + const e = item[1] as Entry | undefined; + if (e && typeof e.at === 'number' && now - e.at < KEEP_MS) + entries.set(item[0], e); + } + } catch { + // unreadable or blocked storage: start empty + } +} + +let saveTimer: ReturnType | undefined; +function save() { + const id = sessionUserId(); + if (id == null || loadedFor !== id) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + if (sessionUserId() !== id) return; + try { + const newest = [...entries] + .sort((a, b) => b[1].at - a[1].at) + .slice(0, MAX_SAVED); + localStorage.setItem(storeKey(id), JSON.stringify(newest)); + } catch { + // full or blocked storage: keep it in memory only + } + }, 400); +} + +export function cached(path: string): T | undefined { + ensureLoaded(); + return entries.get(path)?.data as T | undefined; +} + +export function isFresh(path: string) { + ensureLoaded(); + const e = entries.get(path); + return !!e && !ALWAYS_CHECK.test(path) && Date.now() - e.at < FRESH_MS; +} + +export function remember(path: string, data: unknown) { + ensureLoaded(); + entries.set(path, { at: Date.now(), data }); + save(); +} + +export function forget(prefix: string) { + ensureLoaded(); + for (const k of [...entries.keys()]) + if (k.startsWith(prefix)) entries.delete(k); + save(); +} + +// after a request or other change: keep showing what's there, but ask again next time +export function markAllStale() { + ensureLoaded(); + const stale = Date.now() - FRESH_MS - 1; + for (const e of entries.values()) e.at = Math.min(e.at, stale); + save(); +} + +onSessionReset(() => { + const previous = loadedFor; + if (previous != null && previous !== sessionUserId()) + try { + localStorage.removeItem(storeKey(previous)); + } catch { + // blocked storage: nothing was saved + } + clearTimeout(saveTimer); + entries = new Map(); + loadedFor = undefined; +}); diff --git a/lumen/src/components/cards.tsx b/lumen/src/components/cards.tsx index 228f03e85..39375ecdd 100644 --- a/lumen/src/components/cards.tsx +++ b/lumen/src/components/cards.tsx @@ -548,6 +548,26 @@ export const RailSkeleton = ({ n = 8, sq }: { n?: number; sq?: boolean }) => ( ); +// placeholder grid while a list's first page loads, so a slow answer doesn't look empty +export const GridSkeleton = ({ + n = 12, + sq, + className = 'grid', +}: { + n?: number; + sq?: boolean; + className?: string; +}) => ( +
+ {Array.from({ length: n }, (_, i) => ( +
+
+
+
+ ))} +
+); + // endless scrolling: loads the next page when this marker comes within a screen of view export function MoreOnScroll({ done, diff --git a/lumen/src/hooks.ts b/lumen/src/hooks.ts index a609b2c52..04f9faa76 100644 --- a/lumen/src/hooks.ts +++ b/lumen/src/hooks.ts @@ -1,14 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api, hardcoverWorkId, hcCovers } from './api'; +import { cached, forget, isFresh, remember } from './apiCache'; import { onSessionReset, sessionGeneration } from './sessionState'; -// small response cache so going back to a page doesn't refetch everything -const cache = new Map(); - +// A saved answer shows at once; a fresh one isn't asked for again (see apiCache.ts). export function useApi(path: string | null) { const [data, setData] = useState( - path ? ((cache.get(path) as T) ?? null) : null + path ? (cached(path) ?? null) : null ); const [error, setError] = useState(null); const [tick, setTick] = useState(0); @@ -17,32 +16,41 @@ export function useApi(path: string | null) { let live = true; const generation = sessionGeneration(); setError(null); - if (cache.has(path)) setData(cache.get(path) as T); - else setData(null); + const saved = cached(path); + setData(saved ?? null); + if (saved !== undefined && isFresh(path)) return; api(path) .then((d) => { if (generation !== sessionGeneration()) return; - cache.set(path, d); + remember(path, d); if (live) setData(d); }) - .catch( - (e) => - live && setError(e instanceof Error ? e.message : 'Failed to load') - ); + .catch((e) => { + // a saved answer stays up if refreshing it fails + if (live && saved === undefined) + setError(e instanceof Error ? e.message : 'Failed to load'); + }); return () => { live = false; }; }, [path, tick]); const reload = useCallback(() => { - if (path) cache.delete(path); + if (path) forget(path); setTick((t) => t + 1); }, [path]); return { data, error, reload }; } -export const dropCache = (prefix: string) => { - for (const k of cache.keys()) if (k.startsWith(prefix)) cache.delete(k); -}; +// one answer, from the saved copy while it's fresh +export async function cachedApi(path: string): Promise { + if (isFresh(path)) return cached(path) as T; + const generation = sessionGeneration(); + const d = await api(path); + if (generation === sessionGeneration()) remember(path, d); + return d; +} + +export const dropCache = forget; export interface PublicSettings { applicationTitle: string; @@ -111,9 +119,11 @@ export function usePaged(base: string) { setLoading(true); try { const sep = base.includes('?') ? '&' : '?'; - const d = await api<{ page: number; totalPages: number; results: T[] }>( - `${base}${sep}page=${page + 1}` - ); + const d = await cachedApi<{ + page: number; + totalPages: number; + results: T[]; + }>(`${base}${sep}page=${page + 1}`); if (!ownsRequest()) return; // an old filter must not replace the current list // popular lists shift between pages, so a title can come back; show it once const idOf = (x: T) => String((x as { id?: unknown }).id); @@ -298,7 +308,6 @@ export const CAN_HOVER = // In-flight responses from an earlier principal cannot refill the new cache. onSessionReset(() => { - cache.clear(); pagedSnap.clear(); bookStates = null; bookStatesAt = 0; diff --git a/lumen/src/pages/Books.tsx b/lumen/src/pages/Books.tsx index 7b6069511..de8a2532a 100644 --- a/lumen/src/pages/Books.tsx +++ b/lumen/src/pages/Books.tsx @@ -1,6 +1,6 @@ import { Link, useSearchParams } from 'react-router-dom'; import { bookMediaForFormat, MediaStatus, type BookResult } from '../api'; -import { BookCard, MoreOnScroll } from '../components/cards'; +import { BookCard, GridSkeleton, MoreOnScroll } from '../components/cards'; import { Avatar } from '../components/Shell'; import { useEbookCovers, usePaged } from '../hooks'; import { Icon } from '../icons'; @@ -103,6 +103,11 @@ export default function Books({ format }: { format: 'ebook' | 'audiobook' }) { : 'No books match this view.'}
)} + {list.loading && !items.length && list.page === 0 && ( +
+ +
+ )}
{items.map((b) => ( diff --git a/lumen/src/pages/Browse.tsx b/lumen/src/pages/Browse.tsx index 0b86a7bb5..65dae6c9f 100644 --- a/lumen/src/pages/Browse.tsx +++ b/lumen/src/pages/Browse.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { MediaStatus, type TitleResult } from '../api'; -import { MoreOnScroll, TitleCard } from '../components/cards'; +import { GridSkeleton, MoreOnScroll, TitleCard } from '../components/cards'; import { useAsksRequestOptions } from '../components/requestSheet'; import { Avatar } from '../components/Shell'; import { CAN_HOVER, useApi, usePaged } from '../hooks'; @@ -193,6 +193,7 @@ export default function Browse({ kind }: { kind: 'movie' | 'tv' }) { Nothing hereTry another genre, or switch to All.
)} + {list.loading && !items.length && list.page === 0 && }
{items.map((x) => ( diff --git a/lumen/src/pages/Discover.tsx b/lumen/src/pages/Discover.tsx index d320e1ec2..a88caa79e 100644 --- a/lumen/src/pages/Discover.tsx +++ b/lumen/src/pages/Discover.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { - api, tmdbImg, type BookResult, type Genre, @@ -21,27 +20,14 @@ import { } from '../components/cards'; import { Avatar } from '../components/Shell'; import { useExtras } from '../extras'; -import { useEbookCovers } from '../hooks'; +import { cached, isFresh, remember } from '../apiCache'; +import { cachedApi, useApi, useEbookCovers } from '../hooks'; +import { sessionGeneration } from '../sessionState'; import { Icon, type IconName } from '../icons'; import { usePrefs } from '../settings'; -// rows that are switched off (or have no service behind them) don't fetch anything: -// every call counts against SeerrNG's per-account rate limit -function useApi(path: string | null) { - const [data, setData] = useState(null); - const [error, setError] = useState(false); - useEffect(() => { - if (!path) return; - let live = true; - api(path) - .then((d) => live && setData(d)) - .catch(() => live && setError(true)); - return () => { - live = false; - }; - }, [path]); - return { data, error }; -} +// Rows that are switched off (or have no service behind them) don't fetch anything, and +// rows use saved answers (useApi): every call counts against SeerrNG's per-account rate limit. // Recently added media: SeerrNG lists recently added media by id, so look up each title's details // SeerrNG only lists recently added media for admins, request managers and people with @@ -49,15 +35,22 @@ function useApi(path: string | null) { const RECENT = 2 | 16 | 67108864; // ADMIN | MANAGE_REQUESTS | RECENT_VIEW export const canSeeRecent = (permissions?: number) => ((permissions ?? 0) & RECENT) !== 0; +type Fresh = (TitleResult & { added?: string })[]; +// the finished row is saved too, so coming back to Discover doesn't look up 14 titles again +const NEW_ROW = '/lumen/new-on-server'; function useNewOnPlex(enabled: boolean) { - const [items, setItems] = useState< - (TitleResult & { added?: string })[] | null - >(null); + const [items, setItems] = useState( + () => (enabled && cached(NEW_ROW)) || null + ); useEffect(() => { if (!enabled) return; + const saved = cached(NEW_ROW); + if (saved) setItems(saved); + if (saved && isFresh(NEW_ROW)) return; let live = true; + const generation = sessionGeneration(); (async () => { - const page = await api<{ results: MediaRow[] }>( + const page = await cachedApi<{ results: MediaRow[] }>( '/media?filter=allavailable&take=30&sort=mediaAdded' ); const rows = page.results @@ -67,7 +60,7 @@ function useNewOnPlex(enabled: boolean) { .slice(0, 14); const details = await Promise.all( rows.map((m) => - api(`/${m.mediaType}/${m.tmdbId}`) + cachedApi(`/${m.mediaType}/${m.tmdbId}`) .then((d) => ({ ...d, mediaType: m.mediaType as 'movie' | 'tv', @@ -76,11 +69,10 @@ function useNewOnPlex(enabled: boolean) { .catch(() => null) ) ); - if (live) - setItems( - details.filter(Boolean) as (TitleResult & { added?: string })[] - ); - })().catch(() => live && setItems([])); + const row = details.filter(Boolean) as Fresh; + if (generation === sessionGeneration()) remember(NEW_ROW, row); + if (live) setItems(row); + })().catch(() => live && !saved && setItems([])); return () => { live = false; }; diff --git a/lumen/tests/browser/admin-options.spec.ts b/lumen/tests/browser/admin-options.spec.ts index ae97fb5b3..afe002bf4 100644 --- a/lumen/tests/browser/admin-options.spec.ts +++ b/lumen/tests/browser/admin-options.spec.ts @@ -966,3 +966,58 @@ test('a stale game page cannot overwrite loading or errors after a new search', page.getByRole('button', { name: 'Retry', exact: true }) ).toHaveCount(0); }); + +test('Discover reuses saved rows when reopened, and asks again after a request', async ({ + page, +}) => { + const { posts } = await fixtures(page, { permissions: 32 }); + const rowCalls = () => + asked.filter( + (u) => + new URL(u).pathname === '/api/v1/discover/movies' && !new URL(u).search + ).length; + const asked: string[] = []; + page.on('request', (r) => asked.push(r.url())); + await page.goto('/'); + await expect( + page.getByRole('link', { name: 'Fresh Pick' }).first() + ).toBeVisible(); + expect(rowCalls()).toBe(1); + await page.waitForTimeout(700); // saved after a short pause + // reopened (new page load): rows show from the saved copy, nothing asked again + await page.goto('/'); + await expect( + page.getByRole('link', { name: 'Fresh Pick' }).first() + ).toBeVisible(); + await page.waitForTimeout(500); + expect(rowCalls()).toBe(1); + // a request marks saved answers stale: the next visit asks again + await page.goto('/movies'); + await quickRequest(page, 'Fresh Pick'); + await expect + .poll(() => posts.filter((p) => p.path === '/request').length) + .toBe(1); + await page.waitForTimeout(700); + await page.goto('/'); + await expect( + page.getByRole('link', { name: 'Fresh Pick' }).first() + ).toBeVisible(); + await expect.poll(rowCalls).toBe(2); +}); + +test('signing out drops the saved answers', async ({ page }) => { + await fixtures(page); + await page.goto('/'); + await expect( + page.getByRole('link', { name: 'Fresh Pick' }).first() + ).toBeVisible(); + await page.waitForTimeout(700); + expect( + await page.evaluate(() => !!localStorage.getItem('lumen-cache:1')) + ).toBe(true); + await page.goto('/me'); + await page.getByRole('button', { name: /Sign out/ }).click(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('lumen-cache:1'))) + .toBeNull(); +}); From d1d437cad320dc167f631eb85bd0cc4e3e82b1dc Mon Sep 17 00:00:00 2001 From: Ncbudd <209378361+ncbudd@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:52:49 -0500 Subject: [PATCH 2/7] feat(lumen): everyone's requests, request as, library badge, saved settings Tester feedback: - Requests gets an Everyone tab for admins and request managers: every waiting request first, then the newest, each with who asked, and Approve / Decline on waiting ones. The tab shows how many are waiting. - The request sheet and the Advanced options get "Request as" for people SeerrNG lets choose the requester (Admin, or Manage Users together with Manage Requests). - Title pages show the posters' corner glyph on their poster: the check when it's in the library, the clock when requested. - "Lumen settings for everyone" can be saved from the admin page. A small nginx script (njs, already in the image) checks with SeerrNG that the person is an admin, then writes lumen-config.json into a mounted /lumen-data folder. Without that folder the page keeps the download. --- docs/using-seerr/lumen.md | 27 ++- lumen/Dockerfile | 6 + lumen/compose.lumen-only.yaml | 9 +- lumen/compose.yaml | 9 +- lumen/deploy/lumen-config.js | 110 +++++++++++ lumen/deploy/nginx.conf.template | 32 ++++ lumen/src/api.ts | 1 + lumen/src/components/detail.tsx | 54 +++++- lumen/src/pages/BookDetail.tsx | 1 + lumen/src/pages/Comics.tsx | 2 + lumen/src/pages/Games.tsx | 1 + lumen/src/pages/LumenSettings.tsx | 91 +++++++++- lumen/src/pages/MovieDetail.tsx | 1 + lumen/src/pages/Music.tsx | 1 + lumen/src/pages/Requests.tsx | 121 ++++++++++-- lumen/src/pages/Sports.tsx | 2 + lumen/src/pages/TvDetail.tsx | 9 +- lumen/src/settings.tsx | 4 + lumen/src/styles/app.css | 11 ++ lumen/tests/browser/admin-options.spec.ts | 212 ++++++++++++++++++++++ 20 files changed, 670 insertions(+), 34 deletions(-) create mode 100644 lumen/deploy/lumen-config.js diff --git a/docs/using-seerr/lumen.md b/docs/using-seerr/lumen.md index 53b7493f4..a460003ec 100644 --- a/docs/using-seerr/lumen.md +++ b/docs/using-seerr/lumen.md @@ -339,13 +339,25 @@ 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. + +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: @@ -389,7 +401,8 @@ To set it up: - 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`. diff --git a/lumen/Dockerfile b/lumen/Dockerfile index 4f334972a..738eae7a1 100644 --- a/lumen/Dockerfile +++ b/lumen/Dockerfile @@ -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 && \ diff --git a/lumen/compose.lumen-only.yaml b/lumen/compose.lumen-only.yaml index b59c0e237..1841c8e53 100644 --- a/lumen/compose.lumen-only.yaml +++ b/lumen/compose.lumen-only.yaml @@ -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 diff --git a/lumen/compose.yaml b/lumen/compose.yaml index 5c24aa74c..ffce0f9d3 100644 --- a/lumen/compose.yaml +++ b/lumen/compose.yaml @@ -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 diff --git a/lumen/deploy/lumen-config.js b/lumen/deploy/lumen-config.js new file mode 100644 index 000000000..7db7abb29 --- /dev/null +++ b/lumen/deploy/lumen-config.js @@ -0,0 +1,110 @@ +// 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. +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'; +} + +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; + } + const me = await r.subrequest('/_lumen_session_check', { method: 'GET' }); + if (me.status !== 200) { + send(r, 401, { message: 'Sign in again.' }); + return; + } + let user; + try { + user = JSON.parse(me.responseText); + } catch (e) { + send(r, 502, { message: 'SeerrNG didn’t say who you are.' }); + return; + } + if (!user || ((user.permissions || 0) & ADMIN) === 0) { + send(r, 403, { message: 'Only admins can change settings for everyone.' }); + 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 }); +} + +export default { save }; diff --git a/lumen/deploy/nginx.conf.template b/lumen/deploy/nginx.conf.template index b4d073ae7..5ecc128f4 100644 --- a/lumen/deploy/nginx.conf.template +++ b/lumen/deploy/nginx.conf.template @@ -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'"; } @@ -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; @@ -80,6 +85,33 @@ 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; + } + # 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}/; diff --git a/lumen/src/api.ts b/lumen/src/api.ts index 6aa5fc2d5..22135ebe6 100644 --- a/lumen/src/api.ts +++ b/lumen/src/api.ts @@ -130,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', { diff --git a/lumen/src/components/detail.tsx b/lumen/src/components/detail.tsx index 155dc88cb..7731a6e1d 100644 --- a/lumen/src/components/detail.tsx +++ b/lumen/src/components/detail.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { api, type Person, type RequestOptions } from '../api'; import { useAuth } from '../auth'; +import { Glyph, type Stat } from './cards'; import { Icon, type IconName } from '../icons'; const hue = (s: string) => @@ -89,6 +90,7 @@ export function DetailTop({ title, by, line, + status, }: { backdrop?: string; poster?: string; @@ -96,6 +98,8 @@ export function DetailTop({ title: string; by?: ReactNode; line: ReactNode[]; + // in the library, requested or downloading: the posters' corner glyph, on this poster too + status?: Stat; }) { // the floating back button lives in Shell, so it's there while the page loads or if it fails return ( @@ -106,6 +110,7 @@ export function DetailTop({
{poster && } +

{title}

@@ -156,6 +161,16 @@ interface ServerDetail { // who may pick the Radarr/Sonarr server, quality and folder for a request export const canChooseRequestOptions = (permissions?: number) => ((permissions ?? 0) & MANAGE) !== 0; +// who may make a request in someone else's name: SeerrNG wants Manage Users and Manage +// Requests together (or Admin) +export const canRequestAs = (permissions?: number) => { + const p = permissions ?? 0; + return (p & 2) !== 0 || (p & (8 | 16)) === (8 | 16); +}; +interface RequestUser { + id: number; + displayName: string; +} export function AdvancedOptions({ kind, @@ -178,6 +193,15 @@ export function AdvancedOptions({ const [detail, setDetail] = useState(null); const [err, setErr] = useState(''); const [o, setO] = useState({}); + const asAllowed = canRequestAs(user?.permissions); + const [people, setPeople] = useState(null); + const [asUser, setAsUser] = useState(); + useEffect(() => { + if (!open || !asAllowed || people) return; + api<{ results: RequestUser[] }>('/user?take=100&skip=0&sort=displayname') + .then((d) => setPeople(d.results)) + .catch(() => setPeople([])); // the picker just stays hidden + }, [open, asAllowed, people]); useEffect(() => { if (!open || servers) return; @@ -221,7 +245,17 @@ export function AdvancedOptions({ live = false; }; }, [o.serverId, svc]); - useEffect(() => onChange(open ? o : {}), [o, open, onChange]); + useEffect( + () => + onChange( + open + ? asUser && asUser !== user?.id + ? { ...o, userId: asUser } + : o + : {} + ), + [o, open, asUser, user?.id, onChange] + ); useEffect(() => { onReady?.( !!detail && @@ -239,6 +273,24 @@ export function AdvancedOptions({ {!servers && !err && open &&

Loading servers…

} {servers && (
+ {asAllowed && !!people?.length && user && ( + + )}