@1agh/maude 0.39.1 → 0.40.0
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 +1 -1
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +253 -3
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +812 -64
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/canvas-edit.ts +1885 -104
- package/apps/studio/canvas-lib.tsx +19 -0
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +27 -0
- package/apps/studio/client/app.jsx +1029 -30
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +107 -0
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +1 -1
- package/apps/studio/context.ts +113 -1
- package/apps/studio/dist/client.bundle.js +88 -16
- package/apps/studio/dist/comment-mount.js +1 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/http.ts +535 -27
- package/apps/studio/input-router.tsx +7 -1
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +90 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/ws.ts +5 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
package/apps/studio/http.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
|
10
10
|
|
|
11
11
|
import { probeAcpAvailability } from './acp/probe.ts';
|
|
12
12
|
import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
|
|
13
|
-
import type
|
|
13
|
+
import { type Api, ASSET_MAX_VIDEO_BYTES } from './api.ts';
|
|
14
14
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
15
15
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
16
16
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
@@ -55,6 +55,15 @@ export const MIME: Record<string, string> = {
|
|
|
55
55
|
'.woff2': 'font/woff2',
|
|
56
56
|
'.ttf': 'font/ttf',
|
|
57
57
|
'.otf': 'font/otf',
|
|
58
|
+
// DDR-148 — video/audio media referenced by a video-comp `<Video>`/`<Audio>`.
|
|
59
|
+
'.mp4': 'video/mp4',
|
|
60
|
+
'.m4v': 'video/mp4',
|
|
61
|
+
'.mov': 'video/quicktime',
|
|
62
|
+
'.webm': 'video/webm',
|
|
63
|
+
'.mp3': 'audio/mpeg',
|
|
64
|
+
'.wav': 'audio/wav',
|
|
65
|
+
'.m4a': 'audio/mp4',
|
|
66
|
+
'.ogg': 'audio/ogg',
|
|
58
67
|
};
|
|
59
68
|
|
|
60
69
|
function ext(p: string): string {
|
|
@@ -112,6 +121,11 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
|
112
121
|
`script-src ${scriptSrc}`,
|
|
113
122
|
"connect-src 'self'",
|
|
114
123
|
"img-src 'self' data: blob:",
|
|
124
|
+
// DDR-148 — a video-comp's <Video>/<Audio> loads media from the canvas
|
|
125
|
+
// origin's own designRoot (assets/). Same inert-bytes trust as img-src
|
|
126
|
+
// 'self'; without it default-src 'none' blocks all media (black video, no
|
|
127
|
+
// sound). `blob:` covers Remotion's processed-media URLs.
|
|
128
|
+
"media-src 'self' blob:",
|
|
115
129
|
"style-src 'self' 'unsafe-inline'",
|
|
116
130
|
"font-src 'self' data:",
|
|
117
131
|
"frame-src 'self'",
|
|
@@ -124,6 +138,39 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
|
124
138
|
return directives.join('; ');
|
|
125
139
|
}
|
|
126
140
|
|
|
141
|
+
/**
|
|
142
|
+
* CSP for the EXPORT/CAPTURE render (DDR-148 security, attacker F1). The export
|
|
143
|
+
* renders the (untrusted, DDR-054) canvas on the MAIN origin — where the normal
|
|
144
|
+
* shell CSP is env-gated off — and the capture shim ACTIVELY primes+seeks every
|
|
145
|
+
* `<video>`, so a canvas with `<Video src="http://169.254.169.254/…">` or comp
|
|
146
|
+
* JS doing `fetch(internal)` would turn ⌘E into read-SSRF + exfil-via-artifact.
|
|
147
|
+
*
|
|
148
|
+
* The comp is arbitrary JS by design and the in-page encoder is injected via
|
|
149
|
+
* `addScriptTag`, so restricting SCRIPT execution is neither possible nor the
|
|
150
|
+
* threat — the threat is NETWORK EGRESS. This CSP therefore locks every network
|
|
151
|
+
* sink to `'self'` (media/img/connect/font, no frames/objects/form posts) while
|
|
152
|
+
* leaving script permissive. Legit designRoot media is same-origin → still
|
|
153
|
+
* loads; every off-origin fetch/media/beacon is refused.
|
|
154
|
+
*/
|
|
155
|
+
export function cspForCapture(): string {
|
|
156
|
+
return [
|
|
157
|
+
"default-src 'self'",
|
|
158
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
|
159
|
+
"style-src 'self' 'unsafe-inline'",
|
|
160
|
+
"img-src 'self' data: blob:",
|
|
161
|
+
"media-src 'self' blob:",
|
|
162
|
+
"font-src 'self' data:",
|
|
163
|
+
"connect-src 'self'",
|
|
164
|
+
"worker-src 'self' blob:",
|
|
165
|
+
"child-src 'self' blob:",
|
|
166
|
+
"frame-src 'none'",
|
|
167
|
+
"object-src 'none'",
|
|
168
|
+
"base-uri 'none'",
|
|
169
|
+
"form-action 'none'",
|
|
170
|
+
"webrtc 'block'",
|
|
171
|
+
].join('; ');
|
|
172
|
+
}
|
|
173
|
+
|
|
127
174
|
/**
|
|
128
175
|
* CSRF guard for the main-origin source-write routes (edit-css / edit-text /
|
|
129
176
|
* edit-attr / reorder). Those routes are reachable only from the shell, which is
|
|
@@ -475,6 +522,57 @@ async function serveFile(absPath: string, headers: Record<string, string> = {}):
|
|
|
475
522
|
});
|
|
476
523
|
}
|
|
477
524
|
|
|
525
|
+
/** Media (video/audio) extensions that get HTTP Range support when served. */
|
|
526
|
+
const RANGE_MEDIA_EXTS = new Set(['.mp4', '.m4v', '.mov', '.webm', '.mp3', '.wav', '.m4a', '.ogg']);
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* DDR-150 dogfood (team finding) — serve a media file with HTTP Range support.
|
|
530
|
+
* The plain `new Response(Bun.file(..))` lane answered `Range:` requests with a
|
|
531
|
+
* 200 + full body: Chrome copes for small clips (buffers everything) but large
|
|
532
|
+
* MP4s scrub terribly, and WKWebView (the native desktop app) can refuse to
|
|
533
|
+
* play media from servers without Range support at all. Single-range only
|
|
534
|
+
* (`bytes=a-b`, suffix `bytes=-n`, open `bytes=a-`); malformed ranges fall back
|
|
535
|
+
* to a full 200; an unsatisfiable one gets an honest 416.
|
|
536
|
+
*/
|
|
537
|
+
async function serveMediaFile(
|
|
538
|
+
absPath: string,
|
|
539
|
+
req: Request,
|
|
540
|
+
headers: Record<string, string> = {}
|
|
541
|
+
): Promise<Response> {
|
|
542
|
+
const file = Bun.file(absPath);
|
|
543
|
+
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
544
|
+
const size = file.size;
|
|
545
|
+
const base = {
|
|
546
|
+
'Content-Type': MIME[ext(absPath)] || 'application/octet-stream',
|
|
547
|
+
'Cache-Control': 'no-store',
|
|
548
|
+
'Accept-Ranges': 'bytes',
|
|
549
|
+
...headers,
|
|
550
|
+
};
|
|
551
|
+
const range = req.headers.get('range');
|
|
552
|
+
const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
|
|
553
|
+
if (m && (m[1] || m[2]) && size > 0) {
|
|
554
|
+
const start = m[1]
|
|
555
|
+
? Number.parseInt(m[1], 10)
|
|
556
|
+
: Math.max(0, size - Number.parseInt(m[2] as string, 10));
|
|
557
|
+
const end = m[1] && m[2] ? Math.min(Number.parseInt(m[2], 10), size - 1) : size - 1;
|
|
558
|
+
if (Number.isFinite(start) && start >= 0 && start <= end && start < size) {
|
|
559
|
+
return new Response(file.slice(start, end + 1), {
|
|
560
|
+
status: 206,
|
|
561
|
+
headers: {
|
|
562
|
+
...base,
|
|
563
|
+
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
564
|
+
'Content-Length': String(end - start + 1),
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return new Response(null, {
|
|
569
|
+
status: 416,
|
|
570
|
+
headers: { ...base, 'Content-Range': `bytes */${size}` },
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return new Response(file, { headers: base });
|
|
574
|
+
}
|
|
575
|
+
|
|
478
576
|
export interface Http {
|
|
479
577
|
routes: Record<string, (req: Request) => Response | Promise<Response>>;
|
|
480
578
|
fetch(req: Request): Promise<Response>;
|
|
@@ -484,7 +582,7 @@ export interface Http {
|
|
|
484
582
|
* origin keeps it env-gated for the POC). Shared so both listeners produce
|
|
485
583
|
* byte-identical HTML.
|
|
486
584
|
*/
|
|
487
|
-
serveCanvasShell(applyCsp: boolean): Promise<Response>;
|
|
585
|
+
serveCanvasShell(applyCsp: boolean, capture?: boolean): Promise<Response>;
|
|
488
586
|
/**
|
|
489
587
|
* T2 (9.1-A) — allowlist gate for the segregated canvas origin. Returns true
|
|
490
588
|
* only for the routes the canvas runtime legitimately needs (shell, runtime
|
|
@@ -568,8 +666,43 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
568
666
|
|
|
569
667
|
async function readJson<T = unknown>(req: Request, max = 256 * 1024): Promise<T | null> {
|
|
570
668
|
try {
|
|
571
|
-
|
|
572
|
-
|
|
669
|
+
// DDR-148 security (attacker F2): the global maxRequestBodySize is raised
|
|
670
|
+
// to ~108 MB so the STREAMING /_api/asset route can accept large videos —
|
|
671
|
+
// but a JSON route must never buffer that. `req.text()` would materialize
|
|
672
|
+
// the whole body BEFORE the length check (a 107 MB body from the untrusted
|
|
673
|
+
// canvas origin = memory-amplification DoS, reverting DDR-088). So: honest
|
|
674
|
+
// content-length fast-reject, then read the stream and ABORT past `max`,
|
|
675
|
+
// buffering at most ~max + one chunk.
|
|
676
|
+
const cl = Number(req.headers.get('content-length'));
|
|
677
|
+
if (Number.isFinite(cl) && cl > max) return null;
|
|
678
|
+
const body = req.body;
|
|
679
|
+
if (!body) return null;
|
|
680
|
+
const reader = body.getReader();
|
|
681
|
+
const chunks: Uint8Array[] = [];
|
|
682
|
+
let size = 0;
|
|
683
|
+
for (;;) {
|
|
684
|
+
const { done, value } = await reader.read();
|
|
685
|
+
if (done) break;
|
|
686
|
+
if (!value) continue;
|
|
687
|
+
size += value.byteLength;
|
|
688
|
+
if (size > max) {
|
|
689
|
+
try {
|
|
690
|
+
await reader.cancel();
|
|
691
|
+
} catch {
|
|
692
|
+
/* stream already closing */
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
chunks.push(value);
|
|
697
|
+
}
|
|
698
|
+
if (size === 0) return null;
|
|
699
|
+
const buf = new Uint8Array(size);
|
|
700
|
+
let off = 0;
|
|
701
|
+
for (const c of chunks) {
|
|
702
|
+
buf.set(c, off);
|
|
703
|
+
off += c.byteLength;
|
|
704
|
+
}
|
|
705
|
+
const text = new TextDecoder().decode(buf);
|
|
573
706
|
return text ? (JSON.parse(text) as T) : null;
|
|
574
707
|
} catch {
|
|
575
708
|
return null;
|
|
@@ -619,6 +752,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
619
752
|
// ChatPanel not-connected explainer + the Help-menu modal read this.
|
|
620
753
|
'/_api/preflight': async (req: Request) => {
|
|
621
754
|
if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
|
|
755
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
756
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
622
757
|
return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
|
|
623
758
|
},
|
|
624
759
|
|
|
@@ -631,6 +766,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
631
766
|
// chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
|
|
632
767
|
if (!sameOriginWrite(req))
|
|
633
768
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
769
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
770
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
634
771
|
ctx.bus.emit('acp-focus', {});
|
|
635
772
|
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
636
773
|
},
|
|
@@ -685,6 +822,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
685
822
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
686
823
|
if (!sameOriginWrite(req))
|
|
687
824
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
825
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
826
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
688
827
|
const declared = Number(req.headers.get('content-length') || '0');
|
|
689
828
|
if (Number.isFinite(declared) && declared > 10 * 1024 * 1024) {
|
|
690
829
|
return Response.json(
|
|
@@ -791,6 +930,34 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
791
930
|
return new Response('Method not allowed', { status: 405 });
|
|
792
931
|
},
|
|
793
932
|
|
|
933
|
+
'/_api/canvas-source': async (req: Request) => {
|
|
934
|
+
// DDR-148 — read-only raw .tsx source for the Timeline panel's sequence/
|
|
935
|
+
// keyframe parser. GET ?file=<canvas rel or slug> → { source }.
|
|
936
|
+
// MAIN-ORIGIN ONLY: intentionally absent from CANVAS_SAFE_API +
|
|
937
|
+
// startCanvasServer's routes (dual-allowlist, DDR-054) — the untrusted
|
|
938
|
+
// canvas iframe must never read arbitrary project source. Containment via
|
|
939
|
+
// api.loadCanvasSource → resolveCanvasAbs (stays under designRoot).
|
|
940
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
941
|
+
// DNS-rebinding guard — a GET read of project source is CORS-readable once
|
|
942
|
+
// a rebound page shares this origin; the dev-server always binds 127.0.0.1,
|
|
943
|
+
// so a real browser (Host: localhost:<port>) passes and only a rebound
|
|
944
|
+
// foreign hostname 403s. (No sameOriginWrite here — GET.)
|
|
945
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
946
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
947
|
+
const file = new URL(req.url).searchParams.get('file');
|
|
948
|
+
const r = await api.loadCanvasSource(file);
|
|
949
|
+
if (!r.ok) {
|
|
950
|
+
return Response.json(
|
|
951
|
+
{ ok: false, error: r.error },
|
|
952
|
+
{ status: r.status, headers: { 'Cache-Control': 'no-store' } }
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
return Response.json(
|
|
956
|
+
{ ok: true, source: r.source },
|
|
957
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
958
|
+
);
|
|
959
|
+
},
|
|
960
|
+
|
|
794
961
|
'/_api/git-committers': async (req: Request) => {
|
|
795
962
|
// Phase 6 — feed for the @mention autocomplete in composer + reply box.
|
|
796
963
|
// GET → top-20 committers on HEAD (`git shortlog -sne | head -20`)
|
|
@@ -829,6 +996,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
829
996
|
// slash-command driver omits Origin (→ allowed); a browser drive-by can't.
|
|
830
997
|
if (!sameOriginWrite(req))
|
|
831
998
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
999
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1000
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
832
1001
|
const body = await readJson<{ file?: string; author?: string }>(req);
|
|
833
1002
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
834
1003
|
return new Response('body.file required', { status: 400 });
|
|
@@ -847,6 +1016,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
847
1016
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
848
1017
|
if (!sameOriginWrite(req))
|
|
849
1018
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1019
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1020
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
850
1021
|
const body = await readJson<{ file?: string }>(req);
|
|
851
1022
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
852
1023
|
return new Response('body.file required', { status: 400 });
|
|
@@ -861,6 +1032,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
861
1032
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
862
1033
|
if (!sameOriginWrite(req))
|
|
863
1034
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1035
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1036
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
864
1037
|
const body = await readJson<{ file?: string }>(req);
|
|
865
1038
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
866
1039
|
return new Response('body.file required', { status: 400 });
|
|
@@ -912,6 +1085,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
912
1085
|
// startCanvasServer's allowlist (DDR-054) — the untrusted canvas iframe
|
|
913
1086
|
// origin must never reach a file-write/-delete endpoint. Validation lives in
|
|
914
1087
|
// api.createCanvas / api.deleteCanvas (containment + group allowlist).
|
|
1088
|
+
// DDR-150 P4 Task 12 — this route creates/deletes source `.tsx` (the
|
|
1089
|
+
// video-comp assemble writes real comp source), so it's CSRF-gated like the
|
|
1090
|
+
// other source-write routes: a cross-origin top-level page is rejected (the
|
|
1091
|
+
// DDR-054 split only blocks the canvas iframe). No-Origin (curl/tests) + the
|
|
1092
|
+
// same-origin shell pass. Closes a pre-existing gap on create/delete.
|
|
1093
|
+
if (req.method === 'DELETE' || req.method === 'POST') {
|
|
1094
|
+
if (!sameOriginWrite(req))
|
|
1095
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1096
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1097
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1098
|
+
}
|
|
915
1099
|
if (req.method === 'DELETE') {
|
|
916
1100
|
const file = new URL(req.url).searchParams.get('file');
|
|
917
1101
|
const result = await api.deleteCanvas({ file });
|
|
@@ -933,9 +1117,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
933
1117
|
);
|
|
934
1118
|
}
|
|
935
1119
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
936
|
-
const body = await readJson<{
|
|
1120
|
+
const body = await readJson<{
|
|
1121
|
+
name?: unknown;
|
|
1122
|
+
kind?: unknown;
|
|
1123
|
+
group?: unknown;
|
|
1124
|
+
clips?: unknown;
|
|
1125
|
+
fps?: unknown;
|
|
1126
|
+
width?: unknown;
|
|
1127
|
+
height?: unknown;
|
|
1128
|
+
}>(
|
|
937
1129
|
req,
|
|
938
|
-
|
|
1130
|
+
// DDR-150 P4 Task 12 — a video-comp assemble carries a clips[] array;
|
|
1131
|
+
// widen the read cap accordingly (still bounded well under abuse).
|
|
1132
|
+
64 * 1024
|
|
939
1133
|
);
|
|
940
1134
|
if (!body) return new Response('body required', { status: 400 });
|
|
941
1135
|
const result = await api.createCanvas(body);
|
|
@@ -994,6 +1188,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
994
1188
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
995
1189
|
if (!sameOriginWrite(req))
|
|
996
1190
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1191
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1192
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
997
1193
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
998
1194
|
return gitJson(await gitApi.createBranch(body));
|
|
999
1195
|
},
|
|
@@ -1002,6 +1198,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1002
1198
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1003
1199
|
if (!sameOriginWrite(req))
|
|
1004
1200
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1201
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1202
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1005
1203
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1006
1204
|
return gitJson(await gitApi.checkout(body));
|
|
1007
1205
|
},
|
|
@@ -1034,6 +1232,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1034
1232
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1035
1233
|
if (!sameOriginWrite(req))
|
|
1036
1234
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1235
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1236
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1037
1237
|
const body = await readJson<unknown>(req, 256 * 1024);
|
|
1038
1238
|
return gitJson(await gitApi.commit(body));
|
|
1039
1239
|
},
|
|
@@ -1042,6 +1242,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1042
1242
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1043
1243
|
if (!sameOriginWrite(req))
|
|
1044
1244
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1245
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1246
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1045
1247
|
const body = await readJson<unknown>(req, 256 * 1024);
|
|
1046
1248
|
return gitJson(await gitApi.discard(body));
|
|
1047
1249
|
},
|
|
@@ -1050,6 +1252,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1050
1252
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1051
1253
|
if (!sameOriginWrite(req))
|
|
1052
1254
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1255
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1256
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1053
1257
|
// Token-bearing: refuse anything not from a loopback Host (the server binds
|
|
1054
1258
|
// 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
|
|
1055
1259
|
if (!isLoopbackHost(req.headers.get('host')))
|
|
@@ -1197,6 +1401,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1197
1401
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1198
1402
|
if (!sameOriginWrite(req))
|
|
1199
1403
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1404
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1405
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1200
1406
|
const body = await readJson<{
|
|
1201
1407
|
canvas?: unknown;
|
|
1202
1408
|
id?: unknown;
|
|
@@ -1227,6 +1433,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1227
1433
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1228
1434
|
if (!sameOriginWrite(req))
|
|
1229
1435
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1436
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1437
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1230
1438
|
const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
|
|
1231
1439
|
req,
|
|
1232
1440
|
16 * 1024
|
|
@@ -1255,6 +1463,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1255
1463
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1256
1464
|
if (!sameOriginWrite(req))
|
|
1257
1465
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1466
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1467
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1258
1468
|
const body = await readJson<{
|
|
1259
1469
|
canvas?: unknown;
|
|
1260
1470
|
id?: unknown;
|
|
@@ -1271,7 +1481,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1271
1481
|
);
|
|
1272
1482
|
}
|
|
1273
1483
|
return Response.json(
|
|
1274
|
-
{ ok: true, delta: result.delta },
|
|
1484
|
+
{ ok: true, delta: result.delta, seq: result.seq },
|
|
1275
1485
|
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1276
1486
|
);
|
|
1277
1487
|
},
|
|
@@ -1289,6 +1499,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1289
1499
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1290
1500
|
if (!sameOriginWrite(req))
|
|
1291
1501
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1502
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1503
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1292
1504
|
const body = await readJson<{
|
|
1293
1505
|
canvas?: unknown;
|
|
1294
1506
|
id?: unknown;
|
|
@@ -1317,6 +1529,227 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1317
1529
|
);
|
|
1318
1530
|
},
|
|
1319
1531
|
|
|
1532
|
+
'/_api/retime-sequence': async (req: Request) => {
|
|
1533
|
+
// DDR-148 — Timeline drag-to-retime. POST { canvas, index, durationInFrames?,
|
|
1534
|
+
// from? } → rewrites the index-th sequence's timing (const-preferring so a
|
|
1535
|
+
// derived total updates too). Same MAIN-ORIGIN-ONLY trust boundary as
|
|
1536
|
+
// /_api/reorder + /_api/edit-css (absent from CANVAS_SAFE_API +
|
|
1537
|
+
// startCanvasServer routes, DDR-054) — the source write is privileged; the
|
|
1538
|
+
// untrusted canvas iframe can only request it over the dgn:* bus.
|
|
1539
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1540
|
+
if (!sameOriginWrite(req))
|
|
1541
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1542
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1543
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1544
|
+
const body = await readJson<{
|
|
1545
|
+
canvas?: unknown;
|
|
1546
|
+
stableId?: unknown;
|
|
1547
|
+
artboardId?: unknown;
|
|
1548
|
+
contentHash?: unknown;
|
|
1549
|
+
index?: unknown;
|
|
1550
|
+
durationInFrames?: unknown;
|
|
1551
|
+
from?: unknown;
|
|
1552
|
+
}>(req, 8 * 1024);
|
|
1553
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1554
|
+
const result = await api.retimeSequenceOp(body);
|
|
1555
|
+
if (!result.ok) {
|
|
1556
|
+
return Response.json(
|
|
1557
|
+
{ ok: false, error: result.error },
|
|
1558
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
return Response.json(
|
|
1562
|
+
{ ok: true, seq: result.seq },
|
|
1563
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1564
|
+
);
|
|
1565
|
+
},
|
|
1566
|
+
|
|
1567
|
+
'/_api/remove-sequence': async (req: Request) => {
|
|
1568
|
+
// DDR-150 P3 — remove a clip. POST { canvas, stableId, artboardId?,
|
|
1569
|
+
// contentHash? } → removeClip (fingerprint + semantic gate; refuses the
|
|
1570
|
+
// only clip; drops an adjacent transition in a series). Snapshots pre-remove
|
|
1571
|
+
// for /design:rollback. Same MAIN-ORIGIN-ONLY trust boundary as the other
|
|
1572
|
+
// source-writes (absent from CANVAS_SAFE_API + startCanvasServer routes).
|
|
1573
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1574
|
+
if (!sameOriginWrite(req))
|
|
1575
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1576
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1577
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1578
|
+
const body = await readJson<{
|
|
1579
|
+
canvas?: unknown;
|
|
1580
|
+
stableId?: unknown;
|
|
1581
|
+
artboardId?: unknown;
|
|
1582
|
+
contentHash?: unknown;
|
|
1583
|
+
}>(req, 8 * 1024);
|
|
1584
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1585
|
+
const result = await api.removeSequenceOp(body);
|
|
1586
|
+
if (!result.ok) {
|
|
1587
|
+
return Response.json(
|
|
1588
|
+
{ ok: false, error: result.error },
|
|
1589
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
return Response.json(
|
|
1593
|
+
{ ok: true, seq: result.seq },
|
|
1594
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1595
|
+
);
|
|
1596
|
+
},
|
|
1597
|
+
|
|
1598
|
+
'/_api/insert-sequence': async (req: Request) => {
|
|
1599
|
+
// DDR-150 P4 — insert a clip. POST { canvas, artboardId, from,
|
|
1600
|
+
// durationInFrames, mediaTag?, src? } → insertClip (appends after the
|
|
1601
|
+
// comp's last clip; refuses TransitionSeries + empty comps; src contained).
|
|
1602
|
+
// MAIN-ORIGIN-ONLY (absent from CANVAS_SAFE_API + startCanvasServer routes).
|
|
1603
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1604
|
+
if (!sameOriginWrite(req))
|
|
1605
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1606
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1607
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1608
|
+
const body = await readJson<{
|
|
1609
|
+
canvas?: unknown;
|
|
1610
|
+
artboardId?: unknown;
|
|
1611
|
+
from?: unknown;
|
|
1612
|
+
durationInFrames?: unknown;
|
|
1613
|
+
mediaTag?: unknown;
|
|
1614
|
+
src?: unknown;
|
|
1615
|
+
}>(req, 8 * 1024);
|
|
1616
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1617
|
+
const result = await api.insertSequenceOp(body);
|
|
1618
|
+
if (!result.ok) {
|
|
1619
|
+
return Response.json(
|
|
1620
|
+
{ ok: false, error: result.error },
|
|
1621
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
return Response.json(
|
|
1625
|
+
{ ok: true, stableId: result.stableId, seq: result.seq },
|
|
1626
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1627
|
+
);
|
|
1628
|
+
},
|
|
1629
|
+
|
|
1630
|
+
'/_api/reorder-sequence': async (req: Request) => {
|
|
1631
|
+
// DDR-150 P5 — z-order reorder. POST { canvas, artboardId, stableId,
|
|
1632
|
+
// contentHash, refStableId, refContentHash, position } → reorderClip
|
|
1633
|
+
// (moves a standalone <Sequence> before/after a sibling via moveElement +
|
|
1634
|
+
// semantic gate; both clips fingerprint-checked; refuses TransitionSeries
|
|
1635
|
+
// clips + self-move). MAIN-ORIGIN-ONLY (absent from CANVAS_SAFE_API +
|
|
1636
|
+
// startCanvasServer routes — same dual-allowlist as the other clip writes).
|
|
1637
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1638
|
+
if (!sameOriginWrite(req))
|
|
1639
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1640
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1641
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1642
|
+
const body = await readJson<{
|
|
1643
|
+
canvas?: unknown;
|
|
1644
|
+
artboardId?: unknown;
|
|
1645
|
+
stableId?: unknown;
|
|
1646
|
+
contentHash?: unknown;
|
|
1647
|
+
refStableId?: unknown;
|
|
1648
|
+
refContentHash?: unknown;
|
|
1649
|
+
position?: unknown;
|
|
1650
|
+
}>(req, 8 * 1024);
|
|
1651
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1652
|
+
const result = await api.reorderSequenceOp(body);
|
|
1653
|
+
if (!result.ok) {
|
|
1654
|
+
return Response.json(
|
|
1655
|
+
{ ok: false, error: result.error },
|
|
1656
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
return Response.json(
|
|
1660
|
+
{ ok: true, stableId: result.stableId, seq: result.seq },
|
|
1661
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1662
|
+
);
|
|
1663
|
+
},
|
|
1664
|
+
|
|
1665
|
+
'/_api/toggle-hide': async (req: Request) => {
|
|
1666
|
+
// DDR-150 dogfood — hide/show a clip (gates its body behind {false && …};
|
|
1667
|
+
// the tag + time slot + TransitionSeries alternation stay). MAIN-ORIGIN-ONLY
|
|
1668
|
+
// + CSRF-gated like the other source writes.
|
|
1669
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1670
|
+
if (!sameOriginWrite(req))
|
|
1671
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1672
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1673
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1674
|
+
const body = await readJson<{
|
|
1675
|
+
canvas?: unknown;
|
|
1676
|
+
stableId?: unknown;
|
|
1677
|
+
artboardId?: unknown;
|
|
1678
|
+
contentHash?: unknown;
|
|
1679
|
+
}>(req, 8 * 1024);
|
|
1680
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1681
|
+
const result = await api.toggleHideOp(body);
|
|
1682
|
+
if (!result.ok) {
|
|
1683
|
+
return Response.json(
|
|
1684
|
+
{ ok: false, error: result.error },
|
|
1685
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
return Response.json(
|
|
1689
|
+
{ ok: true, hidden: result.hidden, seq: result.seq },
|
|
1690
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1691
|
+
);
|
|
1692
|
+
},
|
|
1693
|
+
|
|
1694
|
+
'/_api/edit-array-src': async (req: Request) => {
|
|
1695
|
+
// DDR-150 dogfood — replace a media src stored in an array literal (the
|
|
1696
|
+
// showreel `const CLIPS = [{ src }, …]` fed via `<ClipShot clip={CLIPS[i]}>`),
|
|
1697
|
+
// addressed by the enumerator's mediaArrayRef. MAIN-ORIGIN-ONLY + CSRF-gated
|
|
1698
|
+
// (same dual-allowlist as the other source writes); value contained under
|
|
1699
|
+
// assets/ in the engine.
|
|
1700
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1701
|
+
if (!sameOriginWrite(req))
|
|
1702
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1703
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1704
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1705
|
+
const body = await readJson<{
|
|
1706
|
+
canvas?: unknown;
|
|
1707
|
+
arrayName?: unknown;
|
|
1708
|
+
index?: unknown;
|
|
1709
|
+
field?: unknown;
|
|
1710
|
+
value?: unknown;
|
|
1711
|
+
}>(req, 8 * 1024);
|
|
1712
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1713
|
+
const result = await api.editArraySrcOp(body);
|
|
1714
|
+
if (!result.ok) {
|
|
1715
|
+
return Response.json(
|
|
1716
|
+
{ ok: false, error: result.error },
|
|
1717
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
return Response.json(
|
|
1721
|
+
{ ok: true, seq: result.seq },
|
|
1722
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1723
|
+
);
|
|
1724
|
+
},
|
|
1725
|
+
|
|
1726
|
+
'/_api/comp-clips': async (req: Request) => {
|
|
1727
|
+
// DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
|
|
1728
|
+
// GET ?canvas=<rel>&artboardId=<id> → { ok, compName, clips:[{ stableId,
|
|
1729
|
+
// from, durationInFrames, mediaTag, mediaSrc, mediaCdId, contentHash }] }.
|
|
1730
|
+
// The Timeline addresses every clip op by the returned stableId (never a
|
|
1731
|
+
// regex document-order index — the multi-comp mis-hit defect). Read-only +
|
|
1732
|
+
// MAIN-ORIGIN-ONLY: absent from CANVAS_SAFE_API + the startCanvasServer
|
|
1733
|
+
// route map, so the untrusted canvas iframe can never reach it (DDR-054).
|
|
1734
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1735
|
+
// DNS-rebinding guard — this GET echoes project source structure; loopback-
|
|
1736
|
+
// only Host (real browser passes, rebound hostname 403s). No sameOriginWrite (GET).
|
|
1737
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1738
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1739
|
+
const u = new URL(req.url);
|
|
1740
|
+
const result = await api.compClips({
|
|
1741
|
+
canvas: u.searchParams.get('canvas'),
|
|
1742
|
+
artboardId: u.searchParams.get('artboardId') ?? undefined,
|
|
1743
|
+
});
|
|
1744
|
+
if (!result.ok) {
|
|
1745
|
+
return Response.json(
|
|
1746
|
+
{ ok: false, error: result.error },
|
|
1747
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
return Response.json(result, { status: 200, headers: { 'Cache-Control': 'no-store' } });
|
|
1751
|
+
},
|
|
1752
|
+
|
|
1320
1753
|
'/_api/reorder-revert': async (req: Request) => {
|
|
1321
1754
|
// Phase 12.1 follow-up — Cmd+Z / Cmd+Shift+Z for a reorder. POST body
|
|
1322
1755
|
// { canvas, seq, dir:'undo'|'redo' } → api.reorderRevert swaps the whole
|
|
@@ -1327,6 +1760,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1327
1760
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1328
1761
|
if (!sameOriginWrite(req))
|
|
1329
1762
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
1763
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1764
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1330
1765
|
const body = await readJson<{ canvas?: unknown; seq?: unknown; dir?: unknown }>(
|
|
1331
1766
|
req,
|
|
1332
1767
|
8 * 1024
|
|
@@ -1346,31 +1781,29 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1346
1781
|
},
|
|
1347
1782
|
|
|
1348
1783
|
'/_api/asset': async (req: Request) => {
|
|
1349
|
-
// Phase 23 — binary
|
|
1350
|
-
//
|
|
1784
|
+
// Phase 23 / DDR-148 — binary media upload from the canvas (drag-drop /
|
|
1785
|
+
// paste). POST raw bytes → content-addressed write under
|
|
1351
1786
|
// <designRoot>/assets/<sha8>.<ext>, returns 201 { path }. This route is on
|
|
1352
1787
|
// the canvas-origin allowlist (CANVAS_SAFE_API below) — a bigger grant than
|
|
1353
1788
|
// the inert annotation-SVG write (binary, disk) — so the caps in
|
|
1354
|
-
// api.
|
|
1355
|
-
// traversal guard, no-SVG, dedupe
|
|
1356
|
-
// NOT optional.
|
|
1789
|
+
// api.saveAssetFromStream (magic-byte sniff, per-category ceiling, content-
|
|
1790
|
+
// addressed name, traversal guard, no-SVG/script, dedupe, session budget)
|
|
1791
|
+
// are the load-bearing trust mitigation, NOT optional. DDR-088 + DDR-148:
|
|
1792
|
+
// the body is STREAMED to disk (no full-buffer of a 100 MB clip in RAM).
|
|
1357
1793
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1358
|
-
// Early reject on a declared oversize body (the
|
|
1359
|
-
//
|
|
1794
|
+
// Early reject on a declared oversize body (the streamed per-category cap in
|
|
1795
|
+
// saveAssetFromStream is the authoritative gate — Content-Length can be
|
|
1796
|
+
// omitted/lied; this only trims an obvious oversize before we open a temp).
|
|
1360
1797
|
const declared = Number(req.headers.get('content-length') || '0');
|
|
1361
|
-
if (Number.isFinite(declared) && declared >
|
|
1798
|
+
if (Number.isFinite(declared) && declared > ASSET_MAX_VIDEO_BYTES) {
|
|
1799
|
+
const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
|
|
1362
1800
|
return Response.json(
|
|
1363
|
-
{ ok: false, error:
|
|
1801
|
+
{ ok: false, error: `media exceeds the ${mb} MB cap` },
|
|
1364
1802
|
{ status: 413, headers: { 'Cache-Control': 'no-store' } }
|
|
1365
1803
|
);
|
|
1366
1804
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
bytes = new Uint8Array(await req.arrayBuffer());
|
|
1370
|
-
} catch {
|
|
1371
|
-
return new Response('could not read request body', { status: 400 });
|
|
1372
|
-
}
|
|
1373
|
-
const result = await api.saveAsset(bytes);
|
|
1805
|
+
if (!req.body) return new Response('empty body', { status: 400 });
|
|
1806
|
+
const result = await api.saveAssetFromStream(req.body as ReadableStream<Uint8Array>);
|
|
1374
1807
|
if (!result.ok) {
|
|
1375
1808
|
return Response.json(
|
|
1376
1809
|
{ ok: false, error: result.error },
|
|
@@ -1569,8 +2002,51 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1569
2002
|
if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
|
|
1570
2003
|
// The segregated canvas origin (server.ts) calls serveCanvasShell(true)
|
|
1571
2004
|
// directly with CSP always on; on the legacy main origin the CSP stays
|
|
1572
|
-
// env-gated (MAUDE_CSP_POC) for the POC / backwards-compat.
|
|
1573
|
-
|
|
2005
|
+
// env-gated (MAUDE_CSP_POC) for the POC / backwards-compat. A capture
|
|
2006
|
+
// render (?hide-chrome=1 — ⌘E export / screenshot shim) ALWAYS gets the
|
|
2007
|
+
// network-locked capture CSP so it can't SSRF/exfil (attacker F1).
|
|
2008
|
+
const capture = url.searchParams.get('hide-chrome') === '1';
|
|
2009
|
+
return serveCanvasShell(process.env.MAUDE_CSP_POC === '1', capture);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// DDR-150 dogfood — canvas-relative assets alias. A comp's
|
|
2013
|
+
// `<Video src="assets/x.mp4">` resolves against the iframe root →
|
|
2014
|
+
// `/assets/x.mp4`; serve `designRoot/assets/x.mp4` (flat sha8-named
|
|
2015
|
+
// uploads, media extensions only — mirrors isCanvasSafeRoute's gate on
|
|
2016
|
+
// the canvas origin). Without this, every src the timeline insert /
|
|
2017
|
+
// replace / assemble writers emit 404'd in the Player.
|
|
2018
|
+
if (pathname.startsWith('/assets/')) {
|
|
2019
|
+
let name = '';
|
|
2020
|
+
try {
|
|
2021
|
+
name = decodeURIComponent(pathname.slice('/assets/'.length));
|
|
2022
|
+
} catch {
|
|
2023
|
+
return new Response('Bad request', { status: 400 });
|
|
2024
|
+
}
|
|
2025
|
+
if (
|
|
2026
|
+
name &&
|
|
2027
|
+
!name.includes('/') &&
|
|
2028
|
+
!name.includes('\\') &&
|
|
2029
|
+
!name.includes('..') &&
|
|
2030
|
+
!name.startsWith('_') &&
|
|
2031
|
+
CANVAS_ASSET_EXTS.has(ext(name))
|
|
2032
|
+
) {
|
|
2033
|
+
const abs = join(ctx.paths.designRoot, 'assets', name);
|
|
2034
|
+
// Range-aware for video/audio (scrubbing + WKWebView compat).
|
|
2035
|
+
if (RANGE_MEDIA_EXTS.has(ext(name))) {
|
|
2036
|
+
return serveMediaFile(abs, req, { 'X-Content-Type-Options': 'nosniff' });
|
|
2037
|
+
}
|
|
2038
|
+
const f = Bun.file(abs);
|
|
2039
|
+
if (await f.exists()) {
|
|
2040
|
+
return new Response(f, {
|
|
2041
|
+
headers: {
|
|
2042
|
+
'Content-Type': MIME[ext(name)] || 'application/octet-stream',
|
|
2043
|
+
'Cache-Control': 'no-store',
|
|
2044
|
+
'X-Content-Type-Options': 'nosniff',
|
|
2045
|
+
},
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
return new Response('Not found', { status: 404 });
|
|
2049
|
+
}
|
|
1574
2050
|
}
|
|
1575
2051
|
|
|
1576
2052
|
// Fall-through: serve user repo files (designRoot + everything under repoRoot).
|
|
@@ -1587,6 +2063,11 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1587
2063
|
if (e === '.tsx' && underDesignRoot) {
|
|
1588
2064
|
return serveCanvasTsx(fp, req, ctx, join(ctx.paths.designRoot, '_locator.json'));
|
|
1589
2065
|
}
|
|
2066
|
+
// Video/audio get Range support (scrubbing + WKWebView compat) — the
|
|
2067
|
+
// `/.design/assets/…` form comps reference goes through here.
|
|
2068
|
+
if (RANGE_MEDIA_EXTS.has(e)) {
|
|
2069
|
+
return serveMediaFile(fp, req, { 'X-Content-Type-Options': 'nosniff' });
|
|
2070
|
+
}
|
|
1590
2071
|
// Bun.file streams transparently for binary content.
|
|
1591
2072
|
return new Response(file, {
|
|
1592
2073
|
headers: {
|
|
@@ -1605,7 +2086,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1605
2086
|
}
|
|
1606
2087
|
}
|
|
1607
2088
|
|
|
1608
|
-
async function serveCanvasShell(applyCsp: boolean): Promise<Response> {
|
|
2089
|
+
async function serveCanvasShell(applyCsp: boolean, capture = false): Promise<Response> {
|
|
1609
2090
|
const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
|
|
1610
2091
|
// Inject inspector overlay — Cmd+Click selection + add-comment flow.
|
|
1611
2092
|
const injected = inspect.injectInspector(shellHtml);
|
|
@@ -1613,7 +2094,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1613
2094
|
'Content-Type': 'text/html; charset=utf-8',
|
|
1614
2095
|
'Cache-Control': 'no-store',
|
|
1615
2096
|
};
|
|
1616
|
-
|
|
2097
|
+
// A capture render (⌘E export / screenshot) ALWAYS carries the network-locked
|
|
2098
|
+
// capture CSP, even on the main origin where the normal shell CSP is off —
|
|
2099
|
+
// this is the SSRF/exfil boundary the export otherwise lacked (attacker F1).
|
|
2100
|
+
if (capture) headers['Content-Security-Policy'] = cspForCapture();
|
|
2101
|
+
else if (applyCsp)
|
|
2102
|
+
headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
|
|
1617
2103
|
return new Response(injected, { headers });
|
|
1618
2104
|
}
|
|
1619
2105
|
|
|
@@ -1634,6 +2120,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1634
2120
|
'.woff2',
|
|
1635
2121
|
'.ttf',
|
|
1636
2122
|
'.otf',
|
|
2123
|
+
// DDR-148 — video/audio media a video-comp `<Video>`/`<Audio>` loads from
|
|
2124
|
+
// assets/. Served like images (inert static bytes, `nosniff`, no script
|
|
2125
|
+
// execution under the canvas CSP) — the same read grant, widened for media.
|
|
2126
|
+
'.mp4',
|
|
2127
|
+
'.m4v',
|
|
2128
|
+
'.mov',
|
|
2129
|
+
'.webm',
|
|
2130
|
+
'.mp3',
|
|
2131
|
+
'.wav',
|
|
2132
|
+
'.m4a',
|
|
2133
|
+
'.ogg',
|
|
1637
2134
|
]);
|
|
1638
2135
|
|
|
1639
2136
|
// Exact API paths the canvas iframe needs (collab + display data). See
|
|
@@ -1697,6 +2194,17 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1697
2194
|
if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
|
|
1698
2195
|
return CANVAS_ASSET_EXTS.has(ext(safe));
|
|
1699
2196
|
}
|
|
2197
|
+
// DDR-150 dogfood — `/assets/<file>`: the canvas-RELATIVE form every writer
|
|
2198
|
+
// emits (`<Video src="assets/x.mp4">` from timeline insert / replace /
|
|
2199
|
+
// assemble, and what the video-comp skill teaches). It aliases to
|
|
2200
|
+
// `designRoot/assets/<file>` — flat single segment (uploads are sha8-named),
|
|
2201
|
+
// media/image extensions only, no `_` names. Same read-only trust class as
|
|
2202
|
+
// the designRel static lane above (DDR-088 widened media grant).
|
|
2203
|
+
if (safe.startsWith('/assets/')) {
|
|
2204
|
+
const rest = safe.slice('/assets/'.length);
|
|
2205
|
+
if (!rest || rest.includes('/') || rest.startsWith('_')) return false;
|
|
2206
|
+
return CANVAS_ASSET_EXTS.has(ext(safe));
|
|
2207
|
+
}
|
|
1700
2208
|
return false;
|
|
1701
2209
|
}
|
|
1702
2210
|
|