Add secure content delivery library
This commit is contained in:
parent
155f080fc5
commit
7ec23e5709
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,3 +22,4 @@ codex-guidelines
|
||||
Twitch.png
|
||||
twitch-credentials-lumi.png
|
||||
.secrets
|
||||
.secrets-*
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
# Lumi changelog
|
||||
|
||||
## 0.2.23
|
||||
|
||||
- Added an admin-only content library for verified image, audio, video, caption, document, data, and font uploads with previews, searching, storage quotas, disk reserves, and upload progress.
|
||||
- Added locked resources for internal/plugin use and revocable exposed resources with encrypted high-entropy tokens, read-only URLs, CORS, ETags, and HTTP byte-range delivery suitable for OBS and streaming media.
|
||||
- Added a shared `global.lumiFrameworks.resources` API, centralized additive storage migration, focused verification, and operator documentation.
|
||||
- Fixed clean OBS Browser Source CSP rules so configured remote and `blob:` audio/video media can load.
|
||||
- Connected resource deletion and URL revocation to Lumi's existing server-validated timed confirmation flow.
|
||||
|
||||
## 0.2.22
|
||||
|
||||
- Added reusable per-overlay event alerts that can queue one-shot Audio/Video playback or show Text/Image sources for a chosen duration without refreshing OBS.
|
||||
|
||||
12
TODO.md
12
TODO.md
@ -57,6 +57,18 @@ Remaining work:
|
||||
chat-presence joins where the platform can distinguish a real join from an
|
||||
ordinary message or reconnect.
|
||||
|
||||
## Content delivery library
|
||||
|
||||
Implemented on 2026-07-20: administrators can upload and manage verified
|
||||
streaming resources through a shared core library with previews, filters,
|
||||
storage quotas, physical free-space reserves, configurable upload limits, and
|
||||
progress feedback. Locked resources remain available only to admins and trusted
|
||||
Lumi code; exposed resources receive revocable encrypted bearer URLs. Delivery
|
||||
supports safe MIME handling, CORS, ETags, and byte ranges for OBS and browser
|
||||
media seeking. Plugins use the shared `global.lumiFrameworks.resources` API.
|
||||
OKF's generated core reference now discovers shared route modules so the AI can
|
||||
also find and explain the Resources and overlay endpoints.
|
||||
|
||||
## Current Local State / Source of Truth
|
||||
|
||||
### P0 OBS Overlay System
|
||||
|
||||
97
docs/content-library.md
Normal file
97
docs/content-library.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Lumi content library
|
||||
|
||||
The content library is a core Lumi service for storing and delivering streaming and multimedia resources. Administrators manage it from **Admin → Resources**.
|
||||
|
||||
## Storage
|
||||
|
||||
Files are stored under:
|
||||
|
||||
```text
|
||||
data/content-library/files/
|
||||
```
|
||||
|
||||
Temporary uploads and deletion staging remain under the same data directory so normal Lumi backup and update boundaries preserve them.
|
||||
|
||||
The Resources page displays:
|
||||
|
||||
- bytes currently used by registered resources;
|
||||
- physical free space on the filesystem hosting Lumi's content directory;
|
||||
- Lumi's configured free-space reserve;
|
||||
- an optional content-library quota;
|
||||
- the effective space available to Lumi, which is the smaller of remaining quota and usable physical space.
|
||||
|
||||
A quota of `0` means that Lumi does not impose an additional library limit. Physical disk space and the configured reserve still apply.
|
||||
|
||||
## Access levels
|
||||
|
||||
### Locked
|
||||
|
||||
Locked resources do not have a permanent public URL. They can be read by:
|
||||
|
||||
- authenticated Lumi administrators through the WebUI preview/download route;
|
||||
- Lumi server code through `global.lumiFrameworks.resources`;
|
||||
- a short-lived signed URL explicitly created by Lumi.
|
||||
|
||||
### Exposed
|
||||
|
||||
Exposed resources receive a high-entropy, read-only URL under `/media/...`. The URL supports HTTP byte ranges for video/audio seeking and can be used in OBS browser/media sources, overlays, alerts, and external integrations.
|
||||
|
||||
Changing an exposed resource back to locked removes its token. The previous URL stops resolving. Exposing it again creates a new URL.
|
||||
|
||||
## Core framework API
|
||||
|
||||
Core features and plugins running inside Lumi can use:
|
||||
|
||||
```js
|
||||
const resources = global.lumiFrameworks.resources;
|
||||
|
||||
const all = resources.list({ category: "audio" });
|
||||
const item = resources.get(resourceId);
|
||||
const absolutePath = resources.resolvePath(resourceId);
|
||||
const { stream } = resources.openReadStream(resourceId);
|
||||
|
||||
// Returns a permanent URL only when the resource is exposed.
|
||||
const publicUrl = resources.publicUrl(resourceId, "https://lumi.example.com");
|
||||
|
||||
// Works for locked or exposed resources and expires automatically.
|
||||
const temporaryUrl = resources.createSignedUrl(resourceId, {
|
||||
base_url: "https://lumi.example.com",
|
||||
ttl_seconds: 300
|
||||
});
|
||||
```
|
||||
|
||||
`global.lumiFrameworks.content` is an alias of the same API.
|
||||
|
||||
## Supported formats
|
||||
|
||||
The library accepts common streaming assets and verifies that file contents match their extensions. Supported groups include:
|
||||
|
||||
- images and graphics: PNG/APNG, JPEG/JFIF, WebP, GIF, AVIF, HEIC/HEIF, BMP, TIFF, ICO, PSD, and sanitized SVG;
|
||||
- audio: MP3, WAV, Ogg/Opus, FLAC, M4A, AAC, WebM audio, Matroska audio, WMA, CAF, and AIFF;
|
||||
- video: MP4/M4V, MOV, WebM, OGV, MKV, AVI, MPEG, transport stream formats, 3GP, FLV, WMV, and MXF;
|
||||
- captions/data: WebVTT, SRT, ASS/SSA, JSON, and Lottie JSON;
|
||||
- documents and fonts: PDF, WOFF/WOFF2, TTF/TTC, and OTF.
|
||||
|
||||
Browser preview support depends on the codecs installed in the browser/OBS Chromium build. A file can be accepted and delivered correctly even when the browser cannot decode its preview, such as some MKV, AVI, HEIC, PSD, WMA, or MXF files.
|
||||
|
||||
Active web-document formats such as HTML and JavaScript are intentionally not accepted as resources. Safe custom webpages remain the responsibility of Lumi's custom page and overlay web-source systems.
|
||||
|
||||
## Delivery behavior
|
||||
|
||||
Raw delivery includes:
|
||||
|
||||
- `Accept-Ranges: bytes` and single-range `206 Partial Content` responses;
|
||||
- stable MIME types and `X-Content-Type-Options: nosniff`;
|
||||
- ETags for efficient revalidation;
|
||||
- permissive CORS/Cross-Origin-Resource-Policy headers for exposed and signed media;
|
||||
- read-only routes with no mutation capability.
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run verify:content
|
||||
```
|
||||
|
||||
The focused check creates a temporary database and content directory, verifies locked/exposed transitions, URL signing, token invalidation, storage accounting, file deletion, byte-range parsing, route registration, and required WebUI elements.
|
||||
@ -14,8 +14,9 @@ editable: false
|
||||
Lumi is the core web UI and bot runtime.
|
||||
## Runtime
|
||||
Package: lumi-bot
|
||||
Version: 0.2.22
|
||||
Version: 0.2.23
|
||||
## Routes
|
||||
- POST /api/diagnostics/v1/run
|
||||
- GET /api/events
|
||||
- POST /api/destructive-confirmations
|
||||
- GET /api/users/search
|
||||
@ -45,6 +46,7 @@ Version: 0.2.22
|
||||
- POST /auth/logout
|
||||
- GET /auth/twitch
|
||||
- GET /auth/twitch/login
|
||||
- GET /admin/twitch-events/connect
|
||||
- GET /auth/twitch/callback
|
||||
- GET /auth/youtube
|
||||
- GET /auth/youtube/login
|
||||
@ -90,6 +92,10 @@ Version: 0.2.22
|
||||
- POST /admin/theming/custom/:id/rename
|
||||
- POST /admin/theming/custom/:id/delete
|
||||
- POST /admin/theming
|
||||
- GET /admin/diagnostics
|
||||
- POST /admin/diagnostics/run
|
||||
- POST /admin/diagnostics/access/renew
|
||||
- POST /admin/diagnostics/access/revoke
|
||||
- GET /admin/logs
|
||||
- POST /admin/logs/retention
|
||||
- GET /admin/logs/download
|
||||
@ -106,6 +112,12 @@ Version: 0.2.22
|
||||
- POST /admin/feedback/:id/cleanup
|
||||
- GET /admin/privileges
|
||||
- GET /admin/commands
|
||||
- GET /admin/command-policies
|
||||
- POST /admin/command-policies/groups
|
||||
- POST /admin/command-policies/groups/:id
|
||||
- POST /admin/command-policies/groups/:id/delete
|
||||
- POST /admin/command-policies/commands
|
||||
- POST /admin/command-policies/command
|
||||
- POST /admin/commands
|
||||
- POST /admin/commands/:id/toggle
|
||||
- POST /admin/commands/:id/delete
|
||||
@ -146,7 +158,69 @@ Version: 0.2.22
|
||||
- POST /admin/update
|
||||
- POST /admin/check-update
|
||||
- POST /admin/restart
|
||||
- GET /media/:token/:filename
|
||||
- GET /internal/media/:id/:filename
|
||||
- GET /admin/resources
|
||||
- GET /api/admin/resources
|
||||
- GET /admin/resources/:id/raw/:filename
|
||||
- POST /admin/resources/upload
|
||||
- POST /admin/resources/settings
|
||||
- POST /admin/resources/:id/rename
|
||||
- POST /admin/resources/:id/access
|
||||
- POST /admin/resources/:id/revoke
|
||||
- POST /admin/resources/:id/delete
|
||||
- GET /overlay-web/:ticket
|
||||
- POST ${basePath}/obs-bridge
|
||||
- GET ${basePath}/module-health
|
||||
- GET ${basePath}/state
|
||||
- GET ${basePath}/events
|
||||
- GET /overlay-chat/:ticket/state
|
||||
- GET /overlay-chat/:ticket/events
|
||||
- GET /overlay-chat/:ticket
|
||||
- GET /admin/overlays/:id/modules/:moduleId/web-preview/:ticket
|
||||
- GET /admin/overlays
|
||||
- POST /admin/overlays
|
||||
- POST /admin/overlays/reorder
|
||||
- GET /admin/overlays/:id
|
||||
- POST /admin/overlays/:id
|
||||
- POST /admin/overlays/:id/duplicate
|
||||
- POST /admin/overlays/:id/delete
|
||||
- POST /admin/overlays/:id/token/revoke
|
||||
- POST /admin/overlays/:id/active-scene
|
||||
- POST /admin/overlays/:id/scenes
|
||||
- POST /admin/overlays/:id/scenes/reorder
|
||||
- POST /admin/overlays/:id/scenes/:sceneId
|
||||
- POST /admin/overlays/:id/scenes/:sceneId/duplicate
|
||||
- POST /admin/overlays/:id/scenes/:sceneId/delete
|
||||
- POST /admin/overlays/:id/scenes/:sceneId/token/revoke
|
||||
- POST /admin/overlays/:id/scenes/:sceneId/modules
|
||||
- POST /admin/overlays/:id/scenes/:sceneId/modules/reorder
|
||||
- POST /admin/overlays/:id/modules/:moduleId
|
||||
- POST /admin/overlays/:id/modules/:moduleId/duplicate
|
||||
- POST /admin/overlays/:id/modules/:moduleId/delete
|
||||
- POST /admin/overlays/:id/event-hooks
|
||||
- POST /admin/overlays/:id/event-hooks/:hookId
|
||||
- POST /admin/overlays/:id/event-hooks/:hookId/test
|
||||
- POST /admin/overlays/:id/event-hooks/:hookId/delete
|
||||
- POST /api/admin/overlays/:id/modules/:moduleId/transform
|
||||
- POST /api/admin/overlays/:id/modules/:moduleId/preview
|
||||
- POST /api/admin/overlays/:id/modules/:moduleId/refresh
|
||||
- GET /api/admin/overlays/:id/modules/:moduleId/health
|
||||
- POST /admin/overlays/:id/obs
|
||||
- GET /api/admin/overlays/:id/obs/status
|
||||
- POST /api/admin/overlays/:id/obs/test
|
||||
- POST /api/admin/overlays/:id/obs/scene
|
||||
- POST /api/admin/overlays/:id/obs/import
|
||||
## Route Reference
|
||||
### POST /api/diagnostics/v1/run
|
||||
|
||||
- Purpose: Provides api diagnostics v1 run data as JSON.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: API route; access requirements were not fully detected by static analysis.
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /api/events
|
||||
|
||||
- Purpose: Streams live WebUI event notifications to the browser.
|
||||
@ -408,12 +482,21 @@ Version: 0.2.22
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /admin/twitch-events/connect
|
||||
|
||||
- Purpose: Handles admin twitch events connect.
|
||||
- Inputs: query: `return`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /auth/twitch/callback
|
||||
|
||||
- Purpose: Starts, completes, or cancels a platform authentication/linking flow.
|
||||
- Inputs: query: full query object is passed to a helper; exact fields are defined by the matching view/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: logged-in session required or used
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page.
|
||||
|
||||
@ -813,6 +896,42 @@ Version: 0.2.22
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /admin/diagnostics
|
||||
|
||||
- Purpose: Handles admin diagnostics.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /admin/diagnostics/run
|
||||
|
||||
- Purpose: Processes the admin diagnostics run action and stores or applies submitted form data.
|
||||
- Inputs: body: `check`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/diagnostics/access/renew
|
||||
|
||||
- Purpose: Processes the admin diagnostics access renew action and stores or applies submitted form data.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/diagnostics/access/revoke
|
||||
|
||||
- Purpose: Processes the admin diagnostics access revoke action and stores or applies submitted form data.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /admin/logs
|
||||
|
||||
- Purpose: Displays, downloads, or manages application logs.
|
||||
@ -957,10 +1076,64 @@ Version: 0.2.22
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /admin/command-policies
|
||||
|
||||
- Purpose: Renders the admin command policies WebUI page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected
|
||||
- Side effects: writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /admin/command-policies/groups
|
||||
|
||||
- Purpose: Processes the admin command policies groups action and stores or applies submitted form data.
|
||||
- Inputs: body: `description`, `name`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/command-policies/groups/:id
|
||||
|
||||
- Purpose: Processes the admin command policies groups id action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `description`, `name`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/command-policies/groups/:id/delete
|
||||
|
||||
- Purpose: Processes the admin command policies groups id delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/command-policies/commands
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: body: `${prefix}group_ids`, `${prefix}key`, `command_count`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/command-policies/command
|
||||
|
||||
- Purpose: Processes the admin command policies command action and stores or applies submitted form data.
|
||||
- Inputs: body: `command_key`, `group_ids`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/commands
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: body: `code`, `description`, `language`, `mode`, `response`, `trigger`
|
||||
- Inputs: body: `code`, `conditional_fuzzy`, `description`, `language`, `mode`, `response`, `trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
@ -987,7 +1160,7 @@ Version: 0.2.22
|
||||
### POST /admin/commands/:id/update
|
||||
|
||||
- Purpose: Creates, updates, previews, toggles, or deletes custom commands.
|
||||
- Inputs: path params: `id`; body: `code`, `description`, `language`, `mode`, `response`, `trigger`
|
||||
- Inputs: path params: `id`; body: `code`, `conditional_fuzzy`, `description`, `language`, `mode`, `response`, `trigger`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes database state when the called service mutates data
|
||||
@ -1203,7 +1376,7 @@ Version: 0.2.22
|
||||
### POST /admin/updates/core/apply
|
||||
|
||||
- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Inputs: body: `version`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; publishes or streams live WebUI events
|
||||
@ -1257,7 +1430,7 @@ Version: 0.2.22
|
||||
### POST /admin/updates/plugins/:id/apply
|
||||
|
||||
- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates.
|
||||
- Inputs: path params: `id`
|
||||
- Inputs: path params: `id`; body: `version`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; publishes or streams live WebUI events
|
||||
@ -1316,6 +1489,483 @@ Version: 0.2.22
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; may restart or stop runtime processes
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /media/:token/:filename
|
||||
|
||||
- Purpose: Handles media token filename.
|
||||
- Inputs: path params: `filename`, `token`; query: `download`
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /internal/media/:id/:filename
|
||||
|
||||
- Purpose: Handles internal media id filename.
|
||||
- Inputs: path params: `filename`, `id`; query: `download`, `expires`, `sig`
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /admin/resources
|
||||
|
||||
- Purpose: Renders the admin resources WebUI page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /api/admin/resources
|
||||
|
||||
- Purpose: Provides api admin resources data as JSON.
|
||||
- Inputs: query: full query object is passed to a helper; exact fields are defined by the matching view/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /admin/resources/:id/raw/:filename
|
||||
|
||||
- Purpose: Handles admin resources id raw filename.
|
||||
- Inputs: path params: `filename`, `id`; query: `download`
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /admin/resources/upload
|
||||
|
||||
- Purpose: Processes the admin resources upload action and stores or applies submitted form data.
|
||||
- Inputs: body: `access_level`; file upload: multipart form file data
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/resources/settings
|
||||
|
||||
- Purpose: Processes the admin resources settings action and stores or applies submitted form data.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/resources/:id/rename
|
||||
|
||||
- Purpose: Processes the admin resources id rename action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `display_name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/resources/:id/access
|
||||
|
||||
- Purpose: Processes the admin resources id access action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/resources/:id/revoke
|
||||
|
||||
- Purpose: Processes the admin resources id revoke action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/resources/:id/delete
|
||||
|
||||
- Purpose: Processes the admin resources id delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /overlay-web/:ticket
|
||||
|
||||
- Purpose: Handles overlay web ticket.
|
||||
- Inputs: path params: `ticket`
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page.
|
||||
|
||||
### POST ${basePath}/obs-bridge
|
||||
|
||||
- Purpose: Provides ${basePath} obs bridge data as JSON.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET ${basePath}/module-health
|
||||
|
||||
- Purpose: Provides ${basePath} module health data as JSON.
|
||||
- Inputs: query: `module_id`
|
||||
- Response format: JSON response
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET ${basePath}/state
|
||||
|
||||
- Purpose: Provides ${basePath} state data as JSON.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: JSON response
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET ${basePath}/events
|
||||
|
||||
- Purpose: Handles ${basePath} events.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: streaming event response
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: publishes or streams live WebUI events
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /overlay-chat/:ticket/state
|
||||
|
||||
- Purpose: Provides overlay chat ticket state data as JSON.
|
||||
- Inputs: path params: `ticket`
|
||||
- Response format: JSON response
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /overlay-chat/:ticket/events
|
||||
|
||||
- Purpose: Handles overlay chat ticket events.
|
||||
- Inputs: path params: `ticket`
|
||||
- Response format: streaming event response
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: publishes or streams live WebUI events
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /overlay-chat/:ticket
|
||||
|
||||
- Purpose: Renders the overlay chat ticket WebUI page.
|
||||
- Inputs: path params: `ticket`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /admin/overlays/:id/modules/:moduleId/web-preview/:ticket
|
||||
|
||||
- Purpose: Handles admin overlays id modules moduleId web preview ticket.
|
||||
- Inputs: path params: `id`, `moduleId`, `ticket`
|
||||
- Response format: HTML or data response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page.
|
||||
|
||||
### GET /admin/overlays
|
||||
|
||||
- Purpose: Renders the admin overlays WebUI page.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /admin/overlays
|
||||
|
||||
- Purpose: Processes the admin overlays action and stores or applies submitted form data.
|
||||
- Inputs: body: `description`, `name`, `scene_name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/reorder
|
||||
|
||||
- Purpose: Processes the admin overlays reorder action and stores or applies submitted form data.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /admin/overlays/:id
|
||||
|
||||
- Purpose: Renders the admin overlays id WebUI page.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /admin/overlays/:id
|
||||
|
||||
- Purpose: Processes the admin overlays id action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `canvas_height`, `canvas_width`, `description`, `name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/duplicate
|
||||
|
||||
- Purpose: Processes the admin overlays id duplicate action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/delete
|
||||
|
||||
- Purpose: Processes the admin overlays id delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/token/revoke
|
||||
|
||||
- Purpose: Processes the admin overlays id token revoke action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/active-scene
|
||||
|
||||
- Purpose: Processes the admin overlays id active scene action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `scene_id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `name`, `obs_scene_name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/reorder
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes reorder action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/:sceneId
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes sceneId action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `sceneId`; body: `name`, `obs_scene_name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/:sceneId/duplicate
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes sceneId duplicate action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `sceneId`; body: `name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/:sceneId/delete
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes sceneId delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `sceneId`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/:sceneId/token/revoke
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes sceneId token revoke action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `sceneId`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/:sceneId/modules
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes sceneId modules action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `sceneId`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/scenes/:sceneId/modules/reorder
|
||||
|
||||
- Purpose: Processes the admin overlays id scenes sceneId modules reorder action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `sceneId`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/modules/:moduleId
|
||||
|
||||
- Purpose: Processes the admin overlays id modules moduleId action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `moduleId`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/modules/:moduleId/duplicate
|
||||
|
||||
- Purpose: Processes the admin overlays id modules moduleId duplicate action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `moduleId`; body: `name`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/modules/:moduleId/delete
|
||||
|
||||
- Purpose: Processes the admin overlays id modules moduleId delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`, `moduleId`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/event-hooks
|
||||
|
||||
- Purpose: Processes the admin overlays id event hooks action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/event-hooks/:hookId
|
||||
|
||||
- Purpose: Processes the admin overlays id event hooks hookId action and stores or applies submitted form data.
|
||||
- Inputs: path params: `hookId`, `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/event-hooks/:hookId/test
|
||||
|
||||
- Purpose: Processes the admin overlays id event hooks hookId test action and stores or applies submitted form data.
|
||||
- Inputs: path params: `hookId`, `id`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /admin/overlays/:id/event-hooks/:hookId/delete
|
||||
|
||||
- Purpose: Processes the admin overlays id event hooks hookId delete action and stores or applies submitted form data.
|
||||
- Inputs: path params: `hookId`, `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /api/admin/overlays/:id/modules/:moduleId/transform
|
||||
|
||||
- Purpose: Provides api admin overlays id modules moduleId transform data as JSON.
|
||||
- Inputs: path params: `id`, `moduleId`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /api/admin/overlays/:id/modules/:moduleId/preview
|
||||
|
||||
- Purpose: Provides api admin overlays id modules moduleId preview data as JSON.
|
||||
- Inputs: path params: `id`, `moduleId`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /api/admin/overlays/:id/modules/:moduleId/refresh
|
||||
|
||||
- Purpose: Provides api admin overlays id modules moduleId refresh data as JSON.
|
||||
- Inputs: path params: `id`, `moduleId`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Action route; side effects were not detected statically.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### GET /api/admin/overlays/:id/modules/:moduleId/health
|
||||
|
||||
- Purpose: Returns runtime health information.
|
||||
- Inputs: path params: `id`, `moduleId`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /admin/overlays/:id/obs
|
||||
|
||||
- Purpose: Processes the admin overlays id obs action and stores or applies submitted form data.
|
||||
- Inputs: path params: `id`; body: `endpoint`, `password`, `provider`, `sync_direction`
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### GET /api/admin/overlays/:id/obs/status
|
||||
|
||||
- Purpose: Provides api admin overlays id obs status data as JSON.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /api/admin/overlays/:id/obs/test
|
||||
|
||||
- Purpose: Provides api admin overlays id obs test data as JSON.
|
||||
- Inputs: path params: `id`; body: `endpoint`, `password`, `provider`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /api/admin/overlays/:id/obs/scene
|
||||
|
||||
- Purpose: Provides api admin overlays id obs scene data as JSON.
|
||||
- Inputs: path params: `id`; body: `scene_name`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
|
||||
### POST /api/admin/overlays/:id/obs/import
|
||||
|
||||
- Purpose: Provides api admin overlays id obs import data as JSON.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: JSON response
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise.
|
||||
## README Summary
|
||||
# Lumi Bot
|
||||
Discord bot + WebUI with role-based access, plugin management, and self-update support.
|
||||
|
||||
@ -444,7 +444,7 @@ Default state: enabled
|
||||
### GET /plugins/lumi_ai/improvement_center
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for improvement center.
|
||||
- Inputs: query: `correction_page`, `eval_page`, `review_page`, `status`
|
||||
- Inputs: query: `correction_page`, `eval_page`, `history_page`, `history_q`, `history_status`, `review_page`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Usually read-only.
|
||||
@ -462,7 +462,7 @@ Default state: enabled
|
||||
### POST /plugins/lumi_ai/improvement_center/reviews/:id
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id.
|
||||
- Inputs: path params: `id`; body: `action`, `review_notes`
|
||||
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
@ -489,7 +489,7 @@ Default state: enabled
|
||||
### POST /plugins/lumi_ai/improvement_center/reviews/:id/implement
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement.
|
||||
- Inputs: path params: `id`; body: `corrected_answer`, `enabled`, `expected_link`, `explicitly_safe`, `forbidden_behavior`, `min_role`, `notes`, `permission_origin`, `target`
|
||||
- Inputs: path params: `id`; body: `corrected_answer`, `enabled`, `expected_link`, `explicitly_safe`, `forbidden_behavior`, `min_role`, `notes`, `permission_origin`, `review_notes`, `target`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Role-gated knowledge, facts, and Q&A entries for Lumi communities.
|
||||
## Metadata
|
||||
Plugin ID: okf
|
||||
Version: 0.1.1
|
||||
Version: 0.1.2
|
||||
Default state: enabled
|
||||
## Web Routes
|
||||
- /plugins/okf
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.22",
|
||||
"version": "0.2.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.22",
|
||||
"version": "0.2.23",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.12",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.22",
|
||||
"version": "0.2.23",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
@ -17,7 +17,8 @@
|
||||
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
|
||||
"verify:overlays": "node scripts/verify-overlays.js",
|
||||
"verify:webui": "node scripts/verify-webui.js && node scripts/verify-destructive-actions.js",
|
||||
"benchmark:okf": "node scripts/benchmark-okf-search.js"
|
||||
"benchmark:okf": "node scripts/benchmark-okf-search.js",
|
||||
"verify:content": "node scripts/verify-content-library.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# OKF Knowledge changelog
|
||||
|
||||
## 0.1.2
|
||||
|
||||
- Extended generated core knowledge discovery to shared `src/services/*-routes.js` modules so Lumi AI can accurately find content-library and overlay endpoints.
|
||||
- Preserved all published entries and authored community/correction knowledge.
|
||||
|
||||
## 0.1.1
|
||||
|
||||
- Added role-gated database and Markdown-backed knowledge with user, moderator, and administrator visibility controls.
|
||||
|
||||
@ -140,17 +140,26 @@ function discoverPlugins(rootDir) {
|
||||
}
|
||||
|
||||
function discoverCoreRoutes(rootDir) {
|
||||
const server = readText(path.join(rootDir, "src", "web", "server.js"));
|
||||
const routes = [];
|
||||
for (const match of server.matchAll(/\bapp\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
const source = extractCallExpression(server, match.index);
|
||||
routes.push({
|
||||
method: match[1],
|
||||
path: match[2],
|
||||
details: analyzeRouteSource(source, match[1], match[2])
|
||||
});
|
||||
const routeFiles = [path.join(rootDir, "src", "web", "server.js")];
|
||||
const servicesDir = path.join(rootDir, "src", "services");
|
||||
if (fs.existsSync(servicesDir)) {
|
||||
for (const entry of fs.readdirSync(servicesDir, { withFileTypes: true })) {
|
||||
if (entry.isFile() && entry.name.endsWith("-routes.js")) routeFiles.push(path.join(servicesDir, entry.name));
|
||||
}
|
||||
}
|
||||
return uniqueRoutes(routes).slice(0, 200);
|
||||
const routes = [];
|
||||
for (const filePath of routeFiles) {
|
||||
const sourceFile = readText(filePath);
|
||||
for (const match of sourceFile.matchAll(/\bapp\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
||||
const source = extractCallExpression(sourceFile, match.index);
|
||||
routes.push({
|
||||
method: match[1],
|
||||
path: match[2],
|
||||
details: analyzeRouteSource(source, match[1], match[2])
|
||||
});
|
||||
}
|
||||
}
|
||||
return uniqueRoutes(routes).slice(0, 320);
|
||||
}
|
||||
|
||||
function discoverPluginRoutes(source, pluginId) {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
{
|
||||
"id": "okf",
|
||||
"name": "OKF Knowledge",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Role-gated knowledge, facts, and Q&A entries for Lumi communities.",
|
||||
"main": "index.js",
|
||||
"channel": "stable",
|
||||
"compatible_from": "0.1.0",
|
||||
"migration_notes": "Published database entries and local community/correction knowledge files are retained. No manual migration is required.",
|
||||
"compatible_from": "0.1.1",
|
||||
"migration_notes": "Generated core knowledge now discovers routes registered in shared core route modules, including Resources and OBS overlays. Published database entries and local community/correction files are retained; no manual migration is required.",
|
||||
"rollback_safe": true
|
||||
}
|
||||
|
||||
@ -2,6 +2,36 @@
|
||||
"schema_version": 1,
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.2.23",
|
||||
"ref": "refs/tags/v0.2.23",
|
||||
"released_at": "2026-07-20",
|
||||
"installable": true,
|
||||
"rollback_safe": true,
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"data_policy": "preserve",
|
||||
"dependency_policy": "sync_on_restart",
|
||||
"migration_notes": "Adds an admin content-delivery library with verified uploads, revocable read-only delivery, storage controls, and a shared plugin API, plus the OBS media CSP fix. Existing data is preserved and content files are additive under data/content-library.",
|
||||
"plugins": {
|
||||
"auto-vc": "0.1.6",
|
||||
"birthday": "0.1.3",
|
||||
"economy-framework": "0.2.10",
|
||||
"economy-games": "0.1.7",
|
||||
"expression-interaction": "0.2.1",
|
||||
"lumi_ai": "0.8.5",
|
||||
"moderation": "0.1.5",
|
||||
"okf": "0.1.2",
|
||||
"quotes": "0.1.2",
|
||||
"sample-plugin": "0.1.0",
|
||||
"throne_wishlist": "0.1.2",
|
||||
"welcome_messages": "0.1.1"
|
||||
},
|
||||
"tools": {
|
||||
"lumi_ai_web_search": "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.2.22",
|
||||
"ref": "refs/tags/v0.2.22",
|
||||
|
||||
@ -25,6 +25,7 @@ const checks = [
|
||||
"scripts/verify-command-policies.js",
|
||||
"scripts/verify-destructive-actions.js",
|
||||
"scripts/verify-overlays.js",
|
||||
"scripts/verify-content-library.js",
|
||||
"scripts/verify-overlay-web-documents.js",
|
||||
"scripts/verify-webhooks.js"
|
||||
];
|
||||
|
||||
135
scripts/verify-content-library.js
Normal file
135
scripts/verify-content-library.js
Normal file
@ -0,0 +1,135 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-content-library-"));
|
||||
const serviceDir = path.join(sandbox, "src", "services");
|
||||
fs.mkdirSync(serviceDir, { recursive: true });
|
||||
|
||||
for (const file of [
|
||||
"config.js",
|
||||
"db.js",
|
||||
"settings.js",
|
||||
"upload-security.js",
|
||||
"content-library.js",
|
||||
"content-routes.js"
|
||||
]) {
|
||||
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
||||
}
|
||||
|
||||
let database = null;
|
||||
(async () => {
|
||||
try {
|
||||
database = require(path.join(serviceDir, "db.js"));
|
||||
// Core imports the Web server before main() runs migrate(). The content
|
||||
// service must therefore be safe to load without touching database tables.
|
||||
const library = require(path.join(serviceDir, "content-library.js"));
|
||||
const routes = require(path.join(serviceDir, "content-routes.js"));
|
||||
database.migrate();
|
||||
const settings = require(path.join(serviceDir, "settings.js"));
|
||||
settings.ensureDefaults();
|
||||
settings.setSetting("session_secret", "content-library-verification-secret");
|
||||
assert(database.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'content_resources'").get());
|
||||
library.ensureContentLibrary();
|
||||
assert(library.supportedExtensions().includes(".mp4"));
|
||||
assert(library.supportedExtensions().includes(".mp3"));
|
||||
assert(library.supportedExtensions().includes(".webm"));
|
||||
assert(library.supportedExtensions().includes(".png"));
|
||||
assert(library.supportedExtensions().includes(".woff2"));
|
||||
|
||||
const pngPath = path.join(library.INCOMING_DIR, "verify.png.upload");
|
||||
fs.writeFileSync(
|
||||
pngPath,
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZK1sAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
)
|
||||
);
|
||||
const [created] = await library.importUploadedFiles([
|
||||
{
|
||||
path: pngPath,
|
||||
originalname: "Alert Graphic.png",
|
||||
mimetype: "image/png"
|
||||
}
|
||||
], { access_level: "locked", uploaded_by: "verify-admin" });
|
||||
|
||||
assert.strictEqual(created.access_level, "locked");
|
||||
assert.strictEqual(created.category, "image");
|
||||
assert.strictEqual(created.preview_kind, "image");
|
||||
assert.strictEqual(library.publicUrl(created, "https://lumi.example"), null);
|
||||
assert(fs.existsSync(library.resourceFilePath(created)));
|
||||
|
||||
const signed = new URL(library.createSignedUrl(created.id, {
|
||||
base_url: "https://lumi.example",
|
||||
ttl_seconds: 60
|
||||
}));
|
||||
const signedResource = library.verifySignedResource(
|
||||
created.id,
|
||||
signed.searchParams.get("expires"),
|
||||
signed.searchParams.get("sig")
|
||||
);
|
||||
assert.strictEqual(signedResource.id, created.id);
|
||||
|
||||
const exposed = library.setResourceAccess(created.id, "exposed");
|
||||
const publicUrl = new URL(library.publicUrl(exposed, "https://lumi.example"));
|
||||
const publicToken = publicUrl.pathname.split("/")[2];
|
||||
assert(publicToken.length >= 40);
|
||||
assert.strictEqual(library.getResourceByExposedToken(publicToken).id, created.id);
|
||||
|
||||
const renamed = library.updateResourceName(created.id, "Follower alert");
|
||||
assert.strictEqual(renamed.display_name, "Follower alert");
|
||||
assert.strictEqual(library.listResources({ category: "image" }).length, 1);
|
||||
|
||||
assert.deepStrictEqual(routes.parseByteRange("bytes=0-9", 100), { start: 0, end: 9 });
|
||||
assert.deepStrictEqual(routes.parseByteRange("bytes=-10", 100), { start: 90, end: 99 });
|
||||
assert.strictEqual(routes.parseByteRange("bytes=200-300", 100).invalid, true);
|
||||
assert.strictEqual(routes.parseByteRange("bytes=0-1,4-5", 100).invalid, true);
|
||||
|
||||
const stats = library.getStorageStats();
|
||||
assert.strictEqual(stats.file_count, 1);
|
||||
assert(stats.used_bytes > 0);
|
||||
assert(stats.max_file_bytes > 0);
|
||||
|
||||
const lockedAgain = library.setResourceAccess(created.id, "locked");
|
||||
assert.strictEqual(library.getResourceByExposedToken(publicToken), null);
|
||||
assert.strictEqual(lockedAgain.public_token, "");
|
||||
|
||||
fs.rmSync(library.resourceFilePath(created), { force: true });
|
||||
library.deleteResource(created.id);
|
||||
assert.strictEqual(library.getResource(created.id), null);
|
||||
assert.strictEqual(library.getStorageStats().file_count, 0);
|
||||
|
||||
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
assert(serverSource.includes("registerPublicContentRoutes(app)"));
|
||||
assert(serverSource.includes("registerContentAdminRoutes(app"));
|
||||
assert(serverSource.includes('path: "/admin/resources"'));
|
||||
assert(serverSource.includes("global.lumiFrameworks.resources"));
|
||||
|
||||
const overlayRouteSource = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
|
||||
assert(overlayRouteSource.includes("media-src https: http: data: blob:"), "OBS overlay CSP must allow configured media sources");
|
||||
|
||||
const viewSource = fs.readFileSync(path.join(root, "src", "web", "views", "admin-resources.ejs"), "utf8");
|
||||
assert(viewSource.includes("data-resource-dropzone"));
|
||||
assert(viewSource.includes("data-resource-preview-modal"));
|
||||
assert(viewSource.includes("Available to Lumi"));
|
||||
assert(viewSource.includes("data-resource-access-form") && viewSource.includes("data-confirm-mode=\"modal\""));
|
||||
const contentRouteSource = fs.readFileSync(path.join(root, "src", "services", "content-routes.js"), "utf8");
|
||||
assert(contentRouteSource.includes('/admin/resources/:id/revoke'));
|
||||
|
||||
const clientSource = fs.readFileSync(path.join(root, "src", "web", "public", "content-library.js"), "utf8");
|
||||
assert(clientSource.includes("if (event.defaultPrevented) return;"), "AJAX actions must wait for shared timed confirmation");
|
||||
assert(clientSource.includes('body.has("confirmation_token")') && clientSource.includes('[data-destructive-cancel]'), "confirmed AJAX actions must close the shared modal without losing the issued token");
|
||||
|
||||
const coreKnowledge = fs.readFileSync(path.join(root, "knowledge", "core", "lumi-core.md"), "utf8");
|
||||
assert(coreKnowledge.includes("GET /admin/resources") && coreKnowledge.includes("GET /media/:token/:filename"));
|
||||
|
||||
console.log("Content-library verification passed.");
|
||||
} finally {
|
||||
try { database?.db?.close(); } catch {}
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@ -4,8 +4,8 @@ const path = require("path");
|
||||
const { findSafeTarget } = require("../src/services/versioning");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const releaseVersion = "0.2.22";
|
||||
const previousCoreVersion = "0.2.21";
|
||||
const releaseVersion = "0.2.23";
|
||||
const previousCoreVersion = "0.2.22";
|
||||
const earliestCompatibleCoreVersion = "0.1.9";
|
||||
const changedPlugins = {
|
||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
||||
@ -14,7 +14,7 @@ const changedPlugins = {
|
||||
"expression-interaction": { from: "0.2.0", to: "0.2.1", knowledge: "expression-interaction" },
|
||||
lumi_ai: { from: "0.8.4", to: "0.8.5", knowledge: "lumi-ai" },
|
||||
moderation: { from: "0.1.4", to: "0.1.5", knowledge: "moderation" },
|
||||
okf: { from: "0.1.0", to: "0.1.1", knowledge: "okf" },
|
||||
okf: { from: "0.1.1", to: "0.1.2", knowledge: "okf" },
|
||||
quotes: { from: "0.1.1", to: "0.1.2", knowledge: "quotes" },
|
||||
throne_wishlist: { from: "0.1.1", to: "0.1.2", knowledge: "throne-wishlist" },
|
||||
welcome_messages: { from: "0.1.0", to: "0.1.1", knowledge: "welcome-messages" }
|
||||
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||
|
||||
console.log("Release metadata verification passed: core 0.2.22, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||
console.log("Release metadata verification passed: core 0.2.23, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||
|
||||
@ -16,7 +16,7 @@ function readJson(relativePath) {
|
||||
|
||||
const releaseIndex = readJson("release-index.json");
|
||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||
assert.deepEqual(releaseVersions, ["0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||
assert.deepEqual(releaseVersions, ["0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||
for (const release of releaseIndex.releases) {
|
||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
||||
const baseTarget = {
|
||||
current_version: "0.2.4",
|
||||
available_versions: [
|
||||
{ version: "0.2.23", ref: "refs/tags/v0.2.23", rollback_safe: true },
|
||||
{ version: "0.2.22", ref: "refs/tags/v0.2.22", rollback_safe: true },
|
||||
{ version: "0.2.21", ref: "refs/tags/v0.2.21", rollback_safe: true },
|
||||
{ version: "0.2.20", ref: "refs/tags/v0.2.20", rollback_safe: true },
|
||||
@ -82,7 +83,7 @@ const corrected = buildStatus({
|
||||
channel: "stable"
|
||||
});
|
||||
assert.equal(corrected.version_correction, true);
|
||||
assert.equal(corrected.safe_target_version, "0.2.22");
|
||||
assert.equal(corrected.safe_target_version, "0.2.23");
|
||||
assert.equal(corrected.update_available, true);
|
||||
assert.equal(corrected.blocked, false);
|
||||
|
||||
|
||||
887
src/services/content-library.js
Normal file
887
src/services/content-library.js
Normal file
@ -0,0 +1,887 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { TextDecoder } = require("util");
|
||||
|
||||
const { db } = require("./db");
|
||||
const { getSetting, setSetting } = require("./settings");
|
||||
const { safeDownloadFilename } = require("./upload-security");
|
||||
|
||||
const DATA_DIR = path.join(__dirname, "..", "..", "data", "content-library");
|
||||
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||
const INCOMING_DIR = path.join(DATA_DIR, ".incoming");
|
||||
const TRASH_DIR = path.join(DATA_DIR, ".trash");
|
||||
|
||||
const ACCESS_LEVELS = Object.freeze(["locked", "exposed"]);
|
||||
const DEFAULT_STORAGE_RESERVE_BYTES = 512 * 1024 * 1024;
|
||||
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const HARD_MAX_FILE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
const DEFAULT_UPLOAD_MAX_FILES = 20;
|
||||
const HARD_UPLOAD_MAX_FILES = 50;
|
||||
const SIGNED_URL_MAX_TTL_SECONDS = 24 * 60 * 60;
|
||||
const TEXT_RESOURCE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
let initialized = false;
|
||||
|
||||
const FORMAT_DEFINITIONS = Object.freeze({
|
||||
".png": media("image/png", "image", "image", matchPng),
|
||||
".apng": media("image/apng", "image", "image", matchPng),
|
||||
".jpg": media("image/jpeg", "image", "image", matchJpeg),
|
||||
".jpeg": media("image/jpeg", "image", "image", matchJpeg),
|
||||
".jfif": media("image/jpeg", "image", "image", matchJpeg),
|
||||
".webp": media("image/webp", "image", "image", matchWebp),
|
||||
".gif": media("image/gif", "image", "image", matchGif),
|
||||
".avif": media("image/avif", "image", "image", (buffer) => matchIsoBrand(buffer, ["avif", "avis"])),
|
||||
".heic": media("image/heic", "image", "image", (buffer) => matchIsoBrand(buffer, ["heic", "heix", "hevc", "hevx", "mif1", "msf1"])),
|
||||
".heif": media("image/heif", "image", "image", (buffer) => matchIsoBrand(buffer, ["heif", "heim", "heis", "mif1", "msf1"])),
|
||||
".bmp": media("image/bmp", "image", "image", (buffer) => buffer.subarray(0, 2).toString("ascii") === "BM"),
|
||||
".tif": media("image/tiff", "image", "image", matchTiff),
|
||||
".tiff": media("image/tiff", "image", "image", matchTiff),
|
||||
".ico": media("image/x-icon", "image", "image", (buffer) => startsWith(buffer, [0x00, 0x00, 0x01, 0x00])),
|
||||
".psd": media("image/vnd.adobe.photoshop", "image", "none", (buffer) => buffer.subarray(0, 4).toString("ascii") === "8BPS"),
|
||||
".svg": media("image/svg+xml; charset=utf-8", "image", "image", matchSafeSvg, true),
|
||||
|
||||
".mp3": media("audio/mpeg", "audio", "audio", matchMp3),
|
||||
".wav": media("audio/wav", "audio", "audio", (buffer) => matchRiff(buffer, "WAVE")),
|
||||
".wave": media("audio/wav", "audio", "audio", (buffer) => matchRiff(buffer, "WAVE")),
|
||||
".ogg": media("audio/ogg", "audio", "audio", matchOgg),
|
||||
".oga": media("audio/ogg", "audio", "audio", matchOgg),
|
||||
".opus": media("audio/ogg", "audio", "audio", matchOgg),
|
||||
".flac": media("audio/flac", "audio", "audio", (buffer) => buffer.subarray(0, 4).toString("ascii") === "fLaC"),
|
||||
".m4a": media("audio/mp4", "audio", "audio", matchIsoMedia),
|
||||
".aac": media("audio/aac", "audio", "audio", matchAac),
|
||||
".weba": media("audio/webm", "audio", "audio", matchEbml),
|
||||
".mka": media("audio/x-matroska", "audio", "audio", matchEbml),
|
||||
".wma": media("audio/x-ms-wma", "audio", "audio", matchAsf),
|
||||
".caf": media("audio/x-caf", "audio", "audio", (buffer) => buffer.subarray(0, 4).toString("ascii") === "caff"),
|
||||
".aiff": media("audio/aiff", "audio", "audio", (buffer) => matchForm(buffer, ["AIFF", "AIFC"])),
|
||||
".aif": media("audio/aiff", "audio", "audio", (buffer) => matchForm(buffer, ["AIFF", "AIFC"])),
|
||||
|
||||
".mp4": media("video/mp4", "video", "video", matchIsoMedia),
|
||||
".m4v": media("video/x-m4v", "video", "video", matchIsoMedia),
|
||||
".mov": media("video/quicktime", "video", "video", matchIsoMedia),
|
||||
".webm": media("video/webm", "video", "video", matchEbml),
|
||||
".ogv": media("video/ogg", "video", "video", matchOgg),
|
||||
".mkv": media("video/x-matroska", "video", "video", matchEbml),
|
||||
".avi": media("video/x-msvideo", "video", "video", (buffer) => matchRiff(buffer, "AVI ")),
|
||||
".mpeg": media("video/mpeg", "video", "video", matchMpegVideo),
|
||||
".mpg": media("video/mpeg", "video", "video", matchMpegVideo),
|
||||
".ts": media("video/mp2t", "video", "video", matchTransportStream),
|
||||
".mts": media("video/mp2t", "video", "video", matchTransportStream),
|
||||
".m2ts": media("video/mp2t", "video", "video", matchTransportStream),
|
||||
".3gp": media("video/3gpp", "video", "video", matchIsoMedia),
|
||||
".flv": media("video/x-flv", "video", "video", (buffer) => buffer.subarray(0, 3).toString("ascii") === "FLV"),
|
||||
".wmv": media("video/x-ms-wmv", "video", "video", matchAsf),
|
||||
".mxf": media("application/mxf", "video", "video", (buffer) => startsWith(buffer, [0x06, 0x0e, 0x2b, 0x34])),
|
||||
|
||||
".vtt": media("text/vtt; charset=utf-8", "caption", "text", matchWebVtt, true),
|
||||
".webvtt": media("text/vtt; charset=utf-8", "caption", "text", matchWebVtt, true),
|
||||
".srt": media("application/x-subrip; charset=utf-8", "caption", "text", matchSubRip, true),
|
||||
".ass": media("text/x-ssa; charset=utf-8", "caption", "text", matchSubStationAlpha, true),
|
||||
".ssa": media("text/x-ssa; charset=utf-8", "caption", "text", matchSubStationAlpha, true),
|
||||
".json": media("application/json; charset=utf-8", "data", "text", matchJson, true),
|
||||
".lottie": media("application/json; charset=utf-8", "data", "text", matchJson, true),
|
||||
".pdf": media("application/pdf", "document", "pdf", (buffer) => buffer.subarray(0, 5).toString("ascii") === "%PDF-"),
|
||||
|
||||
".woff": media("font/woff", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "wOFF"),
|
||||
".woff2": media("font/woff2", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "wOF2"),
|
||||
".ttf": media("font/ttf", "font", "font", matchTrueType),
|
||||
".ttc": media("font/collection", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "ttcf"),
|
||||
".otf": media("font/otf", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "OTTO")
|
||||
});
|
||||
|
||||
function media(mime, category, previewKind, matches, text = false) {
|
||||
return Object.freeze({ mime, category, previewKind, matches, text });
|
||||
}
|
||||
|
||||
function ensureContentLibrary() {
|
||||
if (initialized) return;
|
||||
fs.mkdirSync(FILES_DIR, { recursive: true });
|
||||
fs.mkdirSync(INCOMING_DIR, { recursive: true });
|
||||
fs.mkdirSync(TRASH_DIR, { recursive: true });
|
||||
ensureSettingDefault("content_storage_limit_bytes", 0);
|
||||
ensureSettingDefault("content_storage_reserve_bytes", DEFAULT_STORAGE_RESERVE_BYTES);
|
||||
ensureSettingDefault("content_max_file_bytes", DEFAULT_MAX_FILE_BYTES);
|
||||
ensureSettingDefault("content_upload_max_files", DEFAULT_UPLOAD_MAX_FILES);
|
||||
cleanupStaleWorkingFiles();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function ensureSettingDefault(key, value) {
|
||||
if (getSetting(key, null) === null) setSetting(key, value);
|
||||
}
|
||||
|
||||
function cleanupStaleWorkingFiles() {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
for (const directory of [INCOMING_DIR, TRASH_DIR]) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const target = path.join(directory, entry.name);
|
||||
try {
|
||||
if (fs.statSync(target).mtimeMs < cutoff) fs.rmSync(target, { force: true });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function supportedFormats() {
|
||||
return Object.entries(FORMAT_DEFINITIONS).map(([extension, definition]) => ({
|
||||
extension,
|
||||
mime: definition.mime,
|
||||
category: definition.category,
|
||||
preview_kind: definition.previewKind
|
||||
}));
|
||||
}
|
||||
|
||||
function supportedExtensions() {
|
||||
return Object.keys(FORMAT_DEFINITIONS);
|
||||
}
|
||||
|
||||
function uploadLimits() {
|
||||
return {
|
||||
max_file_bytes: clampInteger(
|
||||
getSetting("content_max_file_bytes", DEFAULT_MAX_FILE_BYTES),
|
||||
1 * 1024 * 1024,
|
||||
HARD_MAX_FILE_BYTES,
|
||||
DEFAULT_MAX_FILE_BYTES
|
||||
),
|
||||
max_files: clampInteger(
|
||||
getSetting("content_upload_max_files", DEFAULT_UPLOAD_MAX_FILES),
|
||||
1,
|
||||
HARD_UPLOAD_MAX_FILES,
|
||||
DEFAULT_UPLOAD_MAX_FILES
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function getStorageStats() {
|
||||
ensureContentLibrary();
|
||||
const usedRow = db.prepare("SELECT COALESCE(SUM(size), 0) AS used FROM content_resources").get();
|
||||
const usedBytes = nonNegativeNumber(usedRow?.used);
|
||||
const limitBytes = nonNegativeNumber(getSetting("content_storage_limit_bytes", 0));
|
||||
const reserveBytes = nonNegativeNumber(
|
||||
getSetting("content_storage_reserve_bytes", DEFAULT_STORAGE_RESERVE_BYTES)
|
||||
);
|
||||
const disk = diskStats(DATA_DIR);
|
||||
const diskUsableBytes = disk.available
|
||||
? Math.max(0, disk.available_bytes - reserveBytes)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const quotaRemainingBytes = limitBytes > 0
|
||||
? Math.max(0, limitBytes - usedBytes)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const effectiveAvailableBytes = Math.min(diskUsableBytes, quotaRemainingBytes);
|
||||
return {
|
||||
used_bytes: usedBytes,
|
||||
file_count: Number(db.prepare("SELECT COUNT(*) AS count FROM content_resources").get()?.count || 0),
|
||||
limit_bytes: limitBytes,
|
||||
quota_enabled: limitBytes > 0,
|
||||
quota_remaining_bytes: limitBytes > 0 ? quotaRemainingBytes : null,
|
||||
reserve_bytes: reserveBytes,
|
||||
disk_available: disk.available,
|
||||
disk_free_bytes: disk.available ? disk.available_bytes : null,
|
||||
disk_total_bytes: disk.available ? disk.total_bytes : null,
|
||||
disk_usable_bytes: disk.available ? diskUsableBytes : null,
|
||||
effective_available_bytes: Number.isFinite(effectiveAvailableBytes)
|
||||
? effectiveAvailableBytes
|
||||
: null,
|
||||
usage_percent: limitBytes > 0
|
||||
? percent(usedBytes, limitBytes)
|
||||
: disk.available
|
||||
? percent(disk.total_bytes - disk.available_bytes, disk.total_bytes)
|
||||
: 0,
|
||||
quota_usage_percent: limitBytes > 0 ? percent(usedBytes, limitBytes) : null,
|
||||
disk_usage_percent: disk.available
|
||||
? percent(disk.total_bytes - disk.available_bytes, disk.total_bytes)
|
||||
: null,
|
||||
...uploadLimits()
|
||||
};
|
||||
}
|
||||
|
||||
function diskStats(target) {
|
||||
try {
|
||||
if (typeof fs.statfsSync !== "function") return { available: false };
|
||||
const stat = fs.statfsSync(target);
|
||||
return {
|
||||
available: true,
|
||||
available_bytes: Number(stat.bavail) * Number(stat.bsize),
|
||||
total_bytes: Number(stat.blocks) * Number(stat.bsize)
|
||||
};
|
||||
} catch {
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
|
||||
function preflightIncomingRequest(contentLength) {
|
||||
const bytes = nonNegativeNumber(contentLength);
|
||||
if (!bytes) return { ok: true };
|
||||
const stats = getStorageStats();
|
||||
if (stats.effective_available_bytes !== null && bytes > stats.effective_available_bytes) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 507,
|
||||
reason: "The upload is larger than the space currently available to Lumi."
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function importUploadedFiles(files, options = {}) {
|
||||
ensureContentLibrary();
|
||||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||
const limits = uploadLimits();
|
||||
if (!list.length) throw new Error("Choose at least one file to upload.");
|
||||
if (list.length > limits.max_files) {
|
||||
throw new Error(`Upload no more than ${limits.max_files} files at once.`);
|
||||
}
|
||||
|
||||
const accessLevel = normalizeAccessLevel(options.access_level);
|
||||
const prepared = [];
|
||||
try {
|
||||
for (const file of list) {
|
||||
prepared.push(await inspectUpload(file, limits.max_file_bytes));
|
||||
}
|
||||
ensureImportCapacity(prepared.reduce((sum, item) => sum + item.size, 0));
|
||||
|
||||
const moved = [];
|
||||
try {
|
||||
for (const item of prepared) {
|
||||
const id = crypto.randomUUID();
|
||||
const storedName = `${id}${item.extension}`;
|
||||
const destination = safeStoragePath(storedName);
|
||||
moveFile(item.temp_path, destination);
|
||||
moved.push(destination);
|
||||
const token = accessLevel === "exposed" ? createStoredToken() : null;
|
||||
item.row = {
|
||||
id,
|
||||
display_name: displayNameFromFilename(item.original_name),
|
||||
original_name: item.original_name,
|
||||
stored_name: storedName,
|
||||
mime: item.mime,
|
||||
extension: item.extension,
|
||||
category: item.category,
|
||||
preview_kind: item.preview_kind,
|
||||
size: item.size,
|
||||
checksum_sha256: item.checksum_sha256,
|
||||
access_level: accessLevel,
|
||||
public_token_hash: token?.hash || null,
|
||||
public_token_encrypted: token?.encrypted || null,
|
||||
uploaded_by: options.uploaded_by || null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO content_resources (
|
||||
id, display_name, original_name, stored_name, mime, extension,
|
||||
category, preview_kind, size, checksum_sha256, access_level,
|
||||
public_token_hash, public_token_encrypted, uploaded_by, created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @display_name, @original_name, @stored_name, @mime, @extension,
|
||||
@category, @preview_kind, @size, @checksum_sha256, @access_level,
|
||||
@public_token_hash, @public_token_encrypted, @uploaded_by, @created_at, @updated_at
|
||||
)
|
||||
`);
|
||||
const transaction = db.transaction((items) => {
|
||||
items.forEach((item) => insert.run(item.row));
|
||||
});
|
||||
transaction(prepared);
|
||||
return prepared.map((item) => hydrateResource(item.row));
|
||||
} catch (error) {
|
||||
moved.forEach((target) => {
|
||||
try { fs.rmSync(target, { force: true }); } catch {}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
list.forEach((file) => {
|
||||
if (!file?.path) return;
|
||||
try { fs.rmSync(file.path, { force: true }); } catch {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectUpload(file, maxFileBytes) {
|
||||
if (!file?.path) throw new Error("One of the uploaded files could not be read.");
|
||||
const originalName = safeDownloadFilename(file.originalname, "resource");
|
||||
const extension = path.extname(originalName).toLowerCase();
|
||||
const definition = FORMAT_DEFINITIONS[extension];
|
||||
if (!definition) {
|
||||
throw new Error(`${extension || "That file type"} is not supported by Lumi's media library.`);
|
||||
}
|
||||
const stat = fs.statSync(file.path);
|
||||
if (!stat.isFile() || stat.size < 1) throw new Error(`${originalName} is empty.`);
|
||||
if (stat.size > maxFileBytes) {
|
||||
throw new Error(`${originalName} is larger than the configured per-file limit (${formatBytes(maxFileBytes)}).`);
|
||||
}
|
||||
if (definition.text && stat.size > TEXT_RESOURCE_MAX_BYTES) {
|
||||
throw new Error(`${originalName} is too large for a text-based resource (${formatBytes(TEXT_RESOURCE_MAX_BYTES)} maximum).`);
|
||||
}
|
||||
const sample = definition.text ? fs.readFileSync(file.path) : readPrefix(file.path, 128 * 1024);
|
||||
if (!definition.matches(sample)) {
|
||||
throw new Error(`${originalName} does not match its filename extension or is not a valid supported media file.`);
|
||||
}
|
||||
return {
|
||||
temp_path: file.path,
|
||||
original_name: originalName,
|
||||
extension,
|
||||
mime: definition.mime,
|
||||
category: definition.category,
|
||||
preview_kind: definition.previewKind,
|
||||
size: stat.size,
|
||||
checksum_sha256: await hashFile(file.path)
|
||||
};
|
||||
}
|
||||
|
||||
function ensureImportCapacity(bytes) {
|
||||
const stats = getStorageStats();
|
||||
if (stats.quota_enabled && bytes > stats.quota_remaining_bytes) {
|
||||
throw new Error("Uploading these files would exceed Lumi's configured content-library limit.");
|
||||
}
|
||||
if (stats.disk_available && stats.disk_free_bytes < stats.reserve_bytes) {
|
||||
throw new Error("The upload reached Lumi's reserved free-space boundary. Remove files or lower the reserve before retrying.");
|
||||
}
|
||||
if (stats.disk_available && bytes > stats.disk_usable_bytes) {
|
||||
throw new Error("Lumi cannot safely finish storing these files without crossing the reserved free-space boundary.");
|
||||
}
|
||||
}
|
||||
|
||||
function listResources(filters = {}) {
|
||||
ensureContentLibrary();
|
||||
const where = [];
|
||||
const params = {};
|
||||
const category = String(filters.category || "").trim().toLowerCase();
|
||||
const accessLevel = String(filters.access_level || "").trim().toLowerCase();
|
||||
const search = String(filters.search || "").trim();
|
||||
if (category) {
|
||||
where.push("category = @category");
|
||||
params.category = category;
|
||||
}
|
||||
if (ACCESS_LEVELS.includes(accessLevel)) {
|
||||
where.push("access_level = @access_level");
|
||||
params.access_level = accessLevel;
|
||||
}
|
||||
if (search) {
|
||||
where.push("(display_name LIKE @search ESCAPE '\\' OR original_name LIKE @search ESCAPE '\\' OR mime LIKE @search ESCAPE '\\')");
|
||||
params.search = `%${search.replace(/[\\%_]/g, "\\$&")}%`;
|
||||
}
|
||||
const orderBy = filters.sort === "name"
|
||||
? "display_name COLLATE NOCASE ASC"
|
||||
: filters.sort === "size"
|
||||
? "size DESC"
|
||||
: "created_at DESC";
|
||||
const sql = `SELECT * FROM content_resources${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY ${orderBy}`;
|
||||
return db.prepare(sql).all(params).map(hydrateResource);
|
||||
}
|
||||
|
||||
function getResource(id) {
|
||||
ensureContentLibrary();
|
||||
const row = db.prepare("SELECT * FROM content_resources WHERE id = ?").get(String(id || ""));
|
||||
return row ? hydrateResource(row) : null;
|
||||
}
|
||||
|
||||
function getResourceByExposedToken(token) {
|
||||
ensureContentLibrary();
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM content_resources WHERE public_token_hash = ? AND access_level = 'exposed'"
|
||||
).get(tokenHash(token));
|
||||
return row ? hydrateResource(row) : null;
|
||||
}
|
||||
|
||||
function updateResourceName(id, value) {
|
||||
const current = requireResource(id);
|
||||
const displayName = normalizeDisplayName(value, current.display_name);
|
||||
db.prepare("UPDATE content_resources SET display_name = ?, updated_at = ? WHERE id = ?")
|
||||
.run(displayName, Date.now(), current.id);
|
||||
return getResource(current.id);
|
||||
}
|
||||
|
||||
function setResourceAccess(id, value) {
|
||||
const current = requireResource(id);
|
||||
const accessLevel = normalizeAccessLevel(value);
|
||||
if (current.access_level === accessLevel) return current;
|
||||
const token = accessLevel === "exposed" ? createStoredToken() : null;
|
||||
db.prepare(`
|
||||
UPDATE content_resources
|
||||
SET access_level = ?, public_token_hash = ?, public_token_encrypted = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(accessLevel, token?.hash || null, token?.encrypted || null, Date.now(), current.id);
|
||||
return getResource(current.id);
|
||||
}
|
||||
|
||||
function deleteResource(id) {
|
||||
const current = requireResource(id);
|
||||
const source = safeStoragePath(current.stored_name);
|
||||
const trashName = `${current.id}-${Date.now()}${current.extension}`;
|
||||
const trashPath = path.join(TRASH_DIR, trashName);
|
||||
let moved = false;
|
||||
try {
|
||||
if (fs.existsSync(source)) {
|
||||
const stat = fs.lstatSync(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The resource storage entry is not a regular file.");
|
||||
moveFile(source, trashPath);
|
||||
moved = true;
|
||||
}
|
||||
db.prepare("DELETE FROM content_resources WHERE id = ?").run(current.id);
|
||||
if (moved) fs.rmSync(trashPath, { force: true });
|
||||
return current;
|
||||
} catch (error) {
|
||||
if (moved && fs.existsSync(trashPath) && !fs.existsSync(source)) {
|
||||
try { moveFile(trashPath, source); } catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStorageSettings(values = {}) {
|
||||
const limitBytes = gibToBytes(values.limit_gib, { allowZero: true, max: 1024 * 1024 });
|
||||
const reserveBytes = gibToBytes(values.reserve_gib, { allowZero: true, max: 1024 });
|
||||
const maxFileBytes = mibToBytes(values.max_file_mib, {
|
||||
min: 1,
|
||||
max: HARD_MAX_FILE_BYTES / (1024 * 1024),
|
||||
fallback: DEFAULT_MAX_FILE_BYTES
|
||||
});
|
||||
const maxFiles = clampInteger(values.max_files, 1, HARD_UPLOAD_MAX_FILES, DEFAULT_UPLOAD_MAX_FILES);
|
||||
const usedBytes = getStorageStats().used_bytes;
|
||||
if (limitBytes > 0 && limitBytes < usedBytes) {
|
||||
throw new Error(`The storage limit cannot be lower than the ${formatBytes(usedBytes)} already stored.`);
|
||||
}
|
||||
setSetting("content_storage_limit_bytes", limitBytes);
|
||||
setSetting("content_storage_reserve_bytes", reserveBytes);
|
||||
setSetting("content_max_file_bytes", maxFileBytes);
|
||||
setSetting("content_upload_max_files", maxFiles);
|
||||
return getStorageStats();
|
||||
}
|
||||
|
||||
function resourceFilePath(resourceOrId) {
|
||||
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
|
||||
if (!resource?.stored_name) throw new Error("Resource storage metadata is missing.");
|
||||
const target = safeStoragePath(resource.stored_name);
|
||||
const stat = fs.lstatSync(target);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The resource file is unavailable.");
|
||||
return target;
|
||||
}
|
||||
|
||||
function openReadStream(id, options = {}) {
|
||||
const resource = requireResource(id);
|
||||
const filePath = resourceFilePath(resource);
|
||||
return {
|
||||
resource,
|
||||
file_path: filePath,
|
||||
stream: fs.createReadStream(filePath, options)
|
||||
};
|
||||
}
|
||||
|
||||
function publicUrl(resourceOrId, baseUrl = "") {
|
||||
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
|
||||
if (resource.access_level !== "exposed" || !resource.public_token) return null;
|
||||
return joinBaseUrl(baseUrl, `/media/${encodeURIComponent(resource.public_token)}/${encodeURIComponent(resource.original_name)}`);
|
||||
}
|
||||
|
||||
function adminUrl(resourceOrId, baseUrl = "") {
|
||||
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
|
||||
return joinBaseUrl(baseUrl, `/admin/resources/${encodeURIComponent(resource.id)}/raw/${encodeURIComponent(resource.original_name)}`);
|
||||
}
|
||||
|
||||
function createSignedUrl(id, options = {}) {
|
||||
const resource = requireResource(id);
|
||||
const ttlSeconds = clampInteger(
|
||||
options.ttl_seconds,
|
||||
1,
|
||||
SIGNED_URL_MAX_TTL_SECONDS,
|
||||
5 * 60
|
||||
);
|
||||
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||
const signature = signedResourceSignature(resource.id, expires);
|
||||
const route = `/internal/media/${encodeURIComponent(resource.id)}/${encodeURIComponent(resource.original_name)}?expires=${expires}&sig=${encodeURIComponent(signature)}`;
|
||||
return joinBaseUrl(options.base_url || "", route);
|
||||
}
|
||||
|
||||
function verifySignedResource(id, expires, signature) {
|
||||
const normalizedId = String(id || "");
|
||||
const expiry = Number(expires);
|
||||
if (!normalizedId || !Number.isFinite(expiry) || expiry < Math.floor(Date.now() / 1000)) return null;
|
||||
if (expiry > Math.floor(Date.now() / 1000) + SIGNED_URL_MAX_TTL_SECONDS + 60) return null;
|
||||
const expected = Buffer.from(signedResourceSignature(normalizedId, expiry));
|
||||
const received = Buffer.from(String(signature || ""));
|
||||
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) return null;
|
||||
return getResource(normalizedId);
|
||||
}
|
||||
|
||||
function serializeResource(resource, options = {}) {
|
||||
const baseUrl = options.base_url || "";
|
||||
return {
|
||||
id: resource.id,
|
||||
display_name: resource.display_name,
|
||||
original_name: resource.original_name,
|
||||
mime: resource.mime,
|
||||
extension: resource.extension,
|
||||
category: resource.category,
|
||||
preview_kind: resource.preview_kind,
|
||||
size: resource.size,
|
||||
size_display: formatBytes(resource.size),
|
||||
checksum_sha256: resource.checksum_sha256,
|
||||
access_level: resource.access_level,
|
||||
created_at: resource.created_at,
|
||||
updated_at: resource.updated_at,
|
||||
admin_url: adminUrl(resource, baseUrl),
|
||||
public_url: publicUrl(resource, baseUrl)
|
||||
};
|
||||
}
|
||||
|
||||
function frameworkApi() {
|
||||
return Object.freeze({
|
||||
list: (filters) => listResources(filters).map((resource) => serializeResource(resource)),
|
||||
get: (id) => {
|
||||
const resource = getResource(id);
|
||||
return resource ? serializeResource(resource) : null;
|
||||
},
|
||||
resolvePath: resourceFilePath,
|
||||
openReadStream,
|
||||
publicUrl,
|
||||
createSignedUrl,
|
||||
storage: getStorageStats,
|
||||
supportedFormats
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateResource(row) {
|
||||
let publicToken = "";
|
||||
if (row.public_token_encrypted) {
|
||||
try { publicToken = decryptSecret(row.public_token_encrypted); } catch { publicToken = ""; }
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
size: Number(row.size),
|
||||
created_at: Number(row.created_at),
|
||||
updated_at: Number(row.updated_at),
|
||||
public_token: publicToken
|
||||
};
|
||||
}
|
||||
|
||||
function requireResource(id) {
|
||||
const resource = getResource(id);
|
||||
if (!resource) {
|
||||
const error = new Error("Resource not found.");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
function safeStoragePath(storedName) {
|
||||
const base = path.resolve(FILES_DIR);
|
||||
const target = path.resolve(FILES_DIR, path.basename(String(storedName || "")));
|
||||
if (path.dirname(target) !== base) throw new Error("Invalid resource storage path.");
|
||||
return target;
|
||||
}
|
||||
|
||||
function normalizeAccessLevel(value) {
|
||||
const normalized = String(value || "locked").trim().toLowerCase();
|
||||
if (!ACCESS_LEVELS.includes(normalized)) throw new Error("Choose locked or exposed access.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value, fallback = "Resource") {
|
||||
const normalized = String(value || "")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.trim()
|
||||
.slice(0, 160);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function displayNameFromFilename(filename) {
|
||||
const extension = path.extname(filename);
|
||||
return normalizeDisplayName(path.basename(filename, extension), filename);
|
||||
}
|
||||
|
||||
function moveFile(source, destination) {
|
||||
try {
|
||||
fs.renameSync(source, destination);
|
||||
} catch (error) {
|
||||
if (!["EXDEV", "EPERM", "EACCES"].includes(error.code)) throw error;
|
||||
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
|
||||
fs.rmSync(source, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readPrefix(filePath, length) {
|
||||
const descriptor = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = fs.readSync(descriptor, buffer, 0, length, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function hashFile(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
function encryptionKey() {
|
||||
const secret = getSetting("session_secret", "");
|
||||
if (!secret) throw new Error("Lumi's session secret is not initialized.");
|
||||
return crypto.createHash("sha256").update(`lumi-content-library:${secret}`).digest();
|
||||
}
|
||||
|
||||
function createStoredToken() {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
return { token, hash: tokenHash(token), encrypted: encryptSecret(token) };
|
||||
}
|
||||
|
||||
function tokenHash(token) {
|
||||
return crypto.createHash("sha256").update(String(token || "")).digest("hex");
|
||||
}
|
||||
|
||||
function encryptSecret(value) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `v1.${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
|
||||
}
|
||||
|
||||
function decryptSecret(value) {
|
||||
const [version, ivValue, tagValue, encryptedValue] = String(value || "").split(".");
|
||||
if (version !== "v1" || !ivValue || !tagValue || !encryptedValue) throw new Error("Invalid stored token.");
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey(),
|
||||
Buffer.from(ivValue, "base64url")
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedValue, "base64url")),
|
||||
decipher.final()
|
||||
]).toString("utf8");
|
||||
}
|
||||
|
||||
function signedResourceSignature(id, expires) {
|
||||
return crypto.createHmac("sha256", encryptionKey())
|
||||
.update(`resource:${id}:${expires}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function joinBaseUrl(baseUrl, route) {
|
||||
return `${String(baseUrl || "").replace(/\/$/, "")}${route}`;
|
||||
}
|
||||
|
||||
function matchPng(buffer) {
|
||||
return startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
}
|
||||
|
||||
function matchJpeg(buffer) {
|
||||
return startsWith(buffer, [0xff, 0xd8, 0xff]);
|
||||
}
|
||||
|
||||
function matchWebp(buffer) {
|
||||
return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
|
||||
}
|
||||
|
||||
function matchGif(buffer) {
|
||||
const header = buffer.subarray(0, 6).toString("ascii");
|
||||
return header === "GIF87a" || header === "GIF89a";
|
||||
}
|
||||
|
||||
function matchTiff(buffer) {
|
||||
return startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) || startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a]);
|
||||
}
|
||||
|
||||
function matchMp3(buffer) {
|
||||
return buffer.subarray(0, 3).toString("ascii") === "ID3" ||
|
||||
(buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0);
|
||||
}
|
||||
|
||||
function matchAac(buffer) {
|
||||
return buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xf6) === 0xf0;
|
||||
}
|
||||
|
||||
function matchOgg(buffer) {
|
||||
return buffer.subarray(0, 4).toString("ascii") === "OggS";
|
||||
}
|
||||
|
||||
function matchRiff(buffer, formType) {
|
||||
return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === formType;
|
||||
}
|
||||
|
||||
function matchForm(buffer, types) {
|
||||
return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "FORM" && types.includes(buffer.subarray(8, 12).toString("ascii"));
|
||||
}
|
||||
|
||||
function matchIsoMedia(buffer) {
|
||||
return buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp";
|
||||
}
|
||||
|
||||
function matchIsoBrand(buffer, brands) {
|
||||
if (!matchIsoMedia(buffer)) return false;
|
||||
const brandBlock = buffer.subarray(8, Math.min(buffer.length, 64)).toString("ascii");
|
||||
return brands.some((brand) => brandBlock.includes(brand));
|
||||
}
|
||||
|
||||
function matchEbml(buffer) {
|
||||
return startsWith(buffer, [0x1a, 0x45, 0xdf, 0xa3]);
|
||||
}
|
||||
|
||||
function matchMpegVideo(buffer) {
|
||||
for (let index = 0; index < Math.min(buffer.length - 3, 4096); index += 1) {
|
||||
if (buffer[index] === 0x00 && buffer[index + 1] === 0x00 && buffer[index + 2] === 0x01 && [0xb3, 0xba].includes(buffer[index + 3])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchTransportStream(buffer) {
|
||||
const offsets = [0, 4];
|
||||
return offsets.some((offset) => buffer.length > offset + 376 && buffer[offset] === 0x47 && buffer[offset + 188] === 0x47 && buffer[offset + 376] === 0x47);
|
||||
}
|
||||
|
||||
function matchWebVtt(buffer) {
|
||||
const text = decodeText(buffer).replace(/^\uFEFF/, "");
|
||||
return /^WEBVTT(?:[ \t]|\r?\n)/.test(text);
|
||||
}
|
||||
|
||||
function matchSubRip(buffer) {
|
||||
const text = decodeText(buffer);
|
||||
return /^\s*\d+\s*\r?\n\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}/m.test(text);
|
||||
}
|
||||
|
||||
function matchJson(buffer) {
|
||||
try {
|
||||
const value = JSON.parse(decodeText(buffer));
|
||||
return value !== null && typeof value === "object";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function matchAsf(buffer) {
|
||||
return startsWith(buffer, [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c]);
|
||||
}
|
||||
|
||||
function matchSubStationAlpha(buffer) {
|
||||
const text = decodeText(buffer);
|
||||
return /^\s*\[Script Info\]/im.test(text) && /^\s*\[Events\]/im.test(text);
|
||||
}
|
||||
|
||||
function matchSafeSvg(buffer) {
|
||||
const source = decodeText(buffer).replace(/^\uFEFF/, "").trim();
|
||||
if (!source || (!/^<\?xml\b[^>]*>\s*/i.test(source) && !/^<svg\b/i.test(source))) return false;
|
||||
if (!/<svg\b/i.test(source)) return false;
|
||||
return !/<\s*(script|foreignObject|iframe|object|embed|audio|video)\b/i.test(source)
|
||||
&& !/\bon[a-z]+\s*=/i.test(source)
|
||||
&& !/javascript\s*:/i.test(source)
|
||||
&& !/@import\b/i.test(source)
|
||||
&& !/<\s*!DOCTYPE|<\s*!ENTITY/i.test(source)
|
||||
&& !/\b(?:href|xlink:href)\s*=\s*["']\s*(?!#)/i.test(source)
|
||||
&& !/\burl\s*\(\s*["']?\s*(?!#)/i.test(source);
|
||||
}
|
||||
|
||||
function matchTrueType(buffer) {
|
||||
return startsWith(buffer, [0x00, 0x01, 0x00, 0x00]) || buffer.subarray(0, 4).toString("ascii") === "true";
|
||||
}
|
||||
|
||||
function decodeText(buffer) {
|
||||
if (buffer.includes(0)) return "";
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function startsWith(buffer, bytes) {
|
||||
return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = nonNegativeNumber(value);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let amount = bytes;
|
||||
let unit = "B";
|
||||
for (const next of units) {
|
||||
amount /= 1024;
|
||||
unit = next;
|
||||
if (amount < 1024) break;
|
||||
}
|
||||
return `${amount >= 10 ? amount.toFixed(1) : amount.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
function percent(value, total) {
|
||||
if (!total) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((Number(value) / Number(total)) * 1000) / 10));
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : 0;
|
||||
}
|
||||
|
||||
function clampInteger(value, min, max, fallback) {
|
||||
const number = Math.floor(Number(value));
|
||||
if (!Number.isFinite(number)) return Math.floor(fallback);
|
||||
return Math.max(min, Math.min(max, number));
|
||||
}
|
||||
|
||||
function gibToBytes(value, options = {}) {
|
||||
const number = Number(value);
|
||||
if (options.allowZero && (!Number.isFinite(number) || number <= 0)) return 0;
|
||||
if (!Number.isFinite(number) || number < 0) throw new Error("Storage values must be positive numbers.");
|
||||
const bounded = Math.min(options.max || number, number);
|
||||
return Math.floor(bounded * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function mibToBytes(value, options = {}) {
|
||||
const number = Number(value);
|
||||
const fallbackBytes = Number(options.fallback || DEFAULT_MAX_FILE_BYTES);
|
||||
if (!Number.isFinite(number)) return fallbackBytes;
|
||||
const bounded = Math.max(options.min || 1, Math.min(options.max || number, number));
|
||||
return Math.floor(bounded * 1024 * 1024);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCESS_LEVELS,
|
||||
DATA_DIR,
|
||||
FILES_DIR,
|
||||
FORMAT_DEFINITIONS,
|
||||
HARD_MAX_FILE_BYTES,
|
||||
HARD_UPLOAD_MAX_FILES,
|
||||
INCOMING_DIR,
|
||||
adminUrl,
|
||||
createSignedUrl,
|
||||
deleteResource,
|
||||
ensureContentLibrary,
|
||||
formatBytes,
|
||||
frameworkApi,
|
||||
getResource,
|
||||
getResourceByExposedToken,
|
||||
getStorageStats,
|
||||
importUploadedFiles,
|
||||
listResources,
|
||||
normalizeAccessLevel,
|
||||
openReadStream,
|
||||
preflightIncomingRequest,
|
||||
publicUrl,
|
||||
resourceFilePath,
|
||||
serializeResource,
|
||||
setResourceAccess,
|
||||
supportedExtensions,
|
||||
supportedFormats,
|
||||
updateResourceName,
|
||||
updateStorageSettings,
|
||||
uploadLimits,
|
||||
verifySignedResource
|
||||
};
|
||||
382
src/services/content-routes.js
Normal file
382
src/services/content-routes.js
Normal file
@ -0,0 +1,382 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
let multer = null;
|
||||
try {
|
||||
multer = require("multer");
|
||||
} catch {
|
||||
multer = null;
|
||||
}
|
||||
|
||||
const {
|
||||
INCOMING_DIR,
|
||||
deleteResource,
|
||||
formatBytes,
|
||||
getResource,
|
||||
getResourceByExposedToken,
|
||||
getStorageStats,
|
||||
importUploadedFiles,
|
||||
listResources,
|
||||
preflightIncomingRequest,
|
||||
resourceFilePath,
|
||||
serializeResource,
|
||||
setResourceAccess,
|
||||
supportedExtensions,
|
||||
supportedFormats,
|
||||
updateResourceName,
|
||||
updateStorageSettings,
|
||||
uploadLimits,
|
||||
verifySignedResource
|
||||
} = require("./content-library");
|
||||
|
||||
function registerPublicContentRoutes(app) {
|
||||
app.get("/media/:token/:filename", (req, res) => {
|
||||
const resource = getResourceByExposedToken(req.params.token);
|
||||
if (!resource) return res.status(404).send("Not found.");
|
||||
return serveResource(req, res, resource, {
|
||||
cache_control: "public, max-age=0, must-revalidate",
|
||||
cors: true,
|
||||
disposition: req.query.download === "1" ? "attachment" : "inline"
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/internal/media/:id/:filename", (req, res) => {
|
||||
const resource = verifySignedResource(req.params.id, req.query.expires, req.query.sig);
|
||||
if (!resource) return res.status(404).send("Not found.");
|
||||
return serveResource(req, res, resource, {
|
||||
cache_control: "private, no-store",
|
||||
cors: true,
|
||||
disposition: req.query.download === "1" ? "attachment" : "inline"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerContentAdminRoutes(app, options = {}) {
|
||||
const requireAdmin = options.requireAdmin;
|
||||
if (typeof requireAdmin !== "function") {
|
||||
throw new Error("Content-library admin routes require an admin authorization middleware.");
|
||||
}
|
||||
const uploadMany = buildUploadMiddleware();
|
||||
|
||||
app.get("/admin/resources", requireAdmin, (req, res) => {
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
const resources = listResources().map((resource) => serializeResource(resource, { base_url: baseUrl }));
|
||||
const storage = getStorageStats();
|
||||
res.render("admin-resources", {
|
||||
title: "Resources",
|
||||
resources,
|
||||
storage,
|
||||
settings: storageSettingsForView(storage),
|
||||
formatGroups: groupFormats(supportedFormats()),
|
||||
formatBytes
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/admin/resources", requireAdmin, (req, res) => {
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.json({
|
||||
ok: true,
|
||||
resources: listResources(req.query).map((resource) => serializeResource(resource, { base_url: baseUrl })),
|
||||
storage: getStorageStats()
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/admin/resources/:id/raw/:filename", requireAdmin, (req, res) => {
|
||||
const resource = getResource(req.params.id);
|
||||
if (!resource) return res.status(404).send("Not found.");
|
||||
return serveResource(req, res, resource, {
|
||||
cache_control: "private, no-store",
|
||||
cors: false,
|
||||
disposition: req.query.download === "1" ? "attachment" : "inline"
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/admin/resources/upload",
|
||||
requireAdmin,
|
||||
guardUploadCapacity,
|
||||
uploadMany,
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (req.contentUploadError) throw req.contentUploadError;
|
||||
const imported = await importUploadedFiles(req.files, {
|
||||
access_level: req.body.access_level,
|
||||
uploaded_by: req.session.user?.id || null
|
||||
});
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
return mutationSuccess(req, res, {
|
||||
message: `${imported.length} resource${imported.length === 1 ? "" : "s"} uploaded.`,
|
||||
resources: imported.map((resource) => serializeResource(resource, { base_url: baseUrl })),
|
||||
storage: getStorageStats()
|
||||
}, "/admin/resources");
|
||||
} catch (error) {
|
||||
cleanupRequestFiles(req.files);
|
||||
return mutationError(req, res, error, "/admin/resources");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/admin/resources/settings", requireAdmin, (req, res) => {
|
||||
try {
|
||||
const storage = updateStorageSettings(req.body);
|
||||
return mutationSuccess(req, res, {
|
||||
message: "Resource storage settings saved.",
|
||||
storage,
|
||||
settings: storageSettingsForView(storage)
|
||||
}, "/admin/resources");
|
||||
} catch (error) {
|
||||
return mutationError(req, res, error, "/admin/resources");
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/resources/:id/rename", requireAdmin, (req, res) => {
|
||||
try {
|
||||
const resource = updateResourceName(req.params.id, req.body.display_name);
|
||||
return mutationSuccess(req, res, {
|
||||
message: "Resource name updated.",
|
||||
resource: serializeResource(resource, { base_url: requestBaseUrl(req) })
|
||||
}, "/admin/resources");
|
||||
} catch (error) {
|
||||
return mutationError(req, res, error, "/admin/resources");
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/resources/:id/access", requireAdmin, (req, res) => {
|
||||
try {
|
||||
const resource = setResourceAccess(req.params.id, "exposed");
|
||||
return mutationSuccess(req, res, {
|
||||
message: "Resource exposed with a new read-only URL.",
|
||||
resource: serializeResource(resource, { base_url: requestBaseUrl(req) })
|
||||
}, "/admin/resources");
|
||||
} catch (error) {
|
||||
return mutationError(req, res, error, "/admin/resources");
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/resources/:id/revoke", requireAdmin, (req, res) => {
|
||||
try {
|
||||
const resource = setResourceAccess(req.params.id, "locked");
|
||||
return mutationSuccess(req, res, {
|
||||
message: "Resource locked. Its previous exposed URL no longer works.",
|
||||
resource: serializeResource(resource, { base_url: requestBaseUrl(req) })
|
||||
}, "/admin/resources");
|
||||
} catch (error) {
|
||||
return mutationError(req, res, error, "/admin/resources");
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/admin/resources/:id/delete", requireAdmin, (req, res) => {
|
||||
try {
|
||||
const deleted = deleteResource(req.params.id);
|
||||
return mutationSuccess(req, res, {
|
||||
message: `${deleted.display_name} deleted.`,
|
||||
deleted_id: deleted.id,
|
||||
storage: getStorageStats()
|
||||
}, "/admin/resources");
|
||||
} catch (error) {
|
||||
return mutationError(req, res, error, "/admin/resources");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildUploadMiddleware() {
|
||||
if (!multer) {
|
||||
return (req, _res, next) => {
|
||||
req.contentUploadError = withStatus(new Error("File uploads require the multer dependency. Run npm install, then restart Lumi."), 503);
|
||||
next();
|
||||
};
|
||||
}
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, callback) => callback(null, INCOMING_DIR),
|
||||
filename: (_req, _file, callback) => callback(null, `${crypto.randomUUID()}.upload`)
|
||||
});
|
||||
return (req, res, next) => {
|
||||
const configured = uploadLimits();
|
||||
const uploader = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: configured.max_file_bytes,
|
||||
files: configured.max_files,
|
||||
fields: 10,
|
||||
parts: configured.max_files + 12
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const extension = path.extname(file.originalname || "").toLowerCase();
|
||||
if (supportedExtensions().includes(extension)) return callback(null, true);
|
||||
return callback(new Error(`${extension || "That file type"} is not supported by Lumi's media library.`));
|
||||
}
|
||||
});
|
||||
uploader.array("resources", configured.max_files)(req, res, (error) => {
|
||||
if (!error) return next();
|
||||
cleanupRequestFiles(req.files);
|
||||
const message = error.code === "LIMIT_FILE_SIZE"
|
||||
? `A file exceeded the configured per-file limit of ${formatBytes(configured.max_file_bytes)}.`
|
||||
: error.code === "LIMIT_FILE_COUNT"
|
||||
? `Upload no more than ${configured.max_files} files at once.`
|
||||
: error.message;
|
||||
req.contentUploadError = withStatus(new Error(message), error.code?.startsWith("LIMIT_") ? 413 : 400);
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function guardUploadCapacity(req, res, next) {
|
||||
const result = preflightIncomingRequest(req.get("content-length"));
|
||||
if (result.ok) return next();
|
||||
return mutationError(req, res, withStatus(new Error(result.reason), result.status || 507), "/admin/resources");
|
||||
}
|
||||
|
||||
function serveResource(req, res, resource, options = {}) {
|
||||
let filePath;
|
||||
let stat;
|
||||
try {
|
||||
filePath = resourceFilePath(resource);
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return res.status(404).send("Not found.");
|
||||
}
|
||||
|
||||
const etag = `"sha256-${resource.checksum_sha256}"`;
|
||||
res.set({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": options.cache_control || "private, no-store",
|
||||
"Content-Type": resource.mime,
|
||||
"Content-Disposition": contentDisposition(options.disposition || "inline", resource.original_name),
|
||||
"ETag": etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cross-Origin-Resource-Policy": options.cors ? "cross-origin" : "same-origin"
|
||||
});
|
||||
if (options.cors) res.set("Access-Control-Allow-Origin", "*");
|
||||
if (String(resource.mime || "").startsWith("image/svg+xml")) {
|
||||
res.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; sandbox");
|
||||
}
|
||||
|
||||
if (!req.headers.range && req.headers["if-none-match"] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
const range = parseByteRange(req.headers.range, stat.size);
|
||||
if (range?.invalid) {
|
||||
res.set("Content-Range", `bytes */${stat.size}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
if (range) {
|
||||
const length = range.end - range.start + 1;
|
||||
res.status(206);
|
||||
res.set({
|
||||
"Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`,
|
||||
"Content-Length": String(length)
|
||||
});
|
||||
if (req.method === "HEAD") return res.end();
|
||||
return pipeFile(res, filePath, { start: range.start, end: range.end });
|
||||
}
|
||||
|
||||
res.set("Content-Length", String(stat.size));
|
||||
if (req.method === "HEAD") return res.end();
|
||||
return pipeFile(res, filePath);
|
||||
}
|
||||
|
||||
function parseByteRange(header, size) {
|
||||
if (!header) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim());
|
||||
if (!match) return { invalid: true };
|
||||
let start;
|
||||
let end;
|
||||
if (!match[1]) {
|
||||
const suffixLength = Number(match[2]);
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0) return { invalid: true };
|
||||
start = Math.max(0, size - suffixLength);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(match[1]);
|
||||
end = match[2] ? Number(match[2]) : size - 1;
|
||||
}
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
|
||||
return { invalid: true };
|
||||
}
|
||||
return { start, end: Math.min(end, size - 1) };
|
||||
}
|
||||
|
||||
function pipeFile(res, filePath, options = undefined) {
|
||||
const stream = fs.createReadStream(filePath, options);
|
||||
stream.on("error", (error) => {
|
||||
if (!res.headersSent) res.status(500).send("Unable to read resource.");
|
||||
else res.destroy(error);
|
||||
});
|
||||
stream.pipe(res);
|
||||
return stream;
|
||||
}
|
||||
|
||||
function contentDisposition(type, filename) {
|
||||
const safeAscii = String(filename || "resource")
|
||||
.replace(/[^\x20-\x7e]/g, "_")
|
||||
.replace(/["\\]/g, "_");
|
||||
return `${type}; filename="${safeAscii}"; filename*=UTF-8''${encodeURIComponent(filename || "resource")}`;
|
||||
}
|
||||
|
||||
function requestBaseUrl(req) {
|
||||
return `${req.protocol}://${req.get("host")}`;
|
||||
}
|
||||
|
||||
function groupFormats(formats) {
|
||||
const groups = {};
|
||||
formats.forEach((format) => {
|
||||
if (!groups[format.category]) groups[format.category] = [];
|
||||
groups[format.category].push(format.extension);
|
||||
});
|
||||
return Object.entries(groups)
|
||||
.map(([category, extensions]) => ({ category, extensions: extensions.sort() }))
|
||||
.sort((left, right) => left.category.localeCompare(right.category));
|
||||
}
|
||||
|
||||
function storageSettingsForView(storage) {
|
||||
return {
|
||||
limit_gib: storage.limit_bytes > 0 ? round(storage.limit_bytes / (1024 ** 3), 2) : 0,
|
||||
reserve_gib: round(storage.reserve_bytes / (1024 ** 3), 2),
|
||||
max_file_mib: Math.round(storage.max_file_bytes / (1024 ** 2)),
|
||||
max_files: storage.max_files
|
||||
};
|
||||
}
|
||||
|
||||
function mutationSuccess(req, res, payload, fallbackPath) {
|
||||
if (wantsJson(req)) return res.json({ ok: true, ...payload });
|
||||
req.session.flash = { type: "success", message: payload.message || "Saved." };
|
||||
return res.redirect(fallbackPath);
|
||||
}
|
||||
|
||||
function mutationError(req, res, error, fallbackPath) {
|
||||
const status = Number(error?.status) || 400;
|
||||
const message = error?.message || "Unable to update the resource library.";
|
||||
if (wantsJson(req)) return res.status(status).json({ ok: false, error: message });
|
||||
req.session.flash = { type: "error", message };
|
||||
return res.redirect(fallbackPath);
|
||||
}
|
||||
|
||||
function wantsJson(req) {
|
||||
return req.xhr || String(req.get("accept") || "").toLowerCase().includes("application/json");
|
||||
}
|
||||
|
||||
function cleanupRequestFiles(files) {
|
||||
for (const file of Array.isArray(files) ? files : []) {
|
||||
if (!file?.path) continue;
|
||||
try { fs.rmSync(file.path, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function withStatus(error, status) {
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
function round(value, places) {
|
||||
const factor = 10 ** places;
|
||||
return Math.round(Number(value) * factor) / factor;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseByteRange,
|
||||
registerContentAdminRoutes,
|
||||
registerPublicContentRoutes,
|
||||
serveResource
|
||||
};
|
||||
@ -369,6 +369,29 @@ function migrate() {
|
||||
CREATE INDEX IF NOT EXISTS overlay_event_hooks_event_idx ON overlay_event_hooks (event_type, enabled);
|
||||
CREATE INDEX IF NOT EXISTS overlay_event_hooks_overlay_idx ON overlay_event_hooks (overlay_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_name TEXT NOT NULL UNIQUE,
|
||||
mime TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
preview_kind TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
access_level TEXT NOT NULL DEFAULT 'locked',
|
||||
public_token_hash TEXT UNIQUE,
|
||||
public_token_encrypted TEXT,
|
||||
uploaded_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS content_resources_created_idx ON content_resources (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS content_resources_access_idx ON content_resources (access_level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS content_resources_category_idx ON content_resources (category, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overlay_obs_settings (
|
||||
overlay_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
@ -251,7 +251,7 @@ function registerPublicOverlayRoutes(app) {
|
||||
const eventsPath = `${req.path.replace(/\/$/, "")}/events`;
|
||||
const healthPath = `${req.path.replace(/\/$/, "")}/module-health`;
|
||||
const bridgePath = `${req.path.replace(/\/$/, "")}/obs-bridge`;
|
||||
res.set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; frame-src https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';");
|
||||
res.set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; media-src https: http: data: blob:; frame-src https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';");
|
||||
return res.render("overlay-render", {
|
||||
statePath,
|
||||
eventsPath,
|
||||
|
||||
616
src/web/public/content-library.css
Normal file
616
src/web/public/content-library.css
Normal file
@ -0,0 +1,616 @@
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.content-library {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-5);
|
||||
}
|
||||
|
||||
.resource-storage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) repeat(3, minmax(10rem, 1fr));
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-storage-card {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.resource-storage-card > span:first-child {
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.resource-storage-card strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--lumi-font-display);
|
||||
font-size: clamp(1.2rem, 2vw, 1.7rem);
|
||||
}
|
||||
|
||||
.resource-storage-card small,
|
||||
.resource-storage-card p {
|
||||
margin: 0;
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.resource-storage-primary {
|
||||
background:
|
||||
linear-gradient(125deg, color-mix(in srgb, var(--lumi-primary) 12%, transparent), transparent 65%),
|
||||
var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.resource-meter {
|
||||
width: 100%;
|
||||
height: 0.55rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: color-mix(in srgb, var(--lumi-border) 78%, transparent);
|
||||
}
|
||||
|
||||
.resource-meter > span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--lumi-primary), var(--lumi-accent));
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.resource-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(18rem, 0.8fr);
|
||||
gap: var(--lumi-space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.resource-upload-panel,
|
||||
.resource-settings-panel {
|
||||
min-width: 0;
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.resource-upload-panel h2,
|
||||
.resource-browser-header h2 {
|
||||
margin-bottom: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.resource-upload-panel form {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-dropzone {
|
||||
min-height: 12rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-5);
|
||||
border: 2px dashed color-mix(in srgb, var(--lumi-primary) 48%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: color-mix(in srgb, var(--lumi-primary) 5%, var(--lumi-surface));
|
||||
color: var(--lumi-text);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.resource-dropzone:hover,
|
||||
.resource-dropzone.is-dragging {
|
||||
border-color: var(--lumi-primary);
|
||||
background: color-mix(in srgb, var(--lumi-primary) 11%, var(--lumi-surface));
|
||||
}
|
||||
|
||||
.resource-dropzone.is-dragging {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.resource-dropzone input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resource-dropzone-icon {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--lumi-radius-pill);
|
||||
background: var(--lumi-primary);
|
||||
color: var(--lumi-button-text);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.resource-dropzone small,
|
||||
.resource-dropzone > span:not(.resource-dropzone-icon) {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.resource-selected-files {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
padding: var(--lumi-space-2);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.resource-selected-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-3);
|
||||
min-width: 0;
|
||||
padding: var(--lumi-space-2);
|
||||
border-bottom: 1px solid var(--lumi-border);
|
||||
}
|
||||
|
||||
.resource-selected-file:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-selected-file span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-selected-file small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.resource-upload-options {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-upload-options .field {
|
||||
flex: 1 1 18rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resource-upload-progress {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.resource-status {
|
||||
min-height: 1.5em;
|
||||
margin: 0;
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.resource-status.is-error,
|
||||
.resource-preview-error {
|
||||
color: var(--lumi-danger);
|
||||
}
|
||||
|
||||
.resource-status.is-success {
|
||||
color: var(--lumi-success, var(--lumi-primary));
|
||||
}
|
||||
|
||||
.resource-settings-panel > summary {
|
||||
min-height: var(--lumi-control-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-settings-panel[open] > summary {
|
||||
margin-bottom: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.resource-format-groups {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-2);
|
||||
margin-top: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.resource-format-groups > div {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
padding: var(--lumi-space-2) 0;
|
||||
border-top: 1px solid var(--lumi-border);
|
||||
}
|
||||
|
||||
.resource-format-groups span {
|
||||
color: var(--lumi-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.resource-browser-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-browser-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.resource-browser-controls input,
|
||||
.resource-browser-controls select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.resource-search input {
|
||||
min-width: min(18rem, 68vw);
|
||||
}
|
||||
|
||||
.resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: 11rem minmax(0, 1fr);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface);
|
||||
box-shadow: var(--lumi-shadow-sm);
|
||||
}
|
||||
|
||||
.resource-card[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resource-preview-tile {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--lumi-border);
|
||||
border-radius: 0;
|
||||
background:
|
||||
radial-gradient(circle at 25% 15%, color-mix(in srgb, var(--lumi-primary) 16%, transparent), transparent 48%),
|
||||
var(--lumi-surface-subtle);
|
||||
color: var(--lumi-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-preview-tile:hover {
|
||||
background:
|
||||
radial-gradient(circle at 25% 15%, color-mix(in srgb, var(--lumi-primary) 24%, transparent), transparent 54%),
|
||||
var(--lumi-surface-raised);
|
||||
}
|
||||
|
||||
.resource-preview-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: repeating-conic-gradient(
|
||||
color-mix(in srgb, var(--lumi-border) 35%, transparent) 0 25%,
|
||||
transparent 0 50%
|
||||
) 50% / 1rem 1rem;
|
||||
}
|
||||
|
||||
.resource-type-mark {
|
||||
min-width: 4.5rem;
|
||||
min-height: 3.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--lumi-space-2);
|
||||
border: 1px solid color-mix(in srgb, var(--lumi-primary) 35%, var(--lumi-border));
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: color-mix(in srgb, var(--lumi-primary) 10%, var(--lumi-surface));
|
||||
color: var(--lumi-primary);
|
||||
font-family: var(--lumi-font-mono);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.resource-card-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.resource-card-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.resource-card-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-card-heading h3 {
|
||||
margin-bottom: var(--lumi-space-1);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.resource-card-heading p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-metadata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--lumi-space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resource-metadata > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: var(--lumi-space-2);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.resource-metadata dt {
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.resource-metadata dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.resource-public-url {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.resource-public-url[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resource-public-url > label {
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.resource-url-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.resource-url-row input {
|
||||
min-width: 0;
|
||||
font-family: var(--lumi-font-mono);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.resource-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.resource-card-actions form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.resource-edit-details {
|
||||
padding-top: var(--lumi-space-2);
|
||||
border-top: 1px solid var(--lumi-border);
|
||||
}
|
||||
|
||||
.resource-edit-details > summary {
|
||||
min-height: 2.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--lumi-text-muted);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-edit-details[open] > summary {
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-edit-details form + form {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-rename-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.resource-rename-form label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.resource-preview-modal {
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.resource-preview-dialog {
|
||||
width: min(94vw, 68rem);
|
||||
max-height: min(92vh, 58rem);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(10rem, 1fr) auto auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-preview-stage {
|
||||
min-height: 18rem;
|
||||
max-height: 68vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background:
|
||||
repeating-conic-gradient(
|
||||
color-mix(in srgb, var(--lumi-border) 25%, transparent) 0 25%,
|
||||
transparent 0 50%
|
||||
) 50% / 1.25rem 1.25rem,
|
||||
var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.resource-preview-stage img,
|
||||
.resource-preview-stage video,
|
||||
.resource-preview-stage iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 68vh;
|
||||
border: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.resource-preview-stage audio {
|
||||
width: min(90%, 42rem);
|
||||
}
|
||||
|
||||
.resource-preview-stage pre {
|
||||
width: 100%;
|
||||
min-height: 18rem;
|
||||
max-height: 65vh;
|
||||
margin: 0;
|
||||
padding: var(--lumi-space-4);
|
||||
overflow: auto;
|
||||
background: var(--lumi-surface);
|
||||
color: var(--lumi-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-font-preview {
|
||||
width: 100%;
|
||||
padding: var(--lumi-space-5);
|
||||
color: var(--lumi-text);
|
||||
font-size: clamp(1.6rem, 5vw, 4rem);
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resource-preview-fallback {
|
||||
max-width: 36rem;
|
||||
padding: var(--lumi-space-5);
|
||||
color: var(--lumi-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.resource-storage-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.resource-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.resource-storage-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.resource-upload-panel,
|
||||
.resource-settings-panel,
|
||||
.resource-card-body {
|
||||
padding: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.resource-dropzone {
|
||||
min-height: 10rem;
|
||||
padding: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.resource-browser-controls,
|
||||
.resource-browser-controls label,
|
||||
.resource-browser-controls input,
|
||||
.resource-browser-controls select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
grid-template-rows: 9rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.resource-card-actions > *,
|
||||
.resource-card-actions form,
|
||||
.resource-card-actions .button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.resource-rename-form,
|
||||
.resource-url-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.resource-preview-dialog {
|
||||
width: 100%;
|
||||
max-height: 96vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.resource-dropzone,
|
||||
.resource-meter > span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
654
src/web/public/content-library.js
Normal file
654
src/web/public/content-library.js
Normal file
@ -0,0 +1,654 @@
|
||||
(() => {
|
||||
if (window.LumiContentLibrary?.init) {
|
||||
window.LumiContentLibrary.init(document);
|
||||
return;
|
||||
}
|
||||
|
||||
const initializedLibraries = new WeakSet();
|
||||
const initializedUploads = new WeakSet();
|
||||
let previewReturnFocus = null;
|
||||
|
||||
function init(root = document) {
|
||||
root.querySelectorAll?.("[data-content-library]").forEach(initLibrary);
|
||||
root.querySelectorAll?.("[data-resource-upload-form]").forEach(initUploadForm);
|
||||
}
|
||||
|
||||
function initLibrary(library) {
|
||||
if (initializedLibraries.has(library)) return;
|
||||
initializedLibraries.add(library);
|
||||
const update = () => applyFilters(library);
|
||||
library.querySelector("[data-resource-search]")?.addEventListener("input", update);
|
||||
library.querySelector("[data-resource-category-filter]")?.addEventListener("change", update);
|
||||
library.querySelector("[data-resource-access-filter]")?.addEventListener("change", update);
|
||||
applyFilters(library);
|
||||
}
|
||||
|
||||
function initUploadForm(form) {
|
||||
if (initializedUploads.has(form)) return;
|
||||
initializedUploads.add(form);
|
||||
const input = form.querySelector("[data-resource-file-input]");
|
||||
const dropzone = form.querySelector("[data-resource-dropzone]");
|
||||
if (!input || !dropzone) return;
|
||||
|
||||
input.addEventListener("change", () => renderSelectedFiles(form));
|
||||
dropzone.addEventListener("keydown", (event) => {
|
||||
if (!["Enter", " "].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
input.click();
|
||||
});
|
||||
["dragenter", "dragover"].forEach((type) => {
|
||||
dropzone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
["dragleave", "dragend", "drop"].forEach((type) => {
|
||||
dropzone.addEventListener(type, () => dropzone.classList.remove("is-dragging"));
|
||||
});
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (!event.dataTransfer?.files?.length) return;
|
||||
try {
|
||||
const transfer = new DataTransfer();
|
||||
Array.from(event.dataTransfer.files).forEach((file) => transfer.items.add(file));
|
||||
input.files = transfer.files;
|
||||
} catch {
|
||||
input.files = event.dataTransfer.files;
|
||||
}
|
||||
renderSelectedFiles(form);
|
||||
});
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
uploadResources(form);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedFiles(form) {
|
||||
const input = form.querySelector("[data-resource-file-input]");
|
||||
const list = form.querySelector("[data-resource-selected-files]");
|
||||
if (!input || !list) return;
|
||||
list.replaceChildren();
|
||||
const files = Array.from(input.files || []);
|
||||
list.hidden = files.length === 0;
|
||||
files.forEach((file) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "resource-selected-file";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const size = document.createElement("small");
|
||||
size.textContent = formatBytes(file.size);
|
||||
row.append(name, size);
|
||||
list.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
function uploadResources(form) {
|
||||
const input = form.querySelector("[data-resource-file-input]");
|
||||
const files = Array.from(input?.files || []);
|
||||
if (!files.length) {
|
||||
setStatus(form, "Choose at least one file to upload.", "error");
|
||||
return;
|
||||
}
|
||||
const button = form.querySelector("[data-resource-upload-submit]");
|
||||
const progress = form.querySelector("[data-resource-upload-progress]");
|
||||
const bar = form.querySelector("[data-resource-upload-progress-bar]");
|
||||
const text = form.querySelector("[data-resource-upload-progress-text]");
|
||||
button.disabled = true;
|
||||
progress.hidden = false;
|
||||
setStatus(form, "", "");
|
||||
updateUploadProgress(progress, bar, text, 0, "Preparing upload…");
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.open((form.getAttribute("method") || "POST").toUpperCase(), form.getAttribute("action"));
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
request.setRequestHeader("X-Requested-With", "XMLHttpRequest");
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
if (text) text.textContent = "Uploading…";
|
||||
return;
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)));
|
||||
updateUploadProgress(progress, bar, text, percent, `Uploading… ${percent}%`);
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(request.responseText || "{}"); } catch {}
|
||||
if (request.status < 200 || request.status >= 300 || !payload.ok) {
|
||||
finishUpload(form, button, progress, payload.error || `Upload failed (${request.status || "network error"}).`, false);
|
||||
return;
|
||||
}
|
||||
const library = form.closest("[data-content-library]");
|
||||
payload.resources?.forEach((resource) => addOrUpdateResource(library, resource));
|
||||
updateStorage(library, payload.storage);
|
||||
form.reset();
|
||||
renderSelectedFiles(form);
|
||||
finishUpload(form, button, progress, payload.message || "Resources uploaded.", true);
|
||||
applyFilters(library);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
finishUpload(form, button, progress, "Upload failed because Lumi could not be reached.", false);
|
||||
});
|
||||
request.addEventListener("abort", () => {
|
||||
finishUpload(form, button, progress, "Upload cancelled.", false);
|
||||
});
|
||||
request.send(new FormData(form));
|
||||
}
|
||||
|
||||
function updateUploadProgress(progress, bar, text, percent, message) {
|
||||
progress?.querySelector("[role='progressbar']")?.setAttribute("aria-valuenow", String(percent));
|
||||
if (bar) bar.style.width = `${percent}%`;
|
||||
if (text) text.textContent = message;
|
||||
}
|
||||
|
||||
function finishUpload(form, button, progress, message, success) {
|
||||
button.disabled = false;
|
||||
if (success) {
|
||||
const bar = progress.querySelector("[data-resource-upload-progress-bar]");
|
||||
const text = progress.querySelector("[data-resource-upload-progress-text]");
|
||||
updateUploadProgress(progress, bar, text, 100, "Upload complete");
|
||||
window.setTimeout(() => { progress.hidden = true; }, 900);
|
||||
} else {
|
||||
progress.hidden = true;
|
||||
}
|
||||
setStatus(form, message, success ? "success" : "error");
|
||||
notify(message, success ? "info" : "danger");
|
||||
}
|
||||
|
||||
document.addEventListener("submit", async (event) => {
|
||||
const form = event.target.closest?.("[data-resource-action-form]");
|
||||
if (!form) return;
|
||||
if (event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter;
|
||||
const button = submitter || form.querySelector("button[type='submit'], input[type='submit']");
|
||||
const body = new FormData(form);
|
||||
if (body.has("confirmation_token")) {
|
||||
document.querySelector("[data-destructive-cancel]")?.click();
|
||||
}
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
const response = await fetch(effectiveAction(form, submitter), {
|
||||
method: effectiveMethod(form, submitter),
|
||||
body,
|
||||
headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
|
||||
credentials: "same-origin"
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !payload.ok) throw new Error(payload.error || "The resource update failed.");
|
||||
const library = form.closest("[data-content-library]") || document.querySelector("[data-content-library]");
|
||||
if (payload.resource) addOrUpdateResource(library, payload.resource);
|
||||
if (payload.deleted_id) removeResource(library, payload.deleted_id);
|
||||
if (payload.storage) updateStorage(library, payload.storage);
|
||||
if (payload.settings) updateSettingsForm(library, payload.settings);
|
||||
setStatus(library, payload.message || "Saved.", "success");
|
||||
notify(payload.message || "Saved.", "info");
|
||||
applyFilters(library);
|
||||
} catch (error) {
|
||||
const library = form.closest("[data-content-library]") || document.querySelector("[data-content-library]");
|
||||
setStatus(library, error.message || "The resource update failed.", "error");
|
||||
notify(error.message || "The resource update failed.", "danger");
|
||||
} finally {
|
||||
if (button && document.contains(button)) button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const previewButton = event.target.closest?.("[data-resource-preview]");
|
||||
if (previewButton) {
|
||||
event.preventDefault();
|
||||
openPreview(previewButton);
|
||||
return;
|
||||
}
|
||||
const closeButton = event.target.closest?.("[data-resource-preview-close]");
|
||||
if (closeButton) {
|
||||
event.preventDefault();
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
const copyButton = event.target.closest?.("[data-resource-copy-url]");
|
||||
if (copyButton) {
|
||||
event.preventDefault();
|
||||
const input = copyButton.closest("[data-resource-public-block]")?.querySelector("[data-resource-public-url]");
|
||||
copyText(input?.value || "", copyButton);
|
||||
return;
|
||||
}
|
||||
const modal = event.target.closest?.("[data-resource-preview-modal]");
|
||||
if (modal && event.target === modal) closePreview();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const modal = document.querySelector("[data-resource-preview-modal].is-open");
|
||||
if (!modal) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePreview();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(modal.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)).filter((item) => !item.hidden && item.getClientRects().length > 0);
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
modal.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function addOrUpdateResource(library, resource) {
|
||||
if (!library || !resource) return;
|
||||
const existing = library.querySelector(`[data-resource-card][data-resource-id="${cssEscape(resource.id)}"]`);
|
||||
if (existing) {
|
||||
updateResourceCard(existing, resource);
|
||||
return existing;
|
||||
}
|
||||
const card = buildResourceCard(resource);
|
||||
library.querySelector("[data-resource-grid]")?.prepend(card);
|
||||
library.querySelector("[data-resource-empty]")?.setAttribute("hidden", "");
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateResourceCard(card, resource) {
|
||||
card.dataset.resourceName = `${resource.display_name} ${resource.original_name} ${resource.mime}`.toLowerCase();
|
||||
card.dataset.resourceCategory = resource.category;
|
||||
card.dataset.resourceAccess = resource.access_level;
|
||||
card.querySelector("[data-resource-title]").textContent = resource.display_name;
|
||||
const rename = card.querySelector('input[name="display_name"]');
|
||||
if (rename) rename.value = resource.display_name;
|
||||
const badge = card.querySelector("[data-resource-access-badge]");
|
||||
if (badge) {
|
||||
badge.textContent = resource.access_level === "exposed" ? "Exposed" : "Locked";
|
||||
badge.classList.toggle("success", resource.access_level === "exposed");
|
||||
}
|
||||
const block = card.querySelector("[data-resource-public-block]");
|
||||
const publicInput = card.querySelector("[data-resource-public-url]");
|
||||
if (block) block.hidden = !resource.public_url;
|
||||
if (publicInput) publicInput.value = resource.public_url || "";
|
||||
const accessForm = card.querySelector("[data-resource-access-form]");
|
||||
const accessButton = card.querySelector("[data-resource-access-button]");
|
||||
if (accessForm) {
|
||||
accessForm.setAttribute("action", `/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`);
|
||||
if (resource.access_level === "exposed") {
|
||||
accessForm.dataset.confirmMode = "modal";
|
||||
accessForm.dataset.confirmTitle = "Lock resource";
|
||||
accessForm.dataset.confirmText = "Lock this resource and revoke its current read-only URL? Anything using that URL will stop working.";
|
||||
accessForm.dataset.confirmLabel = "Lock resource";
|
||||
} else {
|
||||
delete accessForm.dataset.confirmMode;
|
||||
delete accessForm.dataset.confirmTitle;
|
||||
delete accessForm.dataset.confirmText;
|
||||
delete accessForm.dataset.confirmLabel;
|
||||
}
|
||||
}
|
||||
if (accessButton) accessButton.textContent = resource.access_level === "exposed" ? "Lock" : "Expose";
|
||||
card.querySelectorAll("[data-resource-preview]").forEach((button) => applyPreviewData(button, resource));
|
||||
}
|
||||
|
||||
function removeResource(library, id) {
|
||||
library?.querySelector(`[data-resource-card][data-resource-id="${cssEscape(id)}"]`)?.remove();
|
||||
}
|
||||
|
||||
function buildResourceCard(resource) {
|
||||
const card = element("article", "resource-card");
|
||||
card.dataset.resourceCard = "";
|
||||
card.dataset.resourceId = resource.id;
|
||||
card.dataset.resourceName = `${resource.display_name} ${resource.original_name} ${resource.mime}`.toLowerCase();
|
||||
card.dataset.resourceCategory = resource.category;
|
||||
card.dataset.resourceAccess = resource.access_level;
|
||||
|
||||
const tile = element("button", "resource-preview-tile");
|
||||
tile.type = "button";
|
||||
tile.dataset.resourcePreview = "";
|
||||
tile.setAttribute("aria-label", `Preview ${resource.display_name}`);
|
||||
applyPreviewData(tile, resource);
|
||||
if (resource.preview_kind === "image") {
|
||||
const image = document.createElement("img");
|
||||
image.src = resource.admin_url;
|
||||
image.alt = "";
|
||||
image.loading = "lazy";
|
||||
tile.append(image);
|
||||
} else {
|
||||
const mark = element("span", "resource-type-mark", resource.extension.replace(".", "").toUpperCase());
|
||||
mark.setAttribute("aria-hidden", "true");
|
||||
tile.append(mark, element("span", "", "Preview"));
|
||||
}
|
||||
|
||||
const body = element("div", "resource-card-body");
|
||||
const heading = element("div", "resource-card-heading");
|
||||
const headingText = document.createElement("div");
|
||||
const title = element("h3", "", resource.display_name);
|
||||
title.dataset.resourceTitle = "";
|
||||
const original = element("p", "", resource.original_name);
|
||||
original.title = resource.original_name;
|
||||
headingText.append(title, original);
|
||||
const badge = element("span", `badge${resource.access_level === "exposed" ? " success" : ""}`, resource.access_level === "exposed" ? "Exposed" : "Locked");
|
||||
badge.dataset.resourceAccessBadge = "";
|
||||
heading.append(headingText, badge);
|
||||
|
||||
const metadata = element("dl", "resource-metadata");
|
||||
metadata.append(metadataItem("Type", `${resource.category} · ${resource.extension}`), metadataItem("Size", resource.size_display));
|
||||
|
||||
const publicBlock = element("div", "resource-public-url");
|
||||
publicBlock.dataset.resourcePublicBlock = "";
|
||||
publicBlock.hidden = !resource.public_url;
|
||||
publicBlock.append(element("label", "", "Read-only URL"));
|
||||
const urlRow = element("div", "resource-url-row");
|
||||
const urlInput = document.createElement("input");
|
||||
urlInput.readOnly = true;
|
||||
urlInput.value = resource.public_url || "";
|
||||
urlInput.dataset.resourcePublicUrl = "";
|
||||
urlInput.setAttribute("aria-label", `Read-only URL for ${resource.display_name}`);
|
||||
const copy = button("Copy", "button subtle");
|
||||
copy.dataset.resourceCopyUrl = "";
|
||||
urlRow.append(urlInput, copy);
|
||||
publicBlock.append(urlRow);
|
||||
|
||||
const actions = element("div", "resource-card-actions");
|
||||
const preview = button("Preview", "button subtle");
|
||||
preview.dataset.resourcePreview = "";
|
||||
applyPreviewData(preview, resource);
|
||||
const download = element("a", "button subtle", "Download");
|
||||
download.href = `${resource.admin_url}?download=1`;
|
||||
download.download = "";
|
||||
const accessForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`);
|
||||
accessForm.dataset.resourceAccessForm = "";
|
||||
if (resource.access_level === "exposed") {
|
||||
accessForm.dataset.confirmMode = "modal";
|
||||
accessForm.dataset.confirmTitle = "Lock resource";
|
||||
accessForm.dataset.confirmText = "Lock this resource and revoke its current read-only URL? Anything using that URL will stop working.";
|
||||
accessForm.dataset.confirmLabel = "Lock resource";
|
||||
}
|
||||
const accessButton = button(resource.access_level === "exposed" ? "Lock" : "Expose", "button subtle", "submit");
|
||||
accessButton.dataset.resourceAccessButton = "";
|
||||
accessForm.append(accessButton);
|
||||
actions.append(preview, download, accessForm);
|
||||
|
||||
const details = element("details", "resource-edit-details");
|
||||
details.append(element("summary", "", "More actions"));
|
||||
const renameForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/rename`, "resource-rename-form");
|
||||
const renameLabel = document.createElement("label");
|
||||
renameLabel.append(element("span", "", "Display name"));
|
||||
const renameInput = document.createElement("input");
|
||||
renameInput.name = "display_name";
|
||||
renameInput.maxLength = 160;
|
||||
renameInput.required = true;
|
||||
renameInput.value = resource.display_name;
|
||||
renameLabel.append(renameInput);
|
||||
renameForm.append(renameLabel, button("Rename", "button subtle", "submit"));
|
||||
const deleteForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/delete`);
|
||||
deleteForm.dataset.confirmMode = "modal";
|
||||
deleteForm.dataset.confirmTitle = "Delete resource";
|
||||
deleteForm.dataset.confirmText = `Permanently delete '${resource.display_name}' from Lumi? Any overlay or feature using it will stop working.`;
|
||||
deleteForm.dataset.confirmLabel = "Delete resource";
|
||||
deleteForm.append(button("Delete resource", "button danger", "submit"));
|
||||
details.append(renameForm, deleteForm);
|
||||
|
||||
body.append(heading, metadata, publicBlock, actions, details);
|
||||
card.append(tile, body);
|
||||
return card;
|
||||
}
|
||||
|
||||
function actionForm(action, className = "") {
|
||||
const form = element("form", className);
|
||||
form.method = "post";
|
||||
form.action = action;
|
||||
form.dataset.resourceActionForm = "";
|
||||
return form;
|
||||
}
|
||||
|
||||
function metadataItem(label, value) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.append(element("dt", "", label), element("dd", "", value));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function button(text, className, type = "button") {
|
||||
const item = element("button", className, text);
|
||||
item.type = type;
|
||||
return item;
|
||||
}
|
||||
|
||||
function element(tag, className = "", text = null) {
|
||||
const item = document.createElement(tag);
|
||||
if (className) item.className = className;
|
||||
if (text !== null) item.textContent = text;
|
||||
return item;
|
||||
}
|
||||
|
||||
function applyPreviewData(button, resource) {
|
||||
button.dataset.resourceId = resource.id;
|
||||
button.dataset.resourceDisplayName = resource.display_name;
|
||||
button.dataset.resourceOriginalName = resource.original_name;
|
||||
button.dataset.resourceUrl = resource.admin_url;
|
||||
button.dataset.resourcePreviewKind = resource.preview_kind;
|
||||
button.dataset.resourceMime = resource.mime;
|
||||
button.dataset.resourceSize = resource.size_display;
|
||||
}
|
||||
|
||||
function applyFilters(library) {
|
||||
if (!library) return;
|
||||
const search = (library.querySelector("[data-resource-search]")?.value || "").trim().toLowerCase();
|
||||
const category = library.querySelector("[data-resource-category-filter]")?.value || "";
|
||||
const access = library.querySelector("[data-resource-access-filter]")?.value || "";
|
||||
const cards = Array.from(library.querySelectorAll("[data-resource-card]"));
|
||||
let visible = 0;
|
||||
cards.forEach((card) => {
|
||||
const matches = (!search || card.dataset.resourceName.includes(search)) &&
|
||||
(!category || card.dataset.resourceCategory === category) &&
|
||||
(!access || card.dataset.resourceAccess === access);
|
||||
card.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
});
|
||||
const empty = library.querySelector("[data-resource-empty]");
|
||||
const filterEmpty = library.querySelector("[data-resource-filter-empty]");
|
||||
if (empty) empty.hidden = cards.length > 0;
|
||||
if (filterEmpty) filterEmpty.hidden = cards.length === 0 || visible > 0;
|
||||
}
|
||||
|
||||
async function openPreview(source) {
|
||||
const card = source.closest("[data-resource-card]");
|
||||
const dataSource = source.dataset.resourceUrl ? source : card?.querySelector(".resource-preview-tile[data-resource-preview]");
|
||||
if (!dataSource) return;
|
||||
const modal = document.querySelector("[data-resource-preview-modal]");
|
||||
const stage = modal?.querySelector("[data-resource-preview-stage]");
|
||||
const error = modal?.querySelector("[data-resource-preview-error]");
|
||||
if (!modal || !stage || !error) return;
|
||||
stage.replaceChildren();
|
||||
error.hidden = true;
|
||||
error.textContent = "";
|
||||
modal.querySelector("[data-resource-preview-title]").textContent = dataSource.dataset.resourceDisplayName || "Resource preview";
|
||||
modal.querySelector("[data-resource-preview-meta]").textContent = `${dataSource.dataset.resourceOriginalName || ""} · ${dataSource.dataset.resourceSize || ""}`;
|
||||
const download = modal.querySelector("[data-resource-preview-download]");
|
||||
download.href = `${dataSource.dataset.resourceUrl}?download=1`;
|
||||
previewReturnFocus = source instanceof HTMLElement ? source : document.activeElement;
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
|
||||
const kind = dataSource.dataset.resourcePreviewKind;
|
||||
const url = dataSource.dataset.resourceUrl;
|
||||
if (kind === "image") {
|
||||
const image = document.createElement("img");
|
||||
image.src = url;
|
||||
image.alt = dataSource.dataset.resourceDisplayName || "Resource preview";
|
||||
image.addEventListener("error", () => showPreviewError(error, "This browser could not decode the image format."));
|
||||
stage.append(image);
|
||||
} else if (kind === "video") {
|
||||
const video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "metadata";
|
||||
video.src = url;
|
||||
video.addEventListener("error", () => showPreviewError(error, "This browser could not decode the video. The original file is still stored and available for download or delivery."));
|
||||
stage.append(video);
|
||||
} else if (kind === "audio") {
|
||||
const audio = document.createElement("audio");
|
||||
audio.controls = true;
|
||||
audio.preload = "metadata";
|
||||
audio.src = url;
|
||||
audio.addEventListener("error", () => showPreviewError(error, "This browser could not decode the audio. The original file is still stored and available for download or delivery."));
|
||||
stage.append(audio);
|
||||
} else if (kind === "pdf") {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = url;
|
||||
frame.title = dataSource.dataset.resourceDisplayName || "PDF preview";
|
||||
frame.setAttribute("sandbox", "");
|
||||
stage.append(frame);
|
||||
} else if (kind === "text") {
|
||||
const pre = document.createElement("pre");
|
||||
pre.textContent = "Loading preview…";
|
||||
stage.append(pre);
|
||||
try {
|
||||
const response = await fetch(url, { headers: { Range: "bytes=0-131071" }, credentials: "same-origin" });
|
||||
if (!response.ok && response.status !== 206) throw new Error("Preview unavailable.");
|
||||
const text = await response.text();
|
||||
pre.textContent = text + (text.length >= 131072 ? "\n\n— Preview truncated —" : "");
|
||||
} catch (fetchError) {
|
||||
pre.remove();
|
||||
showPreviewError(error, fetchError.message || "Preview unavailable.");
|
||||
}
|
||||
} else if (kind === "font") {
|
||||
const family = `LumiResource_${String(dataSource.dataset.resourceId || "font").replace(/[^a-z0-9]/gi, "_")}`;
|
||||
const style = document.createElement("style");
|
||||
style.dataset.resourceFontPreview = "";
|
||||
style.textContent = `@font-face { font-family: "${family}"; src: url("${url.replace(/["\\]/g, "")}"); }`;
|
||||
const sample = element("div", "resource-font-preview", "Cozy Carnage · Lumi 0123456789");
|
||||
sample.style.fontFamily = `"${family}", sans-serif`;
|
||||
stage.append(style, sample);
|
||||
} else {
|
||||
stage.append(element("div", "resource-preview-fallback", "This format does not have an in-browser preview. Use Download to inspect the original file."));
|
||||
}
|
||||
window.setTimeout(() => modal.querySelector("[data-resource-preview-close]")?.focus(), 0);
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
const modal = document.querySelector("[data-resource-preview-modal].is-open");
|
||||
if (!modal) return;
|
||||
modal.querySelectorAll("video, audio").forEach((media) => {
|
||||
media.pause();
|
||||
media.removeAttribute("src");
|
||||
media.load();
|
||||
});
|
||||
modal.querySelector("[data-resource-preview-stage]")?.replaceChildren();
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
const returnTarget = previewReturnFocus;
|
||||
previewReturnFocus = null;
|
||||
if (returnTarget instanceof HTMLElement && document.contains(returnTarget)) {
|
||||
window.setTimeout(() => returnTarget.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function showPreviewError(target, message) {
|
||||
target.textContent = message;
|
||||
target.hidden = false;
|
||||
}
|
||||
|
||||
function updateStorage(library, storage) {
|
||||
if (!library || !storage) return;
|
||||
setText(library, "[data-storage-available]", storage.effective_available_bytes === null ? "Unknown" : formatBytes(storage.effective_available_bytes));
|
||||
setText(library, "[data-storage-used]", formatBytes(storage.used_bytes));
|
||||
setText(library, "[data-storage-count]", String(storage.file_count));
|
||||
setText(library, "[data-storage-disk-free]", storage.disk_free_bytes === null ? "Unavailable" : formatBytes(storage.disk_free_bytes));
|
||||
setText(library, "[data-storage-limit]", storage.quota_enabled ? formatBytes(storage.limit_bytes) : "Unlimited");
|
||||
setText(library, "[data-storage-max-file]", formatBytes(storage.max_file_bytes));
|
||||
setText(library, "[data-storage-available-note]", storage.quota_enabled
|
||||
? "Limited by the smaller of remaining quota and usable disk space."
|
||||
: "Based on disk space after Lumi's safety reserve.");
|
||||
const meter = library.querySelector("[data-storage-meter]");
|
||||
if (meter) meter.style.width = `${Math.max(0, Math.min(100, Number(storage.usage_percent) || 0))}%`;
|
||||
const progress = meter?.closest("[role='progressbar']");
|
||||
progress?.setAttribute("aria-valuenow", String(storage.usage_percent || 0));
|
||||
}
|
||||
|
||||
function updateSettingsForm(library, settings) {
|
||||
if (!library || !settings) return;
|
||||
const form = library.querySelector("[data-resource-settings-form]");
|
||||
if (!form) return;
|
||||
for (const [name, value] of Object.entries(settings)) {
|
||||
const field = form.elements.namedItem(name);
|
||||
if (field) field.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setText(root, selector, value) {
|
||||
const target = root?.querySelector(selector);
|
||||
if (target) target.textContent = value;
|
||||
}
|
||||
|
||||
function setStatus(root, message, tone = "") {
|
||||
const target = root?.matches?.("[data-resource-status]")
|
||||
? root
|
||||
: root?.querySelector?.("[data-resource-status]") || document.querySelector("[data-resource-status]");
|
||||
if (!target) return;
|
||||
target.textContent = message || "";
|
||||
target.classList.toggle("is-error", tone === "error");
|
||||
target.classList.toggle("is-success", tone === "success");
|
||||
}
|
||||
|
||||
function notify(message, tone) {
|
||||
window.LumiInteractions?.showEventNotice?.({ message }, tone);
|
||||
}
|
||||
|
||||
async function copyText(value, buttonTarget) {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
const helper = document.createElement("textarea");
|
||||
helper.value = value;
|
||||
helper.style.position = "fixed";
|
||||
helper.style.opacity = "0";
|
||||
document.body.append(helper);
|
||||
helper.select();
|
||||
document.execCommand("copy");
|
||||
helper.remove();
|
||||
}
|
||||
const original = buttonTarget.textContent;
|
||||
buttonTarget.textContent = "Copied";
|
||||
window.setTimeout(() => { if (document.contains(buttonTarget)) buttonTarget.textContent = original; }, 1200);
|
||||
}
|
||||
|
||||
function effectiveAction(form, submitter) {
|
||||
return window.LumiForms?.action?.(form, submitter) || submitter?.formAction || form.getAttribute("action") || window.location.href;
|
||||
}
|
||||
|
||||
function effectiveMethod(form, submitter) {
|
||||
return (window.LumiForms?.method?.(form, submitter) || submitter?.formMethod || form.getAttribute("method") || "POST").toUpperCase();
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS?.escape) return window.CSS.escape(String(value));
|
||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
let bytes = Math.max(0, Number(value) || 0);
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let unit = "B";
|
||||
for (const next of units) {
|
||||
bytes /= 1024;
|
||||
unit = next;
|
||||
if (bytes < 1024) break;
|
||||
}
|
||||
return `${bytes >= 10 ? bytes.toFixed(1) : bytes.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
window.LumiContentLibrary = Object.freeze({ init });
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => init(document), { once: true });
|
||||
} else {
|
||||
init(document);
|
||||
}
|
||||
})();
|
||||
3
src/web/public/icons/nav/resources.svg
Normal file
3
src/web/public/icons/nav/resources.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M4 4.5A2.5 2.5 0 0 1 6.5 2h7.1a2.5 2.5 0 0 1 1.77.73l3.9 3.9A2.5 2.5 0 0 1 20 8.4v9.1a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-13Zm10 0V8h3.5L14 4.5ZM8 11.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm-1.5 5.5h11l-3.2-3.8a1 1 0 0 0-1.53-.02l-1.45 1.66-1.05-1.08a1 1 0 0 0-1.48.05L6.5 16.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
@ -171,6 +171,11 @@ const {
|
||||
registerOverlayAdminRoutes,
|
||||
registerPublicOverlayRoutes
|
||||
} = require("../services/overlay-routes");
|
||||
const { frameworkApi: createContentLibraryFramework } = require("../services/content-library");
|
||||
const {
|
||||
registerContentAdminRoutes,
|
||||
registerPublicContentRoutes
|
||||
} = require("../services/content-routes");
|
||||
const { registerOverlayModuleType } = require("../services/overlay-modules");
|
||||
const { registerOverlayAccessProvider } = require("../services/overlay-permissions");
|
||||
const { registerOverlayConnectorProvider } = require("../services/overlay-connectors");
|
||||
@ -3057,6 +3062,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
registerConnectorProvider: registerOverlayConnectorProvider,
|
||||
registerModuleType: registerOverlayModuleType
|
||||
});
|
||||
const contentLibraryFramework = createContentLibraryFramework();
|
||||
global.lumiFrameworks.content = contentLibraryFramework;
|
||||
global.lumiFrameworks.resources = contentLibraryFramework;
|
||||
const assetVersion = Date.now().toString();
|
||||
const sessionStore = new BetterSqlite3Store({
|
||||
client: db
|
||||
@ -3384,6 +3392,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
};
|
||||
|
||||
registerPublicOverlayRoutes(app);
|
||||
registerPublicContentRoutes(app);
|
||||
app.post("/api/diagnostics/v1/run", (req, res) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.set("Pragma", "no-cache");
|
||||
@ -3660,6 +3669,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
|
||||
registerOverlayAdminRoutes(app);
|
||||
registerContentAdminRoutes(app, { requireAdmin: requireRole("admin") });
|
||||
|
||||
app.get("/", async (req, res, next) => {
|
||||
try {
|
||||
@ -7567,6 +7577,12 @@ function collectNavItems(user, pluginNav, currentPath) {
|
||||
role: "admin",
|
||||
section: "admin"
|
||||
},
|
||||
{
|
||||
label: "Resources",
|
||||
path: "/admin/resources",
|
||||
role: "admin",
|
||||
section: "admin"
|
||||
},
|
||||
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
||||
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
|
||||
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
||||
@ -7841,6 +7857,7 @@ function getDefaultNavIcon(item) {
|
||||
if (pathName === "/admin/commands") return "commands";
|
||||
if (pathName === "/admin/command-policies") return "commands";
|
||||
if (pathName === "/admin/pages") return "pages";
|
||||
if (pathName === "/admin/resources") return "resources";
|
||||
if (pathName === "/admin/users") return "users";
|
||||
if (pathName === "/admin/plugins") return "plugins";
|
||||
if (pathName === "/moderator") return "users";
|
||||
|
||||
272
src/web/views/admin-resources.ejs
Normal file
272
src/web/views/admin-resources.ejs
Normal file
@ -0,0 +1,272 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<link rel="stylesheet" href="/content-library.css?v=<%= assetVersion %>" />
|
||||
<section class="card content-library" data-content-library>
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Content delivery",
|
||||
pageTitle: "Resources",
|
||||
description: "Upload and manage media for Lumi, overlays, alerts, and other streaming features. Locked resources stay internal; exposed resources receive a read-only URL."
|
||||
}) %>
|
||||
|
||||
<div class="resource-storage-grid" aria-label="Resource storage status">
|
||||
<article class="resource-storage-card resource-storage-primary">
|
||||
<span class="eyebrow">Available to Lumi</span>
|
||||
<strong data-storage-available><%= storage.effective_available_bytes === null ? "Unknown" : formatBytes(storage.effective_available_bytes) %></strong>
|
||||
<p data-storage-available-note>
|
||||
<% if (storage.quota_enabled) { %>
|
||||
Limited by the smaller of remaining quota and usable disk space.
|
||||
<% } else { %>
|
||||
Based on disk space after Lumi's safety reserve.
|
||||
<% } %>
|
||||
</p>
|
||||
<div class="resource-meter" role="progressbar" aria-label="Storage usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= storage.usage_percent %>">
|
||||
<span data-storage-meter style="width: <%= storage.usage_percent %>%"></span>
|
||||
</div>
|
||||
</article>
|
||||
<article class="resource-storage-card">
|
||||
<span>Library usage</span>
|
||||
<strong data-storage-used><%= formatBytes(storage.used_bytes) %></strong>
|
||||
<small><span data-storage-count><%= storage.file_count %></span> file<%= storage.file_count === 1 ? "" : "s" %></small>
|
||||
</article>
|
||||
<article class="resource-storage-card">
|
||||
<span>Disk free</span>
|
||||
<strong data-storage-disk-free><%= storage.disk_free_bytes === null ? "Unavailable" : formatBytes(storage.disk_free_bytes) %></strong>
|
||||
<small><%= storage.disk_available ? `${formatBytes(storage.reserve_bytes)} reserved` : "Disk statistics unavailable" %></small>
|
||||
</article>
|
||||
<article class="resource-storage-card">
|
||||
<span>Library limit</span>
|
||||
<strong data-storage-limit><%= storage.quota_enabled ? formatBytes(storage.limit_bytes) : "Unlimited" %></strong>
|
||||
<small>Per-file maximum: <span data-storage-max-file><%= formatBytes(storage.max_file_bytes) %></span></small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="resource-workspace">
|
||||
<section class="resource-upload-panel" aria-labelledby="resource-upload-title">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 id="resource-upload-title">Add resources</h2>
|
||||
<p class="hint">Uploads are stored under generated filenames and never executed by Lumi.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/resources/upload" enctype="multipart/form-data" data-resource-upload-form>
|
||||
<label class="resource-dropzone" data-resource-dropzone tabindex="0">
|
||||
<input
|
||||
type="file"
|
||||
name="resources"
|
||||
multiple
|
||||
accept="<%= formatGroups.flatMap((group) => group.extensions).join(',') %>"
|
||||
data-resource-file-input
|
||||
/>
|
||||
<span class="resource-dropzone-icon" aria-hidden="true">+</span>
|
||||
<strong>Drop files here</strong>
|
||||
<span>or select files from this device</span>
|
||||
<small>Up to <%= settings.max_files %> files per upload · <%= formatBytes(storage.max_file_bytes) %> per file</small>
|
||||
</label>
|
||||
<div class="resource-selected-files" data-resource-selected-files hidden></div>
|
||||
<div class="resource-upload-options">
|
||||
<div class="field">
|
||||
<label for="resource-upload-access">Initial access</label>
|
||||
<select id="resource-upload-access" name="access_level">
|
||||
<option value="locked" selected>Locked — admins and Lumi only</option>
|
||||
<option value="exposed">Exposed — create read-only URLs</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="button" data-resource-upload-submit>Upload resources</button>
|
||||
</div>
|
||||
<div class="resource-upload-progress" data-resource-upload-progress hidden>
|
||||
<div class="resource-meter" role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<span data-resource-upload-progress-bar></span>
|
||||
</div>
|
||||
<span data-resource-upload-progress-text>Preparing upload…</span>
|
||||
</div>
|
||||
<p class="resource-status" data-resource-status aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<details class="resource-settings-panel">
|
||||
<summary>Storage limits and supported formats</summary>
|
||||
<form method="post" action="/admin/resources/settings" class="form-grid" data-resource-action-form data-resource-settings-form>
|
||||
<div class="field">
|
||||
<label for="resource-limit-gib">Library limit (GiB)</label>
|
||||
<input id="resource-limit-gib" name="limit_gib" type="number" min="0" step="0.1" value="<%= settings.limit_gib %>" />
|
||||
<span class="hint">Use 0 for no Lumi-specific quota. Physical disk capacity still applies.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resource-reserve-gib">Keep free on disk (GiB)</label>
|
||||
<input id="resource-reserve-gib" name="reserve_gib" type="number" min="0" step="0.1" value="<%= settings.reserve_gib %>" />
|
||||
<span class="hint">Lumi stops accepting uploads before the disk falls below this reserve.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resource-max-file-mib">Maximum file size (MiB)</label>
|
||||
<input id="resource-max-file-mib" name="max_file_mib" type="number" min="1" max="8192" step="1" value="<%= settings.max_file_mib %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resource-max-files">Files per upload</label>
|
||||
<input id="resource-max-files" name="max_files" type="number" min="1" max="50" step="1" value="<%= settings.max_files %>" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button subtle">Save storage settings</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="resource-format-groups">
|
||||
<% formatGroups.forEach((group) => { %>
|
||||
<div>
|
||||
<strong><%= group.category.charAt(0).toUpperCase() + group.category.slice(1) %></strong>
|
||||
<span><%= group.extensions.join(" · ") %></span>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<p class="hint">Some accepted formats, such as MKV, AVI, HEIC, or PSD, may not decode in the browser preview. Lumi will still store and serve them correctly.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="resource-browser-header">
|
||||
<div>
|
||||
<h2>File browser</h2>
|
||||
<p class="hint">Preview, rename, lock, expose, copy, or remove uploaded resources.</p>
|
||||
</div>
|
||||
<div class="resource-browser-controls">
|
||||
<label class="resource-search">
|
||||
<span class="visually-hidden">Search resources</span>
|
||||
<input type="search" placeholder="Search files" data-resource-search />
|
||||
</label>
|
||||
<label>
|
||||
<span class="visually-hidden">Filter by type</span>
|
||||
<select data-resource-category-filter>
|
||||
<option value="">All types</option>
|
||||
<% formatGroups.forEach((group) => { %>
|
||||
<option value="<%= group.category %>"><%= group.category.charAt(0).toUpperCase() + group.category.slice(1) %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="visually-hidden">Filter by access</span>
|
||||
<select data-resource-access-filter>
|
||||
<option value="">All access</option>
|
||||
<option value="locked">Locked</option>
|
||||
<option value="exposed">Exposed</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resource-empty-state empty-state" data-resource-empty <%= resources.length ? "hidden" : "" %>>
|
||||
No resources yet. Drop media above to make it available to Lumi.
|
||||
</div>
|
||||
<div class="resource-empty-state empty-state" data-resource-filter-empty hidden>
|
||||
No resources match the current filters.
|
||||
</div>
|
||||
|
||||
<div class="resource-grid" data-resource-grid>
|
||||
<% resources.forEach((resource) => { %>
|
||||
<article
|
||||
class="resource-card"
|
||||
data-resource-card
|
||||
data-resource-id="<%= resource.id %>"
|
||||
data-resource-name="<%= resource.display_name.toLowerCase() %> <%= resource.original_name.toLowerCase() %> <%= resource.mime.toLowerCase() %>"
|
||||
data-resource-category="<%= resource.category %>"
|
||||
data-resource-access="<%= resource.access_level %>"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="resource-preview-tile"
|
||||
data-resource-preview
|
||||
data-resource-id="<%= resource.id %>"
|
||||
data-resource-display-name="<%= resource.display_name %>"
|
||||
data-resource-original-name="<%= resource.original_name %>"
|
||||
data-resource-url="<%= resource.admin_url %>"
|
||||
data-resource-preview-kind="<%= resource.preview_kind %>"
|
||||
data-resource-mime="<%= resource.mime %>"
|
||||
data-resource-size="<%= resource.size_display %>"
|
||||
aria-label="Preview <%= resource.display_name %>"
|
||||
>
|
||||
<% if (resource.preview_kind === "image") { %>
|
||||
<img src="<%= resource.admin_url %>" alt="" loading="lazy" />
|
||||
<% } else { %>
|
||||
<span class="resource-type-mark" aria-hidden="true"><%= resource.extension.replace('.', '').toUpperCase() %></span>
|
||||
<span>Preview</span>
|
||||
<% } %>
|
||||
</button>
|
||||
<div class="resource-card-body">
|
||||
<div class="resource-card-heading">
|
||||
<div>
|
||||
<h3 data-resource-title><%= resource.display_name %></h3>
|
||||
<p title="<%= resource.original_name %>"><%= resource.original_name %></p>
|
||||
</div>
|
||||
<span class="badge <%= resource.access_level === 'exposed' ? 'success' : '' %>" data-resource-access-badge><%= resource.access_level === "exposed" ? "Exposed" : "Locked" %></span>
|
||||
</div>
|
||||
<dl class="resource-metadata">
|
||||
<div><dt>Type</dt><dd><%= resource.category %> · <%= resource.extension %></dd></div>
|
||||
<div><dt>Size</dt><dd><%= resource.size_display %></dd></div>
|
||||
</dl>
|
||||
<div class="resource-public-url" data-resource-public-block <%= resource.public_url ? "" : "hidden" %>>
|
||||
<label>Read-only URL</label>
|
||||
<div class="resource-url-row">
|
||||
<input readonly value="<%= resource.public_url || '' %>" data-resource-public-url aria-label="Read-only URL for <%= resource.display_name %>" />
|
||||
<button type="button" class="button subtle" data-resource-copy-url>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-card-actions">
|
||||
<button type="button" class="button subtle" data-resource-preview>Preview</button>
|
||||
<a class="button subtle" href="<%= resource.admin_url %>?download=1" download>Download</a>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/resources/<%= resource.id %>/<%= resource.access_level === 'exposed' ? 'revoke' : 'access' %>"
|
||||
data-resource-action-form
|
||||
data-resource-access-form
|
||||
<% if (resource.access_level === "exposed") { %>
|
||||
data-confirm-mode="modal"
|
||||
data-confirm-title="Lock resource"
|
||||
data-confirm-text="Lock this resource and revoke its current read-only URL? Anything using that URL will stop working."
|
||||
data-confirm-label="Lock resource"
|
||||
<% } %>
|
||||
>
|
||||
<button type="submit" class="button subtle" data-resource-access-button><%= resource.access_level === "exposed" ? "Lock" : "Expose" %></button>
|
||||
</form>
|
||||
</div>
|
||||
<details class="resource-edit-details">
|
||||
<summary>More actions</summary>
|
||||
<form method="post" action="/admin/resources/<%= resource.id %>/rename" class="resource-rename-form" data-resource-action-form>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input name="display_name" maxlength="160" value="<%= resource.display_name %>" required />
|
||||
</label>
|
||||
<button type="submit" class="button subtle">Rename</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/resources/<%= resource.id %>/delete"
|
||||
data-resource-action-form
|
||||
data-confirm-mode="modal"
|
||||
data-confirm-title="Delete resource"
|
||||
data-confirm-text="Permanently delete '<%= resource.display_name %>' from Lumi? Any overlay or feature using it will stop working."
|
||||
data-confirm-label="Delete resource"
|
||||
>
|
||||
<button type="submit" class="button danger">Delete resource</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-backdrop resource-preview-modal" data-resource-preview-modal aria-hidden="true">
|
||||
<div class="modal resource-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="resource-preview-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="resource-preview-title" data-resource-preview-title>Resource preview</h2>
|
||||
<p class="hint" data-resource-preview-meta></p>
|
||||
</div>
|
||||
<button type="button" class="icon-button" data-resource-preview-close aria-label="Close preview">×</button>
|
||||
</div>
|
||||
<div class="resource-preview-stage" data-resource-preview-stage></div>
|
||||
<p class="resource-preview-error" data-resource-preview-error role="status" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<a class="button subtle" href="#" data-resource-preview-download download>Download</a>
|
||||
<button type="button" class="button" data-resource-preview-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/content-library.js?v=<%= assetVersion %>" defer></script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.2.22",
|
||||
"version": "0.2.23",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-19",
|
||||
"compatible_from": "0.1.9",
|
||||
@ -8,7 +8,7 @@
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"migration_notes": "Adds event-driven overlay sound and temporary visual alerts for Twitch follows, raids, subscriptions, gifted subscriptions, and Discord member joins. Existing commands, policies, groups, usage totals, overlays, chat settings, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved; new event-hook storage and Twitch event credentials are additive.",
|
||||
"migration_notes": "Adds an admin content-delivery library with verified uploads, locked or revocably exposed resources, streaming byte ranges, storage limits, and a shared plugin API. Also allows configured remote and blob audio/video in clean OBS Browser Sources. Existing commands, policies, groups, overlays, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved; content metadata and files are additive under the existing preserved data directory.",
|
||||
"rollback_safe": true,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer"
|
||||
@ -289,6 +289,18 @@
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Adds event-driven overlay sound and temporary visual alerts for Twitch follows, raids, subscriptions, gifted subscriptions, and Discord member joins; existing overlays, sources, tokens, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
||||
},
|
||||
{
|
||||
"version": "0.2.23",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-20",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Adds an admin content-delivery library with verified uploads, revocable read-only delivery, storage controls, and a shared plugin API, plus the OBS media CSP fix; existing data remains preserved and content files are additive under data/content-library."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user