Merge pull request #58 from quoije/2026

v2.2 - redesign admin panel, fix readme.md
This commit was merged in pull request #58.
This commit is contained in:
Mathieu Moran
2025-12-22 16:36:48 -05:00
committed by GitHub
6 changed files with 416 additions and 96 deletions
+27 -4
View File
@@ -5,13 +5,13 @@ Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep
## Features
- User accounts: register, login, JWT auth
- Simple registration verification via `DOG_NAMES`
- Optional registration "verification" via `DOG_NAMES` (if unset, verification is skipped)
- Mark nominees as watched (per user), with watched date
- Browse movies by Oscar year + API for available years
- User stats: watched count/ratio and watched titles
- Movie details pulled from OMDb (title/plot/rating/poster) via IMDb ID
- Movie details pulled from OMDb (title/plot/rating/poster) via IMDb ID (optional)
- Admin movie management: add, edit (optionally refresh from OMDb), delete
- Built-in player page with multiple source options (VOD link, direct video file, embed)
- Built-in player page with multiple source options (VOD link, direct video URL, embed, or server file)
- Resume playback: per-user playback progress saving (time/duration)
- Global settings: active Oscar year, “completion” modal content for checklist
- Password flows: admin temp password reset + forced change on next login
@@ -35,7 +35,7 @@ MONGO_URI=mongodb://127.0.0.1:27017/oscars-pool # MongoDB connection string
JWT_SECRET=replace-with-a-random-string # JWT signing secret (auth)
# optional
DOG_NAMES=woof,WOOF,Woof # simple registration "verification"
DOG_NAMES=woof,WOOF,Woof # registration verification answers (comma-separated)
OMDB_API=your_omdb_key # OMDb API key (movie info)
```
@@ -53,3 +53,26 @@ This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_UR
- Atlas: `mongodb+srv://USER:[email protected]/<dbName>?retryWrites=true&w=majority`
- Local: `mongodb://127.0.0.1:27017/<dbName>`
- Collections are created/managed automatically from the models in `models/` (e.g. `User`, `Movie`, `Setting`, `PlaybackProgress`).
## More `.env` options
The app also reads these environment variables (all optional):
```bash
PORT=5000 # server port (default: 5000)
JWT_EXPIRES_IN=8h # JWT expiry (default: 8h)
VIDEO_FILES_DIR=/abs/path/to/video # where server video files live (default: ./public/video)
VIDEO_SESSION_MAX_AGE_SECONDS=28800 # video_auth cookie max-age (default: 8h)
MOVIES_CACHE_TTL_MS=30000 # movies route cache TTL (default: 30000ms)
```
Notes:
- The server serves the UI from `public/` (open `http://localhost:5000` by default).
- If you use `video_file` in a movie record, it must be a **relative path** under `VIDEO_FILES_DIR` (the backend blocks traversal/absolute paths).
## Python video server (optional)
There is also a standalone Python server in `python_video_server/` that mirrors `/api/video/:id` (Range streaming + JWT + Mongo).
See `python_video_server/README.md` for full instructions. In addition to `MONGO_URI` and `JWT_SECRET`, you may need:
```bash
export MONGO_DB_NAME="oscars-pool" # if your MONGO_URI doesn't include a /<dbName> path
```
+87 -42
View File
@@ -102,51 +102,96 @@
<div class="tab-content pt-3">
<div class="tab-pane fade show active" id="admin-settings" role="tabpanel" aria-labelledby="admin-settings-tab">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card p-3">
<h4 class="mb-1">Année active</h4>
<p class="text-muted mb-3">
Cette année pilote tout le site (films affichés, checklist et statistiques).
</p>
<div class="mb-3">
<label for="active_oscar_year" class="form-label">Année active</label>
<select class="form-select" id="active_oscar_year"></select>
<div class="form-text">La liste est basée sur les années présentes dans les films.</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" id="save-active-year" class="btn btn-warning">Appliquer l’année</button>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card p-3">
<h4 class="mb-1">Date des Oscars (par année)</h4>
<p class="text-muted mb-3">
Configure la date exacte de la cérémonie pour le <strong>compte à rebours</strong> sur Films &amp; Checklist.
Si non défini, la date par défaut reste <code>15 mars</code> (comportement actuel).
</p>
<div class="row g-3">
<div class="col-md-4">
<label for="oscar_date_year" class="form-label">Année</label>
<select class="form-select" id="oscar_date_year"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="col-md-4">
<label for="oscar_date_value" class="form-label">Date</label>
<input type="date" class="form-control" id="oscar_date_value">
<div class="form-text">Format: YYYY-MM-DD.</div>
</div>
<div class="col-md-4 d-flex align-items-end justify-content-end gap-2">
<button type="button" id="clear-oscar-date" class="btn btn-outline-danger">Effacer</button>
<button type="button" id="save-oscar-date" class="btn btn-warning">Enregistrer</button>
<div class="col-12 col-xxl-8">
<div class="accordion" id="adminSettingsAccordion">
<!-- Active year -->
<div class="accordion-item">
<h2 class="accordion-header" id="adminSettingsHeadingYear">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#adminSettingsCollapseYear" aria-expanded="true" aria-controls="adminSettingsCollapseYear">
Année active
</button>
</h2>
<div id="adminSettingsCollapseYear" class="accordion-collapse collapse show" aria-labelledby="adminSettingsHeadingYear" data-bs-parent="#adminSettingsAccordion">
<div class="accordion-body">
<div class="text-muted small mb-2">
Pilote tout le site (films, checklist, statistiques).
</div>
<div class="d-flex flex-wrap gap-2 align-items-end justify-content-between">
<div class="flex-grow-1" style="min-width: 220px;">
<label for="active_oscar_year" class="form-label mb-1">Année active</label>
<select class="form-select" id="active_oscar_year"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" id="save-active-year" class="btn btn-warning">Appliquer</button>
</div>
</div>
</div>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Dates configurées</div>
<div id="oscar-date-current" class="border rounded p-2 bg-light">—</div>
<!-- Player -->
<div class="accordion-item">
<h2 class="accordion-header" id="adminSettingsHeadingPlayer">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminSettingsCollapsePlayer" aria-expanded="false" aria-controls="adminSettingsCollapsePlayer">
Lecteur (player)
</button>
</h2>
<div id="adminSettingsCollapsePlayer" class="accordion-collapse collapse" aria-labelledby="adminSettingsHeadingPlayer" data-bs-parent="#adminSettingsAccordion">
<div class="accordion-body">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="player_ui_show_status">
<label class="form-check-label" for="player_ui_show_status">Afficher “Source” + “Progress” (admins)</label>
</div>
<div class="d-flex gap-2">
<button type="button" id="player_ui_reload" class="btn btn-outline-secondary btn-sm">Recharger</button>
<button type="button" id="player_ui_save" class="btn btn-warning btn-sm">Enregistrer</button>
</div>
</div>
<div class="form-text mt-2">
Contrôle l’affichage du bloc debug dans <code>player.html</code> (admins uniquement).
</div>
</div>
</div>
</div>
<!-- Oscar date -->
<div class="accordion-item">
<h2 class="accordion-header" id="adminSettingsHeadingOscarDate">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminSettingsCollapseOscarDate" aria-expanded="false" aria-controls="adminSettingsCollapseOscarDate">
Date des Oscars (par année)
</button>
</h2>
<div id="adminSettingsCollapseOscarDate" class="accordion-collapse collapse" aria-labelledby="adminSettingsHeadingOscarDate" data-bs-parent="#adminSettingsAccordion">
<div class="accordion-body">
<div class="text-muted small mb-2">
Configure la date de cérémonie pour le compte à rebours (Films &amp; Checklist). Défaut: <code>15 mars</code>.
</div>
<div class="row g-2">
<div class="col-12 col-md-4">
<label for="oscar_date_year" class="form-label mb-1">Année</label>
<select class="form-select" id="oscar_date_year"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="col-12 col-md-4">
<label for="oscar_date_value" class="form-label mb-1">Date</label>
<input type="date" class="form-control" id="oscar_date_value">
<div class="form-text">Format: YYYY-MM-DD.</div>
</div>
<div class="col-12 col-md-4 d-flex align-items-end justify-content-end gap-2">
<button type="button" id="clear-oscar-date" class="btn btn-outline-danger btn-sm">Effacer</button>
<button type="button" id="save-oscar-date" class="btn btn-warning btn-sm">Enregistrer</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Dates configurées</div>
<div id="oscar-date-current" class="border rounded p-2 bg-light">—</div>
</div>
</div>
</div>
</div>
</div>
</div>
+106
View File
@@ -41,6 +41,11 @@ window.onload = async function () {
const activeYearInput = document.getElementById('active_oscar_year');
const saveActiveYearBtn = document.getElementById('save-active-year');
// Player UI (settings tab): show/hide admin status blocks in player.html
const playerUiShowStatusEl = document.getElementById('player_ui_show_status');
const playerUiReloadBtn = document.getElementById('player_ui_reload');
const playerUiSaveBtn = document.getElementById('player_ui_save');
// Version control tab elements
const adminVersionTabBtn = document.getElementById('admin-version-tab');
const appVersionAlertEl = document.getElementById('app_version_alert');
@@ -132,6 +137,11 @@ window.onload = async function () {
bodyHtml: '',
});
const DEFAULT_PLAYER_UI = Object.freeze({
showSource: true,
showProgress: true,
});
function showResponse(kind, message) {
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
@@ -147,6 +157,67 @@ window.onload = async function () {
.replace(/'/g, '&#039;');
}
function normalizePlayerUi(value) {
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource;
const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_UI.showProgress : !!v.showProgress;
return { showSource, showProgress };
}
async function fetchPlayerUi() {
const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return normalizePlayerUi(data);
}
async function savePlayerUi(payload) {
const res = await fetch('/api/settings/player-admin-status-ui', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return normalizePlayerUi(data);
}
function setPlayerUiForm(value) {
if (!playerUiShowStatusEl) return;
// Single checkbox controls both flags.
playerUiShowStatusEl.checked = !!value.showSource && !!value.showProgress;
}
function getPlayerUiFormValue() {
const enabled = !!playerUiShowStatusEl?.checked;
return normalizePlayerUi({
showSource: enabled,
showProgress: enabled,
});
}
async function loadPlayerUiIntoUi() {
// If the section isn't present (older HTML), skip gracefully.
if (!playerUiShowStatusEl) return;
try {
const value = await fetchPlayerUi();
setPlayerUiForm(value);
} catch (err) {
// Keep defaults so admin can still set something.
setPlayerUiForm(DEFAULT_PLAYER_UI);
showResponse('warning', err.message || 'Impossible de charger les réglages du lecteur.');
}
}
async function copyToClipboard(text) {
const t = String(text || '').trim();
if (!t) return false;
@@ -2337,6 +2408,41 @@ window.onload = async function () {
hookModal100Inputs();
await loadCompletionModalIntoUi();
await loadPlayerUiIntoUi();
if (playerUiReloadBtn) {
playerUiReloadBtn.addEventListener('click', async () => {
playerUiReloadBtn.disabled = true;
const old = playerUiReloadBtn.textContent;
playerUiReloadBtn.textContent = 'Chargement...';
try {
await loadPlayerUiIntoUi();
showResponse('success', 'Réglages du lecteur rechargés.');
} finally {
playerUiReloadBtn.disabled = false;
playerUiReloadBtn.textContent = old;
}
});
}
if (playerUiSaveBtn) {
playerUiSaveBtn.addEventListener('click', async () => {
playerUiSaveBtn.disabled = true;
const old = playerUiSaveBtn.textContent;
playerUiSaveBtn.textContent = 'Enregistrement...';
try {
const payload = getPlayerUiFormValue();
const saved = await savePlayerUi(payload);
setPlayerUiForm(saved);
showResponse('success', 'Réglages du lecteur enregistrés.');
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
playerUiSaveBtn.disabled = false;
playerUiSaveBtn.textContent = old;
}
});
}
if (saveActiveYearBtn && activeYearInput) {
saveActiveYearBtn.addEventListener('click', async () => {
+27 -4
View File
@@ -139,6 +139,20 @@ async function fetchActiveOscarYear() {
}
}
async function fetchPlayerAdminStatusUi() {
try {
const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' });
if (!res.ok) return { showSource: true, showProgress: true };
const data = await res.json().catch(() => ({}));
return {
showSource: data?.showSource === undefined ? true : !!data.showSource,
showProgress: data?.showProgress === undefined ? true : !!data.showProgress,
};
} catch (_) {
return { showSource: true, showProgress: true };
}
}
async function fetchPlaybackProgress(movieId, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
@@ -868,8 +882,9 @@ window.onload = async function () {
}
pageLoader.setProgress(14);
// Load the active Oscar year in parallel with the movie fetch.
// Load the active Oscar year (and player UI flags) in parallel with the movie fetch.
const activeYearPromise = fetchActiveOscarYear();
const playerUiPromise = fetchPlayerAdminStatusUi();
let decoded = null;
try {
@@ -889,8 +904,6 @@ window.onload = async function () {
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
const adminStatus = document.getElementById('player-admin-status');
if (adminStatus) adminStatus.classList.remove('d-none');
}
const logoffBtn = document.getElementById('log-off');
@@ -927,10 +940,20 @@ window.onload = async function () {
return;
}
const activeYear = await activeYearPromise;
const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]);
setHeader(movie, { activeYear });
pageLoader.setProgress(58);
// Admin-only status block (Source + Progress) can be globally disabled via settings.
if (decoded?.admin) {
const enabled = !!playerUi?.showSource && !!playerUi?.showProgress;
const adminStatus = document.getElementById('player-admin-status');
if (adminStatus) {
if (enabled) adminStatus.classList.remove('d-none');
else adminStatus.classList.add('d-none');
}
}
const resolved = resolveMovieSource(movie);
if (!resolved?.src) {
setSourceLabel('—');
+116 -46
View File
@@ -11,69 +11,129 @@ This is a small standalone server that mirrors the app’s `/api/video/:id` beha
```bash
python -m pip install -r python_video_server/requirements.txt
export MONGO_URI="..."
export MONGO_DB_NAME="..." # optional; required if MONGO_URI doesn't include /<dbName>
export JWT_SECRET="..."
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
```
## HTTPS (Let’s Encrypt, Python-only)
This server can be served over HTTPS by running `uvicorn` with `--ssl-certfile` and `--ssl-keyfile`.
To obtain/renew a trusted cert from Let’s Encrypt **without installing system certbot**, this repo includes a small wrapper that uses the **`certbot` Python package**.
### 1) Issue a certificate (HTTP-01 standalone)
Requirements:
- Your domain’s DNS `A/AAAA` records must point to this machine.
- Port **80** must be reachable from the internet for the ACME challenge.
## Extra environment variables (optional)
```bash
python3 -m pip install -r python_video_server/requirements.txt
# CORS (needed when the main app is on a different origin)
export CORS_ALLOW_ORIGINS="https://your-app.example.com" # default: "*"
# Staging by default (recommended first). Add --production when it works.
sudo -E python3 -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com
# Cookie behavior for /api/video/session
export VIDEO_COOKIE_SAMESITE="Lax" # Lax|Strict|None (default: Lax)
# Mongo TLS / connectivity troubleshooting (Atlas, odd hosts, etc.)
export MONGO_TLS_CA_FILE="/path/to/ca.pem" # prefer a valid CA bundle
export MONGO_TLS_FORCE_TLS12="1" # force TLS 1.2 if TLS 1.3 breaks
export MONGO_TLS_INSECURE="1" # NOT recommended; debug only
export MONGO_SERVER_SELECTION_TIMEOUT_MS="8000"
export MONGO_CONNECT_TIMEOUT_MS="8000"
export MONGO_SOCKET_TIMEOUT_MS="20000"
```
### 1b) Issue a certificate (DNS-01 manual TXT record — no port 80, no root)
## Video auth / usage
If your host already uses port **80** (or you don't have permission to bind it), use DNS-01.
This does **not** require any inbound ports on the machine, but you must be able to edit DNS for the domain.
`GET /api/video/{movieId}` accepts auth in three ways (in priority order):
1) `video_auth` cookie (best for browser `<video>` tags)
2) `Authorization: Bearer <token>` header
3) `?token=<token>` query param (debug only; tokens in URLs leak via logs/referrers)
### Browser `<video>` (cookie flow)
Because HTML `<video>` cannot send an `Authorization` header, the pattern is:
1. Your JS calls `POST /api/video/session` with `Authorization: Bearer <token>`
2. The server responds `204` and sets a `video_auth` cookie (scoped to `/api/video`)
3. Your `<video>` loads `/api/video/{movieId}` and the cookie is used automatically
Example (curl):
```bash
python3 -m pip install -r python_video_server/requirements.txt
# 1) exchange header token -> cookie
curl -i -X POST "https://video.example.com/api/video/session" \
-H "Authorization: Bearer $JWT"
# Add --production when staging works.
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com
# 2) then the <video> element can simply use:
# https://video.example.com/api/video/<movieId>
```
During issuance, certbot will prompt you to create a TXT record like:
- Name: `_acme-challenge.example.com`
- Type: `TXT`
- Value: (a token certbot prints)
Cross-site note (frontend on a different origin):
- Set `CORS_ALLOW_ORIGINS` to your exact app origin (not `*`) and ensure your frontend uses `credentials: "include"` on the `/api/video/session` request.
- If the video host is cross-site, you’ll usually need `VIDEO_COOKIE_SAMESITE=None` **and** HTTPS, otherwise browsers may block the cookie.
After it propagates, press Enter and certbot will continue.
If you run into a situation where the prompt/token only shows up in logs, you can try the hook-based mode:
### Non-browser clients (header flow)
```bash
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--dns-hooks \
--email [email protected] \
--domains example.com,www.example.com
curl -I "https://video.example.com/api/video/<movieId>" \
-H "Authorization: Bearer $JWT"
```
Certificates will be written under:
### Token in URL (debug flow)
```bash
curl -I "https://video.example.com/api/video/<movieId>?token=$JWT"
```
### Seeking / Range requests (example)
```bash
curl -i "https://video.example.com/api/video/<movieId>" \
-H "Authorization: Bearer $JWT" \
-H "Range: bytes=0-1023"
```
## HTTPS (optional)
You can run behind a reverse proxy/ingress, or run `uvicorn` with TLS directly:
```bash
uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile /path/to/fullchain.pem \
--ssl-keyfile /path/to/privkey.pem
```
Notes:
- If you’re behind a proxy, forward `X-Forwarded-Proto: https` so `/api/video/session` can set `Secure` cookies correctly.
## Let’s Encrypt certs (python-only helper)
This repo includes `python_video_server/letsencrypt.py`, a wrapper around the **certbot Python package** (no system `certbot` needed).
Certificates (and certbot state) are stored under:
- `python_video_server/certs/config/live/<primary-domain>/fullchain.pem`
- `python_video_server/certs/config/live/<primary-domain>/privkey.pem`
### 2) Run the video server on 443 with TLS
You can override the storage directory with `LETSENCRYPT_BASE_DIR` (optional).
### Issue a cert (HTTP-01 standalone, easiest)
Requirements:
- DNS `A/AAAA` records point to this host
- Port **80** reachable from the internet
- You can bind port 80 (often requires `sudo`)
```bash
python -m pip install -r python_video_server/requirements.txt
# staging by default (recommended first)
sudo -E python -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com
# production (real cert)
sudo -E python -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com \
--production
```
Then run uvicorn with those certs:
```bash
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
@@ -81,18 +141,28 @@ sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
```
Notes:
- Binding to ports **80/443** usually requires `sudo` (or a reverse proxy / port forwarding).
- If you’re behind a proxy/ingress, keep sending `X-Forwarded-Proto: https` so `/api/video/session` sets `Secure` cookies correctly.
### Issue a cert (DNS-01 manual TXT, no port 80)
### 3) Renew certificates
Use this if you cannot expose/bind port 80. Certbot will prompt you to create a TXT record.
```bash
# Dry-run renewal (safe)
sudo -E python3 -m python_video_server.letsencrypt renew --dry-run
python -m pip install -r python_video_server/requirements.txt
# Actual renew (typically run via cron/systemd timer)
sudo -E python3 -m python_video_server.letsencrypt renew --production
python -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com \
--production
```
### Renew certs
```bash
# dry-run (safe)
sudo -E python -m python_video_server.letsencrypt renew --dry-run
# real renewal
sudo -E python -m python_video_server.letsencrypt renew --production
```
## Endpoints
+53
View File
@@ -13,6 +13,7 @@ const COMPLETION_MODAL_KEY = "completion_modal_content";
const WINNERS_BY_YEAR_KEY = "winners_by_year";
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const crypto = require("crypto");
@@ -23,6 +24,12 @@ const DEFAULT_COMPLETION_MODAL = Object.freeze({
bodyHtml: "",
});
const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
// Controls the admin-only debug/status block in player.html
showSource: true,
showProgress: true,
});
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
@@ -146,6 +153,13 @@ function normalizeCompletionModal(rawValue) {
};
}
function normalizePlayerAdminStatusUi(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showSource : !!v.showSource;
const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showProgress : !!v.showProgress;
return { showSource, showProgress };
}
function createId() {
try {
// Node 16+ / 18+: randomUUID
@@ -431,6 +445,45 @@ router.get("/app-version", async (req, res) => {
}
});
// Public: get player admin status UI flags (source/progress visibility)
router.get("/player-admin-status-ui", async (req, res) => {
try {
const existing = await Setting.findOne({ key: PLAYER_ADMIN_STATUS_UI_KEY }).select("value");
const normalized = normalizePlayerAdminStatusUi(existing?.value);
return res.status(200).json(normalized);
} catch (_) {
return res.status(200).json(DEFAULT_PLAYER_ADMIN_STATUS_UI);
}
});
// Admin-only: update player admin status UI flags
// Body: { showSource?: boolean, showProgress?: boolean }
router.put("/player-admin-status-ui", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const normalized = normalizePlayerAdminStatusUi(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: PLAYER_ADMIN_STATUS_UI_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizePlayerAdminStatusUi(updated?.value));
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: create a new version entry (optionally activates if none or if activate=true)
// Body: { version: "1.2.3", message?: "..." }
router.post("/app-version", async (req, res) => {