From 7ccc96ac111f1116082719a1f2799b5a1e061356 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:34:04 +0000 Subject: [PATCH] feat(media): shared geo, path-safety and profile loading helpers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj --- packages/media/src/geo.ts | 63 ++++++++++--- packages/media/src/node.ts | 86 ++++++++++++++++-- packages/media/src/profiles.ts | 60 ++++++++++--- packages/media/src/shared.test.ts | 144 ++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 31 deletions(-) create mode 100644 packages/media/src/shared.test.ts diff --git a/packages/media/src/geo.ts b/packages/media/src/geo.ts index 2efc882..0cda7a6 100644 --- a/packages/media/src/geo.ts +++ b/packages/media/src/geo.ts @@ -1,28 +1,65 @@ -import { notImplemented } from "./not-implemented.js"; +const EARTH_RADIUS_KM = 6371.0088; + +function toRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} /** Great-circle distance in kilometres. */ export function haversineKm( - _a: { lat: number; lon: number }, - _b: { lat: number; lon: number }, + a: { lat: number; lon: number }, + b: { lat: number; lon: number }, ): number { - return notImplemented("haversineKm"); + const dLat = toRadians(b.lat - a.lat); + const dLon = toRadians(b.lon - a.lon); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRadians(a.lat)) * Math.cos(toRadians(b.lat)) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); } /** Round a coordinate to two decimals (about 1 km) before storage or display. */ -export function roundCoordinate(_value: number): number { - return notImplemented("roundCoordinate"); +export function roundCoordinate(value: number): number { + return Math.round(value * 100) / 100; } -/** Cosine similarity of two equal-length vectors. */ -export function cosineSimilarity(_a: ArrayLike, _b: ArrayLike): number { - return notImplemented("cosineSimilarity"); +/** Cosine similarity of two equal-length vectors; 0 when either is all zeros. */ +export function cosineSimilarity(a: ArrayLike, b: ArrayLike): number { + if (a.length !== b.length) throw new Error("vector length mismatch"); + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + const x = a[i] as number; + const y = b[i] as number; + dot += x * y; + normA += x * x; + normB += y * y; + } + if (normA === 0 || normB === 0) return 0; + return dot / Math.sqrt(normA * normB); } /** Encode Float32 vectors as little-endian bytes for MediaAnalysis.vectors and centroids. */ -export function encodeVectors(_vectors: number[][]): Uint8Array { - return notImplemented("encodeVectors"); +export function encodeVectors(vectors: ArrayLike[]): Uint8Array { + const dim = vectors[0]?.length ?? 0; + const bytes = new Uint8Array(vectors.length * dim * 4); + const view = new DataView(bytes.buffer); + vectors.forEach((vector, row) => { + if (vector.length !== dim) throw new Error("vector length mismatch"); + for (let i = 0; i < dim; i++) view.setFloat32((row * dim + i) * 4, vector[i] as number, true); + }); + return bytes; } -export function decodeVectors(_bytes: Uint8Array, _dim: number): Float32Array[] { - return notImplemented("decodeVectors"); +export function decodeVectors(bytes: Uint8Array, dim: number): Float32Array[] { + if (dim <= 0 || bytes.byteLength % (dim * 4) !== 0) throw new Error("invalid vector bytes"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const rows = bytes.byteLength / (dim * 4); + const out: Float32Array[] = []; + for (let row = 0; row < rows; row++) { + const vector = new Float32Array(dim); + for (let i = 0; i < dim; i++) vector[i] = view.getFloat32((row * dim + i) * 4, true); + out.push(vector); + } + return out; } diff --git a/packages/media/src/node.ts b/packages/media/src/node.ts index 5d6911a..d316369 100644 --- a/packages/media/src/node.ts +++ b/packages/media/src/node.ts @@ -1,18 +1,88 @@ +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { PlatformProfile } from "@rakazo/contracts"; -import { notImplemented } from "./not-implemented.js"; import type { ProfileLoadError } from "./profiles.js"; +import { parseProfiles } from "./profiles.js"; /** Directory of the profile JSON files shipped with this package. */ -export const BUILTIN_PROFILES_DIR = new URL("../profiles/", import.meta.url); +export const BUILTIN_PROFILES_DIR = fileURLToPath(new URL("../profiles/", import.meta.url)); -/** Read every *.json in a directory; missing directory yields no profiles. */ +/** Read every *.json in a directory; a missing directory yields no profiles. */ export async function loadProfilesFromDir( - _dir: string | URL, + dir: string, ): Promise<{ profiles: PlatformProfile[]; errors: ProfileLoadError[] }> { - return notImplemented("loadProfilesFromDir"); + let names: string[]; + try { + names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { profiles: [], errors: [] }; + throw error; + } + const documents: Array<{ file: string; json: unknown }> = []; + const errors: ProfileLoadError[] = []; + for (const name of names) { + const file = path.join(dir, name); + try { + documents.push({ file: name, json: JSON.parse(await readFile(file, "utf8")) }); + } catch (error) { + errors.push({ file: name, message: error instanceof Error ? error.message : String(error) }); + } + } + const parsed = parseProfiles(documents); + return { profiles: parsed.profiles, errors: [...errors, ...parsed.errors] }; } -/** Resolve a path inside root; rejects traversal and symlinks that escape it. */ -export async function resolveInside(_root: string, _relative: string): Promise { - return notImplemented("resolveInside"); +/** Expand a leading "~/" against the given home directory. */ +export function expandHome(input: string, home: string): string { + return input === "~" ? home : input.startsWith("~/") ? path.join(home, input.slice(2)) : input; +} + +export class PathOutsideRootError extends Error { + constructor(readonly requested: string) { + super("path is outside the allowed root"); + this.name = "PathOutsideRootError"; + } +} + +function isInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** + * Resolve `relative` inside `root`. Rejects absolute inputs, traversal, NUL + * bytes, and any existing path whose real location (after symlinks) leaves the + * real root. A path that does not exist yet is checked through its nearest + * existing ancestor. + */ +export async function resolveInside(root: string, relative: string): Promise { + if (relative.includes("\0") || path.isAbsolute(relative)) + throw new PathOutsideRootError(relative); + const realRoot = await realpath(root); + const lexical = path.resolve(realRoot, relative); + if (!isInside(realRoot, lexical)) throw new PathOutsideRootError(relative); + let probe = lexical; + for (;;) { + try { + await lstat(probe); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = path.dirname(probe); + if (parent === probe) throw new PathOutsideRootError(relative); + probe = parent; + } + } + const real = await realpath(probe); + if (!isInside(realRoot, real)) throw new PathOutsideRootError(relative); + return lexical; +} + +/** Like resolveInside for an absolute path that must lie within root. */ +export async function assertInside(root: string, absolute: string): Promise { + if (!path.isAbsolute(absolute)) throw new PathOutsideRootError(absolute); + const realRoot = await realpath(root); + const relative = path.relative(realRoot, path.resolve(absolute)); + return resolveInside(realRoot, relative === "" ? "." : relative); } diff --git a/packages/media/src/profiles.ts b/packages/media/src/profiles.ts index c78fe82..3cfb77d 100644 --- a/packages/media/src/profiles.ts +++ b/packages/media/src/profiles.ts @@ -1,5 +1,5 @@ import type { PlatformProfile, PlatformProfileSummary } from "@rakazo/contracts"; -import { notImplemented } from "./not-implemented.js"; +import { PlatformProfileSchema } from "@rakazo/contracts"; export const PROFILE_STALE_DAYS = 90; @@ -14,25 +14,65 @@ export interface ProfileSet { } /** Parse raw JSON documents; invalid ones become errors and are skipped whole. */ -export function parseProfiles(_documents: Array<{ file: string; json: unknown }>): { +export function parseProfiles(documents: Array<{ file: string; json: unknown }>): { profiles: PlatformProfile[]; errors: ProfileLoadError[]; } { - return notImplemented("parseProfiles"); + const profiles: PlatformProfile[] = []; + const errors: ProfileLoadError[] = []; + const seen = new Set(); + for (const document of documents) { + const parsed = PlatformProfileSchema.safeParse(document.json); + if (!parsed.success) { + errors.push({ + file: document.file, + message: parsed.error.issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("; "), + }); + continue; + } + if (seen.has(parsed.data.id)) { + errors.push({ file: document.file, message: `duplicate profile id ${parsed.data.id}` }); + continue; + } + seen.add(parsed.data.id); + profiles.push(parsed.data); + } + return { profiles, errors }; } /** Private profiles override builtin ones with the same id. */ export function mergeProfiles( - _builtin: PlatformProfile[], - _private: PlatformProfile[], + builtin: PlatformProfile[], + privateProfiles: PlatformProfile[], ): ProfileSet { - return notImplemented("mergeProfiles"); + const profiles: ProfileSet["profiles"] = new Map(); + for (const profile of builtin) profiles.set(profile.id, { profile, origin: "builtin" }); + for (const profile of privateProfiles) profiles.set(profile.id, { profile, origin: "private" }); + return { profiles, errors: [] }; +} + +export function isProfileStale(profile: PlatformProfile, now: Date): boolean { + const reviewed = Date.parse(`${profile.reviewedAt}T00:00:00Z`); + return now.getTime() - reviewed > PROFILE_STALE_DAYS * 24 * 60 * 60 * 1000; } export function summarizeProfile( - _profile: PlatformProfile, - _origin: "builtin" | "private", - _now: Date, + profile: PlatformProfile, + origin: "builtin" | "private", + now: Date, ): PlatformProfileSummary { - return notImplemented("summarizeProfile"); + return { + id: profile.id, + name: profile.name, + kind: profile.kind, + origin, + unverified: profile.unverified, + stale: isProfileStale(profile, now), + aspectRatios: profile.video.aspectRatios, + hardMinMs: profile.duration.hardMinMs, + hardMaxMs: profile.duration.hardMaxMs, + recommendedMs: profile.duration.recommended, + }; } diff --git a/packages/media/src/shared.test.ts b/packages/media/src/shared.test.ts new file mode 100644 index 0000000..00ff282 --- /dev/null +++ b/packages/media/src/shared.test.ts @@ -0,0 +1,144 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + cosineSimilarity, + decodeVectors, + encodeVectors, + haversineKm, + roundCoordinate, +} from "./geo.js"; +import { + assertInside, + expandHome, + loadProfilesFromDir, + PathOutsideRootError, + resolveInside, +} from "./node.js"; +import { mergeProfiles, parseProfiles, summarizeProfile } from "./profiles.js"; + +const profileJson = (id: string, overrides: Record = {}) => ({ + id, + name: `Profile ${id}`, + kind: "free_social", + reviewedAt: "2026-09-01", + video: { + aspectRatios: ["9:16"], + width: 1080, + height: 1920, + fps: 30, + codec: "h264", + maxBitrateKbps: 8000, + maxFileSizeMb: 500, + }, + duration: { hardMinMs: 1000, hardMaxMs: 60000, recommended: [15000] }, + safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 10 }, + captions: { recommended: true }, + audio: {}, + text: { captionMaxChars: 2200, hashtagLimit: 5, linkPolicy: "bio_only" }, + content: { thresholds: { explicit_nudity: 0.2 } }, + disclosure: { aiAlteredLabelRequired: true }, + consent: { requirePerformerConsent: false, requireAgeVerification: false }, + export: {}, + ...overrides, +}); + +describe("geo", () => { + it("measures distance and rounds coordinates", () => { + expect(haversineKm({ lat: 52.52, lon: 13.405 }, { lat: 48.137, lon: 11.575 })).toBeCloseTo( + 504, + -1, + ); + expect(haversineKm({ lat: 1, lon: 1 }, { lat: 1, lon: 1 })).toBe(0); + expect(roundCoordinate(52.51678)).toBe(52.52); + }); + + it("computes cosine similarity and round-trips vectors", () => { + expect(cosineSimilarity([1, 0], [1, 0])).toBe(1); + expect(cosineSimilarity([1, 0], [0, 1])).toBe(0); + expect(cosineSimilarity([0, 0], [1, 1])).toBe(0); + expect(() => cosineSimilarity([1], [1, 2])).toThrow(); + const decoded = decodeVectors( + encodeVectors([ + [1, 2.5], + [-3, 0], + ]), + 2, + ); + expect(Array.from(decoded[1] ?? [])).toEqual([-3, 0]); + expect(() => decodeVectors(new Uint8Array(6), 2)).toThrow(); + }); +}); + +describe("profiles", () => { + it("skips invalid and duplicate documents whole", () => { + const result = parseProfiles([ + { file: "a.json", json: profileJson("a") }, + { file: "b.json", json: { id: "b" } }, + { file: "a2.json", json: profileJson("a") }, + { file: "c.json", json: profileJson("c", { duration: { hardMinMs: 9, hardMaxMs: 1 } }) }, + ]); + expect(result.profiles.map((p) => p.id)).toEqual(["a"]); + expect(result.errors.map((e) => e.file)).toEqual(["b.json", "a2.json", "c.json"]); + }); + + it("lets private profiles override builtin ones and flags stale ones", () => { + const builtin = parseProfiles([{ file: "x", json: profileJson("shared") }]).profiles; + const own = parseProfiles([ + { file: "y", json: profileJson("shared", { name: "Mine" }) }, + ]).profiles; + const merged = mergeProfiles(builtin, own); + expect(merged.profiles.get("shared")?.origin).toBe("private"); + expect(merged.profiles.get("shared")?.profile.name).toBe("Mine"); + const summary = summarizeProfile(builtin[0]!, "builtin", new Date("2027-01-15T00:00:00Z")); + expect(summary.stale).toBe(true); + expect(summarizeProfile(builtin[0]!, "builtin", new Date("2026-09-20T00:00:00Z")).stale).toBe( + false, + ); + }); +}); + +describe("node helpers", () => { + let root: string; + let outside: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "media-root-")); + outside = await mkdtemp(path.join(tmpdir(), "media-outside-")); + await mkdir(path.join(root, "a")); + await writeFile(path.join(root, "a", "f.txt"), "x"); + await symlink(outside, path.join(root, "escape")); + await writeFile(path.join(root, "good.json"), JSON.stringify(profileJson("good"))); + await writeFile(path.join(root, "broken.json"), "{"); + }); + + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + it("resolves paths inside the root and rejects escapes", async () => { + expect(await resolveInside(root, "a/f.txt")).toMatch(/a\/f\.txt$/); + expect(await resolveInside(root, "a/new/file.mp4")).toMatch(/a\/new\/file\.mp4$/); + await expect(resolveInside(root, "../x")).rejects.toBeInstanceOf(PathOutsideRootError); + await expect(resolveInside(root, "/etc/passwd")).rejects.toBeInstanceOf(PathOutsideRootError); + await expect(resolveInside(root, "escape/secret")).rejects.toBeInstanceOf(PathOutsideRootError); + await expect(resolveInside(root, "a/\0x")).rejects.toBeInstanceOf(PathOutsideRootError); + await expect(assertInside(root, path.join(outside, "x"))).rejects.toBeInstanceOf( + PathOutsideRootError, + ); + expect(await assertInside(root, path.join(root, "a"))).toMatch(/\/a$/); + }); + + it("loads profile directories and reports broken files", async () => { + const loaded = await loadProfilesFromDir(root); + expect(loaded.profiles.map((p) => p.id)).toEqual(["good"]); + expect(loaded.errors.map((e) => e.file)).toEqual(["broken.json"]); + expect(await loadProfilesFromDir(path.join(root, "missing"))).toEqual({ + profiles: [], + errors: [], + }); + expect(expandHome("~/x", "/home/u")).toBe("/home/u/x"); + }); +});