@pithy-sh/media 0.1.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/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +52 -0
- package/pithy.manifest.json +85 -0
- package/src/ai/enrich.ts +187 -0
- package/src/ai/videoBatching.ts +130 -0
- package/src/capability.ts +129 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +155 -0
- package/src/data/enums.ts +53 -0
- package/src/data/extend.ts +135 -0
- package/src/data/mediaAsset.ts +78 -0
- package/src/data/mediaHash.ts +24 -0
- package/src/data/tables.ts +45 -0
- package/src/deliver/url.ts +78 -0
- package/src/error/errors.ts +95 -0
- package/src/hash/duplicates.ts +129 -0
- package/src/hash/sha256.ts +34 -0
- package/src/http/dispatch.ts +86 -0
- package/src/http/guard.ts +25 -0
- package/src/http/handlers.ts +205 -0
- package/src/http/routes.ts +119 -0
- package/src/http/schemas.ts +90 -0
- package/src/index.ts +39 -0
- package/src/migrations/0001_init.ts +104 -0
- package/src/migrations/extend.ts +40 -0
- package/src/provision/provisionMedia.ts +226 -0
- package/src/provision/resolveMediaConfig.ts +78 -0
- package/src/record/d1Store.ts +95 -0
- package/src/record/hashStore.ts +104 -0
- package/src/record/kvStore.ts +147 -0
- package/src/record/resolve.ts +34 -0
- package/src/record/store.ts +50 -0
- package/src/secret/registry.ts +85 -0
- package/src/storage/backend.ts +29 -0
- package/src/storage/cloudflare.ts +77 -0
- package/src/storage/minter.ts +57 -0
- package/src/storage/resolve.ts +78 -0
- package/src/storage/storage.ts +130 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/enrich.ts +116 -0
- package/src/workflows/hls.ts +99 -0
- package/src/workflows/retryPolicy.ts +56 -0
- package/src/workflows/specs.ts +75 -0
- package/src/workflows/worker.ts +192 -0
- package/src/workflows/wrangler.jsonc +73 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MediaType } from "../data/enums";
|
|
6
|
+
import type { HashStore } from "../record/hashStore";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Duplicate detection — one exact key, one fuzzy key.
|
|
10
|
+
*
|
|
11
|
+
* Two records are the *same bytes* when their SHA-256 matches: an exact-duplicate is a single equality
|
|
12
|
+
* lookup. Two images are *visually alike* when their perceptual hashes are close: a near-duplicate is a
|
|
13
|
+
* Hamming distance under a threshold. This module ports the CMS dedup pass into pithy conventions — the
|
|
14
|
+
* exact check runs for every type, the fuzzy check runs only for images that carry a perceptual hash.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The default perceptual-hash distance under which two images count as near-duplicates. A Hamming distance
|
|
19
|
+
* of `0` is a pixel-identical image; small distances are crops, re-encodes, or watermarks; large distances
|
|
20
|
+
* are unrelated images. Five is a conservative starting point tuned in the CMS — override per call when a
|
|
21
|
+
* corpus wants a tighter or looser match.
|
|
22
|
+
*/
|
|
23
|
+
export const SIMILAR_THRESHOLD = 5;
|
|
24
|
+
|
|
25
|
+
/** How a perceptual-hash distance reads: an exact match, a near match under threshold, or unrelated. */
|
|
26
|
+
export type HammingResult = "identical" | "similar" | "different";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Classify a perceptual-hash distance. `0` is `identical` (the same hash); a distance within `threshold`
|
|
30
|
+
* is `similar` (a near-duplicate); anything further is `different`. The one place a raw distance becomes a
|
|
31
|
+
* decision, so the scan and any caller agree on where the lines fall.
|
|
32
|
+
*/
|
|
33
|
+
export function classifyDistance(distance: number, threshold = SIMILAR_THRESHOLD): HammingResult {
|
|
34
|
+
if (distance === 0) return "identical";
|
|
35
|
+
if (distance <= threshold) return "similar";
|
|
36
|
+
return "different";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Hamming distance between two equal-length hex perceptual hashes: the count of positions whose
|
|
41
|
+
* characters differ. Both hashes come from the same phash algorithm, so they are always the same length;
|
|
42
|
+
* a length mismatch means the inputs were produced differently and cannot be compared — an internal
|
|
43
|
+
* invariant, so it throws rather than returning a meaningless number.
|
|
44
|
+
*/
|
|
45
|
+
export function hammingDistance(a: string, b: string): number {
|
|
46
|
+
if (a.length !== b.length) {
|
|
47
|
+
throw new InternalError({
|
|
48
|
+
message: "Perceptual hashes must be equal length.",
|
|
49
|
+
detail: `lengths ${a.length} vs ${b.length}`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
let distance = 0;
|
|
53
|
+
for (let i = 0; i < a.length; i++) {
|
|
54
|
+
if (a[i] !== b[i]) distance++;
|
|
55
|
+
}
|
|
56
|
+
return distance;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A record that matches the query, with why: `identical` bytes (SHA-256) or a `similar` image (phash). */
|
|
60
|
+
export interface DuplicateCandidate {
|
|
61
|
+
/** The matching record's id. */
|
|
62
|
+
id: string;
|
|
63
|
+
/** The matching record's media type. */
|
|
64
|
+
mediaType: MediaType;
|
|
65
|
+
/** The perceptual-hash distance: `0` for an exact byte match, a small count for a near image match. */
|
|
66
|
+
distance: number;
|
|
67
|
+
/** Whether the match is the same bytes (`identical`) or a visually close image (`similar`). */
|
|
68
|
+
kind: "identical" | "similar";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The query for a duplicate scan: the new object's hashes, its type, and optional scan tuning. */
|
|
72
|
+
export interface FindDuplicatesParams {
|
|
73
|
+
/** The SHA-256 of the new object's bytes — the exact-duplicate key. */
|
|
74
|
+
sha256: string;
|
|
75
|
+
/** The new image's perceptual hash, when it has one. Absent for non-images; enables the near scan. */
|
|
76
|
+
phash?: string;
|
|
77
|
+
/** The new object's media type. The near scan runs only for `image`. */
|
|
78
|
+
type: MediaType;
|
|
79
|
+
/** Override the near-match distance threshold. Defaults to {@link SIMILAR_THRESHOLD}. */
|
|
80
|
+
threshold?: number;
|
|
81
|
+
/** Cap the number of candidates returned. Defaults to 10, closest first. */
|
|
82
|
+
limit?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find the records that duplicate a new object. Runs two passes against the D1 {@link HashStore} — dedup
|
|
87
|
+
* is always D1, whatever the record store (KV cannot serve these queries):
|
|
88
|
+
*
|
|
89
|
+
* 1. **Exact.** Every record with the same SHA-256 is a byte-identical duplicate (`kind: "identical"`,
|
|
90
|
+
* `distance: 0`), for any media type.
|
|
91
|
+
* 2. **Near.** Only for an `image` that carries a `phash`: compare against every stored image phash,
|
|
92
|
+
* skipping records already matched exactly and any hash of a different length, and keep the ones whose
|
|
93
|
+
* Hamming distance is not `different` (`kind: "similar"`).
|
|
94
|
+
*
|
|
95
|
+
* The two sets are combined — exact matches never double-counted as near — sorted closest first (exact
|
|
96
|
+
* matches lead at distance `0`), and capped to `limit`. Inputs are not mutated.
|
|
97
|
+
*/
|
|
98
|
+
export async function findDuplicates(hashes: HashStore, params: FindDuplicatesParams): Promise<DuplicateCandidate[]> {
|
|
99
|
+
const threshold = params.threshold ?? SIMILAR_THRESHOLD;
|
|
100
|
+
const limit = params.limit ?? 10;
|
|
101
|
+
|
|
102
|
+
// Pass 1 — exact byte matches by SHA-256. The id → type map also lets pass 2 skip records already exact.
|
|
103
|
+
const exactMatches = await hashes.findBySha256(params.sha256);
|
|
104
|
+
const exactIds = new Set<string>();
|
|
105
|
+
for (const match of exactMatches) exactIds.add(match.mediaId);
|
|
106
|
+
|
|
107
|
+
const exact: DuplicateCandidate[] = exactMatches.map((match) => ({
|
|
108
|
+
id: match.mediaId,
|
|
109
|
+
mediaType: match.mediaType,
|
|
110
|
+
distance: 0,
|
|
111
|
+
kind: "identical",
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Pass 2 — near image matches by perceptual hash. Only when this is an image with a phash to compare.
|
|
115
|
+
const near: DuplicateCandidate[] = [];
|
|
116
|
+
if (params.type === "image" && params.phash) {
|
|
117
|
+
const phash = params.phash;
|
|
118
|
+
for (const entry of await hashes.listImagePhashes()) {
|
|
119
|
+
if (exactIds.has(entry.mediaId)) continue;
|
|
120
|
+
if (entry.phash.length !== phash.length) continue;
|
|
121
|
+
const distance = hammingDistance(phash, entry.phash);
|
|
122
|
+
if (classifyDistance(distance, threshold) === "different") continue;
|
|
123
|
+
near.push({ id: entry.mediaId, mediaType: "image", distance, kind: "similar" });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Combine, closest first, capped. A stable sort keeps the exact matches (distance 0) ahead of near ones.
|
|
128
|
+
return [...exact, ...near].sort((a, b) => a.distance - b.distance).slice(0, limit);
|
|
129
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SHA-256 content hashing — the exact-duplicate key.
|
|
6
|
+
*
|
|
7
|
+
* Every stored object gets a SHA-256 of its bytes. Identical uploads produce an identical hash, so an
|
|
8
|
+
* exact-duplicate check is a single equality lookup on this value (see {@link findDuplicates}). The digest
|
|
9
|
+
* is computed with Web Crypto (`crypto.subtle`), which is a global in the Workers runtime and on Node 22+
|
|
10
|
+
* — no import, no dependency, same code everywhere the package runs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Lowercase hex alphabet, indexed by nibble. Precomputed so the encode loop does no per-byte formatting. */
|
|
14
|
+
const HEX = "0123456789abcdef";
|
|
15
|
+
|
|
16
|
+
/** Encode raw digest bytes as a lowercase-hex string. Two hex chars per byte, high nibble first. */
|
|
17
|
+
function toHex(bytes: Uint8Array): string {
|
|
18
|
+
let out = "";
|
|
19
|
+
for (const byte of bytes) {
|
|
20
|
+
out += HEX.charAt(byte >> 4) + HEX.charAt(byte & 0x0f);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute the lowercase-hex SHA-256 of a byte payload. Accepts a `Uint8Array` or a raw `ArrayBuffer`, so
|
|
27
|
+
* a caller can hand over either a decoded view or the buffer straight off an upload. The result is a
|
|
28
|
+
* stable 64-character hex string — the exact-duplicate key stored on every record.
|
|
29
|
+
*/
|
|
30
|
+
export async function computeSha256(bytes: Uint8Array | ArrayBuffer): Promise<string> {
|
|
31
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
32
|
+
const digest = await crypto.subtle.digest("SHA-256", view);
|
|
33
|
+
return toHex(new Uint8Array(digest));
|
|
34
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
5
|
+
import { noopLogger } from "@pithy-sh/core/src/logger/logger";
|
|
6
|
+
import { triggerWorkflow } from "@pithy-sh/core/src/workflow/dispatch";
|
|
7
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
8
|
+
import type { MediaConfig } from "../config/config";
|
|
9
|
+
import { isExtractableDocument } from "../data/enums";
|
|
10
|
+
import { MEDIA_CAPABILITY, mediaWorkflowRegistry } from "../workflows/specs";
|
|
11
|
+
import type { EnrichmentDispatcher } from "./handlers";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A Cloudflare Workflow binding this capability dispatches to. The enrichment Workflows live in the
|
|
15
|
+
* prebuilt media worker (`workflows/worker.ts`); the app worker holds their bindings and starts an
|
|
16
|
+
* instance on finalize.
|
|
17
|
+
*/
|
|
18
|
+
export interface EnrichmentWorkflowBinding {
|
|
19
|
+
/** Start a Workflow instance for one media record. */
|
|
20
|
+
create(options: { params: { id: string } }): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The enrichment Workflow bindings, each optional so a project that disables a feature omits it. */
|
|
24
|
+
export interface EnrichmentBindings {
|
|
25
|
+
/** Image → alt text / caption. */
|
|
26
|
+
MEDIA_IMAGE_TO_TEXT?: EnrichmentWorkflowBinding;
|
|
27
|
+
/** Audio → transcription. */
|
|
28
|
+
MEDIA_AUDIO_TRANSCRIBE?: EnrichmentWorkflowBinding;
|
|
29
|
+
/** Video → transcription (Stream readiness + HLS batching). */
|
|
30
|
+
MEDIA_VIDEO_TRANSCRIBE?: EnrichmentWorkflowBinding;
|
|
31
|
+
/** Document → extracted text. */
|
|
32
|
+
MEDIA_DOC_EXTRACT?: EnrichmentWorkflowBinding;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which job enriches which media type. The binding is no longer named here — the spec map owns it, so
|
|
37
|
+
* this table only has to answer "what runs for a video?".
|
|
38
|
+
*/
|
|
39
|
+
const JOB_FOR_TYPE = {
|
|
40
|
+
image: "image-to-text",
|
|
41
|
+
audio: "audio-transcribe",
|
|
42
|
+
video: "video-transcribe",
|
|
43
|
+
document: "doc-extract",
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the enrichment dispatcher: on finalize it starts the configured Workflow for the record's type,
|
|
48
|
+
* and only when the feature is enabled. Each AI feature is independently opt-in and a true no-op (no
|
|
49
|
+
* dispatch, no cost) when disabled.
|
|
50
|
+
*
|
|
51
|
+
* Dispatch itself goes through core's {@link triggerWorkflow} rather than reaching for `env.X?.create()`.
|
|
52
|
+
* That buys two things the hand-rolled version could not: the `{ id }` payload is validated against the
|
|
53
|
+
* job's own schema before the binding is touched, and an **absent** binding — an unprovisioned project —
|
|
54
|
+
* is logged rather than silently skipped. Enrichment quietly doing nothing, with no error and no log, was
|
|
55
|
+
* the harder failure to diagnose by far.
|
|
56
|
+
*/
|
|
57
|
+
export function makeEnrichmentDispatcher(
|
|
58
|
+
env: EnrichmentBindings,
|
|
59
|
+
config: MediaConfig,
|
|
60
|
+
log: Logger = noopLogger,
|
|
61
|
+
): EnrichmentDispatcher {
|
|
62
|
+
// The bindings are declared as named optional fields for the reader's sake; dispatch reads them by
|
|
63
|
+
// the spec's binding name, which is what a Worker env is.
|
|
64
|
+
const bindings = env as unknown as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
return async (record) => {
|
|
67
|
+
if (!enabledFor(record.type, record.filename, config)) return;
|
|
68
|
+
const key = workflowKey(MEDIA_CAPABILITY, JOB_FOR_TYPE[record.type]);
|
|
69
|
+
await triggerWorkflow(bindings, mediaWorkflowRegistry, key, { id: String(record.id) }, log);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether the record's enrichment feature is on — and, for a document, whether the file is extractable. */
|
|
74
|
+
function enabledFor(type: keyof typeof JOB_FOR_TYPE, filename: string, config: MediaConfig): boolean {
|
|
75
|
+
switch (type) {
|
|
76
|
+
case "image":
|
|
77
|
+
return config.images.imageToText;
|
|
78
|
+
case "audio":
|
|
79
|
+
return config.audio.transcribe;
|
|
80
|
+
case "video":
|
|
81
|
+
return config.video.transcribe;
|
|
82
|
+
case "document":
|
|
83
|
+
// Only pdf/doc/docx are extractable — never enqueue a Workflow for an unsupported extension.
|
|
84
|
+
return config.documents.extractText && isExtractableDocument(filename);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The media routes' identity gate. Every media route declares the `bearer`/`session` verification
|
|
10
|
+
* strategy; this middleware enforces it through the core `AuthContext` seam (`c.var.auth`), which
|
|
11
|
+
* `@pithy-sh/auth` populates. It never validates a credential itself — it only asserts one resolved,
|
|
12
|
+
* so the media package depends on the seam, not on auth internals. With no auth capability composed,
|
|
13
|
+
* `c.var.auth` is always null and every media route is denied — the correct default.
|
|
14
|
+
*/
|
|
15
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
16
|
+
return async (c, next) => {
|
|
17
|
+
if (!c.var.auth) {
|
|
18
|
+
throw new UnauthorizedError({
|
|
19
|
+
message: "Authentication required.",
|
|
20
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
await next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { fromZodError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import type { MediaConfig } from "../config/config";
|
|
7
|
+
import { BASE_COLUMN_NAMES } from "../data/extend";
|
|
8
|
+
import { MediaNotFoundError } from "../error/errors";
|
|
9
|
+
import { findDuplicates } from "../hash/duplicates";
|
|
10
|
+
import type { HashStore } from "../record/hashStore";
|
|
11
|
+
import type { MediaRecord, RecordStore } from "../record/store";
|
|
12
|
+
import { backendForType } from "../storage/backend";
|
|
13
|
+
import type { MediaStorage } from "../storage/storage";
|
|
14
|
+
import type { CreateMediaInput, DuplicatesInput, FinalizeMediaInput, ListMediaQuery } from "./schemas";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The media request handlers — pure functions over injected dependencies, so every branch is unit-tested
|
|
18
|
+
* against real D1/KV bindings without standing up a Worker. The route shell (`routes.ts`) resolves these
|
|
19
|
+
* deps from the request env. Every mutating route is gated by `requireAuth` at the router; ownership
|
|
20
|
+
* scoping is an adopter concern via an extension field (e.g. `userId`).
|
|
21
|
+
*
|
|
22
|
+
* Request shape is NOT validated here. Each route declares its own schema with
|
|
23
|
+
* `zValidator(target, Schema, validationHook)` and hands the handler the already-parsed value, so a
|
|
24
|
+
* malformed request is rejected before any dependency is resolved. What survives in these handlers is
|
|
25
|
+
* validation the schema cannot express: the R2 size rule (a cross-field, config-dependent check) and
|
|
26
|
+
* `validateRecord`, which validates the *assembled* record against the effective (adopter-extended)
|
|
27
|
+
* schema — neither is a property of the request body alone.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Triggers the configured enrichment Workflow(s) for a finalized record. A no-op when nothing applies. */
|
|
31
|
+
export type EnrichmentDispatcher = (record: MediaRecord) => Promise<void>;
|
|
32
|
+
|
|
33
|
+
/** The dependencies the handlers need, all injectable. */
|
|
34
|
+
export interface HandlerDeps {
|
|
35
|
+
/** The record store (D1 or KV), already bound to the effective schema. */
|
|
36
|
+
store: RecordStore;
|
|
37
|
+
/** The D1 dedup hash store — written on finalize, queried by duplicate search, cleared on delete. */
|
|
38
|
+
hashes: HashStore;
|
|
39
|
+
/** The storage seam (mint, delete, read). */
|
|
40
|
+
storage: MediaStorage;
|
|
41
|
+
/** The effective record schema (base + adopter extension) — validates the assembled record. */
|
|
42
|
+
schema: z.ZodObject;
|
|
43
|
+
/** The resolved media config. */
|
|
44
|
+
config: MediaConfig;
|
|
45
|
+
/** Triggers enrichment Workflows on finalize. */
|
|
46
|
+
dispatchEnrichment: EnrichmentDispatcher;
|
|
47
|
+
/** Mints a new record id. */
|
|
48
|
+
newId: () => string;
|
|
49
|
+
/** The current time. */
|
|
50
|
+
now: () => Date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The response to an upload-init: where to upload and the record's coordinates. */
|
|
54
|
+
export interface UploadInitResult {
|
|
55
|
+
/** The created record id. */
|
|
56
|
+
id: string;
|
|
57
|
+
/** The URL the client uploads the bytes to. */
|
|
58
|
+
uploadUrl: string;
|
|
59
|
+
/** The backend the bytes will live in. */
|
|
60
|
+
storageBackend: string;
|
|
61
|
+
/** The backend-specific storage handle. */
|
|
62
|
+
storageKey: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract only the adopter's extension fields from a create body: every key that is NOT a base column.
|
|
67
|
+
* Excluding the full base column set — not just the client-input keys — is a security boundary: it stops
|
|
68
|
+
* a client from smuggling a server-owned base field (`id`, `status`, `storageKey`, timestamps, derived
|
|
69
|
+
* text) in through the extension bag and overriding it (mass assignment).
|
|
70
|
+
*/
|
|
71
|
+
function splitExtension(parsed: Record<string, unknown>): Record<string, unknown> {
|
|
72
|
+
const extension: Record<string, unknown> = {};
|
|
73
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
74
|
+
if (!BASE_COLUMN_NAMES.has(key)) extension[key] = value;
|
|
75
|
+
}
|
|
76
|
+
return extension;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Validate an assembled record against the effective schema, mapping a Zod failure to a 400. */
|
|
80
|
+
function validateRecord(schema: z.ZodObject, record: MediaRecord): MediaRecord {
|
|
81
|
+
const result = schema.safeParse(record);
|
|
82
|
+
if (!result.success) throw fromZodError(result.error);
|
|
83
|
+
return result.data as MediaRecord;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Upload-init: mint a direct-upload URL, create a `pending` record (with the client-computed sha256/phash
|
|
88
|
+
* and any extension fields), and return where to upload. The bytes go straight to the backend — never
|
|
89
|
+
* through the Worker.
|
|
90
|
+
*/
|
|
91
|
+
export async function createMedia(deps: HandlerDeps, input: CreateMediaInput): Promise<UploadInitResult> {
|
|
92
|
+
const extension = splitExtension(input);
|
|
93
|
+
// An R2 presigned PUT signs the content length, so the client must upload exactly that many bytes — a
|
|
94
|
+
// size is required for any R2-backed upload (audio and documents always; images/video when `store: 'r2'`).
|
|
95
|
+
if (backendForType(input.type, deps.config) === "r2" && input.size == null) {
|
|
96
|
+
throw new ValidationError({
|
|
97
|
+
message: "A file size is required to upload this media type.",
|
|
98
|
+
detail: `size is required for the R2-backed ${input.type} upload; the presigned PUT signs the content length`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const id = deps.newId();
|
|
102
|
+
const target = await deps.storage.mintUpload({
|
|
103
|
+
type: input.type,
|
|
104
|
+
id,
|
|
105
|
+
contentType: input.contentType,
|
|
106
|
+
size: input.size ?? undefined,
|
|
107
|
+
});
|
|
108
|
+
const now = deps.now();
|
|
109
|
+
// Server-owned fields are written LAST so the extension bag (already stripped of base keys) can never
|
|
110
|
+
// override the minted id, the lifecycle status, or the storage coordinates — defense in depth.
|
|
111
|
+
const record = validateRecord(deps.schema, {
|
|
112
|
+
...extension,
|
|
113
|
+
id,
|
|
114
|
+
type: input.type,
|
|
115
|
+
status: "pending",
|
|
116
|
+
name: input.name,
|
|
117
|
+
filename: input.filename,
|
|
118
|
+
contentType: input.contentType,
|
|
119
|
+
size: input.size ?? null,
|
|
120
|
+
storageBackend: target.storageBackend,
|
|
121
|
+
storageKey: target.storageKey,
|
|
122
|
+
sha256: input.sha256 ?? null,
|
|
123
|
+
phash: input.phash ?? null,
|
|
124
|
+
width: input.width ?? null,
|
|
125
|
+
height: input.height ?? null,
|
|
126
|
+
altText: null,
|
|
127
|
+
caption: null,
|
|
128
|
+
transcription: null,
|
|
129
|
+
hasTranscription: false,
|
|
130
|
+
extractedText: null,
|
|
131
|
+
hasExtractedText: false,
|
|
132
|
+
createdAt: now,
|
|
133
|
+
updatedAt: now,
|
|
134
|
+
} as MediaRecord);
|
|
135
|
+
await deps.store.create(record);
|
|
136
|
+
return { id, uploadUrl: target.uploadUrl, storageBackend: target.storageBackend, storageKey: target.storageKey };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Finalize: mark the record `stored`, fold in any client-computed size/sha256/phash, and dispatch the
|
|
141
|
+
* configured enrichment Workflow(s). Returns the updated record.
|
|
142
|
+
*/
|
|
143
|
+
export async function finalizeMedia(deps: HandlerDeps, id: string, input: FinalizeMediaInput): Promise<MediaRecord> {
|
|
144
|
+
const existing = await deps.store.get(id);
|
|
145
|
+
if (!existing) throw new MediaNotFoundError({ detail: `finalize: no media record ${id}` });
|
|
146
|
+
const changes: Partial<MediaRecord> = { status: "stored", updatedAt: deps.now() };
|
|
147
|
+
if (input.size != null) changes.size = input.size;
|
|
148
|
+
if (input.sha256 != null) changes.sha256 = input.sha256;
|
|
149
|
+
if (input.phash != null) changes.phash = input.phash;
|
|
150
|
+
const updated = await deps.store.patch(id, changes);
|
|
151
|
+
// Write the dedup hash to D1 (always) now the upload is confirmed, so duplicate search can find it.
|
|
152
|
+
const sha256 = typeof updated.sha256 === "string" ? updated.sha256 : null;
|
|
153
|
+
if (sha256) {
|
|
154
|
+
const phash = typeof updated.phash === "string" ? updated.phash : null;
|
|
155
|
+
await deps.hashes.upsert({ mediaId: id, mediaType: updated.type, sha256, phash });
|
|
156
|
+
}
|
|
157
|
+
await deps.dispatchEnrichment(updated);
|
|
158
|
+
return updated;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Fetch one record by id, or 404. */
|
|
162
|
+
export async function getMedia(deps: HandlerDeps, id: string): Promise<MediaRecord> {
|
|
163
|
+
const record = await deps.store.get(id);
|
|
164
|
+
if (!record) throw new MediaNotFoundError({ detail: `get: no media record ${id}` });
|
|
165
|
+
return record;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** List records, newest first, optionally filtered by type. */
|
|
169
|
+
export function listMedia(deps: HandlerDeps, query: ListMediaQuery) {
|
|
170
|
+
return deps.store.list({ type: query.type, limit: query.limit, cursor: query.cursor });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Delete a record and best-effort delete its stored object. */
|
|
174
|
+
export async function deleteMedia(deps: HandlerDeps, id: string): Promise<{ id: string; deleted: true }> {
|
|
175
|
+
const record = await deps.store.get(id);
|
|
176
|
+
if (!record) throw new MediaNotFoundError({ detail: `delete: no media record ${id}` });
|
|
177
|
+
// Best-effort object cleanup — a storage hiccup must not leave the record undeletable.
|
|
178
|
+
await deps.storage
|
|
179
|
+
.deleteObject({ storageBackend: record.storageBackend, storageKey: record.storageKey })
|
|
180
|
+
.catch(() => {});
|
|
181
|
+
await deps.hashes.deleteByMedia(id);
|
|
182
|
+
await deps.store.delete(id);
|
|
183
|
+
return { id, deleted: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Find exact (sha256) and near (phash) duplicates of a file. Detection runs against the D1 hash store;
|
|
188
|
+
* each candidate is then hydrated from the record store so the caller gets the full record (a hash whose
|
|
189
|
+
* record was deleted out from under it is skipped).
|
|
190
|
+
*/
|
|
191
|
+
export async function searchDuplicates(deps: HandlerDeps, input: DuplicatesInput) {
|
|
192
|
+
const candidates = await findDuplicates(deps.hashes, {
|
|
193
|
+
type: input.type,
|
|
194
|
+
sha256: input.sha256,
|
|
195
|
+
phash: input.phash,
|
|
196
|
+
threshold: input.threshold,
|
|
197
|
+
limit: input.limit,
|
|
198
|
+
});
|
|
199
|
+
const matches = [];
|
|
200
|
+
for (const candidate of candidates) {
|
|
201
|
+
const record = await deps.store.get(candidate.id);
|
|
202
|
+
if (record) matches.push({ id: candidate.id, distance: candidate.distance, kind: candidate.kind, record });
|
|
203
|
+
}
|
|
204
|
+
return { matches };
|
|
205
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { zValidator } from "@hono/zod-validator";
|
|
5
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
7
|
+
import type { Context, Hono } from "hono";
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
import type { MediaConfig } from "../config/config";
|
|
10
|
+
import { type RecordStoreEnv, resolveHashStore, resolveRecordStore } from "../record/resolve";
|
|
11
|
+
import { resolveStorage, type StorageEnv } from "../storage/resolve";
|
|
12
|
+
import { type EnrichmentBindings, makeEnrichmentDispatcher } from "./dispatch";
|
|
13
|
+
import { requireAuth } from "./guard";
|
|
14
|
+
import {
|
|
15
|
+
createMedia,
|
|
16
|
+
deleteMedia,
|
|
17
|
+
finalizeMedia,
|
|
18
|
+
getMedia,
|
|
19
|
+
type HandlerDeps,
|
|
20
|
+
listMedia,
|
|
21
|
+
searchDuplicates,
|
|
22
|
+
} from "./handlers";
|
|
23
|
+
import { CreateMediaInput, DuplicatesInput, FinalizeMediaInput, ListMediaQuery, MediaIdParam } from "./schemas";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The media routes, their declared verification strategies, and what each accepts:
|
|
27
|
+
*
|
|
28
|
+
* POST /media → upload-init (bearer | session) json: CreateMediaInput
|
|
29
|
+
* POST /media/duplicates → duplicate search (bearer | session) json: DuplicatesInput
|
|
30
|
+
* POST /media/:id/finalize → finalize upload (bearer | session) param: MediaIdParam, json: FinalizeMediaInput
|
|
31
|
+
* GET /media/:id → fetch one (bearer | session) param: MediaIdParam
|
|
32
|
+
* GET /media → list (bearer | session) query: ListMediaQuery
|
|
33
|
+
* DELETE /media/:id → delete (bearer | session) param: MediaIdParam
|
|
34
|
+
*
|
|
35
|
+
* Every route is gated by {@link requireAuth} — there is no public media surface. The validators sit
|
|
36
|
+
* AFTER the guard on purpose: an unauthenticated request with a malformed body is a 401, not a 400 —
|
|
37
|
+
* shape is never leaked to a caller who has not been verified. Bytes never proxy through the Worker:
|
|
38
|
+
* upload-init returns a direct-upload URL the client uploads to. Ownership scoping (e.g. filtering by a
|
|
39
|
+
* `userId` extension field) is an adopter concern layered over these routes.
|
|
40
|
+
*/
|
|
41
|
+
export interface MediaRoutesOptions {
|
|
42
|
+
/** The resolved media config. */
|
|
43
|
+
config: MediaConfig;
|
|
44
|
+
/** The effective record schema (base + adopter extension). */
|
|
45
|
+
schema: z.ZodObject;
|
|
46
|
+
/** The path the routes mount under. Defaults to `/media`. */
|
|
47
|
+
basePath?: string;
|
|
48
|
+
/** Test seam: resolve handler deps from the request context. Defaults to the env-based resolver. */
|
|
49
|
+
resolveDeps?: (c: Context<PithyHonoEnv>) => Promise<HandlerDeps>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The full request env the media routes read. */
|
|
53
|
+
type MediaEnv = RecordStoreEnv & StorageEnv & EnrichmentBindings;
|
|
54
|
+
|
|
55
|
+
/** Build the default per-request dependency resolver from config and the effective schema. */
|
|
56
|
+
function defaultResolveDeps(
|
|
57
|
+
config: MediaConfig,
|
|
58
|
+
schema: z.ZodObject,
|
|
59
|
+
): (c: Context<PithyHonoEnv>) => Promise<HandlerDeps> {
|
|
60
|
+
return async (c) => {
|
|
61
|
+
const env = c.env as unknown as MediaEnv;
|
|
62
|
+
const store = resolveRecordStore(env, config, schema);
|
|
63
|
+
const hashes = resolveHashStore(env);
|
|
64
|
+
const storage = await resolveStorage(env, config);
|
|
65
|
+
return {
|
|
66
|
+
store,
|
|
67
|
+
hashes,
|
|
68
|
+
storage,
|
|
69
|
+
schema,
|
|
70
|
+
config,
|
|
71
|
+
// The request logger, so an enrichment skipped for a missing binding says so in the request's log.
|
|
72
|
+
dispatchEnrichment: makeEnrichmentDispatcher(env, config, c.var.log),
|
|
73
|
+
newId: () => crypto.randomUUID(),
|
|
74
|
+
now: () => new Date(),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Register the media sub-router. Returned as the capability's `routes` hook. */
|
|
80
|
+
export function registerMediaRoutes(options: MediaRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
81
|
+
const base = options.basePath ?? "/media";
|
|
82
|
+
const resolve = options.resolveDeps ?? defaultResolveDeps(options.config, options.schema);
|
|
83
|
+
|
|
84
|
+
return (app) => {
|
|
85
|
+
app.post(`${base}/duplicates`, requireAuth(), zValidator("json", DuplicatesInput, validationHook), async (c) => {
|
|
86
|
+
const result = await searchDuplicates(await resolve(c), c.req.valid("json"));
|
|
87
|
+
return c.json(result);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
app.post(base, requireAuth(), zValidator("json", CreateMediaInput, validationHook), async (c) => {
|
|
91
|
+
const result = await createMedia(await resolve(c), c.req.valid("json"));
|
|
92
|
+
return c.json(result, 201);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
app.post(
|
|
96
|
+
`${base}/:id/finalize`,
|
|
97
|
+
requireAuth(),
|
|
98
|
+
zValidator("param", MediaIdParam, validationHook),
|
|
99
|
+
zValidator("json", FinalizeMediaInput, validationHook),
|
|
100
|
+
async (c) => {
|
|
101
|
+
const record = await finalizeMedia(await resolve(c), c.req.valid("param").id, c.req.valid("json"));
|
|
102
|
+
return c.json(record);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
app.get(`${base}/:id`, requireAuth(), zValidator("param", MediaIdParam, validationHook), async (c) => {
|
|
107
|
+
const record = await getMedia(await resolve(c), c.req.valid("param").id);
|
|
108
|
+
return c.json(record);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
app.get(base, requireAuth(), zValidator("query", ListMediaQuery, validationHook), async (c) => {
|
|
112
|
+
return c.json(await listMedia(await resolve(c), c.req.valid("query")));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
app.delete(`${base}/:id`, requireAuth(), zValidator("param", MediaIdParam, validationHook), async (c) => {
|
|
116
|
+
return c.json(await deleteMedia(await resolve(c), c.req.valid("param").id));
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
}
|