@songsid/agend 2.2.0-beta.9 → 2.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -24
- package/README.zh-TW.md +40 -24
- package/dist/agent-cli.js +248 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +28 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +30 -0
- package/dist/agent-file-read.js +129 -0
- package/dist/agent-file-read.js.map +1 -0
- package/dist/agent-whats-new.js +20 -1
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/atomic-write.js +9 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.d.ts +3 -1
- package/dist/auth-api.js +32 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +33 -0
- package/dist/backend/antigravity.js +129 -21
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +3 -0
- package/dist/backend/claude-code.js +9 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +33 -0
- package/dist/backend/codex-metadata.js +122 -18
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -0
- package/dist/backend/codex.js +17 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +37 -1
- package/dist/backend/effort-metadata.js +86 -15
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.d.ts +3 -0
- package/dist/backend/grok.js +9 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +3 -1
- package/dist/backend/kiro.js +7 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.d.ts +61 -0
- package/dist/backend/launch-effort.js +170 -0
- package/dist/backend/launch-effort.js.map +1 -0
- package/dist/backend/muse.js +10 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +24 -0
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-command.d.ts +91 -0
- package/dist/backend-command.js +122 -0
- package/dist/backend-command.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +14 -0
- package/dist/channel/adapters/discord.js +66 -4
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +26 -1
- package/dist/channel/adapters/telegram.js +98 -7
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +36 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.d.ts +2 -0
- package/dist/channel/tool-router.js +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +31 -0
- package/dist/cli.js +124 -24
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +5 -2
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.d.ts +86 -0
- package/dist/compact-watch.js +288 -0
- package/dist/compact-watch.js.map +1 -0
- package/dist/config-validator.js +55 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.js +394 -29
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +8 -1
- package/dist/delivery-outbox.js +6 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.js +7 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +12 -0
- package/dist/fleet-manager.d.ts +247 -9
- package/dist/fleet-manager.js +1227 -142
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +4 -3
- package/dist/forged-envelope-scan.js +4 -3
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +258 -0
- package/dist/install-lock.d.ts +18 -1
- package/dist/install-lock.js +30 -3
- package/dist/install-lock.js.map +1 -1
- package/dist/instance-avatar.d.ts +5 -4
- package/dist/instance-avatar.js +15 -8
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +3 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.js +9 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +2 -2
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.d.ts +38 -0
- package/dist/kiro-compact-pane.js +57 -0
- package/dist/kiro-compact-pane.js.map +1 -0
- package/dist/launchd-handoff.d.ts +43 -0
- package/dist/launchd-handoff.js +56 -0
- package/dist/launchd-handoff.js.map +1 -0
- package/dist/locale.js +202 -30
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +10 -2
- package/dist/log-service.js.map +1 -1
- package/dist/login-shell.d.ts +21 -0
- package/dist/login-shell.js +98 -0
- package/dist/login-shell.js.map +1 -0
- package/dist/needs-you-hub.d.ts +4 -0
- package/dist/needs-you-hub.js +31 -6
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +1 -13
- package/dist/needs-you.js +3 -37
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +32 -0
- package/dist/outbound-handlers.js +150 -45
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +76 -0
- package/dist/outbound-schemas.js +65 -4
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +29 -0
- package/dist/provider-probe.js +198 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +25 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +8 -6
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +12 -0
- package/dist/quickstart.js +36 -6
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +86 -5
- package/dist/reply-buttons-controller.js +271 -7
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-buttons.d.ts +48 -3
- package/dist/reply-buttons.js +105 -10
- package/dist/reply-buttons.js.map +1 -1
- package/dist/reply-polls.d.ts +85 -0
- package/dist/reply-polls.js +154 -0
- package/dist/reply-polls.js.map +1 -0
- package/dist/repo-worktree-guard.d.ts +36 -0
- package/dist/repo-worktree-guard.js +143 -0
- package/dist/repo-worktree-guard.js.map +1 -0
- package/dist/restart-guard.d.ts +6 -3
- package/dist/restart-guard.js +6 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +0 -2
- package/dist/restart-progress.js +0 -2
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +58 -1
- package/dist/scheduler/scheduler.js +176 -10
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/sd-notify.d.ts +22 -1
- package/dist/sd-notify.js +63 -10
- package/dist/sd-notify.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +42 -26
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +24 -0
- package/dist/service-installer.js +49 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +11 -0
- package/dist/service-plan.js +23 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/settings-api.js +74 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +8 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +16 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +5 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/systemd-peer-bus.d.ts +95 -0
- package/dist/systemd-peer-bus.js +430 -0
- package/dist/systemd-peer-bus.js.map +1 -0
- package/dist/systemd-runtime.d.ts +27 -1
- package/dist/systemd-runtime.js +69 -7
- package/dist/systemd-runtime.js.map +1 -1
- package/dist/tool-permissions.js +53 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +72 -2
- package/dist/topic-commands.js +156 -38
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.d.ts +128 -0
- package/dist/topic-link.js +269 -0
- package/dist/topic-link.js.map +1 -0
- package/dist/transcript-jsonl.d.ts +7 -2
- package/dist/transcript-jsonl.js +13 -7
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-sources.js +6 -1
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/ui/app-effects.js +187 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-effect-posters.js +42 -0
- package/dist/ui/chat-effects.js +72 -0
- package/dist/ui/chat-live-replies.js +58 -0
- package/dist/ui/chat-render.js +59 -14
- package/dist/ui/chat-store.js +78 -8
- package/dist/ui/chat-strings.js +43 -16
- package/dist/ui/chat-thread.js +175 -31
- package/dist/ui/chat-tour.js +18 -15
- package/dist/ui/effect-trial-controller.js +119 -0
- package/dist/ui/effect-trial-offer.js +31 -0
- package/dist/ui/effect-trial-panel.js +37 -0
- package/dist/ui/image-lightbox.js +1 -1
- package/dist/ui/panel-chat.js +101 -27
- package/dist/ui/panel-details.js +18 -5
- package/dist/ui/panel-fleet.js +18 -5
- package/dist/ui/panel-log.js +10 -10
- package/dist/ui/panel-needs.js +13 -10
- package/dist/ui/panel-settings.js +66 -12
- package/dist/ui/preview-panel.js +25 -9
- package/dist/ui/settings-appearance.js +325 -0
- package/dist/ui/settings-dialogs.js +11 -6
- package/dist/ui/settings-effects.js +201 -0
- package/dist/ui/settings-model.js +3 -1
- package/dist/ui/settings-mods.js +257 -0
- package/dist/ui/settings-owner-effects.js +80 -0
- package/dist/ui/settings-strings.js +269 -10
- package/dist/ui/settings-token.js +7 -0
- package/dist/ui/settings-wizard.js +5 -3
- package/dist/ui/shared/agend-auth.js +27 -3
- package/dist/ui/shared/agent-widget.js +248 -0
- package/dist/ui/shared/app-i18n.js +3 -1
- package/dist/ui/shared/app-prefs.js +30 -10
- package/dist/ui/shared/app-route.js +23 -1
- package/dist/ui/shared/app-session.js +1 -1
- package/dist/ui/shared/app-shell.js +29 -16
- package/dist/ui/shared/app-store.js +2 -0
- package/dist/ui/shared/app-stream.js +15 -7
- package/dist/ui/shared/app-themes.js +188 -0
- package/dist/ui/shared/app.css +266 -11
- package/dist/ui/shared/app.js +6 -0
- package/dist/ui/shared/card-strings.js +28 -0
- package/dist/ui/shared/effect-app-runtime.js +248 -0
- package/dist/ui/shared/effect-baseline.js +64 -0
- package/dist/ui/shared/effect-chat-runtime.js +154 -0
- package/dist/ui/shared/effect-cursor.js +125 -0
- package/dist/ui/shared/effect-frame.js +127 -0
- package/dist/ui/shared/effect-geometry.js +122 -0
- package/dist/ui/shared/effect-host-style.js +10 -0
- package/dist/ui/shared/effect-owner-regions.js +74 -0
- package/dist/ui/shared/effect-owner-source.js +115 -0
- package/dist/ui/shared/effect-policy.js +137 -0
- package/dist/ui/shared/effect-poster-reader.js +98 -0
- package/dist/ui/shared/effect-posters.js +117 -0
- package/dist/ui/shared/effect-profiler.js +162 -0
- package/dist/ui/shared/effect-profiles.js +65 -0
- package/dist/ui/shared/effect-registry.js +496 -0
- package/dist/ui/shared/effect-runtime-loader.js +234 -0
- package/dist/ui/shared/effect-safety.js +153 -0
- package/dist/ui/shared/effect-stock-snapshot.js +34 -0
- package/dist/ui/shared/effect-stock-trust.js +165 -0
- package/dist/ui/shared/effect-trial-source.js +103 -0
- package/dist/ui/shared/effect-trial-stage.js +147 -0
- package/dist/ui/shared/header-tools.js +35 -15
- package/dist/ui/shared/identity-card.js +202 -0
- package/dist/ui/shared/identity-color.js +261 -0
- package/dist/ui/shared/instance-nav.js +22 -14
- package/dist/ui/shared/instance-switch.js +5 -2
- package/dist/ui/shared/owner-css.js +221 -0
- package/dist/ui/shared/owner-effect-prefs.js +117 -0
- package/dist/ui/shared/panel-view.js +96 -12
- package/dist/ui/shared/signin.js +17 -9
- package/dist/ui/shared/ui-dialog.js +1 -1
- package/dist/ui/shared/ui-icons.js +3 -0
- package/dist/ui/shared/ui-menu.js +1 -1
- package/dist/ui/shared/ui-toast.js +1 -1
- package/dist/ui/shared/view-strings.js +40 -2
- package/dist/ui/signin.html +9 -3
- package/dist/update-check.d.ts +31 -0
- package/dist/update-check.js +47 -0
- package/dist/update-check.js.map +1 -1
- package/dist/update-launch.d.ts +48 -1
- package/dist/update-launch.js +159 -5
- package/dist/update-launch.js.map +1 -1
- package/dist/usage/providers.d.ts +28 -8
- package/dist/usage/providers.js +96 -41
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.d.ts +29 -0
- package/dist/view-api.js +214 -17
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +86 -2
- package/dist/web-api.js +1160 -92
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-limits.d.ts +82 -0
- package/dist/web-appearance-limits.js +83 -0
- package/dist/web-appearance-limits.js.map +1 -0
- package/dist/web-appearance-policy.d.ts +66 -0
- package/dist/web-appearance-policy.js +94 -0
- package/dist/web-appearance-policy.js.map +1 -0
- package/dist/web-appearance-tools.d.ts +50 -0
- package/dist/web-appearance-tools.js +105 -0
- package/dist/web-appearance-tools.js.map +1 -0
- package/dist/web-appearance-view.d.ts +99 -0
- package/dist/web-appearance-view.js +163 -0
- package/dist/web-appearance-view.js.map +1 -0
- package/dist/web-appearance.d.ts +79 -0
- package/dist/web-appearance.js +298 -0
- package/dist/web-appearance.js.map +1 -0
- package/dist/web-asset-parts.d.ts +98 -0
- package/dist/web-asset-parts.js +133 -0
- package/dist/web-asset-parts.js.map +1 -0
- package/dist/web-auth.js +17 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +96 -0
- package/dist/web-card.js +232 -0
- package/dist/web-card.js.map +1 -0
- package/dist/web-chat-history.d.ts +39 -1
- package/dist/web-chat-history.js +69 -9
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +39 -3
- package/dist/web-commands.js +22 -5
- package/dist/web-commands.js.map +1 -1
- package/dist/web-effect-bundle.d.ts +28 -0
- package/dist/web-effect-bundle.js +126 -0
- package/dist/web-effect-bundle.js.map +1 -0
- package/dist/web-effect-canonical.d.ts +8 -0
- package/dist/web-effect-canonical.js +27 -0
- package/dist/web-effect-canonical.js.map +1 -0
- package/dist/web-effect-library.d.ts +114 -0
- package/dist/web-effect-library.js +530 -0
- package/dist/web-effect-library.js.map +1 -0
- package/dist/web-effect-poster.d.ts +8 -0
- package/dist/web-effect-poster.js +52 -0
- package/dist/web-effect-poster.js.map +1 -0
- package/dist/web-effect-profiles.d.ts +118 -0
- package/dist/web-effect-profiles.js +180 -0
- package/dist/web-effect-profiles.js.map +1 -0
- package/dist/web-effect-service.d.ts +86 -0
- package/dist/web-effect-service.js +312 -0
- package/dist/web-effect-service.js.map +1 -0
- package/dist/web-effect-tools.d.ts +47 -0
- package/dist/web-effect-tools.js +126 -0
- package/dist/web-effect-tools.js.map +1 -0
- package/dist/web-effect-trials.d.ts +86 -0
- package/dist/web-effect-trials.js +204 -0
- package/dist/web-effect-trials.js.map +1 -0
- package/dist/web-effect-upload.d.ts +29 -0
- package/dist/web-effect-upload.js +190 -0
- package/dist/web-effect-upload.js.map +1 -0
- package/dist/web-effect-worker.d.ts +8 -0
- package/dist/web-effect-worker.js +170 -0
- package/dist/web-effect-worker.js.map +1 -0
- package/dist/web-effect.d.ts +89 -0
- package/dist/web-effect.js +132 -0
- package/dist/web-effect.js.map +1 -0
- package/dist/web-identity-color.d.ts +87 -0
- package/dist/web-identity-color.js +209 -0
- package/dist/web-identity-color.js.map +1 -0
- package/dist/web-identity-palette.d.ts +113 -0
- package/dist/web-identity-palette.js +84 -0
- package/dist/web-identity-palette.js.map +1 -0
- package/dist/web-image-meta.d.ts +25 -0
- package/dist/web-image-meta.js +200 -0
- package/dist/web-image-meta.js.map +1 -0
- package/dist/web-login.d.ts +14 -0
- package/dist/web-login.js +4 -2
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +50 -0
- package/dist/web-mod-agent.js +203 -0
- package/dist/web-mod-agent.js.map +1 -0
- package/dist/web-mod-bundle.d.ts +144 -0
- package/dist/web-mod-bundle.js +464 -0
- package/dist/web-mod-bundle.js.map +1 -0
- package/dist/web-mod-export.d.ts +59 -0
- package/dist/web-mod-export.js +79 -0
- package/dist/web-mod-export.js.map +1 -0
- package/dist/web-mod-store.d.ts +283 -0
- package/dist/web-mod-store.js +624 -0
- package/dist/web-mod-store.js.map +1 -0
- package/dist/web-org.d.ts +3 -3
- package/dist/web-org.js +11 -17
- package/dist/web-org.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +180 -0
- package/dist/web-owner-css-store.js +384 -0
- package/dist/web-owner-css-store.js.map +1 -0
- package/dist/web-owner-css.d.ts +58 -0
- package/dist/web-owner-css.js +552 -0
- package/dist/web-owner-css.js.map +1 -0
- package/dist/web-prefs.d.ts +66 -0
- package/dist/web-prefs.js +243 -35
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +9 -1
- package/dist/web-preview.js +24 -7
- package/dist/web-preview.js.map +1 -1
- package/dist/web-profile.d.ts +32 -0
- package/dist/web-profile.js +132 -0
- package/dist/web-profile.js.map +1 -0
- package/dist/web-session.d.ts +14 -0
- package/dist/web-session.js +10 -0
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +18 -0
- package/dist/web-settings-patch.js +51 -0
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-theme-store.d.ts +218 -0
- package/dist/web-theme-store.js +516 -0
- package/dist/web-theme-store.js.map +1 -0
- package/dist/web-theme.d.ts +121 -0
- package/dist/web-theme.js +407 -0
- package/dist/web-theme.js.map +1 -0
- package/dist/web-upload.d.ts +119 -4
- package/dist/web-upload.js +252 -39
- package/dist/web-upload.js.map +1 -1
- package/dist/web-widget.d.ts +72 -0
- package/dist/web-widget.js +245 -0
- package/dist/web-widget.js.map +1 -0
- package/launcher/install-admission.cjs +61 -6
- package/package.json +3 -3
package/dist/web-api.js
CHANGED
|
@@ -3,6 +3,12 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
|
3
3
|
import { binaryProbe } from "./binary-probe.js";
|
|
4
4
|
import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
|
|
5
5
|
import { readBoundedWebBody } from "./web-body.js";
|
|
6
|
+
import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
|
|
7
|
+
import { buildModBundle } from "./web-mod-export.js";
|
|
8
|
+
import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
|
|
9
|
+
import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
|
|
10
|
+
import { canonicalText } from "./web-theme.js";
|
|
11
|
+
import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
|
|
6
12
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
7
13
|
import { permitWebContinuation } from "./web-continuation.js";
|
|
8
14
|
/**
|
|
@@ -15,7 +21,7 @@ import { isSafeInstanceName } from "./web-shell-routes.js";
|
|
|
15
21
|
import { MAX_COMMAND_ARGS } from "./web-commands.js";
|
|
16
22
|
import { sendPanelHtml } from "./web-host-guard.js";
|
|
17
23
|
import { shellRoute } from "./web-shell-routes.js";
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
24
|
+
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
19
25
|
import { join, dirname } from "node:path";
|
|
20
26
|
import { fileURLToPath } from "node:url";
|
|
21
27
|
import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
|
|
@@ -26,9 +32,10 @@ import { z } from "zod";
|
|
|
26
32
|
import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
27
33
|
import { dashboardOriginFor } from "./web-preview.js";
|
|
28
34
|
import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
|
|
29
|
-
import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment,
|
|
35
|
+
import { attachmentDelivery, createPartFile, displayName, fileTooLargeText, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, UPLOAD_LIMITS, UPLOAD_MAX_MS, UPLOAD_STALL_MS, UploadSniffer, uploadLimits, wellFormed } from "./web-upload.js";
|
|
30
36
|
import { getAgendHome } from "./paths.js";
|
|
31
|
-
import { OPERATOR } from "./web-prefs.js";
|
|
37
|
+
import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
|
|
38
|
+
import { WEB_NAME_FALLBACK, webPersonKey } from "./web-profile.js";
|
|
32
39
|
import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
|
|
33
40
|
import { parseMentionQuery } from "./web-mentions.js";
|
|
34
41
|
import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
|
|
@@ -149,6 +156,732 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
149
156
|
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
150
157
|
return false;
|
|
151
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
|
|
161
|
+
* signed-in human session is, or the status to answer.
|
|
162
|
+
*/
|
|
163
|
+
function handleOwnerCss(req, res, url, ctx, path, method) {
|
|
164
|
+
const store = ctx.ownerCss;
|
|
165
|
+
if (!store) {
|
|
166
|
+
json(res, 404, { error: "Owner CSS is not kept on this fleet" });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const reading = method === "GET" || method === "HEAD";
|
|
170
|
+
if (gatewayRequestContext(req)) {
|
|
171
|
+
json(res, reading ? 404 : 403, { error: "owner_css_not_over_public_link" });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const who = () => {
|
|
175
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
176
|
+
if (verdict.kind !== "allow")
|
|
177
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
178
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
179
|
+
return { status: 403, error: "owner_css_needs_a_signed_in_person" };
|
|
180
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
181
|
+
return { status: 403, error: "owner_css_read_only" };
|
|
182
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
183
|
+
};
|
|
184
|
+
const first = who();
|
|
185
|
+
if ("status" in first) {
|
|
186
|
+
json(res, first.status, { error: first.error });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
const again = () => {
|
|
190
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
191
|
+
return null;
|
|
192
|
+
const now = who();
|
|
193
|
+
if ("status" in now) {
|
|
194
|
+
json(res, now.status, { error: now.error });
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (now.person !== first.person) {
|
|
198
|
+
json(res, 409, { error: "owner_css_person_changed" });
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return now.person;
|
|
202
|
+
};
|
|
203
|
+
const announce = () => ctx.emitSseEvent("status", ctx.getUiStatus()); // their other devices see the new prefs rev
|
|
204
|
+
const fail = (err) => {
|
|
205
|
+
ctx.logger.error({ err: err.message }, "Owner CSS request failed");
|
|
206
|
+
try {
|
|
207
|
+
json(res, 500, { error: "Owner CSS request failed" });
|
|
208
|
+
}
|
|
209
|
+
catch { /* already answered */ }
|
|
210
|
+
};
|
|
211
|
+
if (path === "/ui/owner-css.css" && method === "GET") {
|
|
212
|
+
const v = url.searchParams.get("v");
|
|
213
|
+
const sha = v ?? store.state(first.person).active;
|
|
214
|
+
const text = sha ? store.served(first.person, sha) : null;
|
|
215
|
+
if (text === null) {
|
|
216
|
+
json(res, 404, { error: "no owner CSS" });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
res.writeHead(200, {
|
|
220
|
+
"Content-Type": "text/css; charset=utf-8", "X-Content-Type-Options": "nosniff",
|
|
221
|
+
// Keyed by its sha (?v): a version's text never changes. Without ?v it is whatever is in force now.
|
|
222
|
+
"Cache-Control": v ? "private, max-age=31536000, immutable" : "private, no-store",
|
|
223
|
+
});
|
|
224
|
+
res.end(text);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
const assetPath = /^\/ui\/owner-css\/asset\/([0-9a-f]{32})$/.exec(path);
|
|
228
|
+
if (assetPath && method === "GET") {
|
|
229
|
+
const asset = store.asset(first.person, assetPath[1]);
|
|
230
|
+
if (!asset) {
|
|
231
|
+
json(res, 404, { error: "no such asset" });
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
res.writeHead(200, {
|
|
235
|
+
"Content-Type": asset.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable",
|
|
236
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
237
|
+
});
|
|
238
|
+
res.end(asset.bytes);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (path === "/ui/owner-css/state" && method === "GET") {
|
|
242
|
+
json(res, 200, store.state(first.person));
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (assetPath && method === "DELETE") {
|
|
246
|
+
const removed = store.removeAsset(first.person, assetPath[1]);
|
|
247
|
+
json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
|
|
251
|
+
json(res, 404, { error: "not found" });
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
(async () => {
|
|
255
|
+
if (path === "/ui/owner-css/asset") {
|
|
256
|
+
let bytes;
|
|
257
|
+
try {
|
|
258
|
+
bytes = await readBoundedWebBody(req, 1024 * 1024);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
json(res, 413, { error: "asset_quota", detail: "at most 1 MiB" });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const person = again();
|
|
265
|
+
if (!person)
|
|
266
|
+
return;
|
|
267
|
+
const added = store.addAsset(person, bytes);
|
|
268
|
+
if (!added.ok) {
|
|
269
|
+
json(res, added.status, { error: added.error, ...(added.detail ? { detail: added.detail } : {}) });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
json(res, 200, { id: added.id, kind: added.kind, bytes: added.bytes });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
let body;
|
|
276
|
+
try {
|
|
277
|
+
body = await parseBody(req);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const person = again();
|
|
284
|
+
if (!person)
|
|
285
|
+
return;
|
|
286
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
287
|
+
json(res, 400, { error: "an object" });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (path === "/ui/owner-css") {
|
|
291
|
+
const saved = store.save(person, body.text);
|
|
292
|
+
if (!saved.ok) {
|
|
293
|
+
json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
announce();
|
|
297
|
+
json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
|
|
298
|
+
}
|
|
299
|
+
else if (path === "/ui/owner-css/keep") {
|
|
300
|
+
const kept = store.keep(person, body.trial);
|
|
301
|
+
if (!kept.ok) {
|
|
302
|
+
json(res, kept.status, { error: kept.error });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
announce(); // the person's other devices drop their bar
|
|
306
|
+
json(res, 200, { active: kept.active });
|
|
307
|
+
}
|
|
308
|
+
else if (path === "/ui/owner-css/revert") {
|
|
309
|
+
// Exactly this trial, while it still is one: a trial kept (or ended) on another device meanwhile stays (409).
|
|
310
|
+
const reverted = store.revertTrial(person, body.trial);
|
|
311
|
+
if (!reverted.ok) {
|
|
312
|
+
json(res, reverted.status, { error: reverted.error });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
announce();
|
|
316
|
+
json(res, 200, { active: reverted.active });
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const restored = store.restore(person, body.sha);
|
|
320
|
+
if (!restored.ok) {
|
|
321
|
+
json(res, restored.status, { error: restored.error });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
announce();
|
|
325
|
+
json(res, 200, { active: restored.active });
|
|
326
|
+
}
|
|
327
|
+
})().catch(fail);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
/** #1629: static current-placement posters, separate from the local human-only runtime API. */
|
|
331
|
+
function handleEffectPosters(res, url, ctx, path, method) {
|
|
332
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
333
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
334
|
+
const image = /^\/ui\/effect-posters\/([0-9a-f]{32})\.png$/.exec(path);
|
|
335
|
+
const names = [...url.searchParams.keys()], instance = url.searchParams.get("instance");
|
|
336
|
+
if (method !== "GET" || path !== "/ui/effect-posters" && !image || names.length !== 1 || names[0] !== "instance" || !instance || instance.length > 128) {
|
|
337
|
+
json(res, 404, { error: "not_found" });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
const service = ctx.effectService;
|
|
341
|
+
if (!service) {
|
|
342
|
+
json(res, 404, { error: "effects_unavailable" });
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
if (image) {
|
|
346
|
+
const r = service.placedPoster(image[1], instance);
|
|
347
|
+
if (!r.ok)
|
|
348
|
+
json(res, 404, { error: "not_found" });
|
|
349
|
+
else {
|
|
350
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
351
|
+
res.end(r.bytes);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const result = service.posterPlacements(instance);
|
|
356
|
+
json(res, result.error === "themes_unreadable" ? 503 : result.error ? 404 : 200, result);
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
/** #1629 Try: separate local-admin routes. These never borrow ordinary read-tier/placement authority. */
|
|
361
|
+
function handleEffectTrials(req, res, url, ctx, path, method) {
|
|
362
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
363
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
364
|
+
const reading = method === "GET";
|
|
365
|
+
if (gatewayRequestContext(req)) {
|
|
366
|
+
json(res, reading ? 404 : 403, { error: "trials_not_over_public_link" });
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
const who = () => {
|
|
370
|
+
const sessions = ctx.webSessions;
|
|
371
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, sessions, { touch: false });
|
|
372
|
+
if (v.kind !== "allow")
|
|
373
|
+
return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
|
|
374
|
+
if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
|
|
375
|
+
return { error: "trials_need_a_local_admin", status: 403 };
|
|
376
|
+
const person = webPersonKey(v.session.owner);
|
|
377
|
+
let css = "unknown";
|
|
378
|
+
try {
|
|
379
|
+
const value = ctx.ownerCss?.effectState(person);
|
|
380
|
+
if (value === "off" || value === "on")
|
|
381
|
+
css = value;
|
|
382
|
+
}
|
|
383
|
+
catch { /* unavailable is never off */ }
|
|
384
|
+
// CSS remains a checked runtime value. The authority refuses anything except off on start/read.
|
|
385
|
+
const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
|
|
386
|
+
return { actor, record: v.session, sessions };
|
|
387
|
+
};
|
|
388
|
+
const first = who();
|
|
389
|
+
if ("error" in first) {
|
|
390
|
+
json(res, first.status, { error: first.error });
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
const service = ctx.effectService;
|
|
394
|
+
if (!service) {
|
|
395
|
+
json(res, 404, { error: "trials_unavailable" });
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
const start = path === "/ui/effect-trials/start";
|
|
399
|
+
const route = /^\/ui\/effect-trials\/([0-9a-f]{32})\/(resource|poster\.png|profile|stop)$/.exec(path);
|
|
400
|
+
const resource = reading && route && ["resource", "poster.png", "profile"].includes(route[2]);
|
|
401
|
+
const stop = method === "POST" && route?.[2] === "stop";
|
|
402
|
+
if (!(resource || stop || method === "POST" && start)) {
|
|
403
|
+
json(res, 404, { error: "not_found" });
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
const allowed = resource ? ["ownerEpoch", "instance"] : ["ownerEpoch"];
|
|
407
|
+
if ([...url.searchParams.keys()].some(k => !allowed.includes(k)) || allowed.some(k => url.searchParams.getAll(k).length !== 1)
|
|
408
|
+
|| url.searchParams.get("ownerEpoch") !== first.actor.epoch) {
|
|
409
|
+
json(res, reading ? 404 : 409, { error: reading ? "not_found" : "trials_person_changed" });
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
const instance = url.searchParams.get("instance") ?? "";
|
|
413
|
+
const answer = (result) => json(res, result.error ? result.error === "not_found" ? 404
|
|
414
|
+
: ["profiles_unreadable", "profiles_busy", "profiles_write_failed"].includes(String(result.error)) ? 503 : 400 : 200, result);
|
|
415
|
+
if (resource) {
|
|
416
|
+
if (route[2] === "poster.png") {
|
|
417
|
+
const result = service.trialPoster(route[1], instance, first.actor);
|
|
418
|
+
if (!result.ok)
|
|
419
|
+
json(res, 404, { error: "not_found" });
|
|
420
|
+
else {
|
|
421
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
422
|
+
res.end(result.bytes);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else
|
|
426
|
+
answer(route[2] === "profile" ? service.trialProfile(route[1], instance, first.actor) : service.trialResource(route[1], instance, first.actor));
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
(async () => {
|
|
430
|
+
let bytes;
|
|
431
|
+
try {
|
|
432
|
+
bytes = await readBoundedWebBody(req, 16 * 1024);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
json(res, 413, { error: "trial_body_bound" });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
439
|
+
return;
|
|
440
|
+
const now = who();
|
|
441
|
+
if ("error" in now) {
|
|
442
|
+
json(res, now.status, { error: now.error });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
|
|
446
|
+
|| now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
|
|
447
|
+
json(res, 409, { error: "trials_person_changed" });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
let body;
|
|
451
|
+
try {
|
|
452
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
json(res, 400, { error: "trial_json" });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (start) {
|
|
459
|
+
const parsed = z.strictObject({ offer: z.string().regex(/^[0-9a-f]{32}$/), instance: z.string().min(1).max(128) }).safeParse(body);
|
|
460
|
+
if (!parsed.success) {
|
|
461
|
+
json(res, 400, { error: "trial_args" });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
answer(service.trialStart(parsed.data.offer, parsed.data.instance, now.actor));
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
if (!z.strictObject({}).safeParse(body).success) {
|
|
468
|
+
json(res, 400, { error: "trial_args" });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
answer(service.trialStop(route[1], now.actor));
|
|
472
|
+
}
|
|
473
|
+
})().catch(() => { try {
|
|
474
|
+
json(res, 500, { error: "trial_request_failed" });
|
|
475
|
+
}
|
|
476
|
+
catch { /* response ended */ } });
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
/** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
|
|
480
|
+
function handleEffects(req, res, url, ctx, path, method) {
|
|
481
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
482
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
483
|
+
const reading = method === "GET";
|
|
484
|
+
// Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
|
|
485
|
+
const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
|
|
486
|
+
if (gatewayRequestContext(req)) {
|
|
487
|
+
json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
const who = () => {
|
|
491
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
492
|
+
if (verdict.kind !== "allow")
|
|
493
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
494
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
495
|
+
return { status: 403, error: "effects_need_a_signed_in_person" };
|
|
496
|
+
if (!reading && !profilePost && verdict.session.tier !== "admin")
|
|
497
|
+
return { status: 403, error: "effects_read_only" };
|
|
498
|
+
return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle };
|
|
499
|
+
};
|
|
500
|
+
const first = who();
|
|
501
|
+
if ("status" in first) {
|
|
502
|
+
json(res, first.status, { error: first.error });
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
const service = ctx.effectService;
|
|
506
|
+
if (!service) {
|
|
507
|
+
json(res, 404, { error: "effects_unavailable" });
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
const unavailable = new Set(["themes_unreadable", "profiles_unreadable", "library_unreadable", "references_unreadable", "library_busy", "profiles_busy", "profiles_write_failed", "library_write_failed", "prefs_unreadable"]);
|
|
511
|
+
const answer = (result) => json(res, typeof result.error === "string" ? result.error === "not_found" ? 404 : result.error === "profile_rate_limited" ? 429 : unavailable.has(result.error) ? 503 : 400 : 200, result);
|
|
512
|
+
const idPath = /^\/ui\/effects\/(resource|profile)\/([0-9a-f]{32})$/.exec(path);
|
|
513
|
+
const instance = url.searchParams.get("instance") ?? undefined, slot = url.searchParams.get("slot");
|
|
514
|
+
if (slot !== null && instance !== undefined) {
|
|
515
|
+
json(res, 404, { error: "not_found" });
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
const runtimeRead = reading && (slot !== null || path === "/ui/effects/owner" || path === "/ui/effects/placements" || idPath?.[1] === "resource" || instance !== undefined && /^\/ui\/effects\/image\//.test(path));
|
|
519
|
+
if ((runtimeRead || profilePost) && (url.searchParams.getAll("ownerEpoch").length !== 1 || url.searchParams.get("ownerEpoch") !== first.epoch)) {
|
|
520
|
+
const privateResource = reading && (slot !== null || idPath?.[1] === "resource" || /^\/ui\/effects\/image\//.test(path));
|
|
521
|
+
json(res, privateResource ? 404 : 409, { error: privateResource ? "not_found" : "effects_person_changed" });
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
if (reading) {
|
|
525
|
+
const image = /^\/ui\/effects\/image\/([0-9a-f]{32})\/(poster|cursor)\.png$/.exec(path);
|
|
526
|
+
if (image) {
|
|
527
|
+
const r = slot === null ? service.image(first.person, image[1], image[2], instance) : service.ownerImage(first.person, image[1], image[2], slot);
|
|
528
|
+
if (!r.ok)
|
|
529
|
+
json(res, 404, { error: "not_found" });
|
|
530
|
+
else {
|
|
531
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
532
|
+
res.end(r.bytes);
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
if (path === "/ui/effects")
|
|
537
|
+
answer({ ...service.list(first.person), canWrite: first.canWrite });
|
|
538
|
+
else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
|
|
539
|
+
answer(service.ownerSelections(first.person));
|
|
540
|
+
else if (path === "/ui/effects/placements" && instance !== undefined)
|
|
541
|
+
answer(service.placements(first.person, instance));
|
|
542
|
+
else if (idPath?.[1] === "resource")
|
|
543
|
+
answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
|
|
544
|
+
else if (idPath?.[1] === "profile" && instance === undefined)
|
|
545
|
+
answer(service.profile(first.person, idPath[2]));
|
|
546
|
+
else
|
|
547
|
+
json(res, 404, { error: "not_found" });
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
const deletion = /^\/ui\/effects\/([0-9a-f]{32})$/.exec(path);
|
|
551
|
+
if (method === "DELETE" && deletion) {
|
|
552
|
+
answer(service.delete(first.person, deletion[1]));
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
if (method !== "POST" || !["/ui/effects/upload", "/ui/effects/apply", "/ui/effects/remove", "/ui/effects/share"].includes(path) && idPath?.[1] !== "profile") {
|
|
556
|
+
json(res, 404, { error: "not_found" });
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
(async () => {
|
|
560
|
+
let bytes;
|
|
561
|
+
try {
|
|
562
|
+
bytes = await readBoundedWebBody(req, path === "/ui/effects/upload" ? EFFECT_WIRE_LIMIT : 16 * 1024);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
json(res, 413, { error: "effect_body_bound" });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
569
|
+
return;
|
|
570
|
+
const now = who();
|
|
571
|
+
if ("status" in now) {
|
|
572
|
+
json(res, now.status, { error: now.error });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (now.person !== first.person || now.epoch !== first.epoch) {
|
|
576
|
+
json(res, 409, { error: "effects_person_changed" });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (path === "/ui/effects/upload") {
|
|
580
|
+
answer(service.upload(now.person, bytes));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
let body;
|
|
584
|
+
try {
|
|
585
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
json(res, 400, { error: "effect_json" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (path === "/ui/effects/apply")
|
|
592
|
+
answer(service.apply(now.person, body));
|
|
593
|
+
else if (path === "/ui/effects/remove")
|
|
594
|
+
answer(service.remove(now.person, body));
|
|
595
|
+
else if (path === "/ui/effects/share")
|
|
596
|
+
answer(service.share(now.person, body));
|
|
597
|
+
else
|
|
598
|
+
answer(service.report(now.person, idPath[2], body, instance));
|
|
599
|
+
})().catch(() => { try {
|
|
600
|
+
json(res, 500, { error: "effect_request_failed" });
|
|
601
|
+
}
|
|
602
|
+
catch { /* response ended */ } });
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* #1629 r4 §4 (r4-5): mods — a person's import (stage, then commit), uninstall and export. Every route is a signed-in
|
|
607
|
+
* session's, writes an admin's: never the CLI's header token (the way an agent could reach it) and never the public link
|
|
608
|
+
* (absent from its manifest, and refused here too). `who()` is asked first and again once a body is in.
|
|
609
|
+
*/
|
|
610
|
+
function handleMods(req, res, url, ctx, path, method) {
|
|
611
|
+
const mods = ctx.mods;
|
|
612
|
+
if (!mods) {
|
|
613
|
+
json(res, 404, { error: "Mods are not kept on this fleet" });
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
const reading = method === "GET";
|
|
617
|
+
if (gatewayRequestContext(req)) {
|
|
618
|
+
json(res, reading ? 404 : 403, { error: "mods_not_over_public_link" });
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
const who = () => {
|
|
622
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
623
|
+
if (verdict.kind !== "allow")
|
|
624
|
+
return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
|
|
625
|
+
if (verdict.via !== "session" || !verdict.session)
|
|
626
|
+
return { status: 403, error: "mods_need_a_signed_in_session" };
|
|
627
|
+
if (!reading && verdict.session.tier !== "admin")
|
|
628
|
+
return { status: 403, error: "mods_need_an_admin_session" };
|
|
629
|
+
return { person: webPersonKey(verdict.session.owner) };
|
|
630
|
+
};
|
|
631
|
+
const first = who();
|
|
632
|
+
if ("status" in first) {
|
|
633
|
+
json(res, first.status, { error: first.error });
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
const again = () => {
|
|
637
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
638
|
+
return null;
|
|
639
|
+
const now = who();
|
|
640
|
+
if ("status" in now) {
|
|
641
|
+
json(res, now.status, { error: now.error });
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (now.person !== first.person) {
|
|
645
|
+
json(res, 409, { error: "mods_person_changed" });
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
return now.person;
|
|
649
|
+
};
|
|
650
|
+
const answer = (r, body) => {
|
|
651
|
+
if (r.ok)
|
|
652
|
+
json(res, 200, body);
|
|
653
|
+
else
|
|
654
|
+
json(res, r.status ?? 500, { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.problems ? { problems: r.problems } : {}) });
|
|
655
|
+
};
|
|
656
|
+
const fail = (err) => {
|
|
657
|
+
ctx.logger.error({ err: err.message }, "Mod request failed");
|
|
658
|
+
try {
|
|
659
|
+
json(res, 500, { error: "Mod request failed" });
|
|
660
|
+
}
|
|
661
|
+
catch { /* already answered */ }
|
|
662
|
+
};
|
|
663
|
+
if (path === "/ui/mods" && method === "GET") {
|
|
664
|
+
json(res, 200, { ...mods.list(), staged: mods.staged(first.person), proposals: mods.proposals() });
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
// A staged mod's picture (this session's person's stage only): the plan shows the actual pictures before install.
|
|
668
|
+
const stagedPic = /^\/ui\/mods\/staged\/([0-9a-f]{32})$/.exec(path);
|
|
669
|
+
if (stagedPic && method === "GET") {
|
|
670
|
+
const pic = mods.stagedPicture(first.person, stagedPic[1]);
|
|
671
|
+
if (!pic) {
|
|
672
|
+
json(res, 404, { error: "No such image" });
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
res.writeHead(200, { "Content-Type": pic.mime, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store", "Content-Security-Policy": "default-src 'none'; sandbox" });
|
|
676
|
+
res.end(Buffer.from(pic.bytes));
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
const fileMatch = /^\/ui\/mods\/file\/([0-9a-f]{32})$/.exec(path);
|
|
680
|
+
if (fileMatch && method === "GET") {
|
|
681
|
+
const text = mods.bundleText(fileMatch[1]);
|
|
682
|
+
const rec = mods.list().mods.find(m => m.id === fileMatch[1]);
|
|
683
|
+
if (text === null || !rec) {
|
|
684
|
+
json(res, 404, { error: "no_such_mod" });
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
// The file is the installed bundle, effects and all — also effects its import left out, which nobody installed.
|
|
688
|
+
// So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
|
|
689
|
+
// read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
|
|
690
|
+
const fx = mods.bundleEffects(fileMatch[1]);
|
|
691
|
+
if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
|
|
692
|
+
json(res, 403, { error: "mod_effects_not_yours", detail: "this mod's file carries effects that are not yours to share: export your own look instead" });
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
|
|
696
|
+
"Content-Disposition": `attachment; filename="${rec.name}-${rec.version}.agendmod.json"` });
|
|
697
|
+
res.end(text);
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
if (method !== "POST" || !["/ui/mods/stage", "/ui/mods/abort", "/ui/mods/commit", "/ui/mods/uninstall", "/ui/mods/export", "/ui/mods/proposal/stage", "/ui/mods/proposal/dismiss"].includes(path)) {
|
|
701
|
+
json(res, 404, { error: "not found" });
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
(async () => {
|
|
705
|
+
if (path === "/ui/mods/stage") {
|
|
706
|
+
// Too large by its own declared length: answered before a byte is read (the body is drained, not cut off).
|
|
707
|
+
if (Number(req.headers["content-length"]) > MOD_MAX_BYTES) {
|
|
708
|
+
req.resume();
|
|
709
|
+
json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
let raw;
|
|
713
|
+
try {
|
|
714
|
+
raw = await readBoundedWebBody(req, MOD_MAX_BYTES);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
json(res, 413, { error: "mod_too_large", detail: `at most ${MOD_MAX_BYTES / 1024 / 1024} MiB` });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const person = again();
|
|
721
|
+
if (!person)
|
|
722
|
+
return;
|
|
723
|
+
let text;
|
|
724
|
+
try {
|
|
725
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
json(res, 400, { error: "mod_invalid", problems: [{ path: "", problem: "not UTF-8" }] });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const r = mods.stage(person, text);
|
|
732
|
+
answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
let body;
|
|
736
|
+
try {
|
|
737
|
+
body = await parseBody(req);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const person = again();
|
|
744
|
+
if (!person)
|
|
745
|
+
return;
|
|
746
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
747
|
+
json(res, 400, { error: "an object" });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (path === "/ui/mods/abort") {
|
|
751
|
+
const r = mods.abort(person, body.stage);
|
|
752
|
+
answer(r, { ok: true });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// r4-5b: an agent's proposal, opened into the usual review (staged as this person's), or dismissed.
|
|
756
|
+
if (path === "/ui/mods/proposal/stage") {
|
|
757
|
+
const r = mods.stageProposal(person, body.instance);
|
|
758
|
+
answer(r, r.ok ? { stage: r.stage, plan: r.plan, plan_hash: r.plan_hash } : null);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (path === "/ui/mods/proposal/dismiss") {
|
|
762
|
+
const r = mods.dismiss(body.instance);
|
|
763
|
+
answer(r, { ok: true });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (path === "/ui/mods/commit") {
|
|
767
|
+
const r = mods.commit(person, body);
|
|
768
|
+
if (r.ok)
|
|
769
|
+
ctx.emitSseEvent("status", ctx.getUiStatus()); // themes_rev and prefs_rev moved: every page re-reads
|
|
770
|
+
answer(r, r.ok ? { id: r.id, applied: r.applied, ...(r.css ? { css: r.css } : {}) } : null);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (path === "/ui/mods/uninstall") {
|
|
774
|
+
const r = mods.uninstall(body.id);
|
|
775
|
+
answer(r, r.ok ? { ok: true, released: r.released } : null);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
// Export: what is in force now, as a bundle file.
|
|
779
|
+
const r = exportMod(ctx, person, body);
|
|
780
|
+
if (!r.ok) {
|
|
781
|
+
json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store",
|
|
785
|
+
"Content-Disposition": `attachment; filename="${r.filename}"`, "X-AgEnD-Effects-Omitted": String(r.omitted) });
|
|
786
|
+
res.end(r.text);
|
|
787
|
+
})().catch(fail);
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
/** A person's export: an instance's look (theme, identity, its widgets), their sidebar widget and their own CSS. */
|
|
791
|
+
export function exportMod(ctx, person, body) {
|
|
792
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
793
|
+
const inc = isObj(body.include) ? body.include : null;
|
|
794
|
+
if (!inc)
|
|
795
|
+
return { ok: false, status: 400, error: "include" };
|
|
796
|
+
const meta = { name: body.name, version: body.version, title: body.title };
|
|
797
|
+
if (typeof meta.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(meta.name))
|
|
798
|
+
return { ok: false, status: 400, error: "name", detail: "a-z, 0-9 and - (1..64)" };
|
|
799
|
+
if (typeof meta.version !== "string" || !/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.test(meta.version))
|
|
800
|
+
return { ok: false, status: 400, error: "version", detail: "MAJOR.MINOR.PATCH" };
|
|
801
|
+
// The importer's own rule (#1897 review): what is exported always imports.
|
|
802
|
+
const title = canonicalText(meta.title, 1, 60);
|
|
803
|
+
if (title === null)
|
|
804
|
+
return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
|
|
805
|
+
const parts = {};
|
|
806
|
+
let omitted = 0;
|
|
807
|
+
const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
|
|
808
|
+
let name = null;
|
|
809
|
+
if (wantsInstance) {
|
|
810
|
+
if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
|
|
811
|
+
return { ok: false, status: 400, error: "instance" };
|
|
812
|
+
name = body.instance;
|
|
813
|
+
}
|
|
814
|
+
if (name && ctx.themeStore) {
|
|
815
|
+
const store = ctx.themeStore;
|
|
816
|
+
const rec = store.get(name);
|
|
817
|
+
if (inc.theme === true) {
|
|
818
|
+
// The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
|
|
819
|
+
// Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
|
|
820
|
+
const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e) {
|
|
821
|
+
omitted++;
|
|
822
|
+
return [];
|
|
823
|
+
} return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
|
|
824
|
+
parts.theme = { theme: rec.theme, picture: id => { const a = rec.assets.find(x => x.id === id); const b = store.asset(name, id); return a && b ? { bytes: b.bytes, kind: a.kind } : null; }, effects };
|
|
825
|
+
}
|
|
826
|
+
if (inc.identity === true)
|
|
827
|
+
parts.identity = ctx.modCardText?.(name) ?? {};
|
|
828
|
+
for (const slot of AGENT_WIDGET_SLOTS) {
|
|
829
|
+
const w = rec.placements.widgets[slot];
|
|
830
|
+
if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
|
|
831
|
+
(parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
|
|
835
|
+
const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
|
|
836
|
+
const spec = v ? checkWidgetSpec(v.spec, "sidebar") : null;
|
|
837
|
+
const data = spec?.ok ? checkWidgetData(v.data, spec.value) : null;
|
|
838
|
+
if (spec?.ok && data?.ok)
|
|
839
|
+
(parts.widgets ??= {}).sidebar = { spec: spec.value, data: { ...data.value } };
|
|
840
|
+
}
|
|
841
|
+
if (inc.ownerCss === true && ctx.ownerCss) {
|
|
842
|
+
const state = ctx.ownerCss.state(person);
|
|
843
|
+
const checked = state.text ? checkOwnerCss(state.text, new Set(state.assets.map(a => a.id))) : null;
|
|
844
|
+
if (state.text && checked?.ok) {
|
|
845
|
+
// Only the assets this text names travel (read from its tokens: url("\\61sset:<id>") names one too).
|
|
846
|
+
const named = ownerCssAssetRefs(checked.tokens);
|
|
847
|
+
const assets = state.assets.filter(a => named.has(a.id)).flatMap(a => { const b = ctx.ownerCss.asset(person, a.id); return b ? [{ id: a.id, bytes: b.bytes, kind: a.kind }] : []; });
|
|
848
|
+
parts.ownerCss = { text: state.text, assets };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const built = buildModBundle({ name: meta.name, version: meta.version, title }, parts);
|
|
852
|
+
return built.ok ? { ...built, omitted } : { ok: false, status: built.error === "nothing_to_export" ? 400 : 409, error: built.error };
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
|
|
856
|
+
* no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
|
|
857
|
+
* session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
|
|
858
|
+
*/
|
|
859
|
+
function webAccountOf(req, url, ctx) {
|
|
860
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
861
|
+
if (verdict.kind !== "allow")
|
|
862
|
+
return null;
|
|
863
|
+
return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
|
|
864
|
+
}
|
|
865
|
+
function webPersonOf(req, url, ctx) {
|
|
866
|
+
// No people kept here: everyone is "web-user", as before, and there is no identity to protect.
|
|
867
|
+
if (!ctx.webPerson)
|
|
868
|
+
return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
|
|
869
|
+
const who = webAccountOf(req, url, ctx);
|
|
870
|
+
return who ? ctx.webPerson(who.owner) : null;
|
|
871
|
+
}
|
|
872
|
+
/** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
|
|
873
|
+
function webPersonOr401(req, res, url, ctx) {
|
|
874
|
+
const p = webPersonOf(req, url, ctx);
|
|
875
|
+
if (!p)
|
|
876
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
877
|
+
return p;
|
|
878
|
+
}
|
|
879
|
+
/** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
|
|
880
|
+
function profileBody(p) {
|
|
881
|
+
return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
|
|
882
|
+
}
|
|
883
|
+
/** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
|
|
884
|
+
function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
|
|
152
885
|
/**
|
|
153
886
|
* Handle a Web UI request. Returns true if handled, false to pass through.
|
|
154
887
|
*/
|
|
@@ -261,6 +994,8 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
261
994
|
// #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
|
|
262
995
|
// records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
|
|
263
996
|
cursor: history ? history.pollCursor() : null,
|
|
997
|
+
// #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
|
|
998
|
+
now: Date.now(),
|
|
264
999
|
});
|
|
265
1000
|
return true;
|
|
266
1001
|
}
|
|
@@ -274,7 +1009,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
274
1009
|
"Cache-Control": "no-cache",
|
|
275
1010
|
Connection: "keep-alive",
|
|
276
1011
|
});
|
|
277
|
-
|
|
1012
|
+
// #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
|
|
1013
|
+
// poll down by (one write: the frames a connect sends are unchanged in number).
|
|
1014
|
+
res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
|
|
278
1015
|
// An EventSource that reconnects says what it last saw: send what it missed, before anything new.
|
|
279
1016
|
const lastSeen = parseLastEventId(req.headers["last-event-id"]);
|
|
280
1017
|
if (lastSeen !== null && ctx.webChatHistory) {
|
|
@@ -310,7 +1047,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
310
1047
|
}
|
|
311
1048
|
try {
|
|
312
1049
|
// Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
|
|
313
|
-
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
|
|
1050
|
+
res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
|
|
314
1051
|
}
|
|
315
1052
|
catch {
|
|
316
1053
|
cleanup();
|
|
@@ -406,12 +1143,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
406
1143
|
serveMediaRange(req, res, ctx.webFiles, id, media);
|
|
407
1144
|
return true;
|
|
408
1145
|
}
|
|
409
|
-
|
|
410
|
-
|
|
1146
|
+
// #1937: a file over MEDIA_RANGE_MAX streams from the descriptor its checks were made on — never whole in memory.
|
|
1147
|
+
const big = isFileId(id) && (ctx.webFiles?.servedFile?.(id)?.size ?? 0) > MEDIA_RANGE_MAX ? ctx.webFiles?.open?.(id) ?? null : null;
|
|
1148
|
+
const got = big ? null : isFileId(id) ? ctx.webFiles?.read(id) : null;
|
|
1149
|
+
const file = big?.file ?? got?.file;
|
|
1150
|
+
if (!file) {
|
|
411
1151
|
json(res, 404, { error: "No such file" });
|
|
412
1152
|
return true;
|
|
413
1153
|
}
|
|
414
|
-
const { file, bytes } = got;
|
|
415
1154
|
const inline = INLINE_MIME.has(file.mime);
|
|
416
1155
|
// Only the four image types are shown in the page; anything else is a download, never rendered.
|
|
417
1156
|
res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
|
|
@@ -419,8 +1158,116 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
419
1158
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
420
1159
|
res.setHeader("Cache-Control", "private, no-store");
|
|
421
1160
|
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
1161
|
+
if (big) {
|
|
1162
|
+
streamWhole(res, big);
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
422
1165
|
res.writeHead(200);
|
|
423
|
-
res.end(bytes);
|
|
1166
|
+
res.end(got.bytes);
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
// ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
|
|
1170
|
+
// GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
|
|
1171
|
+
// GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
|
|
1172
|
+
// POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out): a person's, from an admin session — never the CLI token, never the public
|
|
1173
|
+
// link (absent from its manifest, and refused here too).
|
|
1174
|
+
if (method === "GET" && path === "/ui/themes") {
|
|
1175
|
+
const store = ctx.themeStore;
|
|
1176
|
+
if (!store) {
|
|
1177
|
+
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
|
|
1184
|
+
if (method === "GET" && themeImage) {
|
|
1185
|
+
let name;
|
|
1186
|
+
try {
|
|
1187
|
+
name = decodeURIComponent(themeImage[1]);
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
json(res, 404, { error: "No such image" });
|
|
1191
|
+
return true;
|
|
1192
|
+
}
|
|
1193
|
+
const image = ctx.themeStore?.asset(name, themeImage[2]) ?? null;
|
|
1194
|
+
if (!image) {
|
|
1195
|
+
json(res, 404, { error: "No such image" });
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
res.setHeader("Content-Type", image.mime);
|
|
1199
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1200
|
+
res.setHeader("Cache-Control", "private, max-age=86400");
|
|
1201
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
1202
|
+
res.writeHead(200);
|
|
1203
|
+
res.end(image.bytes);
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
|
|
1207
|
+
if (method === "POST" && themeWrite) {
|
|
1208
|
+
const service = ctx.webAppearance;
|
|
1209
|
+
if (!service) {
|
|
1210
|
+
json(res, 404, { error: "Themes are not kept on this fleet" });
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
if (gatewayRequestContext(req)) {
|
|
1214
|
+
json(res, 403, { error: "theme_not_over_public_link" });
|
|
1215
|
+
return true;
|
|
1216
|
+
}
|
|
1217
|
+
let name;
|
|
1218
|
+
try {
|
|
1219
|
+
name = decodeURIComponent(themeWrite[1]);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
json(res, 400, { error: "instance" });
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
const who = () => {
|
|
1226
|
+
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
1227
|
+
if (verdict.kind !== "allow")
|
|
1228
|
+
return 401;
|
|
1229
|
+
return verdict.via === "session" && verdict.session?.tier === "admin" ? 200 : 403;
|
|
1230
|
+
};
|
|
1231
|
+
const first = who();
|
|
1232
|
+
if (first !== 200) {
|
|
1233
|
+
json(res, first, { error: first === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
(async () => {
|
|
1237
|
+
let body = {};
|
|
1238
|
+
try {
|
|
1239
|
+
body = await parseBody(req);
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1246
|
+
return;
|
|
1247
|
+
const now = who();
|
|
1248
|
+
if (now !== 200) {
|
|
1249
|
+
json(res, now, { error: now === 401 ? WEB_TOKEN_INVALID_MESSAGE : "theme_needs_an_admin_session" });
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (!ctx.isKnownInstance?.(name)) {
|
|
1253
|
+
json(res, 404, { error: "unknown instance" });
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const r = themeWrite[2] === "reset" ? service.humanReset(name)
|
|
1257
|
+
: themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
|
|
1258
|
+
: typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
|
|
1259
|
+
if (typeof r.error === "string") {
|
|
1260
|
+
json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
json(res, 200, r);
|
|
1264
|
+
})().catch(err => {
|
|
1265
|
+
ctx.logger.error({ err: err.message }, "Theme reset/lock failed");
|
|
1266
|
+
try {
|
|
1267
|
+
json(res, 500, { error: "Theme change failed" });
|
|
1268
|
+
}
|
|
1269
|
+
catch { /* already answered */ }
|
|
1270
|
+
});
|
|
424
1271
|
return true;
|
|
425
1272
|
}
|
|
426
1273
|
// ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
|
|
@@ -527,6 +1374,63 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
527
1374
|
});
|
|
528
1375
|
return true;
|
|
529
1376
|
}
|
|
1377
|
+
// #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
|
|
1378
|
+
// link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
|
|
1379
|
+
if (path === "/ui/profile" && method === "GET") {
|
|
1380
|
+
if (!ctx.webPerson) {
|
|
1381
|
+
json(res, 404, { error: "Profiles are not kept on this fleet" });
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
const p = webPersonOr401(req, res, url, ctx);
|
|
1385
|
+
if (p)
|
|
1386
|
+
json(res, 200, profileBody(p));
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
if (path === "/ui/profile" && method === "POST") {
|
|
1390
|
+
if (!ctx.setWebPersonName) {
|
|
1391
|
+
json(res, 404, { error: "Profiles are not kept on this fleet" });
|
|
1392
|
+
return true;
|
|
1393
|
+
}
|
|
1394
|
+
const set = ctx.setWebPersonName.bind(ctx);
|
|
1395
|
+
(async () => {
|
|
1396
|
+
let body;
|
|
1397
|
+
try {
|
|
1398
|
+
body = await parseBody(req);
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (!permitWebContinuation(req, res, ctx))
|
|
1405
|
+
return;
|
|
1406
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
|
|
1407
|
+
json(res, 400, { error: "name required (text, or null to clear)" });
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
// Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
|
|
1411
|
+
// not the operator (#1833 review).
|
|
1412
|
+
const who = webAccountOf(req, url, ctx);
|
|
1413
|
+
if (!who) {
|
|
1414
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
const r = set(who.owner, body.name);
|
|
1418
|
+
if (!r.ok) {
|
|
1419
|
+
json(res, r.status, { error: r.error });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
// The person's other devices read it at their next status (the prefs rev moved).
|
|
1423
|
+
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
1424
|
+
json(res, 200, profileBody(r.person));
|
|
1425
|
+
})().catch(err => {
|
|
1426
|
+
ctx.logger.error({ err: err.message }, "Saving the web name failed");
|
|
1427
|
+
try {
|
|
1428
|
+
json(res, 500, { error: "Saving the name failed" });
|
|
1429
|
+
}
|
|
1430
|
+
catch { /* already answered */ }
|
|
1431
|
+
});
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
530
1434
|
// #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
|
|
531
1435
|
// is a message, and the public link may send messages).
|
|
532
1436
|
if (method === "POST" && path === "/ui/reply-button") {
|
|
@@ -551,8 +1455,14 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
551
1455
|
json(res, 400, { error: "instance, id and index required" });
|
|
552
1456
|
return;
|
|
553
1457
|
}
|
|
554
|
-
|
|
555
|
-
|
|
1458
|
+
// #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
|
|
1459
|
+
// theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
|
|
1460
|
+
// gate (a session revoked meanwhile) clicks nothing as anyone.
|
|
1461
|
+
const person = webPersonOr401(req, res, url, ctx);
|
|
1462
|
+
if (!person)
|
|
1463
|
+
return;
|
|
1464
|
+
const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
|
|
1465
|
+
json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
|
|
556
1466
|
})().catch(err => {
|
|
557
1467
|
ctx.logger.error({ err: err.message }, "Web reply-button click failed");
|
|
558
1468
|
try {
|
|
@@ -574,15 +1484,19 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
574
1484
|
json(res, 200, { code: p.code, previewOrigin: p.previewOrigin, boot: p.previewOrigin ? p.boot : null, detail: p.detail ?? null });
|
|
575
1485
|
return true;
|
|
576
1486
|
}
|
|
577
|
-
//
|
|
578
|
-
// included (it reads what is in force); written only by a signed-in admin session or the CLI's header token — never
|
|
579
|
-
// over the public link (absent from its route manifest, and refused here too), never by a read-only session.
|
|
1487
|
+
// Host preferences preserve their scope; only five declared owner slots use the current human session bucket.
|
|
580
1488
|
if (path === "/ui/prefs" && method === "GET") {
|
|
581
1489
|
if (!ctx.webPrefs) {
|
|
582
1490
|
json(res, 404, { error: "Preferences are not kept on this fleet" });
|
|
583
1491
|
return true;
|
|
584
1492
|
}
|
|
585
|
-
|
|
1493
|
+
const viewer = prefsViewer(req, url, ctx);
|
|
1494
|
+
if (!viewer) {
|
|
1495
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
1496
|
+
return true;
|
|
1497
|
+
}
|
|
1498
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
1499
|
+
json(res, 200, prefsView(ctx.webPrefs, viewer));
|
|
586
1500
|
return true;
|
|
587
1501
|
}
|
|
588
1502
|
if (path === "/ui/prefs" && method === "POST") {
|
|
@@ -595,17 +1509,9 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
595
1509
|
json(res, 403, { error: "prefs_not_over_public_link" });
|
|
596
1510
|
return true;
|
|
597
1511
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
602
|
-
if (verdict.kind !== "allow")
|
|
603
|
-
return 401;
|
|
604
|
-
return verdict.via === "session" && verdict.session?.tier !== "admin" ? 403 : 200;
|
|
605
|
-
};
|
|
606
|
-
const refuse = (code) => json(res, code, code === 401 ? { error: WEB_TOKEN_INVALID_MESSAGE } : { error: "prefs_read_only" });
|
|
607
|
-
const first = writer();
|
|
608
|
-
if (first !== 200) {
|
|
1512
|
+
const refuse = (viewer) => json(res, viewer ? 403 : 401, { error: viewer ? "prefs_read_only" : WEB_TOKEN_INVALID_MESSAGE });
|
|
1513
|
+
const first = prefsViewer(req, url, ctx);
|
|
1514
|
+
if (!first?.canWrite) {
|
|
609
1515
|
refuse(first);
|
|
610
1516
|
return true;
|
|
611
1517
|
}
|
|
@@ -629,21 +1535,39 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
629
1535
|
json(res, 400, { error: parsed.error });
|
|
630
1536
|
return;
|
|
631
1537
|
}
|
|
632
|
-
const now =
|
|
633
|
-
if (now
|
|
1538
|
+
const now = prefsViewer(req, url, ctx);
|
|
1539
|
+
if (!now?.canWrite) {
|
|
634
1540
|
refuse(now);
|
|
635
1541
|
return;
|
|
636
1542
|
}
|
|
637
|
-
|
|
638
|
-
|
|
1543
|
+
if (now.person !== first.person || now.epoch !== first.epoch || now.via !== first.via) {
|
|
1544
|
+
json(res, 409, { error: "prefs_person_changed" });
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
|
|
1548
|
+
if (personal && now.person === null) {
|
|
1549
|
+
json(res, 403, { error: "prefs_need_a_signed_in_person" });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
// A scope marker rejects a queued old page after a different login, including the same person's new session.
|
|
1553
|
+
// It supplies neither identity nor authority; the current verified session still chooses the bucket.
|
|
1554
|
+
if (personal && body.ownerEpoch !== now.epoch) {
|
|
1555
|
+
json(res, 409, { error: "prefs_person_changed" });
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
const before = prefs.readForViewer(now.person);
|
|
1559
|
+
const written = prefs.writeForViewer(now.person, parsed.patch, { onlyIfUnset: body.onlyIfUnset === true,
|
|
1560
|
+
validateEffect: (person, key, id) => ctx.effectService ? ctx.effectService.validateOwnerSelection(person, key, id) : "effects_unavailable" });
|
|
639
1561
|
if (!written.ok) {
|
|
640
1562
|
json(res, written.status, { error: written.error });
|
|
641
1563
|
return;
|
|
642
1564
|
}
|
|
643
|
-
|
|
1565
|
+
const dropped = written.changed.filter(isOwnerEffectKey).flatMap(key => typeof before.values[key] === "string" && before.values[key] !== written.view.values[key] ? [before.values[key]] : []);
|
|
1566
|
+
if (dropped.length)
|
|
1567
|
+
ctx.effectService?.collectOwnerReplaced(dropped);
|
|
644
1568
|
if (written.changed.length)
|
|
645
1569
|
ctx.emitSseEvent("status", ctx.getUiStatus());
|
|
646
|
-
json(res, 200, { ...written.view, changed: written.changed });
|
|
1570
|
+
json(res, 200, { ...written.view, ...prefsScope(now), changed: written.changed });
|
|
647
1571
|
})().catch(err => {
|
|
648
1572
|
ctx.logger.error({ err: err.message }, "Saving web preferences failed");
|
|
649
1573
|
try {
|
|
@@ -653,6 +1577,24 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
653
1577
|
});
|
|
654
1578
|
return true;
|
|
655
1579
|
}
|
|
1580
|
+
// #1629 r4 §3: owner CSS — one person's own stylesheet, for their own screens. Every route is a signed-in human's
|
|
1581
|
+
// session: never the CLI's header token (no person, and the way an agent could reach it), never the public link
|
|
1582
|
+
// (absent from its manifest, and refused here too), reads included. The stylesheet and its assets are that session's
|
|
1583
|
+
// person's only: no name or identity in any URL.
|
|
1584
|
+
if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
|
|
1585
|
+
return handleOwnerCss(req, res, url, ctx, path, method);
|
|
1586
|
+
}
|
|
1587
|
+
// #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
|
|
1588
|
+
if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
|
|
1589
|
+
return handleMods(req, res, url, ctx, path, method);
|
|
1590
|
+
// Posters remain behind the common session gate, with a closed GET-only public manifest.
|
|
1591
|
+
if (path === "/ui/effect-posters" || path.startsWith("/ui/effect-posters/"))
|
|
1592
|
+
return handleEffectPosters(res, url, ctx, path, method);
|
|
1593
|
+
// Canvas effects are absent from the public gateway manifest and independently refused above.
|
|
1594
|
+
if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
|
|
1595
|
+
return handleEffectTrials(req, res, url, ctx, path, method);
|
|
1596
|
+
if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
|
|
1597
|
+
return handleEffects(req, res, url, ctx, path, method);
|
|
656
1598
|
// #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
|
|
657
1599
|
// token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
|
|
658
1600
|
if (method === "POST" && path === "/ui/needs/ack") {
|
|
@@ -728,7 +1670,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
728
1670
|
json(res, 400, { error: "invalid confirmation" });
|
|
729
1671
|
return;
|
|
730
1672
|
}
|
|
731
|
-
const
|
|
1673
|
+
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
1674
|
+
if (!person)
|
|
1675
|
+
return;
|
|
1676
|
+
const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
|
|
732
1677
|
json(res, r.status, r.body);
|
|
733
1678
|
})().catch(err => {
|
|
734
1679
|
ctx.logger.error({ err: err.message }, "Web command failed");
|
|
@@ -762,7 +1707,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
762
1707
|
json(res, 409, { error: `${name} is not running` });
|
|
763
1708
|
return true;
|
|
764
1709
|
}
|
|
765
|
-
ctx.eventLog?.logActivity("cancel",
|
|
1710
|
+
ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
|
|
766
1711
|
json(res, 200, { cancelled: name });
|
|
767
1712
|
return true;
|
|
768
1713
|
}
|
|
@@ -993,12 +1938,15 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
993
1938
|
const parsed = parseOrReject(TaskCreateSchema, body, res);
|
|
994
1939
|
if (!parsed)
|
|
995
1940
|
return;
|
|
1941
|
+
const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
|
|
1942
|
+
if (!person)
|
|
1943
|
+
return;
|
|
996
1944
|
const task = ctx.scheduler.db.createTask({
|
|
997
1945
|
title: parsed.title,
|
|
998
1946
|
description: parsed.description,
|
|
999
1947
|
priority: parsed.priority,
|
|
1000
1948
|
assignee: parsed.assignee,
|
|
1001
|
-
created_by:
|
|
1949
|
+
created_by: person.name, // #1827
|
|
1002
1950
|
});
|
|
1003
1951
|
json(res, 200, task);
|
|
1004
1952
|
}
|
|
@@ -1025,7 +1973,10 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
1025
1973
|
return;
|
|
1026
1974
|
let result;
|
|
1027
1975
|
if (parsed.action === "claim") {
|
|
1028
|
-
|
|
1976
|
+
const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
|
|
1977
|
+
if (!parsed.assignee && !person)
|
|
1978
|
+
return;
|
|
1979
|
+
result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
|
|
1029
1980
|
}
|
|
1030
1981
|
else if (parsed.action === "complete") {
|
|
1031
1982
|
result = ctx.scheduler.db.completeTask(id, parsed.result);
|
|
@@ -1414,6 +2365,14 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1414
2365
|
return;
|
|
1415
2366
|
}
|
|
1416
2367
|
const message = typed;
|
|
2368
|
+
// #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
|
|
2369
|
+
// body is in: a session revoked meanwhile sends nothing (not as the operator).
|
|
2370
|
+
const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
|
|
2371
|
+
if (!person) {
|
|
2372
|
+
ctx.webFiles?.release(files);
|
|
2373
|
+
json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
1417
2376
|
const ts = new Date().toISOString();
|
|
1418
2377
|
// The id the agent is given, and the one its delivery reports come back under: the page's ticks.
|
|
1419
2378
|
const messageId = newWebMessageId();
|
|
@@ -1432,7 +2391,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1432
2391
|
// opt-in resolved at send time inside sendClassicWebEcho.
|
|
1433
2392
|
const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
|
|
1434
2393
|
const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1435
|
-
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(
|
|
2394
|
+
await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
|
|
1436
2395
|
}) : undefined;
|
|
1437
2396
|
const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
|
|
1438
2397
|
// A replacement adapter or edited binding is not the route we reserved.
|
|
@@ -1440,7 +2399,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1440
2399
|
|| (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
|
|
1441
2400
|
|| ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
|
|
1442
2401
|
return;
|
|
1443
|
-
return syncAdapter.sendText(String(groupId), formatWebChannelEcho(
|
|
2402
|
+
return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
|
|
1444
2403
|
}) : undefined;
|
|
1445
2404
|
let delivered;
|
|
1446
2405
|
try {
|
|
@@ -1454,7 +2413,7 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1454
2413
|
meta: {
|
|
1455
2414
|
chat_id: groupId ? String(groupId) : "",
|
|
1456
2415
|
message_id: messageId,
|
|
1457
|
-
user:
|
|
2416
|
+
user: person.name, user_id: person.userId,
|
|
1458
2417
|
ts,
|
|
1459
2418
|
thread_id: topicId != null ? String(topicId) : "",
|
|
1460
2419
|
source: "web",
|
|
@@ -1492,9 +2451,9 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1492
2451
|
ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
|
|
1493
2452
|
}
|
|
1494
2453
|
ctx.webFiles?.commit(files);
|
|
1495
|
-
ctx.lastInboundUser.set(instance,
|
|
1496
|
-
ctx.eventLog?.logActivity("message",
|
|
1497
|
-
ctx.emitSseEvent("message", { instance, sender:
|
|
2454
|
+
ctx.lastInboundUser.set(instance, person.name);
|
|
2455
|
+
ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
|
|
2456
|
+
ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
|
|
1498
2457
|
json(res, 200, { sent: true, messageId });
|
|
1499
2458
|
}
|
|
1500
2459
|
catch {
|
|
@@ -1506,6 +2465,11 @@ function handleSendMessage(req, res, ctx) {
|
|
|
1506
2465
|
* One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
|
|
1507
2466
|
* read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
|
|
1508
2467
|
* workspace inbox under a name chosen here; answered with the id the message will name.
|
|
2468
|
+
*
|
|
2469
|
+
* #1937: streamed to a part file in that inbox (web-pending-….part) as it arrives — never held whole in memory — and
|
|
2470
|
+
* counted by the bytes that came, whatever Content-Length said. The limits are the ones in force for this request
|
|
2471
|
+
* (uploadLimits(): the config, and the public link's own cap). An upload that goes UPLOAD_STALL_MS without a byte, or
|
|
2472
|
+
* runs past UPLOAD_MAX_MS, is dropped; any upload that does not end in a stored file takes its part file with it.
|
|
1509
2473
|
*/
|
|
1510
2474
|
function handleUpload(req, res, url, ctx) {
|
|
1511
2475
|
const instance = url.searchParams.get("instance") ?? "";
|
|
@@ -1517,57 +2481,112 @@ function handleUpload(req, res, url, ctx) {
|
|
|
1517
2481
|
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
1518
2482
|
return;
|
|
1519
2483
|
}
|
|
2484
|
+
const limits = uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined });
|
|
2485
|
+
const tooLarge = () => json(res, 413, { error: fileTooLargeText(limits), max_bytes: limits.maxFileBytes, ...(limits.publicLinkCapped ? { public_link: true } : {}) });
|
|
1520
2486
|
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
1521
|
-
if (Number.isFinite(declared) && declared >
|
|
1522
|
-
|
|
2487
|
+
if (Number.isFinite(declared) && declared > limits.maxFileBytes) {
|
|
2488
|
+
tooLarge();
|
|
1523
2489
|
req.resume();
|
|
1524
2490
|
return;
|
|
1525
2491
|
}
|
|
1526
|
-
|
|
2492
|
+
let part, fd, discard;
|
|
2493
|
+
try {
|
|
2494
|
+
({ path: part, fd, discard } = createPartFile(join(getAgendHome(), "workspaces", instance, "inbox")));
|
|
2495
|
+
}
|
|
2496
|
+
catch (err) {
|
|
2497
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
2498
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
2499
|
+
req.resume();
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
const out = createWriteStream(part, { fd, autoClose: true });
|
|
2503
|
+
const sniffer = new UploadSniffer();
|
|
1527
2504
|
let size = 0;
|
|
1528
|
-
let
|
|
2505
|
+
let over = false; // ended without a stored file: no further write, and the part file goes
|
|
2506
|
+
let stall;
|
|
2507
|
+
const clocks = () => { clearTimeout(stall); clearTimeout(deadline); };
|
|
2508
|
+
/** Stop here: answer (when the client is still there), remove the part file, and stop a body still arriving. */
|
|
2509
|
+
const drop = (answer) => {
|
|
2510
|
+
if (over)
|
|
2511
|
+
return;
|
|
2512
|
+
over = true;
|
|
2513
|
+
clocks();
|
|
2514
|
+
answer?.();
|
|
2515
|
+
out.destroy();
|
|
2516
|
+
discard();
|
|
2517
|
+
if (!req.complete)
|
|
2518
|
+
req.destroy();
|
|
2519
|
+
};
|
|
2520
|
+
const armStall = () => { clearTimeout(stall); stall = setTimeout(() => drop(() => json(res, 408, { error: "the upload stalled — attach the file again" })), UPLOAD_STALL_MS); stall.unref?.(); };
|
|
2521
|
+
const deadline = setTimeout(() => drop(() => json(res, 408, { error: "the upload took too long — attach the file again" })), UPLOAD_MAX_MS);
|
|
2522
|
+
deadline.unref?.();
|
|
2523
|
+
armStall();
|
|
2524
|
+
out.on("error", (err) => { ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored"); drop(() => json(res, 500, { error: "the file could not be stored" })); });
|
|
2525
|
+
out.on("drain", () => { if (!over)
|
|
2526
|
+
req.resume?.(); });
|
|
1529
2527
|
req.on("data", (c) => {
|
|
1530
|
-
if (
|
|
2528
|
+
if (over)
|
|
1531
2529
|
return;
|
|
1532
2530
|
size += c.length;
|
|
1533
|
-
if (size >
|
|
1534
|
-
|
|
1535
|
-
json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
|
|
1536
|
-
req.destroy();
|
|
2531
|
+
if (size > limits.maxFileBytes) {
|
|
2532
|
+
drop(tooLarge);
|
|
1537
2533
|
return;
|
|
1538
2534
|
}
|
|
1539
|
-
|
|
2535
|
+
armStall();
|
|
2536
|
+
sniffer.push(c);
|
|
2537
|
+
if (!out.write(c))
|
|
2538
|
+
req.pause?.();
|
|
1540
2539
|
});
|
|
1541
|
-
req.on("error", () =>
|
|
2540
|
+
req.on("error", () => drop()); // the client went away
|
|
2541
|
+
req.on("close", () => { if (!over && req.complete === false)
|
|
2542
|
+
drop(); });
|
|
2543
|
+
// The instance is checked again now the whole body is here: it may have been deleted (and its files
|
|
2544
|
+
// forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
|
|
2545
|
+
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
2546
|
+
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
|
|
2547
|
+
const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
|
|
2548
|
+
const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
|
|
1542
2549
|
req.on("end", () => {
|
|
1543
|
-
if (
|
|
2550
|
+
if (over)
|
|
1544
2551
|
return;
|
|
1545
|
-
|
|
2552
|
+
clocks();
|
|
2553
|
+
if (!permitWebContinuation(req, res, ctx)) {
|
|
2554
|
+
drop();
|
|
1546
2555
|
return;
|
|
1547
|
-
|
|
2556
|
+
}
|
|
1548
2557
|
const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
|
|
1549
|
-
const type =
|
|
2558
|
+
const type = sniffer.result(name);
|
|
1550
2559
|
if (!type) {
|
|
1551
|
-
json(res, 415, { error: "
|
|
2560
|
+
drop(() => json(res, 415, { error: "the file is empty" }));
|
|
1552
2561
|
return;
|
|
1553
2562
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
// Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
|
|
1557
|
-
// in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
|
|
1558
|
-
const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
|
|
1559
|
-
if (!ctx.instanceIpcClients.has(instance) || !registered) {
|
|
1560
|
-
json(res, 404, { error: `Instance not found: ${instance}` });
|
|
2563
|
+
if (!deliverable()) {
|
|
2564
|
+
gone();
|
|
1561
2565
|
return;
|
|
1562
2566
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
2567
|
+
out.end(() => {
|
|
2568
|
+
if (over)
|
|
2569
|
+
return;
|
|
2570
|
+
// The write's finish is another wait (Prism #1941 P2-1): the session or the public link may have ended in it.
|
|
2571
|
+
if (!permitWebContinuation(req, res, ctx)) {
|
|
2572
|
+
drop();
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
if (!deliverable()) {
|
|
2576
|
+
gone();
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
over = true;
|
|
2580
|
+
try {
|
|
2581
|
+
const entry = ctx.webFiles.storeUploadedFile({ instance, partPath: part, name, type });
|
|
2582
|
+
json(res, 200, publicAttachment(entry));
|
|
2583
|
+
}
|
|
2584
|
+
catch (err) {
|
|
2585
|
+
discard();
|
|
2586
|
+
ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
|
|
2587
|
+
json(res, 500, { error: "the file could not be stored" });
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
1571
2590
|
});
|
|
1572
2591
|
}
|
|
1573
2592
|
function shellPreview(req, ctx, mode) {
|
|
@@ -1585,6 +2604,15 @@ function shellPreview(req, ctx, mode) {
|
|
|
1585
2604
|
* No Range, several, or one not in that form → 200 with the whole file, as every file is served (the Download link).
|
|
1586
2605
|
* Inline, nosniff, no caching, and the route's own sandbox policy, as for every file.
|
|
1587
2606
|
*/
|
|
2607
|
+
/** #1937: a whole file (headers already set) streamed from the descriptor its checks were made on: 200, its length. */
|
|
2608
|
+
function streamWhole(res, opened) {
|
|
2609
|
+
res.setHeader("Content-Length", String(opened.file.size));
|
|
2610
|
+
res.writeHead(200);
|
|
2611
|
+
const stream = createReadStream("", { fd: opened.fd, start: 0, end: Math.max(0, opened.file.size - 1), autoClose: true });
|
|
2612
|
+
stream.on("error", () => res.destroy());
|
|
2613
|
+
res.on("close", () => stream.destroy());
|
|
2614
|
+
stream.pipe(res);
|
|
2615
|
+
}
|
|
1588
2616
|
function serveMediaRange(req, res, files, id, file) {
|
|
1589
2617
|
const total = file.size;
|
|
1590
2618
|
const header = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
|
|
@@ -1608,40 +2636,73 @@ function serveMediaRange(req, res, files, id, file) {
|
|
|
1608
2636
|
}
|
|
1609
2637
|
}
|
|
1610
2638
|
end = ranged ? Math.min(end, total - 1, start + MEDIA_RANGE_MAX - 1) : total - 1;
|
|
2639
|
+
const mediaHeaders = () => {
|
|
2640
|
+
res.setHeader("Content-Type", file.mime);
|
|
2641
|
+
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
2642
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
2643
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
2644
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
2645
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
2646
|
+
};
|
|
2647
|
+
// #1937 (Prism #1941 P3-2): the whole of a file over MEDIA_RANGE_MAX (no Range asked) streams, as /ui/file's do.
|
|
2648
|
+
if (!ranged && total > MEDIA_RANGE_MAX) {
|
|
2649
|
+
const opened = files.open(id);
|
|
2650
|
+
if (!opened) {
|
|
2651
|
+
json(res, 404, { error: "No such file" });
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
mediaHeaders();
|
|
2655
|
+
streamWhole(res, opened);
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
1611
2658
|
const got = files.readSlice(id, start, end);
|
|
1612
2659
|
if (!got || (!ranged && got.bytes.length !== total)) {
|
|
1613
2660
|
json(res, 404, { error: "No such file" });
|
|
1614
2661
|
return;
|
|
1615
2662
|
}
|
|
1616
|
-
|
|
1617
|
-
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
|
|
1618
|
-
res.setHeader("Accept-Ranges", "bytes");
|
|
2663
|
+
mediaHeaders();
|
|
1619
2664
|
if (ranged)
|
|
1620
2665
|
res.setHeader("Content-Range", `bytes ${got.start}-${got.end}/${total}`);
|
|
1621
2666
|
res.setHeader("Content-Length", String(got.bytes.length));
|
|
1622
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1623
|
-
res.setHeader("Cache-Control", "private, no-store");
|
|
1624
|
-
res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
1625
2667
|
res.writeHead(ranged ? 206 : 200);
|
|
1626
2668
|
res.end(got.bytes);
|
|
1627
2669
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2670
|
+
function prefsViewer(req, url, ctx) {
|
|
2671
|
+
const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
|
|
2672
|
+
if (v.kind !== "allow")
|
|
2673
|
+
return null;
|
|
2674
|
+
if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
|
|
2675
|
+
const person = webPersonKey(v.session.owner);
|
|
2676
|
+
return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
|
|
2677
|
+
}
|
|
2678
|
+
return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
|
|
2679
|
+
}
|
|
2680
|
+
const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
|
|
2681
|
+
const prefsView = (prefs, v) => ({ ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) });
|
|
2682
|
+
function shellHtmlTag(ctx, mode, viewer) {
|
|
1636
2683
|
if (mode !== "full" || !ctx.webPrefs)
|
|
1637
2684
|
return '<html lang="en">';
|
|
1638
|
-
const view = ctx.webPrefs
|
|
2685
|
+
const view = prefsView(ctx.webPrefs, viewer);
|
|
1639
2686
|
const lang = view.values.lang === "zh-TW" ? "zh-Hant" : "en";
|
|
1640
2687
|
const attr = (v) => v.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1641
|
-
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(
|
|
2688
|
+
return `<html lang="${lang}" data-prefs="${attr(JSON.stringify(view))}">`;
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* #1629 r4 §3: the owner CSS this load links (its sha), or null. Only the full app of a signed-in human session — never
|
|
2692
|
+
* the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
|
|
2693
|
+
*/
|
|
2694
|
+
function shellOwnerCss(ctx, mode, viewer) {
|
|
2695
|
+
return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
|
|
1642
2696
|
}
|
|
1643
|
-
function shellBodyTag(req, ctx, mode) {
|
|
2697
|
+
function shellBodyTag(req, ctx, mode, viewer) {
|
|
1644
2698
|
const p = shellPreview(req, ctx, mode);
|
|
2699
|
+
const ownerCss = shellOwnerCss(ctx, mode, viewer);
|
|
2700
|
+
let ownerCssState = "unknown";
|
|
2701
|
+
if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
|
|
2702
|
+
ownerCssState = ctx.ownerCss.effectState(viewer.person);
|
|
2703
|
+
if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
|
|
2704
|
+
ownerCssState = "unknown";
|
|
2705
|
+
}
|
|
1645
2706
|
const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
1646
2707
|
return `<body data-mode="${mode}"${mode === "full" && gatewayRequestContext(req) ? ' data-web-transport="poll"' : ""} data-dashboard-origin="${attr(p?.dashboardOrigin)}" data-preview-origin="${attr(p?.previewOrigin)}"`
|
|
1647
2708
|
+ ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
|
|
@@ -1653,8 +2714,13 @@ function shellBodyTag(req, ctx, mode) {
|
|
|
1653
2714
|
+ ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
|
|
1654
2715
|
// #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
|
|
1655
2716
|
+ ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
|
|
2717
|
+
// #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
|
|
2718
|
+
// checks before sending a file, and the numbers its words say. The server checks again on every upload.
|
|
2719
|
+
+ ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
|
|
1656
2720
|
// #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
|
|
1657
2721
|
+ ` data-fleet-tz="${attr(resolveTz())}"`
|
|
2722
|
+
// #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
|
|
2723
|
+
+ ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
|
|
1658
2724
|
// #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
|
|
1659
2725
|
+ `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
|
|
1660
2726
|
}
|
|
@@ -1663,6 +2729,7 @@ function shellBodyTag(req, ctx, mode) {
|
|
|
1663
2729
|
export function serveAppShell(req, res, ctx, mode) {
|
|
1664
2730
|
try {
|
|
1665
2731
|
const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
|
|
2732
|
+
const viewer = mode === "full" ? prefsViewer(req, new URL(req.url ?? "/", "http://localhost"), ctx) : null;
|
|
1666
2733
|
const p = shellPreview(req, ctx, mode);
|
|
1667
2734
|
// #1589: media plays only from this listener's own file route (path-scoped, like frame-src), at the address this page
|
|
1668
2735
|
// was loaded from (a Host the host guard accepted); never on the public link, nor where no chat is shown.
|
|
@@ -1671,8 +2738,9 @@ export function serveAppShell(req, res, ctx, mode) {
|
|
|
1671
2738
|
const mediaSrc = own ? `${own}/ui/file/` : "'none'";
|
|
1672
2739
|
// A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
|
|
1673
2740
|
// the page may load); every other page frames exactly the origin this load chose (#1723).
|
|
1674
|
-
const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin
|
|
1675
|
-
|
|
2741
|
+
const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin
|
|
2742
|
+
? `${p.previewOrigin}/frame${mode === "full" && !gatewayRequestContext(req) ? ` ${p.previewOrigin}/effect` : ""}` : null;
|
|
2743
|
+
sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
|
|
1676
2744
|
}
|
|
1677
2745
|
catch {
|
|
1678
2746
|
json(res, 500, { error: "app.html not found" });
|