Complete experimental OKF branch hardening
This commit is contained in:
parent
32ec0a102f
commit
a782412a43
32
README.md
32
README.md
@ -4,18 +4,27 @@ Discord bot + WebUI with role-based access, plugin management, and self-update s
|
||||
|
||||
## Quick start
|
||||
|
||||
Requires Node.js 18+.
|
||||
Requires Node.js 18+ on Windows or Linux.
|
||||
|
||||
1. Install dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
Do not use `--ignore-scripts`: Lumi uses the native `better-sqlite3` module,
|
||||
which must download or build a binary for your Node.js version. If Node was
|
||||
upgraded after installation, run `npm rebuild better-sqlite3` or remove
|
||||
`node_modules` and run `npm install` again. Linux source builds may also need
|
||||
Python and a C/C++ compiler toolchain.
|
||||
2. Run with auto-restart:
|
||||
```
|
||||
npm run run
|
||||
```
|
||||
3. Open `http://localhost:3000/setup` and enter your Discord app + bot settings.
|
||||
|
||||
Before starting Lumi, you can check the runtime and native dependency with
|
||||
`npm run verify:preflight`. Run the complete syntax and focused test suite with
|
||||
`npm run verify:all`; it stops at the first failure and prints the failing check.
|
||||
|
||||
You can also seed local configuration with a `.env` file. Use `.env.example`
|
||||
as the template; `.env` is ignored by git.
|
||||
|
||||
@ -54,11 +63,23 @@ Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`,
|
||||
## Twitch bot
|
||||
|
||||
Configure Twitch chat settings in **Admin → Settings**:
|
||||
|
||||
- `twitch_bot_username`
|
||||
- `twitch_bot_oauth` (OAuth token)
|
||||
- `twitch_channels` (comma-separated)
|
||||
|
||||
Custom commands can target Discord, Twitch, or both from **Admin → Commands**.
|
||||
Custom commands can target any enabled chat platform from **Admin → Commands**.
|
||||
Static commands send one template. Random Reply commands choose among weighted
|
||||
messages and can optionally roll a number for conditions such as `<20` or
|
||||
`>=10`; insert that roll with `{{core.command.rng}}`. Admin-only Dynamic commands
|
||||
accept JavaScript or Python snippets that return a reply. Lumi wraps simple
|
||||
snippets automatically while preserving explicit `run(ctx)` and module exports.
|
||||
Previews use deterministic mock data and never send external network requests;
|
||||
network calls, if intentionally used by an admin-authored command, occur only in
|
||||
the real command runtime.
|
||||
|
||||
Admins can create reusable permission-aware `{{custom.*}}` text values under
|
||||
**Admin → Settings → Reusable text values**.
|
||||
|
||||
## Users and linking
|
||||
|
||||
@ -75,6 +96,13 @@ incomplete values fall back safely to the selected built-in base theme.
|
||||
Developer and modding conventions are documented in
|
||||
[`docs/lumi-ui.md`](docs/lumi-ui.md).
|
||||
|
||||
## OBS overlays
|
||||
|
||||
Admins can create live, token-protected OBS Browser Sources under **Admin → OBS
|
||||
overlays**, with server-controlled or fixed scenes and optional local OBS
|
||||
WebSocket or zero-credential OBS Browser Bridge synchronization. See
|
||||
[`docs/obs-overlays.md`](docs/obs-overlays.md).
|
||||
|
||||
## Notes
|
||||
|
||||
- Auto-update uses `git pull` from the configured remote + branch.
|
||||
|
||||
70
TODO.md
70
TODO.md
@ -4,6 +4,30 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
||||
|
||||
## Current Local State / Source of Truth
|
||||
|
||||
### P0 OBS Overlay System
|
||||
|
||||
Implemented locally on 2026-07-17: admin-only overlay management, separate public
|
||||
Browser Source rendering, encrypted and revocable overlay/scene tokens, live
|
||||
scoped event updates with reconnect recovery, scene and module-instance CRUD and
|
||||
ordering, optional OBS WebSocket control/synchronization, connector provider
|
||||
interfaces, focused verification, and operator documentation.
|
||||
|
||||
The editor now also includes an OBS-style live canvas with source selection,
|
||||
eight resize handles, drag/nudge positioning, edge/center/source snapping,
|
||||
anchors, contextual source fields, text layout/overflow controls, and external
|
||||
website-overlay health checks plus manual and automatic refresh. Video and audio
|
||||
sources now include playback controls and OBS-routable sound; web sources include
|
||||
isolated custom frame CSS, cropping, and zoom. An optional OBS Browser Bridge now
|
||||
syncs supported OBS scene, transition, canvas, output, permission, and source
|
||||
state without WebSocket credentials, with a one-action setup importer.
|
||||
|
||||
Remaining work:
|
||||
|
||||
- Build and authenticate the future trusted Lumi streaming-computer client.
|
||||
- Add moderator-to-overlay assignment UI and persistence when the assignment
|
||||
model is defined; centralized overlay capability hooks are ready and continue
|
||||
to deny moderators and all secret access by default.
|
||||
|
||||
### OKF Knowledge System
|
||||
|
||||
Current state on `experimental-okf` as of 2026-06-21: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, role-preview tooling, role-aware Lumi AI context integration through the generic AI context provider hook, a file-backed Markdown knowledge retrieval layer, generated core/plugin OKF baseline files, admin-editable community OKF files, and admin-created correction OKF files from reviewed core feedback.
|
||||
@ -192,6 +216,33 @@ Current state on `experimental-okf` as of 2026-06-21: the core feedback system i
|
||||
|
||||
## Active Work
|
||||
|
||||
Audited on 2026-07-17 after completing the exported P0-P3 queue. Only the items
|
||||
in this section remain active; the detailed planning checklists below are kept
|
||||
as historical design context and are no longer an open-work list.
|
||||
|
||||
- Build and authenticate the future trusted Lumi streaming-computer client.
|
||||
- Add moderator-to-overlay assignments after the ownership model is defined;
|
||||
the centralized overlay capability hooks already fail closed.
|
||||
- Add Twitch live state, follower/subscriber statistics, and scheduled stream
|
||||
placeholders only after a background provider cache exists. Rendering must
|
||||
never make provider API calls.
|
||||
- Revisit a persistent OKF index when there are at least 2,000 active files and
|
||||
generated-fixture or production warm-search p95 exceeds 100 ms. The
|
||||
2026-07-17 benchmark measured about 45 ms p95 at 1,000 files and 87 ms at
|
||||
2,000 files, so the current mtime/size parse cache remains appropriate.
|
||||
- Continue live Lumi AI controller tuning only when real usage provides evidence
|
||||
that the current heuristics need adjustment.
|
||||
- Consider Discord.js 14 as a separately planned compatibility upgrade; the
|
||||
current v13 integration is verified and a major upgrade should not be mixed
|
||||
into unrelated security fixes.
|
||||
- Broaden browser-matrix checks for clipboard image copy/read behavior. Upload,
|
||||
paste, native-menu, URL-copy, and file-picker fallbacks are implemented.
|
||||
- Add deeper provider-specific homepage failure detection only where a safe
|
||||
cached API or browser signal exists. Cross-origin iframe CSP/X-Frame-Options
|
||||
failures cannot be reliably preflighted without creating an SSRF path.
|
||||
|
||||
## Historical Planning Checklists (Implemented or Superseded)
|
||||
|
||||
### OKF Knowledge System: File-Backed Knowledge Storage
|
||||
|
||||
- Add richer category/tag management and Markdown preview/editor polish.
|
||||
@ -621,6 +672,25 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
|
||||
|
||||
## Done
|
||||
|
||||
### 2026-07-17
|
||||
|
||||
- 2026-07-17: Completed exported P0 preflight/security work: deterministic preflight/all verifiers, shared atomic retrying file writes with diagnostics, shared WebUI authentication guards, OKF privilege/field filtering, direct dependency audit remediation, and signature-validated bounded uploads with cleanup and protected downloads.
|
||||
- 2026-07-17: Completed exported P1 update/recovery work: protected-data preservation tests, rollback-safe repository/tool updates, shared async update actions, clearer update language, and activation validation inside tool-install transactions.
|
||||
- 2026-07-17: Completed Lumi AI feedback and work-history tasks: answer ratings/comments/context snapshots, admin review/corrections, correction links/inventory, sanitized grouped Codex exports including legacy rows, and controller/retrieval diagnostics coverage.
|
||||
- 2026-07-17: Completed OKF usability and retrieval work: live Markdown previews, role-safe contributor navigation, category/tag filtering, deterministic generated files, improved specific-query ranking, generic-help suppression, and correction discovery.
|
||||
- 2026-07-17: Completed core feedback hardening: merge/unmerge with canonical support aggregation, richer route/plugin/target filters, stronger duplicate similarity, attachment lifecycle/security, clipboard fallbacks, and expanded correction redaction.
|
||||
- 2026-07-17: Completed the trusted placeholder migration audit, fixed deterministic async replacement, added resolver timeouts/fallbacks, documented cache-only platform data status, and added the admin-managed permission-aware `{{custom.*}}` registry.
|
||||
- 2026-07-17: Completed homepage embed reliability: Twitch multi-parent player/chat layouts and minimum sizing, YouTube cached connected-channel live lookup and valid-video live chat, live-only fall-through, responsive dual-frame rendering, and clearer setup warnings.
|
||||
- 2026-07-17: Completed the major UI wording/action audit: task-focused labels, contextual helper text, consistent destructive styling/confirmations, and removal of generic Save/Update button labels from primary forms.
|
||||
- 2026-07-17: Completed custom-command expansion: automatically wrapped JavaScript/Python snippets, preserved explicit/module handlers, Random Reply mode, optional RNG conditions, integer weights, the `{{core.command.rng}}` value, migration-safe storage, validation, and previews.
|
||||
- 2026-07-17: Hardened Dynamic command previews after release audit: safe built-ins remain available, harmless blocked-word text no longer causes false failures, host-constructor escapes remain disabled, external preview network requests are blocked, and long previews now expand inline without altering punctuation or whitespace.
|
||||
- 2026-07-17: Benchmarked file-backed OKF search with generated 1,000- and 2,000-file fixtures, including visibility and changed/deleted-file assertions. Persistent indexing is deferred until the documented 2,000-file/100-ms-p95 threshold is crossed.
|
||||
- 2026-07-17: Added the optional OBS Browser Bridge provider using OBS-injected page bindings: scoped heartbeat/state reporting, active-instance election, page-permission diagnostics, scene synchronization and commands, transitions/output/canvas/source status, profile/scene-collection capability reporting, and a non-destructive OBS setup importer. No repo push yet.
|
||||
- 2026-07-17: Connected destructive controls across core and plugins to the shared timed-confirmation contract, moved generic Lumi AI and Auto VC deletes onto explicit protected routes, added timed update-restore requests, and added a repository-wide destructive-action verifier. No repo push yet.
|
||||
- 2026-07-17: Extended OBS overlays with video/media and audio sources, play-once/loop/manual behavior, volume/start/speed/video-fit controls, live playback restart, website-alert autoplay audio, and isolated web-frame CSS plus crop/zoom controls. No repo push yet.
|
||||
- 2026-07-17: Added the visual overlay editor follow-up locally: simplified user-facing language, contextual help, a responsive sticky preview canvas, drag/resize/nudge transforms, Ctrl/Cmd-toggle snapping to canvas and source lines, nine anchors, conditional source controls, horizontal/vertical text alignment and overflow modes, and sandboxed third-party website sources with live manual refresh plus bounded automatic health recovery. No repo push yet.
|
||||
- 2026-07-17: Implemented the P0 OBS overlay system locally: multi-overlay and scene management, fixed and active-scene Browser Sources, encrypted revocable tokens, live scoped updates with reconnect recovery, reusable overlay module instances, optional OBS WebSocket testing/control/mapping/sync with loop prevention, future trusted-client provider interfaces, focused tests, and documentation. No repo push yet.
|
||||
|
||||
### 2026-06-25
|
||||
|
||||
- 2026-06-25: Continued Lumi AI runtime CUDA-preference work locally: Windows NVIDIA-compatible installs now prefer the managed CUDA llama.cpp runtime over Vulkan, the runtime manifest supports CUDA dependency archives, runtime downloads validate/reuse complete partial archives, install metadata records the backend variant/dependencies, and `/plugins/lumi_ai` shows the total runtime download size. No repo push yet.
|
||||
|
||||
164
docs/obs-overlays.md
Normal file
164
docs/obs-overlays.md
Normal file
@ -0,0 +1,164 @@
|
||||
# OBS overlays
|
||||
|
||||
Admins manage overlays at **Admin → OBS overlays**. Management pages use the
|
||||
normal Lumi session, role checks, layout, forms, lists, modals, and confirmation
|
||||
flow. Browser Source pages use a separate transparent document with no Lumi
|
||||
navigation or authenticated website data.
|
||||
|
||||
## Editing an overlay
|
||||
|
||||
The editor uses the same basic model as OBS: scenes contain ordered sources, and
|
||||
the selected source has a red box with resize handles. On a wide screen the
|
||||
preview stays beside the settings; on smaller screens it moves above them.
|
||||
|
||||
- Drag a source to move it.
|
||||
- Drag any corner or edge handle to resize it.
|
||||
- Use arrow keys for small moves, or Shift plus an arrow for larger moves.
|
||||
- Snapping uses canvas edges, center lines, and other source edges/midpoints.
|
||||
- Hold Ctrl on Windows/Linux or Cmd on macOS to temporarily reverse the Snap
|
||||
setting.
|
||||
- Center, Fit to canvas, and Reset box provide familiar transform shortcuts.
|
||||
|
||||
Every source can use any corner, edge midpoint, or center as its anchor. Text
|
||||
sources have separate horizontal and vertical alignment plus wrapping, shrinking,
|
||||
clipping, single-line, and scrolling ticker behavior. The editor only shows fields
|
||||
that apply to the selected source type.
|
||||
|
||||
## Private OBS links
|
||||
|
||||
Each overlay has one server-controlled URL. It follows the active overlay scene.
|
||||
Each scene also has a fixed URL that always renders that scene. Both URL forms
|
||||
contain 256-bit random bearer tokens instead of database IDs. Tokens are stored
|
||||
encrypted for admin copy actions and as SHA-256 hashes for public lookup.
|
||||
Regenerating a URL immediately revokes the old token.
|
||||
|
||||
Treat every Browser Source URL as a password. Only admins can view, copy, preview,
|
||||
or regenerate it. Public state responses contain render data only; they do not
|
||||
contain other URLs, tokens, OBS credentials, or website session data.
|
||||
|
||||
Browser Sources receive scoped live-change events through Lumi's existing event
|
||||
service. The render page fetches a fresh authorized snapshot after each event and
|
||||
after every reconnect, so temporary network loss does not require an OBS refresh.
|
||||
|
||||
## Scenes and sources
|
||||
|
||||
An overlay always keeps at least one scene. Admins can create, edit, duplicate,
|
||||
order, enable, activate, and delete scenes. Current built-in source types are:
|
||||
|
||||
- Text
|
||||
- Image URL
|
||||
- Video / media URL
|
||||
- Audio URL
|
||||
- Website or alert overlay
|
||||
|
||||
Module instances can be created, edited, duplicated, ordered, enabled, and
|
||||
deleted. New module types can call `registerOverlayModuleType(...)` in
|
||||
`src/services/overlay-modules.js`; providers normalize their configuration and
|
||||
select the safe built-in `text`, `image`, `video`, `audio`, or `web` render type while the overlay
|
||||
service retains the same instance model.
|
||||
|
||||
### Video and audio
|
||||
|
||||
Video sources support browser-playable media such as WebM and MP4; audio sources
|
||||
support browser-playable formats such as Ogg, MP3, and WAV. Actual codec support
|
||||
follows the Chromium/CEF build used by the browser or OBS.
|
||||
|
||||
Playback can start once when the overlay scene loads, loop continuously, or wait
|
||||
for manual playback. Sources also provide volume, start offset, playback speed,
|
||||
optional player controls, and a live **Restart playback** action. Video adds mute
|
||||
and contain/cover/stretch sizing. Activating another Lumi scene removes the old
|
||||
scene's media elements, which stops their playback; returning to the scene applies
|
||||
its configured start behavior again.
|
||||
|
||||
Website overlays can play their own alert audio because their iframe receives
|
||||
autoplay permission. To route video, audio, and website-alert sound through an
|
||||
independent OBS mixer channel, enable **Control audio via OBS** in the Lumi Browser
|
||||
Source properties, then configure monitoring/output in OBS as desired.
|
||||
|
||||
### External website and alert overlays
|
||||
|
||||
Website sources support third-party browser overlays such as alert and chat
|
||||
overlays. They run in a sandbox and do not receive the Lumi admin page or its
|
||||
login data. The **Refresh now** action sends a live refresh command to every open
|
||||
OBS copy of that source.
|
||||
|
||||
Automatic recovery is optional. When enabled it watches for a page that never
|
||||
finishes loading, can check public website replies for HTTP errors, empty content,
|
||||
unexpected content types, and obviously malformed pages, retries at 5, 15, and 45
|
||||
second-style bounded intervals, and periodically reloads the source to avoid stale
|
||||
content. Retry count and refresh interval are configurable. Local/private website
|
||||
addresses are periodically refreshed but are not fetched by the Lumi server for
|
||||
security; browser load-timeout recovery still applies.
|
||||
|
||||
Website sources also support per-edge percentage cropping, zoom, and isolated
|
||||
custom frame CSS. Custom CSS is scoped to the source frame so it cannot restyle
|
||||
the Lumi render page or other sources. Standard browser cross-origin security
|
||||
does not allow parent-page CSS to select elements inside a third-party iframe;
|
||||
use crop and zoom to hide unwanted portions of an external site. Frame-level CSS
|
||||
can target `iframe`, for example `iframe { filter: saturate(1.2); }`.
|
||||
|
||||
## OBS WebSocket
|
||||
|
||||
The connector is disabled by default. Enable OBS WebSocket in OBS, keep its
|
||||
password enabled, and use the default local endpoint
|
||||
`ws://127.0.0.1:4455` when Lumi runs on the streaming computer. If Lumi runs on
|
||||
another trusted machine, use a private LAN/VPN address and firewall rules. Do not
|
||||
port-forward or publicly expose OBS WebSocket.
|
||||
|
||||
The admin page can test the connection, read OBS scenes, change the current OBS
|
||||
scene, and map OBS scene names to overlay scenes. Sync can run in either direction
|
||||
or bidirectionally. Source tagging and a short outbound suppression window prevent
|
||||
sync loops. Connector errors never stop overlay rendering, and optional reconnect
|
||||
uses bounded exponential backoff.
|
||||
|
||||
OBS passwords use authenticated AES-256-GCM encryption derived from Lumi's stored
|
||||
session secret. They are never returned by public APIs or rendered for
|
||||
non-admins.
|
||||
|
||||
## OBS Browser Bridge
|
||||
|
||||
The optional **OBS Browser Bridge** uses the `window.obsstudio` bindings that OBS
|
||||
injects into Browser Sources. It does not need an OBS WebSocket address or
|
||||
password. Select the provider, save it, then load the private Lumi overlay URL in
|
||||
OBS. The render page reports a heartbeat every five seconds and immediately after
|
||||
relevant OBS events.
|
||||
|
||||
Use the minimum Page permission needed:
|
||||
|
||||
- **Read access to user information** reads the current scene, scene list,
|
||||
transitions, canvas dimensions, output status, and Browser Source activity.
|
||||
- **Advanced access to OBS** is needed only when Lumi should change OBS scenes.
|
||||
- Full access is not requested or used by the current bridge.
|
||||
|
||||
Lumi elects one recently active Browser Source instance per overlay as the bridge,
|
||||
targets commands to that instance, and deduplicates command IDs. Existing
|
||||
scene-direction checks and outbound suppression prevent synchronization loops. If
|
||||
the source stops reporting, Lumi marks the bridge as waiting instead of breaking
|
||||
overlay rendering. Leave OBS's **Shutdown source when not visible** option off if
|
||||
the bridge must remain available outside the source's active scene.
|
||||
|
||||
The **Apply detected OBS setup** action adds missing OBS scenes, maps same-name
|
||||
Lumi scenes, adopts the detected OBS canvas size, and selects the current OBS
|
||||
scene. It does not delete existing Lumi scenes. OBS Browser bindings do not expose
|
||||
profile or scene-collection names, so those fields are explicitly reported as
|
||||
unavailable; the OBS WebSocket provider can read them when that metadata is
|
||||
required.
|
||||
|
||||
## Connector extension point
|
||||
|
||||
`src/services/overlay-connectors.js` owns the provider registry. A provider
|
||||
creates a connector with these operations:
|
||||
|
||||
- `connect()` and `disconnect()`
|
||||
- `status()`
|
||||
- `listScenes()` and `getActiveScene()`
|
||||
- `setActiveScene(sceneName)`
|
||||
- `onSceneChanged(listener)` and `onClosed(listener)`
|
||||
|
||||
The reserved `trusted_lumi_client` provider is intentionally unavailable. A
|
||||
future trusted desktop client can implement this interface to transport OBS state
|
||||
and commands without changing overlay routes, mappings, or synchronization rules.
|
||||
Plugins can register providers, module types, or future scoped access providers
|
||||
through `global.lumiFrameworks.overlays`.
|
||||
|
||||
Run focused verification with `npm run verify:overlays`.
|
||||
@ -27,6 +27,8 @@ Use namespaced double-brace tokens:
|
||||
|
||||
- `{{core.main.bot_name}}`
|
||||
- `{{core.main.command_prefix}}`
|
||||
- `{{core.command.rng}}`
|
||||
- `{{custom.stream.title}}`
|
||||
- `{{user.public.display_name}}`
|
||||
- `{{platform.discord.guild.member_count}}`
|
||||
- `{{okf.file.community.currency.primary_name}}`
|
||||
@ -35,23 +37,27 @@ Use namespaced double-brace tokens:
|
||||
Avoid unqualified names for new work. Compatibility aliases may exist for old
|
||||
templates, but the catalog should present canonical namespaced tokens.
|
||||
|
||||
Custom admin-defined placeholders, if added later, should live under the
|
||||
`{{custom.*}}` namespace and be managed from a trusted admin settings page.
|
||||
Admin-managed values live under `{{custom.*}}`. Admins create them under
|
||||
Settings > Reusable text values; names are validated, cannot collide with
|
||||
system/plugin namespaces, and are stored in the update-preserved settings
|
||||
database.
|
||||
|
||||
## Core Namespaces
|
||||
|
||||
Current core-owned namespaces include:
|
||||
|
||||
- `core.main.*`: safe Lumi core settings such as bot/site name and command prefix
|
||||
- `core.command.*`: values created for one command execution, such as a Random Reply RNG roll
|
||||
- `custom.*`: admin-managed reusable values with explicit user, moderator, or admin visibility
|
||||
- `user.public.*`: safe viewer/triggering-user display information
|
||||
- `platform.discord.guild.*`: safe Discord guild statistics from the configured guild
|
||||
- `platform.twitch.channel.*`: safe locally configured Twitch channel/runtime values
|
||||
- `platform.youtube.channel.*`: safe locally configured or hydrated YouTube channel/runtime values
|
||||
- `okf.file.*`: file-backed OKF frontmatter values, with legacy aliases such as `{{community.currency.primary_name}}`
|
||||
|
||||
API-backed platform statistics such as Twitch follower/subscriber counts or
|
||||
future stream schedules should be registered here only after the integration has
|
||||
a reliable server-side fetch/cache layer and clear sensitivity rules.
|
||||
Each platform namespace exposes a public-safe `data_status` value. Current values are `live_cache`, `connected`, `configured_not_connected`, or `unavailable`, depending on the integration. Discord statistics read only the connected client's guild cache; placeholder rendering never triggers a Discord fetch.
|
||||
|
||||
Twitch follower/subscriber counts and Twitch/YouTube scheduled-start values remain intentionally unavailable. The current Twitch integration is chat-only, and the YouTube runtime does not maintain those statistics. Add them only after a background refresh service stores value, fetched-at time, expiry/stale state, and last error. Rendering must read that cache without a provider API call and return a safe unavailable/stale result when credentials or rate limits prevent refresh.
|
||||
|
||||
## Sensitivity
|
||||
|
||||
@ -91,9 +97,25 @@ Security is based on the intersection of:
|
||||
- plugin availability
|
||||
- runtime context
|
||||
|
||||
Current trusted field policies:
|
||||
|
||||
| Field ID | Destination | Editor | Output | Allowed values |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `core.custom_commands.static_response` | Static and Random Reply custom-command replies | Moderator | User | Public-safe `core.main.*`, `core.command.*`, `custom.*`, and `user.public.*` |
|
||||
| `plugin.throne_wishlist.message_template` | Throne event announcements | Admin | User | Public-safe Throne event placeholders |
|
||||
| `okf.markdown` | SQLite/community OKF Markdown | OKF contributor | Selected role | Permission-filtered core, platform, and visible OKF values |
|
||||
|
||||
The current WebUI contains no inline JSON placeholder catalogs; double-brace editors use these server-owned IDs. Welcome Messages and Auto VC retain their older `{username}` and `[username]` runtime token formats for compatibility. They are not treated as generic placeholders until their event-context renderers and saved templates are migrated together; adding only autocomplete would advertise tokens those runtimes cannot safely resolve.
|
||||
|
||||
An admin editing a user-visible template is not enough to allow admin-only
|
||||
placeholders. The output audience still limits what can render.
|
||||
|
||||
Custom values use the same catalog, validation, preview, and runtime checks as
|
||||
code-registered placeholders. Values marked for moderators or admins do not
|
||||
appear in lower-privilege catalogs and cannot render into user-visible command
|
||||
replies. The `custom` namespace is reserved for this registry; plugins must use
|
||||
their own `plugin.*` namespace.
|
||||
|
||||
## Plugin Registration
|
||||
|
||||
Plugins should register placeholders during backend plugin initialization using
|
||||
@ -130,3 +152,5 @@ Templates must be checked:
|
||||
Unauthorized placeholders should fail closed. User-visible output should show a
|
||||
generic unavailable marker or leave legacy unknown tokens unchanged, depending
|
||||
on the existing renderer contract, but it must not reveal the restricted value.
|
||||
|
||||
Async resolvers are bounded by a short timeout (1.5 seconds by default). Duplicate and adjacent tokens are resolved by source position, and resolver output is inserted literally rather than scanned again. A failed, unavailable, forbidden, or timed-out value therefore affects only that token and renders the caller's safe fallback.
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
Failed updates should leave an administrator with a recovery path. Lumi writes a
|
||||
recovery marker before update files are applied and keeps snapshots available for
|
||||
manual revert.
|
||||
manual revert. If an update throws after replacement begins, Lumi first attempts
|
||||
to restore the new snapshot automatically. Failed automatic restoration keeps
|
||||
the backup and records both errors for safe-mode recovery.
|
||||
|
||||
## Recovery Marker
|
||||
|
||||
@ -50,5 +52,6 @@ Admins can:
|
||||
- clear a stale marker after verifying startup,
|
||||
- retry normal startup.
|
||||
|
||||
Rollback is never automatic. Major-version rollback remains blocked unless the
|
||||
snapshot is explicitly marked rollback safe.
|
||||
Manual major-version rollback remains blocked unless the snapshot is explicitly
|
||||
marked rollback safe. The automatic rollback attempted for the same failed
|
||||
update is allowed because it restores the immediately preceding state.
|
||||
|
||||
@ -61,10 +61,34 @@ Manual revert is available for core and individual plugins. Revert actions are
|
||||
limited to the previous-version snapshot for that target. Major-version rollback
|
||||
is blocked unless the snapshot or manifest explicitly marks rollback as safe.
|
||||
|
||||
If a repository or ZIP update fails after file replacement begins, Lumi
|
||||
automatically attempts to restore the snapshot. The failed recovery marker and
|
||||
backup are retained when automatic restore cannot finish, so safe mode can show
|
||||
the actionable error instead of silently deleting the recovery copy.
|
||||
|
||||
## Preserved Local Data
|
||||
|
||||
Core updates, ZIP updates, plugin updates, and restores leave these local areas
|
||||
in place:
|
||||
|
||||
- `data/`, including the database, feedback files, AI models/runtimes, tool
|
||||
settings, caches, and local exports;
|
||||
- plugin-owned `data/` directories;
|
||||
- `knowledge/community/` and `knowledge/corrections/`;
|
||||
- files under generated knowledge folders unless they explicitly declare both
|
||||
`generated: true` and `editable: false`;
|
||||
- local configuration, storage, uploads, logs, secrets, environment files, and
|
||||
the plugin directory during core-only updates.
|
||||
|
||||
Full core updates remove stale replaceable code before copying the new version.
|
||||
Plugin code is prepared in a staging directory and swapped only after the new
|
||||
files are ready; plugin data is moved into the replacement as part of that
|
||||
transaction.
|
||||
|
||||
## ZIP Fallback
|
||||
|
||||
Core and plugin ZIP updates remain available, but they are hidden under
|
||||
**Show advanced ZIP update options**. ZIP updates create snapshots and recovery
|
||||
**Manual ZIP updates**. ZIP updates create snapshots and recovery
|
||||
markers. They may bypass repo metadata and compatibility checks unless the ZIP
|
||||
contains valid manifest data, so use them as a manual fallback.
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ category: Core
|
||||
tags: core, routes, commands, settings
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# lumi-bot
|
||||
Lumi is the core web UI and bot runtime.
|
||||
@ -97,6 +96,8 @@ Version: 0.1.9
|
||||
- POST /admin/feedback/export
|
||||
- POST /admin/feedback/:id/export
|
||||
- POST /admin/feedback/:id/create-okf-correction
|
||||
- POST /admin/feedback/:id/merge
|
||||
- POST /admin/feedback/:id/unmerge
|
||||
- POST /admin/feedback/:id
|
||||
- POST /admin/feedback/:id/finalize
|
||||
- POST /admin/feedback/:id/reopen
|
||||
@ -213,7 +214,7 @@ Version: 0.1.9
|
||||
- Inputs: file upload: multipart form file data; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: API response; exact schema was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state; writes files
|
||||
- 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.
|
||||
|
||||
### GET /api/feedback/notifications
|
||||
@ -508,9 +509,9 @@ Version: 0.1.9
|
||||
|
||||
- Purpose: Handles feedback id attachments attachmentId.
|
||||
- Inputs: path params: `attachmentId`, `id`
|
||||
- Response format: static file response
|
||||
- Response format: file download
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Side effects: writes or mutates server-side state
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### GET /health
|
||||
@ -831,7 +832,7 @@ Version: 0.1.9
|
||||
### GET /admin/feedback
|
||||
|
||||
- Purpose: Renders the admin feedback WebUI page.
|
||||
- Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `scope`, `severity`, `sort`, `status`, `submitter`
|
||||
- Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `plugin`, `route`, `scope`, `severity`, `sort`, `status`, `submitter`, `target`
|
||||
- Response format: HTML page rendered from an EJS view
|
||||
- Access: admin access expected
|
||||
- Side effects: Usually read-only.
|
||||
@ -864,6 +865,24 @@ Version: 0.1.9
|
||||
- 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/feedback/:id/merge
|
||||
|
||||
- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path.
|
||||
- Inputs: path params: `id`; body: `target_feedback_id`
|
||||
- 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
|
||||
- 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/feedback/:id/unmerge
|
||||
|
||||
- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path.
|
||||
- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- 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
|
||||
- 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/feedback/:id
|
||||
|
||||
- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path.
|
||||
@ -1255,18 +1274,18 @@ Version: 0.1.9
|
||||
|
||||
- Purpose: Processes the admin update action and stores or applies submitted form data.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state; may restart or stop runtime processes
|
||||
- Side effects: writes or mutates server-side state; publishes or streams live WebUI events
|
||||
- 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/check-update
|
||||
|
||||
- Purpose: Processes the admin check update action and stores or applies submitted form data.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: admin access expected
|
||||
- Side effects: writes or mutates server-side state
|
||||
- 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/restart
|
||||
@ -1281,12 +1300,12 @@ Version: 0.1.9
|
||||
# Lumi Bot
|
||||
Discord bot + WebUI with role-based access, plugin management, and self-update support.
|
||||
## Quick start
|
||||
Requires Node.js 18+.
|
||||
Requires Node.js 18+ on Windows or Linux.
|
||||
1. Install dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
2. Run with auto-restart:
|
||||
```
|
||||
npm run run
|
||||
```
|
||||
Do not use `--ignore-scripts`: Lumi uses the native `better-sqlite3` module,
|
||||
which must download or build a binary for your Node.js version. If Node was
|
||||
upgraded after installation, run `npm rebuild better-sqlite3` or remove
|
||||
`node_modules` and run `npm install` again. Linux source builds may also need
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, auto-vc
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Auto VC
|
||||
Auto-create managed voice channels from lobby rooms.
|
||||
@ -21,6 +20,7 @@ Default state: enabled
|
||||
- /plugins/auto-vc
|
||||
- GET /plugins/auto-vc
|
||||
- POST /plugins/auto-vc/settings
|
||||
- POST /plugins/auto-vc/settings/remove
|
||||
- POST /plugins/auto-vc/bans
|
||||
- POST /plugins/auto-vc/unban
|
||||
## Route Reference
|
||||
@ -45,9 +45,18 @@ Default state: enabled
|
||||
### POST /plugins/auto-vc/settings
|
||||
|
||||
- Purpose: Processes the auto-vc plugin action for settings.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: HTTP redirect after handling the request
|
||||
- Access: admin access expected; logged-in session required or used
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- 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 /plugins/auto-vc/settings/remove
|
||||
|
||||
- Purpose: Processes the auto-vc plugin action for settings remove.
|
||||
- Inputs: No request parameters detected by static analysis.
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
|
||||
- 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.
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, birthday
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Birthday
|
||||
Birthday profiles, announcements, lookup commands, and optional birthday currency gifts.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, economy-framework
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Economy Framework
|
||||
Cross-platform currency framework with shared balances and extensible hooks.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, economy-games
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Economy Games
|
||||
Cross-platform mini-games that use the Economy currency framework.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, expression-interaction
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Expression Interaction
|
||||
Express yourself through interactions with other users, such as hugging, bonking, comforting, etc
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, lumi_ai
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Lumi AI
|
||||
Managed local AI provider and scoped WebUI assistant for Lumi.
|
||||
@ -39,6 +38,7 @@ Default state: enabled
|
||||
- POST /plugins/lumi_ai/logs/:name/delete
|
||||
- POST /plugins/lumi_ai/runtime/:action
|
||||
- GET /plugins/lumi_ai/diagnostics/download
|
||||
- GET /plugins/lumi_ai/work-history/:id/export
|
||||
- POST /plugins/lumi_ai/assistant/message
|
||||
- POST /plugins/lumi_ai/assistant/feedback
|
||||
- GET /plugins/lumi_ai/assistant/jobs/:id
|
||||
@ -61,9 +61,12 @@ Default state: enabled
|
||||
- GET /plugins/lumi_ai/improvement_center
|
||||
- POST /plugins/lumi_ai/improvement_center/settings
|
||||
- POST /plugins/lumi_ai/improvement_center/reviews/:id
|
||||
- POST /plugins/lumi_ai/improvement_center/reviews/:id/delete
|
||||
- POST /plugins/lumi_ai/improvement_center/reviews/:id/archive
|
||||
- POST /plugins/lumi_ai/improvement_center/reviews/:id/implement
|
||||
- POST /plugins/lumi_ai/improvement_center/corrections/save
|
||||
- POST /plugins/lumi_ai/improvement_center/corrections/:id
|
||||
- POST /plugins/lumi_ai/improvement_center/corrections/:id/delete
|
||||
- POST /plugins/lumi_ai/improvement_center/evals
|
||||
- POST /plugins/lumi_ai/improvement_center/evals/:id/delete
|
||||
- POST /plugins/lumi_ai/improvement_center/evals/run
|
||||
@ -258,6 +261,15 @@ Default state: enabled
|
||||
- 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 /plugins/lumi_ai/work-history/:id/export
|
||||
|
||||
- Purpose: Renders or serves the lumi_ai plugin page for work history id export.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: plain or HTML response
|
||||
- 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. Input length or numeric bounds are enforced by helper functions in the handler.
|
||||
|
||||
### POST /plugins/lumi_ai/assistant/message
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant message.
|
||||
@ -270,7 +282,7 @@ Default state: enabled
|
||||
### POST /plugins/lumi_ai/assistant/feedback
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for assistant feedback.
|
||||
- Inputs: body: `assistant_answer`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `route_used`, `timestamp`, `user_message`
|
||||
- Inputs: body: `assistant_answer`, `context_snapshot`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `rating`, `route_used`, `timestamp`, `user_message`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- Side effects: Consumes submitted data; state mutation happens in called helpers if present.
|
||||
@ -456,6 +468,24 @@ Default state: enabled
|
||||
- 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. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/reviews/:id/delete
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id delete.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- 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 /plugins/lumi_ai/improvement_center/reviews/:id/archive
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id archive.
|
||||
- Inputs: path params: `id`; body: `review_notes`
|
||||
- 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
|
||||
- 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 /plugins/lumi_ai/improvement_center/reviews/:id/implement
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement.
|
||||
@ -483,6 +513,15 @@ Default state: enabled
|
||||
- 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. Most non-API POST routes are browser form submissions and usually redirect after completion.
|
||||
|
||||
### POST /plugins/lumi_ai/improvement_center/corrections/:id/delete
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center corrections id delete.
|
||||
- Inputs: path params: `id`
|
||||
- Response format: Form/action response; exact format was not detected statically.
|
||||
- Access: logged-in session required or used
|
||||
- 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 /plugins/lumi_ai/improvement_center/evals
|
||||
|
||||
- Purpose: Processes the lumi_ai plugin action for improvement center evals.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, moderation
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Moderation Center
|
||||
Cross-platform moderation actions, notes, and sanctions.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, okf
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# OKF Knowledge
|
||||
Role-gated knowledge, facts, and Q&A entries for Lumi communities.
|
||||
@ -21,6 +20,7 @@ Default state: enabled
|
||||
- /plugins/okf
|
||||
- GET /plugins/okf
|
||||
- GET /plugins/okf/admin
|
||||
- POST /plugins/okf/admin/preview
|
||||
- POST /plugins/okf/admin/community
|
||||
- POST /plugins/okf/admin/community/:slug
|
||||
- POST /plugins/okf/admin/entries
|
||||
@ -58,6 +58,15 @@ Default state: enabled
|
||||
- Side effects: Usually read-only.
|
||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||
|
||||
### POST /plugins/okf/admin/preview
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for preview.
|
||||
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
|
||||
- Response format: JSON response
|
||||
- Access: OKF editor or manager permission required; 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 /plugins/okf/admin/community
|
||||
|
||||
- Purpose: Processes the okf plugin administration action for community.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, quotes
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Quotes
|
||||
Store, search, and manage community quotes.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, sample-plugin
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Sample Plugin
|
||||
Example plugin with a simple page.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, throne_wishlist
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Throne Wishlist
|
||||
Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages.
|
||||
|
||||
@ -9,7 +9,6 @@ category: Plugin
|
||||
tags: plugin, welcome_messages
|
||||
generated: true
|
||||
editable: false
|
||||
updated_at: "2026-06-25T12:06:11.231Z"
|
||||
---
|
||||
# Welcome Messages
|
||||
Randomized Discord welcome and welcome-back messages with safe pronoun preferences.
|
||||
|
||||
282
package-lock.json
generated
282
package-lock.json
generated
@ -15,7 +15,8 @@
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.2.0",
|
||||
"obs-websocket-js": "^5.0.8",
|
||||
"tmi.js": "^1.8.5"
|
||||
},
|
||||
"engines": {
|
||||
@ -55,6 +56,15 @@
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpack/msgpack": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
|
||||
"integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/async-queue": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||
@ -219,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@ -232,7 +242,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@ -243,9 +253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@ -349,50 +359,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 0.8"
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@ -429,10 +409,10 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
@ -659,6 +639,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@ -669,14 +655,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@ -695,7 +681,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@ -773,16 +759,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -904,9 +890,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@ -988,11 +974,14 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
"node_modules/isomorphic-ws": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
|
||||
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
@ -1012,9 +1001,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
@ -1099,9 +1088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
@ -1119,18 +1108,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@ -1144,22 +1121,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
@ -1209,15 +1186,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@ -1230,6 +1198,47 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obs-websocket-js": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.8.tgz",
|
||||
"integrity": "sha512-QDnQJMr5wuCoYugK02ggZ1/cvESs4KJDEK+UhGg0Ry35jnY8tK1Xr3KoPjKyoD6sd8G+WdtIdzuY+yUyPtogQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^2.7.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"debug": "^4.3.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"type-fest": "^3.11.0",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obs-websocket-js/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/obs-websocket-js/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@ -1270,9 +1279,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@ -1307,12 +1316,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@ -1337,12 +1340,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@ -1503,14 +1507,14 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@ -1522,13 +1526,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -1734,6 +1738,18 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
|
||||
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@ -1827,10 +1843,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@ -1846,15 +1863,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@ -6,7 +6,15 @@
|
||||
"scripts": {
|
||||
"start": "node src/main.js",
|
||||
"run": "node run.js",
|
||||
"verify:webui": "node scripts/verify-webui.js"
|
||||
"verify:all": "node scripts/verify-all.js",
|
||||
"verify:preflight": "node scripts/verify-preflight.js",
|
||||
"verify:safe-files": "node scripts/verify-safe-files.js",
|
||||
"verify:uploads": "node scripts/verify-upload-security.js",
|
||||
"verify:web-auth": "node scripts/verify-web-auth.js",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@ -19,7 +27,8 @@
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.2.0",
|
||||
"obs-websocket-js": "^5.0.8",
|
||||
"tmi.js": "^1.8.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ module.exports = {
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
router.post("/settings", (req, res) => {
|
||||
const saveSettings = (req, res) => {
|
||||
if (!req.session.user || !req.session.user.isAdmin) {
|
||||
return res.status(403).render("error", {
|
||||
title: "Access denied",
|
||||
@ -69,7 +69,10 @@ module.exports = {
|
||||
message: "Auto VC settings saved."
|
||||
};
|
||||
res.redirect("/plugins/auto-vc");
|
||||
});
|
||||
};
|
||||
|
||||
router.post("/settings", saveSettings);
|
||||
router.post("/settings/remove", saveSettings);
|
||||
|
||||
router.post("/bans", (req, res) => {
|
||||
if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) {
|
||||
|
||||
@ -164,7 +164,7 @@
|
||||
<% if (isAdmin) { %>
|
||||
<section class="card">
|
||||
<h2>Lobby setup</h2>
|
||||
<form method="post" action="/plugins/auto-vc/settings" class="form-grid">
|
||||
<form method="post" action="/plugins/auto-vc/settings" class="form-grid" data-auto-vc-settings data-confirm-mode="modal" data-confirm-title="Delete lobby" data-confirm-text="Save these settings and permanently delete the selected lobby configuration?" data-confirm-label="Delete lobby">
|
||||
<div class="card">
|
||||
<h3>Rate limits</h3>
|
||||
<div class="form-grid rate-limit-grid">
|
||||
@ -371,11 +371,11 @@
|
||||
<div class="modal" id="delete-lobby-modal" aria-hidden="true">
|
||||
<div class="modal-backdrop" data-modal-close></div>
|
||||
<div class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-lobby-title">
|
||||
<h3 id="delete-lobby-title">Delete lobby?</h3>
|
||||
<p>This removes the lobby configuration and stops auto-creating rooms from it.</p>
|
||||
<h3 id="delete-lobby-title">Mark lobby for deletion?</h3>
|
||||
<p>The lobby is removed only after you save the settings and complete the safety timer.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="button subtle" data-modal-cancel>Cancel</button>
|
||||
<button type="button" class="button danger" data-modal-confirm>Delete lobby</button>
|
||||
<button type="button" class="button danger" data-modal-confirm>Mark for deletion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -437,6 +437,7 @@
|
||||
const addButton = document.getElementById("add-lobby");
|
||||
const container = document.getElementById("lobby-sections");
|
||||
const template = document.getElementById("lobby-template");
|
||||
const settingsForm = document.querySelector("[data-auto-vc-settings]");
|
||||
if (!addButton || !container || !template) {
|
||||
return;
|
||||
}
|
||||
@ -492,6 +493,7 @@
|
||||
field.disabled = true;
|
||||
});
|
||||
updateHeaders();
|
||||
if (settingsForm) settingsForm.action = "/plugins/auto-vc/settings/remove";
|
||||
};
|
||||
|
||||
const openModal = (card) => {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<tr><th>Discord client</th><td><%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %></td></tr>
|
||||
<tr><th>Announcement channel</th><td><%= diagnostics.channel.message %></td></tr>
|
||||
<tr><th>Economy</th><td><%= diagnostics.economyAvailable ? "Available" : "Unavailable" %></td></tr>
|
||||
<tr><th>Current plugin date</th><td><%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %></td></tr>
|
||||
<tr><th>Today in the selected timezone</th><td><%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -37,8 +37,8 @@
|
||||
<div class="field">
|
||||
<label>Gift mode</label>
|
||||
<select name="gift_mode">
|
||||
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>automatic</option>
|
||||
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>manual</option>
|
||||
<option value="automatic" <%= config.gift_mode === "automatic" ? "selected" : "" %>>Send automatically</option>
|
||||
<option value="manual" <%= config.gift_mode === "manual" ? "selected" : "" %>>Staff sends manually</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
@ -53,8 +53,8 @@
|
||||
<div class="field">
|
||||
<label>Feb 29 handling</label>
|
||||
<select name="leap_day_policy">
|
||||
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>feb28</option>
|
||||
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>mar1</option>
|
||||
<option value="feb28" <%= config.leap_day_policy === "feb28" ? "selected" : "" %>>Celebrate on February 28</option>
|
||||
<option value="mar1" <%= config.leap_day_policy === "mar1" ? "selected" : "" %>>Celebrate on March 1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
@ -69,7 +69,7 @@
|
||||
<% } else { %>
|
||||
<input name="announcement_channel_id" value="<%= config.announcement_channel_id %>" placeholder="Discord text channel ID" />
|
||||
<% } %>
|
||||
<p class="hint">Only regular guild text channels are accepted.</p>
|
||||
<p class="hint">Choose a regular text channel from this Discord server.</p>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Save settings</button>
|
||||
@ -109,7 +109,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="field profile-actions">
|
||||
<button type="submit" class="button subtle">Save</button>
|
||||
<button type="submit" class="button subtle">Save message</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="profile-actions">
|
||||
@ -117,9 +117,9 @@
|
||||
<input type="hidden" name="pool" value="fullYear" />
|
||||
<button type="submit" class="button subtle">Duplicate</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove">
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove" data-confirm-mode="modal" data-confirm-title="Remove birthday template" data-confirm-text="Remove this birthday message template?" data-confirm-label="Remove template">
|
||||
<input type="hidden" name="pool" value="fullYear" />
|
||||
<button type="submit" class="button subtle">Remove</button>
|
||||
<button type="submit" class="button danger">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
<% }) %>
|
||||
@ -158,7 +158,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="field profile-actions">
|
||||
<button type="submit" class="button subtle">Save</button>
|
||||
<button type="submit" class="button subtle">Save message</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="profile-actions">
|
||||
@ -166,9 +166,9 @@
|
||||
<input type="hidden" name="pool" value="partialYear" />
|
||||
<button type="submit" class="button subtle">Duplicate</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove">
|
||||
<form method="post" action="/plugins/birthday/templates/<%= message.id %>/remove" data-confirm-mode="modal" data-confirm-title="Remove birthday template" data-confirm-text="Remove this birthday message template?" data-confirm-label="Remove template">
|
||||
<input type="hidden" name="pool" value="partialYear" />
|
||||
<button type="submit" class="button subtle">Remove</button>
|
||||
<button type="submit" class="button danger">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
<% }) %>
|
||||
|
||||
@ -22,8 +22,8 @@
|
||||
</div>
|
||||
</form>
|
||||
<% if (birthday) { %>
|
||||
<form method="post" action="/plugins/birthday/profile/unset">
|
||||
<button type="submit" class="button subtle">Remove birthday</button>
|
||||
<form method="post" action="/plugins/birthday/profile/unset" data-confirm-mode="modal" data-confirm-title="Remove birthday" data-confirm-text="Remove your saved birthday?" data-confirm-label="Remove birthday">
|
||||
<button type="submit" class="button danger">Remove birthday</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
|
||||
@ -6,6 +6,10 @@ const express = require("express");
|
||||
const multer = require("multer");
|
||||
const EventEmitter = require("events");
|
||||
const { ensureUserForIdentity } = require("../../src/services/users");
|
||||
const {
|
||||
cleanupUploadedFiles,
|
||||
validateUploadedFile
|
||||
} = require("../../src/services/upload-security");
|
||||
|
||||
const PLUGIN_ID = "economy-framework";
|
||||
const LEGACY_STEM = ["echo", "nomy"].join("");
|
||||
@ -218,6 +222,7 @@ module.exports = {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const upload = multer({
|
||||
dest: uploadDir,
|
||||
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 10, parts: 12 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === "image/png") {
|
||||
return cb(null, true);
|
||||
@ -225,6 +230,31 @@ module.exports = {
|
||||
cb(new Error("Only PNG files are allowed."));
|
||||
}
|
||||
});
|
||||
const currencyIconUpload = (req, res, next) => upload.single("currency_icon")(req, res, (error) => {
|
||||
if (error) {
|
||||
cleanupUploadedFiles(req.file);
|
||||
req.file = null;
|
||||
req.currencyIconUploadError = error.code === "LIMIT_FILE_SIZE"
|
||||
? "Currency icons must be 2 MB or smaller."
|
||||
: error.message;
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
if (req.file) {
|
||||
validateUploadedFile(req.file, {
|
||||
allowedTypes: ["png"],
|
||||
maxBytes: 2 * 1024 * 1024,
|
||||
requireExtension: true,
|
||||
label: "currency icon"
|
||||
});
|
||||
}
|
||||
} catch (validationError) {
|
||||
cleanupUploadedFiles(req.file);
|
||||
req.file = null;
|
||||
req.currencyIconUploadError = validationError.message;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const router = web.createRouter();
|
||||
router.use("/assets", express.static(uploadDir));
|
||||
@ -464,18 +494,18 @@ module.exports = {
|
||||
res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
});
|
||||
|
||||
router.post("/settings/icon", upload.single("currency_icon"), (req, res) => {
|
||||
router.post("/settings/icon", (req, res, next) => {
|
||||
if (!req.session.user?.isAdmin) {
|
||||
return deny(res);
|
||||
}
|
||||
if (!req.file) {
|
||||
req.session.flash = { type: "error", message: "Upload a PNG icon." };
|
||||
next();
|
||||
}, currencyIconUpload, (req, res) => {
|
||||
if (req.currencyIconUploadError) {
|
||||
req.session.flash = { type: "error", message: req.currencyIconUploadError };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
const ext = path.extname(req.file.originalname || "").toLowerCase();
|
||||
if (ext && ext !== ".png") {
|
||||
fs.rmSync(req.file.path, { force: true });
|
||||
req.session.flash = { type: "error", message: "Only PNG files are allowed." };
|
||||
if (!req.file) {
|
||||
req.session.flash = { type: "error", message: "Upload a PNG icon." };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`;
|
||||
|
||||
@ -203,7 +203,7 @@
|
||||
<div class="field">
|
||||
<label>Upload PNG icon</label>
|
||||
<input type="file" name="currency_icon" accept="image/png" />
|
||||
<span class="hint">PNG only. Used across the WebUI.</span>
|
||||
<span class="hint">PNG only, up to 2 MB. Used across the WebUI.</span>
|
||||
</div>
|
||||
<button type="submit" class="button">Upload icon</button>
|
||||
</form>
|
||||
@ -454,8 +454,8 @@
|
||||
<% events.forEach((event) => { %>
|
||||
<li>
|
||||
<span><strong><%= event.name %></strong> (<%= event.amount %>)</span>
|
||||
<form method="post" action="/plugins/economy-framework/events/<%= event.id %>/delete">
|
||||
<button type="submit" class="button subtle">Delete</button>
|
||||
<form method="post" action="/plugins/economy-framework/events/<%= event.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete event reward" data-confirm-text="Delete this event reward?" data-confirm-label="Delete reward">
|
||||
<button type="submit" class="button danger">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
<% }) %>
|
||||
@ -523,7 +523,7 @@
|
||||
<button type="button" class="button subtle" data-response-add>Add response</button>
|
||||
<div class="response-note">Weights are used only when "Weighted" is selected.</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="button">Save</button>
|
||||
<button type="submit" class="button">Save responses</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -196,7 +196,7 @@
|
||||
<button type="submit" class="button">Restore</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/archive" class="inline-form">
|
||||
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive expression" data-confirm-text="Archive this expression interaction?" data-confirm-label="Archive expression">
|
||||
<button type="submit" class="button danger">Archive</button>
|
||||
</form>
|
||||
<% } %>
|
||||
@ -244,7 +244,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Save</button>
|
||||
<button type="submit" class="button">Save expression</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@ -522,6 +522,15 @@ class AiProvider {
|
||||
force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null,
|
||||
internal_generated_length: initialText.length + String(finalText || "").length,
|
||||
fallback_reason: fallbackReason,
|
||||
feedback_context_snapshot: buildFeedbackContextSnapshot({
|
||||
contextBlocks,
|
||||
correctionContext,
|
||||
repoContext,
|
||||
contextDiagnostics,
|
||||
controllerDecision,
|
||||
exposedTools: toolExposure.exposed,
|
||||
selectedTool
|
||||
}),
|
||||
stage_timings: {
|
||||
deterministic_ms: gateDecision?.deterministic_ms || 0,
|
||||
gate_ms: gateDecision?.gate_ms || 0,
|
||||
@ -841,6 +850,49 @@ function redactPrompt(value) {
|
||||
.slice(0, 6000);
|
||||
}
|
||||
|
||||
function buildFeedbackContextSnapshot({
|
||||
contextBlocks = [],
|
||||
correctionContext = [],
|
||||
repoContext = [],
|
||||
contextDiagnostics = [],
|
||||
controllerDecision = {},
|
||||
exposedTools = [],
|
||||
selectedTool = null
|
||||
} = {}) {
|
||||
const retrieval = summarizeContextDiagnostics(contextDiagnostics);
|
||||
return {
|
||||
retrieval: {
|
||||
query: safeFeedbackText(retrieval.query, 500),
|
||||
candidate_count: retrieval.candidate_count,
|
||||
selected_count: Array.isArray(contextBlocks) ? contextBlocks.length : 0,
|
||||
depth: String(controllerDecision.okf_retrieval || "none").slice(0, 40)
|
||||
},
|
||||
okf: safeFeedbackList(contextBlocks, 4),
|
||||
corrections: safeFeedbackList(correctionContext, 3),
|
||||
repository: safeFeedbackList(repoContext, 3),
|
||||
tools: [...new Set([
|
||||
...exposedTools.map((tool) => tool?.tool_id || tool?.id).filter(Boolean),
|
||||
selectedTool
|
||||
].filter(Boolean).map((value) => String(value).slice(0, 120)))].slice(0, 20)
|
||||
};
|
||||
}
|
||||
|
||||
function safeFeedbackList(values, limit) {
|
||||
return (Array.isArray(values) ? values : [])
|
||||
.slice(0, limit)
|
||||
.map((value) => safeFeedbackText(typeof value === "string" ? value : JSON.stringify(value), 1200))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function safeFeedbackText(value, limit) {
|
||||
const cwd = process.cwd();
|
||||
return redactPrompt(value)
|
||||
.replaceAll(cwd, "[repo]")
|
||||
.replaceAll(cwd.replaceAll("\\", "/"), "[repo]")
|
||||
.replace(/\b[A-Za-z]:[\\/][^\r\n"']+/g, "[local path]")
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AiProvider,
|
||||
isInScope,
|
||||
@ -849,5 +901,6 @@ module.exports = {
|
||||
isExactHelpShortcut,
|
||||
normalizeHistory,
|
||||
normalizeInferenceDiagnostics,
|
||||
resolveOutputBudget
|
||||
resolveOutputBudget,
|
||||
buildFeedbackContextSnapshot
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { resolveData } = require("./paths");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
class SafeAnswerCache {
|
||||
constructor(getConfig) {
|
||||
@ -57,13 +58,7 @@ class SafeAnswerCache {
|
||||
}
|
||||
|
||||
write(store) {
|
||||
const tmp = `${this.file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`);
|
||||
fs.renameSync(tmp, this.file);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
writeJsonAtomicSync(this.file, store);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const fs = require("fs");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
const { resolveData, ensureDataDirs } = require("./paths");
|
||||
const { DEFAULT_SCOPE, normalizeScope } = require("./scope_manager");
|
||||
const { DEFAULT_RATE_LIMITS, mergeLimits } = require("./rate_limits");
|
||||
@ -99,9 +100,7 @@ function readJson(name, fallback) {
|
||||
}
|
||||
function writeJson(name, value) {
|
||||
const file = resolveData("config", name);
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
||||
fs.renameSync(tmp, file);
|
||||
writeJsonAtomicSync(file, value);
|
||||
}
|
||||
function getConfig() {
|
||||
const config = readJson("ai_config.json", DEFAULT_CONFIG);
|
||||
|
||||
@ -58,7 +58,8 @@ class CorrectionStore {
|
||||
verified_at: null,
|
||||
active: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
updated_at: new Date().toISOString(),
|
||||
linked_okf_path: null
|
||||
};
|
||||
const store = this.read();
|
||||
store.entries.unshift(entry);
|
||||
@ -142,6 +143,14 @@ class CorrectionStore {
|
||||
return { total: store.entries.length, active };
|
||||
}
|
||||
|
||||
linkOkf(id, correctionPath) {
|
||||
return this.mutate(id, (entry) => ({
|
||||
...entry,
|
||||
linked_okf_path: clean(correctionPath, 1000),
|
||||
updated_at: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
match(input, limit = 4) {
|
||||
if (this.getConfig()?.improvement?.corrections_enabled === false) return [];
|
||||
const role = normalizeRole(input.role);
|
||||
|
||||
@ -2,6 +2,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const AdmZip = require("adm-zip");
|
||||
const { resolveData, ensureDataDirs } = require("./paths");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
function redact(value) {
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
@ -26,7 +27,7 @@ function createDiagnostic(input) {
|
||||
function persistDiagnostic(input) {
|
||||
ensureDataDirs();
|
||||
const diagnostic = createDiagnostic(input);
|
||||
fs.writeFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
|
||||
writeJsonAtomicSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), diagnostic);
|
||||
fs.appendFileSync(resolveData("diagnostics", "runtime_diagnostics.jsonl"), `${JSON.stringify(diagnostic)}\n`);
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const AdmZip = require("adm-zip");
|
||||
const { resolveData } = require("./paths");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
class DownloadManager {
|
||||
constructor(onEvent) {
|
||||
@ -144,7 +145,7 @@ class DownloadManager {
|
||||
job.state = "installing";
|
||||
await replaceDirectoryContents(staging, finalDir);
|
||||
|
||||
fs.writeFileSync(path.join(finalDir, "lumi-runtime.json"), `${JSON.stringify({
|
||||
writeJsonAtomicSync(path.join(finalDir, "lumi-runtime.json"), {
|
||||
backend: runtimeMetadata?.backend || "cpu",
|
||||
backend_variant: runtimeMetadata?.backend_variant || null,
|
||||
version: runtimeMetadata?.version || null,
|
||||
@ -155,7 +156,7 @@ class DownloadManager {
|
||||
size: dependency.size || 0
|
||||
})),
|
||||
installed_at: new Date().toISOString()
|
||||
}, null, 2)}\n`);
|
||||
});
|
||||
} finally {
|
||||
if (installCallbackStarted && typeof afterInstall === "function") {
|
||||
await afterInstall({ job, finalDir, staging, runtimeMetadata });
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
const { resolveData } = require("./paths");
|
||||
const { roleOf } = require("./permissions");
|
||||
|
||||
@ -13,7 +13,10 @@ const FEEDBACK_TAGS = Object.freeze([
|
||||
"should_clarify",
|
||||
"bad_code",
|
||||
"wrong_scope",
|
||||
"wrong_tool_usage"
|
||||
"wrong_tool_usage",
|
||||
"bad_retrieval",
|
||||
"bad_response",
|
||||
"missing_knowledge"
|
||||
]);
|
||||
|
||||
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
|
||||
@ -36,11 +39,14 @@ class FeedbackStore {
|
||||
platform: clean(input.platform, 80) || "webui",
|
||||
model: clean(input.model, 200),
|
||||
timestamp: validDate(input.timestamp) || new Date().toISOString(),
|
||||
rating: input.rating === "up" || input.feedback_tag === "good" ? "up" : "down",
|
||||
feedback_tag: tag,
|
||||
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
|
||||
? input.feedback_kind
|
||||
: "instruction_based",
|
||||
optional_correction: clean(input.optional_correction, 16000),
|
||||
context_snapshot: normalizeContextSnapshot(input.context_snapshot),
|
||||
linked_okf_correction: null,
|
||||
status: "pending",
|
||||
submitted_by: String(actor?.id || "anonymous"),
|
||||
reviewed_by: null,
|
||||
@ -59,8 +65,10 @@ class FeedbackStore {
|
||||
return entry;
|
||||
}
|
||||
|
||||
list({ page = 1, pageSize = 20, status = "" } = {}) {
|
||||
const filtered = this.read().entries.filter((entry) => !status || entry.status === status);
|
||||
list({ page = 1, pageSize = 20, status = "", viewerRole = "admin" } = {}) {
|
||||
const filtered = this.read().entries
|
||||
.filter((entry) => roleRank(viewerRole) >= roleRank(entry.role))
|
||||
.filter((entry) => !status || entry.status === status);
|
||||
return paginate(filtered, page, pageSize);
|
||||
}
|
||||
|
||||
@ -85,7 +93,7 @@ class FeedbackStore {
|
||||
}
|
||||
|
||||
setStatus(id, status, actor, notes = "") {
|
||||
if (!["pending", "flagged", "verified", "approved", "rejected"].includes(status)) {
|
||||
if (!["pending", "flagged", "verified", "approved", "rejected", "reviewed", "archived"].includes(status)) {
|
||||
throw new Error("Invalid review status.");
|
||||
}
|
||||
return this.mutate(id, (entry) => ({
|
||||
@ -116,6 +124,15 @@ class FeedbackStore {
|
||||
}));
|
||||
}
|
||||
|
||||
linkOkfCorrection(id, correctionPath, actor) {
|
||||
return this.mutate(id, (entry) => ({
|
||||
...entry,
|
||||
linked_okf_correction: clean(correctionPath, 1000),
|
||||
reviewed_by: entry.reviewed_by || String(actor.id),
|
||||
reviewed_at: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
const store = this.read();
|
||||
const before = store.entries.length;
|
||||
@ -180,13 +197,7 @@ function paginate(rows, pageValue, pageSizeValue) {
|
||||
}
|
||||
|
||||
function atomicJson(file, value) {
|
||||
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
||||
fs.renameSync(tmp, file);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
writeJsonAtomicSync(file, value);
|
||||
}
|
||||
|
||||
function clean(value, max) {
|
||||
@ -202,11 +213,42 @@ function validDate(value) {
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeContextSnapshot(value) {
|
||||
const snapshot = value && typeof value === "object" ? value : {};
|
||||
const retrieval = snapshot.retrieval && typeof snapshot.retrieval === "object" ? snapshot.retrieval : {};
|
||||
return {
|
||||
retrieval: {
|
||||
query: clean(retrieval.query, 500),
|
||||
candidate_count: safeCount(retrieval.candidate_count),
|
||||
selected_count: safeCount(retrieval.selected_count),
|
||||
depth: clean(retrieval.depth, 40)
|
||||
},
|
||||
okf: cleanList(snapshot.okf, 4, 1200),
|
||||
corrections: cleanList(snapshot.corrections, 3, 1200),
|
||||
repository: cleanList(snapshot.repository, 3, 1200),
|
||||
tools: cleanList(snapshot.tools, 20, 120)
|
||||
};
|
||||
}
|
||||
|
||||
function cleanList(value, limit, maxLength) {
|
||||
return (Array.isArray(value) ? value : []).slice(0, limit).map((entry) => clean(entry, maxLength)).filter(Boolean);
|
||||
}
|
||||
|
||||
function safeCount(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.max(0, Math.min(100000, Math.floor(number))) : 0;
|
||||
}
|
||||
|
||||
function roleRank(role) {
|
||||
return { user: 1, mod: 2, admin: 3 }[normalizeRole(role)] || 1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEEDBACK_KINDS,
|
||||
FEEDBACK_TAGS,
|
||||
FeedbackStore,
|
||||
improvementAccess,
|
||||
normalizeContextSnapshot,
|
||||
paginate,
|
||||
atomicJson
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const { resolveData } = require("./paths");
|
||||
const { writeJsonAtomicSync, writeTextAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
const historyFile = () => resolveData("metrics", "history.jsonl");
|
||||
const stateFile = () => resolveData("metrics", "summary.json");
|
||||
@ -118,7 +119,7 @@ function record(entry) {
|
||||
});
|
||||
summary.slow_requests = summary.slow_requests.slice(0, 25);
|
||||
}
|
||||
fs.writeFileSync(stateFile(), JSON.stringify(summary, null, 2));
|
||||
writeJsonAtomicSync(stateFile(), summary);
|
||||
fs.appendFileSync(historyFile(), `${JSON.stringify({ timestamp:new Date().toISOString(), ...entry })}\n`);
|
||||
}
|
||||
function report() {
|
||||
@ -176,6 +177,10 @@ function workHistoryPage(query = {}, page = 1, pageSize = 20) {
|
||||
.filter((work) => matchesWorkFilters(work, filters));
|
||||
return paginateRows(grouped, page, pageSize);
|
||||
}
|
||||
function workHistoryExport(workId) {
|
||||
const work = groupWorkRows(readHistoryRows()).find((entry) => entry.work_id === String(workId || ""));
|
||||
return work ? buildWorkHistoryExport(work) : null;
|
||||
}
|
||||
function slowRequestsPage(page = 1, pageSize = 15) {
|
||||
const safePage = Math.max(1, Number.parseInt(page, 10) || 1);
|
||||
const safeSize = Math.max(1, Math.min(100, Number.parseInt(pageSize, 10) || 15));
|
||||
@ -202,12 +207,12 @@ function applyRetention() {
|
||||
if (!rows.length) return;
|
||||
const keepIds = retainedWorkIds(groupWorkRows(rows), retention);
|
||||
if (!keepIds) return;
|
||||
const kept = rows.filter((row) => {
|
||||
const id = row.request_id || row.work_id;
|
||||
return !id || keepIds.has(id);
|
||||
const kept = rows.filter((row, index) => {
|
||||
const id = rowWorkId(row, index);
|
||||
return keepIds.has(id);
|
||||
});
|
||||
if (kept.length === rows.length) return;
|
||||
fs.writeFileSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : ""));
|
||||
writeTextAtomicSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : ""));
|
||||
}
|
||||
function maybeApplyRetention() {
|
||||
if (Math.random() > 0.02) return;
|
||||
@ -225,9 +230,8 @@ function readHistoryRows() {
|
||||
}
|
||||
function groupWorkRows(rows) {
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const id = row.request_id || row.work_id;
|
||||
if (!id) continue;
|
||||
rows.forEach((row, index) => {
|
||||
const id = rowWorkId(row, index);
|
||||
if (!groups.has(id)) {
|
||||
groups.set(id, {
|
||||
work_id: id,
|
||||
@ -237,6 +241,8 @@ function groupWorkRows(rows) {
|
||||
source: row.origin || row.platform || "webui",
|
||||
user_id: row.user_id || "",
|
||||
role: row.role || "",
|
||||
model: row.model || row.model_id || "",
|
||||
provider: row.provider || row.backend || "",
|
||||
internal_mode: row.controller_complexity || "",
|
||||
okf_retrieval: row.okf_retrieval_depth || "",
|
||||
reason_code: row.controller_reason_code || row.gate_reason_code || row.reason_code || "",
|
||||
@ -260,6 +266,8 @@ function groupWorkRows(rows) {
|
||||
group.source = row.origin || row.platform || group.source;
|
||||
group.user_id = row.user_id || group.user_id;
|
||||
group.role = row.role || group.role;
|
||||
group.model = row.model || row.model_id || group.model;
|
||||
group.provider = row.provider || row.backend || group.provider;
|
||||
group.internal_mode = row.controller_complexity || group.internal_mode;
|
||||
group.okf_retrieval = row.okf_retrieval_depth || group.okf_retrieval;
|
||||
group.reason_code = row.controller_reason_code || row.gate_reason_code || row.reason_code || group.reason_code;
|
||||
@ -283,7 +291,7 @@ function groupWorkRows(rows) {
|
||||
else if (row.delivered_length != null) group.delivered_tokens = estimateTokensFromChars(row.delivered_length);
|
||||
group.duration_ms = Math.max(group.duration_ms, validOrZero(row.total_ms ?? row.duration_ms));
|
||||
group.events.push(workEvent(row));
|
||||
}
|
||||
});
|
||||
return [...groups.values()]
|
||||
.map((group) => {
|
||||
if (!group.processed_tokens) group.processed_tokens = group.prompt_tokens + group.final_tokens;
|
||||
@ -339,10 +347,104 @@ function compactWorkData(row) {
|
||||
"controller_complexity", "okf_retrieval_depth", "answer_style", "source_profile",
|
||||
"controller_confidence", "okf_match_count", "context_block_count", "prompt_tokens",
|
||||
"generated_tokens", "final_tokens", "delivered_tokens", "final_reply_length",
|
||||
"delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code"
|
||||
"delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code",
|
||||
"model", "model_id", "provider", "backend", "okf_query", "okf_candidate_count", "okf_limit",
|
||||
"okf_provider_count", "deterministic_ms", "gate_ms", "queue_ms", "prompt_eval_ms", "generation_ms", "total_ms"
|
||||
];
|
||||
return Object.fromEntries(keys.filter((key) => row[key] != null).map((key) => [key, row[key]]));
|
||||
}
|
||||
function rowWorkId(row, index) {
|
||||
if (row.request_id || row.work_id) return String(row.request_id || row.work_id);
|
||||
const timestamp = String(row.timestamp || "unknown").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60);
|
||||
return `legacy-${timestamp || "row"}-${index}`;
|
||||
}
|
||||
|
||||
function buildWorkHistoryExport(work) {
|
||||
const cleanPrompt = sanitizeWorkExportText(work.prompt || "Prompt was not recorded for this legacy entry.", work, 6000);
|
||||
const lifecycle = (work.events || []).map((event) => ({
|
||||
timestamp: event.timestamp || null,
|
||||
type: event.type || "event",
|
||||
status: event.status || null,
|
||||
summary: sanitizeWorkExportText(event.summary, work, 500),
|
||||
tokens: validOrZero(event.tokens),
|
||||
data: sanitizeWorkEventData(event.data, work)
|
||||
}));
|
||||
const retrieval = lifecycle
|
||||
.filter((event) => event.type === "okf_retrieval")
|
||||
.map((event) => ({
|
||||
query: event.data.okf_query || null,
|
||||
depth: event.data.okf_retrieval_depth || work.okf_retrieval || null,
|
||||
candidates: validOrZero(event.data.okf_candidate_count),
|
||||
selected: validOrZero(event.data.okf_match_count),
|
||||
fallback_reason: event.data.fallback_reason || null
|
||||
}));
|
||||
const errors = lifecycle
|
||||
.filter((event) => event.type === "error" || event.status === "failed" || event.data.error_code)
|
||||
.map((event) => ({ code: event.data.error_code || null, summary: event.summary }))
|
||||
.slice(0, 10);
|
||||
return {
|
||||
schema: "lumi.ai.work.codex.v1",
|
||||
generated_at: new Date().toISOString(),
|
||||
objective: `Investigate Lumi AI work item ${work.status || "unknown"}: ${cleanPrompt.slice(0, 240)}`,
|
||||
work: {
|
||||
id: String(work.work_id || "legacy"),
|
||||
prompt: cleanPrompt,
|
||||
source: sanitizeWorkExportText(work.source, work, 80),
|
||||
role: ["user", "mod", "admin"].includes(work.role) ? work.role : "unknown",
|
||||
status: work.status || "unknown",
|
||||
route: lifecycle.map((event) => event.data.route_used).find(Boolean) || null,
|
||||
model: sanitizeWorkExportText(work.model, work, 160) || null,
|
||||
provider: sanitizeWorkExportText(work.provider, work, 120) || null,
|
||||
controller: {
|
||||
mode: work.internal_mode || null,
|
||||
reason: sanitizeWorkExportText(work.reason_code, work, 160) || null,
|
||||
okf_retrieval: work.okf_retrieval || null
|
||||
},
|
||||
tokens: {
|
||||
processed: validOrZero(work.processed_tokens),
|
||||
prompt: validOrZero(work.prompt_tokens),
|
||||
final: validOrZero(work.final_tokens),
|
||||
delivered: validOrZero(work.delivered_tokens)
|
||||
},
|
||||
duration_ms: validOrZero(work.duration_ms),
|
||||
flags: [
|
||||
work.has_error && "error",
|
||||
work.has_refusal && "refusal",
|
||||
work.has_fallback && "fallback",
|
||||
work.has_truncation && "truncation",
|
||||
work.has_okf_context && "okf_context"
|
||||
].filter(Boolean),
|
||||
retrieval,
|
||||
errors,
|
||||
lifecycle
|
||||
},
|
||||
privacy: {
|
||||
mode: "task_focused",
|
||||
removed: ["user identity", "session data", "secrets", "local paths", "raw diagnostics", "tool payloads"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeWorkEventData(value, work) {
|
||||
const data = value && typeof value === "object" ? value : {};
|
||||
return Object.fromEntries(Object.entries(data).map(([key, item]) => [
|
||||
key,
|
||||
typeof item === "string" ? sanitizeWorkExportText(item, work, 500) : item
|
||||
]));
|
||||
}
|
||||
|
||||
function sanitizeWorkExportText(value, work, maxLength) {
|
||||
let text = String(value || "");
|
||||
if (work.user_id) text = text.replaceAll(String(work.user_id), "[user]");
|
||||
return text
|
||||
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]")
|
||||
.replace(/\b((?:token|secret|password|cookie|authorization|api[_ -]?key|client[_ -]?secret)\s*[:=]\s*)\S+/gi, "$1[redacted]")
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
|
||||
.replace(/\b[A-Za-z]:[\\/][^\r\n"']+/g, "[local path]")
|
||||
.replace(/\/(?:home|Users|mnt|var|tmp)\/[^\s"']+/g, "[local path]")
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
function normalizeWorkFilters(query = {}) {
|
||||
return {
|
||||
q: String(query.work_q || query.q || "").trim().toLowerCase(),
|
||||
@ -474,6 +576,9 @@ module.exports = {
|
||||
history,
|
||||
historyPage,
|
||||
workHistoryPage,
|
||||
workHistoryExport,
|
||||
buildWorkHistoryExport,
|
||||
groupWorkRows,
|
||||
slowRequestsPage,
|
||||
paginateRows,
|
||||
isValidTiming,
|
||||
|
||||
@ -2,6 +2,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { PLUGIN_ROOT, resolveData } = require("./paths");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
const INDEX_FILE = "index.json";
|
||||
const REPOSITORY_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
|
||||
@ -52,7 +53,7 @@ function refreshIndex(root = repoRoot()) {
|
||||
documents
|
||||
};
|
||||
const target = resolveData("repo_index", INDEX_FILE);
|
||||
fs.writeFileSync(target, `${JSON.stringify(index, null, 2)}\n`);
|
||||
writeJsonAtomicSync(target, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
@ -71,7 +72,7 @@ function refreshPublicIndex() {
|
||||
const index = refreshIndex(staging);
|
||||
index.source = "public";
|
||||
index.root = REPOSITORY_URL;
|
||||
fs.writeFileSync(resolveData("repo_index", INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`);
|
||||
writeJsonAtomicSync(resolveData("repo_index", INDEX_FILE), index);
|
||||
return index;
|
||||
} finally {
|
||||
const resolved = path.resolve(staging);
|
||||
|
||||
@ -3,6 +3,7 @@ const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { resolveData } = require("./paths");
|
||||
const { assertToolId } = require("./tool_repo_client");
|
||||
const { runFileOperationWithRetries } = require("../../../src/services/safe-files");
|
||||
|
||||
const REQUIRED_FIELDS = Object.freeze([
|
||||
"tool_id",
|
||||
@ -46,12 +47,12 @@ class ToolInstaller {
|
||||
return this.stageAndSwap(toolId, false);
|
||||
}
|
||||
|
||||
async update(toolId) {
|
||||
async update(toolId, options = {}) {
|
||||
if (!this.local(toolId)) throw new Error("AI tool plugin is not installed.");
|
||||
return this.stageAndSwap(toolId, true);
|
||||
return this.stageAndSwap(toolId, true, options);
|
||||
}
|
||||
|
||||
async stageAndSwap(toolId, updating) {
|
||||
async stageAndSwap(toolId, updating, options = {}) {
|
||||
assertToolId(toolId);
|
||||
const id = crypto.randomUUID();
|
||||
const stageBase = path.join(this.stagingRoot, id);
|
||||
@ -64,23 +65,37 @@ class ToolInstaller {
|
||||
const remoteMetadata = validateToolDirectory(stageDir, toolId);
|
||||
if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata);
|
||||
let movedCurrent = false;
|
||||
let installedReplacement = false;
|
||||
try {
|
||||
if (updating) {
|
||||
fs.renameSync(target, backup);
|
||||
fileOperation(() => fs.renameSync(target, backup), target, "tool_backup");
|
||||
movedCurrent = true;
|
||||
}
|
||||
fs.renameSync(stageDir, target);
|
||||
fileOperation(() => fs.renameSync(stageDir, target), target, "tool_install");
|
||||
installedReplacement = true;
|
||||
if (typeof options.afterSwap === "function") await options.afterSwap({ target, backup, metadata: remoteMetadata });
|
||||
} catch (error) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
if (movedCurrent && fs.existsSync(backup)) fs.renameSync(backup, target);
|
||||
if (installedReplacement && fs.existsSync(target)) {
|
||||
try { fileOperation(() => fs.rmSync(target, { recursive: true, force: true }), target, "tool_remove_failed_replacement"); }
|
||||
catch (cleanupError) { error.message = `${error.message} The failed replacement remains at ${target}: ${cleanupError.message}`; }
|
||||
}
|
||||
if (movedCurrent && fs.existsSync(backup) && !fs.existsSync(target)) {
|
||||
try {
|
||||
fileOperation(() => fs.renameSync(backup, target), target, "tool_restore");
|
||||
movedCurrent = false;
|
||||
} catch (restoreError) {
|
||||
error.message = `${error.message} The previous tool remains recoverable at ${backup}: ${restoreError.message}`;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
if (fs.existsSync(backup)) fileOperation(() => fs.rmSync(backup, { recursive: true, force: true }), backup, "tool_commit_cleanup");
|
||||
return { tool_id: toolId, metadata: remoteMetadata, installed: true, updated: updating };
|
||||
} finally {
|
||||
fs.rmSync(stageBase, { recursive: true, force: true });
|
||||
if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target);
|
||||
else fs.rmSync(backup, { recursive: true, force: true });
|
||||
if (fs.existsSync(backup) && !fs.existsSync(target)) {
|
||||
try { fileOperation(() => fs.renameSync(backup, target), target, "tool_final_restore"); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +109,15 @@ class ToolInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
function fileOperation(operation, target, operationName) {
|
||||
return runFileOperationWithRetries(operation, {
|
||||
target,
|
||||
operation: operationName,
|
||||
attempts: 8,
|
||||
delayMs: 15
|
||||
});
|
||||
}
|
||||
|
||||
function validateToolDirectory(directory, folderName = path.basename(directory)) {
|
||||
const metadataFile = path.join(directory, "tool_info.json");
|
||||
const readmeFile = path.join(directory, "readme.md");
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
const { resolveData } = require("./paths");
|
||||
const { registerManagedTool } = require("./tool_registry");
|
||||
|
||||
@ -272,15 +273,7 @@ class ToolLoader {
|
||||
}
|
||||
|
||||
writeState(state) {
|
||||
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
|
||||
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
|
||||
try { fs.renameSync(temporary, this.stateFile); }
|
||||
catch (error) {
|
||||
if (!["EEXIST", "EPERM"].includes(error.code)) throw error;
|
||||
fs.rmSync(this.stateFile, { force: true });
|
||||
fs.renameSync(temporary, this.stateFile);
|
||||
}
|
||||
writeJsonAtomicSync(this.stateFile, state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -93,8 +93,14 @@ class ToolManager {
|
||||
const wasEnabled = this.loader.isEnabled(toolId);
|
||||
if (wasEnabled) await this.loader.disable(toolId, { persist: false });
|
||||
try {
|
||||
const result = await this.installer.update(toolId);
|
||||
if (wasEnabled) await this.loader.enable(toolId, { persist: false });
|
||||
const result = await this.installer.update(toolId, {
|
||||
afterSwap: wasEnabled
|
||||
? async () => {
|
||||
const activated = await this.loader.enable(toolId, { persist: false });
|
||||
if (!activated?.loaded) throw new Error(activated?.message || "The updated AI tool could not be loaded.");
|
||||
}
|
||||
: null
|
||||
});
|
||||
this.loader.setEnabled(toolId, wasEnabled);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { resolveData } = require("./paths");
|
||||
|
||||
@ -191,10 +192,7 @@ class ToolRepoClient {
|
||||
}
|
||||
|
||||
writeCache(value) {
|
||||
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
|
||||
const temporary = `${this.cacheFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
||||
fs.renameSync(temporary, this.cacheFile);
|
||||
writeJsonAtomicSync(this.cacheFile, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
class ToolSettings {
|
||||
constructor(options = {}) {
|
||||
@ -41,11 +42,7 @@ class ToolSettings {
|
||||
}
|
||||
}
|
||||
const file = settingsFile(local.dir);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
||||
try { fs.chmodSync(temporary, 0o600); } catch {}
|
||||
fs.renameSync(temporary, file);
|
||||
writeJsonAtomicSync(file, next, { mode: 0o600 });
|
||||
return this.describe(toolId);
|
||||
}
|
||||
|
||||
@ -57,16 +54,7 @@ class ToolSettings {
|
||||
Object.entries(schema).map(([key, field]) => [key, normalizeValue(field.default, field, key)])
|
||||
);
|
||||
const file = settingsFile(local.dir);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
|
||||
try { fs.chmodSync(temporary, 0o600); } catch {}
|
||||
try { fs.renameSync(temporary, file); }
|
||||
catch (error) {
|
||||
if (!["EEXIST", "EPERM"].includes(error.code)) throw error;
|
||||
fs.rmSync(file, { force: true });
|
||||
fs.renameSync(temporary, file);
|
||||
}
|
||||
writeJsonAtomicSync(file, values, { mode: 0o600 });
|
||||
return this.describe(toolId);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { resolveData } = require("./paths");
|
||||
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
class TrainingExporter {
|
||||
constructor({ feedback, corrections, outputDir }) {
|
||||
@ -26,7 +27,7 @@ class TrainingExporter {
|
||||
input: "",
|
||||
output: entry.preferred_answer
|
||||
});
|
||||
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""));
|
||||
writeTextAtomicSync(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""));
|
||||
return { file, filename: path.basename(file), count: rows.length, format };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const express = require("express");
|
||||
const { writeJsonAtomicSync } = require("../../src/services/safe-files");
|
||||
const { ensureDataDirs, resolveData } = require("./backend/paths");
|
||||
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
|
||||
const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware");
|
||||
@ -871,6 +872,16 @@ module.exports = {
|
||||
});
|
||||
return res.download(file);
|
||||
});
|
||||
|
||||
router.get("/work-history/:id/export", (req, res) => {
|
||||
if (!req.session.user?.isAdmin) return denied(res);
|
||||
const payload = metrics.workHistoryExport(req.params.id);
|
||||
if (!payload) return res.status(404).send("Work history item not found.");
|
||||
const safeId = String(req.params.id || "work").replace(/[^a-z0-9_-]+/gi, "-").slice(0, 80) || "work";
|
||||
res.set("Cache-Control", "private, no-store");
|
||||
res.attachment(`lumi-ai-work-${safeId}.json`);
|
||||
return res.send(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
});
|
||||
router.post("/assistant/message", async (req, res) => {
|
||||
const permission = canUseAssistant({
|
||||
user: req.session.user,
|
||||
@ -956,7 +967,8 @@ module.exports = {
|
||||
origin: originContext.origin,
|
||||
platform: originContext.platform,
|
||||
model: result.model_id || requestConfig.selected_model_id,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
context_snapshot: result.feedback_context_snapshot || null
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@ -1005,9 +1017,11 @@ module.exports = {
|
||||
platform: "webui",
|
||||
model: req.body.model,
|
||||
timestamp: req.body.timestamp,
|
||||
rating: req.body.rating,
|
||||
feedback_tag: req.body.feedback_tag,
|
||||
feedback_kind: req.body.feedback_kind,
|
||||
optional_correction: req.body.optional_correction
|
||||
optional_correction: req.body.optional_correction,
|
||||
context_snapshot: req.body.context_snapshot
|
||||
}, req.session.user);
|
||||
return res.status(201).json({ success: true, id: entry.id });
|
||||
} catch (error) {
|
||||
@ -1269,7 +1283,8 @@ module.exports = {
|
||||
reviews: feedbackStore.list({
|
||||
page: req.query.review_page,
|
||||
pageSize: 15,
|
||||
status: cleanText(req.query.status, 30)
|
||||
status: cleanText(req.query.status, 30),
|
||||
viewerRole: access.role
|
||||
}),
|
||||
corrections: correctionStore.list({ page: req.query.correction_page, pageSize: 15 }),
|
||||
evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }),
|
||||
@ -1298,6 +1313,8 @@ module.exports = {
|
||||
const access = improvementAccess(req.session.user, config);
|
||||
if (!access.allowed) return deniedImprovement(res);
|
||||
try {
|
||||
const review = feedbackStore.get(req.params.id);
|
||||
if (!canReviewAiFeedback(review, access)) return deniedImprovement(res);
|
||||
const action = cleanText(req.body.action, 30);
|
||||
if (action === "flag" && access.can_flag) {
|
||||
feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes);
|
||||
@ -1307,12 +1324,12 @@ module.exports = {
|
||||
feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes);
|
||||
} else if (action === "reject" && access.can_approve) {
|
||||
feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes);
|
||||
} else if (action === "reviewed" && access.can_approve) {
|
||||
feedbackStore.setStatus(req.params.id, "reviewed", req.session.user, req.body.review_notes);
|
||||
} else if (action === "edit" && access.can_edit) {
|
||||
feedbackStore.edit(req.params.id, req.body, req.session.user);
|
||||
} else if (action === "export" && access.can_export) {
|
||||
feedbackStore.markExportApproved(req.params.id, req.session.user);
|
||||
} else if (action === "delete" && access.can_delete) {
|
||||
feedbackStore.delete(req.params.id);
|
||||
} else {
|
||||
return deniedImprovement(res);
|
||||
}
|
||||
@ -1322,6 +1339,28 @@ module.exports = {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/improvement_center/reviews/:id/delete", (req, res) => {
|
||||
const access = improvementAccess(req.session.user, config);
|
||||
if (!access.can_delete) return deniedImprovement(res);
|
||||
try {
|
||||
feedbackStore.delete(req.params.id);
|
||||
return improvementFlash(req, res, "success", "Review delete completed.");
|
||||
} catch (error) {
|
||||
return improvementFlash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/improvement_center/reviews/:id/archive", (req, res) => {
|
||||
const access = improvementAccess(req.session.user, config);
|
||||
if (!access.can_delete) return deniedImprovement(res);
|
||||
try {
|
||||
feedbackStore.setStatus(req.params.id, "archived", req.session.user, req.body.review_notes);
|
||||
return improvementFlash(req, res, "success", "Feedback record archived.");
|
||||
} catch (error) {
|
||||
return improvementFlash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/improvement_center/reviews/:id/implement", (req, res) => {
|
||||
const access = improvementAccess(req.session.user, config);
|
||||
if (!access.can_implement) return deniedImprovement(res);
|
||||
@ -1342,12 +1381,22 @@ module.exports = {
|
||||
} else if (target === "training_export") {
|
||||
feedbackStore.markExportApproved(review.id, req.session.user);
|
||||
} else {
|
||||
correctionStore.createFromFeedback(review, {
|
||||
const correction = correctionStore.createFromFeedback(review, {
|
||||
...req.body,
|
||||
target,
|
||||
explicitly_safe: req.body.explicitly_safe === "on",
|
||||
enabled: req.body.enabled === "on"
|
||||
}, req.session.user);
|
||||
if (target === "correction") {
|
||||
try {
|
||||
const okfEntry = saveAiFeedbackOkfCorrection(review, correction, req.body, req.session.user);
|
||||
correctionStore.linkOkf(correction.id, okfEntry.path);
|
||||
feedbackStore.linkOkfCorrection(review.id, okfEntry.path, req.session.user);
|
||||
} catch (error) {
|
||||
correctionStore.delete(correction.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return improvementFlash(req, res, "success", "Approved feedback was promoted. Save Corrections before it becomes active.");
|
||||
} catch (error) {
|
||||
@ -1380,8 +1429,6 @@ module.exports = {
|
||||
});
|
||||
} else if (action === "toggle" && access.can_edit) {
|
||||
correctionStore.setEnabled(req.params.id, req.body.enabled === "on");
|
||||
} else if (action === "delete" && access.can_delete) {
|
||||
correctionStore.delete(req.params.id);
|
||||
} else {
|
||||
return deniedImprovement(res);
|
||||
}
|
||||
@ -1391,6 +1438,17 @@ module.exports = {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/improvement_center/corrections/:id/delete", (req, res) => {
|
||||
const access = improvementAccess(req.session.user, config);
|
||||
if (!access.can_delete) return deniedImprovement(res);
|
||||
try {
|
||||
correctionStore.delete(req.params.id);
|
||||
return improvementFlash(req, res, "success", "Correction delete completed. Save Corrections to activate changes.");
|
||||
} catch (error) {
|
||||
return improvementFlash(req, res, "error", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/improvement_center/evals", (req, res) => {
|
||||
const access = improvementAccess(req.session.user, config);
|
||||
if (!access.can_run_evals) return deniedImprovement(res);
|
||||
@ -1555,6 +1613,42 @@ function boundedNumber(value, min, max, fallback) {
|
||||
function cleanText(value, max) {
|
||||
return String(value || "").trim().slice(0, max);
|
||||
}
|
||||
|
||||
function canReviewAiFeedback(review, access) {
|
||||
if (!review || !access?.allowed) return false;
|
||||
const rank = { user: 1, mod: 2, admin: 3 };
|
||||
return (rank[access.role] || 0) >= (rank[review.role] || 1);
|
||||
}
|
||||
|
||||
function saveAiFeedbackOkfCorrection(review, correction, values, actor) {
|
||||
const saveCorrection = global.lumiFrameworks?.okf?.saveCorrection;
|
||||
if (typeof saveCorrection !== "function") {
|
||||
throw new Error("Enable the OKF plugin before creating a searchable correction file.");
|
||||
}
|
||||
const title = cleanText(values.okf_title, 180) || `Assistant correction: ${cleanText(review.user_message, 120)}`;
|
||||
const visibility = ["user", "mod", "admin"].includes(correction.min_role) ? correction.min_role : "user";
|
||||
const safePrompt = cleanText(review.user_message, 3000);
|
||||
const safeAnswer = cleanText(correction.corrected_answer, 10000);
|
||||
return saveCorrection({
|
||||
slug: `lumi-ai-${review.id}`,
|
||||
id: `correction.lumi_ai.${review.id}`,
|
||||
title,
|
||||
status: "active",
|
||||
priority: 150,
|
||||
visibility,
|
||||
category: "Lumi AI correction",
|
||||
tags: `lumi ai, assistant feedback, correction, ${review.feedback_tag || "reviewed"}`,
|
||||
source_feedback_id: `lumi-ai:${review.id}`,
|
||||
source_feedback_url: `/plugins/lumi_ai/improvement_center#review-${review.id}`,
|
||||
body: [
|
||||
"## When this applies",
|
||||
safePrompt,
|
||||
"",
|
||||
"## Approved response guidance",
|
||||
safeAnswer
|
||||
].join("\n")
|
||||
}, actor);
|
||||
}
|
||||
function flash(req, res, type, message) {
|
||||
req.session.flash = { type, message };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
@ -2061,7 +2155,7 @@ function writeCommandsManifest(pluginDir, config) {
|
||||
aliases: triggers.slice(1)
|
||||
}]
|
||||
};
|
||||
fs.writeFileSync(path.join(pluginDir, "cmds.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
writeJsonAtomicSync(path.join(pluginDir, "cmds.json"), manifest);
|
||||
}
|
||||
|
||||
module.exports.shouldAutoResume = shouldAutoResume;
|
||||
|
||||
@ -51,6 +51,8 @@
|
||||
.lumi-ai-confirm { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.lumi-ai-confirm button { padding: 5px 9px; border-radius: 5px; border: 1px solid var(--border); cursor: pointer; }
|
||||
.lumi-ai-feedback { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--border); color: var(--ink-soft); font-size: 11px; }
|
||||
.lumi-ai-feedback-quick-actions, .lumi-ai-feedback-details { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 6px; }
|
||||
.lumi-ai-feedback-details[hidden] { display: none; }
|
||||
.lumi-ai-feedback select, .lumi-ai-feedback input, .lumi-ai-feedback button { min-height: 29px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-2); color: var(--ink); padding: 4px 7px; font: inherit; }
|
||||
.lumi-ai-feedback input { flex: 1 1 180px; }
|
||||
.lumi-ai-feedback button { cursor: pointer; font-weight: 700; }
|
||||
|
||||
@ -279,11 +279,29 @@
|
||||
const controls = document.createElement("form");
|
||||
controls.className = "lumi-ai-feedback";
|
||||
controls.setAttribute("aria-label", "Rate this Lumi Assistant reply");
|
||||
const quickActions = document.createElement("div");
|
||||
quickActions.className = "lumi-ai-feedback-quick-actions";
|
||||
const helpful = document.createElement("button");
|
||||
helpful.type = "button";
|
||||
helpful.className = "button subtle";
|
||||
helpful.textContent = "Helpful";
|
||||
helpful.setAttribute("aria-label", "Mark this reply as helpful");
|
||||
const needsWork = document.createElement("button");
|
||||
needsWork.type = "button";
|
||||
needsWork.className = "button subtle";
|
||||
needsWork.textContent = "Needs work";
|
||||
needsWork.setAttribute("aria-label", "Tell Lumi what needs work");
|
||||
quickActions.append(helpful, needsWork);
|
||||
const details = document.createElement("div");
|
||||
details.className = "lumi-ai-feedback-details";
|
||||
details.hidden = true;
|
||||
const select = document.createElement("select");
|
||||
select.setAttribute("aria-label", "Feedback tag");
|
||||
for (const tag of [
|
||||
"good",
|
||||
"bad",
|
||||
"bad_retrieval",
|
||||
"bad_response",
|
||||
"missing_knowledge",
|
||||
"wrong_link",
|
||||
"hallucinated",
|
||||
"too_generic",
|
||||
@ -316,9 +334,11 @@
|
||||
const submitFeedback = document.createElement("button");
|
||||
submitFeedback.type = "submit";
|
||||
submitFeedback.textContent = "Send feedback";
|
||||
controls.append(select, kind, correction, submitFeedback);
|
||||
controls.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
details.append(select, kind, correction, submitFeedback);
|
||||
controls.append(quickActions, details);
|
||||
const sendFeedback = async ({ tag, rating, correctionText = "", feedbackKind = "instruction_based" }) => {
|
||||
helpful.disabled = true;
|
||||
needsWork.disabled = true;
|
||||
submitFeedback.disabled = true;
|
||||
try {
|
||||
const response = await trackedFetch(`${endpoint}/assistant/feedback`, {
|
||||
@ -326,18 +346,36 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...context,
|
||||
feedback_tag: select.value,
|
||||
feedback_kind: kind.value,
|
||||
optional_correction: correction.value.trim()
|
||||
rating,
|
||||
feedback_tag: tag,
|
||||
feedback_kind: feedbackKind,
|
||||
optional_correction: correctionText
|
||||
})
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (!response.ok) throw new Error(data.error || "Feedback could not be saved.");
|
||||
controls.replaceChildren(document.createTextNode("Feedback saved."));
|
||||
controls.replaceChildren(document.createTextNode("Thanks—feedback saved."));
|
||||
} catch (error) {
|
||||
helpful.disabled = false;
|
||||
needsWork.disabled = false;
|
||||
submitFeedback.disabled = false;
|
||||
submitFeedback.textContent = error.message || "Try again";
|
||||
}
|
||||
};
|
||||
helpful.addEventListener("click", () => sendFeedback({ tag: "good", rating: "up" }), { signal: listeners.signal });
|
||||
needsWork.addEventListener("click", () => {
|
||||
details.hidden = false;
|
||||
needsWork.setAttribute("aria-expanded", "true");
|
||||
select.focus();
|
||||
}, { signal: listeners.signal });
|
||||
controls.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await sendFeedback({
|
||||
tag: select.value,
|
||||
rating: "down",
|
||||
correctionText: correction.value.trim(),
|
||||
feedbackKind: kind.value
|
||||
});
|
||||
}, { signal: listeners.signal });
|
||||
item.append(controls);
|
||||
};
|
||||
|
||||
@ -9,9 +9,4 @@
|
||||
if (closer) closer.closest("dialog")?.close();
|
||||
});
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("[data-improvement-confirm]");
|
||||
if (!form) return;
|
||||
if (!window.confirm(form.dataset.improvementConfirm || "Continue?")) event.preventDefault();
|
||||
});
|
||||
})();
|
||||
|
||||
@ -219,6 +219,7 @@
|
||||
form.dataset.confirmMode = "modal";
|
||||
form.dataset.confirmTitle = `Delete ${tool.display_name || tool.tool_id}?`;
|
||||
form.dataset.confirmText = "The installed AI tool files will be removed. Shared Lumi AI data and unrelated plugins are not affected.";
|
||||
form.dataset.confirmLabel = "Delete tool";
|
||||
const remove = button("Delete", "danger");
|
||||
remove.type = "submit";
|
||||
form.append(remove);
|
||||
|
||||
@ -92,6 +92,37 @@ async function run() {
|
||||
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
|
||||
assert.equal(fs.readFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "utf8"), "preserve");
|
||||
|
||||
const installedWeather = path.join(pluginsDir, "lumi_ai_weather");
|
||||
createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.2.0"), backendSource("updated again"));
|
||||
remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.2.0"));
|
||||
const originalRename = fs.renameSync;
|
||||
fs.renameSync = (source, destination) => {
|
||||
if (source === installedWeather && path.basename(destination).startsWith(".lumi_ai_weather.backup-")) {
|
||||
const error = new Error("simulated locked tool directory");
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
}
|
||||
return originalRename(source, destination);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(() => manager.update("lumi_ai_weather"), /locked tool directory/);
|
||||
} finally {
|
||||
fs.renameSync = originalRename;
|
||||
}
|
||||
assert.equal(validateToolDirectory(installedWeather).version, "1.1.0");
|
||||
assert.equal(fs.readFileSync(path.join(installedWeather, "data", "local.json"), "utf8"), "preserve");
|
||||
|
||||
await manager.enable("lumi_ai_weather");
|
||||
createTool(
|
||||
path.join(remoteDir, "lumi_ai_weather"),
|
||||
metadata("lumi_ai_weather", "1.2.0"),
|
||||
'module.exports.register = () => { throw new Error("simulated activation failure"); };\n'
|
||||
);
|
||||
await assert.rejects(() => manager.update("lumi_ai_weather"), /simulated activation failure/);
|
||||
assert.equal(validateToolDirectory(installedWeather).version, "1.1.0");
|
||||
assert.equal(loader.isEnabled("lumi_ai_weather"), true);
|
||||
assert.equal(registry.tools.has("lumi_ai_weather.lookup"), true);
|
||||
|
||||
repoClient.fail = true;
|
||||
await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i);
|
||||
assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0");
|
||||
|
||||
@ -7,7 +7,7 @@ const { ToolRegistry } = require("../backend/tool_router");
|
||||
const { RequestQueue } = require("../backend/queue_manager");
|
||||
const { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_manager");
|
||||
const { DEFAULT_CONFIG, getRuntimeState } = require("../backend/config_manager");
|
||||
const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget } = require("../backend/ai_provider");
|
||||
const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget, buildFeedbackContextSnapshot } = require("../backend/ai_provider");
|
||||
const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, isSimpleKnowledgeLookup, classifyRequestType, withTimeout } = require("../backend/gate_provider");
|
||||
const {
|
||||
buildControllerDecision,
|
||||
@ -30,7 +30,7 @@ const modelManifest = require("../models_manifest.json");
|
||||
const runtimeManifest = require("../runtime_manifest.json");
|
||||
const storage = require("../backend/storage");
|
||||
const { formatBytes, bytesFromMb, sanityCheckSize } = require("../backend/size_utils");
|
||||
const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage } = require("../backend/metrics");
|
||||
const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage, workHistoryExport, groupWorkRows } = require("../backend/metrics");
|
||||
const { AiAccessControl } = require("../backend/access_control");
|
||||
const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits");
|
||||
const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands");
|
||||
@ -127,7 +127,7 @@ async function run() {
|
||||
user_id: "history-user",
|
||||
role: "admin",
|
||||
origin: "webui",
|
||||
prompt: "Tell me about doing a GET request to /setup/twitch",
|
||||
prompt: "Tell history-user about GET /setup/twitch using C:\\private\\notes.txt password=hunter2",
|
||||
prompt_tokens: 12
|
||||
});
|
||||
recordMetric({
|
||||
@ -172,6 +172,16 @@ async function run() {
|
||||
assert.equal(workPage.entries[0].final_tokens, 40);
|
||||
assert.equal(workPage.entries[0].delivered_tokens, 35);
|
||||
assert(workPage.entries[0].events.some((event) => event.type === "prompt"));
|
||||
const workExport = workHistoryExport(workRequestId);
|
||||
const workExportText = JSON.stringify(workExport);
|
||||
assert.equal(workExport.schema, "lumi.ai.work.codex.v1");
|
||||
assert.equal(workExportText.includes("history-user"), false);
|
||||
assert.equal(workExportText.includes("hunter2"), false);
|
||||
assert.equal(workExportText.includes("C:\\\\private"), false);
|
||||
assert(workExport.privacy.removed.includes("raw diagnostics"));
|
||||
const legacyGroups = groupWorkRows([{ timestamp: "2025-01-01T00:00:00.000Z", kind: "request", status: "failed", message: "legacy failure" }]);
|
||||
assert.equal(legacyGroups.length, 1);
|
||||
assert(legacyGroups[0].work_id.startsWith("legacy-"));
|
||||
|
||||
const panelTemplate = require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs");
|
||||
const panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate);
|
||||
@ -211,14 +221,20 @@ async function run() {
|
||||
assert(settingsTemplate.includes("Improvement Center"));
|
||||
assert(settingsTemplate.includes("work.has_truncation"));
|
||||
assert(settingsTemplate.includes("work.processed_tokens"));
|
||||
assert(settingsTemplate.includes("Export for Codex"));
|
||||
assert(settingsTemplate.includes("raw diagnostics, and tool payloads removed"));
|
||||
const improvementTemplate = fs.readFileSync(path.join(PLUGIN_ROOT, "views", "improvement-center.ejs"), "utf8");
|
||||
for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) {
|
||||
assert(improvementTemplate.includes(control));
|
||||
}
|
||||
assert(improvementTemplate.includes('data-confirm-title="Delete eval case"'));
|
||||
assert(improvementTemplate.includes("Sources used for this answer"));
|
||||
assert(improvementTemplate.includes("Searchable OKF title"));
|
||||
const assistantFeedbackScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "assistant.js"), "utf8");
|
||||
for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`));
|
||||
assert(assistantFeedbackScript.includes("/assistant/feedback"));
|
||||
assert(assistantFeedbackScript.includes('helpful.textContent = "Helpful"'));
|
||||
assert(assistantFeedbackScript.includes('needsWork.textContent = "Needs work"'));
|
||||
const settingsFrontendScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "settings.js"), "utf8");
|
||||
for (const marker of ["preparing_install", "installing", "Stopping runtimes", "Downloading dependency"]) {
|
||||
assert(settingsFrontendScript.includes(marker));
|
||||
@ -252,13 +268,28 @@ async function run() {
|
||||
origin: "webui",
|
||||
platform: "webui",
|
||||
model: "test-model",
|
||||
rating: "down",
|
||||
feedback_tag: "wrong_link",
|
||||
optional_correction: "Use /admin/settings."
|
||||
optional_correction: "Use /admin/settings.",
|
||||
context_snapshot: {
|
||||
retrieval: { query: "settings", candidate_count: 5, selected_count: 1, depth: "light" },
|
||||
okf: ["Verified settings context"],
|
||||
repository: ["GET /admin/settings"],
|
||||
tools: ["settings_lookup"]
|
||||
}
|
||||
}, { id: "reporter" });
|
||||
assert.deepEqual(
|
||||
Object.keys(review).filter((key) => /prompt|context|reasoning/i.test(key)),
|
||||
[]
|
||||
);
|
||||
assert.equal(review.rating, "down");
|
||||
assert.equal(review.context_snapshot.retrieval.candidate_count, 5);
|
||||
assert.deepEqual(review.context_snapshot.tools, ["settings_lookup"]);
|
||||
assert.equal(feedbackStore.list({ viewerRole: "mod" }).entries.length, 0);
|
||||
assert.equal(feedbackStore.list({ viewerRole: "admin" }).entries.length, 1);
|
||||
const safeSnapshot = buildFeedbackContextSnapshot({
|
||||
contextBlocks: [`Open ${process.cwd()}\\private.md; password=secret`],
|
||||
contextDiagnostics: [{ provider: "okf", query: "settings", candidate_count: 3 }],
|
||||
controllerDecision: { okf_retrieval: "light" }
|
||||
});
|
||||
assert.equal(safeSnapshot.okf[0].includes(process.cwd()), false);
|
||||
assert(safeSnapshot.okf[0].includes("[redacted]"));
|
||||
feedbackStore.verify(review.id, { id: "trusted-mod" });
|
||||
assert.equal(feedbackStore.get(review.id).status, "verified");
|
||||
feedbackStore.setStatus(review.id, "approved", { id: "admin" });
|
||||
@ -284,6 +315,10 @@ async function run() {
|
||||
explicitly_safe: true,
|
||||
enabled: true
|
||||
}, { id: "admin" });
|
||||
correctionStore.linkOkf(correction.id, "knowledge/corrections/lumi-ai-review.md");
|
||||
feedbackStore.linkOkfCorrection(review.id, "knowledge/corrections/lumi-ai-review.md", { id: "admin" });
|
||||
assert.equal(correctionStore.get(correction.id).linked_okf_path, "knowledge/corrections/lumi-ai-review.md");
|
||||
assert.equal(feedbackStore.get(review.id).linked_okf_correction, "knowledge/corrections/lumi-ai-review.md");
|
||||
assert.equal(correction.active, false);
|
||||
assert.equal(correctionStore.match({
|
||||
message: review.user_message,
|
||||
|
||||
@ -32,18 +32,18 @@
|
||||
|
||||
<section class="ai-band" id="reviews">
|
||||
<div class="ai-section-heading">
|
||||
<div><h2>Review queue</h2><p>Feedback records contain the user message, assistant answer, delivery metadata, tag, and optional correction only.</p></div>
|
||||
<div><h2>Review queue</h2><p>Review ratings, the answer, and a privacy-limited snapshot of the knowledge sources that informed it. Only admins can turn approved feedback into searchable OKF corrections.</p></div>
|
||||
<div class="improvement-filters">
|
||||
<% ["", "pending", "flagged", "verified", "approved", "rejected"].forEach((status) => { %>
|
||||
<% ["", "pending", "flagged", "verified", "approved", "reviewed", "rejected", "archived"].forEach((status) => { %>
|
||||
<a class="button subtle" href="?status=<%= status %>#reviews"><%= status || "All" %></a>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="improvement-list">
|
||||
<% reviews.entries.forEach((review) => { %>
|
||||
<article class="improvement-card">
|
||||
<article class="improvement-card" id="review-<%= review.id %>">
|
||||
<header>
|
||||
<div><strong><%= review.feedback_tag %></strong> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
|
||||
<div><strong><%= review.rating === "up" ? "Helpful" : "Needs work" %></strong> <span class="ai-tag"><%= review.feedback_tag.replaceAll("_", " ") %></span> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
|
||||
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span>
|
||||
</header>
|
||||
<div class="improvement-pair">
|
||||
@ -51,6 +51,18 @@
|
||||
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div>
|
||||
</div>
|
||||
<% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %>
|
||||
<% const snapshot = review.context_snapshot || {}; const retrieval = snapshot.retrieval || {}; const snapshotRows = [...(snapshot.okf || []), ...(snapshot.corrections || []), ...(snapshot.repository || [])]; %>
|
||||
<% if (snapshotRows.length || retrieval.query || (snapshot.tools || []).length) { %>
|
||||
<details class="lumi-expandable-settings">
|
||||
<summary><strong>Sources used for this answer</strong><span class="hint"><%= retrieval.selected_count || snapshotRows.length %> selected · <%= retrieval.candidate_count || 0 %> considered</span></summary>
|
||||
<div class="lumi-expandable-body">
|
||||
<% if (retrieval.query) { %><p class="hint"><strong>Lookup:</strong> <%= retrieval.query %> · depth <%= retrieval.depth || "unknown" %></p><% } %>
|
||||
<% snapshotRows.forEach((source) => { %><pre><%= source %></pre><% }) %>
|
||||
<% if ((snapshot.tools || []).length) { %><p class="hint"><strong>Available/selected tools:</strong> <%= snapshot.tools.join(", ") %></p><% } %>
|
||||
</div>
|
||||
</details>
|
||||
<% } %>
|
||||
<% if (review.linked_okf_correction) { %><p class="hint"><strong>Searchable correction:</strong> <a href="/plugins/okf?q=<%= encodeURIComponent(review.user_message.slice(0, 80)) %>"><%= review.linked_okf_correction %></a></p><% } %>
|
||||
<% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %>
|
||||
<div class="improvement-actions">
|
||||
<% if (access.can_flag) { %>
|
||||
@ -66,6 +78,10 @@
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (access.can_approve) { %>
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
|
||||
<input type="hidden" name="action" value="reviewed" />
|
||||
<button class="button subtle" type="submit">No action needed</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
|
||||
<input type="hidden" name="action" value="approve" />
|
||||
<button class="button" type="submit">Approve</button>
|
||||
@ -80,10 +96,12 @@
|
||||
<input type="hidden" name="action" value="export" />
|
||||
<button class="button subtle" type="submit"><%= review.export_approved ? "Export approved" : "Approve for export" %></button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-confirm="Delete this feedback record?">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete feedback record" data-confirm-text="Delete this feedback record permanently?" data-confirm-label="Delete feedback">
|
||||
<button class="button danger" type="submit">Delete</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/archive" data-confirm-mode="modal" data-confirm-title="Archive feedback record" data-confirm-text="Archive this feedback record and hide it from the active review queue?" data-confirm-label="Archive feedback">
|
||||
<button class="button danger" type="submit">Archive</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</article>
|
||||
@ -107,6 +125,7 @@
|
||||
<div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div>
|
||||
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
|
||||
<div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
|
||||
<div class="field full"><label>Searchable OKF title</label><input name="okf_title" placeholder="Short title for this correction" /><span class="hint">When the target is Correction, Lumi saves an editable, searchable OKF file after approval.</span></div>
|
||||
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
|
||||
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
|
||||
<div class="field"><label>Route alias</label><input name="route_alias" /></div>
|
||||
@ -146,7 +165,8 @@
|
||||
<td><span class="ai-tag"><%= entry.active ? "active" : entry.enabled ? "staged" : "disabled" %></span></td>
|
||||
<td class="improvement-actions">
|
||||
<% if (access.can_verify) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="verify" /><button class="button subtle" type="submit">Verify</button></form><% } %>
|
||||
<% if (access.can_edit) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="toggle" /><input type="hidden" name="enabled" value="<%= entry.enabled ? "off" : "on" %>" /><button class="button subtle" type="submit"><%= entry.enabled ? "Disable" : "Enable" %></button></form><button class="button subtle" type="button" data-open-dialog="correction-<%= entry.id %>">Edit</button><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>" data-improvement-confirm="Delete this correction?"><input type="hidden" name="action" value="delete" /><button class="button danger" type="submit">Delete</button></form><% } %>
|
||||
<% if (entry.linked_okf_path) { %><a class="button subtle" href="/plugins/okf?q=<%= encodeURIComponent(entry.prompt.slice(0, 80)) %>">Open OKF</a><% } %>
|
||||
<% if (access.can_edit) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="toggle" /><input type="hidden" name="enabled" value="<%= entry.enabled ? "off" : "on" %>" /><button class="button subtle" type="submit"><%= entry.enabled ? "Disable" : "Enable" %></button></form><button class="button subtle" type="button" data-open-dialog="correction-<%= entry.id %>">Edit</button><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete correction" data-confirm-text="Delete this correction permanently?" data-confirm-label="Delete correction"><button class="button danger" type="submit">Delete</button></form><% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% if (access.can_edit) { %>
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
]
|
||||
}) %>
|
||||
</form>
|
||||
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
|
||||
<form method="post" action="/plugins/lumi_ai/models/<%= model.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete model" data-confirm-text="Delete <%= model.label %> and recover <%= formatBytes(model.installed_size || model.size) %>?" data-confirm-label="Delete model">
|
||||
<input type="hidden" name="confirm" value="yes" />
|
||||
<button class="button danger" type="submit" <%= (model.id === config.selected_model_id && runtimeStatus.state === "running") || (model.id === config.gate.model_id && gateStatus.state === "running") ? "disabled title='Stop the AI runtimes before deleting an active model'" : "" %>>Delete</button>
|
||||
</form>
|
||||
@ -258,7 +258,7 @@
|
||||
<div><span><%= category.replace("_", " ") %></span><strong><%= formatBytes(bytes) %></strong></div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
|
||||
<form method="post" action="/plugins/lumi_ai/storage/cleanup" class="ai-cleanup-form" data-confirm-mode="modal" data-confirm-title="Clean AI storage" data-confirm-text="Delete the selected plugin-local storage categories?" data-confirm-label="Clean selected">
|
||||
<label><input type="checkbox" name="categories" value="unused_models" /> Unused models</label>
|
||||
<label><input type="checkbox" name="categories" value="runtime_archives" /> Runtime archives</label>
|
||||
<label><input type="checkbox" name="categories" value="logs" /> Old logs</label>
|
||||
@ -727,6 +727,8 @@
|
||||
<div class="ai-work-prompt">
|
||||
<strong>Original prompt</strong>
|
||||
<pre><%= work.prompt || "Prompt was not recorded for this legacy entry." %></pre>
|
||||
<a class="button subtle" href="/plugins/lumi_ai/work-history/<%= encodeURIComponent(work.work_id) %>/export">Export for Codex</a>
|
||||
<span class="hint">Downloads a task-focused JSON file with identities, secrets, local paths, raw diagnostics, and tool payloads removed.</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table ai-work-events">
|
||||
@ -773,7 +775,7 @@
|
||||
<td class="ai-table-actions">
|
||||
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>">View</a>
|
||||
<a class="button subtle" href="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/download">Download</a>
|
||||
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
|
||||
<form method="post" action="/plugins/lumi_ai/logs/<%= encodeURIComponent(file.name) %>/delete" data-confirm-mode="modal" data-confirm-title="Delete AI runtime log" data-confirm-text="Delete <%= file.name %>?" data-confirm-label="Delete log"><button class="button danger" type="submit">Delete</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
|
||||
@ -2,6 +2,11 @@ const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const multer = require("multer");
|
||||
const {
|
||||
cleanupUploadedFiles,
|
||||
safeDownloadFilename,
|
||||
validateUploadedFiles
|
||||
} = require("../../src/services/upload-security");
|
||||
|
||||
const PLUGIN_ID = "moderation";
|
||||
const EVIDENCE_DIR = path.join(__dirname, "..", "..", "data", "moderation", "evidence");
|
||||
@ -34,7 +39,35 @@ module.exports = {
|
||||
const ext = path.extname(file.originalname || ".png").slice(0, 10);
|
||||
cb(null, `${crypto.randomUUID()}${ext}`);
|
||||
}
|
||||
})
|
||||
}),
|
||||
limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 40, parts: 45 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) return cb(null, true);
|
||||
cb(new Error("Evidence must be a PNG, JPEG, or WebP image."));
|
||||
}
|
||||
});
|
||||
const evidenceUpload = (req, res, next) => upload.array("evidence_files", 4)(req, res, (error) => {
|
||||
if (error) {
|
||||
cleanupUploadedFiles(req.files);
|
||||
req.files = [];
|
||||
req.evidenceUploadError = error.code === "LIMIT_FILE_SIZE"
|
||||
? "Each evidence image must be 8 MB or smaller."
|
||||
: error.message;
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
validateUploadedFiles(req.files, {
|
||||
allowedTypes: ["png", "jpeg", "webp"],
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
requireExtension: true,
|
||||
label: "evidence image"
|
||||
});
|
||||
} catch (validationError) {
|
||||
cleanupUploadedFiles(req.files);
|
||||
req.files = [];
|
||||
req.evidenceUploadError = validationError.message;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
installGlobalGate(app, (req, res, next) => {
|
||||
@ -145,21 +178,37 @@ module.exports = {
|
||||
message: "Evidence file not found."
|
||||
});
|
||||
}
|
||||
res.download(row.file_path, row.file_name || path.basename(row.file_path));
|
||||
const evidencePath = path.resolve(row.file_path);
|
||||
const evidenceRoot = `${path.resolve(EVIDENCE_DIR)}${path.sep}`;
|
||||
if (!evidencePath.startsWith(evidenceRoot) || !fs.existsSync(evidencePath)) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Not found",
|
||||
message: "Evidence file not found."
|
||||
});
|
||||
}
|
||||
res.download(evidencePath, safeDownloadFilename(row.file_name, "evidence"));
|
||||
});
|
||||
|
||||
router.post("/actions", upload.array("evidence_files", 4), async (req, res) => {
|
||||
router.post("/actions", (req, res, next) => {
|
||||
if (!req.session.user) {
|
||||
return res.redirect("/");
|
||||
}
|
||||
const isAdmin = Boolean(req.session.user?.isAdmin);
|
||||
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
|
||||
if (!isMod) {
|
||||
return deny(res);
|
||||
}
|
||||
next();
|
||||
}, evidenceUpload, async (req, res) => {
|
||||
const isAdmin = Boolean(req.session.user?.isAdmin);
|
||||
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
|
||||
if (req.evidenceUploadError) {
|
||||
req.session.flash = { type: "error", message: req.evidenceUploadError };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
|
||||
const actionType = (req.body.action_type || "").toLowerCase();
|
||||
if (actionType === "kick") {
|
||||
cleanupUploadedFiles(req.files);
|
||||
req.session.flash = {
|
||||
type: "info",
|
||||
message: "Kick actions are coming soon."
|
||||
@ -169,6 +218,7 @@ module.exports = {
|
||||
|
||||
const target = resolveTarget(db, req.body);
|
||||
if (!target) {
|
||||
cleanupUploadedFiles(req.files);
|
||||
req.session.flash = { type: "error", message: "Target not found." };
|
||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||
}
|
||||
@ -176,6 +226,7 @@ module.exports = {
|
||||
const reasonShort = (req.body.reason_short || "").trim();
|
||||
const reasonDetail = (req.body.reason_detail || "").trim();
|
||||
if (!reasonShort || !reasonDetail) {
|
||||
cleanupUploadedFiles(req.files);
|
||||
req.session.flash = {
|
||||
type: "error",
|
||||
message: "Both summary and detailed reasons are required."
|
||||
|
||||
@ -217,7 +217,8 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Evidence (optional)</label>
|
||||
<input type="file" name="evidence_files" accept="image/*" multiple />
|
||||
<input type="file" name="evidence_files" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
<span class="hint">Up to 4 PNG, JPEG, or WebP images, 8 MB each.</span>
|
||||
</div>
|
||||
<button type="submit" class="button">Submit action</button>
|
||||
</form>
|
||||
@ -309,7 +310,7 @@
|
||||
<td><%= new Date(note.created_at).toLocaleString() %></td>
|
||||
<% if (isAdmin) { %>
|
||||
<td>
|
||||
<form method="post" action="/plugins/moderation/notes/<%= note.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-text="Delete this moderation note? This removes the note permanently.">
|
||||
<form method="post" action="/plugins/moderation/notes/<%= note.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete moderation note" data-confirm-text="Delete this moderation note? This removes the note permanently." data-confirm-label="Delete note">
|
||||
<button type="submit" class="button danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@ -118,10 +118,10 @@
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text">Permanent</span>
|
||||
</label>
|
||||
<button type="submit" class="button subtle">Update</button>
|
||||
<button type="submit" class="button subtle">Update duration</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<form method="post" action="/plugins/moderation/actions/<%= sanction.id %>/revoke" class="inline-form">
|
||||
<form method="post" action="/plugins/moderation/actions/<%= sanction.id %>/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Revoke moderation action" data-confirm-text="Revoke this moderation action?" data-confirm-label="Revoke action">
|
||||
<button type="submit" class="button danger">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]);
|
||||
const SCOPE_PRIORITY = Object.freeze({
|
||||
@ -26,8 +27,9 @@ const PLACEHOLDER_SUGGEST_RESERVED_KEYS = new Set([
|
||||
"updated_at"
|
||||
]);
|
||||
const SEARCH_STOPWORDS = new Set([
|
||||
"a", "an", "and", "are", "about", "describe", "for", "identify", "is", "me",
|
||||
"of", "please", "tell", "the", "to", "what", "who", "was", "were"
|
||||
"a", "an", "and", "are", "about", "can", "could", "describe", "do", "does", "for",
|
||||
"help", "how", "i", "identify", "is", "me", "my", "of", "please", "tell", "the", "to",
|
||||
"use", "using", "what", "where", "who", "was", "were", "you", "your"
|
||||
]);
|
||||
const knowledgeIndexCache = new Map();
|
||||
|
||||
@ -102,6 +104,7 @@ function searchFileKnowledge({ query = "", user, limit = 5, rootDir = process.cw
|
||||
const access = accessForUser(user);
|
||||
if (!access.authenticated) return [];
|
||||
const tokens = tokenSet(query);
|
||||
if (String(query || "").trim() && !tokens.size) return [];
|
||||
const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir)
|
||||
.filter((entry) => canSeeKnowledgeEntry(entry, access))
|
||||
);
|
||||
@ -207,6 +210,7 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
|
||||
status: entry.status,
|
||||
visibility: entry.visibility,
|
||||
priority: entry.priority,
|
||||
category: entry.category,
|
||||
tags: entry.tags,
|
||||
editable: entry.editable,
|
||||
generated: entry.generated,
|
||||
@ -216,6 +220,18 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) {
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function listCorrectionKnowledgeFiles(rootDir = process.cwd()) {
|
||||
return loadKnowledgeEntries(rootDir, { includeHidden: true })
|
||||
.filter((entry) => entry.scope === "corrections")
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
slug: entry.file_slug,
|
||||
entry_slug: entry.slug,
|
||||
path: `knowledge/${entry.path}`
|
||||
}))
|
||||
.sort((a, b) => b.priority - a.priority || a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function getCommunityKnowledgeFile(rootDir = process.cwd(), slug) {
|
||||
const entry = loadKnowledgeEntries(rootDir, { includeHidden: true })
|
||||
.find((item) => item.scope === "community" && (item.file_slug === slug || item.slug === slug || item.id === slug));
|
||||
@ -241,7 +257,7 @@ function saveCommunityKnowledgeFile(rootDir = process.cwd(), values = {}) {
|
||||
}
|
||||
const metadata = normalizeCommunityFileValues(values, slug);
|
||||
const markdown = serializeKnowledgeFile(metadata, values.body || "");
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
writeTextAtomicSync(filePath, markdown);
|
||||
knowledgeIndexCache.clear();
|
||||
parseKnowledgeFile(filePath, root, { includeHidden: true });
|
||||
return getCommunityKnowledgeFile(rootDir, slug);
|
||||
@ -253,7 +269,7 @@ function saveCorrectionKnowledgeFile(rootDir = process.cwd(), values = {}) {
|
||||
const filePath = path.join(root, "corrections", `${slug}.md`);
|
||||
const metadata = normalizeCorrectionFileValues(values, slug);
|
||||
const markdown = serializeKnowledgeFile(metadata, values.body || "");
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
writeTextAtomicSync(filePath, markdown);
|
||||
knowledgeIndexCache.clear();
|
||||
const entry = parseKnowledgeFile(filePath, root, { includeHidden: true });
|
||||
return entry ? {
|
||||
@ -518,7 +534,16 @@ function scoreKnowledgeChunk(entry, chunk, queryTokens) {
|
||||
const text = [entry.title, entry.category, entry.tags.join(" "), chunk.heading, chunk.text].join(" ");
|
||||
const textTokens = tokenSet(text);
|
||||
const overlap = queryTokens.size ? intersectionSize(queryTokens, textTokens) : 1;
|
||||
const score = (overlap * 100) + SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0);
|
||||
const titleOverlap = queryTokens.size ? intersectionSize(queryTokens, tokenSet(`${entry.title} ${entry.tags.join(" ")}`)) : 0;
|
||||
const headingOverlap = queryTokens.size ? intersectionSize(queryTokens, tokenSet(chunk.heading)) : 0;
|
||||
const coverage = queryTokens.size ? overlap / queryTokens.size : 1;
|
||||
const specificRouteBonus = /^(GET|POST|PUT|PATCH|DELETE|MOUNT)\s+\//i.test(chunk.heading) && headingOverlap
|
||||
? 140
|
||||
: 0;
|
||||
const genericRoutePenalty = ["Routes", "Web Routes", "Route Reference"].includes(chunk.heading) ? 80 : 0;
|
||||
const score = (overlap * 100) + (titleOverlap * 45) + (headingOverlap * 80) +
|
||||
Math.round(coverage * 100) + specificRouteBonus - genericRoutePenalty +
|
||||
SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0);
|
||||
const excerpt = excerptForChunk(chunk.text, queryTokens);
|
||||
return {
|
||||
id: entry.id,
|
||||
@ -584,9 +609,14 @@ function tokenSet(value) {
|
||||
const cleaned = cleanText(value, 4000);
|
||||
const expanded = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||||
const tokens = new Set();
|
||||
for (const token of `${cleaned} ${expanded}`.toLowerCase().split(/[^a-z0-9_]+/)) {
|
||||
if (token.length < 2 || SEARCH_STOPWORDS.has(token)) continue;
|
||||
tokens.add(token);
|
||||
for (const variant of [cleaned, expanded]) {
|
||||
const words = variant.toLowerCase().split(/[^a-z0-9]+/)
|
||||
.filter((token) => token.length >= 2 && !SEARCH_STOPWORDS.has(token));
|
||||
words.forEach((token) => tokens.add(token));
|
||||
for (let index = 0; index < words.length - 1; index += 1) {
|
||||
const compact = `${words[index]}${words[index + 1]}`;
|
||||
if (compact.length <= 80) tokens.add(compact);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@ -663,6 +693,7 @@ module.exports = {
|
||||
knowledgeRoot,
|
||||
listKnowledgeFiles,
|
||||
listCommunityKnowledgeFiles,
|
||||
listCorrectionKnowledgeFiles,
|
||||
listKnowledgePlaceholders,
|
||||
loadKnowledgeEntries,
|
||||
migrateSingleBracePlaceholders,
|
||||
|
||||
@ -2,19 +2,19 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge");
|
||||
const { writeTextAtomicSync } = require("../../../src/services/safe-files");
|
||||
|
||||
function generateKnowledgeFiles(rootDir = process.cwd()) {
|
||||
const root = ensureKnowledgeDirs(rootDir);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const written = [];
|
||||
const coreFile = path.join(root, "core", "lumi-core.md");
|
||||
const coreMarkdown = buildCoreKnowledge(rootDir, generatedAt);
|
||||
const coreMarkdown = buildCoreKnowledge(rootDir);
|
||||
if (writeGeneratedFile(coreFile, coreMarkdown)) {
|
||||
written.push(path.relative(rootDir, coreFile));
|
||||
}
|
||||
for (const plugin of discoverPlugins(rootDir)) {
|
||||
const pluginFile = path.join(root, "plugins", `${slugify(plugin.id)}.md`);
|
||||
const pluginMarkdown = buildPluginKnowledge(plugin, generatedAt);
|
||||
const pluginMarkdown = buildPluginKnowledge(plugin);
|
||||
if (writeGeneratedFile(pluginFile, pluginMarkdown)) {
|
||||
written.push(path.relative(rootDir, pluginFile));
|
||||
}
|
||||
@ -22,7 +22,7 @@ function generateKnowledgeFiles(rootDir = process.cwd()) {
|
||||
return written;
|
||||
}
|
||||
|
||||
function buildCoreKnowledge(rootDir, generatedAt) {
|
||||
function buildCoreKnowledge(rootDir) {
|
||||
const packageJson = readJson(path.join(rootDir, "package.json"));
|
||||
const readme = readText(path.join(rootDir, "README.md"));
|
||||
const routes = discoverCoreRoutes(rootDir);
|
||||
@ -37,8 +37,7 @@ function buildCoreKnowledge(rootDir, generatedAt) {
|
||||
category: "Core",
|
||||
tags: "core, routes, commands, settings",
|
||||
generated: true,
|
||||
editable: false,
|
||||
updated_at: generatedAt
|
||||
editable: false
|
||||
}) + [
|
||||
`# ${packageJson.name || "Lumi Core"}`,
|
||||
"",
|
||||
@ -64,7 +63,7 @@ function buildCoreKnowledge(rootDir, generatedAt) {
|
||||
].filter((line) => line !== "").join("\n");
|
||||
}
|
||||
|
||||
function buildPluginKnowledge(plugin, generatedAt) {
|
||||
function buildPluginKnowledge(plugin) {
|
||||
const routeLines = plugin.routes.length
|
||||
? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n")
|
||||
: "- No plugin routes detected.";
|
||||
@ -82,8 +81,7 @@ function buildPluginKnowledge(plugin, generatedAt) {
|
||||
category: "Plugin",
|
||||
tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "),
|
||||
generated: true,
|
||||
editable: false,
|
||||
updated_at: generatedAt
|
||||
editable: false
|
||||
}) + [
|
||||
`# ${plugin.name || plugin.id}`,
|
||||
"",
|
||||
@ -221,8 +219,9 @@ function discoverCommandTriggers(source) {
|
||||
|
||||
function writeGeneratedFile(filePath, content) {
|
||||
if (!canOverwriteGeneratedFile(filePath)) return false;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${content.trim()}\n`);
|
||||
const output = `${content.trim()}\n`;
|
||||
if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === output) return false;
|
||||
writeTextAtomicSync(filePath, output);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ const PERMISSION_LEVELS = Object.freeze(["edit", "edit_review", "edit_review_imp
|
||||
const STATUS_VALUES = Object.freeze(["draft", "published", "archived"]);
|
||||
const VISIBILITY_VALUES = Object.freeze(["user", "mod", "admin"]);
|
||||
const REVIEW_STATES = Object.freeze(["draft", "review_pending", "approved", "rejected"]);
|
||||
const VISIBILITY_RANK = Object.freeze({ user: 0, mod: 1, admin: 2 });
|
||||
|
||||
function ensureTables(db) {
|
||||
db.exec(`
|
||||
@ -80,29 +81,75 @@ function accessForUser(db, user) {
|
||||
const okfPermission = okfPermissionForUser(db, user?.id);
|
||||
const isAdmin = Boolean(user?.isAdmin);
|
||||
const isMod = Boolean(user?.isAdmin || user?.isMod);
|
||||
const capabilities = Object.freeze({
|
||||
read_user_fields: Boolean(user),
|
||||
read_mod_fields: isMod,
|
||||
read_admin_fields: isAdmin,
|
||||
edit_content: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission),
|
||||
review: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission),
|
||||
implement: isAdmin || okfPermission === "edit_review_implement",
|
||||
manage_permissions: isAdmin,
|
||||
management_view: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission)
|
||||
});
|
||||
return {
|
||||
authenticated: Boolean(user),
|
||||
isAdmin,
|
||||
isMod,
|
||||
okfPermission,
|
||||
canEdit: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission),
|
||||
canReview: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission),
|
||||
canImplement: isAdmin || okfPermission === "edit_review_implement",
|
||||
canManagePermissions: isAdmin,
|
||||
maxVisibility: isAdmin ? "admin" : isMod ? "mod" : "user"
|
||||
capabilities,
|
||||
canEdit: capabilities.edit_content,
|
||||
canReview: capabilities.review,
|
||||
canImplement: capabilities.implement,
|
||||
canManagePermissions: capabilities.manage_permissions,
|
||||
canViewManagement: capabilities.management_view,
|
||||
maxVisibility: capabilities.read_admin_fields ? "admin" : capabilities.read_mod_fields ? "mod" : "user"
|
||||
};
|
||||
}
|
||||
|
||||
function canSeeEntry(access, entry, { management = false } = {}) {
|
||||
if (!access.authenticated || !entry || entry.deleted_at) return false;
|
||||
if (access.isAdmin) return true;
|
||||
if (management && access.canEdit) return true;
|
||||
if (access.capabilities.read_admin_fields) return true;
|
||||
if (!canReadVisibility(access, entry.visibility)) return false;
|
||||
if (management && access.capabilities.management_view) return true;
|
||||
if (entry.status !== "published") return false;
|
||||
if (entry.visibility === "admin") return false;
|
||||
if (entry.visibility === "mod" && !access.isMod) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function canReadVisibility(access, visibility) {
|
||||
const required = VISIBILITY_RANK[visibility] ?? VISIBILITY_RANK.user;
|
||||
const maximum = VISIBILITY_RANK[access.maxVisibility] ?? VISIBILITY_RANK.user;
|
||||
return required <= maximum;
|
||||
}
|
||||
|
||||
function allowedVisibility(value, access, fallback = "user") {
|
||||
const requested = normalizeChoice(value, VISIBILITY_VALUES, fallback);
|
||||
if (canReadVisibility(access, requested)) return requested;
|
||||
return canReadVisibility(access, fallback) ? fallback : access.maxVisibility;
|
||||
}
|
||||
|
||||
function visibleSnapshot(snapshot, access) {
|
||||
if (!snapshot || typeof snapshot !== "object") return {};
|
||||
const visible = {
|
||||
slug: snapshot.slug,
|
||||
title: snapshot.title,
|
||||
category: snapshot.category,
|
||||
tags: Array.isArray(snapshot.tags) ? snapshot.tags : [],
|
||||
aliases: Array.isArray(snapshot.aliases) ? snapshot.aliases : [],
|
||||
summary: snapshot.summary,
|
||||
user_markdown: snapshot.user_markdown,
|
||||
visibility: snapshot.visibility,
|
||||
status: snapshot.status,
|
||||
review_state: snapshot.review_state
|
||||
};
|
||||
if (access.capabilities.read_mod_fields) visible.moderator_markdown = snapshot.moderator_markdown;
|
||||
if (access.capabilities.read_admin_fields) {
|
||||
visible.admin_markdown = snapshot.admin_markdown;
|
||||
visible.ai_facts_markdown = snapshot.ai_facts_markdown;
|
||||
visible.source_links = Array.isArray(snapshot.source_links) ? snapshot.source_links : [];
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function publicEntry(entry, access) {
|
||||
const base = {
|
||||
id: entry.id,
|
||||
@ -119,10 +166,10 @@ function publicEntry(entry, access) {
|
||||
updated_at: entry.updated_at,
|
||||
published_at: entry.published_at
|
||||
};
|
||||
if (access.isMod || access.isAdmin) {
|
||||
if (access.capabilities.read_mod_fields) {
|
||||
base.moderator_markdown = entry.moderator_markdown;
|
||||
}
|
||||
if (access.isAdmin || access.canEdit) {
|
||||
if (access.capabilities.read_admin_fields) {
|
||||
base.admin_markdown = entry.admin_markdown;
|
||||
base.ai_facts_markdown = entry.ai_facts_markdown;
|
||||
base.source_links = parseJsonArray(entry.source_links_json);
|
||||
@ -153,9 +200,10 @@ function getEntryBySlug(db, slug, user, options = {}) {
|
||||
return publicEntry(entry, access);
|
||||
}
|
||||
|
||||
function getEditableEntry(db, slug) {
|
||||
function getEditableEntry(db, slug, user) {
|
||||
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
|
||||
return entry ? normalizeEntry(entry) : null;
|
||||
const access = accessForUser(db, user);
|
||||
return canSeeEntry(access, entry, { management: true }) ? publicEntry(entry, access) : null;
|
||||
}
|
||||
|
||||
function createEntry(db, values, actor) {
|
||||
@ -165,7 +213,7 @@ function createEntry(db, values, actor) {
|
||||
const entry = normalizeValues(values, {
|
||||
actor,
|
||||
existing: null,
|
||||
canImplement: access.canImplement,
|
||||
access,
|
||||
now
|
||||
});
|
||||
entry.id = crypto.randomUUID();
|
||||
@ -179,19 +227,19 @@ function createEntry(db, values, actor) {
|
||||
"VALUES (@id, @slug, @title, @category, @tags_json, @aliases_json, @summary, @user_markdown, @moderator_markdown, @admin_markdown, @ai_facts_markdown, @source_links_json, @visibility, @status, @review_state, @created_by, @updated_by, @reviewed_by, @published_by, @created_at, @updated_at, @reviewed_at, @published_at, @archived_at, @deleted_at)"
|
||||
).run(entry);
|
||||
addVersion(db, entry.id, "create", null, entry, actor, values.change_note || "Entry created.");
|
||||
return normalizeEntry(entry);
|
||||
return publicEntry(entry, access);
|
||||
}
|
||||
|
||||
function updateEntry(db, slug, values, actor) {
|
||||
const access = accessForUser(db, actor);
|
||||
if (!access.canEdit) throw new Error("OKF edit permission is required.");
|
||||
const current = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
|
||||
if (!current) throw new Error("OKF entry was not found.");
|
||||
if (!canSeeEntry(access, current, { management: true })) throw new Error("OKF entry was not found.");
|
||||
const now = Date.now();
|
||||
const next = normalizeValues(values, {
|
||||
actor,
|
||||
existing: current,
|
||||
canImplement: access.canImplement,
|
||||
access,
|
||||
now
|
||||
});
|
||||
next.id = current.id;
|
||||
@ -210,13 +258,13 @@ function updateEntry(db, slug, values, actor) {
|
||||
"UPDATE okf_entries SET slug = @slug, title = @title, category = @category, tags_json = @tags_json, aliases_json = @aliases_json, summary = @summary, user_markdown = @user_markdown, moderator_markdown = @moderator_markdown, admin_markdown = @admin_markdown, ai_facts_markdown = @ai_facts_markdown, source_links_json = @source_links_json, visibility = @visibility, status = @status, review_state = @review_state, updated_by = @updated_by, updated_at = @updated_at, reviewed_by = @reviewed_by, published_by = @published_by, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
|
||||
).run(next);
|
||||
addVersion(db, next.id, "update", current, next, actor, values.change_note || "Entry updated.");
|
||||
return normalizeEntry(next);
|
||||
return publicEntry(next, access);
|
||||
}
|
||||
|
||||
function setEntryWorkflow(db, slug, action, actor, note = "") {
|
||||
const access = accessForUser(db, actor);
|
||||
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
|
||||
if (!entry) throw new Error("OKF entry was not found.");
|
||||
if (!canSeeEntry(access, entry, { management: true })) throw new Error("OKF entry was not found.");
|
||||
const next = { ...entry };
|
||||
const now = Date.now();
|
||||
if (action === "review") {
|
||||
@ -251,25 +299,32 @@ function setEntryWorkflow(db, slug, action, actor, note = "") {
|
||||
"UPDATE okf_entries SET status = @status, review_state = @review_state, reviewed_by = @reviewed_by, published_by = @published_by, updated_by = @updated_by, updated_at = @updated_at, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
|
||||
).run(next);
|
||||
addVersion(db, next.id, action, entry, next, actor, note || `Workflow action: ${action}.`);
|
||||
return normalizeEntry(next);
|
||||
return publicEntry(next, access);
|
||||
}
|
||||
|
||||
function listVersions(db, entryId) {
|
||||
function listVersions(db, entryId, user) {
|
||||
const access = accessForUser(db, user);
|
||||
const entry = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId);
|
||||
if (!canSeeEntry(access, entry, { management: true })) return [];
|
||||
return db
|
||||
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? ORDER BY version_number DESC")
|
||||
.all(entryId)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
previous: parseJsonObject(row.previous_json),
|
||||
next: parseJsonObject(row.next_json)
|
||||
}));
|
||||
.map((row) => {
|
||||
const { previous_json, next_json, changed_by, ...metadata } = row;
|
||||
return {
|
||||
...metadata,
|
||||
...(access.capabilities.read_admin_fields ? { changed_by } : {}),
|
||||
previous: visibleSnapshot(parseJsonObject(previous_json), access),
|
||||
next: visibleSnapshot(parseJsonObject(next_json), access)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function restoreVersion(db, entryId, versionNumber, actor, note = "") {
|
||||
const access = accessForUser(db, actor);
|
||||
if (!access.canImplement) throw new Error("OKF restore permission is required.");
|
||||
const current = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId);
|
||||
if (!current) throw new Error("OKF entry was not found.");
|
||||
if (!canSeeEntry(access, current, { management: true })) throw new Error("OKF entry was not found.");
|
||||
const version = db
|
||||
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?")
|
||||
.get(entryId, Number(versionNumber));
|
||||
@ -287,11 +342,11 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") {
|
||||
aliases_json: JSON.stringify(splitLines(snapshot.aliases || [])),
|
||||
summary: cleanText(snapshot.summary, 800),
|
||||
user_markdown: cleanText(snapshot.user_markdown, 24000),
|
||||
moderator_markdown: cleanText(snapshot.moderator_markdown, 24000),
|
||||
admin_markdown: cleanText(snapshot.admin_markdown, 24000),
|
||||
ai_facts_markdown: cleanText(snapshot.ai_facts_markdown, 24000),
|
||||
source_links_json: JSON.stringify(cleanLinkList(snapshot.source_links || [])),
|
||||
visibility: normalizeChoice(snapshot.visibility, VISIBILITY_VALUES, "user"),
|
||||
moderator_markdown: access.capabilities.read_mod_fields ? cleanText(snapshot.moderator_markdown, 24000) : current.moderator_markdown,
|
||||
admin_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.admin_markdown, 24000) : current.admin_markdown,
|
||||
ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.ai_facts_markdown, 24000) : current.ai_facts_markdown,
|
||||
source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(snapshot.source_links || [])) : current.source_links_json,
|
||||
visibility: allowedVisibility(snapshot.visibility, access, current.visibility),
|
||||
status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"),
|
||||
review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"),
|
||||
updated_by: String(actor.id),
|
||||
@ -315,7 +370,7 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") {
|
||||
actor,
|
||||
note || `Restored version ${Number(versionNumber)}.`
|
||||
);
|
||||
return normalizeEntry(restored);
|
||||
return publicEntry(restored, access);
|
||||
}
|
||||
|
||||
function grantPermission(db, values, actor) {
|
||||
@ -336,7 +391,8 @@ function revokePermission(db, id, actor) {
|
||||
db.prepare("UPDATE okf_permissions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), Number(id));
|
||||
}
|
||||
|
||||
function listPermissions(db) {
|
||||
function listPermissions(db, user) {
|
||||
if (!accessForUser(db, user).capabilities.manage_permissions) return [];
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT okf_permissions.*, user_profiles.internal_username AS user_name, grantor.internal_username AS granted_by_name " +
|
||||
@ -350,7 +406,7 @@ function listPermissions(db) {
|
||||
|
||||
function searchForAi(db, query, user, limit = 5) {
|
||||
const access = accessForUser(db, user);
|
||||
if (!access.authenticated) return [];
|
||||
if (!access.capabilities.read_user_fields) return [];
|
||||
return listEntries(db, { q: query }, user)
|
||||
.filter((entry) => entry.status === "published" && entry.review_state === "approved")
|
||||
.slice(0, limit)
|
||||
@ -361,7 +417,12 @@ function searchForAi(db, query, user, limit = 5) {
|
||||
category: entry.category,
|
||||
visibility: entry.visibility,
|
||||
summary: entry.summary,
|
||||
facts: [entry.user_markdown, entry.moderator_markdown, entry.admin_markdown, entry.ai_facts_markdown]
|
||||
facts: [
|
||||
entry.user_markdown,
|
||||
access.capabilities.read_mod_fields ? entry.moderator_markdown : "",
|
||||
access.capabilities.read_admin_fields ? entry.admin_markdown : "",
|
||||
access.capabilities.read_admin_fields ? entry.ai_facts_markdown : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.slice(0, 4000),
|
||||
@ -383,16 +444,16 @@ function matchesFilters(entry, filters, access, options) {
|
||||
entry.tags_json,
|
||||
entry.aliases_json
|
||||
];
|
||||
if (access.isMod || access.isAdmin || options.management) visibleText.push(entry.moderator_markdown);
|
||||
if (access.isAdmin || options.management) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json);
|
||||
if (access.capabilities.read_mod_fields) visibleText.push(entry.moderator_markdown);
|
||||
if (access.capabilities.read_admin_fields) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json);
|
||||
return visibleText.join(" ").toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
function normalizeValues(values, { actor, existing, canImplement, now }) {
|
||||
const status = canImplement
|
||||
function normalizeValues(values, { actor, existing, access, now }) {
|
||||
const status = access.capabilities.implement
|
||||
? normalizeChoice(values.status, STATUS_VALUES, existing?.status || "draft")
|
||||
: existing?.status || "draft";
|
||||
const reviewState = canImplement
|
||||
const reviewState = access.capabilities.implement
|
||||
? normalizeChoice(values.review_state, REVIEW_STATES, existing?.review_state || "draft")
|
||||
: "review_pending";
|
||||
const publishedAt = status === "published" ? (existing?.published_at || now) : existing?.published_at || null;
|
||||
@ -404,11 +465,11 @@ function normalizeValues(values, { actor, existing, canImplement, now }) {
|
||||
aliases_json: JSON.stringify(splitLines(values.aliases ?? parseJsonArray(existing?.aliases_json))),
|
||||
summary: cleanText(values.summary ?? existing?.summary, 800),
|
||||
user_markdown: cleanText(values.user_markdown ?? existing?.user_markdown, 24000),
|
||||
moderator_markdown: cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000),
|
||||
admin_markdown: cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000),
|
||||
ai_facts_markdown: cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000),
|
||||
source_links_json: JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))),
|
||||
visibility: normalizeChoice(values.visibility, VISIBILITY_VALUES, existing?.visibility || "user"),
|
||||
moderator_markdown: access.capabilities.read_mod_fields ? cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000) : existing?.moderator_markdown || "",
|
||||
admin_markdown: access.capabilities.read_admin_fields ? cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000) : existing?.admin_markdown || "",
|
||||
ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000) : existing?.ai_facts_markdown || "",
|
||||
source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))) : existing?.source_links_json || "[]",
|
||||
visibility: allowedVisibility(values.visibility, access, existing?.visibility || "user"),
|
||||
status,
|
||||
review_state: reviewState,
|
||||
reviewed_by: existing?.reviewed_by || null,
|
||||
|
||||
@ -24,10 +24,12 @@ const {
|
||||
ensureKnowledgeDirs,
|
||||
getCommunityKnowledgeFile,
|
||||
listCommunityKnowledgeFiles,
|
||||
listCorrectionKnowledgeFiles,
|
||||
loadKnowledgeEntries,
|
||||
migrateSingleBracePlaceholders,
|
||||
registerKnowledgePlaceholderDefinitions,
|
||||
saveCommunityKnowledgeFile,
|
||||
saveCorrectionKnowledgeFile,
|
||||
searchFileKnowledge
|
||||
} = require("./backend/file_knowledge");
|
||||
const { generateKnowledgeFiles } = require("./backend/generate_knowledge");
|
||||
@ -39,6 +41,10 @@ const PLUGIN_ID = "okf";
|
||||
module.exports = {
|
||||
id: PLUGIN_ID,
|
||||
init({ web, db, placeholders = placeholderService }) {
|
||||
if (typeof web.auth?.requireAuth !== "function") {
|
||||
throw new Error("OKF requires Lumi's shared WebUI authentication helpers.");
|
||||
}
|
||||
const requireLogin = web.auth.requireAuth;
|
||||
ensureTables(db);
|
||||
ensureKnowledgeDirs(process.cwd());
|
||||
if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) {
|
||||
@ -88,7 +94,11 @@ module.exports = {
|
||||
global.lumiFrameworks.okf = {
|
||||
search: searchOkfForAi,
|
||||
context: okfContextProvider,
|
||||
accessForUser: (user) => accessForUser(db, user)
|
||||
accessForUser: (user) => accessForUser(db, user),
|
||||
saveCorrection(values, actor) {
|
||||
if (!actor?.isAdmin) throw new Error("Only admins can create correction knowledge files.");
|
||||
return saveCorrectionKnowledgeFile(process.cwd(), values);
|
||||
}
|
||||
};
|
||||
global.lumiFrameworks.ai?.registerContext?.(PLUGIN_ID, okfContextProvider);
|
||||
|
||||
@ -119,12 +129,18 @@ module.exports = {
|
||||
renderAdmin(req, res, db);
|
||||
});
|
||||
|
||||
router.post("/admin/preview", requireOkfManagement(db), (req, res) => {
|
||||
const markdown = String(req.body?.markdown || "");
|
||||
if (markdown.length > 24000) return res.status(400).json({ error: "Markdown preview is limited to 24,000 characters." });
|
||||
return res.json({ html: renderMarkdown(markdown) });
|
||||
});
|
||||
|
||||
router.post("/admin/community", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
validateOkfPlaceholderFields({
|
||||
body: req.body.body
|
||||
}, req.session.user, placeholders);
|
||||
const entry = saveCommunityKnowledgeFile(process.cwd(), req.body);
|
||||
const entry = saveCommunityKnowledgeFile(process.cwd(), communityFileValuesForUser(db, req.session.user, req.body));
|
||||
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
|
||||
req.session.flash = { type: "success", message: "Community OKF file saved." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`);
|
||||
@ -139,10 +155,11 @@ module.exports = {
|
||||
validateOkfPlaceholderFields({
|
||||
body: req.body.body
|
||||
}, req.session.user, placeholders);
|
||||
const entry = saveCommunityKnowledgeFile(process.cwd(), {
|
||||
const existing = getCommunityKnowledgeFile(process.cwd(), req.params.slug);
|
||||
const entry = saveCommunityKnowledgeFile(process.cwd(), communityFileValuesForUser(db, req.session.user, {
|
||||
...req.body,
|
||||
existing_slug: req.params.slug
|
||||
});
|
||||
}, existing));
|
||||
registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() });
|
||||
req.session.flash = { type: "success", message: "Community OKF file updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`);
|
||||
@ -189,7 +206,7 @@ module.exports = {
|
||||
|
||||
router.post("/admin/entries/:slug/versions/:version/restore", requireOkfManagement(db), (req, res) => {
|
||||
try {
|
||||
const selected = getEditableEntry(db, req.params.slug);
|
||||
const selected = getEditableEntry(db, req.params.slug, req.session.user);
|
||||
if (!selected) throw new Error("OKF entry was not found.");
|
||||
const entry = restoreVersion(
|
||||
db,
|
||||
@ -258,7 +275,15 @@ module.exports = {
|
||||
role: "public",
|
||||
authRequired: true,
|
||||
section: "admin",
|
||||
canAccess: (user) => accessForUser(db, user).canEdit
|
||||
canAccess: (user) => user?.isAdmin === true
|
||||
});
|
||||
web.addNavItem({
|
||||
label: "Contribute to OKF",
|
||||
path: `/plugins/${PLUGIN_ID}/admin`,
|
||||
role: "public",
|
||||
authRequired: true,
|
||||
section: "community",
|
||||
canAccess: (user) => user?.isAdmin !== true && accessForUser(db, user).canEdit
|
||||
});
|
||||
return () => {
|
||||
global.lumiFrameworks?.ai?.unregisterContext?.(PLUGIN_ID);
|
||||
@ -392,7 +417,8 @@ async function resolveOkfEntryPlaceholders(entry, user, placeholders) {
|
||||
}
|
||||
|
||||
function renderAdmin(req, res, db) {
|
||||
const activeTab = resolveAdminTab(req.query);
|
||||
const access = accessForUser(db, req.session.user);
|
||||
const activeTab = resolveAdminTab(req.query, access.canManagePermissions);
|
||||
const filters = {
|
||||
q: req.query.q || "",
|
||||
status: req.query.status || "",
|
||||
@ -401,13 +427,18 @@ function renderAdmin(req, res, db) {
|
||||
};
|
||||
const entries = listEntries(db, filters, req.session.user, { management: true });
|
||||
const allEntries = listEntries(db, {}, req.session.user, { management: true });
|
||||
const selected = req.query.edit ? getEditableEntry(db, req.query.edit) : null;
|
||||
const communityFiles = listCommunityKnowledgeFiles(process.cwd());
|
||||
const selectedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null;
|
||||
const systemFiles = listSystemKnowledgeFiles(process.cwd());
|
||||
const selected = req.query.edit ? getEditableEntry(db, req.query.edit, req.session.user) : null;
|
||||
const communityFiles = listCommunityKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility));
|
||||
const requestedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null;
|
||||
const selectedCommunity = requestedCommunity && canReadOkfVisibility(access, requestedCommunity.visibility) ? requestedCommunity : null;
|
||||
const systemFiles = listSystemKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility));
|
||||
const selectedSystemFile = req.query.system ? findSystemKnowledgeFile(systemFiles, req.query.system) : null;
|
||||
const correctionFiles = access.canManagePermissions
|
||||
? listCorrectionKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility))
|
||||
: [];
|
||||
const selectedCorrection = req.query.correction ? findSystemKnowledgeFile(correctionFiles, req.query.correction) : null;
|
||||
const suggestionEntries = [...allEntries, ...communityFiles];
|
||||
const versions = selected ? listVersions(db, selected.id) : [];
|
||||
const versions = selected ? listVersions(db, selected.id, req.session.user) : [];
|
||||
res.render(view("admin"), {
|
||||
title: "OKF Management",
|
||||
activeTab,
|
||||
@ -418,11 +449,13 @@ function renderAdmin(req, res, db) {
|
||||
selectedCommunity,
|
||||
systemFiles,
|
||||
selectedSystemFile,
|
||||
correctionFiles,
|
||||
selectedCorrection,
|
||||
versions,
|
||||
permissions: listPermissions(db),
|
||||
permissions: listPermissions(db, req.session.user),
|
||||
levels: PERMISSION_LEVELS,
|
||||
statuses: STATUS_VALUES,
|
||||
visibilityValues: VISIBILITY_VALUES,
|
||||
visibilityValues: VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value)),
|
||||
reviewStates: REVIEW_STATES,
|
||||
categories: categoriesFor(suggestionEntries),
|
||||
tags: tagsFor(suggestionEntries),
|
||||
@ -430,11 +463,13 @@ function renderAdmin(req, res, db) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAdminTab(query = {}) {
|
||||
function resolveAdminTab(query = {}, allowCorrections = false) {
|
||||
if (query.edit) return "general";
|
||||
if (query.community) return "community";
|
||||
if (query.system) return "system";
|
||||
return ["general", "community", "system"].includes(query.tab) ? query.tab : "general";
|
||||
if (allowCorrections && query.correction) return "corrections";
|
||||
const allowed = allowCorrections ? ["general", "community", "system", "corrections"] : ["general", "community", "system"];
|
||||
return allowed.includes(query.tab) ? query.tab : "general";
|
||||
}
|
||||
|
||||
function listSystemKnowledgeFiles(rootDir = process.cwd()) {
|
||||
@ -449,6 +484,24 @@ function listSystemKnowledgeFiles(rootDir = process.cwd()) {
|
||||
.sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function canReadOkfVisibility(access, visibility) {
|
||||
if (visibility === "admin") return access.capabilities.read_admin_fields;
|
||||
if (visibility === "mod") return access.capabilities.read_mod_fields;
|
||||
return access.capabilities.read_user_fields;
|
||||
}
|
||||
|
||||
function communityFileValuesForUser(db, user, values, existing = null) {
|
||||
const access = accessForUser(db, user);
|
||||
if (existing && !canReadOkfVisibility(access, existing.visibility)) {
|
||||
throw new Error("That community knowledge file is not available to your account.");
|
||||
}
|
||||
const requestedVisibility = VISIBILITY_VALUES.includes(values.visibility) ? values.visibility : existing?.visibility || "user";
|
||||
if (!canReadOkfVisibility(access, requestedVisibility)) {
|
||||
throw new Error(`You cannot create or edit ${requestedVisibility}-only knowledge.`);
|
||||
}
|
||||
return { ...values, visibility: requestedVisibility };
|
||||
}
|
||||
|
||||
function findSystemKnowledgeFile(files, key) {
|
||||
const value = String(key || "");
|
||||
return files.find((file) =>
|
||||
@ -463,11 +516,6 @@ function view(name) {
|
||||
return path.join(__dirname, "views", `${name}.ejs`);
|
||||
}
|
||||
|
||||
function requireLogin(req, res, next) {
|
||||
if (req.session.user) return next();
|
||||
return res.redirect("/login");
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session.user?.isAdmin) return next();
|
||||
return denied(res);
|
||||
|
||||
@ -8,6 +8,7 @@ const {
|
||||
accessForUser,
|
||||
createEntry,
|
||||
ensureTables,
|
||||
getEditableEntry,
|
||||
getEntryBySlug,
|
||||
grantPermission,
|
||||
listEntries,
|
||||
@ -23,6 +24,7 @@ const {
|
||||
ensureKnowledgeDirs,
|
||||
getCommunityKnowledgeFile,
|
||||
listCommunityKnowledgeFiles,
|
||||
listCorrectionKnowledgeFiles,
|
||||
loadKnowledgeEntries,
|
||||
listKnowledgePlaceholders,
|
||||
saveCorrectionKnowledgeFile,
|
||||
@ -49,11 +51,15 @@ db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, update
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("mod-1", "Mod", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("user-1", "User", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("editor-1", "Editor", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("reviewer-1", "Reviewer", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("implementer-1", "Implementer", now, now);
|
||||
|
||||
const admin = { id: "admin-1", isAdmin: true, isMod: true };
|
||||
const mod = { id: "mod-1", isMod: true };
|
||||
const user = { id: "user-1" };
|
||||
const editor = { id: "editor-1" };
|
||||
const reviewer = { id: "reviewer-1" };
|
||||
const implementer = { id: "implementer-1" };
|
||||
|
||||
assert(PRESERVED_PATHS.includes("knowledge/community"));
|
||||
assert(PRESERVED_PATHS.includes("knowledge/corrections"));
|
||||
@ -69,16 +75,42 @@ assert(adminTemplate.includes("System-generated OKF") && !adminTemplate.includes
|
||||
assert(adminTemplate.includes("data-okf-file-list"));
|
||||
assert(adminTemplate.includes("data-okf-file-search"));
|
||||
assert(adminTemplate.includes('data-okf-file-filter="category"'));
|
||||
assert(adminTemplate.includes('data-okf-file-filter="tags"'));
|
||||
assert(adminTemplate.includes("data-okf-markdown-preview-panel"));
|
||||
assert(adminTemplate.includes("/plugins/okf/admin/preview"));
|
||||
assert(adminTemplate.includes("No community OKF files match these filters."));
|
||||
assert(adminTemplate.includes("No system-generated OKF files match these filters."));
|
||||
assert(adminTemplate.includes("Feedback corrections"));
|
||||
assert(adminTemplate.includes("selectedCorrection"));
|
||||
const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
|
||||
assert(okfPluginSource.includes("preferSpecificKnowledgeChunks"));
|
||||
assert(okfPluginSource.includes("trimForContext"));
|
||||
assert(okfPluginSource.includes("remaining = 4200"));
|
||||
assert(okfPluginSource.includes("communityFileValuesForUser"));
|
||||
assert(okfPluginSource.includes("canReadOkfVisibility(access, requestedCommunity.visibility)"));
|
||||
assert(okfPluginSource.includes("VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value))"));
|
||||
assert(okfPluginSource.includes("saveCorrection(values, actor)"));
|
||||
assert(okfPluginSource.includes("Only admins can create correction knowledge files"));
|
||||
assert(okfPluginSource.includes('router.post("/admin/preview", requireOkfManagement(db)'));
|
||||
assert(okfPluginSource.includes('label: "Contribute to OKF"'));
|
||||
const fileKnowledgeSource = fs.readFileSync(path.join(__dirname, "..", "backend", "file_knowledge.js"), "utf8");
|
||||
assert(fileKnowledgeSource.includes("writeTextAtomicSync(filePath, markdown)"));
|
||||
|
||||
grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin);
|
||||
grantPermission(db, { user_id: "reviewer-1", level: "edit_review", notes: "Trusted reviewer" }, admin);
|
||||
grantPermission(db, { user_id: "implementer-1", level: "edit_review_implement", notes: "Trusted implementer" }, admin);
|
||||
assert.equal(accessForUser(db, editor).canEdit, true);
|
||||
assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1);
|
||||
assert.equal(accessForUser(db, editor).capabilities.read_admin_fields, false);
|
||||
assert.equal(accessForUser(db, reviewer).capabilities.review, true);
|
||||
assert.equal(accessForUser(db, reviewer).capabilities.implement, false);
|
||||
assert.equal(accessForUser(db, implementer).capabilities.implement, true);
|
||||
assert.equal(accessForUser(db, mod).capabilities.read_mod_fields, true);
|
||||
assert.equal(accessForUser(db, user).capabilities.read_mod_fields, false);
|
||||
assert.equal(accessForUser(db, admin).capabilities.manage_permissions, true);
|
||||
assert.equal(accessForUser(db, null).capabilities.read_user_fields, false);
|
||||
assert.equal(accessForUser(db, mod).capabilities.management_view, false);
|
||||
assert.equal(listPermissions(db, editor).length, 0);
|
||||
assert.equal(listPermissions(db, admin).filter((grant) => !grant.revoked_at).length, 3);
|
||||
|
||||
const entry = createEntry(db, {
|
||||
title: "Currency basics",
|
||||
@ -104,6 +136,7 @@ assert.equal(listEntries(db, {}, mod).length, 1);
|
||||
assert.equal(getEntryBySlug(db, "currency-basics", user), null);
|
||||
assert.equal(getEntryBySlug(db, "currency-basics", mod).moderator_markdown.includes("Moderators"), true);
|
||||
assert.equal(getEntryBySlug(db, "currency-basics", admin).admin_markdown.includes("Admin-only"), true);
|
||||
assert.equal(listEntries(db, {}, editor, { management: true }).some((item) => item.slug === "currency-basics"), false);
|
||||
assert(searchForAi(db, "currency", mod).some((item) => item.facts.includes("Moderators")));
|
||||
assert.equal(searchForAi(db, "currency", user).length, 0);
|
||||
|
||||
@ -125,6 +158,21 @@ assert(publicUserContext.some((item) => item.id === publicEntry.id));
|
||||
assert.equal(publicUserContext.some((item) => item.facts.includes("Admin-only")), false);
|
||||
assert(publicAdminContext.some((item) => item.facts.includes("Admin-only implementation notes.")));
|
||||
assert(publicAdminContext.some((item) => item.facts.includes("Admin AI fact.")));
|
||||
assert.equal(getEntryBySlug(db, "public-help", editor).admin_markdown, undefined);
|
||||
assert.equal(getEditableEntry(db, "public-help", editor).ai_facts_markdown, undefined);
|
||||
assert.equal(getEditableEntry(db, "public-help", reviewer).admin_markdown, undefined);
|
||||
assert.equal(getEditableEntry(db, "public-help", implementer).ai_facts_markdown, undefined);
|
||||
assert.equal(searchForAi(db, "Admin-only implementation", editor).length, 0);
|
||||
updateEntry(db, "public-help", {
|
||||
title: "Public help",
|
||||
slug: "public-help",
|
||||
summary: "Contributor-safe update.",
|
||||
user_markdown: "Everyone can still read this.",
|
||||
admin_markdown: "Attempted overwrite.",
|
||||
ai_facts_markdown: "Attempted AI overwrite."
|
||||
}, editor);
|
||||
assert.equal(getEntryBySlug(db, "public-help", admin).admin_markdown, "Admin-only implementation notes.");
|
||||
assert.equal(getEntryBySlug(db, "public-help", admin).ai_facts_markdown, "Admin AI fact.");
|
||||
|
||||
const proposed = createEntry(db, {
|
||||
title: "Draft from editor",
|
||||
@ -144,12 +192,16 @@ updateEntry(db, "draft-from-editor", {
|
||||
user_markdown: "Updated draft content.",
|
||||
change_note: "Editor update."
|
||||
}, editor);
|
||||
assert.equal(listVersions(db, proposed.id).length, 2);
|
||||
assert.equal(listVersions(db, proposed.id, admin).length, 2);
|
||||
const contributorVersions = listVersions(db, proposed.id, editor);
|
||||
assert.equal(contributorVersions[0].next.admin_markdown, undefined);
|
||||
assert.equal(contributorVersions[0].next_json, undefined);
|
||||
assert.equal(contributorVersions[0].changed_by, undefined);
|
||||
|
||||
const restored = restoreVersion(db, proposed.id, 1, admin, "Test restore.");
|
||||
assert.equal(restored.summary, "Editor draft");
|
||||
assert.equal(restored.user_markdown, "Draft content.");
|
||||
assert.equal(listVersions(db, proposed.id).length, 3);
|
||||
assert.equal(listVersions(db, proposed.id, admin).length, 3);
|
||||
assert.equal(searchForAi(db, "draft", admin).length, 0);
|
||||
|
||||
setEntryWorkflow(db, "draft-from-editor", "review", admin);
|
||||
@ -255,6 +307,8 @@ assert(Number.isFinite(currencyResults[0].source_metadata.score));
|
||||
const identityResults = searchFileKnowledge({ query: "Who is OokamiKunTV?", user, rootDir: tempRoot, limit: 5 });
|
||||
assert.equal(identityResults[0].id, "community.people");
|
||||
assert(identityResults[0].facts.includes("OokamiKunTV is a known community contact"));
|
||||
assert.equal(searchFileKnowledge({ query: "communitypeople", user, rootDir: tempRoot, limit: 5 })[0].id, "community.people");
|
||||
assert.equal(searchFileKnowledge({ query: "Can you help me?", user, rootDir: tempRoot, limit: 5 }).length, 0);
|
||||
const commandUserResult = searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0];
|
||||
assert(commandUserResult.facts.includes("The public currency is coins."));
|
||||
assert(commandUserResult.facts.includes("[missing OKF reference]"));
|
||||
@ -317,6 +371,7 @@ const savedCorrection = saveCorrectionKnowledgeFile(tempRoot, {
|
||||
assert.equal(savedCorrection.id, "correction.feedback-correction");
|
||||
assert.equal(savedCorrection.path, "knowledge/corrections/feedback-correction.md");
|
||||
assert.equal(savedCorrection.frontmatter.source_feedback_id, "feedback-1");
|
||||
assert.equal(listCorrectionKnowledgeFiles(tempRoot)[0].id, "correction.feedback-correction");
|
||||
assert.equal(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction");
|
||||
fs.writeFileSync(currencyKnowledgePath, [
|
||||
"---",
|
||||
@ -393,6 +448,12 @@ assert(generatedPlugin.includes("Test Plugin"));
|
||||
assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings"));
|
||||
assert(generatedPlugin.includes("Inputs:"));
|
||||
assert(generatedPlugin.includes("test"));
|
||||
assert.equal(generatedCore.includes("updated_at:"), false);
|
||||
assert.deepEqual(generateKnowledgeFiles(generatedRoot), []);
|
||||
assert.equal(
|
||||
searchFileKnowledge({ query: "testplugin settings", user, rootDir: generatedRoot, limit: 5 })[0].source_metadata.heading,
|
||||
"GET /plugins/test-plugin/settings"
|
||||
);
|
||||
fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [
|
||||
"---",
|
||||
"id: plugin.test-plugin",
|
||||
@ -406,7 +467,7 @@ generateKnowledgeFiles(generatedRoot);
|
||||
assert(fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8").includes("Manual Test Plugin"));
|
||||
fs.rmSync(generatedRoot, { recursive: true, force: true });
|
||||
|
||||
const grant = listPermissions(db).find((row) => row.user_id === "editor-1" && !row.revoked_at);
|
||||
const grant = listPermissions(db, admin).find((row) => row.user_id === "editor-1" && !row.revoked_at);
|
||||
revokePermission(db, grant.id, admin);
|
||||
assert.equal(accessForUser(db, editor).canEdit, false);
|
||||
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<%- include("../../../src/web/views/partials/page-header", {
|
||||
eyebrow: "Administration",
|
||||
pageTitle: "OKF Management",
|
||||
description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions."
|
||||
eyebrow: okfAccess.canManagePermissions ? "Administration" : "Community contribution",
|
||||
pageTitle: okfAccess.canManagePermissions ? "OKF Management" : "Contribute to OKF",
|
||||
description: okfAccess.canManagePermissions
|
||||
? "Manage knowledge entries, reviews, publishing, history, and contributor access."
|
||||
: `Your ${okfAccess.level || "editor"} access lets you contribute here without granting access to other admin areas.`
|
||||
}) %>
|
||||
<nav class="tabs" aria-label="OKF management sections">
|
||||
<a href="/plugins/okf/admin?tab=general" <%= activeTab === "general" ? 'aria-current="page"' : "" %>>General OKF</a>
|
||||
<a href="/plugins/okf/admin?tab=community" <%= activeTab === "community" ? 'aria-current="page"' : "" %>>Community OKF</a>
|
||||
<a href="/plugins/okf/admin?tab=system" <%= activeTab === "system" ? 'aria-current="page"' : "" %>>System-generated OKF</a>
|
||||
<% if (okfAccess.canManagePermissions) { %><a href="/plugins/okf/admin?tab=corrections" <%= activeTab === "corrections" ? 'aria-current="page"' : "" %>>Feedback corrections</a><% } %>
|
||||
</nav>
|
||||
<% if (activeTab === "general") { %>
|
||||
<form method="get" action="/plugins/okf/admin" class="log-controls">
|
||||
@ -49,8 +52,10 @@
|
||||
</form>
|
||||
<% } else if (activeTab === "community") { %>
|
||||
<p class="hint">Community files are locally maintained OKF Markdown under <code>knowledge/community</code>.</p>
|
||||
<% } else { %>
|
||||
<% } else if (activeTab === "system") { %>
|
||||
<p class="hint">System-generated files are read-only OKF Markdown from core and plugin metadata.</p>
|
||||
<% } else { %>
|
||||
<p class="hint">Feedback corrections are reviewed, searchable knowledge fixes linked back to the core feedback that created them.</p>
|
||||
<% } %>
|
||||
<datalist id="okf-category-suggestions">
|
||||
<% categories.forEach((category) => { %>
|
||||
@ -124,6 +129,7 @@
|
||||
<label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(communityFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Status</span><select data-okf-file-filter="status"><option value="">All statuses</option><% [...new Set(communityFiles.map((file) => file.status).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(communityFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Tag</span><select data-okf-file-filter="tags"><option value="">All tags</option><% [...new Set(communityFiles.flatMap((file) => file.tags || []).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<button class="button subtle" type="reset">Reset</button>
|
||||
</form>
|
||||
<p class="hint" data-okf-file-count><%= communityFiles.length %> file<%= communityFiles.length === 1 ? "" : "s" %> shown.</p>
|
||||
@ -135,7 +141,7 @@
|
||||
<thead><tr><th>File</th><th>Category</th><th>Status</th><th>Visibility</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
<% communityFiles.forEach((file) => { %>
|
||||
<tr data-okf-file-row data-category="<%= file.category %>" data-status="<%= file.status %>" data-visibility="<%= file.visibility %>" data-search="<%= [file.title, file.id, file.slug, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
|
||||
<tr data-okf-file-row data-category="<%= file.category %>" data-status="<%= file.status %>" data-visibility="<%= file.visibility %>" data-tags="|<%= (file.tags || []).join('|') %>|" data-search="<%= [file.title, file.id, file.slug, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
|
||||
<td><a href="/plugins/okf/admin?tab=community&community=<%= encodeURIComponent(file.slug) %>#okf-community-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.slug %>.md</p></td>
|
||||
<td><%= file.category || "Community" %></td>
|
||||
<td><span class="badge"><%= file.status %></span></td>
|
||||
@ -185,6 +191,7 @@
|
||||
<option value="<%= value %>" <%= selectedCommunity && selectedCommunity.visibility === value ? "selected" : "" %>><%= value %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint">Controls the minimum role allowed to retrieve or view this file.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Priority</label>
|
||||
@ -197,8 +204,13 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Markdown body</label>
|
||||
<textarea name="body" rows="14" placeholder="# Community facts" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"><%= selectedCommunity ? selectedCommunity.body : '' %></textarea>
|
||||
<textarea name="body" rows="14" placeholder="# Community facts" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user" data-okf-markdown-input><%= selectedCommunity ? selectedCommunity.body : '' %></textarea>
|
||||
<span class="hint">You can reference visible frontmatter values with placeholders such as <code>{{community.currency.primary_name}}</code>.</span>
|
||||
<details class="feedback-metadata" data-okf-markdown-preview-panel>
|
||||
<summary>Preview Markdown</summary>
|
||||
<p class="hint" data-okf-markdown-preview-status>Open to preview the current text. The same safe renderer is used on the published page.</p>
|
||||
<div class="feedback-copy-block" data-okf-markdown-preview></div>
|
||||
</details>
|
||||
</div>
|
||||
<% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %>
|
||||
<div class="field full">
|
||||
@ -237,6 +249,7 @@
|
||||
<label><span>Category</span><select data-okf-file-filter="category"><option value="">All categories</option><% [...new Set(systemFiles.map((file) => file.category).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Scope</span><select data-okf-file-filter="scope"><option value="">All scopes</option><% [...new Set(systemFiles.map((file) => file.scope).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Visibility</span><select data-okf-file-filter="visibility"><option value="">All visibility</option><% [...new Set(systemFiles.map((file) => file.visibility).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<label><span>Tag</span><select data-okf-file-filter="tags"><option value="">All tags</option><% [...new Set(systemFiles.flatMap((file) => file.tags || []).filter(Boolean))].sort().forEach((value) => { %><option value="<%= value %>"><%= value %></option><% }) %></select></label>
|
||||
<button class="button subtle" type="reset">Reset</button>
|
||||
</form>
|
||||
<p class="hint" data-okf-file-count><%= systemFiles.length %> file<%= systemFiles.length === 1 ? "" : "s" %> shown. Generated files are rebuilt from repository metadata and are read-only here.</p>
|
||||
@ -248,7 +261,7 @@
|
||||
<thead><tr><th>File</th><th>Scope</th><th>Category</th><th>Status</th><th>Visibility</th></tr></thead>
|
||||
<tbody>
|
||||
<% systemFiles.forEach((file) => { %>
|
||||
<tr data-okf-file-row data-category="<%= file.category %>" data-scope="<%= file.scope %>" data-visibility="<%= file.visibility %>" data-search="<%= [file.title, file.id, file.slug, file.scope, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
|
||||
<tr data-okf-file-row data-category="<%= file.category %>" data-scope="<%= file.scope %>" data-visibility="<%= file.visibility %>" data-tags="|<%= (file.tags || []).join('|') %>|" data-search="<%= [file.title, file.id, file.slug, file.scope, file.category, ...(file.tags || [])].filter(Boolean).join(' ').toLowerCase() %>">
|
||||
<td><a href="/plugins/okf/admin?tab=system&system=<%= encodeURIComponent(file.slug) %>#okf-system-files"><strong><%= file.title %></strong></a><p class="hint"><%= file.id %> · <%= file.path %></p></td>
|
||||
<td><%= file.scope %></td>
|
||||
<td><%= file.category || "General" %></td>
|
||||
@ -277,6 +290,46 @@
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<% if (activeTab === "corrections" && okfAccess.canManagePermissions) { %>
|
||||
<section class="card" id="okf-corrections">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Feedback corrections</h2>
|
||||
<p class="hint">These files have retrieval priority over generated and community knowledge so reviewed fixes win when topics overlap.</p>
|
||||
</div>
|
||||
<a class="button subtle" href="/admin/feedback">Open feedback review</a>
|
||||
</div>
|
||||
<% if (!correctionFiles.length) { %>
|
||||
<div class="empty-state">No feedback correction files have been created yet.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Correction</th><th>Category</th><th>Visibility</th><th>Priority</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
<% correctionFiles.forEach((file) => { %>
|
||||
<tr>
|
||||
<td><a href="/plugins/okf/admin?tab=corrections&correction=<%= encodeURIComponent(file.slug) %>#okf-corrections"><strong><%= file.title %></strong></a><p class="hint"><%= file.path %></p></td>
|
||||
<td><%= file.category || "Correction" %></td>
|
||||
<td><%= file.visibility %></td>
|
||||
<td><%= file.priority %></td>
|
||||
<td><% if (String(file.frontmatter.source_feedback_url || '').startsWith('/admin/feedback')) { %><a href="<%= file.frontmatter.source_feedback_url %>">View feedback</a><% } else { %><span class="hint">Not linked</span><% } %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (selectedCorrection) { %>
|
||||
<details class="feedback-metadata" open>
|
||||
<summary><%= selectedCorrection.title %></summary>
|
||||
<p class="hint"><%= selectedCorrection.path %> · priority <%= selectedCorrection.priority %> · <%= selectedCorrection.visibility %></p>
|
||||
<% if (String(selectedCorrection.frontmatter.source_feedback_url || '').startsWith('/admin/feedback')) { %><p><a class="button subtle" href="<%= selectedCorrection.frontmatter.source_feedback_url %>">Open source feedback</a></p><% } %>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selectedCorrection.body || "_No correction body available._") %></div>
|
||||
</details>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<% if (selected) { %>
|
||||
<div class="modal-backdrop okf-edit-modal is-open" id="okf-editor" data-okf-edit-modal aria-hidden="false">
|
||||
<div class="modal okf-entry-modal-dialog okf-entry-fullscreen-modal" role="dialog" aria-modal="true" aria-labelledby="okf-edit-title">
|
||||
@ -309,29 +362,32 @@
|
||||
<span class="hint">Use comma-separated tags. Existing tags appear as suggestions while typing.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<label>Who can see this entry?</label>
|
||||
<select name="visibility">
|
||||
<% visibilityValues.forEach((value) => { %>
|
||||
<option value="<%= value %>" <%= selected && selected.visibility === value ? "selected" : "" %>><%= value %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint">Sets the minimum role that can find, view, or retrieve this entry.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<label>Publishing status</label>
|
||||
<select name="status" <%= okfAccess.canImplement ? "" : "disabled" %>>
|
||||
<% statuses.forEach((status) => { %>
|
||||
<option value="<%= status %>" <%= selected && selected.status === status ? "selected" : "" %>><%= status %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint">Drafts stay in the contributor workflow. Published entries can appear in Knowledge after approval.</span>
|
||||
<% if (!okfAccess.canImplement) { %><span class="hint">Editors can propose changes; publishing requires implement permission.</span><% } %>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Review state</label>
|
||||
<label>Review progress</label>
|
||||
<select name="review_state" <%= okfAccess.canImplement ? "" : "disabled" %>>
|
||||
<% reviewStates.forEach((state) => { %>
|
||||
<option value="<%= state %>" <%= selected && selected.review_state === state ? "selected" : "" %>><%= state %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint">Tracks editorial approval separately from whether an entry is published.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Aliases / related questions</label>
|
||||
@ -344,25 +400,34 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>User-facing Markdown answer</label>
|
||||
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"><%= selected ? selected.user_markdown : '' %></textarea>
|
||||
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user" data-okf-markdown-input><%= selected ? selected.user_markdown : '' %></textarea>
|
||||
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview user answer</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
|
||||
</div>
|
||||
<% if (okfAccess.capabilities.read_mod_fields) { %>
|
||||
<div class="field full">
|
||||
<label>Moderator/support Markdown details</label>
|
||||
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"><%= selected ? selected.moderator_markdown : '' %></textarea>
|
||||
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod" data-okf-markdown-input><%= selected ? selected.moderator_markdown : '' %></textarea>
|
||||
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview moderator details</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (okfAccess.capabilities.read_admin_fields) { %>
|
||||
<div class="field full">
|
||||
<label>Admin/internal Markdown details</label>
|
||||
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"><%= selected ? selected.admin_markdown : '' %></textarea>
|
||||
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin" data-okf-markdown-input><%= selected ? selected.admin_markdown : '' %></textarea>
|
||||
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview admin details</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>AI-facing facts/context</label>
|
||||
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"><%= selected ? selected.ai_facts_markdown : '' %></textarea>
|
||||
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span>
|
||||
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin" data-okf-markdown-input><%= selected ? selected.ai_facts_markdown : '' %></textarea>
|
||||
<span class="hint">Concise grounding facts for Lumi AI. These remain permission-filtered and are never sent to roles below this entry's visibility.</span>
|
||||
<details class="feedback-metadata" data-okf-markdown-preview-panel><summary>Preview AI facts</summary><p class="hint" data-okf-markdown-preview-status></p><div class="feedback-copy-block" data-okf-markdown-preview></div></details>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Source links / references</label>
|
||||
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"><%= selected ? selected.source_links.join('\n') : '' %></textarea>
|
||||
<span class="hint">Add one supporting web link, in-app path, or section link per line. Unsafe URL schemes are ignored.</span>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="field full">
|
||||
<label>Change note</label>
|
||||
<input name="change_note" placeholder="Optional note for version history" />
|
||||
@ -377,6 +442,7 @@
|
||||
<details class="feedback-metadata">
|
||||
<summary>Role preview</summary>
|
||||
<div class="stats-grid">
|
||||
<% if (okfAccess.capabilities.read_mod_fields) { %>
|
||||
<article class="stat-card">
|
||||
<span class="stat-label">User view</span>
|
||||
<strong><%= selected.title %></strong>
|
||||
@ -390,14 +456,17 @@
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div>
|
||||
</article>
|
||||
<% } %>
|
||||
<% if (okfAccess.capabilities.read_admin_fields) { %>
|
||||
<article class="stat-card">
|
||||
<span class="stat-label">Admin/editor view</span>
|
||||
<span class="stat-label">Admin view</span>
|
||||
<strong><%= selected.title %></strong>
|
||||
<p><%= selected.summary || "No summary provided." %></p>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div>
|
||||
<div class="feedback-copy-block"><%- renderMarkdown(selected.admin_markdown || "_No admin/internal details yet._") %></div>
|
||||
</article>
|
||||
<% } %>
|
||||
</div>
|
||||
<p class="hint">AI facts are stored separately for future role-aware retrieval and are not shown in normal user/mod previews.</p>
|
||||
</details>
|
||||
@ -412,13 +481,13 @@
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/publish" class="inline-form">
|
||||
<button class="button subtle" type="submit">Publish</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/archive" class="inline-form">
|
||||
<button class="button subtle" type="submit">Archive</button>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive OKF entry" data-confirm-text="Archive this OKF entry?" data-confirm-label="Archive entry">
|
||||
<button class="button danger" type="submit">Archive</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/restore" class="inline-form">
|
||||
<button class="button subtle" type="submit">Restore as draft</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-text="Delete this OKF entry? The version history is kept for audit, but the entry will no longer be visible.">
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete OKF entry" data-confirm-text="Delete this OKF entry? The version history is kept for audit, but the entry will no longer be visible." data-confirm-label="Delete entry">
|
||||
<button class="button danger" type="submit">Delete</button>
|
||||
</form>
|
||||
<% } %>
|
||||
@ -432,7 +501,7 @@
|
||||
<% versions.forEach((version) => { %>
|
||||
<article class="feedback-copy-block">
|
||||
<strong>Version <%= version.version_number %> · <%= version.change_type %></strong>
|
||||
<p class="hint"><%= new Date(version.created_at).toLocaleString() %> · <%= version.changed_by || "system" %> · <%= version.note || "No note." %></p>
|
||||
<p class="hint"><%= new Date(version.created_at).toLocaleString() %> · <%= version.changed_by || "Contributor" %> · <%= version.note || "No note." %></p>
|
||||
<details>
|
||||
<summary>Snapshot</summary>
|
||||
<pre><%= JSON.stringify(version.next, null, 2) %></pre>
|
||||
@ -516,10 +585,13 @@
|
||||
<label>User-facing Markdown answer</label>
|
||||
<textarea name="user_markdown" rows="8" data-placeholder-field="okf.markdown" data-placeholder-output-audience="user"></textarea>
|
||||
</div>
|
||||
<% if (okfAccess.capabilities.read_mod_fields) { %>
|
||||
<div class="field full">
|
||||
<label>Moderator/support Markdown details</label>
|
||||
<textarea name="moderator_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="mod"></textarea>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (okfAccess.capabilities.read_admin_fields) { %>
|
||||
<div class="field full">
|
||||
<label>Admin/internal Markdown details</label>
|
||||
<textarea name="admin_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea>
|
||||
@ -527,12 +599,13 @@
|
||||
<div class="field full">
|
||||
<label>AI-facing facts/context</label>
|
||||
<textarea name="ai_facts_markdown" rows="6" data-placeholder-field="okf.markdown" data-placeholder-output-audience="admin"></textarea>
|
||||
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span>
|
||||
<span class="hint">Used only for admin-visible AI context.</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Source links / references</label>
|
||||
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"></textarea>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="field full">
|
||||
<label>Change note</label>
|
||||
<input name="change_note" placeholder="Optional note for version history" />
|
||||
@ -600,8 +673,8 @@
|
||||
<td><%= grant.revoked_at ? `Revoked ${new Date(grant.revoked_at).toLocaleString()}` : "Active" %></td>
|
||||
<td>
|
||||
<% if (!grant.revoked_at) { %>
|
||||
<form method="post" action="/plugins/okf/admin/permissions/<%= grant.id %>/revoke" class="inline-form">
|
||||
<button class="button subtle" type="submit">Revoke</button>
|
||||
<form method="post" action="/plugins/okf/admin/permissions/<%= grant.id %>/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Revoke OKF permission" data-confirm-text="Revoke this permission grant?" data-confirm-label="Revoke permission">
|
||||
<button class="button danger" type="submit">Revoke</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
@ -626,7 +699,13 @@
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const matchesSearch = !query || String(row.dataset.search || "").includes(query);
|
||||
const matchesFilters = filters.every((field) => !field.value || row.dataset[field.dataset.okfFileFilter] === field.value);
|
||||
const matchesFilters = filters.every((field) => {
|
||||
if (!field.value) return true;
|
||||
const rowValue = String(row.dataset[field.dataset.okfFileFilter] || "");
|
||||
return field.dataset.okfFileFilter === "tags"
|
||||
? rowValue.includes(`|${field.value}|`)
|
||||
: rowValue === field.value;
|
||||
});
|
||||
row.hidden = !(matchesSearch && matchesFilters);
|
||||
if (!row.hidden) visible += 1;
|
||||
});
|
||||
@ -639,6 +718,45 @@
|
||||
form?.addEventListener("reset", () => window.setTimeout(applyFilters, 0));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-okf-markdown-preview-panel]").forEach((panel) => {
|
||||
const field = panel.closest(".field")?.querySelector("[data-okf-markdown-input]");
|
||||
const output = panel.querySelector("[data-okf-markdown-preview]");
|
||||
const status = panel.querySelector("[data-okf-markdown-preview-status]");
|
||||
if (!field || !output) return;
|
||||
let timer = null;
|
||||
let request = 0;
|
||||
const update = async () => {
|
||||
const current = ++request;
|
||||
if (status) status.textContent = "Rendering preview...";
|
||||
try {
|
||||
const response = await fetch("/plugins/okf/admin/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ markdown: field.value || "" })
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "Preview unavailable.");
|
||||
if (current !== request) return;
|
||||
output.innerHTML = payload.html;
|
||||
if (status) status.textContent = "Safe preview of the current unsaved text.";
|
||||
} catch (error) {
|
||||
if (current !== request) return;
|
||||
output.textContent = "";
|
||||
if (status) status.textContent = error.message;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(update, 180);
|
||||
};
|
||||
panel.addEventListener("toggle", () => {
|
||||
if (panel.open) update();
|
||||
});
|
||||
field.addEventListener("input", () => {
|
||||
if (panel.open) schedule();
|
||||
});
|
||||
});
|
||||
|
||||
(() => {
|
||||
const modal = document.querySelector("[data-okf-create-modal]");
|
||||
const openButtons = document.querySelectorAll("[data-okf-create-open]");
|
||||
|
||||
@ -183,7 +183,7 @@
|
||||
<button type="submit" class="button">Restore</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/archive" class="inline-form">
|
||||
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive quote" data-confirm-text="Archive this quote?" data-confirm-label="Archive quote">
|
||||
<button type="submit" class="button danger">Archive</button>
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
@ -90,8 +90,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-row">
|
||||
<button type="button" class="button subtle" data-confirm-open data-confirm-title="Renew endpoint?" data-confirm-text="The current Throne URL will stop working immediately." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/renew">Renew</button>
|
||||
<button type="button" class="button subtle danger" data-confirm-open data-confirm-title="Remove endpoint?" data-confirm-text="This endpoint will stop accepting Throne payloads." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/remove">Remove</button>
|
||||
<button type="button" class="button subtle" data-confirm-mode="modal" data-confirm-title="Renew endpoint?" data-confirm-text="The current Throne URL will stop working immediately." data-confirm-label="Renew endpoint" data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/renew">Renew</button>
|
||||
<button type="button" class="button subtle danger" data-confirm-mode="modal" data-confirm-title="Remove endpoint?" data-confirm-text="This endpoint will stop accepting Throne payloads." data-confirm-label="Remove endpoint" data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/remove">Remove</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div class="field full">
|
||||
<% if (!message.archived) { %>
|
||||
<button type="submit" class="button">Save</button>
|
||||
<button type="submit" class="button">Save message</button>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
@ -60,7 +60,7 @@
|
||||
<button type="submit" class="button">Restore</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/archive" class="inline-form">
|
||||
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/archive" class="inline-form" data-confirm-mode="modal" data-confirm-title="Archive welcome message" data-confirm-text="Archive this welcome message?" data-confirm-label="Archive message">
|
||||
<input type="hidden" name="pool" value="<%= pool %>" />
|
||||
<button type="submit" class="button danger">Archive</button>
|
||||
</form>
|
||||
|
||||
82
scripts/benchmark-okf-search.js
Normal file
82
scripts/benchmark-okf-search.js
Normal file
@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { performance } = require("perf_hooks");
|
||||
const { ensureKnowledgeDirs, searchFileKnowledge } = require("../plugins/okf/backend/file_knowledge");
|
||||
|
||||
const fixtureCount = Math.max(100, Math.min(Number(process.argv[2]) || 1000, 10000));
|
||||
const queryCount = 120;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-benchmark-"));
|
||||
const user = { id: "benchmark-user" };
|
||||
const admin = { id: "benchmark-admin", isAdmin: true, isMod: true };
|
||||
|
||||
try {
|
||||
ensureKnowledgeDirs(root);
|
||||
const community = path.join(root, "knowledge", "community");
|
||||
for (let index = 0; index < fixtureCount; index += 1) {
|
||||
const visibility = index % 20 === 0 ? "admin" : "user";
|
||||
const secret = visibility === "admin" ? "sealedterm " : "";
|
||||
fs.writeFileSync(path.join(community, `fixture-${index}.md`), [
|
||||
"---",
|
||||
`id: benchmark.fixture-${index}`,
|
||||
`title: Benchmark topic ${index}`,
|
||||
"scope: community",
|
||||
"status: active",
|
||||
`visibility: ${visibility}`,
|
||||
`priority: ${index % 7}`,
|
||||
`tags: benchmark, topic-${index % 50}`,
|
||||
"---",
|
||||
`# Topic ${index}`,
|
||||
`${secret}Support guidance for fixture ${index}, group ${index % 50}, and setting ${index % 17}.`,
|
||||
"This paragraph adds enough ordinary text to approximate a small knowledge entry."
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
const coldStart = performance.now();
|
||||
const coldResults = searchFileKnowledge({ query: "topic 42 setting", user, rootDir: root, limit: 5 });
|
||||
const coldMs = performance.now() - coldStart;
|
||||
assert(coldResults.length > 0);
|
||||
|
||||
const timings = [];
|
||||
for (let index = 0; index < queryCount; index += 1) {
|
||||
const started = performance.now();
|
||||
const results = searchFileKnowledge({
|
||||
query: `topic ${index % 50} setting ${index % 17}`,
|
||||
user,
|
||||
rootDir: root,
|
||||
limit: 5
|
||||
});
|
||||
timings.push(performance.now() - started);
|
||||
assert(results.length > 0);
|
||||
}
|
||||
|
||||
assert.equal(searchFileKnowledge({ query: "sealedterm", user, rootDir: root, limit: 25 }).length, 0);
|
||||
assert(searchFileKnowledge({ query: "sealedterm", user: admin, rootDir: root, limit: 25 }).length > 0);
|
||||
|
||||
const changedPath = path.join(community, "fixture-1.md");
|
||||
fs.appendFileSync(changedPath, "\nuniquelychangedterm\n");
|
||||
assert(searchFileKnowledge({ query: "uniquelychangedterm", user, rootDir: root, limit: 5 }).length > 0);
|
||||
fs.rmSync(changedPath);
|
||||
assert.equal(searchFileKnowledge({ query: "uniquelychangedterm", user, rootDir: root, limit: 5 }).length, 0);
|
||||
|
||||
timings.sort((a, b) => a - b);
|
||||
const percentile = (fraction) => timings[Math.min(timings.length - 1, Math.floor(timings.length * fraction))];
|
||||
const result = {
|
||||
fixtures: fixtureCount,
|
||||
queries: queryCount,
|
||||
cold_ms: round(coldMs),
|
||||
warm_median_ms: round(percentile(0.5)),
|
||||
warm_p95_ms: round(percentile(0.95)),
|
||||
warm_max_ms: round(timings[timings.length - 1])
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
56
scripts/verify-all.js
Normal file
56
scripts/verify-all.js
Normal file
@ -0,0 +1,56 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const checks = [
|
||||
"scripts/verify-preflight.js",
|
||||
"scripts/verify-webui.js",
|
||||
"scripts/verify-web-auth.js",
|
||||
"scripts/verify-feedback-system.js",
|
||||
"scripts/verify-placeholders.js",
|
||||
"scripts/verify-plugin-update-preserves-data.js",
|
||||
"scripts/verify-safe-files.js",
|
||||
"scripts/verify-upload-security.js",
|
||||
"plugins/okf/tests/verify.js",
|
||||
"plugins/lumi_ai/tests/verify.js",
|
||||
"plugins/lumi_ai/tests/verify-tools.js",
|
||||
"plugins/lumi_ai_web_search/tests/verify.js",
|
||||
"scripts/verify-assistant-panels.js",
|
||||
"scripts/verify-command-preview-confirmations.js",
|
||||
"scripts/verify-destructive-actions.js",
|
||||
"scripts/verify-overlays.js",
|
||||
"scripts/verify-webhooks.js"
|
||||
];
|
||||
|
||||
function listJavaScript(directory, output = []) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (["node_modules", ".git"].includes(entry.name) || entry.name.startsWith(".tmp-")) continue;
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) listJavaScript(target, output);
|
||||
else if (entry.name.endsWith(".js")) output.push(target);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function run(label, args) {
|
||||
console.log(`\n[verify] ${label}`);
|
||||
const result = spawnSync(process.execPath, args, { cwd: root, stdio: "inherit" });
|
||||
if (result.error) {
|
||||
console.error(`[verify] Unable to start ${label}: ${result.error.message}`);
|
||||
process.exit(result.status || 1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`[verify] ${label} failed with exit code ${result.status}.`);
|
||||
process.exit(result.status || 1);
|
||||
}
|
||||
}
|
||||
|
||||
run("native dependency preflight", [path.join(root, "scripts", "verify-preflight.js")]);
|
||||
const javaScriptFiles = ["src", "plugins", "scripts"]
|
||||
.flatMap((directory) => listJavaScript(path.join(root, directory)));
|
||||
for (const file of javaScriptFiles) {
|
||||
run(`syntax: ${path.relative(root, file)}`, ["--check", file]);
|
||||
}
|
||||
for (const check of checks.slice(1)) run(check, [path.join(root, check)]);
|
||||
console.log(`\nAll Lumi verification passed (${javaScriptFiles.length} JavaScript files, ${checks.length - 1} focused suites).`);
|
||||
@ -7,6 +7,12 @@ const {
|
||||
normalizePreviewText,
|
||||
previewParts
|
||||
} = require("../src/services/command-preview");
|
||||
const { runAdvancedCommand } = require("../src/services/commands");
|
||||
const {
|
||||
conditionMatches,
|
||||
selectRandomReply,
|
||||
validateRandomConfig
|
||||
} = require("../src/services/command-random");
|
||||
const {
|
||||
DELAY_MS,
|
||||
issueConfirmation,
|
||||
@ -23,7 +29,11 @@ async function run() {
|
||||
"preview_status",
|
||||
"preview_error",
|
||||
"preview_generated_at",
|
||||
"preview_dynamic_segments"
|
||||
"preview_dynamic_segments",
|
||||
"random_replies_json",
|
||||
"rng_enabled",
|
||||
"rng_min",
|
||||
"rng_max"
|
||||
]) {
|
||||
assert(columns.includes(column), `Missing custom command preview column: ${column}`);
|
||||
}
|
||||
@ -47,6 +57,78 @@ async function run() {
|
||||
assert(previewParts(preview.preview_text, segments).some((part) => part.dynamic));
|
||||
assert.equal(normalizePreviewText("Some User and some-user"), "some_user and some_user");
|
||||
assert(findDynamicSegments("some_user 42 user_123 2026-01-02T12:34:56.000Z").length, 4);
|
||||
const punctuationPreview = "Fun fact for some_user:\nExample";
|
||||
assert.equal(
|
||||
previewParts(punctuationPreview, findDynamicSegments(punctuationPreview)).map((part) => part.text).join(""),
|
||||
punctuationPreview
|
||||
);
|
||||
|
||||
const snippetPreview = await generateCommandPreview({
|
||||
language: "js",
|
||||
code: 'return "Snippet for " + ctx.user.username;'
|
||||
});
|
||||
assert.equal(snippetPreview.preview_status, "ready");
|
||||
assert.equal(snippetPreview.preview_text, "Snippet for some_user");
|
||||
|
||||
const exportedPreview = await generateCommandPreview({
|
||||
language: "js",
|
||||
code: 'module.exports = (ctx) => "Export for " + ctx.user.username;'
|
||||
});
|
||||
assert.equal(exportedPreview.preview_status, "ready");
|
||||
assert.equal(exportedPreview.preview_text, "Export for some_user");
|
||||
|
||||
const safeBuiltInsPreview = await generateCommandPreview({
|
||||
language: "js",
|
||||
code: `
|
||||
const facts = ["using a process called respiration"];
|
||||
const selected = facts[Math.floor(Math.random() * facts.length)];
|
||||
const url = new URL("https://example.com/facts?count=1");
|
||||
return JSON.stringify({ selected, host: url.hostname, count: url.searchParams.get("count") });
|
||||
`
|
||||
});
|
||||
assert.equal(safeBuiltInsPreview.preview_status, "ready");
|
||||
assert.deepEqual(JSON.parse(safeBuiltInsPreview.preview_text), {
|
||||
selected: "using a process called respiration",
|
||||
host: "example.com",
|
||||
count: "1"
|
||||
});
|
||||
|
||||
for (const code of [
|
||||
'return Math.constructor.constructor("return pro" + "cess")().cwd();',
|
||||
'return ctx.constructor.constructor("return pro" + "cess")().cwd();',
|
||||
'return URL.constructor.constructor("return pro" + "cess")().cwd();'
|
||||
]) {
|
||||
const constructorEscape = await generateCommandPreview({ language: "js", code });
|
||||
assert.equal(constructorEscape.preview_status, "unavailable");
|
||||
assert.match(constructorEscape.preview_error, /code generation from strings disallowed/i);
|
||||
}
|
||||
|
||||
const pythonSnippetPreview = await generateCommandPreview({
|
||||
language: "python",
|
||||
code: 'return "Python snippet"'
|
||||
});
|
||||
assert.equal(pythonSnippetPreview.preview_status, "ready");
|
||||
assert.equal(pythonSnippetPreview.preview_text, "Python snippet");
|
||||
|
||||
assert.equal(await runAdvancedCommand({ language: "js", code: 'return "wrapped";' }, {}), "wrapped");
|
||||
|
||||
const randomConfig = {
|
||||
replies: [
|
||||
{ text: "Low {{core.command.rng}}", weight: 1, condition: "<20" },
|
||||
{ text: "High", weight: 2, condition: ">=20" }
|
||||
],
|
||||
rngEnabled: true,
|
||||
rngMin: 1,
|
||||
rngMax: 100
|
||||
};
|
||||
const rolls = [0.1, 0.9];
|
||||
const selectedRandom = selectRandomReply(randomConfig, () => rolls.shift());
|
||||
assert.equal(selectedRandom.rng, 11);
|
||||
assert.equal(selectedRandom.reply.text, "Low {{core.command.rng}}");
|
||||
assert.equal(conditionMatches(">=10", 10), true);
|
||||
assert.equal(conditionMatches("<20", 20), false);
|
||||
assert.equal(validateRandomConfig({ replies: [{ text: "Hi", weight: 1000 }], rngMin: 1, rngMax: 100 }).ok, false);
|
||||
assert.equal(validateRandomConfig({ replies: [{ text: "Hi", weight: 1, condition: "maybe" }], rngEnabled: true }).ok, false);
|
||||
|
||||
const noSideEffects = await generateCommandPreview({
|
||||
language: "js",
|
||||
@ -77,6 +159,18 @@ async function run() {
|
||||
assert.equal(readOnlyFetch.preview_status, "ready");
|
||||
assert.equal(readOnlyFetch.preview_text, "preview");
|
||||
|
||||
const blockedNetworkFetch = await generateCommandPreview({
|
||||
language: "js",
|
||||
code: `
|
||||
async function run() {
|
||||
await fetch("http://127.0.0.1:3000/admin/settings");
|
||||
return "no";
|
||||
}
|
||||
`
|
||||
});
|
||||
assert.equal(blockedNetworkFetch.preview_status, "unavailable");
|
||||
assert.match(blockedNetworkFetch.preview_error, /does not send external network requests/i);
|
||||
|
||||
const blockedWriteFetch = await generateCommandPreview({
|
||||
language: "js",
|
||||
code: `
|
||||
@ -142,8 +236,16 @@ async function run() {
|
||||
assert(layout.includes("data-destructive-confirm disabled"));
|
||||
assert(commandView.includes("Preview unavailable"));
|
||||
assert(commandView.includes("preview-dynamic"));
|
||||
assert(commandView.includes('data-collapsed-label="Read more"'));
|
||||
assert(commandView.includes('data-expanded-label="Read less"'));
|
||||
assert.equal(commandView.includes("Full preview"), false);
|
||||
assert.equal(commandView.includes("command-preview-full"), false);
|
||||
assert(appScript.includes('[data-expand-text]'));
|
||||
assert(commandView.includes(">Static<"));
|
||||
assert(commandView.includes(">Random Reply<"));
|
||||
assert(commandView.includes(">Dynamic<"));
|
||||
assert(commandView.includes("random_weight"));
|
||||
assert(commandView.includes("{{core.command.rng}}"));
|
||||
assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false);
|
||||
|
||||
console.log("Command preview and destructive confirmation verification passed.");
|
||||
|
||||
103
scripts/verify-destructive-actions.js
Normal file
103
scripts/verify-destructive-actions.js
Normal file
@ -0,0 +1,103 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const {
|
||||
issueConfirmation,
|
||||
consumeConfirmation,
|
||||
isDestructivePath
|
||||
} = require("../src/services/destructive-confirm");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const destructivePath = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset|revert)(?:\/|$)/i;
|
||||
|
||||
function filesBelow(directory, extension, output = []) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) filesBelow(target, extension, output);
|
||||
else if (target.endsWith(extension)) output.push(target);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function maskEjs(source) {
|
||||
return source.replace(/<%[\s\S]*?%>/g, "EJS");
|
||||
}
|
||||
|
||||
function attribute(tag, name) {
|
||||
return tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"))?.[1] || "";
|
||||
}
|
||||
|
||||
function requireConfirmationCopy(tag, file) {
|
||||
for (const attributeName of ["data-confirm-mode", "data-confirm-title", "data-confirm-text", "data-confirm-label"]) {
|
||||
assert(new RegExp(`\\b${attributeName}(?:\\s*=|\\s|>)`, "i").test(tag), `${path.relative(root, file)} has an unconnected destructive control missing ${attributeName}: ${tag}`);
|
||||
}
|
||||
assert.equal(attribute(tag, "data-confirm-mode"), "modal", `${path.relative(root, file)} must use the shared timed modal`);
|
||||
}
|
||||
|
||||
function verifyViews() {
|
||||
const files = [
|
||||
...filesBelow(path.join(root, "src", "web", "views"), ".ejs"),
|
||||
...filesBelow(path.join(root, "plugins"), ".ejs")
|
||||
];
|
||||
let controls = 0;
|
||||
for (const file of files) {
|
||||
const source = maskEjs(fs.readFileSync(file, "utf8"));
|
||||
assert(!/name\s*=\s*["']action["'][^>]*value\s*=\s*["']delete["']/i.test(source), `${path.relative(root, file)} hides a delete behind a generic POST route; use an explicit /delete endpoint`);
|
||||
for (const tag of source.match(/<(?:form|button)\b[^>]*>/gi) || []) {
|
||||
const action = attribute(tag, "action") || attribute(tag, "formaction");
|
||||
const confirmAction = attribute(tag, "data-confirm-action");
|
||||
if (!destructivePath.test(action) && !destructivePath.test(confirmAction)) continue;
|
||||
requireConfirmationCopy(tag, file);
|
||||
controls += 1;
|
||||
}
|
||||
}
|
||||
assert(controls >= 30, `Expected broad destructive-control coverage, found only ${controls}`);
|
||||
return controls;
|
||||
}
|
||||
|
||||
function verifySharedInfrastructure() {
|
||||
for (const action of [
|
||||
"/admin/commands/1/delete",
|
||||
"/admin/navigation/reset",
|
||||
"/admin/updates/core/revert",
|
||||
"/plugins/auto-vc/settings/remove"
|
||||
]) {
|
||||
assert.equal(isDestructivePath(action), true, `Server does not protect ${action}`);
|
||||
}
|
||||
|
||||
const req = {
|
||||
session: { user: { id: "admin-user", isAdmin: true } },
|
||||
body: {},
|
||||
get() { return null; }
|
||||
};
|
||||
const issued = issueConfirmation(req, "/admin/updates/core/revert");
|
||||
req.session.destructive_confirmations[issued.token].not_before = Date.now() - 1;
|
||||
assert.equal(consumeConfirmation(req, "/admin/updates/core/revert", issued.token).valid, true);
|
||||
|
||||
const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
|
||||
assert(appScript.includes("submitter?.dataset?.confirmMode"), "Submit-button confirmation metadata is ignored");
|
||||
assert(appScript.includes("submitter?.dataset?.confirmLabel || form.dataset.confirmLabel"), "The clicked submit button cannot override generic confirmation copy");
|
||||
assert(appScript.includes('form.querySelector(\'input[name="confirmation_token"]\')'), "Timed form confirmation tokens are not attached to submitted forms");
|
||||
assert(appScript.includes("const response = await fetch(action, requestOptions)"), "Async update requests do not submit through the shared confirmed form payload");
|
||||
assert(!appScript.includes("window.LumiConfirm.destructiveFetch(form.action"), "Timed update forms request a second confirmation instead of reusing the form token");
|
||||
assert(appScript.includes('state.confirmButton.removeEventListener("click", state.onConfirm)'), "Cancelled or expired confirmations leave stale modal submissions behind");
|
||||
assert(appScript.includes("state.onConfirm = onConfirm"), "Timed form confirmations do not retain their listener for cleanup");
|
||||
|
||||
const toolManagerScript = fs.readFileSync(path.join(root, "plugins", "lumi_ai", "public", "tool-manager.js"), "utf8");
|
||||
for (const field of ["confirmMode", "confirmTitle", "confirmText", "confirmLabel"]) {
|
||||
assert(toolManagerScript.includes(`form.dataset.${field}`), `Dynamic AI tool deletion is missing ${field}`);
|
||||
}
|
||||
|
||||
const autoVcView = fs.readFileSync(path.join(root, "plugins", "auto-vc", "views", "auto-vc.ejs"), "utf8");
|
||||
assert(autoVcView.includes('settingsForm.action = "/plugins/auto-vc/settings/remove"'), "Auto VC lobby deletion does not switch to its protected route");
|
||||
|
||||
const improvementView = fs.readFileSync(path.join(root, "plugins", "lumi_ai", "views", "improvement-center.ejs"), "utf8");
|
||||
assert(improvementView.includes("/reviews/<%= review.id %>/delete"));
|
||||
assert(improvementView.includes("/corrections/<%= entry.id %>/delete"));
|
||||
const improvementScript = fs.readFileSync(path.join(root, "plugins", "lumi_ai", "public", "improvement-center.js"), "utf8");
|
||||
assert.equal(improvementScript.includes("window.confirm"), false, "Improvement Center still uses the legacy browser confirmation");
|
||||
}
|
||||
|
||||
const controls = verifyViews();
|
||||
verifySharedInfrastructure();
|
||||
console.log(`Destructive action verification passed (${controls} controls).`);
|
||||
@ -42,6 +42,7 @@ try {
|
||||
current_url: "http://localhost/admin/settings",
|
||||
page_title: "Settings",
|
||||
target_metadata: {
|
||||
path: "/admin/settings; Settings; Save settings",
|
||||
selector: "#save-settings",
|
||||
tag: "button",
|
||||
text: "Save settings",
|
||||
@ -64,12 +65,15 @@ try {
|
||||
});
|
||||
|
||||
assert.equal(entry.status, "new");
|
||||
assert.equal(entry.scope_label, "Clicked element: Save settings");
|
||||
assert.equal(entry.scope_label, "/admin/settings;Settings;Save settings");
|
||||
assert.equal(entry.target_metadata.secret, undefined);
|
||||
assert.equal(entry.diagnostics.hidden, undefined);
|
||||
assert.equal(entry.diagnostics.similar_feedback_confirmation, "distinct_or_additional_context");
|
||||
assert.equal(entry.diagnostics.similar_feedback_ids, "12345678-abcd");
|
||||
assert.equal(entry.screenshot.mime, "image/png");
|
||||
assert.equal(feedback.listFeedbackForAdmin({ route: "/admin/settings" })[0].id, entry.id);
|
||||
assert.equal(feedback.listFeedbackForAdmin({ target: "save-settings" })[0].id, entry.id);
|
||||
assert.equal(feedback.listFeedbackForAdmin({ area: "#save-settings" })[0].id, entry.id);
|
||||
assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true);
|
||||
assert.equal(feedback.listMyFeedback("user-1").length, 1);
|
||||
assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1);
|
||||
@ -124,6 +128,31 @@ try {
|
||||
scope_type: "element",
|
||||
current_url: "http://localhost/admin/settings"
|
||||
}, { userId: "user-2" }).some((match) => match.id === entry.id));
|
||||
|
||||
const duplicate = feedback.createFeedback({
|
||||
summary: "Settings save still fails",
|
||||
category: "bug",
|
||||
severity: "broken",
|
||||
scope_type: "element",
|
||||
scope_label: "Save settings button",
|
||||
description: "The same settings save control reloads without applying my change.",
|
||||
expected_behavior: "The setting should persist.",
|
||||
current_url: "http://localhost/admin/settings"
|
||||
}, { id: "user-2" });
|
||||
feedback.supportFeedback(duplicate.id, { id: "user-1" });
|
||||
const merged = feedback.mergeFeedback(duplicate.id, entry.id, {
|
||||
reason: "Both reports describe the same settings save failure."
|
||||
}, { id: "admin-1" });
|
||||
assert.equal(merged.source.status, "duplicate");
|
||||
assert.equal(merged.source.merged_into.id, entry.id);
|
||||
assert.equal(merged.target.merged_sources[0].id, duplicate.id);
|
||||
assert.equal(merged.target.reporter_count, 2);
|
||||
assert.equal(merged.target.support_count, 2);
|
||||
assert.equal(feedback.getFeedbackForViewer(duplicate.id, "user-2").merged_into.summary, entry.summary);
|
||||
assert.equal(feedback.getFeedbackForViewer(entry.id, "user-1").merged_sources[0].submitter_id, undefined);
|
||||
feedback.unmergeFeedback(duplicate.id, { reason: "Verification undo." }, { id: "admin-1" });
|
||||
assert.equal(feedback.getFeedbackForAdmin(duplicate.id).status, "reviewed");
|
||||
feedback.mergeFeedback(duplicate.id, entry.id, {}, { id: "admin-1" });
|
||||
assert(feedback.findSimilarFeedback({
|
||||
summary: "Controls are weird",
|
||||
description: "Clicking the save button does not apply changes after editing settings.",
|
||||
@ -246,6 +275,7 @@ try {
|
||||
userInitial: "A",
|
||||
title: "Feedback review",
|
||||
feedbackItems: feedback.listFeedbackForAdmin({}),
|
||||
feedbackExportAiAvailable: false,
|
||||
filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" }
|
||||
}, { filename: adminView });
|
||||
assert(adminRendered.includes("Feedback queue"));
|
||||
|
||||
221
scripts/verify-overlays.js
Normal file
221
scripts/verify-overlays.js
Normal file
@ -0,0 +1,221 @@
|
||||
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-overlays-"));
|
||||
const serviceDir = path.join(sandbox, "src", "services");
|
||||
fs.mkdirSync(serviceDir, { recursive: true });
|
||||
|
||||
for (const file of [
|
||||
"config.js",
|
||||
"db.js",
|
||||
"settings.js",
|
||||
"web-events.js",
|
||||
"overlay-secrets.js",
|
||||
"overlay-modules.js",
|
||||
"overlays.js",
|
||||
"overlay-permissions.js",
|
||||
"overlay-connectors.js"
|
||||
]) {
|
||||
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
||||
}
|
||||
|
||||
let database = null;
|
||||
try {
|
||||
database = require(path.join(serviceDir, "db.js"));
|
||||
database.migrate();
|
||||
const settings = require(path.join(serviceDir, "settings.js"));
|
||||
settings.ensureDefaults();
|
||||
settings.setSetting("session_secret", "overlay-verification-session-secret");
|
||||
const overlays = require(path.join(serviceDir, "overlays.js"));
|
||||
const overlayModules = require(path.join(serviceDir, "overlay-modules.js"));
|
||||
const permissions = require(path.join(serviceDir, "overlay-permissions.js"));
|
||||
const connectors = require(path.join(serviceDir, "overlay-connectors.js"));
|
||||
const providerList = connectors.listOverlayConnectorProviders();
|
||||
assert(providerList.some((provider) => provider.id === "local_obs_websocket" && provider.available !== false));
|
||||
assert(providerList.some((provider) => provider.id === "obs_browser_bridge" && provider.available !== false));
|
||||
assert(providerList.some((provider) => provider.id === "trusted_lumi_client" && provider.available === false));
|
||||
const bridge = new connectors.ObsBrowserBridgeConnector({ overlay_id: "bridge-test", sync_direction: "obs_to_overlay" });
|
||||
const bridgeReport = bridge.acceptReport({
|
||||
instance_id: "obs-browser-test",
|
||||
plugin_version: "2.24.0",
|
||||
control_level: 2,
|
||||
current_scene: "Starting Soon",
|
||||
scenes: ["Starting Soon", "Live"],
|
||||
transitions: ["Cut", "Fade"],
|
||||
current_transition: "Fade",
|
||||
canvas_width: 1920,
|
||||
canvas_height: 1080,
|
||||
outputs: { streaming: true },
|
||||
source_active: true,
|
||||
source_visible: true
|
||||
});
|
||||
assert.strictEqual(bridgeReport.accepted, true);
|
||||
assert.strictEqual(bridgeReport.current_scene, "Starting Soon");
|
||||
assert.deepStrictEqual(bridgeReport.scenes, ["Starting Soon", "Live"]);
|
||||
assert.strictEqual(bridgeReport.control_level_label, "Read scenes and transitions");
|
||||
assert.strictEqual(bridgeReport.profiles_supported, false);
|
||||
bridge.disconnect();
|
||||
const sourceTypes = overlayModules.listOverlayModuleTypes();
|
||||
assert(sourceTypes.some((type) => type.id === "video"));
|
||||
assert(sourceTypes.some((type) => type.id === "audio"));
|
||||
|
||||
const created = overlays.createOverlay({ name: "Main Stream", sceneName: "Live" });
|
||||
assert.strictEqual(created.scenes.length, 1, "new overlays must receive a first scene");
|
||||
assert.strictEqual(created.canvas_width, 1920);
|
||||
assert.strictEqual(created.canvas_height, 1080);
|
||||
assert(created.public_token.length >= 40, "overlay token must be high entropy");
|
||||
assert(created.scenes[0].public_token.length >= 40, "scene token must be high entropy");
|
||||
|
||||
const resolved = overlays.resolvePublicOverlay(created.public_token);
|
||||
assert.strictEqual(resolved.overlay.id, created.id);
|
||||
const fixed = overlays.resolvePublicOverlay(created.public_token, created.scenes[0].public_token);
|
||||
assert.strictEqual(fixed.fixedScene.id, created.scenes[0].id);
|
||||
|
||||
const headlineId = overlays.addModule(created.scenes[0].id, {
|
||||
name: "Headline",
|
||||
type: "text",
|
||||
enabled: true,
|
||||
config: { text: "Starting soon", x: 5, y: 10, width: 90, height: 20 }
|
||||
});
|
||||
const state = overlays.buildPublicState(created.id);
|
||||
assert.strictEqual(state.scene.modules.length, 1);
|
||||
assert.strictEqual(state.scene.modules[0].config.text, "Starting soon");
|
||||
overlays.updateModuleTransform(headlineId, { x: 50, y: 50, width: 40, height: 15, anchor: "center" });
|
||||
const transformed = overlays.getOverlay(created.id).scenes[0].modules[0];
|
||||
assert.strictEqual(transformed.config.anchor, "center");
|
||||
assert.strictEqual(transformed.config.text, "Starting soon", "canvas transforms must preserve source content");
|
||||
|
||||
const textConfig = overlayModules.normalizeModuleConfig("text", {
|
||||
horizontalAlign: "right",
|
||||
verticalAlign: "bottom",
|
||||
overflow: "shrink",
|
||||
anchor: "bottom-right"
|
||||
});
|
||||
assert.strictEqual(textConfig.horizontalAlign, "right");
|
||||
assert.strictEqual(textConfig.verticalAlign, "bottom");
|
||||
assert.strictEqual(textConfig.overflow, "shrink");
|
||||
assert.strictEqual(textConfig.anchor, "bottom-right");
|
||||
for (const supportedColor of ["#fff", "#ffff", "#ffffff", "#ffffffff"]) {
|
||||
assert.strictEqual(overlayModules.normalizeModuleConfig("text", { color: supportedColor }).color, supportedColor);
|
||||
}
|
||||
assert.strictEqual(overlayModules.normalizeModuleConfig("text", { color: "#fffff" }).color, "#ffffff");
|
||||
const webConfig = overlayModules.normalizeModuleConfig("web", {
|
||||
url: "https://example.com/alerts",
|
||||
autoRefresh: true,
|
||||
healthCheck: true,
|
||||
refreshIntervalSeconds: 10,
|
||||
retryLimit: 9
|
||||
});
|
||||
assert.strictEqual(webConfig.autoRefresh, true);
|
||||
assert.strictEqual(webConfig.healthCheck, true);
|
||||
assert.strictEqual(webConfig.refreshIntervalSeconds, 30);
|
||||
assert.strictEqual(webConfig.retryLimit, 5);
|
||||
const videoConfig = overlayModules.normalizeModuleConfig("video", {
|
||||
url: "https://example.com/alert.webm",
|
||||
playBehavior: "loop",
|
||||
muted: false,
|
||||
volume: 0.65,
|
||||
playbackRate: 1.25,
|
||||
startAt: 2.5,
|
||||
fit: "cover"
|
||||
});
|
||||
assert.strictEqual(videoConfig.playBehavior, "loop");
|
||||
assert.strictEqual(videoConfig.volume, 0.65);
|
||||
assert.strictEqual(videoConfig.playbackRate, 1.25);
|
||||
assert.strictEqual(videoConfig.startAt, 2.5);
|
||||
assert.strictEqual(videoConfig.fit, "cover");
|
||||
const audioConfig = overlayModules.normalizeModuleConfig("audio", {
|
||||
url: "https://example.com/alert.ogg",
|
||||
playBehavior: "manual",
|
||||
volume: 5
|
||||
});
|
||||
assert.strictEqual(audioConfig.playBehavior, "manual");
|
||||
assert.strictEqual(audioConfig.showControls, true);
|
||||
assert.strictEqual(audioConfig.volume, 1);
|
||||
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
|
||||
url: "https://example.com/widget",
|
||||
cropLeft: 12.5,
|
||||
zoom: 1.4,
|
||||
customCss: "iframe { filter: grayscale(1); }"
|
||||
});
|
||||
assert.strictEqual(styledWebConfig.cropLeft, 12.5);
|
||||
assert.strictEqual(styledWebConfig.zoom, 1.4);
|
||||
assert.strictEqual(styledWebConfig.customCss, "iframe { filter: grayscale(1); }");
|
||||
|
||||
overlays.updateOverlay(created.id, { canvasWidth: 1280, canvasHeight: 720 });
|
||||
assert.deepStrictEqual(overlays.buildPublicState(created.id).canvas, { width: 1280, height: 720 });
|
||||
|
||||
const second = overlays.addScene(created.id, { name: "Break", obsSceneName: "BRB" });
|
||||
overlays.setActiveScene(created.id, second.id, { source: "verify" });
|
||||
assert.strictEqual(overlays.buildPublicState(created.id).scene.id, second.id);
|
||||
assert.strictEqual(overlays.buildPublicState(created.id, created.scenes[0].id).scene.name, "Live");
|
||||
|
||||
const oldOverlayToken = created.public_token;
|
||||
const newOverlayToken = overlays.regenerateOverlayToken(created.id);
|
||||
assert.strictEqual(overlays.resolvePublicOverlay(oldOverlayToken), null, "regeneration must revoke old overlay tokens");
|
||||
assert(overlays.resolvePublicOverlay(newOverlayToken));
|
||||
|
||||
const refreshed = overlays.getOverlay(created.id, { includeSecrets: true });
|
||||
const firstScene = refreshed.scenes.find((scene) => scene.name === "Live");
|
||||
const oldSceneToken = firstScene.public_token;
|
||||
const newSceneToken = overlays.regenerateSceneToken(firstScene.id);
|
||||
assert.strictEqual(overlays.resolvePublicOverlay(newOverlayToken, oldSceneToken), null, "regeneration must revoke old fixed scene tokens");
|
||||
assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken));
|
||||
|
||||
overlays.deleteScene(second.id);
|
||||
assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/);
|
||||
|
||||
const duplicate = overlays.duplicateOverlay(created.id);
|
||||
assert.notStrictEqual(duplicate.public_token, newOverlayToken);
|
||||
assert.strictEqual(duplicate.scenes.length, 1);
|
||||
assert.strictEqual(duplicate.scenes[0].modules.length, 1);
|
||||
|
||||
overlays.saveObsSettings(created.id, {
|
||||
enabled: false,
|
||||
provider: "local_obs_websocket",
|
||||
endpoint: "ws://127.0.0.1:4455",
|
||||
password: "verification-secret",
|
||||
syncDirection: "bidirectional",
|
||||
reconnect: true
|
||||
});
|
||||
const storedObs = database.db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(created.id);
|
||||
assert(storedObs.password_encrypted && !storedObs.password_encrypted.includes("verification-secret"));
|
||||
assert.strictEqual(overlays.getObsSettings(created.id, { includeSecret: true }).password, "verification-secret");
|
||||
|
||||
assert.strictEqual(permissions.canAccessOverlay({ isAdmin: true }, created.id, "view_secrets"), true);
|
||||
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "view_secrets"), false);
|
||||
const unregister = permissions.registerOverlayAccessProvider({
|
||||
allows: ({ overlayId, capability }) => overlayId === created.id && capability === "manage"
|
||||
});
|
||||
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "manage"), true);
|
||||
assert.strictEqual(permissions.canAccessOverlay({ isMod: true }, created.id, "view_secrets"), false);
|
||||
unregister();
|
||||
|
||||
const editorView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-overlay-detail.ejs"), "utf8");
|
||||
const editorScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-management.js"), "utf8");
|
||||
const runtimeScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-runtime.js"), "utf8");
|
||||
const rendererScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-renderer.js"), "utf8");
|
||||
const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
|
||||
const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
|
||||
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
|
||||
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
|
||||
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
|
||||
assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"'));
|
||||
assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure"));
|
||||
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
|
||||
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
|
||||
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
|
||||
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
||||
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
|
||||
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
|
||||
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
|
||||
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
|
||||
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
|
||||
|
||||
console.log("Overlay verification passed: CRUD, visual editor hooks, transforms, layout controls, external recovery, token revocation, encrypted OBS credentials, and permissions.");
|
||||
} finally {
|
||||
database?.db.close();
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
@ -31,6 +31,26 @@ let catalog = placeholders.catalog({
|
||||
});
|
||||
assert(catalog.placeholders.some((entry) => entry.token === "{{core.main.bot_name}}"));
|
||||
assert(catalog.placeholders.some((entry) => entry.token === "{{user.public.display_name}}"));
|
||||
assert(catalog.placeholders.some((entry) => entry.token === "{{core.command.rng}}"));
|
||||
|
||||
const customValidation = placeholders.validateCustomPlaceholders([
|
||||
{ name: "stream.title", value: "Friday stream", visibility: "user", description: "Current stream title" },
|
||||
{ name: "staff.note", value: "Internal note", visibility: "mod" },
|
||||
{ name: "admin.internal_note", value: "Admin only", visibility: "admin" }
|
||||
]);
|
||||
assert.equal(customValidation.ok, true);
|
||||
assert.equal(placeholders.validateCustomPlaceholders([{ name: "Bad Name", value: "no" }]).ok, false);
|
||||
assert.equal(placeholders.validateCustomPlaceholders([{ name: "same", value: "one" }, { name: "same", value: "two" }]).ok, false);
|
||||
assert.equal(placeholders.validateCustomPlaceholders([{ name: "service.api_key", value: "do-not-store", visibility: "admin" }]).ok, false);
|
||||
assert.equal(placeholders.validateCustomPlaceholders([{ name: "missing.visibility", value: "no" }]).ok, false);
|
||||
placeholders.registerCustomPlaceholders(customValidation.definitions);
|
||||
catalog = placeholders.catalog({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
user: mod,
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert(catalog.placeholders.some((entry) => entry.token === "{{custom.stream.title}}"));
|
||||
assert.equal(catalog.placeholders.some((entry) => entry.token === "{{custom.staff.note}}"), false);
|
||||
|
||||
placeholders.registerPlaceholder({
|
||||
id: "core.main.admin_note",
|
||||
@ -73,6 +93,33 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
|
||||
assert.equal(rendered.ok, true);
|
||||
assert.match(rendered.rendered, /^Hi SammyCat, prefix is .+\.$/);
|
||||
|
||||
const randomRendered = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: "Rolled {{core.command.rng}}",
|
||||
user: mod,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true, command: { rng: 42 } }
|
||||
});
|
||||
assert.equal(randomRendered.rendered, "Rolled 42");
|
||||
|
||||
const customRendered = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: "Now: {{custom.stream.title}}",
|
||||
user: mod,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true }
|
||||
});
|
||||
assert.equal(customRendered.rendered, "Now: Friday stream");
|
||||
const hiddenCustom = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: "{{custom.staff.note}}",
|
||||
user: admin,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true },
|
||||
fallback: "[hidden]"
|
||||
});
|
||||
assert.equal(hiddenCustom.rendered, "[hidden]");
|
||||
|
||||
const previousGuildId = getSetting("discord_guild_id", "");
|
||||
try {
|
||||
setSetting("discord_guild_id", "guild-1");
|
||||
@ -102,15 +149,25 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
|
||||
outputAudience: "user"
|
||||
});
|
||||
assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.member_count}}"));
|
||||
assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.data_status}}"));
|
||||
|
||||
const platformRendered = await placeholders.renderTemplate({
|
||||
fieldId: "okf.markdown",
|
||||
template: "Discord has {{platform.discord.guild.member_count}} members.",
|
||||
template: "Discord has {{platform.discord.guild.member_count}} members ({{platform.discord.guild.data_status}}).",
|
||||
user: admin,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true }
|
||||
});
|
||||
assert.equal(platformRendered.rendered, "Discord has 42 members.");
|
||||
assert.equal(platformRendered.rendered, "Discord has 42 members (live_cache).");
|
||||
|
||||
const missingPlatformRendered = await placeholders.renderTemplate({
|
||||
fieldId: "okf.markdown",
|
||||
template: "Twitch: {{platform.twitch.channel.data_status}}; YouTube: {{platform.youtube.channel.data_status}}.",
|
||||
user: admin,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true }
|
||||
});
|
||||
assert.match(missingPlatformRendered.rendered, /^Twitch: (unavailable|configured_not_connected); YouTube: (unavailable|configured_not_connected)\.$/);
|
||||
} finally {
|
||||
setSetting("discord_guild_id", previousGuildId);
|
||||
}
|
||||
@ -133,6 +190,58 @@ assert.equal(invalid.errors[0].reason, "viewer_role_forbidden");
|
||||
});
|
||||
assert.equal(userVisibleAdminPlaceholder.ok, false);
|
||||
assert.equal(userVisibleAdminPlaceholder.errors[0].reason, "viewer_role_forbidden");
|
||||
|
||||
placeholders.registerFieldPolicy({
|
||||
field_id: "test.async",
|
||||
label: "Async renderer test",
|
||||
field_type: "test_text",
|
||||
output_audience: "user",
|
||||
min_editor_role: "user",
|
||||
allowed_namespaces: ["test"],
|
||||
max_sensitivity: "public_safe"
|
||||
});
|
||||
let duplicateCalls = 0;
|
||||
placeholders.registerPlaceholder({
|
||||
id: "test.duplicate",
|
||||
namespace: "test",
|
||||
label: "Duplicate token",
|
||||
allowed_field_types: ["test_text"],
|
||||
resolver: async () => (++duplicateCalls === 1 ? "{{test.duplicate}}" : "second")
|
||||
});
|
||||
placeholders.registerPlaceholder({
|
||||
id: "test.failed",
|
||||
namespace: "test",
|
||||
label: "Failed token",
|
||||
allowed_field_types: ["test_text"],
|
||||
resolver: async () => { throw new Error("simulated failure"); }
|
||||
});
|
||||
placeholders.registerPlaceholder({
|
||||
id: "test.slow",
|
||||
namespace: "test",
|
||||
label: "Slow token",
|
||||
allowed_field_types: ["test_text"],
|
||||
resolver: () => new Promise(() => {})
|
||||
});
|
||||
placeholders.registerPlaceholder({
|
||||
id: "test.unavailable",
|
||||
namespace: "test",
|
||||
label: "Unavailable token",
|
||||
allowed_field_types: ["test_text"],
|
||||
available: () => false,
|
||||
resolver: () => "must-not-render"
|
||||
});
|
||||
const deterministic = await placeholders.renderTemplate({
|
||||
fieldId: "test.async",
|
||||
template: "{{test.duplicate}}{{test.duplicate}}|{{test.failed}}|{{test.unavailable}}|{{test.slow}}",
|
||||
user,
|
||||
outputAudience: "user",
|
||||
runtimeContext: { runtime: true, placeholder_timeout_ms: 50 },
|
||||
fallback: "[safe]"
|
||||
});
|
||||
assert.equal(deterministic.rendered, "{{test.duplicate}}second|[safe]|[safe]|[safe]");
|
||||
assert(deterministic.errors.some((entry) => entry.reason === "resolver_failed"));
|
||||
assert(deterministic.errors.some((entry) => entry.reason === "placeholder_unavailable"));
|
||||
assert(deterministic.errors.some((entry) => entry.reason === "resolver_timeout"));
|
||||
console.log("Placeholder verification passed.");
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
@ -2,7 +2,10 @@ const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { replacePluginDirectory } = require("../src/services/update-manager");
|
||||
const {
|
||||
replacePluginDirectory,
|
||||
resetDirectoryForFullUpdate
|
||||
} = require("../src/services/update-manager");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-update-test-"));
|
||||
try {
|
||||
@ -26,7 +29,83 @@ try {
|
||||
assert.equal(fs.existsSync(path.join(target, "stale.js")), false);
|
||||
assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024);
|
||||
assert.equal(fs.existsSync(path.join(target, "data", "config", "default.json")), false);
|
||||
console.log("Plugin update preservation verification passed.");
|
||||
|
||||
const failedSource = path.join(root, "failed-source");
|
||||
fs.mkdirSync(failedSource, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedSource, "plugin.json"), '{"id":"test"}');
|
||||
fs.writeFileSync(path.join(failedSource, "index.js"), "module.exports = 'must-not-land';");
|
||||
const originalRename = fs.renameSync;
|
||||
fs.renameSync = (from, to) => {
|
||||
if (String(from).includes(".target.update-") && to === target) {
|
||||
throw Object.assign(new Error("simulated install lock"), { code: "EPERM" });
|
||||
}
|
||||
return originalRename(from, to);
|
||||
};
|
||||
try {
|
||||
assert.throws(
|
||||
() => replacePluginDirectory(failedSource, target, { preserveData: true }),
|
||||
/simulated install lock/
|
||||
);
|
||||
} finally {
|
||||
fs.renameSync = originalRename;
|
||||
}
|
||||
assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';");
|
||||
assert.equal(fs.statSync(model).size, 3 * 1024 * 1024 * 1024);
|
||||
assert.equal(fs.readdirSync(root).some((name) => name.includes(".target.update-") || name.includes(".target.backup-")), false);
|
||||
|
||||
const install = path.join(root, "install");
|
||||
for (const relative of [
|
||||
"data/feedback/report.json",
|
||||
"data/lumi_ai/models/model.gguf",
|
||||
"plugins/example/data/settings.json",
|
||||
"knowledge/community/local.md",
|
||||
"knowledge/corrections/fix.md",
|
||||
"knowledge/core/generated.md",
|
||||
"knowledge/core/local.md",
|
||||
"knowledge/plugins/generated.md",
|
||||
"config/local.json",
|
||||
"src/stale.js"
|
||||
]) {
|
||||
const file = path.join(install, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, relative);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(install, "knowledge", "core", "generated.md"),
|
||||
"---\ngenerated: true\neditable: false\n---\ngenerated"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(install, "knowledge", "plugins", "generated.md"),
|
||||
"---\ngenerated: true\neditable: false\n---\ngenerated"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(install, "knowledge", "core", "local.md"),
|
||||
"---\ngenerated: false\neditable: true\n---\nlocal"
|
||||
);
|
||||
resetDirectoryForFullUpdate(install);
|
||||
for (const relative of [
|
||||
"data/feedback/report.json",
|
||||
"data/lumi_ai/models/model.gguf",
|
||||
"plugins/example/data/settings.json",
|
||||
"knowledge/community/local.md",
|
||||
"knowledge/corrections/fix.md",
|
||||
"knowledge/core/local.md",
|
||||
"config/local.json"
|
||||
]) {
|
||||
assert.equal(fs.existsSync(path.join(install, relative)), true, `${relative} should be preserved`);
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(install, "knowledge/core/generated.md")), false);
|
||||
assert.equal(fs.existsSync(path.join(install, "knowledge/plugins/generated.md")), false);
|
||||
assert.equal(fs.existsSync(path.join(install, "src/stale.js")), false);
|
||||
|
||||
const managerSource = fs.readFileSync(path.join(__dirname, "..", "src", "services", "update-manager.js"), "utf8");
|
||||
const updateDocs = fs.readFileSync(path.join(__dirname, "..", "docs", "updates.md"), "utf8");
|
||||
assert(managerSource.includes("Automatic rollback also failed"));
|
||||
assert.equal((managerSource.match(/restoreSnapshot\(record\.id/g) || []).length >= 2, true);
|
||||
assert(updateDocs.includes("Preserved Local Data"));
|
||||
assert(updateDocs.includes("knowledge/community/"));
|
||||
assert(updateDocs.includes("AI models/runtimes"));
|
||||
console.log("Update preservation verification passed: core paths, plugin data, transactional replacement, and failed-update rollback wiring.");
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
36
scripts/verify-preflight.js
Normal file
36
scripts/verify-preflight.js
Normal file
@ -0,0 +1,36 @@
|
||||
const path = require("path");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const minimumNodeMajor = 18;
|
||||
|
||||
function fail(message, details = []) {
|
||||
console.error(`Verification preflight failed: ${message}`);
|
||||
for (const detail of details) console.error(`- ${detail}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0], 10);
|
||||
if (!Number.isFinite(nodeMajor) || nodeMajor < minimumNodeMajor) {
|
||||
fail(`Lumi requires Node.js ${minimumNodeMajor} or newer; this process is ${process.version}.`, [
|
||||
"Install a supported Node.js release, reopen the terminal, then run npm install."
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
const BetterSqlite3 = require("better-sqlite3");
|
||||
const database = new BetterSqlite3(":memory:");
|
||||
database.prepare("SELECT 1 AS ready").get();
|
||||
database.close();
|
||||
} catch (error) {
|
||||
fail("The better-sqlite3 native module is missing or incompatible with this Node.js installation.", [
|
||||
`Node: ${process.version} (${process.platform} ${process.arch})`,
|
||||
`Module error: ${error.message}`,
|
||||
`From ${root}, run npm install. Do not use --ignore-scripts because native modules must be built or downloaded.`,
|
||||
"If Node.js was upgraded after installation, run npm rebuild better-sqlite3 or remove node_modules and run npm install again.",
|
||||
"On Linux, ensure a compiler toolchain and Python are available if no prebuilt binary exists."
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(`Verification preflight passed: Node ${process.version}, better-sqlite3 loaded (${process.platform} ${process.arch}).`);
|
||||
}
|
||||
87
scripts/verify-safe-files.js
Normal file
87
scripts/verify-safe-files.js
Normal file
@ -0,0 +1,87 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { Worker } = require("worker_threads");
|
||||
const {
|
||||
writeJsonAtomicSync,
|
||||
clearSafeFileDiagnostics,
|
||||
getSafeFileDiagnostics
|
||||
} = require("../src/services/safe-files");
|
||||
|
||||
async function run() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-safe-files-"));
|
||||
const target = path.join(directory, "runtime_state.json");
|
||||
const originalRename = fs.renameSync;
|
||||
try {
|
||||
fs.writeFileSync(target, '{"state":"original"}\n');
|
||||
const stale = `${target}.tmp`;
|
||||
fs.writeFileSync(stale, "stale");
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
fs.utimesSync(stale, old, old);
|
||||
|
||||
clearSafeFileDiagnostics();
|
||||
let transientFailures = 0;
|
||||
fs.renameSync = (from, to) => {
|
||||
if (to === target && String(from).endsWith(".tmp") && transientFailures < 2) {
|
||||
transientFailures += 1;
|
||||
throw Object.assign(new Error("temporarily locked"), { code: transientFailures === 1 ? "EPERM" : "EACCES" });
|
||||
}
|
||||
return originalRename(from, to);
|
||||
};
|
||||
writeJsonAtomicSync(target, { state: "recovered" }, { attempts: 4, delayMs: 1, staleTempAgeMs: 1000 });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { state: "recovered" });
|
||||
assert.equal(fs.existsSync(stale), false);
|
||||
assert(getSafeFileDiagnostics().filter((entry) => entry.operation === "rename").length >= 2);
|
||||
|
||||
fs.writeFileSync(target, '{"state":"must-survive"}\n');
|
||||
fs.renameSync = (from, to) => {
|
||||
if (to === target && String(from).endsWith(".tmp")) {
|
||||
throw Object.assign(new Error("replacement locked"), { code: "EPERM" });
|
||||
}
|
||||
return originalRename(from, to);
|
||||
};
|
||||
assert.throws(
|
||||
() => writeJsonAtomicSync(target, { state: "must-not-land" }, { attempts: 2, delayMs: 1 }),
|
||||
/replacement locked/
|
||||
);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { state: "must-survive" });
|
||||
|
||||
fs.renameSync = originalRename;
|
||||
await Promise.all(Array.from({ length: 4 }, (_, worker) => runWorker(target, worker)));
|
||||
const final = JSON.parse(fs.readFileSync(target, "utf8"));
|
||||
assert(Number.isInteger(final.worker));
|
||||
assert(Number.isInteger(final.write));
|
||||
assert.equal(fs.readdirSync(directory).some((name) => name.endsWith(".tmp")), false);
|
||||
} finally {
|
||||
fs.renameSync = originalRename;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
console.log("Safe atomic file verification passed: transient lock retry, rollback, stale cleanup, and concurrent writes.");
|
||||
}
|
||||
|
||||
function runWorker(target, worker) {
|
||||
const helper = require.resolve("../src/services/safe-files");
|
||||
const source = `
|
||||
const { parentPort, workerData } = require("worker_threads");
|
||||
const { writeJsonAtomicSync } = require(workerData.helper);
|
||||
try {
|
||||
for (let write = 0; write < 12; write += 1) {
|
||||
writeJsonAtomicSync(workerData.target, { worker: workerData.worker, write });
|
||||
}
|
||||
parentPort.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ ok: false, error: error.stack || error.message });
|
||||
}
|
||||
`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const instance = new Worker(source, { eval: true, workerData: { helper, target, worker } });
|
||||
instance.once("message", (message) => message.ok ? resolve() : reject(new Error(message.error)));
|
||||
instance.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
64
scripts/verify-upload-security.js
Normal file
64
scripts/verify-upload-security.js
Normal file
@ -0,0 +1,64 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const {
|
||||
cleanupUploadedFiles,
|
||||
safeDownloadFilename,
|
||||
validateUploadedFile,
|
||||
validateUploadedFiles
|
||||
} = require("../src/services/upload-security");
|
||||
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-upload-security-"));
|
||||
|
||||
function upload(name, mime, bytes) {
|
||||
const filePath = path.join(sandbox, `${Date.now()}-${Math.random()}`);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return { path: filePath, originalname: name, mimetype: mime, size: bytes.length };
|
||||
}
|
||||
|
||||
try {
|
||||
const png = upload("icon.png", "image/png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
|
||||
validateUploadedFile(png, { allowedTypes: ["png"], maxBytes: 1024, requireExtension: true });
|
||||
assert.equal(png.detectedType, "png");
|
||||
|
||||
const disguised = upload("not-really.png", "image/png", Buffer.from("not an image"));
|
||||
assert.throws(() => validateUploadedFiles([disguised], { allowedTypes: ["png"], maxBytes: 1024 }), /contents/);
|
||||
assert.equal(fs.existsSync(disguised.path), false);
|
||||
|
||||
const mismatch = upload("photo.jpg", "image/jpeg", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
assert.throws(() => validateUploadedFiles([mismatch], { allowedTypes: ["png", "jpeg"] }), /browser|extension/);
|
||||
assert.equal(fs.existsSync(mismatch.path), false);
|
||||
|
||||
const unsafeSvg = upload("icon.svg", "image/svg+xml", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'));
|
||||
assert.throws(() => validateUploadedFiles([unsafeSvg], { allowedTypes: ["svg"] }), /contents/);
|
||||
assert.equal(fs.existsSync(unsafeSvg.path), false);
|
||||
|
||||
const safeSvg = upload("icon.svg", "image/svg+xml", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h1v1z"/></svg>'));
|
||||
validateUploadedFile(safeSvg, { allowedTypes: ["svg"], requireExtension: true });
|
||||
|
||||
const oversized = upload("large.txt", "text/plain", Buffer.from("12345"));
|
||||
assert.throws(() => validateUploadedFiles([oversized], { allowedTypes: ["text"], maxBytes: 4 }), /larger/);
|
||||
assert.equal(fs.existsSync(oversized.path), false);
|
||||
|
||||
assert.equal(safeDownloadFilename('../../bad\r\n"name.txt'), "badname.txt");
|
||||
assert.equal(safeDownloadFilename("..\\..\\secret.txt"), "secret.txt");
|
||||
|
||||
const serverSource = fs.readFileSync(path.join(__dirname, "..", "src", "web", "server.js"), "utf8");
|
||||
const moderationSource = fs.readFileSync(path.join(__dirname, "..", "plugins", "moderation", "index.js"), "utf8");
|
||||
const economySource = fs.readFileSync(path.join(__dirname, "..", "plugins", "economy-framework", "index.js"), "utf8");
|
||||
assert(serverSource.includes('allowedTypes: ["zip"]'));
|
||||
assert(serverSource.includes('allowedTypes: ["png", "svg"]'));
|
||||
assert(serverSource.includes('safeDownloadFilename(attachment.original_name'));
|
||||
assert(serverSource.includes('"X-Content-Type-Options", "nosniff"'));
|
||||
assert(moderationSource.includes("validateUploadedFiles(req.files"));
|
||||
assert(moderationSource.indexOf('router.post("/actions", (req, res, next)') < moderationSource.indexOf("evidenceUpload, async"));
|
||||
assert(economySource.includes("currencyIconUpload"));
|
||||
assert(economySource.indexOf('router.post("/settings/icon", (req, res, next)') < economySource.indexOf("currencyIconUpload, (req, res)"));
|
||||
|
||||
cleanupUploadedFiles([png, safeSvg]);
|
||||
console.log("Upload security verification passed.");
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
47
scripts/verify-web-auth.js
Normal file
47
scripts/verify-web-auth.js
Normal file
@ -0,0 +1,47 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { createWebAuth } = require("../src/services/web-auth");
|
||||
|
||||
function request({ hostname = "example.test", user = null, path = "/plugins/okf" } = {}) {
|
||||
return { hostname, path, session: { user }, get: () => hostname };
|
||||
}
|
||||
|
||||
function response() {
|
||||
return {
|
||||
redirectedTo: null,
|
||||
redirect(target) { this.redirectedTo = target; return target; }
|
||||
};
|
||||
}
|
||||
|
||||
const noPlatforms = createWebAuth({ getPlatformStatus: () => [] });
|
||||
let res = response();
|
||||
noPlatforms.requireAuth(request(), res, () => assert.fail("Anonymous request should not continue"));
|
||||
assert.equal(res.redirectedTo, "/setup");
|
||||
|
||||
res = response();
|
||||
noPlatforms.requireAuth(request({ hostname: "127.0.0.1" }), res, () => assert.fail("Anonymous request should not continue"));
|
||||
assert.equal(res.redirectedTo, "/auth/localhost");
|
||||
|
||||
const discordAuth = createWebAuth({ getPlatformStatus: () => [{
|
||||
supported: true, enabled: true, configured: true, supportsLogin: true, loginPath: "/auth/discord"
|
||||
}] });
|
||||
res = response();
|
||||
discordAuth.requireAuth(request(), res, () => assert.fail("Anonymous request should not continue"));
|
||||
assert.equal(res.redirectedTo, "/auth/discord");
|
||||
|
||||
let continued = false;
|
||||
discordAuth.requireAuth(request({ user: { id: "user-1" } }), response(), () => { continued = true; });
|
||||
assert.equal(continued, true);
|
||||
|
||||
res = response();
|
||||
noPlatforms.requireConfigured(request(), res, () => assert.fail("Unconfigured remote plugin request should not continue"));
|
||||
assert.equal(res.redirectedTo, "/setup");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const okf = fs.readFileSync(path.join(root, "plugins", "okf", "index.js"), "utf8");
|
||||
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
assert(okf.includes("const requireLogin = web.auth.requireAuth"));
|
||||
assert.equal(okf.includes('res.redirect("/login")'), false);
|
||||
assert(server.includes("app.use(mountPath, webAuth.requireConfigured, router)"));
|
||||
console.log("Shared WebUI authentication verification passed: setup, localhost, configured provider, and OKF wiring.");
|
||||
@ -188,7 +188,55 @@ function verifyUpdateCachePreserved() {
|
||||
);
|
||||
}
|
||||
|
||||
function verifySharedUpdateActions() {
|
||||
const appSource = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
|
||||
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8");
|
||||
const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
|
||||
assert(appSource.includes('button[data-update-action]'));
|
||||
assert(appSource.includes("submitter?.formAction || form.action"));
|
||||
assert(appSource.includes('actionPath.endsWith("/check")'));
|
||||
assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action'));
|
||||
assert(settings.includes('formaction=\\"/admin/check-update\\" formmethod=\\"post\\" data-update-action'));
|
||||
assert(serverSource.includes('sendUpdateResult(req, res, {\n status,'));
|
||||
assert(serverSource.includes('message: "Core update applied. Lumi will restart in a few seconds."'));
|
||||
}
|
||||
|
||||
function verifyHomepageEmbeds() {
|
||||
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
const builder = fs.readFileSync(path.join(root, "src", "web", "public", "homepage-builder.js"), "utf8");
|
||||
const home = fs.readFileSync(path.join(root, "src", "web", "views", "home.ejs"), "utf8");
|
||||
const youtube = fs.readFileSync(path.join(root, "src", "services", "youtube.js"), "utf8");
|
||||
|
||||
assert(server.includes("function twitchParentDomains"));
|
||||
assert(server.includes("function twitchChatEmbedUrl"));
|
||||
assert(server.includes("function youtubeChatEmbedUrl"));
|
||||
assert(server.includes('item.availability_mode === "live_only" && item.live_now !== true'));
|
||||
assert(builder.includes('twitch_layout: "stream_only"'));
|
||||
assert(builder.includes('youtube_show_chat: false'));
|
||||
assert(builder.includes('additional_parent_domains: ""'));
|
||||
assert(home.includes("homepageHero.secondary_embed_url"));
|
||||
assert(home.includes("homepage-hero-embed-grid"));
|
||||
assert(youtube.includes("liveStateUpdatedAt"));
|
||||
assert(youtube.includes("activeVideoId"));
|
||||
}
|
||||
|
||||
function verifyCustomPlaceholderSettings() {
|
||||
const view = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
|
||||
const client = fs.readFileSync(path.join(root, "src", "web", "public", "custom-placeholders.js"), "utf8");
|
||||
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||
assert(view.includes("Reusable text values"));
|
||||
assert(view.includes("custom_placeholder_visibility"));
|
||||
assert(view.includes("/custom-placeholders.js"));
|
||||
assert(client.includes("data-custom-placeholder-row"));
|
||||
assert(server.includes("validateCustomPlaceholders(customPlaceholdersFromBody(req.body))"));
|
||||
assert(server.includes('setSetting("custom_placeholders"'));
|
||||
}
|
||||
|
||||
const viewCount = verifyViews();
|
||||
verifyThemeService();
|
||||
verifyUpdateCachePreserved();
|
||||
verifySharedUpdateActions();
|
||||
verifyHomepageEmbeds();
|
||||
verifyCustomPlaceholderSettings();
|
||||
console.log(`WebUI verification passed: ${viewCount} EJS views, theme CRUD, and update-cache preservation.`);
|
||||
|
||||
@ -16,6 +16,7 @@ const { registerTopCommand } = require("./services/top");
|
||||
const logger = require("./services/logger");
|
||||
const { isPlatformEnabled } = require("./services/platforms");
|
||||
const { registerCorePlaceholders } = require("./services/placeholders");
|
||||
const { overlayConnectorManager } = require("./services/overlay-connectors");
|
||||
const {
|
||||
isSafeModeRequested,
|
||||
markStartupVerification
|
||||
@ -80,6 +81,12 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
if (!safeModeRequested) {
|
||||
overlayConnectorManager.start().catch((error) => {
|
||||
console.error("OBS overlay connectors failed to start", error);
|
||||
});
|
||||
}
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
app.listen(port, () => {
|
||||
console.log(`WebUI listening on http://localhost:${port}`);
|
||||
@ -114,6 +121,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
await overlayConnectorManager.stop();
|
||||
await stopPlugins();
|
||||
await stopBot();
|
||||
await stopTwitchBot();
|
||||
|
||||
48
src/services/command-code.js
Normal file
48
src/services/command-code.js
Normal file
@ -0,0 +1,48 @@
|
||||
"use strict";
|
||||
|
||||
function hasJavaScriptHandler(source) {
|
||||
const code = String(source || "");
|
||||
return /\bfunction\s+run\s*\(|\b(?:const|let|var)\s+run\s*=|\bmodule\.exports\s*=|\bexports(?:\.run)?\s*=/.test(code);
|
||||
}
|
||||
|
||||
function prepareJavaScriptCommand(source) {
|
||||
const code = String(source || "");
|
||||
if (hasJavaScriptHandler(code)) return code;
|
||||
return `async function run(ctx) {\n${indent(code)}\n}`;
|
||||
}
|
||||
|
||||
function resolveJavaScriptHandler(context) {
|
||||
const candidates = [
|
||||
context?.run,
|
||||
context?.module?.exports,
|
||||
context?.module?.exports?.run,
|
||||
context?.exports,
|
||||
context?.exports?.run
|
||||
];
|
||||
return candidates.find((candidate) => typeof candidate === "function") || null;
|
||||
}
|
||||
|
||||
function hasPythonHandler(source) {
|
||||
return /^\s*(?:async\s+)?def\s+run\s*\(/m.test(String(source || ""));
|
||||
}
|
||||
|
||||
function preparePythonCommand(source) {
|
||||
const code = String(source || "");
|
||||
if (hasPythonHandler(code)) return code;
|
||||
return `def run(ctx):\n${indent(code || "return None")}`;
|
||||
}
|
||||
|
||||
function indent(value) {
|
||||
return String(value || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasJavaScriptHandler,
|
||||
hasPythonHandler,
|
||||
prepareJavaScriptCommand,
|
||||
preparePythonCommand,
|
||||
resolveJavaScriptHandler
|
||||
};
|
||||
@ -2,9 +2,14 @@
|
||||
|
||||
const vm = require("vm");
|
||||
const { spawn } = require("child_process");
|
||||
const {
|
||||
prepareJavaScriptCommand,
|
||||
preparePythonCommand,
|
||||
resolveJavaScriptHandler
|
||||
} = require("./command-code");
|
||||
|
||||
const FIXED_NOW = Date.parse("2026-01-02T12:34:56.000Z");
|
||||
const BLOCKED_JS = /\b(?:require|process|child_process|fs|XMLHttpRequest|WebSocket|import\s*\(|import\s+|Deno|Bun)\b/;
|
||||
const BLOCKED_JS = /\b(?:require|process|child_process|fs|XMLHttpRequest|WebSocket|Deno|Bun)\b|\bimport\s*(?:\(|[\w*{])/;
|
||||
const BLOCKED_PYTHON = /(?:\b(?:import|open|exec|eval|compile|globals|locals|getattr|setattr|delattr|vars|input|breakpoint|help)\b|__)/;
|
||||
const MAX_CODE_LENGTH = 20000;
|
||||
const MAX_PREVIEW_FETCH_BYTES = 128 * 1024;
|
||||
@ -33,59 +38,248 @@ process.stdin.on("end", async () => {
|
||||
async function previewJavaScript(code) {
|
||||
const source = String(code || "");
|
||||
if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large.");
|
||||
if (BLOCKED_JS.test(source)) {
|
||||
if (BLOCKED_JS.test(maskJavaScriptLiteralsAndComments(source))) {
|
||||
throw new Error("Preview blocks filesystem, process, browser socket, and module access.");
|
||||
}
|
||||
|
||||
const replies = [];
|
||||
const contextValue = createMockContext((value) => {
|
||||
replies.push(normalizeResult(value));
|
||||
});
|
||||
const safeMath = Object.create(Math);
|
||||
Object.defineProperty(safeMath, "random", { value: () => 0.424242, enumerable: true });
|
||||
Object.freeze(safeMath);
|
||||
|
||||
class PreviewDate extends Date {
|
||||
constructor(...args) {
|
||||
super(args.length ? args[0] : FIXED_NOW);
|
||||
}
|
||||
static now() {
|
||||
return FIXED_NOW;
|
||||
}
|
||||
}
|
||||
|
||||
const sandbox = Object.create(null);
|
||||
Object.assign(sandbox, {
|
||||
ctx: contextValue,
|
||||
console: Object.freeze({ log() {}, warn() {}, error() {} }),
|
||||
Math: safeMath,
|
||||
Date: PreviewDate,
|
||||
JSON,
|
||||
Promise,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
fetch: readOnlyPreviewFetch,
|
||||
module: { exports: {} },
|
||||
exports: {}
|
||||
});
|
||||
sandbox.__previewFetchBridge = readOnlyPreviewFetchBridge;
|
||||
sandbox.__previewParseUrlBridge = parsePreviewUrl;
|
||||
const context = vm.createContext(sandbox, {
|
||||
codeGeneration: { strings: false, wasm: false }
|
||||
});
|
||||
const script = new vm.Script(`"use strict";\n${source}`, {
|
||||
initializeJavaScriptSandbox(context);
|
||||
const contextValue = context.ctx;
|
||||
const script = new vm.Script(`"use strict";\n${prepareJavaScriptCommand(source)}`, {
|
||||
filename: "command-preview.js"
|
||||
});
|
||||
script.runInContext(context, { timeout: 250 });
|
||||
const handler = context.run || context.module.exports || context.exports;
|
||||
const handler = resolveJavaScriptHandler(context);
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("Define a run(ctx) function.");
|
||||
throw new Error("Dynamic command did not create a runnable handler.");
|
||||
}
|
||||
const returned = await withTimeout(Promise.resolve(handler(contextValue)), PREVIEW_FETCH_TIMEOUT_MS + 500);
|
||||
const output = [...replies];
|
||||
const output = Array.from(context.__previewReplies || [], normalizeResult);
|
||||
const normalizedReturn = normalizeResult(returned);
|
||||
if (normalizedReturn && !output.includes(normalizedReturn)) output.push(normalizedReturn);
|
||||
return output.filter(Boolean).join("\n") || "Command returned no output.";
|
||||
}
|
||||
|
||||
function initializeJavaScriptSandbox(context) {
|
||||
const serializedContext = JSON.stringify(createSerializableContext());
|
||||
const bootstrap = new vm.Script(`
|
||||
"use strict";
|
||||
{
|
||||
const fetchBridge = globalThis.__previewFetchBridge;
|
||||
const parseUrlBridge = globalThis.__previewParseUrlBridge;
|
||||
delete globalThis.__previewFetchBridge;
|
||||
delete globalThis.__previewParseUrlBridge;
|
||||
|
||||
const replies = [];
|
||||
const noOpMutation = async () => ({ ok: true, preview: true });
|
||||
const contextValue = ${serializedContext};
|
||||
|
||||
Object.defineProperty(Math, "random", {
|
||||
value: () => 0.424242,
|
||||
configurable: false,
|
||||
writable: false
|
||||
});
|
||||
Object.freeze(Math);
|
||||
|
||||
const NativeDate = Date;
|
||||
class PreviewDate extends NativeDate {
|
||||
constructor(...args) {
|
||||
super(args.length ? args[0] : ${FIXED_NOW});
|
||||
}
|
||||
static now() {
|
||||
return ${FIXED_NOW};
|
||||
}
|
||||
}
|
||||
Object.freeze(PreviewDate);
|
||||
|
||||
class PreviewURLSearchParams {
|
||||
constructor(input = "") {
|
||||
this._pairs = [];
|
||||
const value = String(input || "").replace(/^\\?/, "");
|
||||
for (const part of value.split("&")) {
|
||||
if (!part) continue;
|
||||
const separator = part.indexOf("=");
|
||||
const key = separator >= 0 ? part.slice(0, separator) : part;
|
||||
const item = separator >= 0 ? part.slice(separator + 1) : "";
|
||||
this._pairs.push([decodeURIComponent(key.replace(/\\+/g, " ")), decodeURIComponent(item.replace(/\\+/g, " "))]);
|
||||
}
|
||||
}
|
||||
append(key, value) { this._pairs.push([String(key), String(value)]); }
|
||||
delete(key) { const target = String(key); this._pairs = this._pairs.filter(([name]) => name !== target); }
|
||||
get(key) { const row = this._pairs.find(([name]) => name === String(key)); return row ? row[1] : null; }
|
||||
getAll(key) { return this._pairs.filter(([name]) => name === String(key)).map(([, value]) => value); }
|
||||
has(key) { return this._pairs.some(([name]) => name === String(key)); }
|
||||
set(key, value) { this.delete(key); this.append(key, value); }
|
||||
entries() { return this._pairs[Symbol.iterator](); }
|
||||
keys() { return this._pairs.map(([key]) => key)[Symbol.iterator](); }
|
||||
values() { return this._pairs.map(([, value]) => value)[Symbol.iterator](); }
|
||||
forEach(callback, thisArg) { for (const [key, value] of this._pairs) callback.call(thisArg, value, key, this); }
|
||||
toString() { return this._pairs.map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)).join("&"); }
|
||||
[Symbol.iterator]() { return this.entries(); }
|
||||
}
|
||||
|
||||
class PreviewURL {
|
||||
constructor(input, base) {
|
||||
const parsed = parseUrlBridge(String(input), base === undefined ? undefined : String(base));
|
||||
for (const key of ["href", "origin", "protocol", "username", "password", "host", "hostname", "port", "pathname", "search", "hash"]) {
|
||||
this[key] = parsed[key];
|
||||
}
|
||||
this.searchParams = new PreviewURLSearchParams(parsed.search);
|
||||
}
|
||||
toString() { return this.href; }
|
||||
toJSON() { return this.href; }
|
||||
}
|
||||
|
||||
async function previewFetch(resource, options = {}) {
|
||||
const method = String(options && options.method || "GET").toUpperCase();
|
||||
const raw = await fetchBridge(String(resource), { method });
|
||||
const headers = Object.freeze({
|
||||
get(name) { return raw.headers[String(name).toLowerCase()] ?? null; }
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: Boolean(raw.ok),
|
||||
status: Number(raw.status),
|
||||
statusText: String(raw.statusText || ""),
|
||||
url: String(raw.url || ""),
|
||||
preview: true,
|
||||
readOnly: true,
|
||||
truncated: Boolean(raw.truncated),
|
||||
headers,
|
||||
text: async () => String(raw.text || ""),
|
||||
json: async () => JSON.parse(String(raw.text || "null")),
|
||||
arrayBuffer: async () => Uint8Array.from(raw.bytes || []).buffer
|
||||
});
|
||||
}
|
||||
|
||||
contextValue.reply = async (value) => {
|
||||
replies.push(value);
|
||||
return { ok: true, preview: true };
|
||||
};
|
||||
contextValue.economy = Object.freeze({
|
||||
getBalance: async () => 1234,
|
||||
balance: async () => 1234,
|
||||
add: noOpMutation,
|
||||
take: noOpMutation,
|
||||
transfer: noOpMutation
|
||||
});
|
||||
contextValue.inventory = Object.freeze({
|
||||
list: async () => [{ id: "item_12345", name: "Example item", quantity: 2 }],
|
||||
add: noOpMutation,
|
||||
remove: noOpMutation
|
||||
});
|
||||
contextValue.moderation = Object.freeze({
|
||||
warn: noOpMutation,
|
||||
timeout: noOpMutation,
|
||||
ban: noOpMutation,
|
||||
unban: noOpMutation
|
||||
});
|
||||
contextValue.db = Object.freeze({
|
||||
prepare: () => Object.freeze({
|
||||
run: () => ({ changes: 0, preview: true }),
|
||||
get: () => null,
|
||||
all: () => []
|
||||
})
|
||||
});
|
||||
contextValue.files = Object.freeze({
|
||||
read: async () => null,
|
||||
write: noOpMutation,
|
||||
remove: noOpMutation
|
||||
});
|
||||
contextValue.api = Object.freeze({
|
||||
request: async (resource, options = {}) => {
|
||||
const response = await previewFetch(resource, options);
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch {}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: response.url,
|
||||
preview: true,
|
||||
readOnly: true,
|
||||
truncated: response.truncated,
|
||||
text,
|
||||
data
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
globalThis.Date = PreviewDate;
|
||||
globalThis.URL = PreviewURL;
|
||||
globalThis.URLSearchParams = PreviewURLSearchParams;
|
||||
globalThis.fetch = previewFetch;
|
||||
globalThis.console = Object.freeze({ log() {}, warn() {}, error() {} });
|
||||
globalThis.ctx = Object.freeze(contextValue);
|
||||
globalThis.module = { exports: {} };
|
||||
globalThis.exports = {};
|
||||
globalThis.__previewReplies = replies;
|
||||
}
|
||||
`, { filename: "command-preview-bootstrap.js" });
|
||||
bootstrap.runInContext(context, { timeout: 100 });
|
||||
}
|
||||
|
||||
function maskJavaScriptLiteralsAndComments(source) {
|
||||
const value = String(source || "");
|
||||
let output = "";
|
||||
let state = "code";
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
const next = value[index + 1];
|
||||
if (state === "code") {
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
state = char;
|
||||
output += " ";
|
||||
} else if (char === "/" && next === "/") {
|
||||
state = "line-comment";
|
||||
output += " ";
|
||||
index += 1;
|
||||
} else if (char === "/" && next === "*") {
|
||||
state = "block-comment";
|
||||
output += " ";
|
||||
index += 1;
|
||||
} else {
|
||||
output += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === "line-comment") {
|
||||
if (char === "\n" || char === "\r") {
|
||||
state = "code";
|
||||
output += char;
|
||||
} else {
|
||||
output += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === "block-comment") {
|
||||
if (char === "*" && next === "/") {
|
||||
state = "code";
|
||||
output += " ";
|
||||
index += 1;
|
||||
} else {
|
||||
output += char === "\n" || char === "\r" ? char : " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
output += " ";
|
||||
index += 1;
|
||||
} else if (char === state) {
|
||||
state = "code";
|
||||
output += " ";
|
||||
} else {
|
||||
output += char === "\n" || char === "\r" ? char : " ";
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function previewPython(code) {
|
||||
const source = String(code || "");
|
||||
if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large.");
|
||||
@ -165,80 +359,13 @@ print(json.dumps(replies))
|
||||
}
|
||||
});
|
||||
child.stdin.end(JSON.stringify({
|
||||
code: source,
|
||||
code: preparePythonCommand(source),
|
||||
ctx: createSerializableContext()
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createMockContext(reply) {
|
||||
const context = createSerializableContext();
|
||||
const noOpMutation = async () => ({ ok: true, preview: true });
|
||||
return {
|
||||
...context,
|
||||
reply: async (value) => {
|
||||
reply(value);
|
||||
return { ok: true, preview: true };
|
||||
},
|
||||
economy: Object.freeze({
|
||||
getBalance: async () => 1234,
|
||||
balance: async () => 1234,
|
||||
add: noOpMutation,
|
||||
take: noOpMutation,
|
||||
transfer: noOpMutation
|
||||
}),
|
||||
inventory: Object.freeze({
|
||||
list: async () => [{ id: "item_12345", name: "Example item", quantity: 2 }],
|
||||
add: noOpMutation,
|
||||
remove: noOpMutation
|
||||
}),
|
||||
moderation: Object.freeze({
|
||||
warn: noOpMutation,
|
||||
timeout: noOpMutation,
|
||||
ban: noOpMutation,
|
||||
unban: noOpMutation
|
||||
}),
|
||||
db: Object.freeze({
|
||||
prepare: () => Object.freeze({
|
||||
run: () => ({ changes: 0, preview: true }),
|
||||
get: () => null,
|
||||
all: () => []
|
||||
})
|
||||
}),
|
||||
files: Object.freeze({
|
||||
read: async () => null,
|
||||
write: noOpMutation,
|
||||
remove: noOpMutation
|
||||
}),
|
||||
api: Object.freeze({
|
||||
request: readOnlyApiRequest
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function readOnlyApiRequest(resource, options = {}) {
|
||||
const response = await readOnlyPreviewFetch(resource, options);
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: response.url,
|
||||
preview: true,
|
||||
readOnly: true,
|
||||
truncated: response.truncated,
|
||||
text,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
async function readOnlyPreviewFetch(resource, options = {}) {
|
||||
async function readOnlyPreviewFetchBridge(resource, options = {}) {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("Preview fetch is unavailable in this runtime.");
|
||||
}
|
||||
@ -246,10 +373,14 @@ async function readOnlyPreviewFetch(resource, options = {}) {
|
||||
if (!["GET", "HEAD"].includes(method)) {
|
||||
throw new Error("Preview fetch only allows read-only GET and HEAD requests.");
|
||||
}
|
||||
const target = new URL(String(resource || ""));
|
||||
if (target.protocol !== "data:") {
|
||||
throw new Error("Preview does not send external network requests.");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PREVIEW_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(resource, {
|
||||
const response = await fetch(target, {
|
||||
...options,
|
||||
method,
|
||||
body: undefined,
|
||||
@ -262,11 +393,11 @@ async function readOnlyPreviewFetch(resource, options = {}) {
|
||||
? buffer.subarray(0, MAX_PREVIEW_FETCH_BYTES)
|
||||
: buffer;
|
||||
const text = limited.toString("utf8");
|
||||
const arrayBuffer = limited.buffer.slice(
|
||||
limited.byteOffset,
|
||||
limited.byteOffset + limited.byteLength
|
||||
);
|
||||
return Object.freeze({
|
||||
const headers = Object.create(null);
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[String(key).toLowerCase()] = String(value);
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
@ -274,18 +405,32 @@ async function readOnlyPreviewFetch(resource, options = {}) {
|
||||
preview: true,
|
||||
readOnly: true,
|
||||
truncated: buffer.length > MAX_PREVIEW_FETCH_BYTES,
|
||||
headers: Object.freeze({
|
||||
get: (name) => response.headers.get(name)
|
||||
}),
|
||||
text: async () => text,
|
||||
json: async () => JSON.parse(text || "null"),
|
||||
arrayBuffer: async () => arrayBuffer
|
||||
});
|
||||
headers,
|
||||
text,
|
||||
bytes: Array.from(limited)
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePreviewUrl(input, base) {
|
||||
const parsed = base === undefined ? new URL(input) : new URL(input, base);
|
||||
return {
|
||||
href: parsed.href,
|
||||
origin: parsed.origin,
|
||||
protocol: parsed.protocol,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
host: parsed.host,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
pathname: parsed.pathname,
|
||||
search: parsed.search,
|
||||
hash: parsed.hash
|
||||
};
|
||||
}
|
||||
|
||||
function createSerializableContext() {
|
||||
return {
|
||||
platform: "discord",
|
||||
|
||||
116
src/services/command-random.js
Normal file
116
src/services/command-random.js
Normal file
@ -0,0 +1,116 @@
|
||||
"use strict";
|
||||
|
||||
const CONDITION_PATTERN = /^(<=|>=|<|>|==|=)\s*(-?\d+)$/;
|
||||
|
||||
function normalizeRandomReplies(value) {
|
||||
let rows = value;
|
||||
if (typeof rows === "string") {
|
||||
try { rows = JSON.parse(rows || "[]"); } catch { rows = []; }
|
||||
}
|
||||
if (!Array.isArray(rows)) return [];
|
||||
return rows.map((row) => ({
|
||||
text: String(row?.text || "").trim(),
|
||||
weight: integerValue(row?.weight, 1),
|
||||
condition: String(row?.condition || "").trim().replace(/\s+/g, "")
|
||||
})).filter((row) => row.text);
|
||||
}
|
||||
|
||||
function parseRandomRepliesFromBody(body = {}) {
|
||||
const texts = arrayValue(body.random_text);
|
||||
const weights = arrayValue(body.random_weight);
|
||||
const conditions = arrayValue(body.random_condition);
|
||||
return normalizeRandomReplies(texts.map((text, index) => ({
|
||||
text,
|
||||
weight: weights[index] ?? 1,
|
||||
condition: conditions[index] ?? ""
|
||||
})));
|
||||
}
|
||||
|
||||
function normalizeRandomConfig({ replies, rngEnabled, rngMin, rngMax } = {}) {
|
||||
return {
|
||||
replies: normalizeRandomReplies(replies),
|
||||
rngEnabled: rngEnabled === true || rngEnabled === 1 || rngEnabled === "on" || rngEnabled === "true",
|
||||
rngMin: integerValue(rngMin, 1),
|
||||
rngMax: integerValue(rngMax, 100)
|
||||
};
|
||||
}
|
||||
|
||||
function validateRandomConfig(input = {}) {
|
||||
const config = normalizeRandomConfig(input);
|
||||
const errors = [];
|
||||
if (!config.replies.length) errors.push("Add at least one random reply.");
|
||||
if (config.replies.length > 100) errors.push("Random Reply supports at most 100 messages.");
|
||||
if (!Number.isInteger(config.rngMin) || !Number.isInteger(config.rngMax) || config.rngMin < -1000000 || config.rngMin > 1000000 || config.rngMax < -1000000 || config.rngMax > 1000000) {
|
||||
errors.push("RNG limits must be whole numbers between -1,000,000 and 1,000,000.");
|
||||
}
|
||||
if (config.rngMin > config.rngMax) errors.push("RNG minimum must be less than or equal to the maximum.");
|
||||
config.replies.forEach((reply, index) => {
|
||||
if (reply.text.length > 2000) errors.push(`Reply ${index + 1} is longer than 2,000 characters.`);
|
||||
if (!Number.isInteger(reply.weight) || reply.weight < 1 || reply.weight > 999) {
|
||||
errors.push(`Reply ${index + 1} weight must be a whole number from 1 to 999.`);
|
||||
}
|
||||
if (reply.condition && !CONDITION_PATTERN.test(reply.condition)) {
|
||||
errors.push(`Reply ${index + 1} condition must look like <20, >=10, or =5.`);
|
||||
}
|
||||
if (reply.condition && !config.rngEnabled) {
|
||||
errors.push(`Turn on RNG before adding a condition to reply ${index + 1}.`);
|
||||
}
|
||||
});
|
||||
return { ok: errors.length === 0, errors, config };
|
||||
}
|
||||
|
||||
function selectRandomReply(input = {}, random = Math.random) {
|
||||
const validation = validateRandomConfig(input);
|
||||
if (!validation.ok) throw new Error(validation.errors[0]);
|
||||
const config = validation.config;
|
||||
const rng = config.rngEnabled
|
||||
? config.rngMin + Math.floor(safeRandom(random) * (config.rngMax - config.rngMin + 1))
|
||||
: null;
|
||||
const eligible = config.replies.filter((reply) => !reply.condition || conditionMatches(reply.condition, rng));
|
||||
if (!eligible.length) throw new Error(`No random reply matches RNG value ${rng}.`);
|
||||
const totalWeight = eligible.reduce((total, reply) => total + reply.weight, 0);
|
||||
let target = safeRandom(random) * totalWeight;
|
||||
for (const reply of eligible) {
|
||||
target -= reply.weight;
|
||||
if (target < 0) return { reply, rng, eligibleCount: eligible.length };
|
||||
}
|
||||
return { reply: eligible[eligible.length - 1], rng, eligibleCount: eligible.length };
|
||||
}
|
||||
|
||||
function conditionMatches(condition, value) {
|
||||
const match = String(condition || "").match(CONDITION_PATTERN);
|
||||
if (!match || !Number.isFinite(value)) return false;
|
||||
const expected = Number(match[2]);
|
||||
if (match[1] === "<") return value < expected;
|
||||
if (match[1] === "<=") return value <= expected;
|
||||
if (match[1] === ">") return value > expected;
|
||||
if (match[1] === ">=") return value >= expected;
|
||||
return value === expected;
|
||||
}
|
||||
|
||||
function safeRandom(random) {
|
||||
const value = Number(typeof random === "function" ? random() : Math.random());
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(value, 0.999999999999));
|
||||
}
|
||||
|
||||
function arrayValue(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value === undefined || value === null) return [];
|
||||
return [value];
|
||||
}
|
||||
|
||||
function integerValue(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : Number.NaN;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
conditionMatches,
|
||||
normalizeRandomConfig,
|
||||
normalizeRandomReplies,
|
||||
parseRandomRepliesFromBody,
|
||||
selectRandomReply,
|
||||
validateRandomConfig
|
||||
};
|
||||
@ -5,6 +5,7 @@ const {
|
||||
runAdvancedCommand,
|
||||
normalizeCommandResult
|
||||
} = require("./commands");
|
||||
const { normalizeRandomReplies, selectRandomReply } = require("./command-random");
|
||||
const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
|
||||
const placeholders = require("./placeholders");
|
||||
|
||||
@ -138,7 +139,7 @@ function createCommandRouter({ settings }) {
|
||||
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT response, mode, language, code, platform FROM custom_commands WHERE trigger = ? AND enabled = 1"
|
||||
"SELECT response, mode, language, code, platform, random_replies_json, rng_enabled, rng_min, rng_max FROM custom_commands WHERE trigger = ? AND enabled = 1"
|
||||
)
|
||||
.get(trigger);
|
||||
if (!row) {
|
||||
@ -175,6 +176,26 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
||||
} else {
|
||||
await safeReply(reply, "Command ran but returned no output.");
|
||||
}
|
||||
} else if (row.mode === "random") {
|
||||
const selected = selectRandomReply({
|
||||
replies: normalizeRandomReplies(row.random_replies_json),
|
||||
rngEnabled: Boolean(row.rng_enabled),
|
||||
rngMin: row.rng_min,
|
||||
rngMax: row.rng_max
|
||||
});
|
||||
const rendered = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
template: selected.reply.text,
|
||||
outputAudience: "user",
|
||||
user: {
|
||||
id: ctx.user.id,
|
||||
username: ctx.user.username,
|
||||
isAdmin: false,
|
||||
isMod: false
|
||||
},
|
||||
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, command: { rng: selected.rng } }
|
||||
});
|
||||
await safeReply(reply, rendered.rendered);
|
||||
} else {
|
||||
const rendered = await placeholders.renderTemplate({
|
||||
fieldId: "core.custom_commands.static_response",
|
||||
|
||||
@ -3,6 +3,11 @@ const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const {
|
||||
prepareJavaScriptCommand,
|
||||
preparePythonCommand,
|
||||
resolveJavaScriptHandler
|
||||
} = require("./command-code");
|
||||
|
||||
function buildCommandContext({ platform, user, message, args, argsText }) {
|
||||
return {
|
||||
@ -33,12 +38,12 @@ async function runJsCommand(code, ctx) {
|
||||
exports: {}
|
||||
};
|
||||
const context = vm.createContext(sandbox);
|
||||
const script = new vm.Script(code, { filename: "command.js" });
|
||||
const script = new vm.Script(prepareJavaScriptCommand(code), { filename: "command.js" });
|
||||
script.runInContext(context, { timeout: 1000 });
|
||||
|
||||
const handler = context.run || context.module.exports || context.exports;
|
||||
const handler = resolveJavaScriptHandler(context);
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("Dynamic commands must export a run(ctx) function.");
|
||||
throw new Error("Dynamic command did not create a runnable handler.");
|
||||
}
|
||||
const result = handler(ctx);
|
||||
if (result && typeof result.then === "function") {
|
||||
@ -50,7 +55,7 @@ async function runJsCommand(code, ctx) {
|
||||
async function runPythonCommand(code, ctx) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(ctx);
|
||||
const encoded = Buffer.from(code, "utf8").toString("base64");
|
||||
const encoded = Buffer.from(preparePythonCommand(code), "utf8").toString("base64");
|
||||
const script = `
|
||||
import base64, json, sys, traceback
|
||||
ctx = json.loads(sys.stdin.read() or "{}")
|
||||
@ -59,7 +64,7 @@ globals_dict = {}
|
||||
try:
|
||||
exec(code, globals_dict)
|
||||
if "run" not in globals_dict:
|
||||
raise Exception("Define a run(ctx) function.")
|
||||
raise Exception("Dynamic command did not create a runnable handler.")
|
||||
result = globals_dict["run"](ctx)
|
||||
if result is None:
|
||||
sys.exit(0)
|
||||
|
||||
@ -129,6 +129,10 @@ function migrate() {
|
||||
preview_error TEXT,
|
||||
preview_generated_at INTEGER,
|
||||
preview_dynamic_segments TEXT NOT NULL DEFAULT '[]',
|
||||
random_replies_json TEXT NOT NULL DEFAULT '[]',
|
||||
rng_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
rng_min INTEGER NOT NULL DEFAULT 1,
|
||||
rng_max INTEGER NOT NULL DEFAULT 100,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@ -222,6 +226,16 @@ function migrate() {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedback_merges (
|
||||
source_feedback_id TEXT PRIMARY KEY,
|
||||
target_feedback_id TEXT NOT NULL,
|
||||
merged_by TEXT,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS feedback_merges_target_idx ON feedback_merges (target_feedback_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedback_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
feedback_id TEXT NOT NULL,
|
||||
@ -234,6 +248,67 @@ function migrate() {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overlays (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
canvas_width INTEGER NOT NULL DEFAULT 1920,
|
||||
canvas_height INTEGER NOT NULL DEFAULT 1080,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active_scene_id TEXT,
|
||||
public_token_hash TEXT NOT NULL UNIQUE,
|
||||
public_token_encrypted TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS overlays_sort_idx ON overlays (sort_order, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overlay_scenes (
|
||||
id TEXT PRIMARY KEY,
|
||||
overlay_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
public_token_hash TEXT NOT NULL UNIQUE,
|
||||
public_token_encrypted TEXT NOT NULL,
|
||||
obs_scene_name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS overlay_scenes_overlay_idx ON overlay_scenes (overlay_id, sort_order, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overlay_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
scene_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (scene_id) REFERENCES overlay_scenes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS overlay_modules_scene_idx ON overlay_modules (scene_id, sort_order, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overlay_obs_settings (
|
||||
overlay_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
provider TEXT NOT NULL DEFAULT 'local_obs_websocket',
|
||||
endpoint TEXT NOT NULL DEFAULT 'ws://127.0.0.1:4455',
|
||||
password_encrypted TEXT,
|
||||
sync_direction TEXT NOT NULL DEFAULT 'none',
|
||||
reconnect INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (overlay_id) REFERENCES overlays(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
const columns = db
|
||||
@ -270,6 +345,18 @@ function migrate() {
|
||||
if (!columns.includes("preview_dynamic_segments")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_dynamic_segments TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
if (!columns.includes("random_replies_json")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN random_replies_json TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
if (!columns.includes("rng_enabled")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!columns.includes("rng_min")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_min INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
if (!columns.includes("rng_max")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_max INTEGER NOT NULL DEFAULT 100");
|
||||
}
|
||||
|
||||
const pageColumns = db
|
||||
.prepare("PRAGMA table_info(custom_pages)")
|
||||
@ -310,6 +397,17 @@ function migrate() {
|
||||
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER");
|
||||
}
|
||||
|
||||
const overlayColumns = db
|
||||
.prepare("PRAGMA table_info(overlays)")
|
||||
.all()
|
||||
.map((column) => column.name);
|
||||
if (!overlayColumns.includes("canvas_width")) {
|
||||
db.exec("ALTER TABLE overlays ADD COLUMN canvas_width INTEGER NOT NULL DEFAULT 1920");
|
||||
}
|
||||
if (!overlayColumns.includes("canvas_height")) {
|
||||
db.exec("ALTER TABLE overlays ADD COLUMN canvas_height INTEGER NOT NULL DEFAULT 1080");
|
||||
}
|
||||
|
||||
migrateLegacyUsers();
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ const crypto = require("crypto");
|
||||
const DELAY_MS = 3000;
|
||||
const EXPIRES_MS = 30000;
|
||||
const MAX_PENDING = 20;
|
||||
const DESTRUCTIVE_PATH = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset)(?:\/|$)/i;
|
||||
const DESTRUCTIVE_PATH = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset|revert)(?:\/|$)/i;
|
||||
|
||||
function isDestructivePath(value) {
|
||||
return DESTRUCTIVE_PATH.test(normalizeAction(value));
|
||||
|
||||
@ -211,24 +211,30 @@ function findSimilarFeedback(input = {}, options = {}) {
|
||||
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "";
|
||||
const currentUrl = cleanUrl(input.current_url);
|
||||
const pagePath = pagePathKey(currentUrl);
|
||||
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
|
||||
if (summary.length < 6 && description.length < 12 && !pagePath) return [];
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, submitter_id, summary, description, category, severity, scope_type, scope_label, current_url, page_title, status, created_at, updated_at, last_activity_at " +
|
||||
"SELECT id, submitter_id, summary, description, steps_to_reproduce, expected_behavior, actual_behavior, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, status, created_at, updated_at, last_activity_at " +
|
||||
"FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " +
|
||||
"ORDER BY last_activity_at DESC LIMIT 150"
|
||||
)
|
||||
.all();
|
||||
const queryTokens = tokenSet(`${summary} ${description}`);
|
||||
const queryTokens = tokenSet(`${summary} ${description} ${input.steps_to_reproduce || ""} ${input.expected_behavior || ""} ${input.actual_behavior || ""} ${Object.values(targetMetadata).join(" ")}`);
|
||||
const matches = rows
|
||||
.map((row) => {
|
||||
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.scope_label || ""} ${row.page_title || ""}`);
|
||||
const rowTarget = parseJson(row.target_metadata_json, {});
|
||||
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.steps_to_reproduce || ""} ${row.expected_behavior || ""} ${row.actual_behavior || ""} ${row.scope_label || ""} ${row.page_title || ""} ${Object.values(rowTarget).join(" ")}`);
|
||||
const samePath = pagePath && pagePath === pagePathKey(row.current_url);
|
||||
const sameTarget = targetMetadata.path && targetMetadata.path === rowTarget.path;
|
||||
const sameSelector = targetMetadata.selector && targetMetadata.selector === rowTarget.selector;
|
||||
const score =
|
||||
jaccardScore(queryTokens, rowTokens) +
|
||||
(scopeType && row.scope_type === scopeType ? 0.25 : 0) +
|
||||
(category && row.category === category ? 0.15 : 0) +
|
||||
(samePath ? 0.35 : 0);
|
||||
(samePath ? 0.35 : 0) +
|
||||
(sameTarget ? 0.4 : 0) +
|
||||
(sameSelector ? 0.25 : 0);
|
||||
return { row, score };
|
||||
})
|
||||
.filter(({ score }) => score >= 0.32)
|
||||
@ -291,6 +297,47 @@ function getFeedbackForAdmin(id) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
function mergeFeedback(sourceId, targetId, input = {}, actor) {
|
||||
const source = getFeedbackForAdmin(sourceId);
|
||||
const target = getFeedbackForAdmin(targetId);
|
||||
if (!source || !target) throw new Error("Both feedback items must exist before they can be merged.");
|
||||
if (source.id === target.id) throw new Error("A feedback item cannot be merged into itself.");
|
||||
if (source.status === "deleted" || target.status === "deleted") throw new Error("Deleted feedback cannot be merged.");
|
||||
if (target.merged_into) throw new Error("Choose the final canonical feedback item, not another duplicate.");
|
||||
const reason = cleanText(input.reason, 1000) || `Merged into ${target.summary}.`;
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
"INSERT INTO feedback_merges (source_feedback_id, target_feedback_id, merged_by, reason, created_at) VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(source_feedback_id) DO UPDATE SET target_feedback_id = excluded.target_feedback_id, merged_by = excluded.merged_by, reason = excluded.reason, created_at = excluded.created_at"
|
||||
).run(source.id, target.id, actor?.id || null, reason, now);
|
||||
db.prepare(
|
||||
"UPDATE feedback_entries SET status = 'duplicate', updated_at = ?, last_activity_at = ?, deleted_at = NULL WHERE id = ?"
|
||||
).run(now, now, source.id);
|
||||
addStatusHistory(source.id, "duplicate", actor?.id || null, `Merged into feedback ${target.id}: ${reason}`, now);
|
||||
db.prepare(
|
||||
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
|
||||
).run(target.id, actor?.id || null, `Merged feedback ${source.id} (${source.summary}). ${reason}`, now);
|
||||
touchFeedback(target.id, now);
|
||||
})();
|
||||
return { source: getFeedbackForAdmin(source.id), target: getFeedbackForAdmin(target.id) };
|
||||
}
|
||||
|
||||
function unmergeFeedback(sourceId, input = {}, actor) {
|
||||
const source = getFeedbackForAdmin(sourceId);
|
||||
if (!source?.merged_into) throw new Error("That feedback item is not currently merged.");
|
||||
const now = Date.now();
|
||||
const note = cleanText(input.reason, 1000) || "Duplicate merge was undone.";
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM feedback_merges WHERE source_feedback_id = ?").run(source.id);
|
||||
db.prepare(
|
||||
"UPDATE feedback_entries SET status = 'reviewed', updated_at = ?, last_activity_at = ?, deleted_at = NULL WHERE id = ?"
|
||||
).run(now, now, source.id);
|
||||
addStatusHistory(source.id, "reviewed", actor?.id || null, note, now);
|
||||
})();
|
||||
return getFeedbackForAdmin(source.id);
|
||||
}
|
||||
|
||||
function listFeedbackForAdmin(filters = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
@ -315,9 +362,21 @@ function listFeedbackForAdmin(filters = {}) {
|
||||
}
|
||||
if (filters.area) {
|
||||
where.push(
|
||||
"(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?))"
|
||||
"(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?) OR lower(feedback_entries.target_metadata_json) LIKE lower(?))"
|
||||
);
|
||||
params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`);
|
||||
params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`);
|
||||
}
|
||||
if (filters.route) {
|
||||
where.push("lower(feedback_entries.current_url) LIKE lower(?)");
|
||||
params.push(`%${filters.route}%`);
|
||||
}
|
||||
if (filters.plugin) {
|
||||
where.push("(lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.scope_label) LIKE lower(?))");
|
||||
params.push(`%/plugins/${filters.plugin}%`, `%${filters.plugin}%`);
|
||||
}
|
||||
if (filters.target) {
|
||||
where.push("(lower(feedback_entries.target_metadata_json) LIKE lower(?) OR lower(feedback_entries.scope_label) LIKE lower(?))");
|
||||
params.push(`%${filters.target}%`, `%${filters.target}%`);
|
||||
}
|
||||
const from = parseDateBoundary(filters.date_from, "start");
|
||||
if (from) {
|
||||
@ -585,10 +644,12 @@ function supportFeedback(id, actor) {
|
||||
if (!row || row.deleted_at || row.status === "deleted") {
|
||||
throw new Error("Feedback item was not found.");
|
||||
}
|
||||
const merged = db.prepare("SELECT target_feedback_id FROM feedback_merges WHERE source_feedback_id = ?").get(id);
|
||||
const effectiveId = merged?.target_feedback_id || id;
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)"
|
||||
).run(id, actor.id, Date.now());
|
||||
return supportSummary([id], actor.id).counts.get(id) || 0;
|
||||
).run(effectiveId, actor.id, Date.now());
|
||||
return supportSummary([effectiveId], actor.id).counts.get(effectiveId) || 0;
|
||||
}
|
||||
|
||||
function adminUpdateFeedback(id, input, actor) {
|
||||
@ -644,6 +705,13 @@ function deleteFeedback(id, options = {}) {
|
||||
}
|
||||
const attachments = attachmentsFor(id);
|
||||
db.transaction(() => {
|
||||
const mergedSources = db.prepare("SELECT source_feedback_id FROM feedback_merges WHERE target_feedback_id = ?").all(id);
|
||||
for (const source of mergedSources) {
|
||||
const now = Date.now();
|
||||
db.prepare("UPDATE feedback_entries SET status = 'reviewed', updated_at = ?, last_activity_at = ? WHERE id = ?")
|
||||
.run(now, now, source.source_feedback_id);
|
||||
}
|
||||
db.prepare("DELETE FROM feedback_merges WHERE source_feedback_id = ? OR target_feedback_id = ?").run(id, id);
|
||||
db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id);
|
||||
db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id);
|
||||
db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id);
|
||||
@ -1747,17 +1815,19 @@ function supportSummary(ids, userId) {
|
||||
return { counts, mine };
|
||||
}
|
||||
const placeholders = cleanIds.map(() => "?").join(",");
|
||||
db.prepare(
|
||||
`SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id`
|
||||
)
|
||||
const related = new Map(cleanIds.map((id) => [id, new Set([id])]));
|
||||
db.prepare(`SELECT source_feedback_id, target_feedback_id FROM feedback_merges WHERE target_feedback_id IN (${placeholders})`)
|
||||
.all(...cleanIds)
|
||||
.forEach((row) => counts.set(row.feedback_id, row.count));
|
||||
if (userId) {
|
||||
db.prepare(
|
||||
`SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})`
|
||||
)
|
||||
.all(userId, ...cleanIds)
|
||||
.forEach((row) => mine.add(row.feedback_id));
|
||||
.forEach((row) => related.get(row.target_feedback_id)?.add(row.source_feedback_id));
|
||||
const allIds = [...new Set([...related.values()].flatMap((set) => [...set]))];
|
||||
const allPlaceholders = allIds.map(() => "?").join(",");
|
||||
const supporters = db.prepare(
|
||||
`SELECT feedback_id, user_id FROM feedback_support WHERE feedback_id IN (${allPlaceholders})`
|
||||
).all(...allIds);
|
||||
for (const [id, relatedIds] of related) {
|
||||
const users = new Set(supporters.filter((row) => relatedIds.has(row.feedback_id)).map((row) => row.user_id));
|
||||
if (users.size) counts.set(id, users.size);
|
||||
if (userId && users.has(userId)) mine.add(id);
|
||||
}
|
||||
return { counts, mine };
|
||||
}
|
||||
@ -1782,7 +1852,7 @@ function enforceRateLimit(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFeedback(row, { admin }) {
|
||||
function hydrateFeedback(row, { admin, includeMerge = true }) {
|
||||
const parsed = {
|
||||
...decorateLabels(row),
|
||||
target_metadata: parseJson(row.target_metadata_json, {}),
|
||||
@ -1804,9 +1874,43 @@ function hydrateFeedback(row, { admin }) {
|
||||
delete parsed.diagnostics_json;
|
||||
parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter);
|
||||
}
|
||||
if (includeMerge) Object.assign(parsed, mergeContext(row.id, admin));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function mergeContext(feedbackId, admin) {
|
||||
const mergedInto = db.prepare(
|
||||
"SELECT feedback_merges.*, feedback_entries.summary, feedback_entries.status FROM feedback_merges " +
|
||||
"JOIN feedback_entries ON feedback_entries.id = feedback_merges.target_feedback_id WHERE source_feedback_id = ?"
|
||||
).get(feedbackId);
|
||||
const sourceRows = db.prepare(
|
||||
"SELECT feedback_entries.* FROM feedback_merges JOIN feedback_entries ON feedback_entries.id = feedback_merges.source_feedback_id " +
|
||||
"WHERE feedback_merges.target_feedback_id = ? ORDER BY feedback_merges.created_at ASC"
|
||||
).all(feedbackId);
|
||||
const reporterIds = new Set();
|
||||
const ownReporter = db.prepare("SELECT submitter_id FROM feedback_entries WHERE id = ?").get(feedbackId)?.submitter_id;
|
||||
if (ownReporter) reporterIds.add(ownReporter);
|
||||
sourceRows.forEach((row) => reporterIds.add(row.submitter_id));
|
||||
return {
|
||||
merged_into: mergedInto ? {
|
||||
id: mergedInto.target_feedback_id,
|
||||
summary: mergedInto.summary,
|
||||
status: mergedInto.status,
|
||||
reason: mergedInto.reason,
|
||||
created_at: mergedInto.created_at
|
||||
} : null,
|
||||
merged_sources: sourceRows.map((source) => {
|
||||
const hydrated = hydrateFeedback(source, { admin, includeMerge: false });
|
||||
if (!admin) {
|
||||
hydrated.screenshot = null;
|
||||
hydrated.attachments = [];
|
||||
}
|
||||
return hydrated;
|
||||
}),
|
||||
reporter_count: reporterIds.size
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentsFor(feedbackId) {
|
||||
return db
|
||||
.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC")
|
||||
@ -2042,7 +2146,9 @@ module.exports = {
|
||||
listMyFeedback,
|
||||
listPublicFeedback,
|
||||
markFeedbackViewed,
|
||||
mergeFeedback,
|
||||
notificationSummary,
|
||||
supportFeedback,
|
||||
unmergeFeedback,
|
||||
addSubmitterComment
|
||||
};
|
||||
|
||||
621
src/services/overlay-connectors.js
Normal file
621
src/services/overlay-connectors.js
Normal file
@ -0,0 +1,621 @@
|
||||
const { OBSWebSocket, EventSubscription } = require("obs-websocket-js");
|
||||
const { publishWebEvent } = require("./web-events");
|
||||
const {
|
||||
getObsSettings,
|
||||
getOverlay,
|
||||
listEnabledObsSettings,
|
||||
onOverlayChanged,
|
||||
setActiveScene
|
||||
} = require("./overlays");
|
||||
|
||||
const providers = new Map();
|
||||
const browserBridgeConnectors = new Map();
|
||||
|
||||
const OBS_BROWSER_CONTROL_LEVELS = [
|
||||
"No OBS access",
|
||||
"Read OBS status",
|
||||
"Read scenes and transitions",
|
||||
"Basic OBS actions",
|
||||
"Advanced OBS control",
|
||||
"Full OBS control"
|
||||
];
|
||||
|
||||
function cleanNames(values) {
|
||||
return Array.isArray(values)
|
||||
? values.slice(0, 250).map((value) => {
|
||||
const candidate = value && typeof value === "object"
|
||||
? (value.name || value.sceneName || value.transitionName || value.profileName || value.sceneCollectionName)
|
||||
: value;
|
||||
return String(candidate || "").trim().slice(0, 200);
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function registerOverlayConnectorProvider(provider) {
|
||||
if (!provider?.id || typeof provider.create !== "function") {
|
||||
throw new Error("Connector providers require an id and create(settings) function.");
|
||||
}
|
||||
providers.set(provider.id, provider);
|
||||
return () => providers.delete(provider.id);
|
||||
}
|
||||
|
||||
function listOverlayConnectorProviders() {
|
||||
return Array.from(providers.values()).map(({ id, label, available = true, description }) => ({
|
||||
id,
|
||||
label,
|
||||
available,
|
||||
description
|
||||
}));
|
||||
}
|
||||
|
||||
class LocalObsWebSocketConnector {
|
||||
constructor(settings) {
|
||||
this.settings = settings;
|
||||
this.client = new OBSWebSocket();
|
||||
this.state = "disconnected";
|
||||
this.error = null;
|
||||
this.currentScene = null;
|
||||
this.scenes = [];
|
||||
this.transitions = [];
|
||||
this.currentTransition = null;
|
||||
this.canvasWidth = null;
|
||||
this.canvasHeight = null;
|
||||
this.profiles = [];
|
||||
this.currentProfile = null;
|
||||
this.sceneCollections = [];
|
||||
this.currentSceneCollection = null;
|
||||
this.outputs = {};
|
||||
this.sceneListeners = new Set();
|
||||
this.closedListeners = new Set();
|
||||
this.statusListeners = new Set();
|
||||
this.client.on("CurrentProgramSceneChanged", ({ sceneName }) => {
|
||||
this.currentScene = sceneName || null;
|
||||
this.sceneListeners.forEach((listener) => listener(this.currentScene));
|
||||
});
|
||||
this.client.on("ConnectionClosed", (error) => {
|
||||
this.state = "disconnected";
|
||||
this.error = error?.message || null;
|
||||
this.closedListeners.forEach((listener) => listener(error));
|
||||
});
|
||||
this.client.on("ConnectionError", (error) => {
|
||||
this.state = "error";
|
||||
this.error = error?.message || "OBS connection failed.";
|
||||
});
|
||||
const refreshStatus = () => this.refreshSetupState().then(() => {
|
||||
this.statusListeners.forEach((listener) => listener(this.status()));
|
||||
}).catch(() => {});
|
||||
this.client.on("CurrentProfileChanged", ({ profileName }) => {
|
||||
this.currentProfile = profileName || null;
|
||||
refreshStatus();
|
||||
});
|
||||
this.client.on("ProfileListChanged", refreshStatus);
|
||||
this.client.on("CurrentSceneCollectionChanged", ({ sceneCollectionName }) => {
|
||||
this.currentSceneCollection = sceneCollectionName || null;
|
||||
refreshStatus();
|
||||
});
|
||||
this.client.on("SceneCollectionListChanged", refreshStatus);
|
||||
this.client.on("CurrentSceneTransitionChanged", ({ transitionName }) => {
|
||||
this.currentTransition = transitionName || null;
|
||||
refreshStatus();
|
||||
});
|
||||
this.client.on("SceneTransitionListChanged", refreshStatus);
|
||||
for (const eventName of [
|
||||
"StreamStateChanged", "RecordStateChanged", "ReplayBufferStateChanged", "VirtualcamStateChanged"
|
||||
]) this.client.on(eventName, refreshStatus);
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.state = "connecting";
|
||||
this.error = null;
|
||||
try {
|
||||
await this.client.connect(this.settings.endpoint, this.settings.password || undefined, {
|
||||
eventSubscriptions: EventSubscription.Scenes | EventSubscription.Transitions | EventSubscription.Config | EventSubscription.Outputs
|
||||
});
|
||||
this.state = "connected";
|
||||
const current = await this.client.call("GetCurrentProgramScene");
|
||||
this.currentScene = current.currentProgramSceneName || null;
|
||||
await this.refreshSetupState();
|
||||
return this.status();
|
||||
} catch (error) {
|
||||
this.state = "error";
|
||||
this.error = error?.message || "OBS connection failed.";
|
||||
try { await this.client.disconnect(); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
try { await this.client.disconnect(); } catch {}
|
||||
this.state = "disconnected";
|
||||
}
|
||||
|
||||
onSceneChanged(listener) {
|
||||
this.sceneListeners.add(listener);
|
||||
return () => this.sceneListeners.delete(listener);
|
||||
}
|
||||
|
||||
onClosed(listener) {
|
||||
this.closedListeners.add(listener);
|
||||
return () => this.closedListeners.delete(listener);
|
||||
}
|
||||
|
||||
onStatusChanged(listener) {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
async listScenes() {
|
||||
const result = await this.client.call("GetSceneList");
|
||||
this.scenes = (result.scenes || []).map((scene) => scene.sceneName).filter(Boolean);
|
||||
return this.scenes;
|
||||
}
|
||||
|
||||
async getActiveScene() {
|
||||
const result = await this.client.call("GetCurrentProgramScene");
|
||||
this.currentScene = result.currentProgramSceneName || null;
|
||||
return this.currentScene;
|
||||
}
|
||||
|
||||
async setActiveScene(sceneName) {
|
||||
await this.client.call("SetCurrentProgramScene", { sceneName });
|
||||
this.currentScene = sceneName;
|
||||
}
|
||||
|
||||
async refreshSetupState() {
|
||||
const results = await Promise.allSettled([
|
||||
this.listScenes(),
|
||||
this.client.call("GetTransitionList"),
|
||||
this.client.call("GetVideoSettings"),
|
||||
this.client.call("GetProfileList"),
|
||||
this.client.call("GetSceneCollectionList"),
|
||||
this.client.call("GetStreamStatus"),
|
||||
this.client.call("GetRecordStatus"),
|
||||
this.client.call("GetReplayBufferStatus"),
|
||||
this.client.call("GetVirtualCamStatus")
|
||||
]);
|
||||
if (results[1].status === "fulfilled") {
|
||||
this.transitions = (results[1].value.transitions || []).map((item) => item.transitionName).filter(Boolean);
|
||||
this.currentTransition = results[1].value.currentSceneTransitionName || null;
|
||||
}
|
||||
if (results[2].status === "fulfilled") {
|
||||
this.canvasWidth = Number(results[2].value.baseWidth) || null;
|
||||
this.canvasHeight = Number(results[2].value.baseHeight) || null;
|
||||
}
|
||||
if (results[3].status === "fulfilled") {
|
||||
this.profiles = cleanNames(results[3].value.profiles);
|
||||
this.currentProfile = results[3].value.currentProfileName || null;
|
||||
}
|
||||
if (results[4].status === "fulfilled") {
|
||||
this.sceneCollections = cleanNames(results[4].value.sceneCollections);
|
||||
this.currentSceneCollection = results[4].value.currentSceneCollectionName || null;
|
||||
}
|
||||
this.outputs = {
|
||||
streaming: results[5].status === "fulfilled" && Boolean(results[5].value.outputActive),
|
||||
recording: results[6].status === "fulfilled" && Boolean(results[6].value.outputActive),
|
||||
recording_paused: results[6].status === "fulfilled" && Boolean(results[6].value.outputPaused),
|
||||
replay_buffer: results[7].status === "fulfilled" && Boolean(results[7].value.outputActive),
|
||||
virtual_camera: results[8].status === "fulfilled" && Boolean(results[8].value.outputActive)
|
||||
};
|
||||
}
|
||||
|
||||
status() {
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
current_scene: this.currentScene,
|
||||
scenes: this.scenes,
|
||||
transitions: this.transitions,
|
||||
current_transition: this.currentTransition,
|
||||
canvas_width: this.canvasWidth,
|
||||
canvas_height: this.canvasHeight,
|
||||
profiles: this.profiles,
|
||||
current_profile: this.currentProfile,
|
||||
scene_collections: this.sceneCollections,
|
||||
current_scene_collection: this.currentSceneCollection,
|
||||
outputs: this.outputs,
|
||||
profiles_supported: true,
|
||||
scene_collections_supported: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ObsBrowserBridgeConnector {
|
||||
constructor(settings) {
|
||||
this.settings = settings;
|
||||
this.overlayId = settings.overlay_id;
|
||||
this.state = "waiting";
|
||||
this.error = "Waiting for this overlay to load inside OBS.";
|
||||
this.currentScene = null;
|
||||
this.scenes = [];
|
||||
this.transitions = [];
|
||||
this.currentTransition = null;
|
||||
this.outputs = {};
|
||||
this.controlLevel = 0;
|
||||
this.pluginVersion = null;
|
||||
this.canvasWidth = null;
|
||||
this.canvasHeight = null;
|
||||
this.sourceActive = null;
|
||||
this.sourceVisible = null;
|
||||
this.instanceId = null;
|
||||
this.lastReportAt = 0;
|
||||
this.sceneListeners = new Set();
|
||||
this.closedListeners = new Set();
|
||||
this.statusListeners = new Set();
|
||||
this.staleTimer = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
browserBridgeConnectors.set(this.overlayId, this);
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (browserBridgeConnectors.get(this.overlayId) === this) browserBridgeConnectors.delete(this.overlayId);
|
||||
clearTimeout(this.staleTimer);
|
||||
this.state = "disconnected";
|
||||
}
|
||||
|
||||
onSceneChanged(listener) {
|
||||
this.sceneListeners.add(listener);
|
||||
return () => this.sceneListeners.delete(listener);
|
||||
}
|
||||
|
||||
onClosed(listener) {
|
||||
this.closedListeners.add(listener);
|
||||
return () => this.closedListeners.delete(listener);
|
||||
}
|
||||
|
||||
onStatusChanged(listener) {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
notifyStatus() {
|
||||
this.statusListeners.forEach((listener) => listener(this.status()));
|
||||
}
|
||||
|
||||
acceptReport(report = {}) {
|
||||
const now = Date.now();
|
||||
const instanceId = String(report.instance_id || "").slice(0, 100);
|
||||
if (!instanceId) throw new Error("Browser Bridge instance ID is missing.");
|
||||
const currentIsFresh = this.instanceId && now - this.lastReportAt < 15000;
|
||||
const shouldTakeOver = !currentIsFresh || this.instanceId === instanceId || (report.source_active === true && this.sourceActive !== true);
|
||||
if (!shouldTakeOver) return { accepted: false, primary_instance_id: this.instanceId, ...this.status() };
|
||||
|
||||
const previousScene = this.currentScene;
|
||||
this.instanceId = instanceId;
|
||||
this.lastReportAt = now;
|
||||
this.controlLevel = Math.min(5, Math.max(0, Math.round(Number(report.control_level) || 0)));
|
||||
this.pluginVersion = String(report.plugin_version || "").slice(0, 80) || null;
|
||||
this.currentScene = String(report.current_scene || "").slice(0, 200) || null;
|
||||
this.scenes = cleanNames(report.scenes);
|
||||
this.transitions = cleanNames(report.transitions);
|
||||
this.currentTransition = String(report.current_transition || "").slice(0, 200) || null;
|
||||
this.canvasWidth = Math.min(7680, Math.max(0, Math.round(Number(report.canvas_width) || 0))) || null;
|
||||
this.canvasHeight = Math.min(7680, Math.max(0, Math.round(Number(report.canvas_height) || 0))) || null;
|
||||
this.sourceActive = typeof report.source_active === "boolean" ? report.source_active : null;
|
||||
this.sourceVisible = typeof report.source_visible === "boolean" ? report.source_visible : null;
|
||||
this.outputs = report.outputs && typeof report.outputs === "object" ? {
|
||||
streaming: Boolean(report.outputs.streaming),
|
||||
recording: Boolean(report.outputs.recording),
|
||||
recording_paused: Boolean(report.outputs.recordingPaused || report.outputs.recording_paused),
|
||||
replay_buffer: Boolean(report.outputs.replaybuffer || report.outputs.replay_buffer),
|
||||
virtual_camera: Boolean(report.outputs.virtualcam || report.outputs.virtual_camera)
|
||||
} : {};
|
||||
this.state = "connected";
|
||||
this.error = this.controlLevel < 2
|
||||
? "Choose at least “Read access to user information” in the Browser Source page permissions."
|
||||
: (["overlay_to_obs", "bidirectional"].includes(this.settings.sync_direction) && this.controlLevel < 4
|
||||
? "Advanced OBS access is required when Lumi should change OBS scenes."
|
||||
: null);
|
||||
|
||||
clearTimeout(this.staleTimer);
|
||||
this.staleTimer = setTimeout(() => {
|
||||
this.state = "waiting";
|
||||
this.error = "The OBS Browser Source stopped reporting. Keep it loaded and disable shutdown when hidden for persistent control.";
|
||||
this.notifyStatus();
|
||||
}, 20000);
|
||||
if (this.currentScene && this.currentScene !== previousScene) {
|
||||
this.sceneListeners.forEach((listener) => listener(this.currentScene));
|
||||
}
|
||||
this.notifyStatus();
|
||||
return { accepted: true, primary_instance_id: this.instanceId, ...this.status() };
|
||||
}
|
||||
|
||||
async listScenes() {
|
||||
return this.scenes;
|
||||
}
|
||||
|
||||
async getActiveScene() {
|
||||
return this.currentScene;
|
||||
}
|
||||
|
||||
async setActiveScene(sceneName) {
|
||||
if (this.state !== "connected") throw new Error("The OBS Browser Bridge is not connected.");
|
||||
if (this.controlLevel < 4) throw new Error("Advanced OBS page permission is required to change scenes.");
|
||||
const commandId = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
||||
const delivered = publishWebEvent("overlay:obs-browser-command", {
|
||||
command_id: commandId,
|
||||
target_instance_id: this.instanceId,
|
||||
action: "set_current_scene",
|
||||
scene_name: String(sceneName || "").slice(0, 200)
|
||||
}, { scope: `overlay:${this.overlayId}` });
|
||||
if (!delivered) throw new Error("The OBS Browser Bridge is not listening for commands.");
|
||||
}
|
||||
|
||||
status() {
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
current_scene: this.currentScene,
|
||||
scenes: this.scenes,
|
||||
transitions: this.transitions,
|
||||
current_transition: this.currentTransition,
|
||||
outputs: this.outputs,
|
||||
canvas_width: this.canvasWidth,
|
||||
canvas_height: this.canvasHeight,
|
||||
control_level: this.controlLevel,
|
||||
control_level_label: OBS_BROWSER_CONTROL_LEVELS[this.controlLevel],
|
||||
plugin_version: this.pluginVersion,
|
||||
source_active: this.sourceActive,
|
||||
source_visible: this.sourceVisible,
|
||||
bridge_instance_id: this.instanceId,
|
||||
last_report_at: this.lastReportAt || null,
|
||||
profiles: [],
|
||||
current_profile: null,
|
||||
scene_collections: [],
|
||||
current_scene_collection: null,
|
||||
profiles_supported: false,
|
||||
scene_collections_supported: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registerOverlayConnectorProvider({
|
||||
id: "local_obs_websocket",
|
||||
label: "OBS WebSocket",
|
||||
description: "Connect from Lumi to OBS on the streaming computer or private local network.",
|
||||
create: (settings) => new LocalObsWebSocketConnector(settings)
|
||||
});
|
||||
|
||||
registerOverlayConnectorProvider({
|
||||
id: "obs_browser_bridge",
|
||||
label: "OBS Browser Bridge",
|
||||
description: "Use the Lumi Browser Source itself to read and synchronize OBS without a WebSocket password.",
|
||||
create: (settings) => new ObsBrowserBridgeConnector(settings)
|
||||
});
|
||||
|
||||
registerOverlayConnectorProvider({
|
||||
id: "trusted_lumi_client",
|
||||
label: "Trusted Lumi client (future)",
|
||||
description: "Reserved provider interface for a future trusted client running beside OBS.",
|
||||
available: false,
|
||||
create() {
|
||||
throw new Error("The trusted Lumi client provider is an extension point and is not implemented yet.");
|
||||
}
|
||||
});
|
||||
|
||||
class OverlayConnectorManager {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
this.syncSuppressed = new Set();
|
||||
this.stopped = false;
|
||||
this.removeOverlayListener = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.stopped = false;
|
||||
if (!this.removeOverlayListener) {
|
||||
this.removeOverlayListener = onOverlayChanged((change) => this.handleOverlayChange(change));
|
||||
}
|
||||
for (const settings of listEnabledObsSettings()) {
|
||||
await this.restart(settings.overlay_id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.stopped = true;
|
||||
this.removeOverlayListener?.();
|
||||
this.removeOverlayListener = null;
|
||||
const entries = Array.from(this.connections.values());
|
||||
this.connections.clear();
|
||||
await Promise.all(entries.map(async (entry) => {
|
||||
clearTimeout(entry.reconnectTimer);
|
||||
await entry.connector.disconnect().catch(() => {});
|
||||
}));
|
||||
}
|
||||
|
||||
async restart(overlayId) {
|
||||
await this.disconnect(overlayId);
|
||||
const settings = getObsSettings(overlayId, { includeSecret: true });
|
||||
if (!settings.enabled) return this.status(overlayId);
|
||||
const provider = providers.get(settings.provider);
|
||||
if (!provider || provider.available === false) {
|
||||
const error = provider ? "Connector provider is not implemented." : "Unknown connector provider.";
|
||||
this.connections.set(overlayId, { settings, connector: null, error, state: "unavailable" });
|
||||
this.publishStatus(overlayId);
|
||||
return this.status(overlayId);
|
||||
}
|
||||
const entry = {
|
||||
settings,
|
||||
connector: provider.create(settings),
|
||||
state: "connecting",
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: null,
|
||||
lastOutboundScene: null,
|
||||
lastOutboundAt: 0
|
||||
};
|
||||
this.connections.set(overlayId, entry);
|
||||
entry.connector.onSceneChanged((sceneName) => this.handleObsScene(overlayId, sceneName));
|
||||
entry.connector.onClosed(() => this.scheduleReconnect(overlayId));
|
||||
entry.connector.onStatusChanged?.((status) => {
|
||||
entry.state = status.state || entry.state;
|
||||
entry.error = status.error || null;
|
||||
this.publishStatus(overlayId);
|
||||
});
|
||||
this.publishStatus(overlayId);
|
||||
try {
|
||||
const connectedStatus = await entry.connector.connect();
|
||||
entry.state = connectedStatus?.state || "connected";
|
||||
entry.error = connectedStatus?.error || null;
|
||||
entry.reconnectAttempts = 0;
|
||||
this.publishStatus(overlayId);
|
||||
if (entry.state === "connected") {
|
||||
await this.handleObsScene(overlayId, await entry.connector.getActiveScene());
|
||||
}
|
||||
return this.status(overlayId, { includeScenes: true });
|
||||
} catch (error) {
|
||||
entry.state = "error";
|
||||
entry.error = error?.message || "OBS connection failed.";
|
||||
this.publishStatus(overlayId);
|
||||
this.scheduleReconnect(overlayId);
|
||||
return this.status(overlayId);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(overlayId) {
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry) return;
|
||||
this.connections.delete(overlayId);
|
||||
clearTimeout(entry.reconnectTimer);
|
||||
if (entry.connector) await entry.connector.disconnect().catch(() => {});
|
||||
}
|
||||
|
||||
scheduleReconnect(overlayId) {
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry || this.stopped || !entry.settings.reconnect || entry.reconnectTimer) return;
|
||||
entry.state = "disconnected";
|
||||
entry.reconnectAttempts += 1;
|
||||
const delay = Math.min(30000, 1000 * (2 ** Math.min(entry.reconnectAttempts, 5)));
|
||||
entry.reconnectTimer = setTimeout(() => {
|
||||
entry.reconnectTimer = null;
|
||||
this.restart(overlayId).catch(() => {});
|
||||
}, delay);
|
||||
this.publishStatus(overlayId);
|
||||
}
|
||||
|
||||
async handleObsScene(overlayId, obsSceneName) {
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry || !obsSceneName) return;
|
||||
if (entry.lastOutboundScene === obsSceneName && Date.now() - entry.lastOutboundAt < 2500) return;
|
||||
entry.lastOutboundScene = null;
|
||||
if (!["obs_to_overlay", "bidirectional"].includes(entry.settings.sync_direction)) return;
|
||||
const overlay = getOverlay(overlayId);
|
||||
const match = overlay?.scenes.find((scene) => scene.enabled && scene.obs_scene_name === obsSceneName);
|
||||
if (match && overlay.active_scene_id !== match.id) {
|
||||
setActiveScene(overlayId, match.id, { source: "obs" });
|
||||
}
|
||||
this.publishStatus(overlayId);
|
||||
}
|
||||
|
||||
async handleOverlayChange({ overlayId, source, deleted }) {
|
||||
if (deleted) return this.disconnect(overlayId);
|
||||
if (this.syncSuppressed.has(overlayId)) return;
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry?.connector || entry.connector.status?.().state !== "connected" || source === "obs") return;
|
||||
if (!["overlay_to_obs", "bidirectional"].includes(entry.settings.sync_direction)) return;
|
||||
const overlay = getOverlay(overlayId);
|
||||
const scene = overlay?.scenes.find((item) => item.id === overlay.active_scene_id);
|
||||
const target = scene?.obs_scene_name;
|
||||
if (!target || entry.lastOutboundScene === target) return;
|
||||
entry.lastOutboundScene = target;
|
||||
entry.lastOutboundAt = Date.now();
|
||||
try {
|
||||
await entry.connector.setActiveScene(target);
|
||||
entry.error = null;
|
||||
} catch (error) {
|
||||
entry.error = error?.message || "Could not change the OBS scene.";
|
||||
}
|
||||
this.publishStatus(overlayId);
|
||||
}
|
||||
|
||||
async setObsScene(overlayId, sceneName) {
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry?.connector || entry.connector.status?.().state !== "connected") throw new Error("OBS is not connected.");
|
||||
entry.lastOutboundScene = String(sceneName || "");
|
||||
entry.lastOutboundAt = Date.now();
|
||||
await entry.connector.setActiveScene(entry.lastOutboundScene);
|
||||
this.publishStatus(overlayId);
|
||||
return this.status(overlayId, { includeScenes: true });
|
||||
}
|
||||
|
||||
async status(overlayId, { includeScenes = false } = {}) {
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry) {
|
||||
const settings = getObsSettings(overlayId);
|
||||
return { state: settings.enabled ? "disconnected" : "disabled", error: null, current_scene: null, scenes: [] };
|
||||
}
|
||||
const connectorStatus = entry.connector?.status?.() || {};
|
||||
const result = {
|
||||
...connectorStatus,
|
||||
state: connectorStatus.state || entry.state || "disconnected",
|
||||
error: connectorStatus.error || entry.error || null,
|
||||
current_scene: connectorStatus.current_scene || null,
|
||||
scenes: Array.isArray(connectorStatus.scenes) ? connectorStatus.scenes : []
|
||||
};
|
||||
if (includeScenes && entry.connector && result.state === "connected") {
|
||||
try { result.scenes = await entry.connector.listScenes(); }
|
||||
catch (error) { result.error = error?.message || "Could not read OBS scenes."; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
publishStatus(overlayId) {
|
||||
Promise.resolve(this.status(overlayId)).then((status) => {
|
||||
publishWebEvent("overlay:obs-status", { overlay_id: overlayId, ...status }, { role: "admin" });
|
||||
});
|
||||
}
|
||||
|
||||
async test(settings) {
|
||||
if (settings.provider === "obs_browser_bridge") {
|
||||
if (!settings.overlayId) throw new Error("Overlay ID is required for Browser Bridge testing.");
|
||||
const entry = this.connections.get(settings.overlayId);
|
||||
if (entry?.settings?.provider !== "obs_browser_bridge") {
|
||||
throw new Error("Save the Browser Bridge connection before testing it.");
|
||||
}
|
||||
const status = await this.status(settings.overlayId, { includeScenes: true });
|
||||
if (status.state !== "connected") throw new Error(status.error || "The Browser Bridge is waiting for OBS.");
|
||||
return { ok: true, ...status };
|
||||
}
|
||||
const provider = providers.get(settings.provider);
|
||||
if (!provider || provider.available === false) throw new Error("Connector provider is not available.");
|
||||
const connector = provider.create(settings);
|
||||
try {
|
||||
await connector.connect();
|
||||
return {
|
||||
ok: true,
|
||||
current_scene: await connector.getActiveScene(),
|
||||
scenes: await connector.listScenes()
|
||||
};
|
||||
} finally {
|
||||
await connector.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
reportBrowserBridge(overlayId, report) {
|
||||
const entry = this.connections.get(overlayId);
|
||||
if (!entry?.connector || entry.settings.provider !== "obs_browser_bridge" || typeof entry.connector.acceptReport !== "function") {
|
||||
return { accepted: false, enabled: false, state: "disabled" };
|
||||
}
|
||||
const result = entry.connector.acceptReport(report);
|
||||
entry.state = result.state || entry.state;
|
||||
entry.error = result.error || null;
|
||||
this.publishStatus(overlayId);
|
||||
return { enabled: true, ...result };
|
||||
}
|
||||
|
||||
suppressSync(overlayId, suppressed = true) {
|
||||
if (suppressed) this.syncSuppressed.add(overlayId);
|
||||
else this.syncSuppressed.delete(overlayId);
|
||||
}
|
||||
}
|
||||
|
||||
const overlayConnectorManager = new OverlayConnectorManager();
|
||||
|
||||
module.exports = {
|
||||
ObsBrowserBridgeConnector,
|
||||
listOverlayConnectorProviders,
|
||||
overlayConnectorManager,
|
||||
registerOverlayConnectorProvider
|
||||
};
|
||||
175
src/services/overlay-modules.js
Normal file
175
src/services/overlay-modules.js
Normal file
@ -0,0 +1,175 @@
|
||||
const moduleTypes = new Map();
|
||||
|
||||
function number(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function text(value, fallback = "", max = 2000) {
|
||||
return String(value === undefined || value === null ? fallback : value).slice(0, max);
|
||||
}
|
||||
|
||||
function color(value, fallback = "#ffffff") {
|
||||
const normalized = text(value, fallback, 32).trim();
|
||||
return /^(#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([0-9.,%\s]+\)|transparent)$/i.test(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function url(value, { allowEmpty = true } = {}) {
|
||||
const normalized = text(value, "", 2048).trim();
|
||||
if (!normalized && allowEmpty) return "";
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function commonConfig(input = {}) {
|
||||
const anchors = [
|
||||
"top-left", "top-center", "top-right",
|
||||
"center-left", "center", "center-right",
|
||||
"bottom-left", "bottom-center", "bottom-right"
|
||||
];
|
||||
return {
|
||||
x: number(input.x, 0, 0, 100),
|
||||
y: number(input.y, 0, 0, 100),
|
||||
width: number(input.width, 100, 0.1, 100),
|
||||
height: number(input.height, 100, 0.1, 100),
|
||||
opacity: number(input.opacity, 1, 0, 1),
|
||||
anchor: anchors.includes(input.anchor) ? input.anchor : "top-left"
|
||||
};
|
||||
}
|
||||
|
||||
function mediaConfig(input = {}, { includeFit = false } = {}) {
|
||||
const playBehavior = ["once", "loop", "manual"].includes(input.playBehavior)
|
||||
? input.playBehavior
|
||||
: "once";
|
||||
return {
|
||||
...commonConfig(input),
|
||||
url: url(input.url),
|
||||
playBehavior,
|
||||
muted: input.muted === true || input.muted === "on",
|
||||
volume: number(input.volume, 1, 0, 1),
|
||||
playbackRate: number(input.playbackRate, 1, 0.25, 4),
|
||||
startAt: number(input.startAt, 0, 0, 86400),
|
||||
showControls: playBehavior === "manual" || input.showControls === true || input.showControls === "on",
|
||||
...(includeFit ? { fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain" } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function registerOverlayModuleType(type) {
|
||||
if (!type?.id || typeof type.normalize !== "function") {
|
||||
throw new Error("Overlay module types require an id and normalize function.");
|
||||
}
|
||||
const renderType = type.renderType || type.id;
|
||||
if (!["text", "image", "video", "audio", "web"].includes(renderType)) {
|
||||
throw new Error("Overlay module renderType must be text, image, video, audio, or web.");
|
||||
}
|
||||
moduleTypes.set(type.id, Object.freeze({ ...type, renderType }));
|
||||
return () => moduleTypes.delete(type.id);
|
||||
}
|
||||
|
||||
function getOverlayModuleType(id) {
|
||||
return moduleTypes.get(String(id || "")) || null;
|
||||
}
|
||||
|
||||
function listOverlayModuleTypes() {
|
||||
return Array.from(moduleTypes.values()).map(({ id, label, description }) => ({ id, label, description }));
|
||||
}
|
||||
|
||||
function normalizeModuleConfig(typeId, input) {
|
||||
const type = getOverlayModuleType(typeId);
|
||||
if (!type) throw new Error("Unsupported overlay module type.");
|
||||
return type.normalize(input || {});
|
||||
}
|
||||
|
||||
registerOverlayModuleType({
|
||||
id: "text",
|
||||
label: "Text",
|
||||
description: "Positioned text with configurable colors and sizing.",
|
||||
normalize(input) {
|
||||
return {
|
||||
...commonConfig(input),
|
||||
text: text(input.text, "New text", 4000),
|
||||
fontSize: number(input.fontSize, 48, 8, 400),
|
||||
fontWeight: number(input.fontWeight, 700, 100, 900),
|
||||
color: color(input.color, "#ffffff"),
|
||||
background: color(input.background, "transparent"),
|
||||
horizontalAlign: ["left", "center", "right"].includes(input.horizontalAlign)
|
||||
? input.horizontalAlign
|
||||
: (["left", "center", "right"].includes(input.align) ? input.align : "left"),
|
||||
verticalAlign: ["top", "center", "bottom"].includes(input.verticalAlign) ? input.verticalAlign : "center",
|
||||
overflow: ["wrap", "shrink", "clip", "single-line", "scroll"].includes(input.overflow)
|
||||
? input.overflow
|
||||
: "wrap",
|
||||
padding: number(input.padding, 0, 0, 200)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
registerOverlayModuleType({
|
||||
id: "image",
|
||||
label: "Image",
|
||||
description: "A responsive image from an HTTPS or HTTP URL.",
|
||||
normalize(input) {
|
||||
return {
|
||||
...commonConfig(input),
|
||||
url: url(input.url),
|
||||
fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain"
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
registerOverlayModuleType({
|
||||
id: "video",
|
||||
label: "Video / media",
|
||||
description: "Play a video file, including its audio track, with scene-aware playback controls.",
|
||||
renderType: "video",
|
||||
normalize(input) {
|
||||
return mediaConfig(input, { includeFit: true });
|
||||
}
|
||||
});
|
||||
|
||||
registerOverlayModuleType({
|
||||
id: "audio",
|
||||
label: "Audio",
|
||||
description: "Play an audio file through the OBS Browser Source.",
|
||||
renderType: "audio",
|
||||
normalize(input) {
|
||||
return mediaConfig(input);
|
||||
}
|
||||
});
|
||||
|
||||
registerOverlayModuleType({
|
||||
id: "web",
|
||||
label: "Website or alert overlay",
|
||||
description: "Add a third-party alert box, chat overlay, or other web page.",
|
||||
normalize(input) {
|
||||
return {
|
||||
...commonConfig(input),
|
||||
url: url(input.url),
|
||||
allowPointerEvents: input.allowPointerEvents === true || input.allowPointerEvents === "on",
|
||||
autoRefresh: input.autoRefresh === true || input.autoRefresh === "on",
|
||||
healthCheck: input.healthCheck !== false && input.healthCheck !== "false" && input.healthCheck !== "off",
|
||||
refreshIntervalSeconds: number(input.refreshIntervalSeconds, 300, 30, 3600),
|
||||
retryLimit: Math.round(number(input.retryLimit, 3, 0, 5)),
|
||||
cropTop: number(input.cropTop, 0, 0, 95),
|
||||
cropRight: number(input.cropRight, 0, 0, 95),
|
||||
cropBottom: number(input.cropBottom, 0, 0, 95),
|
||||
cropLeft: number(input.cropLeft, 0, 0, 95),
|
||||
zoom: number(input.zoom, 1, 0.1, 5),
|
||||
customCss: text(input.customCss, "", 12000)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getOverlayModuleType,
|
||||
listOverlayModuleTypes,
|
||||
normalizeModuleConfig,
|
||||
registerOverlayModuleType
|
||||
};
|
||||
57
src/services/overlay-permissions.js
Normal file
57
src/services/overlay-permissions.js
Normal file
@ -0,0 +1,57 @@
|
||||
const overlayAccessProviders = [];
|
||||
|
||||
const OVERLAY_CAPABILITIES = Object.freeze({
|
||||
MANAGE: "manage",
|
||||
VIEW_SECRETS: "view_secrets",
|
||||
CONTROL_SCENE: "control_scene",
|
||||
MANAGE_OBS: "manage_obs"
|
||||
});
|
||||
|
||||
function registerOverlayAccessProvider(provider) {
|
||||
if (!provider || typeof provider.allows !== "function") {
|
||||
throw new Error("Overlay access providers must expose allows(context).");
|
||||
}
|
||||
overlayAccessProviders.push(provider);
|
||||
return () => {
|
||||
const index = overlayAccessProviders.indexOf(provider);
|
||||
if (index >= 0) overlayAccessProviders.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
function canAccessOverlay(user, overlayId, capability = OVERLAY_CAPABILITIES.MANAGE) {
|
||||
if (!user) return false;
|
||||
if (user.isAdmin) return true;
|
||||
|
||||
// Deliberately deny by default. A future moderator-assignment provider can be
|
||||
// registered here without weakening secret access or changing route checks.
|
||||
if (capability === OVERLAY_CAPABILITIES.VIEW_SECRETS || capability === OVERLAY_CAPABILITIES.MANAGE_OBS) {
|
||||
return false;
|
||||
}
|
||||
return overlayAccessProviders.some((provider) => {
|
||||
try {
|
||||
return provider.allows({ user, overlayId, capability }) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requireOverlayCapability(capability, getOverlayId = (req) => req.params.id) {
|
||||
return (req, res, next) => {
|
||||
const overlayId = getOverlayId(req);
|
||||
if (!canAccessOverlay(req.session?.user, overlayId, capability)) {
|
||||
return res.status(403).render("error", {
|
||||
title: "Access denied",
|
||||
message: "You do not have access to that overlay."
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OVERLAY_CAPABILITIES,
|
||||
canAccessOverlay,
|
||||
registerOverlayAccessProvider,
|
||||
requireOverlayCapability
|
||||
};
|
||||
565
src/services/overlay-routes.js
Normal file
565
src/services/overlay-routes.js
Normal file
@ -0,0 +1,565 @@
|
||||
const dns = require("dns").promises;
|
||||
const net = require("net");
|
||||
const { subscribeWebEvents } = require("./web-events");
|
||||
const {
|
||||
OVERLAY_CAPABILITIES,
|
||||
canAccessOverlay
|
||||
} = require("./overlay-permissions");
|
||||
const { getOverlayModuleType, listOverlayModuleTypes } = require("./overlay-modules");
|
||||
const {
|
||||
addModule,
|
||||
addScene,
|
||||
buildPublicState,
|
||||
createOverlay,
|
||||
deleteModule,
|
||||
deleteOverlay,
|
||||
deleteScene,
|
||||
duplicateModule,
|
||||
duplicateOverlay,
|
||||
duplicateScene,
|
||||
getObsSettings,
|
||||
getOverlay,
|
||||
listOverlays,
|
||||
regenerateOverlayToken,
|
||||
regenerateSceneToken,
|
||||
refreshModule,
|
||||
reorderModules,
|
||||
reorderOverlays,
|
||||
reorderScenes,
|
||||
resolvePublicOverlay,
|
||||
saveObsSettings,
|
||||
setActiveScene,
|
||||
updateModule,
|
||||
updateModuleTransform,
|
||||
updateOverlay,
|
||||
updateScene
|
||||
} = require("./overlays");
|
||||
const {
|
||||
listOverlayConnectorProviders,
|
||||
overlayConnectorManager
|
||||
} = require("./overlay-connectors");
|
||||
|
||||
const externalHealthCache = new Map();
|
||||
|
||||
function isPrivateAddress(address) {
|
||||
if (!address) return true;
|
||||
if (net.isIPv4(address)) {
|
||||
const parts = address.split(".").map(Number);
|
||||
return parts[0] === 10 || parts[0] === 127 || parts[0] === 0 ||
|
||||
(parts[0] === 169 && parts[1] === 254) ||
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||||
(parts[0] === 192 && parts[1] === 168);
|
||||
}
|
||||
const normalized = String(address).toLowerCase();
|
||||
return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") ||
|
||||
normalized.startsWith("fea") || normalized.startsWith("feb") || normalized.startsWith("::ffff:127.");
|
||||
}
|
||||
|
||||
async function isPrivateHealthTarget(url) {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
|
||||
if (net.isIP(hostname)) return isPrivateAddress(hostname);
|
||||
const addresses = await dns.lookup(hostname, { all: true, verbatim: true });
|
||||
return !addresses.length || addresses.some((entry) => isPrivateAddress(entry.address));
|
||||
}
|
||||
|
||||
async function fetchPublicOverlay(url, signal, redirects = 0) {
|
||||
if (redirects > 4) throw new Error("Too many redirects.");
|
||||
if (await isPrivateHealthTarget(url)) return null;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal,
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.2",
|
||||
"User-Agent": "Lumi-Overlay-Health/1.0"
|
||||
}
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
||||
await response.body?.cancel?.().catch(() => {});
|
||||
const nextUrl = new URL(response.headers.get("location"), url).toString();
|
||||
return fetchPublicOverlay(nextUrl, signal, redirects + 1);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function probeExternalOverlay(url) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetchPublicOverlay(url, controller.signal);
|
||||
if (!response) {
|
||||
return {
|
||||
healthy: true,
|
||||
skipped: true,
|
||||
status: null,
|
||||
reason: "Private and local website addresses are refreshed but not probed by Lumi."
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
response.body?.cancel?.().catch?.(() => {});
|
||||
return { healthy: false, status: response.status, reason: `The website returned HTTP ${response.status}.` };
|
||||
}
|
||||
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||
if (contentType && !/(html|xhtml|text|javascript|json|svg)/i.test(contentType)) {
|
||||
response.body?.cancel?.().catch?.(() => {});
|
||||
return { healthy: false, status: response.status, reason: "The website returned an unexpected content type." };
|
||||
}
|
||||
const reader = response.body?.getReader?.();
|
||||
let sample = "";
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
while (sample.length < 65536) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
sample += decoder.decode(value, { stream: true });
|
||||
}
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
if (!sample.trim()) {
|
||||
return { healthy: false, status: response.status, reason: "The website returned an empty reply." };
|
||||
}
|
||||
if (contentType.includes("html") && !/<(?:!doctype|html|head|body|script|style|div|canvas|svg)\b/i.test(sample)) {
|
||||
return { healthy: false, status: response.status, reason: "The website reply did not look like a usable overlay page." };
|
||||
}
|
||||
return {
|
||||
healthy: true,
|
||||
status: response.status,
|
||||
reason: null,
|
||||
checked_at: Date.now(),
|
||||
etag: response.headers.get("etag") || null,
|
||||
last_modified: response.headers.get("last-modified") || null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
healthy: false,
|
||||
status: null,
|
||||
reason: error?.name === "AbortError" ? "The website took too long to reply." : "The website could not be reached."
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function noStore(res) {
|
||||
res.set("Cache-Control", "no-store, private");
|
||||
res.set("Pragma", "no-cache");
|
||||
}
|
||||
|
||||
function publicOverlayContext(req) {
|
||||
return resolvePublicOverlay(req.params.overlayToken, req.params.sceneToken || null);
|
||||
}
|
||||
|
||||
function registerPublicOverlayRoutes(app) {
|
||||
const registerVariant = (basePath, fixed) => {
|
||||
app.post(`${basePath}/obs-bridge`, (req, res) => {
|
||||
noStore(res);
|
||||
const context = publicOverlayContext(req);
|
||||
if (!context || !context.overlay.enabled || (fixed && !context.fixedScene?.enabled)) {
|
||||
return res.status(404).json({ accepted: false, error: "Overlay unavailable." });
|
||||
}
|
||||
try {
|
||||
const result = overlayConnectorManager.reportBrowserBridge(context.overlay.id, req.body || {});
|
||||
return res.json({
|
||||
accepted: Boolean(result.accepted),
|
||||
enabled: Boolean(result.enabled),
|
||||
state: result.state || "disabled"
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ accepted: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get(`${basePath}/module-health`, async (req, res) => {
|
||||
noStore(res);
|
||||
const context = publicOverlayContext(req);
|
||||
if (!context) return res.status(404).json({ healthy: false, reason: "Overlay unavailable." });
|
||||
const state = buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null);
|
||||
const module = state.scene?.modules?.find((item) => item.id === req.query.module_id && item.renderType === "web");
|
||||
if (!module?.config?.url) return res.status(404).json({ healthy: false, reason: "Website source unavailable." });
|
||||
const cacheKey = `${context.overlay.id}:${module.id}`;
|
||||
const cached = externalHealthCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < 15000) return res.json(cached.result);
|
||||
const result = await probeExternalOverlay(module.config.url);
|
||||
externalHealthCache.set(cacheKey, { at: Date.now(), result });
|
||||
return res.status(result.healthy ? 200 : 502).json(result);
|
||||
});
|
||||
|
||||
app.get(`${basePath}/state`, (req, res) => {
|
||||
noStore(res);
|
||||
const context = publicOverlayContext(req);
|
||||
if (!context) return res.status(404).json({ exists: false, enabled: false, scene: null });
|
||||
return res.json(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null));
|
||||
});
|
||||
|
||||
app.get(`${basePath}/events`, (req, res) => {
|
||||
const context = publicOverlayContext(req);
|
||||
if (!context) return res.status(404).end();
|
||||
return subscribeWebEvents(req, res, {
|
||||
scopes: [`overlay:${context.overlay.id}`],
|
||||
initialEvents: [{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } }]
|
||||
});
|
||||
});
|
||||
|
||||
app.get(basePath, (req, res) => {
|
||||
noStore(res);
|
||||
const context = publicOverlayContext(req);
|
||||
if (!context || !context.overlay.enabled || (fixed && !context.fixedScene.enabled)) {
|
||||
return res.status(404).send("Overlay unavailable.");
|
||||
}
|
||||
const statePath = `${req.path.replace(/\/$/, "")}/state`;
|
||||
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';");
|
||||
return res.render("overlay-render", {
|
||||
statePath,
|
||||
eventsPath,
|
||||
healthPath,
|
||||
bridgePath,
|
||||
initialStateJson: JSON.stringify(buildPublicState(context.overlay.id, fixed ? context.fixedScene.id : null)).replace(/</g, "\\u003c")
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
registerVariant("/overlay/:overlayToken/scene/:sceneToken", true);
|
||||
registerVariant("/overlay/:overlayToken", false);
|
||||
}
|
||||
|
||||
function requireOverlayAccess(capability = OVERLAY_CAPABILITIES.MANAGE) {
|
||||
return (req, res, next) => {
|
||||
if (!canAccessOverlay(req.session?.user, req.params.id, capability)) {
|
||||
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." });
|
||||
return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that overlay." });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function requireSceneInOverlay(req, res, next) {
|
||||
const overlay = getOverlay(req.params.id);
|
||||
if (!overlay?.scenes.some((scene) => scene.id === req.params.sceneId)) {
|
||||
if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Scene not found." });
|
||||
return res.status(404).render("error", { title: "Scene not found", message: "That scene is not part of this overlay." });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requireModuleInOverlay(req, res, next) {
|
||||
const overlay = getOverlay(req.params.id);
|
||||
if (!overlay?.scenes.some((scene) => scene.modules.some((module) => module.id === req.params.moduleId))) {
|
||||
if (req.path.startsWith("/api/")) return res.status(404).json({ error: "Source not found." });
|
||||
return res.status(404).render("error", { title: "Source not found", message: "That source is not part of this overlay." });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function adminOnly(req, res, next) {
|
||||
if (!req.session?.user?.isAdmin) {
|
||||
if (req.path.startsWith("/api/")) return res.status(403).json({ error: "Access denied." });
|
||||
return res.status(403).render("error", { title: "Access denied", message: "Administrator access is required." });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function flash(req, type, message) {
|
||||
req.session.flash = { type, message };
|
||||
}
|
||||
|
||||
function redirectWithResult(req, res, pathValue, work, success) {
|
||||
try {
|
||||
const value = work();
|
||||
flash(req, "success", success);
|
||||
return res.redirect(pathValue);
|
||||
} catch (error) {
|
||||
flash(req, "error", error.message);
|
||||
return res.redirect(pathValue);
|
||||
}
|
||||
}
|
||||
|
||||
function booleanField(body, name) {
|
||||
return body[name] === true || body[name] === "true" || body[name] === "on" || body[name] === "1";
|
||||
}
|
||||
|
||||
function orderedIds(body) {
|
||||
if (Array.isArray(body.ids)) return body.ids;
|
||||
if (typeof body.ids === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(body.ids);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {}
|
||||
return body.ids.split(",").map((id) => id.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function moduleInput(body) {
|
||||
return {
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
enabled: booleanField(body, "enabled"),
|
||||
config: {
|
||||
text: body.text,
|
||||
url: body.url,
|
||||
x: body.x,
|
||||
y: body.y,
|
||||
width: body.width,
|
||||
height: body.height,
|
||||
opacity: body.opacity,
|
||||
fontSize: body.font_size,
|
||||
fontWeight: body.font_weight,
|
||||
color: body.color,
|
||||
background: body.background,
|
||||
align: body.align,
|
||||
padding: body.padding,
|
||||
fit: body.fit,
|
||||
playBehavior: body.play_behavior,
|
||||
muted: booleanField(body, "muted"),
|
||||
volume: body.volume,
|
||||
playbackRate: body.playback_rate,
|
||||
startAt: body.start_at,
|
||||
showControls: booleanField(body, "show_controls"),
|
||||
allowPointerEvents: booleanField(body, "allow_pointer_events"),
|
||||
anchor: body.anchor,
|
||||
horizontalAlign: body.horizontal_align,
|
||||
verticalAlign: body.vertical_align,
|
||||
overflow: body.text_overflow,
|
||||
autoRefresh: booleanField(body, "auto_refresh"),
|
||||
healthCheck: booleanField(body, "health_check"),
|
||||
refreshIntervalSeconds: body.refresh_interval_seconds,
|
||||
retryLimit: body.retry_limit,
|
||||
cropTop: body.crop_top,
|
||||
cropRight: body.crop_right,
|
||||
cropBottom: body.crop_bottom,
|
||||
cropLeft: body.crop_left,
|
||||
zoom: body.zoom,
|
||||
customCss: body.custom_css
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function baseUrl(req) {
|
||||
return `${req.protocol}://${req.get("host")}`;
|
||||
}
|
||||
|
||||
function registerOverlayAdminRoutes(app) {
|
||||
app.get("/admin/overlays", adminOnly, (req, res) => {
|
||||
noStore(res);
|
||||
res.render("admin-overlays", {
|
||||
title: "OBS overlays",
|
||||
overlays: listOverlays({ includeSecrets: true }),
|
||||
baseUrl: baseUrl(req)
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/overlays", adminOnly, (req, res) => redirectWithResult(
|
||||
req,
|
||||
res,
|
||||
"/admin/overlays",
|
||||
() => createOverlay({ name: req.body.name, description: req.body.description, sceneName: req.body.scene_name || "Main" }),
|
||||
"Overlay created."
|
||||
));
|
||||
|
||||
app.post("/admin/overlays/reorder", adminOnly, (req, res) => redirectWithResult(req, res, "/admin/overlays", () => reorderOverlays(orderedIds(req.body)), "Overlay order saved."));
|
||||
|
||||
app.get("/admin/overlays/:id", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE), async (req, res) => {
|
||||
noStore(res);
|
||||
const canViewSecrets = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.VIEW_SECRETS);
|
||||
const canManageObs = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.MANAGE_OBS);
|
||||
const overlay = getOverlay(req.params.id, { includeSecrets: canViewSecrets });
|
||||
if (!overlay) return res.status(404).render("error", { title: "Overlay not found", message: "That overlay does not exist." });
|
||||
res.render("admin-overlay-detail", {
|
||||
title: overlay.name,
|
||||
overlay,
|
||||
canViewSecrets,
|
||||
canManageObs,
|
||||
baseUrl: baseUrl(req),
|
||||
moduleTypes: listOverlayModuleTypes(),
|
||||
editorStateJson: JSON.stringify({
|
||||
overlayId: overlay.id,
|
||||
canvas: { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 },
|
||||
activeSceneId: overlay.active_scene_id,
|
||||
scenes: overlay.scenes.map((scene) => ({
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
enabled: scene.enabled,
|
||||
modules: scene.modules.map((module) => ({
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
type: module.type,
|
||||
renderType: getOverlayModuleType(module.type)?.renderType || module.type,
|
||||
enabled: module.enabled,
|
||||
config: module.config
|
||||
}))
|
||||
}))
|
||||
}).replace(/</g, "\\u003c"),
|
||||
connectorProviders: canManageObs ? listOverlayConnectorProviders() : [],
|
||||
obsStatus: canManageObs
|
||||
? await overlayConnectorManager.status(overlay.id, { includeScenes: true })
|
||||
: { state: "restricted", current_scene: null, error: null, scenes: [] }
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/overlays/:id", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateOverlay(req.params.id, {
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
enabled: booleanField(req.body, "enabled"),
|
||||
canvasWidth: req.body.canvas_width,
|
||||
canvasHeight: req.body.canvas_height
|
||||
}), "Overlay saved."));
|
||||
|
||||
app.post("/admin/overlays/:id/duplicate", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => duplicateOverlay(req.params.id, req.body.name), "Overlay duplicated with new private URLs."));
|
||||
app.post("/admin/overlays/:id/delete", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, "/admin/overlays", () => deleteOverlay(req.params.id), "Overlay deleted."));
|
||||
app.post("/admin/overlays/:id/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateOverlayToken(req.params.id), "All Browser Source URLs for this overlay were regenerated. The old URLs are revoked."));
|
||||
app.post("/admin/overlays/:id/active-scene", requireOverlayAccess(OVERLAY_CAPABILITIES.CONTROL_SCENE), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => setActiveScene(req.params.id, req.body.scene_id, { source: "admin" }), "Active overlay scene changed."));
|
||||
|
||||
app.post("/admin/overlays/:id/scenes", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addScene(req.params.id, { name: req.body.name, obsSceneName: req.body.obs_scene_name }), "Scene created."));
|
||||
app.post("/admin/overlays/:id/scenes/reorder", requireOverlayAccess(), (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderScenes(req.params.id, orderedIds(req.body)), "Scene order saved."));
|
||||
app.post("/admin/overlays/:id/scenes/:sceneId", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateScene(req.params.sceneId, { name: req.body.name, enabled: booleanField(req.body, "enabled"), obsSceneName: req.body.obs_scene_name }), "Scene saved."));
|
||||
app.post("/admin/overlays/:id/scenes/:sceneId/duplicate", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateScene(req.params.sceneId, req.body.name), "Scene duplicated with a new private URL."));
|
||||
app.post("/admin/overlays/:id/scenes/:sceneId/delete", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteScene(req.params.sceneId), "Scene deleted."));
|
||||
app.post("/admin/overlays/:id/scenes/:sceneId/token/revoke", requireOverlayAccess(OVERLAY_CAPABILITIES.VIEW_SECRETS), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => regenerateSceneToken(req.params.sceneId), "Fixed scene URL regenerated. The old URL is revoked."));
|
||||
|
||||
app.post("/admin/overlays/:id/scenes/:sceneId/modules", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => addModule(req.params.sceneId, moduleInput(req.body)), "Source added."));
|
||||
app.post("/admin/overlays/:id/scenes/:sceneId/modules/reorder", requireOverlayAccess(), requireSceneInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => reorderModules(req.params.sceneId, orderedIds(req.body)), "Layer order saved."));
|
||||
app.post("/admin/overlays/:id/modules/:moduleId", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => updateModule(req.params.moduleId, moduleInput(req.body)), "Source saved."));
|
||||
app.post("/admin/overlays/:id/modules/:moduleId/duplicate", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => duplicateModule(req.params.moduleId, req.body.name), "Source copied."));
|
||||
app.post("/admin/overlays/:id/modules/:moduleId/delete", requireOverlayAccess(), requireModuleInOverlay, (req, res) => redirectWithResult(req, res, `/admin/overlays/${req.params.id}`, () => deleteModule(req.params.moduleId), "Source deleted."));
|
||||
|
||||
app.post("/api/admin/overlays/:id/modules/:moduleId/transform", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
|
||||
noStore(res);
|
||||
try {
|
||||
res.json({ ok: true, module: updateModuleTransform(req.params.moduleId, req.body || {}) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/admin/overlays/:id/modules/:moduleId/refresh", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
|
||||
noStore(res);
|
||||
try {
|
||||
refreshModule(req.params.moduleId);
|
||||
res.json({ ok: true, message: "Source refresh requested." });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/admin/overlays/:id/modules/:moduleId/health", requireOverlayAccess(), requireModuleInOverlay, async (req, res) => {
|
||||
noStore(res);
|
||||
const overlay = getOverlay(req.params.id);
|
||||
const module = overlay?.scenes.flatMap((scene) => scene.modules).find((item) => item.id === req.params.moduleId);
|
||||
if (!module || module.type !== "web" || !module.config?.url) {
|
||||
return res.status(404).json({ ok: false, healthy: false, reason: "Website source unavailable." });
|
||||
}
|
||||
const result = await probeExternalOverlay(module.config.url);
|
||||
return res.json({ ok: true, ...result });
|
||||
});
|
||||
|
||||
app.post("/admin/overlays/:id/obs", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
|
||||
try {
|
||||
saveObsSettings(req.params.id, {
|
||||
enabled: booleanField(req.body, "enabled"),
|
||||
provider: req.body.provider,
|
||||
endpoint: req.body.endpoint,
|
||||
password: req.body.password,
|
||||
clearPassword: booleanField(req.body, "clear_password"),
|
||||
syncDirection: req.body.sync_direction,
|
||||
reconnect: booleanField(req.body, "reconnect")
|
||||
});
|
||||
await overlayConnectorManager.restart(req.params.id);
|
||||
flash(req, "success", "OBS connector settings saved.");
|
||||
} catch (error) {
|
||||
flash(req, "error", error.message);
|
||||
}
|
||||
res.redirect(`/admin/overlays/${req.params.id}`);
|
||||
});
|
||||
|
||||
app.get("/api/admin/overlays/:id/obs/status", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
|
||||
noStore(res);
|
||||
res.json({ ok: true, ...(await overlayConnectorManager.status(req.params.id, { includeScenes: true })) });
|
||||
});
|
||||
|
||||
app.post("/api/admin/overlays/:id/obs/test", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
|
||||
noStore(res);
|
||||
try {
|
||||
const existing = getObsSettings(req.params.id, { includeSecret: true });
|
||||
const result = await overlayConnectorManager.test({
|
||||
overlayId: req.params.id,
|
||||
provider: req.body.provider || existing.provider,
|
||||
endpoint: req.body.endpoint || existing.endpoint,
|
||||
password: req.body.password || existing.password
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/admin/overlays/:id/obs/scene", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
|
||||
noStore(res);
|
||||
try {
|
||||
res.json({ ok: true, ...(await overlayConnectorManager.setObsScene(req.params.id, req.body.scene_name)) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/admin/overlays/:id/obs/import", requireOverlayAccess(OVERLAY_CAPABILITIES.MANAGE_OBS), async (req, res) => {
|
||||
noStore(res);
|
||||
let syncSuppressed = false;
|
||||
try {
|
||||
const status = await overlayConnectorManager.status(req.params.id, { includeScenes: true });
|
||||
if (status.state !== "connected" || !status.scenes?.length) {
|
||||
throw new Error("Connect OBS with scene-reading permission before importing its setup.");
|
||||
}
|
||||
overlayConnectorManager.suppressSync(req.params.id, true);
|
||||
syncSuppressed = true;
|
||||
let overlay = getOverlay(req.params.id);
|
||||
let created = 0;
|
||||
let mapped = 0;
|
||||
for (const obsSceneName of status.scenes) {
|
||||
if (overlay.scenes.some((scene) => scene.obs_scene_name === obsSceneName)) continue;
|
||||
const sameName = overlay.scenes.find((scene) => !scene.obs_scene_name && scene.name.toLowerCase() === obsSceneName.toLowerCase());
|
||||
if (sameName) {
|
||||
updateScene(sameName.id, { name: sameName.name, enabled: sameName.enabled, obsSceneName });
|
||||
mapped += 1;
|
||||
} else {
|
||||
addScene(req.params.id, { name: obsSceneName, obsSceneName });
|
||||
created += 1;
|
||||
}
|
||||
overlay = getOverlay(req.params.id);
|
||||
}
|
||||
if (status.canvas_width && status.canvas_height) {
|
||||
updateOverlay(req.params.id, {
|
||||
name: overlay.name,
|
||||
description: overlay.description,
|
||||
enabled: overlay.enabled,
|
||||
canvasWidth: status.canvas_width,
|
||||
canvasHeight: status.canvas_height
|
||||
});
|
||||
}
|
||||
overlay = getOverlay(req.params.id);
|
||||
const current = overlay.scenes.find((scene) => scene.obs_scene_name === status.current_scene && scene.enabled);
|
||||
if (current && current.id !== overlay.active_scene_id) setActiveScene(req.params.id, current.id, { source: "obs" });
|
||||
return res.json({
|
||||
ok: true,
|
||||
created,
|
||||
mapped,
|
||||
canvas_updated: Boolean(status.canvas_width && status.canvas_height),
|
||||
message: `OBS setup applied: ${created} scene${created === 1 ? "" : "s"} added, ${mapped} existing scene${mapped === 1 ? "" : "s"} matched.`
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ ok: false, error: error.message });
|
||||
} finally {
|
||||
if (syncSuppressed) overlayConnectorManager.suppressSync(req.params.id, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerOverlayAdminRoutes,
|
||||
registerPublicOverlayRoutes
|
||||
};
|
||||
62
src/services/overlay-secrets.js
Normal file
62
src/services/overlay-secrets.js
Normal file
@ -0,0 +1,62 @@
|
||||
const crypto = require("crypto");
|
||||
const { getSetting } = require("./settings");
|
||||
|
||||
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-overlay:${secret}`).digest();
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function tokenHash(token) {
|
||||
return crypto.createHash("sha256").update(String(token || "")).digest("hex");
|
||||
}
|
||||
|
||||
function encryptSecret(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
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) {
|
||||
if (!value) return "";
|
||||
const [version, ivValue, tagValue, encryptedValue] = String(value).split(".");
|
||||
if (version !== "v1" || !ivValue || !tagValue || !encryptedValue) {
|
||||
throw new Error("Stored secret has an unsupported format.");
|
||||
}
|
||||
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 createStoredToken() {
|
||||
const token = generateToken();
|
||||
return {
|
||||
token,
|
||||
hash: tokenHash(token),
|
||||
encrypted: encryptSecret(token)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStoredToken,
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
generateToken,
|
||||
tokenHash
|
||||
};
|
||||
542
src/services/overlays.js
Normal file
542
src/services/overlays.js
Normal file
@ -0,0 +1,542 @@
|
||||
const crypto = require("crypto");
|
||||
const EventEmitter = require("events");
|
||||
const { db } = require("./db");
|
||||
const { publishWebEvent } = require("./web-events");
|
||||
const { getOverlayModuleType, normalizeModuleConfig } = require("./overlay-modules");
|
||||
const {
|
||||
createStoredToken,
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
tokenHash
|
||||
} = require("./overlay-secrets");
|
||||
|
||||
const overlayChanges = new EventEmitter();
|
||||
overlayChanges.setMaxListeners(100);
|
||||
|
||||
function requiredName(value, label = "Name") {
|
||||
const normalized = String(value || "").trim().slice(0, 120);
|
||||
if (!normalized) throw new Error(`${label} is required.`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function canvasDimension(value, fallback) {
|
||||
const parsed = Math.round(Number(value));
|
||||
return Number.isFinite(parsed) ? Math.min(7680, Math.max(240, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function parseConfig(value) {
|
||||
try {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value || "{}") : value;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function nextOrder(table, parentColumn = null, parentId = null) {
|
||||
const allowed = {
|
||||
overlays: null,
|
||||
overlay_scenes: "overlay_id",
|
||||
overlay_modules: "scene_id"
|
||||
};
|
||||
if (!(table in allowed) || allowed[table] !== parentColumn) throw new Error("Invalid ordering target.");
|
||||
const where = parentColumn ? ` WHERE ${parentColumn} = ?` : "";
|
||||
const row = db.prepare(`SELECT COALESCE(MAX(sort_order), -1) + 1 AS value FROM ${table}${where}`)
|
||||
.get(...(parentColumn ? [parentId] : []));
|
||||
return Number(row?.value || 0);
|
||||
}
|
||||
|
||||
function touchOverlay(overlayId, at = Date.now()) {
|
||||
db.prepare("UPDATE overlays SET updated_at = ? WHERE id = ?").run(at, overlayId);
|
||||
}
|
||||
|
||||
function overlayIdForScene(sceneId) {
|
||||
return db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId)?.overlay_id || null;
|
||||
}
|
||||
|
||||
function overlayIdForModule(moduleId) {
|
||||
return db.prepare(
|
||||
"SELECT s.overlay_id FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE m.id = ?"
|
||||
).get(moduleId)?.overlay_id || null;
|
||||
}
|
||||
|
||||
function notifyOverlayChanged(overlayId, source = "server") {
|
||||
const overlay = db.prepare("SELECT updated_at FROM overlays WHERE id = ?").get(overlayId);
|
||||
if (!overlay) return;
|
||||
publishWebEvent("overlay:changed", { revision: overlay.updated_at }, { scope: `overlay:${overlayId}` });
|
||||
overlayChanges.emit("changed", { overlayId, source, revision: overlay.updated_at });
|
||||
}
|
||||
|
||||
function rowWithToken(row, includeSecrets) {
|
||||
if (!row) return null;
|
||||
const result = { ...row, enabled: Boolean(row.enabled) };
|
||||
delete result.public_token_hash;
|
||||
delete result.public_token_encrypted;
|
||||
if (includeSecrets) result.public_token = decryptSecret(row.public_token_encrypted);
|
||||
return result;
|
||||
}
|
||||
|
||||
function listOverlays({ includeSecrets = false } = {}) {
|
||||
return db.prepare(
|
||||
`SELECT o.*,
|
||||
(SELECT COUNT(*) FROM overlay_scenes s WHERE s.overlay_id = o.id) AS scene_count,
|
||||
(SELECT COUNT(*) FROM overlay_modules m JOIN overlay_scenes s ON s.id = m.scene_id WHERE s.overlay_id = o.id) AS module_count,
|
||||
(SELECT name FROM overlay_scenes s WHERE s.id = o.active_scene_id) AS active_scene_name
|
||||
FROM overlays o ORDER BY o.sort_order, o.created_at`
|
||||
).all().map((row) => rowWithToken(row, includeSecrets));
|
||||
}
|
||||
|
||||
function getOverlay(id, { includeSecrets = false } = {}) {
|
||||
const row = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id);
|
||||
if (!row) return null;
|
||||
const overlay = rowWithToken(row, includeSecrets);
|
||||
overlay.scenes = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? ORDER BY sort_order, created_at")
|
||||
.all(id)
|
||||
.map((scene) => {
|
||||
const result = rowWithToken(scene, includeSecrets);
|
||||
result.modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at")
|
||||
.all(scene.id)
|
||||
.map((module) => ({
|
||||
...module,
|
||||
enabled: Boolean(module.enabled),
|
||||
config: parseConfig(module.config_json)
|
||||
}));
|
||||
return result;
|
||||
});
|
||||
overlay.active_scene_id = overlay.active_scene_id || overlay.scenes[0]?.id || null;
|
||||
overlay.obs = getObsSettings(id, { includeSecret: includeSecrets });
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function createOverlay(input = {}) {
|
||||
const overlayToken = createStoredToken();
|
||||
const sceneToken = createStoredToken();
|
||||
const now = Date.now();
|
||||
const overlayId = crypto.randomUUID();
|
||||
const sceneId = crypto.randomUUID();
|
||||
const create = db.transaction(() => {
|
||||
db.prepare(
|
||||
"INSERT INTO overlays (id, name, description, enabled, canvas_width, canvas_height, sort_order, active_scene_id, public_token_hash, public_token_encrypted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
overlayId,
|
||||
requiredName(input.name, "Overlay name"),
|
||||
String(input.description || "").trim().slice(0, 1000),
|
||||
input.enabled === false ? 0 : 1,
|
||||
canvasDimension(input.canvasWidth, 1920),
|
||||
canvasDimension(input.canvasHeight, 1080),
|
||||
nextOrder("overlays"),
|
||||
sceneId,
|
||||
overlayToken.hash,
|
||||
overlayToken.encrypted,
|
||||
now,
|
||||
now
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, 1, 0, ?, ?, NULL, ?, ?)"
|
||||
).run(sceneId, overlayId, requiredName(input.sceneName || "Main", "Scene name"), sceneToken.hash, sceneToken.encrypted, now, now);
|
||||
});
|
||||
create();
|
||||
notifyOverlayChanged(overlayId, "create");
|
||||
return getOverlay(overlayId, { includeSecrets: true });
|
||||
}
|
||||
|
||||
function updateOverlay(id, input = {}) {
|
||||
const existing = db.prepare("SELECT * FROM overlays WHERE id = ?").get(id);
|
||||
if (!existing) throw new Error("Overlay not found.");
|
||||
const now = Date.now();
|
||||
db.prepare("UPDATE overlays SET name = ?, description = ?, enabled = ?, canvas_width = ?, canvas_height = ?, updated_at = ? WHERE id = ?")
|
||||
.run(
|
||||
requiredName(input.name === undefined ? existing.name : input.name, "Overlay name"),
|
||||
String(input.description === undefined ? existing.description : input.description).trim().slice(0, 1000),
|
||||
input.enabled === undefined ? existing.enabled : input.enabled ? 1 : 0,
|
||||
canvasDimension(input.canvasWidth, existing.canvas_width || 1920),
|
||||
canvasDimension(input.canvasHeight, existing.canvas_height || 1080),
|
||||
now,
|
||||
id
|
||||
);
|
||||
notifyOverlayChanged(id, "overlay_update");
|
||||
return getOverlay(id);
|
||||
}
|
||||
|
||||
function duplicateOverlay(id, name) {
|
||||
const source = getOverlay(id);
|
||||
if (!source) throw new Error("Overlay not found.");
|
||||
const copy = createOverlay({
|
||||
name: name || `${source.name} copy`,
|
||||
description: source.description,
|
||||
enabled: source.enabled,
|
||||
canvasWidth: source.canvas_width,
|
||||
canvasHeight: source.canvas_height,
|
||||
sceneName: source.scenes[0]?.name || "Main"
|
||||
});
|
||||
const copyId = copy.id;
|
||||
const clone = db.transaction(() => {
|
||||
db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(copyId);
|
||||
db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(copyId);
|
||||
let firstSceneId = null;
|
||||
for (const scene of source.scenes) {
|
||||
const newSceneId = crypto.randomUUID();
|
||||
const storedToken = createStoredToken();
|
||||
if (!firstSceneId) firstSceneId = newSceneId;
|
||||
db.prepare(
|
||||
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(newSceneId, copyId, scene.name, scene.enabled ? 1 : 0, scene.sort_order, storedToken.hash, storedToken.encrypted, scene.obs_scene_name, Date.now(), Date.now());
|
||||
for (const module of scene.modules) {
|
||||
db.prepare(
|
||||
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(crypto.randomUUID(), newSceneId, module.type, module.name, module.enabled ? 1 : 0, module.sort_order, module.config_json, Date.now(), Date.now());
|
||||
}
|
||||
}
|
||||
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(firstSceneId, Date.now(), copyId);
|
||||
});
|
||||
clone();
|
||||
notifyOverlayChanged(copyId, "duplicate");
|
||||
return getOverlay(copyId, { includeSecrets: true });
|
||||
}
|
||||
|
||||
function reorderRows(table, ids, parentColumn = null, parentId = null) {
|
||||
const allowed = {
|
||||
overlays: { parent: null, idQuery: "SELECT id FROM overlays" },
|
||||
overlay_scenes: { parent: "overlay_id", idQuery: "SELECT id FROM overlay_scenes WHERE overlay_id = ?" },
|
||||
overlay_modules: { parent: "scene_id", idQuery: "SELECT id FROM overlay_modules WHERE scene_id = ?" }
|
||||
};
|
||||
const target = allowed[table];
|
||||
if (!target || target.parent !== parentColumn) throw new Error("Invalid reorder target.");
|
||||
const existing = db.prepare(target.idQuery).all(...(parentColumn ? [parentId] : [])).map((row) => row.id);
|
||||
const requested = Array.isArray(ids) ? ids.map(String) : [];
|
||||
if (requested.length !== existing.length || new Set(requested).size !== existing.length || existing.some((id) => !requested.includes(id))) {
|
||||
throw new Error("Reorder list must contain every item exactly once.");
|
||||
}
|
||||
const update = db.prepare(`UPDATE ${table} SET sort_order = ?, updated_at = ? WHERE id = ?`);
|
||||
db.transaction(() => requested.forEach((id, index) => update.run(index, Date.now(), id)))();
|
||||
}
|
||||
|
||||
function reorderOverlays(ids) {
|
||||
reorderRows("overlays", ids);
|
||||
ids.forEach((id) => notifyOverlayChanged(id, "reorder"));
|
||||
}
|
||||
|
||||
function deleteOverlay(id) {
|
||||
const exists = db.prepare("SELECT id FROM overlays WHERE id = ?").get(id);
|
||||
if (!exists) throw new Error("Overlay not found.");
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM overlay_modules WHERE scene_id IN (SELECT id FROM overlay_scenes WHERE overlay_id = ?)").run(id);
|
||||
db.prepare("DELETE FROM overlay_scenes WHERE overlay_id = ?").run(id);
|
||||
db.prepare("DELETE FROM overlay_obs_settings WHERE overlay_id = ?").run(id);
|
||||
db.prepare("DELETE FROM overlays WHERE id = ?").run(id);
|
||||
})();
|
||||
publishWebEvent("overlay:changed", { deleted: true, revision: Date.now() }, { scope: `overlay:${id}` });
|
||||
overlayChanges.emit("changed", { overlayId: id, source: "delete", deleted: true, revision: Date.now() });
|
||||
}
|
||||
|
||||
function regenerateOverlayToken(id) {
|
||||
const token = createStoredToken();
|
||||
const result = db.prepare("UPDATE overlays SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?")
|
||||
.run(token.hash, token.encrypted, Date.now(), id);
|
||||
if (!result.changes) throw new Error("Overlay not found.");
|
||||
notifyOverlayChanged(id, "token_regenerated");
|
||||
return token.token;
|
||||
}
|
||||
|
||||
function addScene(overlayId, input = {}) {
|
||||
if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found.");
|
||||
const token = createStoredToken();
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
"INSERT INTO overlay_scenes (id, overlay_id, name, enabled, sort_order, public_token_hash, public_token_encrypted, obs_scene_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
id,
|
||||
overlayId,
|
||||
requiredName(input.name, "Scene name"),
|
||||
input.enabled === false ? 0 : 1,
|
||||
nextOrder("overlay_scenes", "overlay_id", overlayId),
|
||||
token.hash,
|
||||
token.encrypted,
|
||||
String(input.obsSceneName || "").trim().slice(0, 256) || null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
touchOverlay(overlayId, now);
|
||||
notifyOverlayChanged(overlayId, "scene_create");
|
||||
return getOverlay(overlayId, { includeSecrets: true }).scenes.find((scene) => scene.id === id);
|
||||
}
|
||||
|
||||
function updateScene(sceneId, input = {}) {
|
||||
const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
|
||||
if (!scene) throw new Error("Scene not found.");
|
||||
const now = Date.now();
|
||||
db.prepare("UPDATE overlay_scenes SET name = ?, enabled = ?, obs_scene_name = ?, updated_at = ? WHERE id = ?")
|
||||
.run(
|
||||
requiredName(input.name === undefined ? scene.name : input.name, "Scene name"),
|
||||
input.enabled === undefined ? scene.enabled : input.enabled ? 1 : 0,
|
||||
String(input.obsSceneName === undefined ? scene.obs_scene_name || "" : input.obsSceneName).trim().slice(0, 256) || null,
|
||||
now,
|
||||
sceneId
|
||||
);
|
||||
touchOverlay(scene.overlay_id, now);
|
||||
notifyOverlayChanged(scene.overlay_id, "scene_update");
|
||||
}
|
||||
|
||||
function duplicateScene(sceneId, name) {
|
||||
const source = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
|
||||
if (!source) throw new Error("Scene not found.");
|
||||
const scene = addScene(source.overlay_id, { name: name || `${source.name} copy`, enabled: Boolean(source.enabled), obsSceneName: source.obs_scene_name });
|
||||
const modules = db.prepare("SELECT * FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at").all(sceneId);
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
db.transaction(() => modules.forEach((module) => insert.run(crypto.randomUUID(), scene.id, module.type, module.name, module.enabled, module.sort_order, module.config_json, Date.now(), Date.now())))();
|
||||
touchOverlay(source.overlay_id);
|
||||
notifyOverlayChanged(source.overlay_id, "scene_duplicate");
|
||||
return getOverlay(source.overlay_id, { includeSecrets: true }).scenes.find((item) => item.id === scene.id);
|
||||
}
|
||||
|
||||
function reorderScenes(overlayId, ids) {
|
||||
reorderRows("overlay_scenes", ids, "overlay_id", overlayId);
|
||||
touchOverlay(overlayId);
|
||||
notifyOverlayChanged(overlayId, "scene_reorder");
|
||||
}
|
||||
|
||||
function deleteScene(sceneId) {
|
||||
const scene = db.prepare("SELECT * FROM overlay_scenes WHERE id = ?").get(sceneId);
|
||||
if (!scene) throw new Error("Scene not found.");
|
||||
const count = db.prepare("SELECT COUNT(*) AS count FROM overlay_scenes WHERE overlay_id = ?").get(scene.overlay_id).count;
|
||||
if (count <= 1) throw new Error("Every overlay must have at least one scene.");
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM overlay_modules WHERE scene_id = ?").run(sceneId);
|
||||
db.prepare("DELETE FROM overlay_scenes WHERE id = ?").run(sceneId);
|
||||
if (db.prepare("SELECT active_scene_id FROM overlays WHERE id = ?").get(scene.overlay_id)?.active_scene_id === sceneId) {
|
||||
const fallback = db.prepare("SELECT id FROM overlay_scenes WHERE overlay_id = ? ORDER BY enabled DESC, sort_order, created_at LIMIT 1").get(scene.overlay_id);
|
||||
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(fallback.id, Date.now(), scene.overlay_id);
|
||||
} else {
|
||||
touchOverlay(scene.overlay_id);
|
||||
}
|
||||
})();
|
||||
notifyOverlayChanged(scene.overlay_id, "scene_delete");
|
||||
}
|
||||
|
||||
function regenerateSceneToken(sceneId) {
|
||||
const scene = db.prepare("SELECT overlay_id FROM overlay_scenes WHERE id = ?").get(sceneId);
|
||||
if (!scene) throw new Error("Scene not found.");
|
||||
const token = createStoredToken();
|
||||
db.prepare("UPDATE overlay_scenes SET public_token_hash = ?, public_token_encrypted = ?, updated_at = ? WHERE id = ?")
|
||||
.run(token.hash, token.encrypted, Date.now(), sceneId);
|
||||
touchOverlay(scene.overlay_id);
|
||||
notifyOverlayChanged(scene.overlay_id, "scene_token_regenerated");
|
||||
return token.token;
|
||||
}
|
||||
|
||||
function setActiveScene(overlayId, sceneId, { source = "server" } = {}) {
|
||||
const scene = db.prepare("SELECT id, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId);
|
||||
if (!scene) throw new Error("Scene not found for this overlay.");
|
||||
if (!scene.enabled) throw new Error("Disabled scenes cannot be activated.");
|
||||
db.prepare("UPDATE overlays SET active_scene_id = ?, updated_at = ? WHERE id = ?").run(sceneId, Date.now(), overlayId);
|
||||
notifyOverlayChanged(overlayId, source);
|
||||
}
|
||||
|
||||
function addModule(sceneId, input = {}) {
|
||||
const overlayId = overlayIdForScene(sceneId);
|
||||
if (!overlayId) throw new Error("Scene not found.");
|
||||
const type = String(input.type || "text");
|
||||
const config = normalizeModuleConfig(type, input.config || input);
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
"INSERT INTO overlay_modules (id, scene_id, type, name, enabled, sort_order, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(id, sceneId, type, requiredName(input.name || "Module", "Module name"), input.enabled === false ? 0 : 1, nextOrder("overlay_modules", "scene_id", sceneId), JSON.stringify(config), now, now);
|
||||
touchOverlay(overlayId, now);
|
||||
notifyOverlayChanged(overlayId, "module_create");
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateModule(moduleId, input = {}) {
|
||||
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
|
||||
if (!module) throw new Error("Overlay module not found.");
|
||||
const overlayId = overlayIdForModule(moduleId);
|
||||
const type = String(input.type || module.type);
|
||||
const config = normalizeModuleConfig(type, input.config || input);
|
||||
const now = Date.now();
|
||||
db.prepare("UPDATE overlay_modules SET type = ?, name = ?, enabled = ?, config_json = ?, updated_at = ? WHERE id = ?")
|
||||
.run(type, requiredName(input.name === undefined ? module.name : input.name, "Module name"), input.enabled === undefined ? module.enabled : input.enabled ? 1 : 0, JSON.stringify(config), now, moduleId);
|
||||
touchOverlay(overlayId, now);
|
||||
notifyOverlayChanged(overlayId, "module_update");
|
||||
}
|
||||
|
||||
function updateModuleTransform(moduleId, input = {}) {
|
||||
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
|
||||
if (!module) throw new Error("Overlay source not found.");
|
||||
const current = parseConfig(module.config_json);
|
||||
updateModule(moduleId, {
|
||||
type: module.type,
|
||||
name: module.name,
|
||||
enabled: Boolean(module.enabled),
|
||||
config: {
|
||||
...current,
|
||||
x: input.x === undefined ? current.x : input.x,
|
||||
y: input.y === undefined ? current.y : input.y,
|
||||
width: input.width === undefined ? current.width : input.width,
|
||||
height: input.height === undefined ? current.height : input.height,
|
||||
anchor: input.anchor === undefined ? current.anchor : input.anchor
|
||||
}
|
||||
});
|
||||
const updated = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
|
||||
return { ...updated, enabled: Boolean(updated.enabled), config: parseConfig(updated.config_json) };
|
||||
}
|
||||
|
||||
function refreshModule(moduleId) {
|
||||
const overlayId = overlayIdForModule(moduleId);
|
||||
if (!overlayId) throw new Error("Overlay source not found.");
|
||||
publishWebEvent("overlay:module-refresh", { module_id: moduleId }, { scope: `overlay:${overlayId}` });
|
||||
return { overlayId, moduleId };
|
||||
}
|
||||
|
||||
function duplicateModule(moduleId, name) {
|
||||
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
|
||||
if (!module) throw new Error("Overlay module not found.");
|
||||
return addModule(module.scene_id, {
|
||||
type: module.type,
|
||||
name: name || `${module.name} copy`,
|
||||
enabled: Boolean(module.enabled),
|
||||
config: parseConfig(module.config_json)
|
||||
});
|
||||
}
|
||||
|
||||
function reorderModules(sceneId, ids) {
|
||||
const overlayId = overlayIdForScene(sceneId);
|
||||
if (!overlayId) throw new Error("Scene not found.");
|
||||
reorderRows("overlay_modules", ids, "scene_id", sceneId);
|
||||
touchOverlay(overlayId);
|
||||
notifyOverlayChanged(overlayId, "module_reorder");
|
||||
}
|
||||
|
||||
function deleteModule(moduleId) {
|
||||
const overlayId = overlayIdForModule(moduleId);
|
||||
if (!overlayId) throw new Error("Overlay module not found.");
|
||||
db.prepare("DELETE FROM overlay_modules WHERE id = ?").run(moduleId);
|
||||
touchOverlay(overlayId);
|
||||
notifyOverlayChanged(overlayId, "module_delete");
|
||||
}
|
||||
|
||||
function resolvePublicOverlay(overlayToken, sceneToken = null) {
|
||||
const overlay = db.prepare("SELECT * FROM overlays WHERE public_token_hash = ?").get(tokenHash(overlayToken));
|
||||
if (!overlay) return null;
|
||||
let fixedScene = null;
|
||||
if (sceneToken) {
|
||||
fixedScene = db.prepare("SELECT * FROM overlay_scenes WHERE overlay_id = ? AND public_token_hash = ?").get(overlay.id, tokenHash(sceneToken));
|
||||
if (!fixedScene) return null;
|
||||
}
|
||||
return { overlay, fixedScene };
|
||||
}
|
||||
|
||||
function buildPublicState(overlayId, fixedSceneId = null) {
|
||||
const overlay = db.prepare("SELECT id, enabled, active_scene_id, canvas_width, canvas_height, updated_at FROM overlays WHERE id = ?").get(overlayId);
|
||||
if (!overlay) return { exists: false, enabled: false, revision: Date.now(), scene: null };
|
||||
const canvas = { width: overlay.canvas_width || 1920, height: overlay.canvas_height || 1080 };
|
||||
if (!overlay.enabled) return { exists: true, enabled: false, revision: overlay.updated_at, canvas, scene: null };
|
||||
const sceneId = fixedSceneId || overlay.active_scene_id;
|
||||
const scene = sceneId
|
||||
? db.prepare("SELECT id, name, enabled FROM overlay_scenes WHERE id = ? AND overlay_id = ?").get(sceneId, overlayId)
|
||||
: null;
|
||||
if (!scene || !scene.enabled) return { exists: true, enabled: true, revision: overlay.updated_at, canvas, scene: null };
|
||||
const modules = db.prepare("SELECT id, type, name, config_json FROM overlay_modules WHERE scene_id = ? AND enabled = 1 ORDER BY sort_order, created_at")
|
||||
.all(scene.id)
|
||||
.map((module) => ({
|
||||
id: module.id,
|
||||
type: module.type,
|
||||
renderType: getOverlayModuleType(module.type)?.renderType || null,
|
||||
name: module.name,
|
||||
config: parseConfig(module.config_json)
|
||||
}))
|
||||
.filter((module) => module.renderType);
|
||||
return {
|
||||
exists: true,
|
||||
enabled: true,
|
||||
revision: overlay.updated_at,
|
||||
canvas,
|
||||
scene: { id: scene.id, name: scene.name, modules }
|
||||
};
|
||||
}
|
||||
|
||||
function getObsSettings(overlayId, { includeSecret = false } = {}) {
|
||||
const row = db.prepare("SELECT * FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId);
|
||||
const result = row || {
|
||||
overlay_id: overlayId,
|
||||
enabled: 0,
|
||||
provider: "local_obs_websocket",
|
||||
endpoint: "ws://127.0.0.1:4455",
|
||||
password_encrypted: null,
|
||||
sync_direction: "none",
|
||||
reconnect: 1
|
||||
};
|
||||
const output = { ...result, enabled: Boolean(result.enabled), reconnect: Boolean(result.reconnect), has_password: Boolean(result.password_encrypted) };
|
||||
delete output.password_encrypted;
|
||||
if (includeSecret) output.password = result.password_encrypted ? decryptSecret(result.password_encrypted) : "";
|
||||
return output;
|
||||
}
|
||||
|
||||
function saveObsSettings(overlayId, input = {}) {
|
||||
if (!db.prepare("SELECT id FROM overlays WHERE id = ?").get(overlayId)) throw new Error("Overlay not found.");
|
||||
const requestedProvider = String(input.provider || "local_obs_websocket").trim();
|
||||
const provider = /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(requestedProvider)
|
||||
? requestedProvider
|
||||
: "local_obs_websocket";
|
||||
const endpoint = String(input.endpoint || "ws://127.0.0.1:4455").trim().slice(0, 2048);
|
||||
if (provider === "local_obs_websocket" && !/^wss?:\/\//i.test(endpoint)) {
|
||||
throw new Error("OBS endpoint must start with ws:// or wss://.");
|
||||
}
|
||||
const syncDirection = ["none", "obs_to_overlay", "overlay_to_obs", "bidirectional"].includes(input.syncDirection) ? input.syncDirection : "none";
|
||||
const existing = db.prepare("SELECT password_encrypted FROM overlay_obs_settings WHERE overlay_id = ?").get(overlayId);
|
||||
let passwordEncrypted = existing?.password_encrypted || null;
|
||||
if (input.clearPassword) passwordEncrypted = null;
|
||||
else if (input.password) passwordEncrypted = encryptSecret(input.password);
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
`INSERT INTO overlay_obs_settings (overlay_id, enabled, provider, endpoint, password_encrypted, sync_direction, reconnect, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(overlay_id) DO UPDATE SET enabled = excluded.enabled, provider = excluded.provider, endpoint = excluded.endpoint,
|
||||
password_encrypted = excluded.password_encrypted, sync_direction = excluded.sync_direction, reconnect = excluded.reconnect, updated_at = excluded.updated_at`
|
||||
).run(overlayId, input.enabled ? 1 : 0, provider, endpoint, passwordEncrypted, syncDirection, input.reconnect === false ? 0 : 1, now, now);
|
||||
return getObsSettings(overlayId, { includeSecret: true });
|
||||
}
|
||||
|
||||
function listEnabledObsSettings() {
|
||||
return db.prepare("SELECT overlay_id FROM overlay_obs_settings WHERE enabled = 1").all().map((row) => getObsSettings(row.overlay_id, { includeSecret: true }));
|
||||
}
|
||||
|
||||
function onOverlayChanged(listener) {
|
||||
overlayChanges.on("changed", listener);
|
||||
return () => overlayChanges.off("changed", listener);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addModule,
|
||||
addScene,
|
||||
buildPublicState,
|
||||
createOverlay,
|
||||
deleteModule,
|
||||
deleteOverlay,
|
||||
deleteScene,
|
||||
duplicateModule,
|
||||
duplicateOverlay,
|
||||
duplicateScene,
|
||||
getObsSettings,
|
||||
getOverlay,
|
||||
listEnabledObsSettings,
|
||||
listOverlays,
|
||||
notifyOverlayChanged,
|
||||
onOverlayChanged,
|
||||
regenerateOverlayToken,
|
||||
regenerateSceneToken,
|
||||
refreshModule,
|
||||
reorderModules,
|
||||
reorderOverlays,
|
||||
reorderScenes,
|
||||
resolvePublicOverlay,
|
||||
saveObsSettings,
|
||||
setActiveScene,
|
||||
updateModule,
|
||||
updateModuleTransform,
|
||||
updateOverlay,
|
||||
updateScene
|
||||
};
|
||||
@ -341,17 +341,17 @@ async function renderTemplate({ fieldId, template, user, outputAudience, runtime
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const value = await definition.resolver({
|
||||
const value = await withTimeout(Promise.resolve(definition.resolver({
|
||||
user,
|
||||
policy,
|
||||
outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience),
|
||||
runtimeContext,
|
||||
token: match,
|
||||
id
|
||||
});
|
||||
})), runtimeContext?.placeholder_timeout_ms);
|
||||
return stringifyResolvedValue(value);
|
||||
} catch (error) {
|
||||
errors.push({ token: match, id, reason: "resolver_failed" });
|
||||
errors.push({ token: match, id, reason: error?.code === "EPLACEHOLDERTIMEOUT" ? "resolver_timeout" : "resolver_failed" });
|
||||
return fallback;
|
||||
}
|
||||
});
|
||||
@ -366,16 +366,31 @@ function stringifyResolvedValue(value) {
|
||||
}
|
||||
|
||||
function replaceAsync(value, matcher, replacer) {
|
||||
const matches = [];
|
||||
value.replace(matcher, (...args) => {
|
||||
matches.push(args);
|
||||
return args[0];
|
||||
const source = String(value || "");
|
||||
const flags = matcher.flags.includes("g") ? matcher.flags : `${matcher.flags}g`;
|
||||
const expression = new RegExp(matcher.source, flags);
|
||||
const matches = [...source.matchAll(expression)];
|
||||
return matches.reduce(async (pending, match) => {
|
||||
const state = await pending;
|
||||
const replacement = await replacer(...match, match.index, source, match.groups);
|
||||
return {
|
||||
output: `${state.output}${source.slice(state.cursor, match.index)}${replacement}`,
|
||||
cursor: match.index + match[0].length
|
||||
};
|
||||
}, Promise.resolve({ output: "", cursor: 0 })).then((state) => `${state.output}${source.slice(state.cursor)}`);
|
||||
}
|
||||
|
||||
function withTimeout(promise, requestedMs) {
|
||||
const timeoutMs = Math.max(50, Math.min(Number(requestedMs) || 1500, 10000));
|
||||
let timer = null;
|
||||
const timeout = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error("Placeholder resolver timed out.");
|
||||
error.code = "EPLACEHOLDERTIMEOUT";
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
return matches.reduce(async (pending, args) => {
|
||||
const current = await pending;
|
||||
const replacement = await replacer(...args);
|
||||
return current.replace(args[0], replacement);
|
||||
}, Promise.resolve(value));
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function registerCorePlaceholders() {
|
||||
@ -385,7 +400,7 @@ function registerCorePlaceholders() {
|
||||
field_type: "command_response",
|
||||
output_audience: "user",
|
||||
min_editor_role: "mod",
|
||||
allowed_namespaces: ["core.main", "user.public"],
|
||||
allowed_namespaces: ["core.main", "core.command", "custom", "user.public"],
|
||||
max_sensitivity: "public_safe"
|
||||
});
|
||||
registerPlaceholders([
|
||||
@ -415,6 +430,19 @@ function registerCorePlaceholders() {
|
||||
example: "!",
|
||||
resolver: () => getSetting("command_prefix", "!")
|
||||
},
|
||||
{
|
||||
id: "core.command.rng",
|
||||
namespace: "core.command",
|
||||
label: "Random Reply number",
|
||||
description: "The number rolled for the current Random Reply command. Blank for other messages.",
|
||||
value_type: "number",
|
||||
sensitivity: "public_safe",
|
||||
min_editor_role: "mod",
|
||||
min_viewer_role: "user",
|
||||
allowed_field_types: ["command_response"],
|
||||
example: "42",
|
||||
resolver: ({ runtimeContext }) => runtimeContext?.command?.rng ?? ""
|
||||
},
|
||||
{
|
||||
id: "user.public.display_name",
|
||||
namespace: "user.public",
|
||||
@ -433,6 +461,75 @@ function registerCorePlaceholders() {
|
||||
""
|
||||
}
|
||||
]);
|
||||
registerCustomPlaceholders(getSetting("custom_placeholders", []));
|
||||
}
|
||||
|
||||
function validateCustomPlaceholders(value) {
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
const errors = [];
|
||||
const definitions = [];
|
||||
const names = new Set();
|
||||
if (rows.length > 50) errors.push("You can save at most 50 custom placeholders.");
|
||||
rows.slice(0, 50).forEach((row, index) => {
|
||||
const name = String(row?.name || "").trim().toLowerCase().replace(/^custom\./, "");
|
||||
const rawValue = String(row?.value ?? "");
|
||||
const visibility = ["user", "mod", "admin"].includes(row?.visibility) ? row.visibility : "";
|
||||
const description = String(row?.description || "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, 240);
|
||||
if (!/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/.test(name)) {
|
||||
errors.push(`Custom placeholder ${index + 1} needs a lowercase name such as stream.title.`);
|
||||
return;
|
||||
}
|
||||
if (!visibility) {
|
||||
errors.push(`Choose who may receive custom.${name}.`);
|
||||
return;
|
||||
}
|
||||
if (/(?:^|[._])(?:api_?key|client_?secret|credential|password|private_?key|secret|token)(?:$|[._])/.test(name)) {
|
||||
errors.push(`custom.${name} looks like a secret. Passwords, tokens, credentials, and API keys must not be placeholders.`);
|
||||
return;
|
||||
}
|
||||
if (names.has(name)) {
|
||||
errors.push(`Custom placeholder custom.${name} is listed more than once.`);
|
||||
return;
|
||||
}
|
||||
if (rawValue.length > 2000) {
|
||||
errors.push(`Custom placeholder custom.${name} is longer than 2,000 characters.`);
|
||||
return;
|
||||
}
|
||||
names.add(name);
|
||||
definitions.push({
|
||||
name,
|
||||
value: rawValue.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, 2000),
|
||||
visibility,
|
||||
description
|
||||
});
|
||||
});
|
||||
return { ok: errors.length === 0, errors, definitions };
|
||||
}
|
||||
|
||||
function registerCustomPlaceholders(value) {
|
||||
unregisterNamespace("custom");
|
||||
const validation = validateCustomPlaceholders(value);
|
||||
if (!validation.ok) return validation;
|
||||
const roles = {
|
||||
user: { viewer: "user", sensitivity: "public_safe" },
|
||||
mod: { viewer: "mod", sensitivity: "moderator" },
|
||||
admin: { viewer: "admin", sensitivity: "admin" }
|
||||
};
|
||||
registerPlaceholders(validation.definitions.map((definition) => ({
|
||||
id: `custom.${definition.name}`,
|
||||
namespace: "custom",
|
||||
label: definition.name.replace(/[._-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()),
|
||||
description: definition.description || `Admin-managed custom value: custom.${definition.name}`,
|
||||
value_type: "string",
|
||||
sensitivity: roles[definition.visibility].sensitivity,
|
||||
min_editor_role: definition.visibility === "user" ? "mod" : definition.visibility,
|
||||
min_viewer_role: roles[definition.visibility].viewer,
|
||||
allowed_field_types: ["command_response", "admin_template", "okf_markdown"],
|
||||
group: "Custom",
|
||||
example: definition.visibility === "user" ? definition.value.slice(0, 120) : null,
|
||||
resolver: () => definition.value
|
||||
})));
|
||||
return validation;
|
||||
}
|
||||
|
||||
function registerPlatformPlaceholders({ discordClient, getTwitchClient, getYouTubeClient } = {}) {
|
||||
@ -468,6 +565,15 @@ function discordPlatformPlaceholders(discordClient) {
|
||||
}
|
||||
};
|
||||
return [
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.data_status`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Discord data status",
|
||||
description: "Whether Discord values come from the connected runtime cache or are unavailable.",
|
||||
example: "live_cache",
|
||||
resolver: () => discordDataStatus(discordClient)
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.name`,
|
||||
namespace,
|
||||
@ -564,6 +670,15 @@ function twitchPlatformPlaceholders(getTwitchClient) {
|
||||
const namespace = "platform.twitch.channel";
|
||||
const group = "Twitch channel";
|
||||
return [
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.data_status`,
|
||||
namespace,
|
||||
group,
|
||||
label: "Twitch data status",
|
||||
description: "Whether Twitch values come from a connected chat runtime, configured local values, or are unavailable.",
|
||||
example: "connected",
|
||||
resolver: () => getTwitchClient?.() ? "connected" : twitchChannels().length ? "configured_not_connected" : "unavailable"
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.primary_name`,
|
||||
namespace,
|
||||
@ -609,6 +724,17 @@ function youtubePlatformPlaceholders(getYouTubeClient) {
|
||||
const namespace = "platform.youtube.channel";
|
||||
const group = "YouTube channel";
|
||||
return [
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.data_status`,
|
||||
namespace,
|
||||
group,
|
||||
label: "YouTube data status",
|
||||
description: "Whether YouTube values come from the connected runtime, configured local values, or are unavailable.",
|
||||
example: "connected",
|
||||
resolver: () => getYouTubeClient?.()
|
||||
? "connected"
|
||||
: getSetting("youtube_bot_channel_id", "") ? "configured_not_connected" : "unavailable"
|
||||
}),
|
||||
platformPlaceholder({
|
||||
id: `${namespace}.id`,
|
||||
namespace,
|
||||
@ -653,12 +779,13 @@ function youtubePlatformPlaceholders(getYouTubeClient) {
|
||||
async function resolveDiscordGuild(discordClient) {
|
||||
const guildId = getSetting("discord_guild_id", "");
|
||||
if (!discordClient || !guildId) return null;
|
||||
const cached = discordClient.guilds?.cache?.get?.(guildId);
|
||||
if (cached) return cached;
|
||||
if (typeof discordClient.guilds?.fetch === "function") {
|
||||
return discordClient.guilds.fetch(guildId).catch(() => null);
|
||||
}
|
||||
return null;
|
||||
return discordClient.guilds?.cache?.get?.(guildId) || null;
|
||||
}
|
||||
|
||||
function discordDataStatus(discordClient) {
|
||||
const guildId = getSetting("discord_guild_id", "");
|
||||
if (!guildId) return "unavailable";
|
||||
return discordClient?.guilds?.cache?.get?.(guildId) ? "live_cache" : "configured_not_connected";
|
||||
}
|
||||
|
||||
function countDiscordChannels(guild, kind) {
|
||||
@ -696,12 +823,14 @@ module.exports = {
|
||||
getFieldPolicy,
|
||||
parsePlaceholders,
|
||||
registerCorePlaceholders,
|
||||
registerCustomPlaceholders,
|
||||
registerFieldPolicy,
|
||||
registerPlaceholder,
|
||||
registerPlaceholders,
|
||||
registerPlatformPlaceholders,
|
||||
renderTemplate,
|
||||
unregisterNamespace,
|
||||
validateCustomPlaceholders,
|
||||
unregisterPlaceholder,
|
||||
validateTemplate,
|
||||
_internals: {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const { writeJsonAtomicSync } = require("./safe-files");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const recoveryDir = path.join(repoRoot, "data", "recovery");
|
||||
@ -34,7 +35,7 @@ function writeRecoveryMarker(marker) {
|
||||
updated_at: new Date().toISOString(),
|
||||
...marker
|
||||
};
|
||||
fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8");
|
||||
writeJsonAtomicSync(markerPath, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
183
src/services/safe-files.js
Normal file
183
src/services/safe-files.js
Normal file
@ -0,0 +1,183 @@
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { threadId } = require("worker_threads");
|
||||
|
||||
const TRANSIENT_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]);
|
||||
const activeWrites = new Set();
|
||||
const diagnostics = [];
|
||||
const sleepArray = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function writeJsonAtomicSync(target, value, options = {}) {
|
||||
return writeTextAtomicSync(target, `${JSON.stringify(value, null, options.indent ?? 2)}\n`, options);
|
||||
}
|
||||
|
||||
function writeTextAtomicSync(target, content, options = {}) {
|
||||
if (!String(target || "").trim()) throw new Error("A target file is required.");
|
||||
const file = path.resolve(String(target));
|
||||
if (activeWrites.has(file)) {
|
||||
const error = new Error(`A write to ${file} is already in progress.`);
|
||||
error.code = "EWRITELOCKED";
|
||||
recordDiagnostic(file, "write_locked", error, 0, "error", options);
|
||||
throw error;
|
||||
}
|
||||
|
||||
activeWrites.add(file);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
cleanupStaleTempFilesSync(file, options);
|
||||
const temporary = temporaryPath(file);
|
||||
let descriptor = null;
|
||||
try {
|
||||
descriptor = fs.openSync(temporary, "wx", options.mode ?? 0o600);
|
||||
fs.writeFileSync(descriptor, content, options.encoding || "utf8");
|
||||
try { fs.fsyncSync(descriptor); } catch (error) {
|
||||
recordDiagnostic(file, "fsync_unavailable", error, 0, "warning", options);
|
||||
}
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = null;
|
||||
replaceFileSync(temporary, file, options);
|
||||
if (options.mode != null) {
|
||||
try { fs.chmodSync(file, options.mode); } catch {}
|
||||
}
|
||||
syncParentDirectory(file, options);
|
||||
return file;
|
||||
} catch (error) {
|
||||
recordDiagnostic(file, "write_failed", error, 0, "error", options);
|
||||
throw error;
|
||||
} finally {
|
||||
if (descriptor != null) {
|
||||
try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
try { fs.rmSync(temporary, { force: true }); } catch {}
|
||||
activeWrites.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceFileSync(temporary, target, options = {}) {
|
||||
try {
|
||||
retrySync(() => fs.renameSync(temporary, target), target, "rename", options);
|
||||
return;
|
||||
} catch (renameError) {
|
||||
if (!fs.existsSync(target) || !TRANSIENT_CODES.has(renameError.code)) throw renameError;
|
||||
}
|
||||
|
||||
const backup = `${target}.${process.pid}.${threadId}.${crypto.randomBytes(5).toString("hex")}.bak`;
|
||||
let movedOriginal = false;
|
||||
try {
|
||||
retrySync(() => fs.renameSync(target, backup), target, "backup_existing", options);
|
||||
movedOriginal = true;
|
||||
retrySync(() => fs.renameSync(temporary, target), target, "install_replacement", options);
|
||||
fs.rmSync(backup, { force: true });
|
||||
recordDiagnostic(target, "backup_replace_succeeded", null, 0, "warning", options);
|
||||
} catch (error) {
|
||||
if (movedOriginal && fs.existsSync(backup) && !fs.existsSync(target)) {
|
||||
try {
|
||||
retrySync(() => fs.renameSync(backup, target), target, "restore_original", options);
|
||||
} catch (restoreError) {
|
||||
recordDiagnostic(target, "restore_failed", restoreError, 0, "error", options);
|
||||
error.message = `${error.message} The original file remains at ${backup}.`;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (fs.existsSync(backup) && fs.existsSync(target)) {
|
||||
try { fs.rmSync(backup, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retrySync(operation, target, operationName, options = {}) {
|
||||
const attempts = Math.max(1, Number(options.attempts) || 8);
|
||||
const delayMs = Math.max(1, Number(options.delayMs) || 15);
|
||||
let lastError = null;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!TRANSIENT_CODES.has(error.code) || attempt === attempts) throw error;
|
||||
recordDiagnostic(target, operationName, error, attempt, "warning", options);
|
||||
Atomics.wait(sleepArray, 0, 0, Math.min(250, delayMs * attempt));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function runFileOperationWithRetries(operation, options = {}) {
|
||||
if (typeof operation !== "function") throw new Error("A file operation function is required.");
|
||||
return retrySync(
|
||||
operation,
|
||||
options.target || "file operation",
|
||||
options.operation || "file_operation",
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupStaleTempFilesSync(target, options = {}) {
|
||||
const directory = path.dirname(target);
|
||||
const basename = path.basename(target);
|
||||
const maxAgeMs = Math.max(1000, Number(options.staleTempAgeMs) || 60 * 60 * 1000);
|
||||
const now = Date.now();
|
||||
let names = [];
|
||||
try { names = fs.readdirSync(directory); } catch { return; }
|
||||
for (const name of names) {
|
||||
if (name !== `${basename}.tmp` && !(name.startsWith(`${basename}.`) && name.endsWith(".tmp"))) continue;
|
||||
const candidate = path.join(directory, name);
|
||||
try {
|
||||
if (now - fs.statSync(candidate).mtimeMs < maxAgeMs) continue;
|
||||
fs.rmSync(candidate, { force: true });
|
||||
recordDiagnostic(target, "stale_temp_removed", null, 0, "warning", options);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryPath(target) {
|
||||
return `${target}.${process.pid}.${threadId}.${crypto.randomBytes(7).toString("hex")}.tmp`;
|
||||
}
|
||||
|
||||
function syncParentDirectory(target, options) {
|
||||
let descriptor = null;
|
||||
try {
|
||||
descriptor = fs.openSync(path.dirname(target), "r");
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
if (process.platform !== "win32") {
|
||||
recordDiagnostic(target, "directory_fsync_unavailable", error, 0, "warning", options);
|
||||
}
|
||||
} finally {
|
||||
if (descriptor != null) try { fs.closeSync(descriptor); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function recordDiagnostic(target, operation, error, attempt, level, options = {}) {
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
operation,
|
||||
target,
|
||||
attempt,
|
||||
code: error?.code || null,
|
||||
message: error?.message || null
|
||||
};
|
||||
diagnostics.push(entry);
|
||||
if (diagnostics.length > 100) diagnostics.splice(0, diagnostics.length - 100);
|
||||
if (typeof options.onDiagnostic === "function") options.onDiagnostic(entry);
|
||||
}
|
||||
|
||||
function getSafeFileDiagnostics() {
|
||||
return diagnostics.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function clearSafeFileDiagnostics() {
|
||||
diagnostics.length = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TRANSIENT_CODES,
|
||||
writeJsonAtomicSync,
|
||||
writeTextAtomicSync,
|
||||
cleanupStaleTempFilesSync,
|
||||
runFileOperationWithRetries,
|
||||
getSafeFileDiagnostics,
|
||||
clearSafeFileDiagnostics
|
||||
};
|
||||
@ -86,6 +86,7 @@ function ensureDefaults() {
|
||||
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
|
||||
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
|
||||
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
|
||||
custom_placeholders: [],
|
||||
localhost_login_username: "admin",
|
||||
localhost_login_password: "admin",
|
||||
theme_light_bg_1: "#ffe5c4",
|
||||
|
||||
@ -2,6 +2,10 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const crypto = require("crypto");
|
||||
const {
|
||||
runFileOperationWithRetries,
|
||||
writeJsonAtomicSync
|
||||
} = require("./safe-files");
|
||||
let AdmZip = null;
|
||||
try {
|
||||
AdmZip = require("adm-zip");
|
||||
@ -77,7 +81,7 @@ function loadIndex() {
|
||||
|
||||
function saveIndex(entries) {
|
||||
ensureSnapshotsDir();
|
||||
fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), "utf8");
|
||||
writeJsonAtomicSync(indexPath, entries);
|
||||
}
|
||||
|
||||
async function backupDatabase(targetPath) {
|
||||
@ -328,17 +332,67 @@ function addFolder(zip, folderPath, basePath, ignore) {
|
||||
}
|
||||
|
||||
function resetCoreFiles() {
|
||||
const ignore = new Set([".git", "node_modules", "data", "plugins"]);
|
||||
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
|
||||
resetDirectoryForFullUpdate(repoRoot, buildCorePreservePaths(repoRoot));
|
||||
}
|
||||
|
||||
function resetDirectoryForFullUpdate(targetRoot, preservePaths = buildCorePreservePaths(targetRoot)) {
|
||||
removeUnpreservedEntries(targetRoot, targetRoot, preservePaths);
|
||||
}
|
||||
|
||||
function buildCorePreservePaths(targetRoot = repoRoot) {
|
||||
const preserve = new Set(PRESERVE_RELATIVE_PATHS);
|
||||
for (const scope of ["core", "plugins"]) {
|
||||
const directory = path.join(targetRoot, "knowledge", scope);
|
||||
collectLocalKnowledgePaths(directory, targetRoot, preserve);
|
||||
}
|
||||
return preserve;
|
||||
}
|
||||
|
||||
function collectLocalKnowledgePaths(directory, base, preserve) {
|
||||
if (!fs.existsSync(directory)) return;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const filePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectLocalKnowledgePaths(filePath, base, preserve);
|
||||
} else if (!entry.isFile() || isLocallyOwnedKnowledgeFile(filePath)) {
|
||||
preserve.add(normalizeRelative(path.relative(base, filePath)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLocallyOwnedKnowledgeFile(filePath) {
|
||||
if (path.extname(filePath).toLowerCase() !== ".md") return true;
|
||||
let source = "";
|
||||
try {
|
||||
source = fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const frontmatter = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || "";
|
||||
const generated = /^generated:\s*true\s*$/im.test(frontmatter);
|
||||
const readOnly = /^editable:\s*false\s*$/im.test(frontmatter);
|
||||
return !(generated && readOnly);
|
||||
}
|
||||
|
||||
function removeUnpreservedEntries(directory, base, preservePaths) {
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (ignore.has(entry.name)) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
const relativePath = normalizeRelative(path.relative(base, fullPath));
|
||||
if (isRelativePathIgnored(relativePath, preservePaths)) continue;
|
||||
if (entry.isDirectory() && hasPreservedDescendant(relativePath, preservePaths)) {
|
||||
removeUnpreservedEntries(fullPath, base, preservePaths);
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(repoRoot, entry.name);
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function hasPreservedDescendant(relativePath, preservePaths) {
|
||||
const prefix = `${normalizeRelative(relativePath)}/`;
|
||||
return [...preservePaths].some((candidate) => normalizeRelative(candidate).startsWith(prefix));
|
||||
}
|
||||
|
||||
function normalizeRelative(value) {
|
||||
return String(value || "").split(path.sep).join("/");
|
||||
}
|
||||
@ -421,19 +475,22 @@ function removeGeneratedPaths() {
|
||||
|
||||
function applyCoreFilesFromDirectory(rootPath) {
|
||||
removeGeneratedPaths();
|
||||
const preservePaths = buildCorePreservePaths(repoRoot);
|
||||
resetDirectoryForFullUpdate(repoRoot, preservePaths);
|
||||
copyDirectory(
|
||||
rootPath,
|
||||
repoRoot,
|
||||
PRESERVE_RELATIVE_PATHS,
|
||||
preservePaths,
|
||||
{ base: rootPath }
|
||||
);
|
||||
}
|
||||
|
||||
function applyCorePatch(rootPath) {
|
||||
const preservePaths = buildCorePreservePaths(repoRoot);
|
||||
copyDirectory(
|
||||
rootPath,
|
||||
repoRoot,
|
||||
PRESERVE_RELATIVE_PATHS,
|
||||
preservePaths,
|
||||
{ base: rootPath }
|
||||
);
|
||||
}
|
||||
@ -476,19 +533,66 @@ function applyPluginFiles(rootPath, pluginId, options = {}) {
|
||||
}
|
||||
|
||||
function replacePluginDirectory(rootPath, targetDir, options = {}) {
|
||||
if (options.preserveData) {
|
||||
resetPluginCode(targetDir);
|
||||
} else {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
const parent = path.dirname(targetDir);
|
||||
const name = path.basename(targetDir);
|
||||
const nonce = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
||||
const staging = path.join(parent, `.${name}.update-${nonce}`);
|
||||
const backup = path.join(parent, `.${name}.backup-${nonce}`);
|
||||
const ignore = options.preserveData
|
||||
? new Set(["node_modules", "data"])
|
||||
: new Set(["node_modules"]);
|
||||
let targetMoved = false;
|
||||
let dataMoved = false;
|
||||
let replacementInstalled = false;
|
||||
let rollbackFailed = false;
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
try {
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
copyDirectory(rootPath, staging, ignore);
|
||||
if (fs.existsSync(targetDir)) {
|
||||
moveDirectory(targetDir, backup, "backup_plugin");
|
||||
targetMoved = true;
|
||||
}
|
||||
const backupData = path.join(backup, "data");
|
||||
const stagedData = path.join(staging, "data");
|
||||
if (options.preserveData && targetMoved && fs.existsSync(backupData)) {
|
||||
moveDirectory(backupData, stagedData, "preserve_plugin_data");
|
||||
dataMoved = true;
|
||||
}
|
||||
moveDirectory(staging, targetDir, "install_plugin");
|
||||
replacementInstalled = true;
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
if (replacementInstalled && fs.existsSync(targetDir)) {
|
||||
const installedData = path.join(targetDir, "data");
|
||||
if (dataMoved && targetMoved && fs.existsSync(installedData) && fs.existsSync(backup)) {
|
||||
moveDirectory(installedData, path.join(backup, "data"), "restore_plugin_data");
|
||||
}
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
} else if (dataMoved && targetMoved && fs.existsSync(path.join(staging, "data")) && fs.existsSync(backup)) {
|
||||
moveDirectory(path.join(staging, "data"), path.join(backup, "data"), "restore_plugin_data");
|
||||
}
|
||||
if (targetMoved && fs.existsSync(backup) && !fs.existsSync(targetDir)) {
|
||||
moveDirectory(backup, targetDir, "restore_plugin");
|
||||
}
|
||||
} catch (restoreError) {
|
||||
rollbackFailed = true;
|
||||
error.message = `${error.message} Plugin rollback also failed: ${restoreError.message}. Backup: ${backup}. Staging: ${staging}`;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!rollbackFailed) fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
copyDirectory(
|
||||
rootPath,
|
||||
targetDir,
|
||||
options.preserveData
|
||||
? new Set(["node_modules", "data"])
|
||||
: new Set(["node_modules"])
|
||||
);
|
||||
}
|
||||
|
||||
function moveDirectory(source, target, operation) {
|
||||
runFileOperationWithRetries(() => fs.renameSync(source, target), {
|
||||
target,
|
||||
operation,
|
||||
attempts: 8,
|
||||
delayMs: 20
|
||||
});
|
||||
}
|
||||
|
||||
async function applyBotUpdate(zipPath, options = {}) {
|
||||
@ -524,17 +628,29 @@ async function applyBotUpdate(zipPath, options = {}) {
|
||||
recovery_marker_id: marker.id
|
||||
}
|
||||
});
|
||||
let record = null;
|
||||
try {
|
||||
record = finalizeSnapshot(snapshot);
|
||||
if (mode === "patch") {
|
||||
applyCorePatch(rootPath);
|
||||
} else {
|
||||
applyCoreFilesFromDirectory(rootPath);
|
||||
}
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
return record;
|
||||
} catch (error) {
|
||||
discardSnapshot(snapshot);
|
||||
if (record) {
|
||||
try {
|
||||
restoreSnapshot(record.id, {
|
||||
expectedType: "bot",
|
||||
allowUnsafeMajorRollback: true
|
||||
});
|
||||
} catch (restoreError) {
|
||||
error.message = `${error.message} Automatic rollback also failed: ${restoreError.message}`;
|
||||
}
|
||||
} else {
|
||||
discardSnapshot(snapshot);
|
||||
}
|
||||
markRecoveryMarkerFailed(error);
|
||||
throw error;
|
||||
}
|
||||
@ -575,15 +691,28 @@ async function applyPluginUpdate(zipPath, options = {}) {
|
||||
recovery_marker_id: marker.id
|
||||
}
|
||||
});
|
||||
let record = null;
|
||||
try {
|
||||
record = finalizeSnapshot(snapshot);
|
||||
applyPluginFiles(rootPath, manifest.id, {
|
||||
preserveData: snapshot.pluginExisted
|
||||
});
|
||||
const record = finalizeSnapshot(snapshot);
|
||||
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||
return record;
|
||||
} catch (error) {
|
||||
discardSnapshot(snapshot);
|
||||
if (record) {
|
||||
try {
|
||||
restoreSnapshot(record.id, {
|
||||
expectedType: "plugin",
|
||||
expectedPluginId: manifest.id,
|
||||
allowUnsafeMajorRollback: true
|
||||
});
|
||||
} catch (restoreError) {
|
||||
error.message = `${error.message} Automatic rollback also failed: ${restoreError.message}`;
|
||||
}
|
||||
} else {
|
||||
discardSnapshot(snapshot);
|
||||
}
|
||||
markRecoveryMarkerFailed(error);
|
||||
throw error;
|
||||
}
|
||||
@ -642,10 +771,13 @@ function restoreSnapshot(id, options = {}) {
|
||||
throw new Error("Snapshot core archive missing.");
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
|
||||
extractZip(restoreZip, tempDir);
|
||||
const rootPath = resolveZipRoot(tempDir);
|
||||
applyCoreFilesFromDirectory(rootPath);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
try {
|
||||
extractZip(restoreZip, tempDir);
|
||||
const rootPath = resolveZipRoot(tempDir);
|
||||
applyCoreFilesFromDirectory(rootPath);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === "plugin") {
|
||||
@ -657,10 +789,13 @@ function restoreSnapshot(id, options = {}) {
|
||||
throw new Error("Snapshot plugin archive missing.");
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
|
||||
extractZip(pluginZip, tempDir);
|
||||
const rootPath = resolvePluginRoot(tempDir);
|
||||
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
try {
|
||||
extractZip(pluginZip, tempDir);
|
||||
const rootPath = resolvePluginRoot(tempDir);
|
||||
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
} else {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
@ -680,6 +815,8 @@ module.exports = {
|
||||
applyCoreFilesFromDirectory,
|
||||
applyPluginFiles,
|
||||
resetPluginCode,
|
||||
resetDirectoryForFullUpdate,
|
||||
buildCorePreservePaths,
|
||||
replacePluginDirectory,
|
||||
listSnapshots,
|
||||
restoreSnapshot
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { writeJsonAtomicSync } = require("./safe-files");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..", "..");
|
||||
const dataDir = path.join(repoRoot, "data");
|
||||
@ -82,7 +83,7 @@ function writeUpdateState(values = {}) {
|
||||
...current,
|
||||
...values
|
||||
};
|
||||
fs.writeFileSync(updateStatePath, JSON.stringify(next, null, 2), "utf8");
|
||||
writeJsonAtomicSync(updateStatePath, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
198
src/services/upload-security.js
Normal file
198
src/services/upload-security.js
Normal file
@ -0,0 +1,198 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { TextDecoder } = require("util");
|
||||
|
||||
const FILE_TYPES = Object.freeze({
|
||||
zip: {
|
||||
mime: "application/zip",
|
||||
mimes: ["application/zip", "application/x-zip-compressed", "application/octet-stream"],
|
||||
extensions: [".zip"]
|
||||
},
|
||||
png: {
|
||||
mime: "image/png",
|
||||
mimes: ["image/png"],
|
||||
extensions: [".png"]
|
||||
},
|
||||
jpeg: {
|
||||
mime: "image/jpeg",
|
||||
mimes: ["image/jpeg", "image/jpg"],
|
||||
extensions: [".jpg", ".jpeg"]
|
||||
},
|
||||
webp: {
|
||||
mime: "image/webp",
|
||||
mimes: ["image/webp"],
|
||||
extensions: [".webp"]
|
||||
},
|
||||
pdf: {
|
||||
mime: "application/pdf",
|
||||
mimes: ["application/pdf"],
|
||||
extensions: [".pdf"]
|
||||
},
|
||||
text: {
|
||||
mime: "text/plain",
|
||||
mimes: ["text/plain"],
|
||||
extensions: [".txt"]
|
||||
},
|
||||
svg: {
|
||||
mime: "image/svg+xml",
|
||||
mimes: ["image/svg+xml"],
|
||||
extensions: [".svg"]
|
||||
}
|
||||
});
|
||||
|
||||
function flattenUploadedFiles(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.flatMap(flattenUploadedFiles);
|
||||
if (value.path) return [value];
|
||||
if (typeof value === "object") return Object.values(value).flatMap(flattenUploadedFiles);
|
||||
return [];
|
||||
}
|
||||
|
||||
function cleanupUploadedFiles(value) {
|
||||
for (const file of flattenUploadedFiles(value)) {
|
||||
if (!file.path) continue;
|
||||
try {
|
||||
fs.rmSync(file.path, { force: true });
|
||||
} catch {
|
||||
// Cleanup is best effort; callers still receive the original upload error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateUploadedFile(file, options = {}) {
|
||||
if (!file?.path) throw new Error("The uploaded file could not be read.");
|
||||
const allowedTypes = normalizeAllowedTypes(options.allowedTypes);
|
||||
const stat = fs.statSync(file.path);
|
||||
const maxBytes = Math.max(1, Number(options.maxBytes) || Number.MAX_SAFE_INTEGER);
|
||||
if (!stat.isFile() || stat.size < 1) throw new Error("The uploaded file is empty.");
|
||||
if (stat.size > maxBytes) throw new Error(`The uploaded file is larger than ${formatBytes(maxBytes)}.`);
|
||||
|
||||
const originalName = safeDownloadFilename(file.originalname, "upload");
|
||||
const extension = path.extname(originalName).toLowerCase();
|
||||
const claimedMime = String(file.mimetype || "").toLowerCase();
|
||||
const buffer = allowedTypes.some((type) => type === "text" || type === "svg")
|
||||
? fs.readFileSync(file.path)
|
||||
: readPrefix(file.path, 16);
|
||||
const detectedType = allowedTypes.find((type) => matchesType(buffer, type));
|
||||
if (!detectedType) {
|
||||
throw new Error(`The file contents do not match an allowed ${options.label || "file"} type.`);
|
||||
}
|
||||
|
||||
const definition = FILE_TYPES[detectedType];
|
||||
if (claimedMime && !definition.mimes.includes(claimedMime)) {
|
||||
throw new Error("The file contents do not match the type reported by the browser.");
|
||||
}
|
||||
if (options.requireExtension && !extension) {
|
||||
throw new Error(`The uploaded file must use ${formatExtensions(allowedTypes)}.`);
|
||||
}
|
||||
if (extension && !definition.extensions.includes(extension)) {
|
||||
throw new Error("The filename extension does not match the file contents.");
|
||||
}
|
||||
|
||||
file.originalname = originalName;
|
||||
file.mimetype = definition.mime;
|
||||
file.size = stat.size;
|
||||
file.detectedType = detectedType;
|
||||
return file;
|
||||
}
|
||||
|
||||
function validateUploadedFiles(value, options = {}) {
|
||||
const files = flattenUploadedFiles(value);
|
||||
try {
|
||||
files.forEach((file) => validateUploadedFile(file, options));
|
||||
return files;
|
||||
} catch (error) {
|
||||
cleanupUploadedFiles(files);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function safeDownloadFilename(value, fallback = "attachment") {
|
||||
const lastSegment = String(value || "")
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/["';]/g, "")
|
||||
.trim();
|
||||
const cleaned = lastSegment.replace(/^\.+/, "").slice(0, 180);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function normalizeAllowedTypes(value) {
|
||||
const types = Array.isArray(value) ? value : [];
|
||||
if (!types.length || types.some((type) => !FILE_TYPES[type])) {
|
||||
throw new Error("Upload validation requires known allowed file types.");
|
||||
}
|
||||
return [...new Set(types)];
|
||||
}
|
||||
|
||||
function matchesType(buffer, type) {
|
||||
if (type === "png") return startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (type === "jpeg") return startsWith(buffer, [0xff, 0xd8, 0xff]);
|
||||
if (type === "webp") return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
|
||||
if (type === "pdf") return buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||
if (type === "zip") {
|
||||
return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]) || startsWith(buffer, [0x50, 0x4b, 0x05, 0x06]) || startsWith(buffer, [0x50, 0x4b, 0x07, 0x08]);
|
||||
}
|
||||
if (type === "text") return isPlainText(buffer);
|
||||
if (type === "svg") return isSafeSvg(buffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPlainText(buffer) {
|
||||
if (buffer.includes(0)) return false;
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeSvg(buffer) {
|
||||
if (!isPlainText(buffer)) return false;
|
||||
const source = buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
|
||||
if (!/^<\?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 startsWith(buffer, bytes) {
|
||||
return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
|
||||
}
|
||||
|
||||
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 formatBytes(bytes) {
|
||||
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function formatExtensions(types) {
|
||||
return types.flatMap((type) => FILE_TYPES[type].extensions).join(" or ");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FILE_TYPES,
|
||||
cleanupUploadedFiles,
|
||||
flattenUploadedFiles,
|
||||
safeDownloadFilename,
|
||||
validateUploadedFile,
|
||||
validateUploadedFiles
|
||||
};
|
||||
72
src/services/web-auth.js
Normal file
72
src/services/web-auth.js
Normal file
@ -0,0 +1,72 @@
|
||||
const { getPlatformStatus } = require("./platforms");
|
||||
|
||||
function createWebAuth(options = {}) {
|
||||
const platformStatus = options.getPlatformStatus || getPlatformStatus;
|
||||
|
||||
function isConfigured() {
|
||||
const platforms = platformStatus().filter((platform) => platform.supported && platform.enabled);
|
||||
return platforms.length > 0 && platforms.some((platform) => platform.configured);
|
||||
}
|
||||
|
||||
function isLocalhostLoginAvailable(req) {
|
||||
if (!req) return false;
|
||||
const host = normalizeHostName(req.hostname || req.get?.("host"));
|
||||
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
|
||||
}
|
||||
|
||||
function getLocalhostLoginPlatform(req) {
|
||||
if (!isLocalhostLoginAvailable(req)) return null;
|
||||
return {
|
||||
id: "localhost",
|
||||
label: "Localhost Login",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
supported: true,
|
||||
supportsLogin: true,
|
||||
loginPath: "/auth/localhost"
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryLoginPlatform(req) {
|
||||
const localhost = getLocalhostLoginPlatform(req);
|
||||
if (localhost) return localhost;
|
||||
const platforms = platformStatus().filter((platform) => platform.supported && platform.enabled && platform.supportsLogin);
|
||||
if (!platforms.length) return null;
|
||||
return platforms.find((platform) => platform.configured) || platforms[0];
|
||||
}
|
||||
|
||||
function getLoginRedirectPath(req) {
|
||||
return getPrimaryLoginPlatform(req)?.loginPath || "/setup";
|
||||
}
|
||||
|
||||
function requireConfigured(req, res, next) {
|
||||
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
|
||||
return res.redirect("/setup");
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session?.user) return res.redirect(getLoginRedirectPath(req));
|
||||
return next();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
isConfigured,
|
||||
isLocalhostLoginAvailable,
|
||||
getLocalhostLoginPlatform,
|
||||
getPrimaryLoginPlatform,
|
||||
getLoginRedirectPath,
|
||||
requireConfigured,
|
||||
requireAuth
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHostName(value) {
|
||||
const raw = String(value || "").toLowerCase();
|
||||
if (raw.startsWith("[")) return raw.slice(1, raw.indexOf("]"));
|
||||
if (raw === "::1" || raw === "::ffff:127.0.0.1") return raw;
|
||||
return raw.replace(/:\d+$/, "");
|
||||
}
|
||||
|
||||
module.exports = { createWebAuth, normalizeHostName };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user