@huynle/hub 0.8.6 → 0.9.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/CHANGELOG.md +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// GET /api/media/remote?url=… (design §4): web images for the console, fetched by the hub through the safe fetcher
|
|
2
|
+
// (./fetcher.ts) for signed-in users only. Only PNG, JPEG, WebP and GIF are served (sniffed from the bytes, never
|
|
3
|
+
// SVG), within REMOTE_MEDIA_LIMITS, with the §1 media headers. A tenant-scoped in-memory cache (per pod in cluster
|
|
4
|
+
// mode) keyed by the normalised URL keeps images for the site's lifetime clamped to 1-24 h and failures for 5 min.
|
|
5
|
+
// Per user: 30 fetches a minute and 4 at once (429), one budget shared with /api/media/unfurl (./fetch-budget.ts);
|
|
6
|
+
// cache hits are free. Each fetch is audited with the user, the
|
|
7
|
+
// host and the outcome; the full URL never is (it may carry leaked data). HUB_REMOTE_FETCH=off turns it off.
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import { MEDIA_LIMITS } from "../protocol"
|
|
10
|
+
import { createFetchBudget, type FetchBudget } from "./fetch-budget"
|
|
11
|
+
import { checkURL, FetchError, type Fetcher } from "./fetcher"
|
|
12
|
+
|
|
13
|
+
/** Remote image caps: those of timeline images (MEDIA_LIMITS: 8 MiB, 40 MP, 16,384 px per side). */
|
|
14
|
+
export const REMOTE_MEDIA_LIMITS = { maxBytes: MEDIA_LIMITS.maxBytes, maxPixels: MEDIA_LIMITS.maxPixels, maxSide: MEDIA_LIMITS.maxSide } as const
|
|
15
|
+
|
|
16
|
+
const MIN_TTL_MS = 3_600_000
|
|
17
|
+
const MAX_TTL_MS = 24 * 3_600_000
|
|
18
|
+
const FAILURE_TTL_MS = 5 * 60_000
|
|
19
|
+
const DEFAULT_CACHE_BYTES = 64 * 1024 * 1024
|
|
20
|
+
const MAX_CACHE_ENTRIES = 4096
|
|
21
|
+
/** What a cached failure counts as against the byte bound. */
|
|
22
|
+
const FAILURE_COST = 256
|
|
23
|
+
const ACCEPT = "image/png,image/jpeg,image/webp,image/gif;q=0.9,*/*;q=0.1"
|
|
24
|
+
|
|
25
|
+
/** The §1 media headers, shared by remote and timeline images (./media-timeline.ts). */
|
|
26
|
+
export const MEDIA_HEADERS = {
|
|
27
|
+
"x-content-type-options": "nosniff",
|
|
28
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
29
|
+
"cache-control": "private, no-cache",
|
|
30
|
+
"cross-origin-resource-policy": "same-origin",
|
|
31
|
+
"referrer-policy": "no-referrer",
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
export interface SniffedImage {
|
|
35
|
+
readonly mime: "image/png" | "image/jpeg" | "image/webp" | "image/gif"
|
|
36
|
+
readonly width: number
|
|
37
|
+
readonly height: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const u16be = (b: Uint8Array, at: number) => (b[at]! << 8) | b[at + 1]!
|
|
41
|
+
const u16le = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8)
|
|
42
|
+
const u24le = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16)
|
|
43
|
+
const u32be = (b: Uint8Array, at: number) => ((b[at]! << 24) >>> 0) + ((b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!)
|
|
44
|
+
const u32le = (b: Uint8Array, at: number) => (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16) | (b[at + 3]! << 24)) >>> 0
|
|
45
|
+
const text = (b: Uint8Array, at: number, s: string) => b.length >= at + s.length && [...s].every((c, i) => b[at + i] === c.charCodeAt(0))
|
|
46
|
+
|
|
47
|
+
/** The type (from magic bytes) and dimensions (from the header) of a PNG, JPEG, WebP or GIF; undefined otherwise. */
|
|
48
|
+
export function sniffImage(b: Uint8Array): SniffedImage | undefined {
|
|
49
|
+
const found = sniff(b)
|
|
50
|
+
return found && found.width > 0 && found.height > 0 ? found : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sniff(b: Uint8Array): SniffedImage | undefined {
|
|
54
|
+
if (b.length >= 24 && b[0] === 0x89 && text(b, 1, "PNG\r\n\x1a\n") && text(b, 12, "IHDR")) {
|
|
55
|
+
return { mime: "image/png", width: u32be(b, 16), height: u32be(b, 20) }
|
|
56
|
+
}
|
|
57
|
+
if (b.length >= 10 && (text(b, 0, "GIF87a") || text(b, 0, "GIF89a"))) return { mime: "image/gif", width: u16le(b, 6), height: u16le(b, 8) }
|
|
58
|
+
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) return jpegSize(b)
|
|
59
|
+
if (b.length >= 30 && text(b, 0, "RIFF") && text(b, 8, "WEBP")) {
|
|
60
|
+
if (text(b, 12, "VP8X")) return { mime: "image/webp", width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }
|
|
61
|
+
if (text(b, 12, "VP8L") && b[20] === 0x2f) {
|
|
62
|
+
const bits = u32le(b, 21)
|
|
63
|
+
return { mime: "image/webp", width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
|
|
64
|
+
}
|
|
65
|
+
if (text(b, 12, "VP8 ") && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
|
|
66
|
+
return { mime: "image/webp", width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Walks the JPEG markers to the first frame header (SOFn). */
|
|
73
|
+
function jpegSize(b: Uint8Array): SniffedImage | undefined {
|
|
74
|
+
let i = 2
|
|
75
|
+
while (i + 3 < b.length) {
|
|
76
|
+
if (b[i] !== 0xff) return undefined
|
|
77
|
+
const marker = b[i + 1]!
|
|
78
|
+
if (marker === 0xff) {
|
|
79
|
+
i++ // fill byte
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
|
|
83
|
+
i += 2 // standalone markers
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (marker === 0xd9 || marker === 0xda) return undefined // end of image, or scan data before any frame header
|
|
87
|
+
const length = u16be(b, i + 2)
|
|
88
|
+
if (length < 2) return undefined
|
|
89
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
90
|
+
if (i + 9 > b.length) return undefined
|
|
91
|
+
return { mime: "image/jpeg", width: u16be(b, i + 7), height: u16be(b, i + 5) }
|
|
92
|
+
}
|
|
93
|
+
i += 2 + length
|
|
94
|
+
}
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RemoteMediaAudit {
|
|
99
|
+
readonly actor: string
|
|
100
|
+
readonly action: string
|
|
101
|
+
readonly target?: string
|
|
102
|
+
readonly detail?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RemoteMediaOptions {
|
|
106
|
+
readonly fetcher: Fetcher
|
|
107
|
+
/** False: HUB_REMOTE_FETCH=off; every request is refused and nothing is fetched. */
|
|
108
|
+
readonly enabled: boolean
|
|
109
|
+
readonly now?: () => number
|
|
110
|
+
/** The hub's audit log (store.audit). */
|
|
111
|
+
readonly audit: (entry: RemoteMediaAudit) => unknown
|
|
112
|
+
/** The cache's byte bound (default 64 MiB). */
|
|
113
|
+
readonly cacheBytes?: number
|
|
114
|
+
/** The per-user fetch budget (http.ts shares one with /api/media/unfurl); default: its own, 30 a minute and 4 at once. */
|
|
115
|
+
readonly budget?: FetchBudget
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface RemoteMedia {
|
|
119
|
+
/** Answers GET /api/media/remote for `userID` (already authenticated as a signed-in user). */
|
|
120
|
+
handle(userID: string, request: Request, url: URL): Promise<Response>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type Entry =
|
|
124
|
+
| { readonly expires: number; readonly cost: number; readonly ok: true; readonly bytes: Uint8Array<ArrayBuffer>; readonly mime: string; readonly etag: string }
|
|
125
|
+
| { readonly expires: number; readonly cost: number; readonly ok: false; readonly status: number; readonly code: string; readonly message: string }
|
|
126
|
+
|
|
127
|
+
/** The 429 text of a refused budget take (shared with ./media-unfurl.ts). */
|
|
128
|
+
export const budgetMessage = (refused: { reason: "minute" | "concurrent"; limit: number }) =>
|
|
129
|
+
refused.reason === "minute" ? `At most ${refused.limit} web fetches (images and link previews) a minute` : `At most ${refused.limit} web fetches (images and link previews) at once`
|
|
130
|
+
|
|
131
|
+
export const failure = (status: number, code: string, message: string, headers: Record<string, string> = {}) =>
|
|
132
|
+
Response.json({ error: message, code }, { status, headers: { "x-content-type-options": "nosniff", "cache-control": "no-store", ...headers } })
|
|
133
|
+
|
|
134
|
+
/** How long the site says a response may be cached, clamped to 1-24 h (shared with ./media-unfurl.ts). */
|
|
135
|
+
export function lifetime(headers: Headers, now: number): number {
|
|
136
|
+
const control = headers.get("cache-control") ?? ""
|
|
137
|
+
const maxAge = /(?:^|[,\s])max-age\s*=\s*"?(\d+)/i.exec(control)
|
|
138
|
+
let ms = 0
|
|
139
|
+
if (maxAge) ms = Number(maxAge[1]) * 1000
|
|
140
|
+
else {
|
|
141
|
+
const expires = Date.parse(headers.get("expires") ?? "")
|
|
142
|
+
const date = Date.parse(headers.get("date") ?? "")
|
|
143
|
+
if (Number.isFinite(expires)) ms = expires - (Number.isFinite(date) ? date : now)
|
|
144
|
+
}
|
|
145
|
+
return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, Number.isFinite(ms) ? ms : 0))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const etagMatches = (header: string | null, etag: string) =>
|
|
149
|
+
!!header && header.split(",").some((tag) => {
|
|
150
|
+
const t = tag.trim()
|
|
151
|
+
return t === "*" || t === etag || t === `W/${etag}`
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
export function createRemoteMedia(options: RemoteMediaOptions): RemoteMedia {
|
|
155
|
+
const now = options.now ?? Date.now
|
|
156
|
+
const cacheBytes = options.cacheBytes ?? DEFAULT_CACHE_BYTES
|
|
157
|
+
const budget = options.budget ?? createFetchBudget({ now })
|
|
158
|
+
const cache = new Map<string, Entry>()
|
|
159
|
+
let cached = 0
|
|
160
|
+
|
|
161
|
+
function remember(key: string, entry: Entry): void {
|
|
162
|
+
forget(key)
|
|
163
|
+
if (entry.cost > cacheBytes) return
|
|
164
|
+
cache.set(key, entry)
|
|
165
|
+
cached += entry.cost
|
|
166
|
+
for (const [oldest, old] of cache) {
|
|
167
|
+
if (cached <= cacheBytes && cache.size <= MAX_CACHE_ENTRIES) break
|
|
168
|
+
cache.delete(oldest)
|
|
169
|
+
cached -= old.cost
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function forget(key: string): void {
|
|
173
|
+
const old = cache.get(key)
|
|
174
|
+
if (!old) return
|
|
175
|
+
cache.delete(key)
|
|
176
|
+
cached -= old.cost
|
|
177
|
+
}
|
|
178
|
+
function lookup(key: string): Entry | undefined {
|
|
179
|
+
const entry = cache.get(key)
|
|
180
|
+
if (!entry) return undefined
|
|
181
|
+
if (entry.expires <= now()) {
|
|
182
|
+
forget(key)
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
cache.delete(key) // most recently used last
|
|
186
|
+
cache.set(key, entry)
|
|
187
|
+
return entry
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function answer(entry: Entry, request: Request): Response {
|
|
191
|
+
if (!entry.ok) return failure(entry.status, entry.code, entry.message)
|
|
192
|
+
if (etagMatches(request.headers.get("if-none-match"), entry.etag)) return new Response(null, { status: 304, headers: { ...MEDIA_HEADERS, etag: entry.etag } })
|
|
193
|
+
return new Response(entry.bytes, { headers: { ...MEDIA_HEADERS, "content-type": entry.mime, "content-length": String(entry.bytes.length), etag: entry.etag } })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The fetch outcome to cache: the image, or why not. */
|
|
197
|
+
async function load(target: URL): Promise<{ entry: Entry; host: string; outcome: string }> {
|
|
198
|
+
const at = now()
|
|
199
|
+
const fail = (status: number, code: string, message: string) => ({ expires: at + FAILURE_TTL_MS, cost: FAILURE_COST, ok: false as const, status, code, message })
|
|
200
|
+
try {
|
|
201
|
+
const got = await options.fetcher.fetch(target, { maxBytes: REMOTE_MEDIA_LIMITS.maxBytes, accept: ACCEPT })
|
|
202
|
+
const host = got.url.hostname
|
|
203
|
+
const image = sniffImage(got.body)
|
|
204
|
+
if (!image) return { entry: fail(415, "not_image", "Not a PNG, JPEG, WebP or GIF image"), host, outcome: "not_image" }
|
|
205
|
+
const { maxSide, maxPixels } = REMOTE_MEDIA_LIMITS
|
|
206
|
+
if (image.width > maxSide || image.height > maxSide || image.width * image.height > maxPixels) {
|
|
207
|
+
return { entry: fail(413, "too_large", `The image is larger than ${maxSide} px per side or ${maxPixels / 1_000_000} MP`), host, outcome: "too_large" }
|
|
208
|
+
}
|
|
209
|
+
const etag = `"sha256-${createHash("sha256").update(got.body).digest("base64url")}"`
|
|
210
|
+
const entry: Entry = { expires: at + lifetime(got.headers, at), cost: got.body.length, ok: true, bytes: got.body, mime: image.mime, etag }
|
|
211
|
+
return { entry, host, outcome: "ok" }
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
if (!(cause instanceof FetchError)) throw cause
|
|
214
|
+
const code = cause.kind === "too_large" ? "too_large" : cause.kind
|
|
215
|
+
const message = cause.kind === "too_large" ? `The image is larger than ${REMOTE_MEDIA_LIMITS.maxBytes / 1024 / 1024} MiB` : cause.message
|
|
216
|
+
return { entry: fail(cause.status, code, message), host: cause.host ?? target.hostname, outcome: cause.kind }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function handle(userID: string, request: Request, url: URL): Promise<Response> {
|
|
221
|
+
if (!options.enabled) return failure(403, "disabled", "Remote media is turned off on this hub (HUB_REMOTE_FETCH=off)")
|
|
222
|
+
const raw = url.searchParams.get("url")
|
|
223
|
+
if (!raw) return failure(400, "bad_url", "Expected a url query parameter")
|
|
224
|
+
let target: URL
|
|
225
|
+
try {
|
|
226
|
+
target = checkURL(raw)
|
|
227
|
+
} catch (cause) {
|
|
228
|
+
if (cause instanceof FetchError) return failure(cause.status, cause.kind, cause.message)
|
|
229
|
+
throw cause
|
|
230
|
+
}
|
|
231
|
+
const key = `${userID}\n${target.href}`
|
|
232
|
+
const hit = lookup(key)
|
|
233
|
+
if (hit) return answer(hit, request)
|
|
234
|
+
|
|
235
|
+
const slot = budget.take(userID)
|
|
236
|
+
if (!slot.ok) return failure(429, "rate_limited", budgetMessage(slot), { "retry-after": String(slot.retryAfter) })
|
|
237
|
+
try {
|
|
238
|
+
const { entry, host, outcome } = await load(target)
|
|
239
|
+
await Promise.resolve(options.audit({ actor: userID, action: "media.remote", target: host, detail: outcome })).catch(() => undefined)
|
|
240
|
+
remember(key, entry)
|
|
241
|
+
return answer(entry, request)
|
|
242
|
+
} finally {
|
|
243
|
+
slot.release()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { handle }
|
|
248
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// (S4 M3, session media design §1) GET /api/sessions/:session/timeline/messages/:message/media/:ref: an image of a
|
|
2
|
+
// session's timeline, pulled from the owning spoke on demand (op timelineMedia, feature "timelineMedia") chunk by
|
|
3
|
+
// chunk and answered to the console. The hub trusts nothing the spoke says about the bytes:
|
|
4
|
+
//
|
|
5
|
+
// - the first chunk's magic bytes must be the type the spoke claims, and one of MEDIA_MIMES (else 415);
|
|
6
|
+
// - the claimed dimensions must fit MEDIA_LIMITS (else 413), the length MEDIA_LIMITS.maxBytes;
|
|
7
|
+
// - every chunk must follow the previous one, and the length and SHA-256 of the whole must be the claimed ones.
|
|
8
|
+
//
|
|
9
|
+
// The whole image (≤ MEDIA_LIMITS.maxBytes) is collected and verified before anything is answered: a mismatch is 502
|
|
10
|
+
// integrity, never a partial 200 (Bun ends an errored chunked body cleanly, so a stream cannot signal it) and never a
|
|
11
|
+
// 304 for an unverified hash; it is not cached. One image gets 60 s in all (504 timeout), past each chunk's timeout.
|
|
12
|
+
//
|
|
13
|
+
// Answers carry the media headers (nosniff, sandbox CSP, CORP same-origin, no referrer, private no-cache) and an ETag
|
|
14
|
+
// of the SHA-256. SVG is always an attachment (never a document on the hub's origin); `?download=1` makes any type one.
|
|
15
|
+
//
|
|
16
|
+
// Cache: per pod, in memory, keyed by (user, session, message, ref), LRU within 64 MiB; never shared between users.
|
|
17
|
+
// Attachments (`f`) and tool items (`t`) of a message do not change, so a cached one is served (or answered 304 for a
|
|
18
|
+
// matching If-None-Match) without contacting the spoke, once the route's access check passed. A path (`p:`) names a
|
|
19
|
+
// file on the agent's machine that may change, so it is revalidated with one first-chunk call (which also re-checks
|
|
20
|
+
// the spoke's path policy); the later chunks come from the cache when the SHA-256 is unchanged. The caller bypasses the
|
|
21
|
+
// cache while a revert is staged (the spoke decides whether the message is hidden) and forgets a session when it is
|
|
22
|
+
// deleted or a revert is committed in it. Every hit also checks the session's current committed revert as the owning
|
|
23
|
+
// connection reports it (seen from any pod, so another pod's cache never outlives a revert) and a 10 min TTL. Limits: 4 spoke fetches in flight per user, 2 per spoke connection (429).
|
|
24
|
+
import { createHash } from "node:crypto"
|
|
25
|
+
import { MEDIA_LIMITS, MEDIA_MIMES, type MediaMime, type MediaRef, type TimelineMediaResult } from "../protocol"
|
|
26
|
+
import { MEDIA_HEADERS } from "./media-remote"
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CACHE_BYTES = 64 * 1024 * 1024
|
|
29
|
+
const DEFAULT_PER_USER = 4
|
|
30
|
+
const DEFAULT_PER_CONNECTION = 2
|
|
31
|
+
const DEFAULT_DEADLINE_MS = 60_000
|
|
32
|
+
/** Backstop: a cached image is fetched again after this long, whatever else happened. */
|
|
33
|
+
const CACHE_TTL_MS = 10 * 60_000
|
|
34
|
+
const SHA256 = /^[0-9a-f]{64}$/
|
|
35
|
+
const EXTENSIONS: Record<MediaMime, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/svg+xml": "svg" }
|
|
36
|
+
|
|
37
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
38
|
+
/** An SVG document: optional BOM, XML declaration, comments and doctype, then the root `<svg` (as the spoke sniffs). */
|
|
39
|
+
const SVG_START = /^\uFEFF?\s*(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE\s+svg[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s>/]/i
|
|
40
|
+
const SVG_SNIFF_BYTES = 4096
|
|
41
|
+
const ascii = (b: Uint8Array, at: number, text: string) => b.length >= at + text.length && [...text].every((c, i) => b[at + i] === c.charCodeAt(0))
|
|
42
|
+
|
|
43
|
+
/** The image type of `bytes` by their magic bytes (one of MEDIA_MIMES), else undefined. */
|
|
44
|
+
export function sniffMediaMime(bytes: Uint8Array): MediaMime | undefined {
|
|
45
|
+
if (bytes.length >= 8 && PNG_SIGNATURE.every((v, i) => bytes[i] === v)) return "image/png"
|
|
46
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg"
|
|
47
|
+
if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return "image/gif"
|
|
48
|
+
if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp"
|
|
49
|
+
if (SVG_START.test(new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, SVG_SNIFF_BYTES)))) return "image/svg+xml"
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type TimelineMediaRequest = { readonly op: "timelineMedia"; readonly sessionID: string; readonly messageID: string; readonly ref: string; readonly offset: number }
|
|
54
|
+
|
|
55
|
+
export interface TimelineMediaOptions {
|
|
56
|
+
/** One timelineMedia op on the owning connection (spokes.request: routed to the owning pod in cluster mode). */
|
|
57
|
+
readonly request: (connID: string, req: TimelineMediaRequest) => Promise<TimelineMediaResult>
|
|
58
|
+
readonly cacheBytes?: number
|
|
59
|
+
readonly perUser?: number
|
|
60
|
+
readonly perConnection?: number
|
|
61
|
+
/** The longest one image may take, all chunks together (default 60 s). */
|
|
62
|
+
readonly deadlineMs?: number
|
|
63
|
+
/** The clock of the cache TTL. */
|
|
64
|
+
readonly now?: () => number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TimelineMediaInput {
|
|
68
|
+
/** The signed-in user the route's access check admitted. */
|
|
69
|
+
readonly userID: string
|
|
70
|
+
/** The session's owning connection (feature "timelineMedia" checked by the caller). */
|
|
71
|
+
readonly connID: string
|
|
72
|
+
readonly sessionID: string
|
|
73
|
+
readonly messageID: string
|
|
74
|
+
readonly ref: string
|
|
75
|
+
readonly parsed: MediaRef
|
|
76
|
+
readonly ifNoneMatch: string | null
|
|
77
|
+
readonly download: boolean
|
|
78
|
+
/** Neither read nor fill the cache (a revert is staged in the session). */
|
|
79
|
+
readonly bypassCache: boolean
|
|
80
|
+
/**
|
|
81
|
+
* The session's latest committed revert (`revertCommitted.at`) as the owning connection reports it, seen from any
|
|
82
|
+
* pod: a cached entry filled under another one is stale (its message may be gone).
|
|
83
|
+
*/
|
|
84
|
+
readonly revertAt: number | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TimelineMedia {
|
|
88
|
+
serve(input: TimelineMediaInput): Promise<Response>
|
|
89
|
+
/** Forgets a session's cached images (it was deleted, or a revert was committed in it). */
|
|
90
|
+
forgetSession(sessionID: string): void
|
|
91
|
+
/** Bytes cached now (tests, metrics). */
|
|
92
|
+
cachedBytes(): number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface Entry {
|
|
96
|
+
readonly sessionID: string
|
|
97
|
+
readonly sha256: string
|
|
98
|
+
readonly mime: MediaMime
|
|
99
|
+
readonly data: Uint8Array<ArrayBuffer>
|
|
100
|
+
/** When it was cached (options.now). */
|
|
101
|
+
readonly filled: number
|
|
102
|
+
/** The session's committed revert (`at`) when it was cached. */
|
|
103
|
+
readonly revertAt: number | undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const failure = (status: number, code: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}) =>
|
|
107
|
+
Response.json({ error: message, code, ...extra }, { status, headers: { "x-content-type-options": "nosniff", "cache-control": "no-store", ...headers } })
|
|
108
|
+
|
|
109
|
+
/** An answer the spoke should never give (a bug or a hostile spoke): 502, nothing served. */
|
|
110
|
+
class BadAnswer extends Error {
|
|
111
|
+
constructor(message: string, readonly code = "bad_answer") {
|
|
112
|
+
super(message)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class DeadlineError extends Error {
|
|
117
|
+
constructor() {
|
|
118
|
+
super("The image took too long to load from the session's machine")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `promise`, or a DeadlineError once `deadline` (performance.now()) passes. */
|
|
123
|
+
function withDeadline<T>(promise: Promise<T>, deadline: number): Promise<T> {
|
|
124
|
+
const left = deadline - performance.now()
|
|
125
|
+
if (left <= 0) return Promise.reject(new DeadlineError())
|
|
126
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
127
|
+
const expired = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new DeadlineError()), left)))
|
|
128
|
+
return Promise.race([promise, expired]).finally(() => clearTimeout(timer))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const etagOf = (sha256: string) => `"sha256-${sha256}"`
|
|
132
|
+
const etagMatches = (header: string | null, etag: string) =>
|
|
133
|
+
!!header && header.split(",").some((tag) => {
|
|
134
|
+
const t = tag.trim()
|
|
135
|
+
return t === "*" || t === etag || t === `W/${etag}`
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/** `inline`/`attachment` with an RFC 5987 file name: the path's base name or `<message>-<ref>`, with the type's extension. */
|
|
139
|
+
function disposition(input: TimelineMediaInput, mime: MediaMime): string {
|
|
140
|
+
const kind = input.download || mime === "image/svg+xml" ? "attachment" : "inline"
|
|
141
|
+
const ext = EXTENSIONS[mime]
|
|
142
|
+
const raw = input.parsed.kind === "path" ? input.parsed.path.split(/[\\/]/).pop()! : `${input.messageID}-${input.ref}`
|
|
143
|
+
let name = raw.normalize("NFC").replace(/[^\p{L}\p{N}._-]+/gu, "_").replace(/^[._]+/, "").slice(0, 120) || "image"
|
|
144
|
+
if (!name.toLowerCase().endsWith(`.${ext}`) && !(ext === "jpg" && /\.jpeg$/i.test(name))) name += `.${ext}`
|
|
145
|
+
const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
146
|
+
return `${kind}; filename*=UTF-8''${encoded}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function decodeChunk(data: unknown): Uint8Array<ArrayBuffer> {
|
|
150
|
+
if (typeof data !== "string" || data.length > Math.ceil(MEDIA_LIMITS.chunkBytes / 3) * 4 + 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new BadAnswer("The spoke sent a malformed chunk")
|
|
151
|
+
const bytes = new Uint8Array(Buffer.from(data, "base64"))
|
|
152
|
+
if (bytes.length === 0 || bytes.length > MEDIA_LIMITS.chunkBytes) throw new BadAnswer("The spoke sent a malformed chunk")
|
|
153
|
+
return bytes
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The checked metadata of a served answer. */
|
|
157
|
+
function checkInfo(media: TimelineMediaResult["media"]): { mime: MediaMime; bytes: number; sha256: string } | Response {
|
|
158
|
+
if (media && typeof media.mime === "string" && !MEDIA_MIMES.includes(media.mime)) return failure(415, "type", "This file is not an image the hub shows")
|
|
159
|
+
if (!media || !Number.isInteger(media.bytes) || media.bytes < 1 || typeof media.sha256 !== "string" || !SHA256.test(media.sha256)) {
|
|
160
|
+
throw new BadAnswer("The spoke described the image incompletely")
|
|
161
|
+
}
|
|
162
|
+
if (media.bytes > MEDIA_LIMITS.maxBytes) return failure(413, "too_large", "This image is too large to show here", { bytes: media.bytes, max: MEDIA_LIMITS.maxBytes })
|
|
163
|
+
const { width, height } = media
|
|
164
|
+
if (width !== undefined || height !== undefined) {
|
|
165
|
+
const { maxSide, maxPixels } = MEDIA_LIMITS
|
|
166
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width! < 0 || height! < 0) throw new BadAnswer("The spoke sent invalid dimensions")
|
|
167
|
+
if (width! > maxSide || height! > maxSide || width! * height! > maxPixels) return failure(413, "dimensions", `This image is larger than ${maxSide} px per side or ${maxPixels / 1_000_000} MP`)
|
|
168
|
+
}
|
|
169
|
+
return { mime: media.mime, bytes: media.bytes, sha256: media.sha256 }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The answer for a spoke result that serves no image (or undefined when it serves one). */
|
|
173
|
+
function refusal(result: TimelineMediaResult): Response | undefined {
|
|
174
|
+
if (result.tooLarge) return failure(413, "too_large", "This image is too large to show here", { bytes: result.tooLarge.bytes, max: result.tooLarge.max })
|
|
175
|
+
switch (result.refused) {
|
|
176
|
+
case "type":
|
|
177
|
+
return failure(415, "type", "This file is not an image the hub shows")
|
|
178
|
+
case "dimensions":
|
|
179
|
+
return failure(413, "dimensions", "This image has too many pixels to show here")
|
|
180
|
+
case "path":
|
|
181
|
+
return failure(404, "path", "This file is not in this session's files")
|
|
182
|
+
case "hidden":
|
|
183
|
+
return failure(404, "hidden", "This message is hidden by a revert")
|
|
184
|
+
case undefined:
|
|
185
|
+
break
|
|
186
|
+
default:
|
|
187
|
+
throw new BadAnswer("The spoke refused the image for an unknown reason")
|
|
188
|
+
}
|
|
189
|
+
if (!result.media && !result.chunk) return failure(404, "not_found", "No such image")
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function createTimelineMedia(options: TimelineMediaOptions): TimelineMedia {
|
|
194
|
+
const cacheBytes = options.cacheBytes ?? DEFAULT_CACHE_BYTES
|
|
195
|
+
const perUser = options.perUser ?? DEFAULT_PER_USER
|
|
196
|
+
const perConnection = options.perConnection ?? DEFAULT_PER_CONNECTION
|
|
197
|
+
const deadlineMs = options.deadlineMs ?? DEFAULT_DEADLINE_MS
|
|
198
|
+
const now = options.now ?? Date.now
|
|
199
|
+
const cache = new Map<string, Entry>()
|
|
200
|
+
let cached = 0
|
|
201
|
+
const users = new Map<string, number>()
|
|
202
|
+
const conns = new Map<string, number>()
|
|
203
|
+
|
|
204
|
+
function remember(key: string, entry: Entry): void {
|
|
205
|
+
forget(key)
|
|
206
|
+
if (entry.data.length > Math.min(cacheBytes, MEDIA_LIMITS.maxBytes)) return
|
|
207
|
+
cache.set(key, entry)
|
|
208
|
+
cached += entry.data.length
|
|
209
|
+
for (const [oldest, old] of cache) {
|
|
210
|
+
if (cached <= cacheBytes) break
|
|
211
|
+
cache.delete(oldest)
|
|
212
|
+
cached -= old.data.length
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function forget(key: string): void {
|
|
216
|
+
const old = cache.get(key)
|
|
217
|
+
if (!old) return
|
|
218
|
+
cache.delete(key)
|
|
219
|
+
cached -= old.data.length
|
|
220
|
+
}
|
|
221
|
+
/** A fresh entry: within the TTL, and filled under the session's current committed revert (any pod's spoke). */
|
|
222
|
+
function lookup(key: string, input: TimelineMediaInput): Entry | undefined {
|
|
223
|
+
const entry = cache.get(key)
|
|
224
|
+
if (!entry) return undefined
|
|
225
|
+
if (now() - entry.filled >= CACHE_TTL_MS || entry.revertAt !== input.revertAt) {
|
|
226
|
+
forget(key)
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
cache.delete(key) // most recently used last
|
|
230
|
+
cache.set(key, entry)
|
|
231
|
+
return entry
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const bump = (counts: Map<string, number>, id: string, by: number) => {
|
|
235
|
+
const n = (counts.get(id) ?? 0) + by
|
|
236
|
+
if (n > 0) counts.set(id, n)
|
|
237
|
+
else counts.delete(id)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function headers(input: TimelineMediaInput, mime: MediaMime, sha256: string, bytes: number): Record<string, string> {
|
|
241
|
+
return { ...MEDIA_HEADERS, "content-type": mime, "content-length": String(bytes), etag: etagOf(sha256), "content-disposition": disposition(input, mime) }
|
|
242
|
+
}
|
|
243
|
+
const notModified = (sha256: string) => new Response(null, { status: 304, headers: { ...MEDIA_HEADERS, etag: etagOf(sha256) } })
|
|
244
|
+
const fromCache = (input: TimelineMediaInput, entry: Entry) =>
|
|
245
|
+
etagMatches(input.ifNoneMatch, etagOf(entry.sha256)) ? notModified(entry.sha256) : new Response(entry.data, { headers: headers(input, entry.mime, entry.sha256, entry.data.length) })
|
|
246
|
+
|
|
247
|
+
async function serve(input: TimelineMediaInput): Promise<Response> {
|
|
248
|
+
const key = [input.userID, input.sessionID, input.messageID, input.ref].join("\n")
|
|
249
|
+
const cacheable = !input.bypassCache
|
|
250
|
+
if (!cacheable) forget(key)
|
|
251
|
+
const hit = cacheable ? lookup(key, input) : undefined
|
|
252
|
+
if (hit && input.parsed.kind !== "path") return fromCache(input, hit)
|
|
253
|
+
const pathHit = hit
|
|
254
|
+
if ((users.get(input.userID) ?? 0) >= perUser) return failure(429, "rate_limited", `At most ${perUser} images load at once`, {}, { "retry-after": "1" })
|
|
255
|
+
if ((conns.get(input.connID) ?? 0) >= perConnection) return failure(429, "rate_limited", `At most ${perConnection} images load at once from one machine`, {}, { "retry-after": "1" })
|
|
256
|
+
bump(users, input.userID, 1)
|
|
257
|
+
bump(conns, input.connID, 1)
|
|
258
|
+
let held = true
|
|
259
|
+
const release = () => {
|
|
260
|
+
if (!held) return
|
|
261
|
+
held = false
|
|
262
|
+
bump(users, input.userID, -1)
|
|
263
|
+
bump(conns, input.connID, -1)
|
|
264
|
+
}
|
|
265
|
+
const deadline = performance.now() + deadlineMs
|
|
266
|
+
const fetchChunk = (offset: number) =>
|
|
267
|
+
withDeadline(options.request(input.connID, { op: "timelineMedia", sessionID: input.sessionID, messageID: input.messageID, ref: input.ref, offset }), deadline)
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const first = await fetchChunk(0)
|
|
271
|
+
const refused = refusal(first)
|
|
272
|
+
if (refused) return refused
|
|
273
|
+
const info = checkInfo(first.media)
|
|
274
|
+
if (info instanceof Response) return info
|
|
275
|
+
if (!first.chunk || first.chunk.offset !== 0 || typeof first.chunk.last !== "boolean") throw new BadAnswer("The spoke sent no first chunk")
|
|
276
|
+
const head = decodeChunk(first.chunk.data)
|
|
277
|
+
if (sniffMediaMime(head) !== info.mime) return failure(415, "type", "This file is not the image type it claims to be")
|
|
278
|
+
if (head.length > info.bytes || (first.chunk.last && head.length !== info.bytes)) throw new BadAnswer("The image's length does not match", "integrity")
|
|
279
|
+
// A path's cached bytes were verified; the spoke's SHA-256 is over the file's whole current bytes, so the same
|
|
280
|
+
// one means the same image (answered from the cache, 304 included, without the later chunks).
|
|
281
|
+
if (pathHit && pathHit.sha256 === info.sha256) return fromCache(input, pathHit)
|
|
282
|
+
|
|
283
|
+
// The whole image is collected and verified before any byte (or a 304) is sent: ≤ MEDIA_LIMITS.maxBytes.
|
|
284
|
+
const whole = new Uint8Array(info.bytes)
|
|
285
|
+
whole.set(head)
|
|
286
|
+
let received = head.length
|
|
287
|
+
let last = first.chunk.last
|
|
288
|
+
while (!last) {
|
|
289
|
+
const next = await fetchChunk(received)
|
|
290
|
+
if (refusal(next) || !next.chunk || next.chunk.offset !== received || typeof next.chunk.last !== "boolean") throw new BadAnswer("The spoke stopped serving the image")
|
|
291
|
+
if (next.media && (next.media.sha256 !== info.sha256 || next.media.bytes !== info.bytes || next.media.mime !== info.mime)) throw new BadAnswer("The image changed while it was sent", "integrity")
|
|
292
|
+
const bytes = decodeChunk(next.chunk.data)
|
|
293
|
+
const end = received + bytes.length
|
|
294
|
+
if (end > info.bytes || (next.chunk.last ? end !== info.bytes : end >= info.bytes)) throw new BadAnswer("The image's length does not match", "integrity")
|
|
295
|
+
whole.set(bytes, received)
|
|
296
|
+
received = end
|
|
297
|
+
last = next.chunk.last
|
|
298
|
+
}
|
|
299
|
+
if (createHash("sha256").update(whole).digest("hex") !== info.sha256) throw new BadAnswer("The image's SHA-256 does not match", "integrity")
|
|
300
|
+
const entry: Entry = { sessionID: input.sessionID, sha256: info.sha256, mime: info.mime, data: whole, filled: now(), revertAt: input.revertAt }
|
|
301
|
+
if (cacheable) remember(key, entry)
|
|
302
|
+
return fromCache(input, entry)
|
|
303
|
+
} catch (cause) {
|
|
304
|
+
if (cause instanceof BadAnswer) return failure(502, cause.code, cause.message)
|
|
305
|
+
if (cause instanceof DeadlineError) return failure(504, "timeout", cause.message)
|
|
306
|
+
throw cause
|
|
307
|
+
} finally {
|
|
308
|
+
release()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function forgetSession(sessionID: string): void {
|
|
313
|
+
for (const [key, entry] of cache) if (entry.sessionID === sessionID) forget(key)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { serve, forgetSession, cachedBytes: () => cached }
|
|
317
|
+
}
|