openvibers/openvibe.media Commits
Media stops reading the frozen paste tables (screenshots and avatars are objects) (#75)
Changes
@@@ -8,7 +8,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tieringobject model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,`network`, `community`) and developer projects. Pastes moved to OpenVibe.Communityon 2026-09-22; Media's read-only paste API is gone (T10 step 2), `/p/:slug` only--redirects there and it serves the frozen rows' old screenshots ([Pastes](#pastes)).++redirects there and legacy screenshots redirect to media objects ([Pastes](#pastes)).Implements **Media API v1** from `../CONTRACTS.md`.@@@ -35,7 +35,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg## Does not own--- pastes since 2026-09-22 (OpenVibe.Community; Media keeps the frozen rows and serves old screenshots)++- pastes since 2026-09-22 (OpenVibe.Community; Media keeps legacy screenshot files through media objects)- channels, streams and who a VOD belongs to (OpenVibe.Live answers lineage), identity (OpenVibe.Network)- AI analysis of recordings (OpenVibe.AI reads them through signed URLs)@@@ -94,7 +94,6 @@ server/vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLIvod/health-scanner.js probe/decode/master-recovery primitivesvod/health-job.js background health scan + quarantine cleanup + master sweep-- pastes/storage.js screenshot dir + slug minting kept after the pastes API was retiredfiles/routes.js /api/v1/:app/filesadmin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)@@@ -296,23 +295,20 @@ deleted through Media any more.`/p/:slug` and `/p/:slug/raw` are unconditional **301s** to Community: the targetis `config.pastes.movedTo` (`PASTES_MOVED_TO`, default `https://openvibe.community`,so the redirect never depends on an env var) and no row is read — a private or--missing slug redirects exactly like a public one. Screenshot bytes are still served--from here, and new screenshots are uploaded to the token-only `community` tenant.--Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,--`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and--`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so--Media has no API that deletes them.----`server/pastes/storage.js` stays as a leaf: the avatar ingest still mints slugs--and writes screenshots into the same directory.++missing slug redirects exactly like a public one. Legacy screenshot and avatar objects are++found by `legacy_ref` and `/p/:slug/screenshot` redirects to `/o/<id>` when they are++public or unlisted and ready. Other slugs redirect to Community. Avatar ingest stores++new avatars as native objects in Live's avatars namespace. Legacy files remain under++`PASTES_PATH` and are named by their objects' local locations. Legacy screenshot and++avatar objects are deleted through account deletion; direct v2 deletion answers 409.| method | path | notes ||---|---|---|| GET | `/p/:slug` | always 301 to OpenVibe.Community — pastes live there; no row is read || GET | `/p/:slug/raw` | always 301 to Community's `/p/:slug/raw`; the suffix is preserved |--| GET | `/p/:slug/screenshot` | frozen screenshot bytes; unknown/private slugs 301 to Community |++| GET | `/p/:slug/screenshot` | 301 to the legacy object when public or unlisted and ready; otherwise 301 to Community |--AI summary/tags columns remain for imported rows.++Legacy paste tables remain in the database until the later contract migration, but Media does not query them.### Files@@@ -339,7 +335,7 @@ directories are shared across apps) and responses carry a `note` saying so.| method | path | notes ||---|---|---|--| GET | `/admin/storage` | `{ disk (df of VOD volume), database.bytes, breakdown [vods/clips/pastes/thumbnails/files dirs], vodStats/clipStats/pasteStats/fileStats (app-scoped), byProvider.{vods,clips} (count+bytes per local/b2/r2) }` |++| GET | `/admin/storage` | `{ disk (df of VOD volume), database.bytes, breakdown [vods/clips/pastes/thumbnails/files dirs], vodStats/clipStats/fileStats (app-scoped), byProvider.{vods,clips} (count+bytes per local/b2/r2) }` || GET | `/admin/storage/vods?limit&offset&sort&order&provider&tier` | detailed app VOD listing: id, title, size, provider, health, views, `last_accessed_at`, created + `fileExists`/`diskSize`/`actualTier` (disk-reconciled: local/b2/r2/missing), per-user summary. `sort` = size\|date\|duration\|tier\|views\|accessed, `order` = asc\|desc, `provider` = local\|b2\|r2 (`tier` accepts legacy hot/cold aliases) || DELETE | `/admin/storage/vods/bulk` | `{ ids: [...] }` (max 200) — deletes each VOD everywhere (local + B2 + R2 + sidecars/master + thumbnail + row), app-scoped → `{ deleted, freed, results: [{id, ok, error?}] }` || GET | `/admin/storage/tiers` | tiering status: settings, provider health, local disk, service-wide tier counts (`tiers`/`clipTiers`), `sweepRunning`, plus `app.{tiers, pendingOffload}` scoped to the caller |@@@ -531,7 +527,7 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.| `GET /c/:id` | clip playback, same logic, `noindex` || `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) || `GET /p/:slug/raw` | **301** to Community's raw text |--| `GET /p/:slug/screenshot` | paste screenshot image |++| `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community || `GET /t/:id` | thumbnail (id = filename), `noindex` || `GET /f/:key` | file with stored Content-Type + Range, `noindex` || `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |@@@ -573,8 +569,7 @@ counts its viewer for the day, identifying no one (see Storage tiering).Every stored blob is a **media object** (`med_<ULID>`, `media_objects`) with one`media_locations` row per copy (local / B2 canonical / R2 cache, each `present`,--`missing`, `pending` or `corrupt`). The `vods`, `clips`, `files` and screenshot--`pastes` rows are typed projections over objects (`object_id` column); every++`missing`, `pending` or `corrupt`). The `vods`, `clips` and `files` rows are typed projections over objects (`object_id` column); everyexisting route and response is unchanged, and the old write paths keep themodel current. Full reference: **[docs/object-model.md](docs/object-model.md)**.@@@ -2,12 +2,12 @@Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into one canonical objectplatform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited--`vods`, `clips`, `files` and screenshot `pastes` rows stay as they are, and each one is now a typed--projection over an object through its `object_id` column. Pastes moved to OpenVibe.Community on--2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only, and--its v1 pastes API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted; only the public--`/p/:slug` routes remain). Other existing URLs and v1 responses are--unchanged. New code talks to objects through the [v2 API](#object-api-v2).++`vods`, `clips` and `files` rows are typed projections over objects through their++`object_id` columns. Pastes moved to OpenVibe.Community on 2026-09-22. Media does not++read or write its frozen paste tables. Legacy screenshot and avatar objects are found++by `legacy_ref`, and `/p/:slug/screenshot` redirects to `/o/<id>` for ready, nonprivate++objects. New avatars are native objects. New code talks to objects through the++[v2 API](#object-api-v2).Code: `server/objects/` (`model.js`, `routes.js`, `reconcile.js`, `invariant.js`,`signing.js`, `verify-job.js`, `copy-report.js`, `multipart.js`, `content-type.js`, `readiness.js`, `namespaces.js`,@@@ -54,12 +54,11 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu| `clips.id` | clip | `legacy:<app>:clip:<id>` || `files.key` | file | `legacy:<app>:file:<key>` || screenshot paste | screenshot | `legacy:<app>:paste:<slug>` |--| avatar (screenshot paste with `metadata.kind = 'avatar'`) | avatar | `legacy:<app>:avatar:<slug>` |++| legacy avatar | avatar | `legacy:<app>:avatar:<slug>` || `vods`/`clips.thumbnail_url` (`/t/<name>`) | thumbnail | `legacy:<app>:thumbnail:<name>` |--Paste rows are frozen since pastes moved to OpenVibe.Community (2026-09-22): no new screenshot or--avatar objects are created through Media's paste API, and the existing ones cannot be deleted--(`DELETE` answers 409 `media.object.legacy_managed`).++Legacy screenshot and avatar objects cannot be deleted through the direct v2 delete route++(`DELETE` answers 409 `media.object.legacy_managed`). Account deletion removes them.Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.@@@ -77,7 +76,7 @@ Every v2 route that takes `:id` accepts either form. Native v2 objects have `leg- Visibility: `visibility`, falling back to `is_public`, the same way playback decides.- Clips-only (ephemeral) recordings are never projected.- **file.** The local file under `FILES_PATH/<app>/<key>`. `visibility = public`, because `/f/:key` serves every file without auth. The row's sha256 becomes both `content_hash` and the location `checksum`.--- **screenshot / avatar.** `screenshot_path`. Text pastes have no bytes and no object; paste text belongs to Community.++- **legacy screenshot / avatar.** Their local file is named by a `media_locations` row. Text pastes belong to Community.- **thumbnail.** One object per vod/clip, reached through the parent's `thumbnail` variant and **updated in place** when the picture is regenerated (live recordings refresh it every couple of minutes). Visibility is `public` when the parent is public and `unlisted` otherwise, because `/t/<name>` serves anyone who has the name. An external `thumbnail_url` is skipped.The historical projection of imported rows is preserved for tests in `test/helpers/project-rows.js`. It is not a production path.@@@ -107,7 +106,7 @@ after the outcome event.| avatar ingest | `avatars/ingest` || tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep: the copy the move verified is marked `present` in the same transaction |--**Deletes need nothing more.** A PostgreSQL trigger (defined in `migrations/`, PL/pgSQL) on `vods`, `clips`, `files` and `pastes` marks the++**Deletes need nothing more.** A PostgreSQL trigger (defined in `migrations/`, PL/pgSQL) on `vods`, `clips` and `files` marks theobject `deleted` in the same statement whenever its row is deleted, which covers all of theinherited delete paths.@@@ -117,7 +116,7 @@ reconciliation verifies them. Two writes record something that already happeneddatabase, so they never lose the row to its object: the recorder's start (ffmpeg is running) and thetier moves (the bytes have moved). If their combined transaction fails, the row is written alone andre-projected after it (`withObjectOrRow`), with a warning. After a move that removed the local file, a follow-up re-projection re-reads the disk. Columns no--object carries (view counts, `master_file_path`, `cut_attempts`, paste likes and AI fields) are++object carries (view counts, `master_file_path`, `cut_attempts`, other app fields) arewritten without one. `db.importLegacyRows` is the historical cutover import.### C-75 retirement@@@ -474,11 +473,10 @@ While any hold applies:- **No delete path works.**- v2 `DELETE` answers 409.-- - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2, so a paste row is only ever deleted by SQL, which the trigger refuses).++ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2).- Quarantine cleanup and the boot junk sweep skip the object.- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).- `deleteVodObjects` keeps the bytes.-- - A paste screenshot has no removal path left at all: the paste API and, with it, the burn-after-read delete went in T10.- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.@@@ -539,8 +537,8 @@ or a row.| section | what it lists ||---|---|--| `unreferenced_local` | files under `VOD_PATH`, `CLIPS_PATH`, `THUMBNAILS_PATH`, `FILES_PATH`, `PASTES_PATH`, `OBJECTS_PATH`, `ASSETS_PATH` that no row names (`media_locations`, `vods` with their sidecars, masters and chunk segments, `clips`, `files`, `pastes`, `assets`, open upload sessions, unfinished jobs). A file whose only row is a deleted object's location counts, unless that object is native and inside its retention period, or held. Largest first, with a hint: a recorder name whose VOD row is gone, bytes of a deleted object, parts of a session that no longer exists, a temp file of an upload that never finished |--| `unreferenced_remote` | B2/R2 keys that no `media_locations` row and no `vods`/`clips` row names; hints for `vods-orphans/` (see below), an R2 copy left behind after its VOD went back to B2, legacy paste screenshots, recorder names |++| `unreferenced_local` | files under `VOD_PATH`, `CLIPS_PATH`, `THUMBNAILS_PATH`, `FILES_PATH`, `PASTES_PATH`, `OBJECTS_PATH`, `ASSETS_PATH` that no row names (`media_locations`, `vods` with their sidecars, masters and chunk segments, `clips`, `files`, `assets`, open upload sessions, unfinished jobs). A file whose only row is a deleted object's location counts, unless that object is native and inside its retention period, or held. Largest first, with a hint: a recorder name whose VOD row is gone, bytes of a deleted object, parts of a session that no longer exists, a temp file of an upload that never finished |++| `unreferenced_remote` | B2/R2 keys that no `media_locations` row and no `vods`/`clips` row names; hints for `vods-orphans/` (see below), an R2 copy left behind after its VOD went back to B2, recorder names || `missing` | copies the database records for objects that are not deleted, whose bytes are not there: no local file, or a key absent from its bucket's listing || `multipart_local` | Media's own multipart sessions still open (`media_uploads` active or completing), with `expired` when past `expires_at` || `multipart_remote` | multipart uploads the buckets still hold open (a killed upload leaves its parts stored and billed) |@@@ -600,7 +598,7 @@ that only moved to B2/R2 keeps it. Test: `test/content-hash.test.js`.node scripts/no-good-copy-report.js [--app live] [--json] [--out report.json]```-- For each object it prints the owner (app, `owner_user_id`, `owner_subject`), `legacy_ref`, the `vods`/`clips`/`files`/`pastes` rows that point at it, its relationships, variants and holds, every location with its state and `verified_at`, and the last verification. The report opens the database read-only. It exits 1 when there are any.++ For each object it prints the owner (app, `owner_user_id`, `owner_subject`), `legacy_ref`, the `vods`/`clips`/`files` rows that point at it, its relationships, variants and holds, every location with its state and `verified_at`, and the last verification. The report opens the database read-only. It exits 1 when there are any.## Public object-size invariant@@@ -2,8 +2,8 @@# OpenVibe.Media — end-to-end smoke test.# Boots the service against a throwaway data dir, then exercises:# vod create → chunk upload → complete → meta (duration+thumbnail) →--# clip cut → /v range playback → paste (+ /p page + /raw) →--# paste admin (screenshot censor + stats) → admin storage (overview, vod++# clip cut → /v range playback → retired paste API and /p redirect →++# admin storage (overview, vod# listing, tier-settings round-trip, buckets, bulk delete) → file upload →# /f fetch → bad API key rejected → cross-tenant key rejected.set -u@@@ -15,8 +15,7 @@* - VODs, clips and files are removed like their delete routes do (the bytes* everywhere, the row, and the object through the row-delete trigger);* - the thumbnails of those VODs and clips are marked deleted.-- * Kept, and counted: media under a retention hold (ADR-006), and paste screenshots-- * and avatars, whose rows sit in the frozen paste tables Community took over.++ * Media under a retention hold (ADR-006) is kept and counted.* Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).*/const fs = require('fs');@@@ -89,7 +88,8 @@ async function erase(db, subjects) {await db.prepare("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, ov_now()), updated_at = ov_now() WHERE id = ? AND lifecycle_status != 'deleted'").run(o.id);bump(erased, 'thumbnails');} else {-- bump(retained, `frozen_${o.kind}s`); // screenshots and avatars: rows in the frozen paste tables++ await model.softDelete(o, { by: 'account_deleted' });++ bump(erased, 'objects');}} catch (e) {if (/retention hold/.test(e.message)) { bump(retained, 'held_media'); continue; }@@@ -107,7 +107,7 @@ router.get('/', async (req, res) => {const directories = [{ name: 'vods', path: config.vod.path },{ name: 'clips', path: config.vod.clipsPath },-- { name: 'pastes', path: config.pastes.path },++ { name: 'pastes', path: config.pastes.path }, // Legacy files still live in this directory.{ name: 'thumbnails', path: config.thumbnails.path },{ name: 'files', path: config.files.path },];@@@ -124,14 +124,6 @@ router.get('/', async (req, res) => {FROM vods WHERE app_id = ?`, [req.appId]) || {};const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};-- // Paste rows are frozen (pastes moved to OpenVibe.Community, 2026-09-22) and-- // the read-only paste API was retired (T10 step 2); the count stays-- // while the table exists (it feeds /api/v2/me and operator stats).-- const pasteStats = await db.get(`-- SELECT COUNT(*) AS count,-- SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots-- FROM pastes WHERE app_id = ?-- `, [req.appId]) || {};const fileStats = await db.get('SELECT COUNT(*) AS count, COALESCE(SUM(size), 0)::bigint AS bytes FROM files WHERE app_id = ?', [req.appId]) || {};res.json({@@@ -142,7 +134,6 @@ router.get('/', async (req, res) => {breakdown,vodStats,clipStats,-- pasteStats,fileStats,byProvider: {vods: await _providerCounts('vods', req.appId, true),@@@ -2,8 +2,8 @@// ═══════════════════════════════════════════════════════════════// Avatar ingestion — every avatar on the network is a file HERE.//--// POST /internal/avatar-ingest { url, user_id, username } (internal key + loopback only)--// → { ok, slug, url: 'https://openvibe.media/p/<slug>/screenshot', width, height, bytes }++// POST /internal/avatar-ingest { url, user_id, username, subject } (internal key + loopback only)++// → { ok, object_id, url: 'https://openvibe.media/o/<id>', width, height, bytes }//// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:@@@ -14,9 +14,10 @@// • 8 MB cap while streaming, 10 s overall deadline// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs--// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same--// system as every other picture (reportable, served with range/caching by the public /p/:slug/screenshot--// route). The paste API was retired in T10 step 2, so Media has no route that deletes them.++// The result is a native media object (kind avatar, unlisted) in Live's `<root>.avatars` namespace, owned by the++// account's subject when Network sends it, created the way an Object API upload is: the namespace and quota are++// checked with the object, and the ready transition, its invariant row and media.object.uploaded commit together.++// /o/<id> serves it with range and caching. (Until 2026-10-10 an avatar was a row in the legacy pastes table.)// ═══════════════════════════════════════════════════════════════const dns = require('dns').promises;const https = require('https');@@@ -34,10 +35,10 @@ const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZEconst egress = require('openvibe-shared/egress');const isPublicAddress = (ip) => egress.isPublicAddress(ip);--// Where avatars land and how their slug is minted: the paste storage module--// (server/pastes/storage.js), a leaf with no router. The defaults keep the exact--// persisted path and slug behavior; tests may inject their own.--const pasteStorage = require('../pastes/storage');++// The tenant every avatar has lived in (Media has no tenant of Network's own) and the namespace below its root.++const AVATAR_APP = 'live';++const AVATAR_NAMESPACE = 'avatars';++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;async function resolvePublic(hostname) {const host = egress.normalizeHost(hostname);@@@ -99,25 +100,67 @@ async function toAvatar(buf) {return { buffer: out, width: SIZE, height: SIZE };}--function createIngestHandler({ db, config, screenshotsDir = pasteStorage.SCREENSHOTS_DIR, generateSlug = pasteStorage.generateSlug, log = console }) {++/**++ * Store one avatar's bytes as a native object; returns its id. Throws (with nothing left behind) when the tenant,++ * the namespace or the quota refuses it.++ */++async function storeAvatar({ db, pic, subject = null, username = null, sourceHost = null }) {++ const model = require('../objects/model');++ const namespaces = require('../objects/namespaces');++ const invariant = require('../objects/invariant');++ const { announce } = require('../webhooks');++ const app = await db.getApp(AVATAR_APP);++ if (!app) throw new Error('Picture storage is not configured on this server');++ const { namespace } = namespaces.resolveName(app, AVATAR_NAMESPACE);++ const bytes = pic.buffer.length;++ const sha256 = crypto.createHash('sha256').update(pic.buffer).digest('hex');++ // The namespace row, the quota check and the object commit together, as an API upload's init does.++ const made = await db.getDb().tx(async () => {++ const row = await namespaces.ensure(app, namespace);++ if (row.error) return { error: row.error };++ const q = await namespaces.checkQuota(app, namespace, { bytes, objects: 1 });++ if (q) return { error: q.detail || 'Picture storage is full' };++ return {++ id: await model.createObject({++ app_id: AVATAR_APP, namespace, kind: 'avatar', owner_subject: SUBJECT_RE.test(String(subject || '')) ? subject : null,++ visibility: 'unlisted', lifecycle_status: 'uploading', mime_type: 'image/webp', size_bytes: bytes, content_hash: sha256,++ metadata: { filename: 'avatar.webp', title: `Avatar${username ? ' of ' + String(username).slice(0, 40) : ''}`, source: 'avatar.ingest', source_host: sourceHost },++ }),++ };++ });++ if (made.error) throw new Error(made.error);++ const obj = await model.getObject(made.id);++ const dest = model.objectFilePath(obj);++ try {++ fs.mkdirSync(path.dirname(dest), { recursive: true });++ fs.writeFileSync(dest, pic.buffer);++ await announce(AVATAR_APP, 'media.object.uploaded', {++ change: async () => {++ await model.upsertLocation(obj.id, { provider: 'local', key: dest, state: 'present', size_bytes: bytes, checksum: sha256, verified: true });++ await model.updateObject(obj.id, { lifecycle_status: 'ready', canonical_provider: 'local', canonical_key: dest });++ await invariant.record(await model.getObject(obj.id));++ },++ payload: async () => await model.objectPublic(await model.getObject(obj.id)),++ });++ } catch (err) {++ try { fs.unlinkSync(dest); } catch { /* not written */ }++ await db.run('DELETE FROM media_objects WHERE id = ?', [obj.id]).catch(() => {});++ throw err;++ }++ await namespaces.reconcileChain(app, namespace).catch(() => {});++ return obj.id;++}++++function createIngestHandler({ db, config, log = console }) {return async function avatarIngest(req, res) {try {-- const { url, user_id, username } = req.body || {};++ const { url, user_id, username, subject } = req.body || {};if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });const raw = await safeFetchImage(url);const pic = await toAvatar(raw);-- fs.mkdirSync(screenshotsDir, { recursive: true });-- const file = path.join(screenshotsDir, `avatar-n${parseInt(user_id, 10) || 0}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.webp`);-- fs.writeFileSync(file, pic.buffer);-- const slug = await generateSlug(); // async since the PostgreSQL switch (it checks the table)-- const insert = async () => await db.run(`INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata, ip_address)-- VALUES ('network', ?, ?, 'screenshot', ?, '', 'text', 'unlisted', ?, ?, NULL) RETURNING id`,-- [slug, parseInt(user_id, 10) || null, `Avatar${username ? ' of ' + String(username).slice(0, 40) : ''}`, file,-- JSON.stringify({ kind: 'avatar', source_host: (() => { try { return new URL(url).hostname; } catch { return null; } })(), size_bytes: pic.buffer.length, mime_type: 'image/webp' })]);-- // The row and the avatar's media_object in one transaction (a database without the object model: the row alone).-- if (typeof db.withObject === 'function') await db.withObject('paste', (r) => r.lastInsertRowid, insert);-- else await insert();-- res.json({ ok: true, slug, url: `${config.publicUrl}/p/${slug}/screenshot`, width: pic.width, height: pic.height, bytes: pic.buffer.length });++ const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();++ const id = await storeAvatar({ db, pic, subject, username, sourceHost });++ res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });} catch (err) {log.warn('[Avatar ingest]', err.message);res.status(422).json({ ok: false, error: /unsupported image|Input buffer|corrupt|VipsJpeg|bad seek/i.test(err.message) ? 'That file is not a picture we can read' : err.message });@@@ -125,4 +168,4 @@ function createIngestHandler({ db, config, screenshotsDir = pasteStorage.SCREENS};}--module.exports = { createIngestHandler, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };++module.exports = { createIngestHandler, storeAvatar, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };@@@ -1,7 +1,7 @@/*** OpenVibe.Media — Database*-- * Lean port of the predecessor's vod/clip/paste helpers with app_id (tenant)++ * Lean port of the predecessor's vod/clip helpers with app_id (tenant)* scoping on every query. PostgreSQL through openvibe-sdk/db (ADR-035); every helper is async.*/@@@ -76,7 +76,7 @@ function heldSql(ref) {/*** Object-first write (WS-G task 1, C-75 retired 2026-10-10): run write() — a change to a projected-- * vods/clips/files/pastes row — and re-project the row's media_object in the same transaction++ * vods/clips/files row — and re-project the row's media_object in the same transaction* (objects/model.js withObject). Throws, with nothing written, when either part fails.*/async function withObject(kind, ids, write) {@@@ -332,9 +332,10 @@ async function getAppStats(appId) {return {vods: await c(`SELECT COUNT(*) n ${vodBase}`, [appId]),clips: await c(`SELECT COUNT(*) n ${clipBase}`, [appId]),-- pastes: await c('SELECT COUNT(*) n FROM pastes WHERE app_id = ?', [appId]),-- pasteImages: await c("SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND type = 'screenshot'", [appId]),-- pasteText: await c("SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND COALESCE(type,'paste') <> 'screenshot'", [appId]),++ // Pastes belong to Community, and Live reads them there; null keys remain for one release for N-1 clients.++ pastes: null,++ pasteImages: null,++ pasteText: null,durationSeconds: await c(`SELECT COALESCE(SUM(duration_seconds),0) n ${vodBase}`, [appId]),recent: {vods: await win(`SELECT COUNT(*) n ${vodBase} AND created_at >= datetime('now', ?)`, [appId]),@@@ -352,7 +353,6 @@ async function getAppStats(appId) {const STAT_SERIES = {vods: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COUNT(*)' },clips: { base: 'FROM clips WHERE app_id = ? AND COALESCE(is_public,1) = 1', agg: 'COUNT(*)' },-- pastes: { base: 'FROM pastes WHERE app_id = ?', agg: 'COUNT(*)' },hours: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COALESCE(SUM(duration_seconds),0) / 3600.0' },};async function getAppStatSeries(appId, metric, days = 30) {@@@ -521,124 +521,6 @@ async function findDuplicateClip({ appId, streamId = null, vodId = null, startTi`, [...params, startTime || 0, startWindow, endTime || 0, endWindow, `-${Math.max(1, createdSinceMinutes)} minutes`]);}--// ── Paste helpers ────────────────────────────────────────────----async function getPasteBySlug(slug, appId = null) {-- const clause = appId ? ' AND app_id = ?' : '';-- const params = appId ? [slug, appId] : [slug];-- return await get(`SELECT * FROM pastes WHERE slug = ?${clause}`, params);--}----async function likePaste(pasteId, userId) {-- await run('INSERT INTO paste_likes (paste_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING', [pasteId, userId]);-- await run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);-- return await get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);--}----async function unlikePaste(pasteId, userId) {-- await run('DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);-- await run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);-- return await get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);--}----async function hasUserLikedPaste(pasteId, userId) {-- return !!await get('SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);--}----async function incrementPasteCopies(slug) {-- return await run('UPDATE pastes SET copies = copies + 1 WHERE slug = ?', [slug]);--}----async function countUserPastesToday(appId, userId, ip) {-- if (userId) {-- return (await get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')", [appId, userId]))?.c || 0;-- }-- if (ip) {-- return (await get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')", [appId, ip]))?.c || 0;-- }-- return 0;--}----async function getLastPasteTime(appId, userId, ip) {-- let row;-- if (userId) {-- row = await get('SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1', [appId, userId]);-- } else if (ip) {-- row = await get('SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1', [appId, ip]);-- }-- return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;--}----/** App-scoped paste stats (predecessor's admin stats shape). */--async function getPasteStats(appId) {-- const row = await get(`-- SELECT COUNT(*) AS total,-- SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS "textPastes",-- SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,-- SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,-- COALESCE(SUM(views), 0)::bigint AS "totalViews",-- COALESCE(SUM(copies), 0)::bigint AS "totalCopies",-- COALESCE(SUM(likes), 0)::bigint AS "totalLikes"-- FROM pastes WHERE app_id = ?-- `, [appId]) || {};-- return {-- total: row.total || 0,-- textPastes: row.textPastes || 0,-- screenshots: row.screenshots || 0,-- forks: row.forks || 0,-- totalViews: row.totalViews || 0,-- totalCopies: row.totalCopies || 0,-- totalLikes: row.totalLikes || 0,-- };--}----// ── Paste comment helpers ────────────────────────────────────----async function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {-- return await run(-- `INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address)-- VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,-- [paste_id, user_id || null, parent_id || null, anon_name || null, message, ip_address || null]-- );--}----async function getPasteComments(pasteId, limit = 50, offset = 0) {-- return await all(`-- SELECT * FROM paste_comments-- WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL-- ORDER BY created_at DESC-- LIMIT ? OFFSET ?-- `, [pasteId, limit, offset]);--}----async function getPasteCommentReplies(parentId) {-- return await all(`-- SELECT * FROM paste_comments-- WHERE parent_id = ? AND is_deleted = 0-- ORDER BY created_at ASC-- `, [parentId]);--}----async function getPasteCommentById(commentId) {-- return await get('SELECT * FROM paste_comments WHERE id = ?', [commentId]);--}----async function getPasteCommentCount(pasteId) {-- return (await get('SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0', [pasteId]))?.count || 0;--}----async function deletePasteComment(commentId) {-- return await run('UPDATE paste_comments SET is_deleted = 1, updated_at = ov_now() WHERE id = ?', [commentId]);--}----async function getRecentPasteCommentsByIp(ip, seconds = 10) {-- return await all(`-- SELECT * FROM paste_comments-- WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds')-- ORDER BY created_at DESC-- `, [ip, seconds]);--}--// ── File helpers ─────────────────────────────────────────────// The row and its media_object in one transaction (the file is already on disk, sha256 computed by the route).@@@ -714,11 +596,6 @@ module.exports = {updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,// clipscreateClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,-- // pastes-- getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,-- countUserPastesToday, getLastPasteTime, getPasteStats,-- createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,-- getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,// filescreateFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,// migration@@@ -3,7 +3,7 @@** Multi-tenant media service: VOD ingest/recording (RTMP pull, RTP, browser* chunks), local/B2/R2 storage tiering with presigned-302 playback, clips,-- * pastes, files, thumbnails, and HMAC-signed webhooks. See CONTRACTS.md++ * files, thumbnails, legacy screenshot redirects, and HMAC-signed webhooks. See CONTRACTS.md* (Media API v1) and README.md.*/'use strict';@@@ -129,8 +129,7 @@ const ready = (async () => {{// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the-- // route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)-- // from server/pastes/storage.js itself — a leaf, not a router.++ // route (server/service-guard.js). The handler stores native avatar objects.app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));}@@@ -200,7 +199,7 @@ const ready = (async () => {}});-- // Daily series behind a stat (vods, clips, pastes, hours) — "over time" charts in the owning app.++ // Daily series behind a stat (vods, clips, hours) — "over time" charts in the owning app.app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth({ verb: 'read' }), async (req, res) => {try {const series = await db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);@@@ -110,7 +110,6 @@ async function projection(appId) {vods: (await db.get(`SELECT COUNT(*) AS n FROM vods WHERE object_id IS NULL AND COALESCE(clips_only, 0) = 0${s.sql}`, s.params)).n,clips: (await db.get(`SELECT COUNT(*) AS n FROM clips WHERE object_id IS NULL${s.sql}`, s.params)).n,files: (await db.get(`SELECT COUNT(*) AS n FROM files WHERE object_id IS NULL${s.sql}`, s.params)).n,-- screenshots: (await db.get(`SELECT COUNT(*) AS n FROM pastes WHERE object_id IS NULL AND type = 'screenshot' AND COALESCE(screenshot_path, '') != ''${s.sql}`, s.params)).n,};const owner = await db.all(`SELECT app_id, COUNT(*) AS n FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULLAND lifecycle_status != 'deleted'${s.sql} GROUP BY app_id ORDER BY n DESC`, s.params);@@@ -38,7 +38,6 @@ async function references(q, obj) {clips: await q.all(`SELECT id, app_id, vod_id, stream_id, user_id, channel_user_id, title, visibility, is_public, file_path, status, created_atFROM clips WHERE object_id = ?`, [obj.id]),files: await q.all('SELECT key, app_id, user_id, original_name, size, mime, created_at FROM files WHERE object_id = ?', [obj.id]),-- pastes: await q.all('SELECT id, slug, app_id, user_id, title, type, visibility, screenshot_path, created_at FROM pastes WHERE object_id = ?', [obj.id]),relationships: await q.all(`SELECT from_object_id, relation, to_object_id FROM media_relationshipsWHERE from_object_id = ? OR to_object_id = ? ORDER BY id`, [obj.id, obj.id]),variants: await q.all(`SELECT object_id, variant_name, derived_object_id FROM media_variants@@@ -92,7 +91,6 @@ function formatReport(report) {for (const v of r.vods) out.push(` vods#${v.id}: user ${v.user_id ?? '-'} "${v.title || ''}" ${v.visibility || ''} provider=${v.storage_provider || 'local'} file=${v.file_path || '-'}`);for (const c of r.clips) out.push(` clips#${c.id}: vod ${c.vod_id ?? '-'} user ${c.user_id ?? '-'} "${c.title || ''}" ${c.visibility || ''} file=${c.file_path || '-'}`);for (const f of r.files) out.push(` files/${f.key}: user ${f.user_id ?? '-'} ${f.original_name || ''}`);-- for (const p of r.pastes) out.push(` pastes/${p.slug}: user ${p.user_id ?? '-'} ${p.type || ''} ${p.visibility || ''}`);for (const x of r.relationships) out.push(` relationship: ${x.from_object_id} ${x.relation} ${x.to_object_id}`);for (const x of r.variants) out.push(` variant: ${x.object_id} ${x.variant_name} → ${x.derived_object_id}`);for (const h of r.holds) out.push(` HOLD ${h.kind}: ${h.reason || ''} (by ${h.created_by || '-'})`);@@@ -2,7 +2,7 @@* OpenVibe.Media — canonical object model (roadmap Wave 4; docs/object-model.md)** Every stored blob is a media_object (id med_<ULID>) with one media_locations-- * row per provider copy. The inherited vods/clips/files/pastes rows are typed++ * row per provider copy. The inherited vods/clips/files rows are typed* projections over objects: each carries object_id, and the sync*() functions* below derive the object + locations from the row as it is right now. The same* functions back every write on the old APIs, which writes the row and its object@@@ -114,7 +114,7 @@ async function isHeld(objectId) {}/**-- * Hold check for an inherited row (vods/clips/files/pastes). A clip row also follows its source VOD's++ * Hold check for an inherited row (vods/clips/files). A clip row also follows its source VOD's* hold before it has an object of its own; any other row with no object yet is not held.*/async function isHeldRow(row) {@@@ -366,24 +366,6 @@ function fileProjection(row) {};}--/** Screenshot pastes (and avatars, which are stored as screenshot pastes) — the bytes only; text is Community's. */--function pasteProjection(row) {-- if (row.type !== 'screenshot') return { skipped: 'text paste (no bytes)' };-- if (!row.screenshot_path) return { skipped: 'screenshot paste without a file path' };-- const meta = parseJson(row.metadata, {}) || {};-- const kind = meta.kind === 'avatar' ? 'avatar' : 'screenshot';-- const local = localLocation([row.screenshot_path]);-- return {-- legacy_ref: legacyRef(row.app_id, kind === 'avatar' ? 'avatar' : 'paste', row.slug), existingId: row.object_id,-- app_id: row.app_id, kind, owner_user_id: row.user_id,-- visibility: VISIBILITIES.includes(row.visibility) ? row.visibility : 'public', lifecycle_status: 'ready',-- mime_type: meta.mime_type || mimeFor(row.screenshot_path, 'image/png'),-- size_bytes: local.state === 'present' ? local.size_bytes : (Number(meta.size_bytes) || 0),-- metadata: { title: row.title || null, slug: row.slug },-- created_at: row.created_at, locations: [local], canonical: 'local',-- };--}--async function syncVod(idOrRow) {const row = typeof idOrRow === 'object' ? idOrRow : await db.get('SELECT * FROM vods WHERE id = ?', [idOrRow]);if (!row) return null;@@@ -420,16 +402,6 @@ async function syncFile(keyOrRow) {return { ...r, locations: [p.locations[0].state] };}--async function syncPaste(idOrRow) {-- const row = typeof idOrRow === 'object' ? idOrRow : await db.get('SELECT * FROM pastes WHERE id = ?', [idOrRow]);-- if (!row) return null;-- const p = pasteProjection(row);-- if (p.skipped) return p;-- const r = await project(p);-- await linkRow('pastes', 'id', row.id, r.id);-- return { ...r, kind: p.kind, locations: [p.locations[0].state] };--}--/** Thumbnail file name behind a stored thumbnail_url, or null when it is not one of ours. */function thumbFileFromUrl(u) {if (!u) return null;@@@ -493,14 +465,14 @@ async function _syncTx(fn) {return out;}--const SYNC_BY_KIND = { vod: syncVod, clip: syncClip, file: syncFile, paste: syncPaste };++const SYNC_BY_KIND = { vod: syncVod, clip: syncClip, file: syncFile };/** Row ids named by withObject()'s `ids` (a value, a list, or a function of write()'s result). */function _rowIds(ids, out) {return [].concat((typeof ids === 'function' ? ids(out) : ids) ?? []).filter(id => id != null);}--/** Re-project one inherited row: kind vod|clip|file|paste, id = row id (file key for files). */++/** Re-project one inherited row: kind vod|clip|file, id = row id (file key for files). */async function sync(kind, id) {const fn = SYNC_BY_KIND[kind];if (!fn || id == null) return null;@@@ -519,7 +491,7 @@ async function safeSync(kind, id) {* Object-first write (WS-G task 1; C-75 retired 2026-10-10, "write the row, then sync its* object"): write() changes inherited rows and the objects behind them are re-projected in the SAME* transaction, so both commit or neither does: no crash or error between the two can leave-- * an object behind its row. kind vod|clip|file|paste; ids the row id (a file's key), a list of them,++ * an object behind its row. kind vod|clip|file; ids the row id (a file's key), a list of them,* or a function of write()'s result that gives them (an INSERT's lastInsertRowid). write() must be* database work (awaited). Returns write()'s result; throws, with nothing written, when either* part fails. Inside an outer transaction (webhooks.announce) it is a savepoint of that one.@@@ -753,11 +725,11 @@ async function objectPublic(obj, { locations = true } = {}) {module.exports = {KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError,-- mimeFor, parseJson, parseLegacyRef, thumbFileFromUrl,++ mimeFor, parseJson, legacyRef, parseLegacyRef, thumbFileFromUrl,getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,-- vodProjection, clipProjection, fileProjection, pasteProjection, thumbnailProjection,-- syncVod, syncClip, syncFile, syncPaste, sync, safeSync, withObject, withObjectOrRow, afterTierMove,++ vodProjection, clipProjection, fileProjection, thumbnailProjection,++ syncVod, syncClip, syncFile, sync, safeSync, withObject, withObjectOrRow, afterTierMove,placeHold, releaseHold, holdPublic,objectFilePath, softDelete, restore, purgeExpired, usedBytes,legacyPublicUrl, objectPublic,@@@ -53,7 +53,6 @@ const PROJECTIONS = [{ table: 'vods', open: (r) => (r.visibility || (r.is_public ? 'public' : 'private')) !== 'private' && !r.clips_only },{ table: 'clips', open: (r) => (r.visibility || (r.is_public ? 'public' : 'private')) !== 'private' },{ table: 'files', open: () => true },-- { table: 'pastes', open: (r) => r.visibility !== 'private' && !!r.screenshot_path },];async function reconcile({ verify = false, hash = false, head = defaultHead, hashMaxBytes = 512 * MB, appId = null, listLimit = 200 } = {}) {@@@ -175,7 +174,6 @@ async function reconcile({ verify = false, hash = false, head = defaultHead, has['vods', 'id', 'object_id IS NULL AND COALESCE(clips_only, 0) = 0'],['clips', 'id', 'object_id IS NULL'],['files', 'key', 'object_id IS NULL'],-- ['pastes', 'id', "object_id IS NULL AND type = 'screenshot' AND screenshot_path IS NOT NULL"],];for (const [table, keyCol, cond] of gaps) {for (const r of await db.all(`SELECT ${keyCol} AS k FROM ${table} WHERE ${cond}${appId ? ' AND app_id = ?' : ''}`, appId ? [appId] : [])) {@@@ -634,7 +634,7 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:const obj = await load(req, res, 'delete');if (!obj) return;if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');-- if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete this object through its v1 route (vods, clips, files); paste screenshots and avatars are frozen since pastes moved to OpenVibe.Community');++ if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete vods, clips and files through their v1 routes; legacy screenshots and avatars are deleted through account deletion');if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');if (obj.lifecycle_status === 'deleted') return res.json(await model.objectPublic(obj));const gone = await model.softDelete(obj, { by: req.principal ? req.principal.sub : `app:${req.appId}` });ddeleted file mode 100644@@@ -1,66 +0,0 @@--'use strict';--// Paste storage plumbing, shared by the public screenshot route and the avatar ingest--// (which stores every avatar as an unlisted screenshot paste): where a paste's--// screenshot bytes land and how a slug is minted (checked against the pastes table,--// so two writes never get the same one). A leaf module — requiring it pulls in--// neither a router nor anything that mounts one.--const fs = require('fs');--const path = require('path');--const crypto = require('crypto');--const db = require('../db/database');--const config = require('../config');--const drill = require('../drill');----const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');--if (!drill.enabled && !fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); // a restore drill writes nothing----// Word pools for readable paste slugs (adj-noun-number)--const SLUG_ADJECTIVES = [-- 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',-- 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',-- 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',-- 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',-- 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',-- 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',-- 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',-- 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',-- 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',-- 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',-- 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',-- 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',-- 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',-- 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',-- 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',--];--const SLUG_NOUNS = [-- 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',-- 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',-- 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',-- 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',-- 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',-- 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',-- 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',-- 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',-- 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',-- 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',-- 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',-- 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',-- 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',-- 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',-- 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',-- 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',-- 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',--];----async function generateSlug(attempts = 0) {-- if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');-- const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];-- const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];-- const num = crypto.randomInt(10, 100); // 10–99-- const slug = `${adj}-${noun}-${num}`;-- const existing = await db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);-- if (existing) return await generateSlug(attempts + 1);-- return slug;--}----module.exports = { SCREENSHOTS_DIR, generateSlug };@@@ -2,10 +2,9 @@* OpenVibe.Media — public media index ("home page").** Server-rendered browse UI at GET / — a mass index of every public piece of-- * media this service owns, tabbed by kind (Videos / Clips / Images / Text /-- * Thumbnails / Files), each card carrying its description / AI overview and a-- * link back to the SOURCE page in the owning app (the VOD / clip / stream /-- * paste it came from). Dependency-free: inline CSS, no client JS required.++ * media this service owns, tabbed by kind (Videos / Clips / Thumbnails / Files).++ * Each card carries its description / AI overview and a link to its source page++ * in the owning app. Dependency-free: inline CSS, no client JS required.*/'use strict';@@@ -17,7 +16,7 @@ const db = require('../db/database');const PAGE_SIZE = 48;const THUMB_DIR = path.resolve(config.thumbnails.path);--// Public base URL per app for "source" links — shared with the watch/paste pages.++// Public base URL per app for "source" links — shared with the watch pages.const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');const cache = require('openvibe-shared/cache-policy');const frame = require('openvibe-shared/frame');@@@ -46,17 +45,6 @@ function clipCard(c) {source: `${appUrl(c.app_id)}/clip/${c.id}`, sourceLabel: 'Clip page',};}--function pasteCard(p) {-- const isImage = p.type === 'screenshot';-- return {-- kind: isImage ? 'image' : 'text', title: p.title || p.slug, date: p.created_at, app: p.app_id,-- thumb: isImage ? `${config.publicUrl}/p/${encodeURIComponent(p.slug)}/screenshot` : null,-- text: snip(isImage ? (p.content || p.description) : p.content),-- view: `${config.publicUrl}/p/${encodeURIComponent(p.slug)}`,-- source: `${appUrl(p.app_id)}/p/${encodeURIComponent(p.slug)}`, sourceLabel: 'Paste page',-- lang: p.language && p.language !== 'text' ? p.language : null,-- };--}function fileCard(f) {return {kind: 'file', title: f.original_name || f.key, date: f.created_at, app: f.app_id,@@@ -101,7 +89,6 @@ const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(cliconst C_WHERE = "COALESCE(is_public,1) = 1";// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";--const P_WHERE = "visibility = 'public'";// A VOD's or clip's thumbnail is listed only while the recording itself is public. The exact file name// is what /t/<name> serves, so listing a private or unlisted recording's thumbnail would publish a frame@@@ -146,14 +133,6 @@ async function fetchTab(tab, page) {total: (await db.get(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`)).c,cards: (await db.all(`SELECT * FROM clips WHERE ${C_WHERE} ${L}`)).map(clipCard),};-- case 'images': return {-- total: (await db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`)).c,-- cards: (await db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND type = 'screenshot' ${L}`)).map(pasteCard),-- };-- case 'text': return {-- total: (await db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`)).c,-- cards: (await db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`)).map(pasteCard),-- };case 'files': return {total: (await db.get(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`)).c,cards: (await db.all(`SELECT * FROM files WHERE ${F_WHERE} ${L}`)).map(fileCard),@@@ -176,19 +155,15 @@ async function fetchTab(tab, page) {UNION ALLSELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}UNION ALL-- SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}-- UNION ALLSELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assetsORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);const total = (await db.get(`SELECT(SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +(SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +-- (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) +(SELECT COUNT(*) FROM assets) c`)).c;const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref }): r.k === 'clip' ? clipCard({ ...r, id: r.ref })-- : r.k === 'asset' ? assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id })-- : pasteCard({ ...r, id: r.ref }));++ : assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id }));return { total, cards };}}@@@ -201,20 +176,18 @@ async function tabCounts() {const counts = {videos: await c(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`),clips: await c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),-- images: await c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),-- text: await c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),files: await c(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`),emotes: await c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),sounds: await c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),thumbnails: (await thumbList()).length,};-- counts.all = counts.videos + counts.clips + counts.images + counts.text + counts.emotes + counts.sounds;++ counts.all = counts.videos + counts.clips + counts.emotes + counts.sounds;_countCache = { at: Date.now(), counts };return counts;}// ── Rendering ────────────────────────────────────────────────--const KIND_ICON = { video: '🎬', clip: '✂️', image: '🖼️', text: '📄', thumbnail: '🏞️', file: '📦' };++const KIND_ICON = { video: '🎬', clip: '✂️', thumbnail: '🏞️', file: '📦' };function renderCard(c) {const preview = c.thumb@@@ -235,7 +208,7 @@ function renderCard(c) {</div>`;}--const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];++const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];function renderPage(tab, page, data, counts) {const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));@@@ -243,13 +216,13 @@ function renderPage(tab, page, data, counts) {return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>OpenVibe.Media — Media Index</title>--<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, images, pastes, and thumbnails.">++<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, files and thumbnails."><link rel="canonical" href="${config.publicUrl}/${tab === 'all' && page === 1 ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}"><meta name="robots" content="${page > 1 ? 'noindex, follow' : 'index, follow'}"><meta property="og:site_name" content="OpenVibe.Media"><meta property="og:type" content="website"><meta property="og:title" content="OpenVibe.Media — Media Index">--<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, images, pastes and thumbnails.">++<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, files and thumbnails."><meta property="og:url" content="${config.publicUrl}/"><meta property="og:image" content="${config.publicUrl}/og-image.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">@@@ -290,7 +263,7 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}</head><body><div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}--<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header>++<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header><nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>@@@ -9,7 +9,7 @@* GET /c/:id clip playback (same tiering/range logic + watch page)* GET /p/:slug 301 to OpenVibe.Community — pastes live there now* GET /p/:slug/raw 301 to OpenVibe.Community's raw text-- * GET /p/:slug/screenshot paste screenshot image++ * GET /p/:slug/screenshot 301 to the screenshot's or avatar's media object (/o/<id>), else to Community* GET /t/:id thumbnails (id = filename)* GET /f/:key files with correct Content-Type + Range* GET /og-image.png the site's share card@@@ -532,10 +532,8 @@ router.get('/f/:key', async (req, res) => {// ── Pastes ───────────────────────────────────────────────────--// Pastes live on OpenVibe.Community (the paste authority since 2026-09-22). Media holds the--// frozen rows but serves nothing from them for these two routes: every slug — found, missing--// or private — is a permanent 301 to Community's own /p/<slug> (and its /raw). Screenshot--// bytes keep being served from here.++// Pastes live on OpenVibe.Community (the paste authority since 2026-09-22): every slug — found, missing++// or private — is a permanent 301 to Community's own /p/<slug> (and its /raw).const movedTo = () => config.pastes.movedTo;const communityPasteUrl = (slug, suffix = '') => `${movedTo()}/p/${encodeURIComponent(String(slug))}${suffix}`;@@@ -543,21 +541,26 @@ router.get('/p/:slug', (req, res) => res.redirect(301, communityPasteUrl(req.parrouter.get('/p/:slug/raw', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug, '/raw')));++// A screenshot or avatar made before the move is a media object now, found by the slug in its legacy ref++// (legacy:live:paste:<slug>, legacy:live:avatar:<slug>; Live was the only tenant with pastes): a permanent++// 301 to its /o/<id>, which serves the bytes with range and caching. Every other slug — made on Community++// since the move, private or unknown — goes to Community's /p/<slug>/screenshot (the image, or its 404).++const LEGACY_PASTE_APP = 'live';++router.get('/p/:slug/screenshot', async (req, res) => {if (drill.refuseBytes(res)) return;++ const slug = String(req.params.slug);try {-- const paste = await db.getPasteBySlug(String(req.params.slug));-- // Pastes made since the move live in Community only: send an unknown slug there, like-- // /p/:slug and /p/:slug/raw (Community answers with the image, or its own 404). Stored-- // hero-moment thumbnails pointed here and showed broken images.-- if ((!paste || paste.visibility === 'private') && movedTo()) { // private answers like missing-- res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));-- return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/screenshot`);++ const model = require('../objects/model');++ const obj = await db.get('SELECT id, visibility, lifecycle_status FROM media_objects WHERE legacy_ref IN (?, ?) LIMIT 1',++ [model.legacyRef(LEGACY_PASTE_APP, 'paste', slug), model.legacyRef(LEGACY_PASTE_APP, 'avatar', slug)]);++ if (obj && obj.visibility !== 'private' && obj.lifecycle_status === 'ready') {++ res.set('Cache-Control', 'public, max-age=86400');++ return res.redirect(301, `/o/${obj.id}`);}-- if (!paste || !paste.screenshot_path) return res.status(404).send('Not found');-- if (paste.visibility === 'private') return res.status(404).send('Not found');-- if (!fs.existsSync(paste.screenshot_path)) return res.status(404).send('Not found');-- streamFileWithRange(req, res, paste.screenshot_path, { 'Cache-Control': 'public, max-age=86400' });++ if (!movedTo()) return res.status(404).send('Not found');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));++ return res.redirect(301, communityPasteUrl(slug, '/screenshot'));} catch {res.status(500).send('Error');}@@@ -1,8 +1,8 @@/*** OpenVibe.Media — app-facing view API (mounted at /api/v1/:app/views, app key).*-- * POST / { type: 'vod'|'clip'|'paste'|'file', id, ip?, user_id?, user_agent? }-- * Apps that render their own watch/paste pages call this when the page++ * POST / { type: 'vod'|'clip'|'file', id, ip?, user_id?, user_agent? }++ * Apps that render their own watch pages call this when the page* opens. The real client IP travels in X-Forwarded-For (Live's proxy sets* it) or `ip`; the viewer's user id in X-OV-User-Id or `user_id`.* → { counted, unique, reason?, view_count, unique_views }@@@ -25,7 +25,7 @@ router.post('/', tenantAuth(), async (req, res) => {const body = req.body || {};const type = String(body.type || '');const id = parseInt(body.id, 10);-- if (!views.TABLES[type] || !id) return res.status(400).json({ error: 'type (vod|clip|paste|file) and id required' });++ if (!views.TABLES[type] || !id) return res.status(400).json({ error: 'type (vod|clip|file) and id required' });// Deletion/tenancy guard: only the owning app may count views on its content.const t = views.TABLES[type];const row = await db.get(`SELECT app_id, user_id FROM ${t.table} WHERE id = ?`, [id]);@@@ -1,5 +1,5 @@/**-- * OpenVibe.Media — view counting (owned here for every app and every content type).++ * OpenVibe.Media — view counting (owned here for every app and supported content type).** Two numbers per item, both maintained by this module:* view_count "views": one per VISIT. A visitor (logged-in user id, else a keyed hash of@@@ -11,14 +11,14 @@* Also: the owner's own views never count; obvious bots/crawlers are ignored; a single IP* firing view events at an absurd rate (any content) is dropped after a per-minute budget.*-- * Entry points: recordView() from Media's own public routes (/v /c /p) and from the++ * Entry points: recordView() from Media's own public routes (/v /c) and from the* app-facing API (POST /api/v1/:app/views) that Live/Tools call for pages they render.*/'use strict';const crypto = require('crypto');const db = require('../db/database');--const TABLES = { vod: { table: 'vods', raw: 'view_count' }, clip: { table: 'clips', raw: 'view_count' }, paste: { table: 'pastes', raw: 'views' }, file: { table: 'files', raw: 'view_count' } };++const TABLES = { vod: { table: 'vods', raw: 'view_count' }, clip: { table: 'clips', raw: 'view_count' }, file: { table: 'files', raw: 'view_count' } };const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;let _secret = null;@@@ -63,7 +63,7 @@ async function _overBudget(ip) {/*** Record a view.-- * @param {'vod'|'clip'|'paste'|'file'} type++ * @param {'vod'|'clip'|'file'} type* @param {number} id* @param {object} o { req?, ip?, userId?, ownerUserId?, userAgent? }* @returns {{ counted:boolean, unique:boolean, reason?:string, view_count?:number, unique_views?:number }}@@@ -150,7 +150,7 @@ async function buildReport({ provider = 'b2', prefix = PREFIX, list = null } = {// ═══════════════════════════════════════════════════════════════//// unreferenced_local files under the data directories that no row names (media_locations, vods,--// clips, files, pastes, assets, open upload sessions, unfinished jobs). A file whose++// clips, files, assets, open upload sessions, unfinished jobs). A file whose// only row is a deleted object's location counts (its bytes outlived the object),// unless the object is a native one still inside its retention period, or held.// unreferenced_remote B2/R2 keys that no media_locations row or vods/clips row names@@@ -242,7 +242,6 @@ async function localReferences() {thumb(c.thumbnail_url);}for (const f of await db.all('SELECT key, app_id FROM files')) add(path.join(config.files.path, f.app_id, f.key));-- for (const p of await db.all('SELECT screenshot_path FROM pastes WHERE screenshot_path IS NOT NULL')) add(p.screenshot_path);for (const a of await db.all('SELECT file_path FROM assets WHERE file_path IS NOT NULL')) add(a.file_path);const sessions = new Set((await db.all("SELECT id FROM media_uploads WHERE status IN ('active', 'completing')")).map(r => r.id));const jobs = new Set((await db.all("SELECT id FROM media_jobs WHERE status IN ('queued', 'running')")).map(r => r.id));@@@ -327,8 +326,6 @@ async function remoteHint(provider, key, refs) {if (del) return del.object_id ? `copy of deleted object ${del.object_id}${del.legacy_ref ? ` (${del.legacy_ref})` : ''}` : 'named only by a location row whose object does not exist';const row = refs.rows.get(key);if (row && provider === 'r2') return `an R2 copy of ${row.kind} ${row.id}, which is served from ${row.provider}`;-- const shot = /paste-screenshot-(\d+)\.[a-z0-9]+$/i.exec(key);-- if (shot) return await db.get('SELECT 1 AS x FROM pastes WHERE id = ?', [Number(shot[1])]) ? `a legacy screenshot of paste ${shot[1]}` : `a legacy screenshot of paste ${shot[1]}, whose row is gone`;return await recordingNameHint(path.basename(key));}@@@ -2,8 +2,7 @@// Account export and deletion → Media (roadmap WS-B task 7, ADR-033): through POST /internal/events (signed),// network.account.export_requested sends Media's part to Network (the subject's objects, VODs and clips, no storage or// stream keys), and network.account.deleted erases what the subject and its merged-in aliases own: native objects are--// soft-deleted, VODs, clips and files removed with their bytes, thumbnails marked deleted; held media and frozen paste--// screenshots are kept and counted. Media confirms with counts; a redelivery sends nothing twice, and a failed++// soft-deleted, VODs, clips and files removed with their bytes, thumbnails marked deleted; held media is kept and counted. Media confirms with counts; a redelivery sends nothing twice, and a failed// confirmation is retried without erasing again.// node test/account-data.test.jsconst assert = require('assert');@@@ -29,7 +28,7 @@ const path = require('path');await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4', 'vod', DANA, 'legacy:live:vod:7'); // VOD: removedawait obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5', 'clip', DANA, 'legacy:live:clip:9'); // clip: removedawait obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', 'thumbnail', DANA, 'legacy:live:thumb:7'); // thumbnail: marked deleted-- await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D7', 'screenshot', DANA, 'legacy:live:paste:abc'); // frozen paste: kept++ await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D7', 'screenshot', DANA, 'legacy:live:paste:abc'); // legacy screenshot object: deletedawait obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D8', 'file', XENA, null); // someone else'sawait db.prepare("INSERT INTO media_holds (object_id, kind, reason) VALUES ('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'moderation', 'report under review') RETURNING id").run();const vodFile = path.join(tmp, 'vod7.mp4'); fs.writeFileSync(vodFile, 'x');@@@ -63,15 +62,15 @@ const path = require('path');failNext = true;await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);const state = Object.fromEntries((await db.prepare('SELECT id, lifecycle_status AS s FROM media_objects').all()).map((r) => [r.id.slice(-2), r.s]));-- assert.deepStrictEqual(state, { D1: 'deleted', D2: 'deleted', D3: 'ready', D4: 'deleted', D5: 'deleted', D6: 'deleted', D7: 'ready', D8: 'ready' });++ assert.deepStrictEqual(state, { D1: 'deleted', D2: 'deleted', D3: 'ready', D4: 'deleted', D5: 'deleted', D6: 'deleted', D7: 'deleted', D8: 'ready' });assert.ok(!fs.existsSync(vodFile) && !fs.existsSync(clipFile), 'the bytes are gone');assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM vods').get()).n + (await db.prepare('SELECT COUNT(*) AS n FROM clips').get()).n, 0);assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');const conf = sent[1];assert.strictEqual(conf.path, '/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1/confirmations');assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));-- assert.deepStrictEqual(conf.body.erased, { objects: 2, vods: 1, clips: 1, thumbnails: 1 });-- assert.deepStrictEqual(conf.body.retained, { held_media: 1, frozen_screenshots: 1 });++ assert.deepStrictEqual(conf.body.erased, { objects: 3, vods: 1, clips: 1, thumbnails: 1 });++ assert.deepStrictEqual(conf.body.retained, { held_media: 1 });assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');assert.strictEqual(sent.length, 2);} finally {@@@ -9,10 +9,9 @@ const https = require('https');const http = require('http');const express = require('express');--// The end-to-end check below writes where the service writes: point the paste--// storage at scratch before anything loads the config.++// The end-to-end check writes native objects under scratch storage.const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-avatar-'));--process.env.PASTES_PATH = path.join(tmp, 'pastes');++process.env.OBJECTS_PATH = path.join(tmp, 'objects');process.env.MEDIA_PUBLIC_URL = 'https://media.test';(async () => {@@@ -48,19 +47,13 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';await rejects(toAvatar(Buffer.from('<svg onload=alert(1)>not a picture</svg><script>')), /./, 'non-image bytes are refused');await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');-- // ── Shared paste storage: the screenshots dir and the slug have one source ──-- // (server/pastes/storage.js — the paste router is gone, the avatar ingest reads the leaf directly)-- const storage = require('../server/pastes/storage');const config = require('../server/config');-- assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');-- const slugA = await storage.generateSlug();-- const slugB = await storage.generateSlug();-- for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);-- assert.notStrictEqual(slugA, slugB, 'two minted slugs differ (uniqueness is checked against the pastes table)');// ── Through the route, wired the way server/index.js wires it: { db, config } only ──const db = require('../server/db/database');const ingest = require('../server/avatars/ingest');++ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });++ await require('../server/events').initWriter();// DNS and the socket are stubbed: nothing leaves the host.const realLookup = dns.promises.lookup;const realGet = https.get;@@@ -83,22 +76,31 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';const first = await post({ url: 'https://pics.example/me.png', user_id: 7, username: 'ada' });assert.strictEqual(first.status, 200, JSON.stringify(first.body));assert.strictEqual(first.body.ok, true);-- assert.match(first.body.slug, /^[a-z]+-[a-z]+-[0-9]{2}$/);-- assert.strictEqual(first.body.url, `${config.publicUrl}/p/${first.body.slug}/screenshot`);++ assert.match(first.body.object_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(first.body.url, `${config.publicUrl}/o/${first.body.object_id}`);assert.deepStrictEqual([first.body.width, first.body.height], [512, 512]);assert.ok(first.body.bytes > 0);-- const row = await db.get('SELECT * FROM pastes WHERE slug = ?', [first.body.slug]);-- assert.ok(row, 'the avatar row was inserted');-- assert.deepStrictEqual([row.app_id, row.user_id, row.type, row.visibility], ['network', 7, 'screenshot', 'unlisted']);-- assert.strictEqual(row.title, 'Avatar of ada');-- assert.ok(row.screenshot_path.startsWith(storage.SCREENSHOTS_DIR), `the file lands in the shared screenshots dir (${row.screenshot_path})`);-- assert.deepStrictEqual((await sharp(fs.readFileSync(row.screenshot_path)).metadata()).format, 'webp', 'the bytes on disk are the re-encoded WebP');-- assert.strictEqual(JSON.parse(row.metadata).kind, 'avatar');-- assert.ok(row.object_id, 'the avatar row carries its media object');-- assert.ok(await db.get('SELECT id FROM media_objects WHERE id = ?', [row.object_id]), 'and that object exists');-- const second = await post({ url: 'https://pics.example/me2.png', user_id: 8 });++ const check = async (body, owner) => {++ const obj = await db.get('SELECT * FROM media_objects WHERE id = ?', [body.object_id]);++ assert.ok(obj, 'avatar object exists');++ const app = await db.getApp('live');++ assert.deepStrictEqual([obj.kind, obj.app_id, obj.namespace, obj.visibility, obj.lifecycle_status, obj.owner_subject],++ ['avatar', 'live', `${db.rootNamespace(app)}.avatars`, 'unlisted', 'ready', owner]);++ const loc = await db.get("SELECT * FROM media_locations WHERE object_id = ? AND provider = 'local' AND state = 'present'", [obj.id]);++ assert.ok(loc && loc.key.startsWith(config.objects.path) && fs.existsSync(loc.key), 'present local object file');++ const meta = await sharp(fs.readFileSync(loc.key)).metadata();++ assert.deepStrictEqual([meta.format, meta.width, meta.height], ['webp', 512, 512]);++ assert.ok((await db.all('SELECT envelope FROM event_outbox')).some((r) => { const e = typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope; return e.event_type === 'media.object.uploaded' && JSON.stringify(e).includes(obj.id); }), 'upload event queued');++ };++ await check(first.body, null);++ const subject = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++ const second = await post({ url: 'https://pics.example/me2.png', user_id: 8, subject });assert.strictEqual(second.status, 200, JSON.stringify(second.body));-- assert.notStrictEqual(second.body.slug, first.body.slug, 'a second avatar gets its own slug');++ assert.notStrictEqual(second.body.object_id, first.body.object_id);++ await check(second.body, subject);++ const invalid = await post({ url: 'https://pics.example/me3.png', user_id: 9, subject: 'not-a-subject' });++ assert.strictEqual(invalid.status, 200, JSON.stringify(invalid.body));++ await check(invalid.body, null);server.close();} finally {dns.promises.lookup = realLookup;@@@ -139,7 +139,6 @@ const { spawnSync, execFileSync } = require('child_process');await db.run("INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE VALUES (2, 'live', 'Still recording', ?, 1, 'public', 1)", [bytes.rec]);await db.run("INSERT INTO clips (id, app_id, vod_id, title, file_path, duration_seconds, status) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 1, 'Restored clip', ?, 10, 'ready')", [bytes.clip]);await db.run("INSERT INTO assets (id, app_id, kind, name, file_path, mime) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'emote', 'drillWave', ?, 'image/png')", [bytes.asset]);-- await db.run("INSERT INTO pastes (id, app_id, slug, type, title, screenshot_path, visibility) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'shot1', 'screenshot', 'Shot', ?, 'public')", [bytes.shot]);await db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('f1.txt', 'live', 'f1.txt', 13, 'text/plain')");await require('./helpers/project-rows').projectRows({ onlyMissing: true });const COUNTED = ['media_objects', 'vods', 'clips', 'apps', 'media_locations', 'media_jobs', 'event_outbox'];@@@ -2,7 +2,7 @@* OpenVibe.Media — test-only legacy row projection** One media_object (+ locations, relationships, thumbnail variant) per existing-- * vod, clip, file, screenshot/avatar paste and vod/clip thumbnail, derived by the++ * vod, clip, file and vod/clip thumbnail, derived by the* same sync functions every write uses (model.withObject). Idempotent: objects are keyed by their* legacy ref, so a re-run updates in place and creates nothing new.*@@@ -24,7 +24,7 @@ function emptyCounts() {async function projectRows({ dryRun = false, onlyMissing = false } = {}) {const report = {started_at: new Date().toISOString(), dry_run: !!dryRun, only_missing: !!onlyMissing,-- counts: { vod: emptyCounts(), clip: emptyCounts(), file: emptyCounts(), screenshot: emptyCounts(), avatar: emptyCounts(), thumbnail: emptyCounts() },++ counts: { vod: emptyCounts(), clip: emptyCounts(), file: emptyCounts(), thumbnail: emptyCounts() },locations: { present: 0, missing: 0, pending: 0 },skipped: [],errors: [],@@@ -69,11 +69,6 @@ async function projectRows({ dryRun = false, onlyMissing = false } = {}) {await each('files', `SELECT * FROM files${where} ORDER BY created_at, key`, async (row) => {tally('file', 'files', row.key, await model.syncFile(row));});-- await each('pastes', `SELECT * FROM pastes${where ? where + ' AND' : ' WHERE'} type = 'screenshot' ORDER BY id`, async (row) => {-- const r = await model.syncPaste(row);-- const kind = (r && r.kind) || (String(row.metadata || '').includes('"kind":"avatar"') ? 'avatar' : 'screenshot');-- tally(kind, 'pastes', row.id, r);-- });if (dryRun) throw DRY;});} catch (err) {@@@ -47,7 +47,6 @@ const SEED_PG = `const put = (dir, name) => { const f = path.join(process.env[dir], name); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'bytes of ' + name); return f; };await db.run("INSERT INTO vods (app_id, user_id, title, file_path, file_size, thumbnail_url, duration_seconds, is_public, visibility) VALUES ('live', 1, 'N-1 VOD', ?, 18, '/t/vod-1-1.jpg', 60, 1, 'public')", [put('VOD_PATH', 'vod-1.mp4')]);await db.run("INSERT INTO clips (app_id, vod_id, user_id, title, file_path, duration_seconds, status, is_public, visibility) VALUES ('live', 1, 1, 'N-1 clip', ?, 10, 'ready', 1, 'public')", [put('CLIPS_PATH', 'clip-1.mp4')]);-- await db.run("INSERT INTO pastes (app_id, slug, type, title, content, visibility) VALUES ('live', 'n1paste', 'paste', 'N-1 paste', 'hello from N-1', 'public')");await db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('n1file.txt', 'live', 'n1file.txt', 17, 'text/plain')");put('FILES_PATH', 'live/n1file.txt'); put('THUMBNAILS_PATH', 'vod-1-1.jpg');// N-1 releases before C-75's retirement project rows with server/objects/backfill.js; later ones with the test helper.@@@ -1,6 +1,6 @@'use strict';// Object-first writes (WS-G task 1, C-75 retired 2026-10-10): every write to an inherited--// vods/clips/files/pastes row makes or updates its media_object in the SAME PostgreSQL transaction++// vods/clips/files row makes or updates its media_object in the SAME PostgreSQL transaction// (objects/model.js withObject). For each main write path the object is there, linked and agreeing// with the row as soon as the write returns; when the object cannot be written, the row write// rolls back with it (neither row).@@@ -52,7 +52,7 @@ const sharp = require('sharp');assert.ok(row.object_id, `${label}: row names its object`);const obj = await model.getObject(row.object_id);assert.ok(obj, `${label}: object exists right after the write`);-- const p = { vods: model.vodProjection, clips: model.clipProjection, files: model.fileProjection, pastes: model.pasteProjection }[table](row);++ const p = { vods: model.vodProjection, clips: model.clipProjection, files: model.fileProjection }[table](row);for (const [field, value] of Object.entries({ kind: p.kind, visibility: p.visibility, lifecycle_status: p.lifecycle_status, size_bytes: Number(p.size_bytes) || 0, legacy_ref: p.legacy_ref, app_id: p.app_id, owner_app: p.owner_app || p.app_id, owner_user_id: p.owner_user_id })) {assert.strictEqual(String(obj[field] ?? ''), String(value ?? ''), `${label}: object ${field}`);}@@@ -184,7 +184,7 @@ const sharp = require('sharp');assert.strictEqual((await model.getObject(doomedObj)).lifecycle_status, 'deleted', 'a deleted row takes its object with it (trigger, same statement)');console.log('✅ clip writes (upload, create, re-cut ready/failed, update, delete) commit with their object');-- // ── 3. Files, screenshot pastes, avatars ──++ // ── 3. Files ──r = await call('POST', '/files', form('file', Buffer.from('hello object model'), 'text/plain', 'notes.txt'));assert.strictEqual(r.status, 201);const fileKey = r.body.key;@@@ -195,27 +195,7 @@ const sharp = require('sharp');assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');-- // The read-only paste API is retired (T10 step 2): POST /pastes has no route and writes nothing.-- const pastesBefore = [await count('pastes'), await count('media_objects')];-- r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));-- assert.strictEqual(r.status, 404, 'no paste-write route remains');-- assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');-- // A screenshot paste row (imported before the move) still commits with its object.-- const slug = 'shot1';-- const shotFile = path.join(process.env.PASTES_PATH, 'shot1.png');-- fs.writeFileSync(shotFile, png);-- await db.withObject('paste', (x) => x.lastInsertRowid, async () => await db.run(`INSERT INTO pastes (app_id, slug, type, title, content, language, visibility, screenshot_path)-- VALUES ('live', ?, 'screenshot', 'Shot', '', 'text', 'unlisted', ?) RETURNING id`, [slug, shotFile]));-- ({ obj } = await linked('pastes', 'slug', slug, 'screenshot paste'));-- assert.deepStrictEqual([obj.kind, obj.visibility, obj.legacy_ref], ['screenshot', 'unlisted', `legacy:live:paste:${slug}`]);---- // Avatar ingest's write (server/avatars/ingest.js): the avatar row and its object together.-- const avatarFile = path.join(process.env.PASTES_PATH, 'avatar.webp');-- fs.writeFileSync(avatarFile, png);-- const avatarId = Number((await db.withObject('paste', (x) => x.lastInsertRowid, async () => await db.run(`INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata)-- VALUES ('network', 'av1', 42, 'screenshot', 'Avatar', '', 'text', 'unlisted', ?, ?) RETURNING id`, [avatarFile, JSON.stringify({ kind: 'avatar', mime_type: 'image/webp' })]))).lastInsertRowid);-- assert.deepStrictEqual([(await linked('pastes', 'id', avatarId, 'avatar')).obj.kind, (await linked('pastes', 'id', avatarId, 'avatar')).obj.legacy_ref], ['avatar', 'legacy:network:avatar:av1']);-- console.log('✅ file, screenshot-paste and avatar writes commit with their object; deletes mark it deleted');++ console.log('✅ file writes commit with their object; deletes mark it deleted');// ── 4. Atomicity: an object write that fails takes the row write with it ──// Every object write fails (as a trigger refusing it would): the model's INSERT/UPDATE of media_objects throws.@@@ -224,8 +204,8 @@ const sharp = require('sharp');if (/^\s*(INSERT INTO|UPDATE) media_objects\b/.test(sql)) throw new Error('object write refused (test)');return await realRun(sql, params);};-- const before = { vods: await count('vods'), clips: await count('clips'), files: await count('files'), pastes: await count('pastes'), objects: await count('media_objects') };-- const unchanged = async (label) => assert.deepStrictEqual({ vods: await count('vods'), clips: await count('clips'), files: await count('files'), pastes: await count('pastes'), objects: await count('media_objects') }, before, label);++ const before = { vods: await count('vods'), clips: await count('clips'), files: await count('files'), objects: await count('media_objects') };++ const unchanged = async (label) => assert.deepStrictEqual({ vods: await count('vods'), clips: await count('clips'), files: await count('files'), objects: await count('media_objects') }, before, label);await assert.rejects(async () => await db.createVod({ app_id: 'live', title: 'No object' }), /object write refused/);await unchanged('createVod: neither row');@@@ -1,6 +1,5 @@'use strict';--// Canonical object model (roadmap Wave 4): the test helper projects every vod, clip, file, screenshot,--// avatar and thumbnail row onto media_objects + media_locations without touching bytes or providers;++// Canonical object model (roadmap Wave 4): the test helper projects every vod, clip, file and thumbnail row onto media_objects + media_locations without touching bytes or providers;// it is idempotent; the write hooks keep the model current; holds block deletion and tier moves on// every path; the public object-size invariant is recorded as policy.const assert = require('assert');@@@ -59,13 +58,7 @@ const path = require('path');await d.prepare("INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, status) OVERRIDING SYSTEM VALUE VALUES (2, 'live', 1, 9, 'C2', '', 'processing') RETURNING id").run();write(path.join(process.env.FILES_PATH, 'live', 'abc123-a.txt'), 12);await d.prepare("INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256) VALUES ('abc123-a.txt', 'live', 3, 'a.txt', 12, 'text/plain', ?)").run('a'.repeat(64));-- const shot = write(path.join(process.env.PASTES_PATH, 'screenshots', 's1.png'), 40);-- const avatar = write(path.join(process.env.PASTES_PATH, 'screenshots', 'avatar-n4.webp'), 30);-- await d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'shot-one', 2, 'screenshot', 'S', '', 'public', ?, NULL) RETURNING id").run(shot);-- await d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata) OVERRIDING SYSTEM VALUE VALUES (2, 'network', 'avatar-four', 4, 'screenshot', 'Avatar', '', 'unlisted', ?, ?) RETURNING id").run(avatar, JSON.stringify({ kind: 'avatar', mime_type: 'image/webp' }));-- await d.prepare("INSERT INTO pastes (id, app_id, slug, type, title, content, visibility) OVERRIDING SYSTEM VALUE VALUES (3, 'live', 'text-three', 'paste', 'T', 'hello', 'public') RETURNING id").run();-- await d.prepare("INSERT INTO pastes (id, app_id, slug, type, title, content, visibility, screenshot_path) OVERRIDING SYSTEM VALUE VALUES (4, 'live', 'shot-nopath', 'screenshot', 'S', '', 'public', NULL) RETURNING id").run();-- const mtimes = [vodFile, clipFile, shot, avatar].map(p => fs.statSync(p).mtimeMs);++ const mtimes = [vodFile, clipFile].map(p => fs.statSync(p).mtimeMs);// ── Dry run: a full report, nothing written ──const dry = await projectRows({ dryRun: true });@@@ -80,12 +73,10 @@ const path = require('path');assert.deepStrictEqual(r.counts.vod, { seen: 7, created: 6, updated: 0, skipped: 1 });assert.deepStrictEqual(r.counts.clip, { seen: 2, created: 2, updated: 0, skipped: 0 });assert.deepStrictEqual(r.counts.file, { seen: 1, created: 1, updated: 0, skipped: 0 });-- assert.deepStrictEqual(r.counts.screenshot, { seen: 2, created: 1, updated: 0, skipped: 1 });-- assert.deepStrictEqual(r.counts.avatar, { seen: 1, created: 1, updated: 0, skipped: 0 });assert.deepStrictEqual(r.counts.thumbnail, { seen: 2, created: 1, updated: 0, skipped: 1 });-- assert.deepStrictEqual(r.skipped.map(s => s.reason).sort(), ['clips-only recording (ephemeral, never published)', 'external thumbnail url', 'screenshot paste without a file path']);++ assert.deepStrictEqual(r.skipped.map(s => s.reason).sort(), ['clips-only recording (ephemeral, never published)', 'external thumbnail url']);assert.deepStrictEqual(r.errors, []);-- assert.deepStrictEqual([vodFile, clipFile, shot, avatar].map(p => fs.statSync(p).mtimeMs), mtimes, 'no bytes touched');++ assert.deepStrictEqual([vodFile, clipFile].map(p => fs.statSync(p).mtimeMs), mtimes, 'no bytes touched');const v1 = await objOf('vods', 1), v2 = await objOf('vods', 2), v3 = await objOf('vods', 3), v4 = await objOf('vods', 4), v6 = await objOf('vods', 6), v7 = await objOf('vods', 7);for (const o of [v1, v2, v3, v4, v6, v7]) {@@@ -119,11 +110,6 @@ const path = require('path');const f = await objOf('files', 'abc123-a.txt');assert.deepStrictEqual([f.kind, f.visibility, f.content_hash, f.mime_type, f.legacy_ref, (await locs(f)).local.checksum], ['file', 'public', 'a'.repeat(64), 'text/plain', 'legacy:live:file:abc123-a.txt', 'a'.repeat(64)]);-- const s1 = await objOf('pastes', 1), av = await objOf('pastes', 2);-- assert.deepStrictEqual([s1.kind, s1.legacy_ref, s1.size_bytes], ['screenshot', 'legacy:live:paste:shot-one', 40]);-- assert.deepStrictEqual([av.kind, av.app_id, av.legacy_ref, av.visibility, av.mime_type], ['avatar', 'network', 'legacy:network:avatar:avatar-four', 'unlisted', 'image/webp']);-- assert.strictEqual((await d.prepare('SELECT object_id FROM pastes WHERE id = 3').get()).object_id, null, 'text pastes have no bytes object');--const tv = await model.getVariant(v1.id, 'thumbnail');const th = await model.getObject(tv.derived_object_id);assert.deepStrictEqual([th.kind, th.legacy_ref, th.visibility, (await locs(th)).local.state], ['thumbnail', 'legacy:live:thumbnail:vod-1-111.jpg', 'public', 'present']);nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++const assert = require('assert');++const http = require('http');++const express = require('express');++++(async () => {++ const db = require('../server/db/database');++ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });++ const ids = [++ 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3',++ 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4',++ 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5',++ ];++ for (const [i, kind, slug, visibility] of [[0, 'screenshot', 'old-shot', 'public'], [1, 'avatar', 'old-avatar', 'unlisted'], [2, 'screenshot', 'private-shot', 'private']]) {++ await db.run('INSERT INTO media_objects (id, app_id, namespace, kind, visibility, lifecycle_status, legacy_ref) VALUES (?, ?, ?, ?, ?, ?, ?)',++ [ids[i], 'live', 'live', kind, visibility, 'ready', `legacy:live:${kind === 'avatar' ? 'avatar' : 'paste'}:${slug}`]);++ }++ const app = express();++ app.use(require('../server/public/routes'));++ const server = http.createServer(app);++ try {++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ const base = `http://127.0.0.1:${server.address().port}`;++ const config = require('../server/config');++ for (const [slug, target] of [++ ['old-shot', `/o/${ids[0]}`],++ ['old-avatar', `/o/${ids[1]}`],++ ['private-shot', `${config.pastes.movedTo}/p/private-shot/screenshot`],++ ['unknown-shot', `${config.pastes.movedTo}/p/unknown-shot/screenshot`],++ ]) {++ const response = await fetch(`${base}/p/${slug}/screenshot`, { redirect: 'manual' });++ assert.deepStrictEqual([response.status, response.headers.get('location')], [301, target], slug);++ }++ } finally {++ await new Promise((resolve) => server.close(resolve));++ }++ console.log('paste screenshot redirect: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++// Server and scripts must leave the frozen paste tables to Community.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.resolve(__dirname, '..');++const tableSql = /\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+(?:"?\w+"?\.)?"?(?:pastes|paste_likes|paste_comments)"?\b/gi;++const found = [];++for (const folder of ['server', 'scripts']) {++ const walk = (dir) => {++ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {++ const file = path.join(dir, entry.name);++ if (entry.isDirectory()) walk(file);++ else if (entry.isFile() && /\.(?:js|sh)$/.test(entry.name)) {++ const source = fs.readFileSync(file, 'utf8');++ for (const match of source.matchAll(tableSql)) found.push(`${path.relative(root, file)}: ${match[0]}`);++ }++ }++ };++ walk(path.join(root, folder));++}++assert.deepStrictEqual(found, [], `legacy paste table SQL remains:\n${found.join('\n')}`);++console.log('paste tables unread: all checks passed');@@@ -3,7 +3,7 @@// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug and// /p/:slug/raw routes are unconditional 301s to Community — no env var needed (the default is// config.pastes.movedTo) and no row is read: a known, missing or private slug all go there.--// Screenshot bytes are still served here.++// Legacy screenshot objects redirect to their object URLs.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -16,10 +16,8 @@ const express = require('express');const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-moved-'));const db = require('../server/db/database');await db.upsertApp({ app_id: 'live', api_key: 'live-key' });-- const shot = path.join(tmp, 'shot.png');-- fs.writeFileSync(shot, Buffer.from('\x89PNG\r\n\x1a\n'));-- await db.getDb().prepare("INSERT INTO pastes (app_id, slug, type, title, content, visibility, screenshot_path) VALUES ('live', 'text-1', 'paste', 't', 'hello', 'public', NULL), ('live', 'shot-1', 'screenshot', 's', '', 'public', ?) RETURNING id").run(shot);--++ const objectId = 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++ await db.run("INSERT INTO media_objects (id, app_id, namespace, kind, visibility, lifecycle_status, legacy_ref) VALUES (?, 'live', 'live', 'screenshot', 'public', 'ready', 'legacy:live:paste:shot-1')", [objectId]);const app = express();app.use(express.json());app.use(require('../server/public/routes'));@@@ -33,13 +31,10 @@ const express = require('express');// The app API is gone: no method on /api/v1/:app/pastes is mounted, so a write has no route to// answer 410 and creates nothing, and a read has no route either.-- const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;-- const rows = await pasteCount();let r = await post();assert.strictEqual(r.status, 404, 'no paste-write route remains');r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });assert.strictEqual(r.status, 404, 'no paste-read route remains');-- assert.strictEqual(await pasteCount(), rows, 'nothing creates a paste');// The page and its text are Community's: a permanent 301, with no env var set.r = await get('/p/text-1');@@@ -57,11 +52,8 @@ const express = require('express');assert.strictEqual(r.status, 301);assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/a%2Fb%20c', 'the slug is URL-encoded, never interpolated raw');-- // The frozen rows still serve their screenshot bytes (Live/Community still link them).r = await get('/p/shot-1/screenshot');-- assert.strictEqual(r.status, 200, 'screenshot bytes are still served here');-- assert.ok(await db.getPasteBySlug('text-1'), 'no request deletes anything');--++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, `/o/${objectId}`]);server.close();fs.rmSync(tmp, { recursive: true, force: true });console.log('pastes moved: all checks passed');@@@ -45,12 +45,6 @@ const http = require('http');VALUES (?, 'live', 1, ?, ?, ?, ?, ?, 'ready') RETURNING id`);await insClip.run(10, 5, 'Public clip', clipFile('clip-10.webm'), 1, 'public');await insClip.run(11, 5, 'Private clip', clipFile('clip-11-secret.webm'), 0, 'private');-- const insPaste = raw.prepare(`INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility, screenshot_path)-- VALUES (?, 'live', 5, 'P', ?, ?, 'text', ?, ?) RETURNING id`);-- await insPaste.run('pub-text', 'paste', 'hello', 'public', null);-- await insPaste.run('priv-text', 'paste', 'secret', 'private', null);-- await insPaste.run('priv-shot', 'screenshot', 'caption', 'private', '/nonexistent.png');--const mkObj = async (visibility, lifecycle_status) => await model.createObject({app_id: 'live', namespace: 'live', kind: 'file', owner_subject: null, owner_app: 'live', owner_user_id: 5,visibility, lifecycle_status, mime_type: 'text/plain', size_bytes: 5, metadata: {},@@@ -174,13 +168,7 @@ const http = require('http');assert.strictEqual((await get('/api/v1/live/clips/11', asUser(5))).status, 200);assert.strictEqual((await get('/api/v1/live/vods/6', asUser(77))).status, 200, 'unlisted stays reachable by id');-- const views = async () => (await raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get()).views;-- const before = await views();-- // The read-only paste API is retired (T10 step 2): the v1 path is unmounted for everyone,-- // so it cannot confirm a private paste exists either.assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 404, 'the paste API is gone');-- assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(77))).status, 404);-- assert.strictEqual(await views(), before, 'a refused read counts no view');console.log('✅ v1 detail: acting non-owners get the missing answer');server.close();@@@ -58,10 +58,6 @@ const http = require('http');await model.sync('clip', 13);const vod = async (id) => await db.get('SELECT * FROM vods WHERE id = ?', [id]);const clip = async (id) => await db.get('SELECT * FROM clips WHERE id = ?', [id]);-- await raw().prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility, screenshot_path)-- VALUES ('shot1', 'live', 'S', 'screenshot', '', 'text', 'public', ?) RETURNING id`).run(file(dir('pastes/screenshots'), 'shot1.png', 20));-- await model.sync('paste', (await db.get("SELECT id FROM pastes WHERE slug = 'shot1'")).id);--const app = express();app.use(express.json());app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));@@@ -172,17 +168,6 @@ const http = require('http');assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');-- // ── A held screenshot paste: the v1 paste API is retired (T10 step 2), so the hold is-- // enforced by the delete trigger alone — the row, its bytes and its object stay. ──-- const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");-- await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });-- await assert.rejects(async () => await db.run("DELETE FROM pastes WHERE slug = 'shot1'"), /retention hold/, 'row SQL is refused by the trigger');-- const probe = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/live/pastes/shot1`, { method: 'DELETE', headers: { authorization: 'Bearer live-key' }, redirect: 'manual' });-- assert.strictEqual(probe.status, 404, 'the paste API has no delete route any more');-- assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));-- const shotObj = await model.getObject(shot.object_id);-- assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');-- console.log('✅ a held screenshot paste: the delete trigger refuses it; row, bytes and object stay');// ── Listing and release ──r = await call('GET', H);@@@ -81,7 +81,6 @@ const crypto = require('crypto');}const HIDDEN_TITLES = ['Secret VOD title', 'Unlisted VOD title', 'Secret clip title', 'Unlisted clip title'];const HIDDEN_THUMBS = [`vod-${privVod}-222.jpg`, `vod-${unlVod}-666.jpg`, `clip-${privClip}-2.jpg`, `clip-${unlClip}-3.jpg`];-- await raw.prepare("INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility) VALUES ('priv-page', 'live', 5, 'Secret paste title', 'paste', 'secret text', 'text', 'private') RETURNING id").run();const app = express();app.put('/api/v2/:app/objects/:id/content', ...require('../server/objects/routes').contentHandlers);@@@ -140,7 +139,7 @@ const crypto = require('crypto');for (const p of ['/', '/browse', '/browse?tab=videos', '/browse?tab=clips', '/browse?tab=thumbnails', '/?tab=thumbnails&page=1', '/sitemap.xml', '/llms.txt', '/updates']) {const r = await get(p);assert.ok(r.status < 500, `${p} answers`);-- for (const t of [...HIDDEN_TITLES, ...HIDDEN_THUMBS, 'Secret paste title']) assert.ok(!r.text.includes(t), `${p} lists ${t}`);++ for (const t of [...HIDDEN_TITLES, ...HIDDEN_THUMBS]) assert.ok(!r.text.includes(t), `${p} lists ${t}`);if (/thumbnails/.test(p)) {assert.ok(r.text.includes(`vod-${pubVod}-111.jpg`) && r.text.includes(`clip-${pubClip}-1.jpg`), `${p}: public recordings' thumbnails are listed`);}@@@ -22,7 +22,7 @@ const https = require('https');(async () => {const ROOT = path.join(__dirname, '..');const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-ssrf-'));-- process.env.PASTES_PATH = path.join(tmp, 'pastes');++ process.env.OBJECTS_PATH = path.join(tmp, 'objects');process.env.MEDIA_PUBLIC_URL = 'https://media.test';delete process.env.MEDIA_RTMP_PULL_ALLOW;@@@ -166,7 +166,7 @@ const https = require('https');const express = require('express');const app = express();app.use(express.json());-- app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config: require('../server/config'), screenshotsDir: path.join(tmp, 'shots'), generateSlug: () => 'slug1', log: { warn() {} } }));++ app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config: require('../server/config'), log: { warn() {} } }));const server = http.createServer(app);await new Promise((r) => server.listen(0, '127.0.0.1', r));for (const url of ['https://169.254.169.254/latest/meta-data/iam/security-credentials/', 'https://[::ffff:7f00:1]/a.png', 'https://2130706433/a.png', 'https://localhost/a.png', 'https://mixed.example/a.png']) {@@@ -174,8 +174,8 @@ const https = require('https');const body = await r.json();assert.deepStrictEqual([r.status, body.ok], [422, false], `${url}: ${JSON.stringify(body)}`);}-- assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM pastes WHERE app_id = 'network'")).n, 0, 'no avatar was stored');-- assert.ok(!fs.existsSync(path.join(tmp, 'shots')) || fs.readdirSync(path.join(tmp, 'shots')).length === 0, 'no file was written');++ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM media_objects WHERE kind = 'avatar'")).n, 0, 'no avatar object was stored');++ assert.ok(!fs.existsSync(require('../server/config').objects.path) || fs.readdirSync(require('../server/config').objects.path).length === 0, 'no object file was written');server.close();console.log('✅ POST /internal/avatar-ingest refuses internal URLs and stores nothing');@@@ -27,6 +27,7 @@ const path = require('path');const h = await db.getAppStatSeries('live', 'hours', 7);assert.strictEqual(h.total, 3);assert.strictEqual(await db.getAppStatSeries('live', 'nope', 7), null);++ assert.strictEqual(await db.getAppStatSeries('live', 'pastes', 7), null);assert.strictEqual((await db.getAppStatSeries('live', 'vods', 9999)).points.length, 365);for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }@@@ -137,7 +137,7 @@ const crypto = require('crypto');walk(tmp);return out.sort();};-- const TABLES = ['vods', 'clips', 'files', 'pastes', 'media_objects', 'media_locations', 'media_holds', 'media_uploads', 'media_upload_parts'];++ const TABLES = ['vods', 'clips', 'files', 'media_objects', 'media_locations', 'media_holds', 'media_uploads', 'media_upload_parts'];const snapshotDb = async () => Object.fromEntries((await Promise.all(TABLES.map(async t => [t, crypto.createHash('sha256').update(JSON.stringify(await raw.prepare(`SELECT * FROM ${t} ORDER BY ${t}::text`).all())).digest('hex')]))));const snapshotBuckets = () => JSON.stringify(Object.entries(buckets).map(([b, m]) => [b, [...m.keys()].sort()]));const before = { files: snapshotFiles(), db: await snapshotDb(), buckets: snapshotBuckets() };
@@ -8,7 +8,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
@@ -8,7 +8,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
on 2026-09-22; Media's read-only paste API is gone (T10 step 2), `/p/:slug` only
on 2026-09-22; Media's read-only paste API is gone (T10 step 2), `/p/:slug` only
-redirects there and it serves the frozen rows' old screenshots ([Pastes](#pastes)).
+redirects there and legacy screenshots redirect to media objects ([Pastes](#pastes)).
Implements **Media API v1** from `../CONTRACTS.md`.
Implements **Media API v1** from `../CONTRACTS.md`.
@@ -35,7 +35,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -35,7 +35,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
## Does not own
## Does not own
-- pastes since 2026-09-22 (OpenVibe.Community; Media keeps the frozen rows and serves old screenshots)
+- pastes since 2026-09-22 (OpenVibe.Community; Media keeps legacy screenshot files through media objects)
- channels, streams and who a VOD belongs to (OpenVibe.Live answers lineage), identity (OpenVibe.Network)
- channels, streams and who a VOD belongs to (OpenVibe.Live answers lineage), identity (OpenVibe.Network)
- AI analysis of recordings (OpenVibe.AI reads them through signed URLs)
- AI analysis of recordings (OpenVibe.AI reads them through signed URLs)
@@ -94,7 +94,6 @@ server/
@@ -94,7 +94,6 @@ server/
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-job.js background health scan + quarantine cleanup + master sweep
vod/health-job.js background health scan + quarantine cleanup + master sweep
- pastes/storage.js screenshot dir + slug minting kept after the pastes API was retired
files/routes.js /api/v1/:app/files
files/routes.js /api/v1/:app/files
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)
registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)
@@ -296,23 +295,20 @@ deleted through Media any more.
@@ -296,23 +295,20 @@ deleted through Media any more.
`/p/:slug` and `/p/:slug/raw` are unconditional **301s** to Community: the target
`/p/:slug` and `/p/:slug/raw` are unconditional **301s** to Community: the target
is `config.pastes.movedTo` (`PASTES_MOVED_TO`, default `https://openvibe.community`,
is `config.pastes.movedTo` (`PASTES_MOVED_TO`, default `https://openvibe.community`,
so the redirect never depends on an env var) and no row is read — a private or
so the redirect never depends on an env var) and no row is read — a private or
-missing slug redirects exactly like a public one. Screenshot bytes are still served
-from here, and new screenshots are uploaded to the token-only `community` tenant.
-Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,
-`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and
-`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so
-Media has no API that deletes them.
-
-`server/pastes/storage.js` stays as a leaf: the avatar ingest still mints slugs
-and writes screenshots into the same directory.
+missing slug redirects exactly like a public one. Legacy screenshot and avatar objects are
+found by `legacy_ref` and `/p/:slug/screenshot` redirects to `/o/<id>` when they are
+public or unlisted and ready. Other slugs redirect to Community. Avatar ingest stores
+new avatars as native objects in Live's avatars namespace. Legacy files remain under
+`PASTES_PATH` and are named by their objects' local locations. Legacy screenshot and
+avatar objects are deleted through account deletion; direct v2 deletion answers 409.
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
| GET | `/p/:slug` | always 301 to OpenVibe.Community — pastes live there; no row is read |
| GET | `/p/:slug` | always 301 to OpenVibe.Community — pastes live there; no row is read |
| GET | `/p/:slug/raw` | always 301 to Community's `/p/:slug/raw`; the suffix is preserved |
| GET | `/p/:slug/raw` | always 301 to Community's `/p/:slug/raw`; the suffix is preserved |
-| GET | `/p/:slug/screenshot` | frozen screenshot bytes; unknown/private slugs 301 to Community |
+| GET | `/p/:slug/screenshot` | 301 to the legacy object when public or unlisted and ready; otherwise 301 to Community |
-AI summary/tags columns remain for imported rows.
+Legacy paste tables remain in the database until the later contract migration, but Media does not query them.
### Files
### Files
@@ -339,7 +335,7 @@ directories are shared across apps) and responses carry a `note` saying so.
@@ -339,7 +335,7 @@ directories are shared across apps) and responses carry a `note` saying so.
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
-| GET | `/admin/storage` | `{ disk (df of VOD volume), database.bytes, breakdown [vods/clips/pastes/thumbnails/files dirs], vodStats/clipStats/pasteStats/fileStats (app-scoped), byProvider.{vods,clips} (count+bytes per local/b2/r2) }` |
+| GET | `/admin/storage` | `{ disk (df of VOD volume), database.bytes, breakdown [vods/clips/pastes/thumbnails/files dirs], vodStats/clipStats/fileStats (app-scoped), byProvider.{vods,clips} (count+bytes per local/b2/r2) }` |
| GET | `/admin/storage/vods?limit&offset&sort&order&provider&tier` | detailed app VOD listing: id, title, size, provider, health, views, `last_accessed_at`, created + `fileExists`/`diskSize`/`actualTier` (disk-reconciled: local/b2/r2/missing), per-user summary. `sort` = size\|date\|duration\|tier\|views\|accessed, `order` = asc\|desc, `provider` = local\|b2\|r2 (`tier` accepts legacy hot/cold aliases) |
| GET | `/admin/storage/vods?limit&offset&sort&order&provider&tier` | detailed app VOD listing: id, title, size, provider, health, views, `last_accessed_at`, created + `fileExists`/`diskSize`/`actualTier` (disk-reconciled: local/b2/r2/missing), per-user summary. `sort` = size\|date\|duration\|tier\|views\|accessed, `order` = asc\|desc, `provider` = local\|b2\|r2 (`tier` accepts legacy hot/cold aliases) |
| DELETE | `/admin/storage/vods/bulk` | `{ ids: [...] }` (max 200) — deletes each VOD everywhere (local + B2 + R2 + sidecars/master + thumbnail + row), app-scoped → `{ deleted, freed, results: [{id, ok, error?}] }` |
| DELETE | `/admin/storage/vods/bulk` | `{ ids: [...] }` (max 200) — deletes each VOD everywhere (local + B2 + R2 + sidecars/master + thumbnail + row), app-scoped → `{ deleted, freed, results: [{id, ok, error?}] }` |
| GET | `/admin/storage/tiers` | tiering status: settings, provider health, local disk, service-wide tier counts (`tiers`/`clipTiers`), `sweepRunning`, plus `app.{tiers, pendingOffload}` scoped to the caller |
| GET | `/admin/storage/tiers` | tiering status: settings, provider health, local disk, service-wide tier counts (`tiers`/`clipTiers`), `sweepRunning`, plus `app.{tiers, pendingOffload}` scoped to the caller |
@@ -531,7 +527,7 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
@@ -531,7 +527,7 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
| `GET /c/:id` | clip playback, same logic, `noindex` |
| `GET /c/:id` | clip playback, same logic, `noindex` |
| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
| `GET /p/:slug/raw` | **301** to Community's raw text |
| `GET /p/:slug/raw` | **301** to Community's raw text |
-| `GET /p/:slug/screenshot` | paste screenshot image |
+| `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |
| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |
@@ -573,8 +569,7 @@ counts its viewer for the day, identifying no one (see Storage tiering).
@@ -573,8 +569,7 @@ counts its viewer for the day, identifying no one (see Storage tiering).
Every stored blob is a **media object** (`med_<ULID>`, `media_objects`) with one
Every stored blob is a **media object** (`med_<ULID>`, `media_objects`) with one
`media_locations` row per copy (local / B2 canonical / R2 cache, each `present`,
`media_locations` row per copy (local / B2 canonical / R2 cache, each `present`,
-`missing`, `pending` or `corrupt`). The `vods`, `clips`, `files` and screenshot
-`pastes` rows are typed projections over objects (`object_id` column); every
+`missing`, `pending` or `corrupt`). The `vods`, `clips` and `files` rows are typed projections over objects (`object_id` column); every
existing route and response is unchanged, and the old write paths keep the
existing route and response is unchanged, and the old write paths keep the
model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.
model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.
@@ -2,12 +2,12 @@
@@ -2,12 +2,12 @@
Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into one canonical object
Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into one canonical object
platform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited
platform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited
-`vods`, `clips`, `files` and screenshot `pastes` rows stay as they are, and each one is now a typed
-projection over an object through its `object_id` column. Pastes moved to OpenVibe.Community on
-2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only, and
-its v1 pastes API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted; only the public
-`/p/:slug` routes remain). Other existing URLs and v1 responses are
-unchanged. New code talks to objects through the [v2 API](#object-api-v2).
+`vods`, `clips` and `files` rows are typed projections over objects through their
+`object_id` columns. Pastes moved to OpenVibe.Community on 2026-09-22. Media does not
+read or write its frozen paste tables. Legacy screenshot and avatar objects are found
+by `legacy_ref`, and `/p/:slug/screenshot` redirects to `/o/<id>` for ready, nonprivate
+objects. New avatars are native objects. New code talks to objects through the
+[v2 API](#object-api-v2).
Code: `server/objects/` (`model.js`, `routes.js`, `reconcile.js`, `invariant.js`,
Code: `server/objects/` (`model.js`, `routes.js`, `reconcile.js`, `invariant.js`,
`signing.js`, `verify-job.js`, `copy-report.js`, `multipart.js`, `content-type.js`, `readiness.js`, `namespaces.js`,
`signing.js`, `verify-job.js`, `copy-report.js`, `multipart.js`, `content-type.js`, `readiness.js`, `namespaces.js`,
@@ -54,12 +54,11 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu
@@ -54,12 +54,11 @@ the transitional `media.media-ref@1` legacy form in `legacy_ref`, which is uniqu
| `clips.id` | clip | `legacy:<app>:clip:<id>` |
| `clips.id` | clip | `legacy:<app>:clip:<id>` |
| `files.key` | file | `legacy:<app>:file:<key>` |
| `files.key` | file | `legacy:<app>:file:<key>` |
| screenshot paste | screenshot | `legacy:<app>:paste:<slug>` |
| screenshot paste | screenshot | `legacy:<app>:paste:<slug>` |
-| avatar (screenshot paste with `metadata.kind = 'avatar'`) | avatar | `legacy:<app>:avatar:<slug>` |
+| legacy avatar | avatar | `legacy:<app>:avatar:<slug>` |
| `vods`/`clips.thumbnail_url` (`/t/<name>`) | thumbnail | `legacy:<app>:thumbnail:<name>` |
| `vods`/`clips.thumbnail_url` (`/t/<name>`) | thumbnail | `legacy:<app>:thumbnail:<name>` |
-Paste rows are frozen since pastes moved to OpenVibe.Community (2026-09-22): no new screenshot or
-avatar objects are created through Media's paste API, and the existing ones cannot be deleted
-(`DELETE` answers 409 `media.object.legacy_managed`).
+Legacy screenshot and avatar objects cannot be deleted through the direct v2 delete route
+(`DELETE` answers 409 `media.object.legacy_managed`). Account deletion removes them.
Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.
Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.
@@ -77,7 +76,7 @@ Every v2 route that takes `:id` accepts either form. Native v2 objects have `leg
@@ -77,7 +76,7 @@ Every v2 route that takes `:id` accepts either form. Native v2 objects have `leg
- Visibility: `visibility`, falling back to `is_public`, the same way playback decides.
- Visibility: `visibility`, falling back to `is_public`, the same way playback decides.
- Clips-only (ephemeral) recordings are never projected.
- Clips-only (ephemeral) recordings are never projected.
- **file.** The local file under `FILES_PATH/<app>/<key>`. `visibility = public`, because `/f/:key` serves every file without auth. The row's sha256 becomes both `content_hash` and the location `checksum`.
- **file.** The local file under `FILES_PATH/<app>/<key>`. `visibility = public`, because `/f/:key` serves every file without auth. The row's sha256 becomes both `content_hash` and the location `checksum`.
-- **screenshot / avatar.** `screenshot_path`. Text pastes have no bytes and no object; paste text belongs to Community.
+- **legacy screenshot / avatar.** Their local file is named by a `media_locations` row. Text pastes belong to Community.
- **thumbnail.** One object per vod/clip, reached through the parent's `thumbnail` variant and **updated in place** when the picture is regenerated (live recordings refresh it every couple of minutes). Visibility is `public` when the parent is public and `unlisted` otherwise, because `/t/<name>` serves anyone who has the name. An external `thumbnail_url` is skipped.
- **thumbnail.** One object per vod/clip, reached through the parent's `thumbnail` variant and **updated in place** when the picture is regenerated (live recordings refresh it every couple of minutes). Visibility is `public` when the parent is public and `unlisted` otherwise, because `/t/<name>` serves anyone who has the name. An external `thumbnail_url` is skipped.
The historical projection of imported rows is preserved for tests in `test/helpers/project-rows.js`. It is not a production path.
The historical projection of imported rows is preserved for tests in `test/helpers/project-rows.js`. It is not a production path.
@@ -107,7 +106,7 @@ after the outcome event.
@@ -107,7 +106,7 @@ after the outcome event.
| avatar ingest | `avatars/ingest` |
| avatar ingest | `avatars/ingest` |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep: the copy the move verified is marked `present` in the same transaction |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep: the copy the move verified is marked `present` in the same transaction |
-**Deletes need nothing more.** A PostgreSQL trigger (defined in `migrations/`, PL/pgSQL) on `vods`, `clips`, `files` and `pastes` marks the
+**Deletes need nothing more.** A PostgreSQL trigger (defined in `migrations/`, PL/pgSQL) on `vods`, `clips` and `files` marks the
object `deleted` in the same statement whenever its row is deleted, which covers all of the
object `deleted` in the same statement whenever its row is deleted, which covers all of the
inherited delete paths.
inherited delete paths.
@@ -117,7 +116,7 @@ reconciliation verifies them. Two writes record something that already happened
@@ -117,7 +116,7 @@ reconciliation verifies them. Two writes record something that already happened
database, so they never lose the row to its object: the recorder's start (ffmpeg is running) and the
database, so they never lose the row to its object: the recorder's start (ffmpeg is running) and the
tier moves (the bytes have moved). If their combined transaction fails, the row is written alone and
tier moves (the bytes have moved). If their combined transaction fails, the row is written alone and
re-projected after it (`withObjectOrRow`), with a warning. After a move that removed the local file, a follow-up re-projection re-reads the disk. Columns no
re-projected after it (`withObjectOrRow`), with a warning. After a move that removed the local file, a follow-up re-projection re-reads the disk. Columns no
-object carries (view counts, `master_file_path`, `cut_attempts`, paste likes and AI fields) are
+object carries (view counts, `master_file_path`, `cut_attempts`, other app fields) are
written without one. `db.importLegacyRows` is the historical cutover import.
written without one. `db.importLegacyRows` is the historical cutover import.
### C-75 retirement
### C-75 retirement
@@ -474,11 +473,10 @@ While any hold applies:
@@ -474,11 +473,10 @@ While any hold applies:
- **No delete path works.**
- **No delete path works.**
- v2 `DELETE` answers 409.
- v2 `DELETE` answers 409.
- - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2, so a paste row is only ever deleted by SQL, which the trigger refuses).
+ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2).
- Quarantine cleanup and the boot junk sweep skip the object.
- Quarantine cleanup and the boot junk sweep skip the object.
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- `deleteVodObjects` keeps the bytes.
- `deleteVodObjects` keeps the bytes.
- - A paste screenshot has no removal path left at all: the paste API and, with it, the burn-after-read delete went in T10.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
@@ -539,8 +537,8 @@ or a row.
@@ -539,8 +537,8 @@ or a row.
| section | what it lists |
| section | what it lists |
|---|---|
|---|---|
-| `unreferenced_local` | files under `VOD_PATH`, `CLIPS_PATH`, `THUMBNAILS_PATH`, `FILES_PATH`, `PASTES_PATH`, `OBJECTS_PATH`, `ASSETS_PATH` that no row names (`media_locations`, `vods` with their sidecars, masters and chunk segments, `clips`, `files`, `pastes`, `assets`, open upload sessions, unfinished jobs). A file whose only row is a deleted object's location counts, unless that object is native and inside its retention period, or held. Largest first, with a hint: a recorder name whose VOD row is gone, bytes of a deleted object, parts of a session that no longer exists, a temp file of an upload that never finished |
-| `unreferenced_remote` | B2/R2 keys that no `media_locations` row and no `vods`/`clips` row names; hints for `vods-orphans/` (see below), an R2 copy left behind after its VOD went back to B2, legacy paste screenshots, recorder names |
+| `unreferenced_local` | files under `VOD_PATH`, `CLIPS_PATH`, `THUMBNAILS_PATH`, `FILES_PATH`, `PASTES_PATH`, `OBJECTS_PATH`, `ASSETS_PATH` that no row names (`media_locations`, `vods` with their sidecars, masters and chunk segments, `clips`, `files`, `assets`, open upload sessions, unfinished jobs). A file whose only row is a deleted object's location counts, unless that object is native and inside its retention period, or held. Largest first, with a hint: a recorder name whose VOD row is gone, bytes of a deleted object, parts of a session that no longer exists, a temp file of an upload that never finished |
+| `unreferenced_remote` | B2/R2 keys that no `media_locations` row and no `vods`/`clips` row names; hints for `vods-orphans/` (see below), an R2 copy left behind after its VOD went back to B2, recorder names |
| `missing` | copies the database records for objects that are not deleted, whose bytes are not there: no local file, or a key absent from its bucket's listing |
| `missing` | copies the database records for objects that are not deleted, whose bytes are not there: no local file, or a key absent from its bucket's listing |
| `multipart_local` | Media's own multipart sessions still open (`media_uploads` active or completing), with `expired` when past `expires_at` |
| `multipart_local` | Media's own multipart sessions still open (`media_uploads` active or completing), with `expired` when past `expires_at` |
| `multipart_remote` | multipart uploads the buckets still hold open (a killed upload leaves its parts stored and billed) |
| `multipart_remote` | multipart uploads the buckets still hold open (a killed upload leaves its parts stored and billed) |
@@ -600,7 +598,7 @@ that only moved to B2/R2 keeps it. Test: `test/content-hash.test.js`.
@@ -600,7 +598,7 @@ that only moved to B2/R2 keeps it. Test: `test/content-hash.test.js`.
node scripts/no-good-copy-report.js [--app live] [--json] [--out report.json]
node scripts/no-good-copy-report.js [--app live] [--json] [--out report.json]
```
```
- For each object it prints the owner (app, `owner_user_id`, `owner_subject`), `legacy_ref`, the `vods`/`clips`/`files`/`pastes` rows that point at it, its relationships, variants and holds, every location with its state and `verified_at`, and the last verification. The report opens the database read-only. It exits 1 when there are any.
+ For each object it prints the owner (app, `owner_user_id`, `owner_subject`), `legacy_ref`, the `vods`/`clips`/`files` rows that point at it, its relationships, variants and holds, every location with its state and `verified_at`, and the last verification. The report opens the database read-only. It exits 1 when there are any.
## Public object-size invariant
## Public object-size invariant
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
# OpenVibe.Media — end-to-end smoke test.
# OpenVibe.Media — end-to-end smoke test.
# Boots the service against a throwaway data dir, then exercises:
# Boots the service against a throwaway data dir, then exercises:
# vod create → chunk upload → complete → meta (duration+thumbnail) →
# vod create → chunk upload → complete → meta (duration+thumbnail) →
-# clip cut → /v range playback → paste (+ /p page + /raw) →
-# paste admin (screenshot censor + stats) → admin storage (overview, vod
+# clip cut → /v range playback → retired paste API and /p redirect →
+# admin storage (overview, vod
# listing, tier-settings round-trip, buckets, bulk delete) → file upload →
# listing, tier-settings round-trip, buckets, bulk delete) → file upload →
# /f fetch → bad API key rejected → cross-tenant key rejected.
# /f fetch → bad API key rejected → cross-tenant key rejected.
set -u
set -u
@@ -15,8 +15,7 @@
@@ -15,8 +15,7 @@
* - VODs, clips and files are removed like their delete routes do (the bytes
* - VODs, clips and files are removed like their delete routes do (the bytes
* everywhere, the row, and the object through the row-delete trigger);
* everywhere, the row, and the object through the row-delete trigger);
* - the thumbnails of those VODs and clips are marked deleted.
* - the thumbnails of those VODs and clips are marked deleted.
- * Kept, and counted: media under a retention hold (ADR-006), and paste screenshots
- * and avatars, whose rows sit in the frozen paste tables Community took over.
+ * Media under a retention hold (ADR-006) is kept and counted.
* Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).
* Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).
*/
*/
const fs = require('fs');
const fs = require('fs');
@@ -89,7 +88,8 @@ async function erase(db, subjects) {
@@ -89,7 +88,8 @@ async function erase(db, subjects) {
await db.prepare("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, ov_now()), updated_at = ov_now() WHERE id = ? AND lifecycle_status != 'deleted'").run(o.id);
await db.prepare("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, ov_now()), updated_at = ov_now() WHERE id = ? AND lifecycle_status != 'deleted'").run(o.id);
bump(erased, 'thumbnails');
bump(erased, 'thumbnails');
} else {
} else {
- bump(retained, `frozen_${o.kind}s`); // screenshots and avatars: rows in the frozen paste tables
+ await model.softDelete(o, { by: 'account_deleted' });
+ bump(erased, 'objects');
}
}
} catch (e) {
} catch (e) {
if (/retention hold/.test(e.message)) { bump(retained, 'held_media'); continue; }
if (/retention hold/.test(e.message)) { bump(retained, 'held_media'); continue; }
@@ -107,7 +107,7 @@ router.get('/', async (req, res) => {
@@ -107,7 +107,7 @@ router.get('/', async (req, res) => {
const directories = [
const directories = [
{ name: 'vods', path: config.vod.path },
{ name: 'vods', path: config.vod.path },
{ name: 'clips', path: config.vod.clipsPath },
{ name: 'clips', path: config.vod.clipsPath },
- { name: 'pastes', path: config.pastes.path },
+ { name: 'pastes', path: config.pastes.path }, // Legacy files still live in this directory.
{ name: 'thumbnails', path: config.thumbnails.path },
{ name: 'thumbnails', path: config.thumbnails.path },
{ name: 'files', path: config.files.path },
{ name: 'files', path: config.files.path },
];
];
@@ -124,14 +124,6 @@ router.get('/', async (req, res) => {
@@ -124,14 +124,6 @@ router.get('/', async (req, res) => {
FROM vods WHERE app_id = ?
FROM vods WHERE app_id = ?
`, [req.appId]) || {};
`, [req.appId]) || {};
const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};
const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};
- // Paste rows are frozen (pastes moved to OpenVibe.Community, 2026-09-22) and
- // the read-only paste API was retired (T10 step 2); the count stays
- // while the table exists (it feeds /api/v2/me and operator stats).
- const pasteStats = await db.get(`
- SELECT COUNT(*) AS count,
- SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots
- FROM pastes WHERE app_id = ?
- `, [req.appId]) || {};
const fileStats = await db.get('SELECT COUNT(*) AS count, COALESCE(SUM(size), 0)::bigint AS bytes FROM files WHERE app_id = ?', [req.appId]) || {};
const fileStats = await db.get('SELECT COUNT(*) AS count, COALESCE(SUM(size), 0)::bigint AS bytes FROM files WHERE app_id = ?', [req.appId]) || {};
res.json({
res.json({
@@ -142,7 +134,6 @@ router.get('/', async (req, res) => {
@@ -142,7 +134,6 @@ router.get('/', async (req, res) => {
breakdown,
breakdown,
vodStats,
vodStats,
clipStats,
clipStats,
- pasteStats,
fileStats,
fileStats,
byProvider: {
byProvider: {
vods: await _providerCounts('vods', req.appId, true),
vods: await _providerCounts('vods', req.appId, true),
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Avatar ingestion — every avatar on the network is a file HERE.
// Avatar ingestion — every avatar on the network is a file HERE.
//
//
-// POST /internal/avatar-ingest { url, user_id, username } (internal key + loopback only)
-// → { ok, slug, url: 'https://openvibe.media/p/<slug>/screenshot', width, height, bytes }
+// POST /internal/avatar-ingest { url, user_id, username, subject } (internal key + loopback only)
+// → { ok, object_id, url: 'https://openvibe.media/o/<id>', width, height, bytes }
//
//
// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.
// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.
// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:
// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:
@@ -14,9 +14,10 @@
@@ -14,9 +14,10 @@
// • 8 MB cap while streaming, 10 s overall deadline
// • 8 MB cap while streaming, 10 s overall deadline
// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image
// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image
// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs
// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs
-// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same
-// system as every other picture (reportable, served with range/caching by the public /p/:slug/screenshot
-// route). The paste API was retired in T10 step 2, so Media has no route that deletes them.
+// The result is a native media object (kind avatar, unlisted) in Live's `<root>.avatars` namespace, owned by the
+// account's subject when Network sends it, created the way an Object API upload is: the namespace and quota are
+// checked with the object, and the ready transition, its invariant row and media.object.uploaded commit together.
+// /o/<id> serves it with range and caching. (Until 2026-10-10 an avatar was a row in the legacy pastes table.)
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const dns = require('dns').promises;
const dns = require('dns').promises;
const https = require('https');
const https = require('https');
@@ -34,10 +35,10 @@ const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE
@@ -34,10 +35,10 @@ const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE
const egress = require('openvibe-shared/egress');
const egress = require('openvibe-shared/egress');
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
-// Where avatars land and how their slug is minted: the paste storage module
-// (server/pastes/storage.js), a leaf with no router. The defaults keep the exact
-// persisted path and slug behavior; tests may inject their own.
-const pasteStorage = require('../pastes/storage');
+// The tenant every avatar has lived in (Media has no tenant of Network's own) and the namespace below its root.
+const AVATAR_APP = 'live';
+const AVATAR_NAMESPACE = 'avatars';
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
async function resolvePublic(hostname) {
async function resolvePublic(hostname) {
const host = egress.normalizeHost(hostname);
const host = egress.normalizeHost(hostname);
@@ -99,25 +100,67 @@ async function toAvatar(buf) {
@@ -99,25 +100,67 @@ async function toAvatar(buf) {
return { buffer: out, width: SIZE, height: SIZE };
return { buffer: out, width: SIZE, height: SIZE };
}
}
-function createIngestHandler({ db, config, screenshotsDir = pasteStorage.SCREENSHOTS_DIR, generateSlug = pasteStorage.generateSlug, log = console }) {
+/**
+ * Store one avatar's bytes as a native object; returns its id. Throws (with nothing left behind) when the tenant,
+ * the namespace or the quota refuses it.
+ */
+async function storeAvatar({ db, pic, subject = null, username = null, sourceHost = null }) {
+ const model = require('../objects/model');
+ const namespaces = require('../objects/namespaces');
+ const invariant = require('../objects/invariant');
+ const { announce } = require('../webhooks');
+ const app = await db.getApp(AVATAR_APP);
+ if (!app) throw new Error('Picture storage is not configured on this server');
+ const { namespace } = namespaces.resolveName(app, AVATAR_NAMESPACE);
+ const bytes = pic.buffer.length;
+ const sha256 = crypto.createHash('sha256').update(pic.buffer).digest('hex');
+ // The namespace row, the quota check and the object commit together, as an API upload's init does.
+ const made = await db.getDb().tx(async () => {
+ const row = await namespaces.ensure(app, namespace);
+ if (row.error) return { error: row.error };
+ const q = await namespaces.checkQuota(app, namespace, { bytes, objects: 1 });
+ if (q) return { error: q.detail || 'Picture storage is full' };
+ return {
+ id: await model.createObject({
+ app_id: AVATAR_APP, namespace, kind: 'avatar', owner_subject: SUBJECT_RE.test(String(subject || '')) ? subject : null,
+ visibility: 'unlisted', lifecycle_status: 'uploading', mime_type: 'image/webp', size_bytes: bytes, content_hash: sha256,
+ metadata: { filename: 'avatar.webp', title: `Avatar${username ? ' of ' + String(username).slice(0, 40) : ''}`, source: 'avatar.ingest', source_host: sourceHost },
+ }),
+ };
+ });
+ if (made.error) throw new Error(made.error);
+ const obj = await model.getObject(made.id);
+ const dest = model.objectFilePath(obj);
+ try {
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.writeFileSync(dest, pic.buffer);
+ await announce(AVATAR_APP, 'media.object.uploaded', {
+ change: async () => {
+ await model.upsertLocation(obj.id, { provider: 'local', key: dest, state: 'present', size_bytes: bytes, checksum: sha256, verified: true });
+ await model.updateObject(obj.id, { lifecycle_status: 'ready', canonical_provider: 'local', canonical_key: dest });
+ await invariant.record(await model.getObject(obj.id));
+ },
+ payload: async () => await model.objectPublic(await model.getObject(obj.id)),
+ });
+ } catch (err) {
+ try { fs.unlinkSync(dest); } catch { /* not written */ }
+ await db.run('DELETE FROM media_objects WHERE id = ?', [obj.id]).catch(() => {});
+ throw err;
+ }
+ await namespaces.reconcileChain(app, namespace).catch(() => {});
+ return obj.id;
+}
+
+function createIngestHandler({ db, config, log = console }) {
return async function avatarIngest(req, res) {
return async function avatarIngest(req, res) {
try {
try {
- const { url, user_id, username } = req.body || {};
+ const { url, user_id, username, subject } = req.body || {};
if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });
if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });
const raw = await safeFetchImage(url);
const raw = await safeFetchImage(url);
const pic = await toAvatar(raw);
const pic = await toAvatar(raw);
- fs.mkdirSync(screenshotsDir, { recursive: true });
- const file = path.join(screenshotsDir, `avatar-n${parseInt(user_id, 10) || 0}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.webp`);
- fs.writeFileSync(file, pic.buffer);
- const slug = await generateSlug(); // async since the PostgreSQL switch (it checks the table)
- const insert = async () => await db.run(`INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata, ip_address)
- VALUES ('network', ?, ?, 'screenshot', ?, '', 'text', 'unlisted', ?, ?, NULL) RETURNING id`,
- [slug, parseInt(user_id, 10) || null, `Avatar${username ? ' of ' + String(username).slice(0, 40) : ''}`, file,
- JSON.stringify({ kind: 'avatar', source_host: (() => { try { return new URL(url).hostname; } catch { return null; } })(), size_bytes: pic.buffer.length, mime_type: 'image/webp' })]);
- // The row and the avatar's media_object in one transaction (a database without the object model: the row alone).
- if (typeof db.withObject === 'function') await db.withObject('paste', (r) => r.lastInsertRowid, insert);
- else await insert();
- res.json({ ok: true, slug, url: `${config.publicUrl}/p/${slug}/screenshot`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
+ const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();
+ const id = await storeAvatar({ db, pic, subject, username, sourceHost });
+ res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
} catch (err) {
} catch (err) {
log.warn('[Avatar ingest]', err.message);
log.warn('[Avatar ingest]', err.message);
res.status(422).json({ ok: false, error: /unsupported image|Input buffer|corrupt|VipsJpeg|bad seek/i.test(err.message) ? 'That file is not a picture we can read' : err.message });
res.status(422).json({ ok: false, error: /unsupported image|Input buffer|corrupt|VipsJpeg|bad seek/i.test(err.message) ? 'That file is not a picture we can read' : err.message });
@@ -125,4 +168,4 @@ function createIngestHandler({ db, config, screenshotsDir = pasteStorage.SCREENS
@@ -125,4 +168,4 @@ function createIngestHandler({ db, config, screenshotsDir = pasteStorage.SCREENS
};
};
}
}
-module.exports = { createIngestHandler, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };
+module.exports = { createIngestHandler, storeAvatar, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* OpenVibe.Media — Database
* OpenVibe.Media — Database
*
*
- * Lean port of the predecessor's vod/clip/paste helpers with app_id (tenant)
+ * Lean port of the predecessor's vod/clip helpers with app_id (tenant)
* scoping on every query. PostgreSQL through openvibe-sdk/db (ADR-035); every helper is async.
* scoping on every query. PostgreSQL through openvibe-sdk/db (ADR-035); every helper is async.
*/
*/
@@ -76,7 +76,7 @@ function heldSql(ref) {
@@ -76,7 +76,7 @@ function heldSql(ref) {
/**
/**
* Object-first write (WS-G task 1, C-75 retired 2026-10-10): run write() — a change to a projected
* Object-first write (WS-G task 1, C-75 retired 2026-10-10): run write() — a change to a projected
- * vods/clips/files/pastes row — and re-project the row's media_object in the same transaction
+ * vods/clips/files row — and re-project the row's media_object in the same transaction
* (objects/model.js withObject). Throws, with nothing written, when either part fails.
* (objects/model.js withObject). Throws, with nothing written, when either part fails.
*/
*/
async function withObject(kind, ids, write) {
async function withObject(kind, ids, write) {
@@ -332,9 +332,10 @@ async function getAppStats(appId) {
@@ -332,9 +332,10 @@ async function getAppStats(appId) {
return {
return {
vods: await c(`SELECT COUNT(*) n ${vodBase}`, [appId]),
vods: await c(`SELECT COUNT(*) n ${vodBase}`, [appId]),
clips: await c(`SELECT COUNT(*) n ${clipBase}`, [appId]),
clips: await c(`SELECT COUNT(*) n ${clipBase}`, [appId]),
- pastes: await c('SELECT COUNT(*) n FROM pastes WHERE app_id = ?', [appId]),
- pasteImages: await c("SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND type = 'screenshot'", [appId]),
- pasteText: await c("SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND COALESCE(type,'paste') <> 'screenshot'", [appId]),
+ // Pastes belong to Community, and Live reads them there; null keys remain for one release for N-1 clients.
+ pastes: null,
+ pasteImages: null,
+ pasteText: null,
durationSeconds: await c(`SELECT COALESCE(SUM(duration_seconds),0) n ${vodBase}`, [appId]),
durationSeconds: await c(`SELECT COALESCE(SUM(duration_seconds),0) n ${vodBase}`, [appId]),
recent: {
recent: {
vods: await win(`SELECT COUNT(*) n ${vodBase} AND created_at >= datetime('now', ?)`, [appId]),
vods: await win(`SELECT COUNT(*) n ${vodBase} AND created_at >= datetime('now', ?)`, [appId]),
@@ -352,7 +353,6 @@ async function getAppStats(appId) {
@@ -352,7 +353,6 @@ async function getAppStats(appId) {
const STAT_SERIES = {
const STAT_SERIES = {
vods: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COUNT(*)' },
vods: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COUNT(*)' },
clips: { base: 'FROM clips WHERE app_id = ? AND COALESCE(is_public,1) = 1', agg: 'COUNT(*)' },
clips: { base: 'FROM clips WHERE app_id = ? AND COALESCE(is_public,1) = 1', agg: 'COUNT(*)' },
- pastes: { base: 'FROM pastes WHERE app_id = ?', agg: 'COUNT(*)' },
hours: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COALESCE(SUM(duration_seconds),0) / 3600.0' },
hours: { base: "FROM vods WHERE app_id = ? AND is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0", agg: 'COALESCE(SUM(duration_seconds),0) / 3600.0' },
};
};
async function getAppStatSeries(appId, metric, days = 30) {
async function getAppStatSeries(appId, metric, days = 30) {
@@ -521,124 +521,6 @@ async function findDuplicateClip({ appId, streamId = null, vodId = null, startTi
@@ -521,124 +521,6 @@ async function findDuplicateClip({ appId, streamId = null, vodId = null, startTi
`, [...params, startTime || 0, startWindow, endTime || 0, endWindow, `-${Math.max(1, createdSinceMinutes)} minutes`]);
`, [...params, startTime || 0, startWindow, endTime || 0, endWindow, `-${Math.max(1, createdSinceMinutes)} minutes`]);
}
}
-// ── Paste helpers ────────────────────────────────────────────
-
-async function getPasteBySlug(slug, appId = null) {
- const clause = appId ? ' AND app_id = ?' : '';
- const params = appId ? [slug, appId] : [slug];
- return await get(`SELECT * FROM pastes WHERE slug = ?${clause}`, params);
-}
-
-async function likePaste(pasteId, userId) {
- await run('INSERT INTO paste_likes (paste_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING', [pasteId, userId]);
- await run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);
- return await get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);
-}
-
-async function unlikePaste(pasteId, userId) {
- await run('DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
- await run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);
- return await get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);
-}
-
-async function hasUserLikedPaste(pasteId, userId) {
- return !!await get('SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
-}
-
-async function incrementPasteCopies(slug) {
- return await run('UPDATE pastes SET copies = copies + 1 WHERE slug = ?', [slug]);
-}
-
-async function countUserPastesToday(appId, userId, ip) {
- if (userId) {
- return (await get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')", [appId, userId]))?.c || 0;
- }
- if (ip) {
- return (await get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')", [appId, ip]))?.c || 0;
- }
- return 0;
-}
-
-async function getLastPasteTime(appId, userId, ip) {
- let row;
- if (userId) {
- row = await get('SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1', [appId, userId]);
- } else if (ip) {
- row = await get('SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1', [appId, ip]);
- }
- return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;
-}
-
-/** App-scoped paste stats (predecessor's admin stats shape). */
-async function getPasteStats(appId) {
- const row = await get(`
- SELECT COUNT(*) AS total,
- SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS "textPastes",
- SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,
- SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,
- COALESCE(SUM(views), 0)::bigint AS "totalViews",
- COALESCE(SUM(copies), 0)::bigint AS "totalCopies",
- COALESCE(SUM(likes), 0)::bigint AS "totalLikes"
- FROM pastes WHERE app_id = ?
- `, [appId]) || {};
- return {
- total: row.total || 0,
- textPastes: row.textPastes || 0,
- screenshots: row.screenshots || 0,
- forks: row.forks || 0,
- totalViews: row.totalViews || 0,
- totalCopies: row.totalCopies || 0,
- totalLikes: row.totalLikes || 0,
- };
-}
-
-// ── Paste comment helpers ────────────────────────────────────
-
-async function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
- return await run(
- `INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address)
- VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
- [paste_id, user_id || null, parent_id || null, anon_name || null, message, ip_address || null]
- );
-}
-
-async function getPasteComments(pasteId, limit = 50, offset = 0) {
- return await all(`
- SELECT * FROM paste_comments
- WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
- ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- `, [pasteId, limit, offset]);
-}
-
-async function getPasteCommentReplies(parentId) {
- return await all(`
- SELECT * FROM paste_comments
- WHERE parent_id = ? AND is_deleted = 0
- ORDER BY created_at ASC
- `, [parentId]);
-}
-
-async function getPasteCommentById(commentId) {
- return await get('SELECT * FROM paste_comments WHERE id = ?', [commentId]);
-}
-
-async function getPasteCommentCount(pasteId) {
- return (await get('SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0', [pasteId]))?.count || 0;
-}
-
-async function deletePasteComment(commentId) {
- return await run('UPDATE paste_comments SET is_deleted = 1, updated_at = ov_now() WHERE id = ?', [commentId]);
-}
-
-async function getRecentPasteCommentsByIp(ip, seconds = 10) {
- return await all(`
- SELECT * FROM paste_comments
- WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds')
- ORDER BY created_at DESC
- `, [ip, seconds]);
-}
-
// ── File helpers ─────────────────────────────────────────────
// ── File helpers ─────────────────────────────────────────────
// The row and its media_object in one transaction (the file is already on disk, sha256 computed by the route).
// The row and its media_object in one transaction (the file is already on disk, sha256 computed by the route).
@@ -714,11 +596,6 @@ module.exports = {
@@ -714,11 +596,6 @@ module.exports = {
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
// clips
// clips
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
- // pastes
- getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
- countUserPastesToday, getLastPasteTime, getPasteStats,
- createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
- getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
// files
// files
createFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,
createFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,
// migration
// migration
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
*
*
* Multi-tenant media service: VOD ingest/recording (RTMP pull, RTP, browser
* Multi-tenant media service: VOD ingest/recording (RTMP pull, RTP, browser
* chunks), local/B2/R2 storage tiering with presigned-302 playback, clips,
* chunks), local/B2/R2 storage tiering with presigned-302 playback, clips,
- * pastes, files, thumbnails, and HMAC-signed webhooks. See CONTRACTS.md
+ * files, thumbnails, legacy screenshot redirects, and HMAC-signed webhooks. See CONTRACTS.md
* (Media API v1) and README.md.
* (Media API v1) and README.md.
*/
*/
'use strict';
'use strict';
@@ -129,8 +129,7 @@ const ready = (async () => {
@@ -129,8 +129,7 @@ const ready = (async () => {
{
{
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
- // route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)
- // from server/pastes/storage.js itself — a leaf, not a router.
+ // route (server/service-guard.js). The handler stores native avatar objects.
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
}
}
@@ -200,7 +199,7 @@ const ready = (async () => {
@@ -200,7 +199,7 @@ const ready = (async () => {
}
}
});
});
- // Daily series behind a stat (vods, clips, pastes, hours) — "over time" charts in the owning app.
+ // Daily series behind a stat (vods, clips, hours) — "over time" charts in the owning app.
app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth({ verb: 'read' }), async (req, res) => {
app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth({ verb: 'read' }), async (req, res) => {
try {
try {
const series = await db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);
const series = await db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);
@@ -110,7 +110,6 @@ async function projection(appId) {
@@ -110,7 +110,6 @@ async function projection(appId) {
vods: (await db.get(`SELECT COUNT(*) AS n FROM vods WHERE object_id IS NULL AND COALESCE(clips_only, 0) = 0${s.sql}`, s.params)).n,
vods: (await db.get(`SELECT COUNT(*) AS n FROM vods WHERE object_id IS NULL AND COALESCE(clips_only, 0) = 0${s.sql}`, s.params)).n,
clips: (await db.get(`SELECT COUNT(*) AS n FROM clips WHERE object_id IS NULL${s.sql}`, s.params)).n,
clips: (await db.get(`SELECT COUNT(*) AS n FROM clips WHERE object_id IS NULL${s.sql}`, s.params)).n,
files: (await db.get(`SELECT COUNT(*) AS n FROM files WHERE object_id IS NULL${s.sql}`, s.params)).n,
files: (await db.get(`SELECT COUNT(*) AS n FROM files WHERE object_id IS NULL${s.sql}`, s.params)).n,
- screenshots: (await db.get(`SELECT COUNT(*) AS n FROM pastes WHERE object_id IS NULL AND type = 'screenshot' AND COALESCE(screenshot_path, '') != ''${s.sql}`, s.params)).n,
};
};
const owner = await db.all(`SELECT app_id, COUNT(*) AS n FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL
const owner = await db.all(`SELECT app_id, COUNT(*) AS n FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL
AND lifecycle_status != 'deleted'${s.sql} GROUP BY app_id ORDER BY n DESC`, s.params);
AND lifecycle_status != 'deleted'${s.sql} GROUP BY app_id ORDER BY n DESC`, s.params);
@@ -38,7 +38,6 @@ async function references(q, obj) {
@@ -38,7 +38,6 @@ async function references(q, obj) {
clips: await q.all(`SELECT id, app_id, vod_id, stream_id, user_id, channel_user_id, title, visibility, is_public, file_path, status, created_at
clips: await q.all(`SELECT id, app_id, vod_id, stream_id, user_id, channel_user_id, title, visibility, is_public, file_path, status, created_at
FROM clips WHERE object_id = ?`, [obj.id]),
FROM clips WHERE object_id = ?`, [obj.id]),
files: await q.all('SELECT key, app_id, user_id, original_name, size, mime, created_at FROM files WHERE object_id = ?', [obj.id]),
files: await q.all('SELECT key, app_id, user_id, original_name, size, mime, created_at FROM files WHERE object_id = ?', [obj.id]),
- pastes: await q.all('SELECT id, slug, app_id, user_id, title, type, visibility, screenshot_path, created_at FROM pastes WHERE object_id = ?', [obj.id]),
relationships: await q.all(`SELECT from_object_id, relation, to_object_id FROM media_relationships
relationships: await q.all(`SELECT from_object_id, relation, to_object_id FROM media_relationships
WHERE from_object_id = ? OR to_object_id = ? ORDER BY id`, [obj.id, obj.id]),
WHERE from_object_id = ? OR to_object_id = ? ORDER BY id`, [obj.id, obj.id]),
variants: await q.all(`SELECT object_id, variant_name, derived_object_id FROM media_variants
variants: await q.all(`SELECT object_id, variant_name, derived_object_id FROM media_variants
@@ -92,7 +91,6 @@ function formatReport(report) {
@@ -92,7 +91,6 @@ function formatReport(report) {
for (const v of r.vods) out.push(` vods#${v.id}: user ${v.user_id ?? '-'} "${v.title || ''}" ${v.visibility || ''} provider=${v.storage_provider || 'local'} file=${v.file_path || '-'}`);
for (const v of r.vods) out.push(` vods#${v.id}: user ${v.user_id ?? '-'} "${v.title || ''}" ${v.visibility || ''} provider=${v.storage_provider || 'local'} file=${v.file_path || '-'}`);
for (const c of r.clips) out.push(` clips#${c.id}: vod ${c.vod_id ?? '-'} user ${c.user_id ?? '-'} "${c.title || ''}" ${c.visibility || ''} file=${c.file_path || '-'}`);
for (const c of r.clips) out.push(` clips#${c.id}: vod ${c.vod_id ?? '-'} user ${c.user_id ?? '-'} "${c.title || ''}" ${c.visibility || ''} file=${c.file_path || '-'}`);
for (const f of r.files) out.push(` files/${f.key}: user ${f.user_id ?? '-'} ${f.original_name || ''}`);
for (const f of r.files) out.push(` files/${f.key}: user ${f.user_id ?? '-'} ${f.original_name || ''}`);
- for (const p of r.pastes) out.push(` pastes/${p.slug}: user ${p.user_id ?? '-'} ${p.type || ''} ${p.visibility || ''}`);
for (const x of r.relationships) out.push(` relationship: ${x.from_object_id} ${x.relation} ${x.to_object_id}`);
for (const x of r.relationships) out.push(` relationship: ${x.from_object_id} ${x.relation} ${x.to_object_id}`);
for (const x of r.variants) out.push(` variant: ${x.object_id} ${x.variant_name} → ${x.derived_object_id}`);
for (const x of r.variants) out.push(` variant: ${x.object_id} ${x.variant_name} → ${x.derived_object_id}`);
for (const h of r.holds) out.push(` HOLD ${h.kind}: ${h.reason || ''} (by ${h.created_by || '-'})`);
for (const h of r.holds) out.push(` HOLD ${h.kind}: ${h.reason || ''} (by ${h.created_by || '-'})`);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
* OpenVibe.Media — canonical object model (roadmap Wave 4; docs/object-model.md)
* OpenVibe.Media — canonical object model (roadmap Wave 4; docs/object-model.md)
*
*
* Every stored blob is a media_object (id med_<ULID>) with one media_locations
* Every stored blob is a media_object (id med_<ULID>) with one media_locations
- * row per provider copy. The inherited vods/clips/files/pastes rows are typed
+ * row per provider copy. The inherited vods/clips/files rows are typed
* projections over objects: each carries object_id, and the sync*() functions
* projections over objects: each carries object_id, and the sync*() functions
* below derive the object + locations from the row as it is right now. The same
* below derive the object + locations from the row as it is right now. The same
* functions back every write on the old APIs, which writes the row and its object
* functions back every write on the old APIs, which writes the row and its object
@@ -114,7 +114,7 @@ async function isHeld(objectId) {
@@ -114,7 +114,7 @@ async function isHeld(objectId) {
}
}
/**
/**
- * Hold check for an inherited row (vods/clips/files/pastes). A clip row also follows its source VOD's
+ * Hold check for an inherited row (vods/clips/files). A clip row also follows its source VOD's
* hold before it has an object of its own; any other row with no object yet is not held.
* hold before it has an object of its own; any other row with no object yet is not held.
*/
*/
async function isHeldRow(row) {
async function isHeldRow(row) {
@@ -366,24 +366,6 @@ function fileProjection(row) {
@@ -366,24 +366,6 @@ function fileProjection(row) {
};
};
}
}
-/** Screenshot pastes (and avatars, which are stored as screenshot pastes) — the bytes only; text is Community's. */
-function pasteProjection(row) {
- if (row.type !== 'screenshot') return { skipped: 'text paste (no bytes)' };
- if (!row.screenshot_path) return { skipped: 'screenshot paste without a file path' };
- const meta = parseJson(row.metadata, {}) || {};
- const kind = meta.kind === 'avatar' ? 'avatar' : 'screenshot';
- const local = localLocation([row.screenshot_path]);
- return {
- legacy_ref: legacyRef(row.app_id, kind === 'avatar' ? 'avatar' : 'paste', row.slug), existingId: row.object_id,
- app_id: row.app_id, kind, owner_user_id: row.user_id,
- visibility: VISIBILITIES.includes(row.visibility) ? row.visibility : 'public', lifecycle_status: 'ready',
- mime_type: meta.mime_type || mimeFor(row.screenshot_path, 'image/png'),
- size_bytes: local.state === 'present' ? local.size_bytes : (Number(meta.size_bytes) || 0),
- metadata: { title: row.title || null, slug: row.slug },
- created_at: row.created_at, locations: [local], canonical: 'local',
- };
-}
-
async function syncVod(idOrRow) {
async function syncVod(idOrRow) {
const row = typeof idOrRow === 'object' ? idOrRow : await db.get('SELECT * FROM vods WHERE id = ?', [idOrRow]);
const row = typeof idOrRow === 'object' ? idOrRow : await db.get('SELECT * FROM vods WHERE id = ?', [idOrRow]);
if (!row) return null;
if (!row) return null;
@@ -420,16 +402,6 @@ async function syncFile(keyOrRow) {
@@ -420,16 +402,6 @@ async function syncFile(keyOrRow) {
return { ...r, locations: [p.locations[0].state] };
return { ...r, locations: [p.locations[0].state] };
}
}
-async function syncPaste(idOrRow) {
- const row = typeof idOrRow === 'object' ? idOrRow : await db.get('SELECT * FROM pastes WHERE id = ?', [idOrRow]);
- if (!row) return null;
- const p = pasteProjection(row);
- if (p.skipped) return p;
- const r = await project(p);
- await linkRow('pastes', 'id', row.id, r.id);
- return { ...r, kind: p.kind, locations: [p.locations[0].state] };
-}
-
/** Thumbnail file name behind a stored thumbnail_url, or null when it is not one of ours. */
/** Thumbnail file name behind a stored thumbnail_url, or null when it is not one of ours. */
function thumbFileFromUrl(u) {
function thumbFileFromUrl(u) {
if (!u) return null;
if (!u) return null;
@@ -493,14 +465,14 @@ async function _syncTx(fn) {
@@ -493,14 +465,14 @@ async function _syncTx(fn) {
return out;
return out;
}
}
-const SYNC_BY_KIND = { vod: syncVod, clip: syncClip, file: syncFile, paste: syncPaste };
+const SYNC_BY_KIND = { vod: syncVod, clip: syncClip, file: syncFile };
/** Row ids named by withObject()'s `ids` (a value, a list, or a function of write()'s result). */
/** Row ids named by withObject()'s `ids` (a value, a list, or a function of write()'s result). */
function _rowIds(ids, out) {
function _rowIds(ids, out) {
return [].concat((typeof ids === 'function' ? ids(out) : ids) ?? []).filter(id => id != null);
return [].concat((typeof ids === 'function' ? ids(out) : ids) ?? []).filter(id => id != null);
}
}
-/** Re-project one inherited row: kind vod|clip|file|paste, id = row id (file key for files). */
+/** Re-project one inherited row: kind vod|clip|file, id = row id (file key for files). */
async function sync(kind, id) {
async function sync(kind, id) {
const fn = SYNC_BY_KIND[kind];
const fn = SYNC_BY_KIND[kind];
if (!fn || id == null) return null;
if (!fn || id == null) return null;
@@ -519,7 +491,7 @@ async function safeSync(kind, id) {
@@ -519,7 +491,7 @@ async function safeSync(kind, id) {
* Object-first write (WS-G task 1; C-75 retired 2026-10-10, "write the row, then sync its
* Object-first write (WS-G task 1; C-75 retired 2026-10-10, "write the row, then sync its
* object"): write() changes inherited rows and the objects behind them are re-projected in the SAME
* object"): write() changes inherited rows and the objects behind them are re-projected in the SAME
* transaction, so both commit or neither does: no crash or error between the two can leave
* transaction, so both commit or neither does: no crash or error between the two can leave
- * an object behind its row. kind vod|clip|file|paste; ids the row id (a file's key), a list of them,
+ * an object behind its row. kind vod|clip|file; ids the row id (a file's key), a list of them,
* or a function of write()'s result that gives them (an INSERT's lastInsertRowid). write() must be
* or a function of write()'s result that gives them (an INSERT's lastInsertRowid). write() must be
* database work (awaited). Returns write()'s result; throws, with nothing written, when either
* database work (awaited). Returns write()'s result; throws, with nothing written, when either
* part fails. Inside an outer transaction (webhooks.announce) it is a savepoint of that one.
* part fails. Inside an outer transaction (webhooks.announce) it is a savepoint of that one.
@@ -753,11 +725,11 @@ async function objectPublic(obj, { locations = true } = {}) {
@@ -753,11 +725,11 @@ async function objectPublic(obj, { locations = true } = {}) {
module.exports = {
module.exports = {
KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError,
KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError,
- mimeFor, parseJson, parseLegacyRef, thumbFileFromUrl,
+ mimeFor, parseJson, legacyRef, parseLegacyRef, thumbFileFromUrl,
getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,
getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,
createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,
createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,
- vodProjection, clipProjection, fileProjection, pasteProjection, thumbnailProjection,
- syncVod, syncClip, syncFile, syncPaste, sync, safeSync, withObject, withObjectOrRow, afterTierMove,
+ vodProjection, clipProjection, fileProjection, thumbnailProjection,
+ syncVod, syncClip, syncFile, sync, safeSync, withObject, withObjectOrRow, afterTierMove,
placeHold, releaseHold, holdPublic,
placeHold, releaseHold, holdPublic,
objectFilePath, softDelete, restore, purgeExpired, usedBytes,
objectFilePath, softDelete, restore, purgeExpired, usedBytes,
legacyPublicUrl, objectPublic,
legacyPublicUrl, objectPublic,
@@ -53,7 +53,6 @@ const PROJECTIONS = [
@@ -53,7 +53,6 @@ const PROJECTIONS = [
{ table: 'vods', open: (r) => (r.visibility || (r.is_public ? 'public' : 'private')) !== 'private' && !r.clips_only },
{ table: 'vods', open: (r) => (r.visibility || (r.is_public ? 'public' : 'private')) !== 'private' && !r.clips_only },
{ table: 'clips', open: (r) => (r.visibility || (r.is_public ? 'public' : 'private')) !== 'private' },
{ table: 'clips', open: (r) => (r.visibility || (r.is_public ? 'public' : 'private')) !== 'private' },
{ table: 'files', open: () => true },
{ table: 'files', open: () => true },
- { table: 'pastes', open: (r) => r.visibility !== 'private' && !!r.screenshot_path },
];
];
async function reconcile({ verify = false, hash = false, head = defaultHead, hashMaxBytes = 512 * MB, appId = null, listLimit = 200 } = {}) {
async function reconcile({ verify = false, hash = false, head = defaultHead, hashMaxBytes = 512 * MB, appId = null, listLimit = 200 } = {}) {
@@ -175,7 +174,6 @@ async function reconcile({ verify = false, hash = false, head = defaultHead, has
@@ -175,7 +174,6 @@ async function reconcile({ verify = false, hash = false, head = defaultHead, has
['vods', 'id', 'object_id IS NULL AND COALESCE(clips_only, 0) = 0'],
['vods', 'id', 'object_id IS NULL AND COALESCE(clips_only, 0) = 0'],
['clips', 'id', 'object_id IS NULL'],
['clips', 'id', 'object_id IS NULL'],
['files', 'key', 'object_id IS NULL'],
['files', 'key', 'object_id IS NULL'],
- ['pastes', 'id', "object_id IS NULL AND type = 'screenshot' AND screenshot_path IS NOT NULL"],
];
];
for (const [table, keyCol, cond] of gaps) {
for (const [table, keyCol, cond] of gaps) {
for (const r of await db.all(`SELECT ${keyCol} AS k FROM ${table} WHERE ${cond}${appId ? ' AND app_id = ?' : ''}`, appId ? [appId] : [])) {
for (const r of await db.all(`SELECT ${keyCol} AS k FROM ${table} WHERE ${cond}${appId ? ' AND app_id = ?' : ''}`, appId ? [appId] : [])) {
@@ -634,7 +634,7 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:
@@ -634,7 +634,7 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:
const obj = await load(req, res, 'delete');
const obj = await load(req, res, 'delete');
if (!obj) return;
if (!obj) return;
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
- if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete this object through its v1 route (vods, clips, files); paste screenshots and avatars are frozen since pastes moved to OpenVibe.Community');
+ if (obj.legacy_ref) return problem(res, 409, 'media.object.legacy_managed', 'Delete vods, clips and files through their v1 routes; legacy screenshots and avatars are deleted through account deletion');
if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
if (await model.isHeld(obj.id)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
if (obj.lifecycle_status === 'deleted') return res.json(await model.objectPublic(obj));
if (obj.lifecycle_status === 'deleted') return res.json(await model.objectPublic(obj));
const gone = await model.softDelete(obj, { by: req.principal ? req.principal.sub : `app:${req.appId}` });
const gone = await model.softDelete(obj, { by: req.principal ? req.principal.sub : `app:${req.appId}` });
deleted file mode 100644
deleted file mode 100644
@@ -1,66 +0,0 @@
@@ -1,66 +0,0 @@
-'use strict';
-// Paste storage plumbing, shared by the public screenshot route and the avatar ingest
-// (which stores every avatar as an unlisted screenshot paste): where a paste's
-// screenshot bytes land and how a slug is minted (checked against the pastes table,
-// so two writes never get the same one). A leaf module — requiring it pulls in
-// neither a router nor anything that mounts one.
-const fs = require('fs');
-const path = require('path');
-const crypto = require('crypto');
-const db = require('../db/database');
-const config = require('../config');
-const drill = require('../drill');
-
-const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');
-if (!drill.enabled && !fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); // a restore drill writes nothing
-
-// Word pools for readable paste slugs (adj-noun-number)
-const SLUG_ADJECTIVES = [
- 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',
- 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',
- 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',
- 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',
- 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',
- 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',
- 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',
- 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',
- 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',
- 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',
- 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',
- 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',
- 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',
- 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',
- 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',
-];
-const SLUG_NOUNS = [
- 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',
- 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',
- 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',
- 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',
- 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',
- 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',
- 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',
- 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',
- 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',
- 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',
- 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',
- 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',
- 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',
- 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',
- 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',
- 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',
- 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
-];
-
-async function generateSlug(attempts = 0) {
- if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');
- const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];
- const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];
- const num = crypto.randomInt(10, 100); // 10–99
- const slug = `${adj}-${noun}-${num}`;
- const existing = await db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);
- if (existing) return await generateSlug(attempts + 1);
- return slug;
-}
-
-module.exports = { SCREENSHOTS_DIR, generateSlug };
@@ -2,10 +2,9 @@
@@ -2,10 +2,9 @@
* OpenVibe.Media — public media index ("home page").
* OpenVibe.Media — public media index ("home page").
*
*
* Server-rendered browse UI at GET / — a mass index of every public piece of
* Server-rendered browse UI at GET / — a mass index of every public piece of
- * media this service owns, tabbed by kind (Videos / Clips / Images / Text /
- * Thumbnails / Files), each card carrying its description / AI overview and a
- * link back to the SOURCE page in the owning app (the VOD / clip / stream /
- * paste it came from). Dependency-free: inline CSS, no client JS required.
+ * media this service owns, tabbed by kind (Videos / Clips / Thumbnails / Files).
+ * Each card carries its description / AI overview and a link to its source page
+ * in the owning app. Dependency-free: inline CSS, no client JS required.
*/
*/
'use strict';
'use strict';
@@ -17,7 +16,7 @@ const db = require('../db/database');
@@ -17,7 +16,7 @@ const db = require('../db/database');
const PAGE_SIZE = 48;
const PAGE_SIZE = 48;
const THUMB_DIR = path.resolve(config.thumbnails.path);
const THUMB_DIR = path.resolve(config.thumbnails.path);
-// Public base URL per app for "source" links — shared with the watch/paste pages.
+// Public base URL per app for "source" links — shared with the watch pages.
const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');
const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
@@ -46,17 +45,6 @@ function clipCard(c) {
@@ -46,17 +45,6 @@ function clipCard(c) {
source: `${appUrl(c.app_id)}/clip/${c.id}`, sourceLabel: 'Clip page',
source: `${appUrl(c.app_id)}/clip/${c.id}`, sourceLabel: 'Clip page',
};
};
}
}
-function pasteCard(p) {
- const isImage = p.type === 'screenshot';
- return {
- kind: isImage ? 'image' : 'text', title: p.title || p.slug, date: p.created_at, app: p.app_id,
- thumb: isImage ? `${config.publicUrl}/p/${encodeURIComponent(p.slug)}/screenshot` : null,
- text: snip(isImage ? (p.content || p.description) : p.content),
- view: `${config.publicUrl}/p/${encodeURIComponent(p.slug)}`,
- source: `${appUrl(p.app_id)}/p/${encodeURIComponent(p.slug)}`, sourceLabel: 'Paste page',
- lang: p.language && p.language !== 'text' ? p.language : null,
- };
-}
function fileCard(f) {
function fileCard(f) {
return {
return {
kind: 'file', title: f.original_name || f.key, date: f.created_at, app: f.app_id,
kind: 'file', title: f.original_name || f.key, date: f.created_at, app: f.app_id,
@@ -101,7 +89,6 @@ const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(cli
@@ -101,7 +89,6 @@ const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(cli
const C_WHERE = "COALESCE(is_public,1) = 1";
const C_WHERE = "COALESCE(is_public,1) = 1";
// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.
// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.
const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
-const P_WHERE = "visibility = 'public'";
// A VOD's or clip's thumbnail is listed only while the recording itself is public. The exact file name
// A VOD's or clip's thumbnail is listed only while the recording itself is public. The exact file name
// is what /t/<name> serves, so listing a private or unlisted recording's thumbnail would publish a frame
// is what /t/<name> serves, so listing a private or unlisted recording's thumbnail would publish a frame
@@ -146,14 +133,6 @@ async function fetchTab(tab, page) {
@@ -146,14 +133,6 @@ async function fetchTab(tab, page) {
total: (await db.get(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`)).c,
total: (await db.get(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`)).c,
cards: (await db.all(`SELECT * FROM clips WHERE ${C_WHERE} ${L}`)).map(clipCard),
cards: (await db.all(`SELECT * FROM clips WHERE ${C_WHERE} ${L}`)).map(clipCard),
};
};
- case 'images': return {
- total: (await db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`)).c,
- cards: (await db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND type = 'screenshot' ${L}`)).map(pasteCard),
- };
- case 'text': return {
- total: (await db.get(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`)).c,
- cards: (await db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`)).map(pasteCard),
- };
case 'files': return {
case 'files': return {
total: (await db.get(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`)).c,
total: (await db.get(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`)).c,
cards: (await db.all(`SELECT * FROM files WHERE ${F_WHERE} ${L}`)).map(fileCard),
cards: (await db.all(`SELECT * FROM files WHERE ${F_WHERE} ${L}`)).map(fileCard),
@@ -176,19 +155,15 @@ async function fetchTab(tab, page) {
@@ -176,19 +155,15 @@ async function fetchTab(tab, page) {
UNION ALL
UNION ALL
SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}
SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE ${C_WHERE}
UNION ALL
UNION ALL
- SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE ${P_WHERE}
- UNION ALL
SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assets
SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assets
ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);
ORDER BY created_at DESC LIMIT ${PAGE_SIZE} OFFSET ${off}`);
const total = (await db.get(`SELECT
const total = (await db.get(`SELECT
(SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +
(SELECT COUNT(*) FROM vods WHERE ${V_WHERE}) +
(SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +
(SELECT COUNT(*) FROM clips WHERE ${C_WHERE}) +
- (SELECT COUNT(*) FROM pastes WHERE ${P_WHERE}) +
(SELECT COUNT(*) FROM assets) c`)).c;
(SELECT COUNT(*) FROM assets) c`)).c;
const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })
const cards = rows.map(r => r.k === 'vod' ? vodCard({ ...r, id: r.ref })
: r.k === 'clip' ? clipCard({ ...r, id: r.ref })
: r.k === 'clip' ? clipCard({ ...r, id: r.ref })
- : r.k === 'asset' ? assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id })
- : pasteCard({ ...r, id: r.ref }));
+ : assetCard({ id: r.ref, name: r.title, username: r.description, channel_username: r.ai_overview, kind: r.thumbnail_url, duration_seconds: r.duration_seconds, created_at: r.created_at, app_id: r.app_id }));
return { total, cards };
return { total, cards };
}
}
}
}
@@ -201,20 +176,18 @@ async function tabCounts() {
@@ -201,20 +176,18 @@ async function tabCounts() {
const counts = {
const counts = {
videos: await c(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`),
videos: await c(`SELECT COUNT(*) c FROM vods WHERE ${V_WHERE}`),
clips: await c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),
clips: await c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),
- images: await c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),
- text: await c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),
files: await c(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`),
files: await c(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`),
emotes: await c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),
emotes: await c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),
sounds: await c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),
sounds: await c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),
thumbnails: (await thumbList()).length,
thumbnails: (await thumbList()).length,
};
};
- counts.all = counts.videos + counts.clips + counts.images + counts.text + counts.emotes + counts.sounds;
+ counts.all = counts.videos + counts.clips + counts.emotes + counts.sounds;
_countCache = { at: Date.now(), counts };
_countCache = { at: Date.now(), counts };
return counts;
return counts;
}
}
// ── Rendering ────────────────────────────────────────────────
// ── Rendering ────────────────────────────────────────────────
-const KIND_ICON = { video: '🎬', clip: '✂️', image: '🖼️', text: '📄', thumbnail: '🏞️', file: '📦' };
+const KIND_ICON = { video: '🎬', clip: '✂️', thumbnail: '🏞️', file: '📦' };
function renderCard(c) {
function renderCard(c) {
const preview = c.thumb
const preview = c.thumb
@@ -235,7 +208,7 @@ function renderCard(c) {
@@ -235,7 +208,7 @@ function renderCard(c) {
</div>`;
</div>`;
}
}
-const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['images', 'Images'], ['text', 'Text'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
+const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
function renderPage(tab, page, data, counts) {
function renderPage(tab, page, data, counts) {
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
@@ -243,13 +216,13 @@ function renderPage(tab, page, data, counts) {
@@ -243,13 +216,13 @@ function renderPage(tab, page, data, counts) {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenVibe.Media — Media Index</title>
<title>OpenVibe.Media — Media Index</title>
-<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, images, pastes, and thumbnails.">
+<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.">
<link rel="canonical" href="${config.publicUrl}/${tab === 'all' && page === 1 ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}">
<link rel="canonical" href="${config.publicUrl}/${tab === 'all' && page === 1 ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}">
<meta name="robots" content="${page > 1 ? 'noindex, follow' : 'index, follow'}">
<meta name="robots" content="${page > 1 ? 'noindex, follow' : 'index, follow'}">
<meta property="og:site_name" content="OpenVibe.Media">
<meta property="og:site_name" content="OpenVibe.Media">
<meta property="og:type" content="website">
<meta property="og:type" content="website">
<meta property="og:title" content="OpenVibe.Media — Media Index">
<meta property="og:title" content="OpenVibe.Media — Media Index">
-<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, images, pastes and thumbnails.">
+<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, files and thumbnails.">
<meta property="og:url" content="${config.publicUrl}/">
<meta property="og:url" content="${config.publicUrl}/">
<meta property="og:image" content="${config.publicUrl}/og-image.png">
<meta property="og:image" content="${config.publicUrl}/og-image.png">
<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
@@ -290,7 +263,7 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
@@ -290,7 +263,7 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
</head><body>
</head><body>
<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}
<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}
-<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header>
+<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* GET /c/:id clip playback (same tiering/range logic + watch page)
* GET /c/:id clip playback (same tiering/range logic + watch page)
* GET /p/:slug 301 to OpenVibe.Community — pastes live there now
* GET /p/:slug 301 to OpenVibe.Community — pastes live there now
* GET /p/:slug/raw 301 to OpenVibe.Community's raw text
* GET /p/:slug/raw 301 to OpenVibe.Community's raw text
- * GET /p/:slug/screenshot paste screenshot image
+ * GET /p/:slug/screenshot 301 to the screenshot's or avatar's media object (/o/<id>), else to Community
* GET /t/:id thumbnails (id = filename)
* GET /t/:id thumbnails (id = filename)
* GET /f/:key files with correct Content-Type + Range
* GET /f/:key files with correct Content-Type + Range
* GET /og-image.png the site's share card
* GET /og-image.png the site's share card
@@ -532,10 +532,8 @@ router.get('/f/:key', async (req, res) => {
@@ -532,10 +532,8 @@ router.get('/f/:key', async (req, res) => {
// ── Pastes ───────────────────────────────────────────────────
// ── Pastes ───────────────────────────────────────────────────
-// Pastes live on OpenVibe.Community (the paste authority since 2026-09-22). Media holds the
-// frozen rows but serves nothing from them for these two routes: every slug — found, missing
-// or private — is a permanent 301 to Community's own /p/<slug> (and its /raw). Screenshot
-// bytes keep being served from here.
+// Pastes live on OpenVibe.Community (the paste authority since 2026-09-22): every slug — found, missing
+// or private — is a permanent 301 to Community's own /p/<slug> (and its /raw).
const movedTo = () => config.pastes.movedTo;
const movedTo = () => config.pastes.movedTo;
const communityPasteUrl = (slug, suffix = '') => `${movedTo()}/p/${encodeURIComponent(String(slug))}${suffix}`;
const communityPasteUrl = (slug, suffix = '') => `${movedTo()}/p/${encodeURIComponent(String(slug))}${suffix}`;
@@ -543,21 +541,26 @@ router.get('/p/:slug', (req, res) => res.redirect(301, communityPasteUrl(req.par
@@ -543,21 +541,26 @@ router.get('/p/:slug', (req, res) => res.redirect(301, communityPasteUrl(req.par
router.get('/p/:slug/raw', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug, '/raw')));
router.get('/p/:slug/raw', (req, res) => res.redirect(301, communityPasteUrl(req.params.slug, '/raw')));
+// A screenshot or avatar made before the move is a media object now, found by the slug in its legacy ref
+// (legacy:live:paste:<slug>, legacy:live:avatar:<slug>; Live was the only tenant with pastes): a permanent
+// 301 to its /o/<id>, which serves the bytes with range and caching. Every other slug — made on Community
+// since the move, private or unknown — goes to Community's /p/<slug>/screenshot (the image, or its 404).
+const LEGACY_PASTE_APP = 'live';
+
router.get('/p/:slug/screenshot', async (req, res) => {
router.get('/p/:slug/screenshot', async (req, res) => {
if (drill.refuseBytes(res)) return;
if (drill.refuseBytes(res)) return;
+ const slug = String(req.params.slug);
try {
try {
- const paste = await db.getPasteBySlug(String(req.params.slug));
- // Pastes made since the move live in Community only: send an unknown slug there, like
- // /p/:slug and /p/:slug/raw (Community answers with the image, or its own 404). Stored
- // hero-moment thumbnails pointed here and showed broken images.
- if ((!paste || paste.visibility === 'private') && movedTo()) { // private answers like missing
- res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
- return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/screenshot`);
+ const model = require('../objects/model');
+ const obj = await db.get('SELECT id, visibility, lifecycle_status FROM media_objects WHERE legacy_ref IN (?, ?) LIMIT 1',
+ [model.legacyRef(LEGACY_PASTE_APP, 'paste', slug), model.legacyRef(LEGACY_PASTE_APP, 'avatar', slug)]);
+ if (obj && obj.visibility !== 'private' && obj.lifecycle_status === 'ready') {
+ res.set('Cache-Control', 'public, max-age=86400');
+ return res.redirect(301, `/o/${obj.id}`);
}
}
- if (!paste || !paste.screenshot_path) return res.status(404).send('Not found');
- if (paste.visibility === 'private') return res.status(404).send('Not found');
- if (!fs.existsSync(paste.screenshot_path)) return res.status(404).send('Not found');
- streamFileWithRange(req, res, paste.screenshot_path, { 'Cache-Control': 'public, max-age=86400' });
+ if (!movedTo()) return res.status(404).send('Not found');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
+ return res.redirect(301, communityPasteUrl(slug, '/screenshot'));
} catch {
} catch {
res.status(500).send('Error');
res.status(500).send('Error');
}
}
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
/**
/**
* OpenVibe.Media — app-facing view API (mounted at /api/v1/:app/views, app key).
* OpenVibe.Media — app-facing view API (mounted at /api/v1/:app/views, app key).
*
*
- * POST / { type: 'vod'|'clip'|'paste'|'file', id, ip?, user_id?, user_agent? }
- * Apps that render their own watch/paste pages call this when the page
+ * POST / { type: 'vod'|'clip'|'file', id, ip?, user_id?, user_agent? }
+ * Apps that render their own watch pages call this when the page
* opens. The real client IP travels in X-Forwarded-For (Live's proxy sets
* opens. The real client IP travels in X-Forwarded-For (Live's proxy sets
* it) or `ip`; the viewer's user id in X-OV-User-Id or `user_id`.
* it) or `ip`; the viewer's user id in X-OV-User-Id or `user_id`.
* → { counted, unique, reason?, view_count, unique_views }
* → { counted, unique, reason?, view_count, unique_views }
@@ -25,7 +25,7 @@ router.post('/', tenantAuth(), async (req, res) => {
@@ -25,7 +25,7 @@ router.post('/', tenantAuth(), async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const type = String(body.type || '');
const type = String(body.type || '');
const id = parseInt(body.id, 10);
const id = parseInt(body.id, 10);
- if (!views.TABLES[type] || !id) return res.status(400).json({ error: 'type (vod|clip|paste|file) and id required' });
+ if (!views.TABLES[type] || !id) return res.status(400).json({ error: 'type (vod|clip|file) and id required' });
// Deletion/tenancy guard: only the owning app may count views on its content.
// Deletion/tenancy guard: only the owning app may count views on its content.
const t = views.TABLES[type];
const t = views.TABLES[type];
const row = await db.get(`SELECT app_id, user_id FROM ${t.table} WHERE id = ?`, [id]);
const row = await db.get(`SELECT app_id, user_id FROM ${t.table} WHERE id = ?`, [id]);
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/**
/**
- * OpenVibe.Media — view counting (owned here for every app and every content type).
+ * OpenVibe.Media — view counting (owned here for every app and supported content type).
*
*
* Two numbers per item, both maintained by this module:
* Two numbers per item, both maintained by this module:
* view_count "views": one per VISIT. A visitor (logged-in user id, else a keyed hash of
* view_count "views": one per VISIT. A visitor (logged-in user id, else a keyed hash of
@@ -11,14 +11,14 @@
@@ -11,14 +11,14 @@
* Also: the owner's own views never count; obvious bots/crawlers are ignored; a single IP
* Also: the owner's own views never count; obvious bots/crawlers are ignored; a single IP
* firing view events at an absurd rate (any content) is dropped after a per-minute budget.
* firing view events at an absurd rate (any content) is dropped after a per-minute budget.
*
*
- * Entry points: recordView() from Media's own public routes (/v /c /p) and from the
+ * Entry points: recordView() from Media's own public routes (/v /c) and from the
* app-facing API (POST /api/v1/:app/views) that Live/Tools call for pages they render.
* app-facing API (POST /api/v1/:app/views) that Live/Tools call for pages they render.
*/
*/
'use strict';
'use strict';
const crypto = require('crypto');
const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
-const TABLES = { vod: { table: 'vods', raw: 'view_count' }, clip: { table: 'clips', raw: 'view_count' }, paste: { table: 'pastes', raw: 'views' }, file: { table: 'files', raw: 'view_count' } };
+const TABLES = { vod: { table: 'vods', raw: 'view_count' }, clip: { table: 'clips', raw: 'view_count' }, file: { table: 'files', raw: 'view_count' } };
const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;
const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;
let _secret = null;
let _secret = null;
@@ -63,7 +63,7 @@ async function _overBudget(ip) {
@@ -63,7 +63,7 @@ async function _overBudget(ip) {
/**
/**
* Record a view.
* Record a view.
- * @param {'vod'|'clip'|'paste'|'file'} type
+ * @param {'vod'|'clip'|'file'} type
* @param {number} id
* @param {number} id
* @param {object} o { req?, ip?, userId?, ownerUserId?, userAgent? }
* @param {object} o { req?, ip?, userId?, ownerUserId?, userAgent? }
* @returns {{ counted:boolean, unique:boolean, reason?:string, view_count?:number, unique_views?:number }}
* @returns {{ counted:boolean, unique:boolean, reason?:string, view_count?:number, unique_views?:number }}
@@ -150,7 +150,7 @@ async function buildReport({ provider = 'b2', prefix = PREFIX, list = null } = {
@@ -150,7 +150,7 @@ async function buildReport({ provider = 'b2', prefix = PREFIX, list = null } = {
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
//
//
// unreferenced_local files under the data directories that no row names (media_locations, vods,
// unreferenced_local files under the data directories that no row names (media_locations, vods,
-// clips, files, pastes, assets, open upload sessions, unfinished jobs). A file whose
+// clips, files, assets, open upload sessions, unfinished jobs). A file whose
// only row is a deleted object's location counts (its bytes outlived the object),
// only row is a deleted object's location counts (its bytes outlived the object),
// unless the object is a native one still inside its retention period, or held.
// unless the object is a native one still inside its retention period, or held.
// unreferenced_remote B2/R2 keys that no media_locations row or vods/clips row names
// unreferenced_remote B2/R2 keys that no media_locations row or vods/clips row names
@@ -242,7 +242,6 @@ async function localReferences() {
@@ -242,7 +242,6 @@ async function localReferences() {
thumb(c.thumbnail_url);
thumb(c.thumbnail_url);
}
}
for (const f of await db.all('SELECT key, app_id FROM files')) add(path.join(config.files.path, f.app_id, f.key));
for (const f of await db.all('SELECT key, app_id FROM files')) add(path.join(config.files.path, f.app_id, f.key));
- for (const p of await db.all('SELECT screenshot_path FROM pastes WHERE screenshot_path IS NOT NULL')) add(p.screenshot_path);
for (const a of await db.all('SELECT file_path FROM assets WHERE file_path IS NOT NULL')) add(a.file_path);
for (const a of await db.all('SELECT file_path FROM assets WHERE file_path IS NOT NULL')) add(a.file_path);
const sessions = new Set((await db.all("SELECT id FROM media_uploads WHERE status IN ('active', 'completing')")).map(r => r.id));
const sessions = new Set((await db.all("SELECT id FROM media_uploads WHERE status IN ('active', 'completing')")).map(r => r.id));
const jobs = new Set((await db.all("SELECT id FROM media_jobs WHERE status IN ('queued', 'running')")).map(r => r.id));
const jobs = new Set((await db.all("SELECT id FROM media_jobs WHERE status IN ('queued', 'running')")).map(r => r.id));
@@ -327,8 +326,6 @@ async function remoteHint(provider, key, refs) {
@@ -327,8 +326,6 @@ async function remoteHint(provider, key, refs) {
if (del) return del.object_id ? `copy of deleted object ${del.object_id}${del.legacy_ref ? ` (${del.legacy_ref})` : ''}` : 'named only by a location row whose object does not exist';
if (del) return del.object_id ? `copy of deleted object ${del.object_id}${del.legacy_ref ? ` (${del.legacy_ref})` : ''}` : 'named only by a location row whose object does not exist';
const row = refs.rows.get(key);
const row = refs.rows.get(key);
if (row && provider === 'r2') return `an R2 copy of ${row.kind} ${row.id}, which is served from ${row.provider}`;
if (row && provider === 'r2') return `an R2 copy of ${row.kind} ${row.id}, which is served from ${row.provider}`;
- const shot = /paste-screenshot-(\d+)\.[a-z0-9]+$/i.exec(key);
- if (shot) return await db.get('SELECT 1 AS x FROM pastes WHERE id = ?', [Number(shot[1])]) ? `a legacy screenshot of paste ${shot[1]}` : `a legacy screenshot of paste ${shot[1]}, whose row is gone`;
return await recordingNameHint(path.basename(key));
return await recordingNameHint(path.basename(key));
}
}
@@ -2,8 +2,7 @@
@@ -2,8 +2,7 @@
// Account export and deletion → Media (roadmap WS-B task 7, ADR-033): through POST /internal/events (signed),
// Account export and deletion → Media (roadmap WS-B task 7, ADR-033): through POST /internal/events (signed),
// network.account.export_requested sends Media's part to Network (the subject's objects, VODs and clips, no storage or
// network.account.export_requested sends Media's part to Network (the subject's objects, VODs and clips, no storage or
// stream keys), and network.account.deleted erases what the subject and its merged-in aliases own: native objects are
// stream keys), and network.account.deleted erases what the subject and its merged-in aliases own: native objects are
-// soft-deleted, VODs, clips and files removed with their bytes, thumbnails marked deleted; held media and frozen paste
-// screenshots are kept and counted. Media confirms with counts; a redelivery sends nothing twice, and a failed
+// soft-deleted, VODs, clips and files removed with their bytes, thumbnails marked deleted; held media is kept and counted. Media confirms with counts; a redelivery sends nothing twice, and a failed
// confirmation is retried without erasing again.
// confirmation is retried without erasing again.
// node test/account-data.test.js
// node test/account-data.test.js
const assert = require('assert');
const assert = require('assert');
@@ -29,7 +28,7 @@ const path = require('path');
@@ -29,7 +28,7 @@ const path = require('path');
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4', 'vod', DANA, 'legacy:live:vod:7'); // VOD: removed
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4', 'vod', DANA, 'legacy:live:vod:7'); // VOD: removed
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5', 'clip', DANA, 'legacy:live:clip:9'); // clip: removed
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5', 'clip', DANA, 'legacy:live:clip:9'); // clip: removed
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', 'thumbnail', DANA, 'legacy:live:thumb:7'); // thumbnail: marked deleted
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', 'thumbnail', DANA, 'legacy:live:thumb:7'); // thumbnail: marked deleted
- await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D7', 'screenshot', DANA, 'legacy:live:paste:abc'); // frozen paste: kept
+ await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D7', 'screenshot', DANA, 'legacy:live:paste:abc'); // legacy screenshot object: deleted
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D8', 'file', XENA, null); // someone else's
await obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D8', 'file', XENA, null); // someone else's
await db.prepare("INSERT INTO media_holds (object_id, kind, reason) VALUES ('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'moderation', 'report under review') RETURNING id").run();
await db.prepare("INSERT INTO media_holds (object_id, kind, reason) VALUES ('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'moderation', 'report under review') RETURNING id").run();
const vodFile = path.join(tmp, 'vod7.mp4'); fs.writeFileSync(vodFile, 'x');
const vodFile = path.join(tmp, 'vod7.mp4'); fs.writeFileSync(vodFile, 'x');
@@ -63,15 +62,15 @@ const path = require('path');
@@ -63,15 +62,15 @@ const path = require('path');
failNext = true;
failNext = true;
await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);
await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);
const state = Object.fromEntries((await db.prepare('SELECT id, lifecycle_status AS s FROM media_objects').all()).map((r) => [r.id.slice(-2), r.s]));
const state = Object.fromEntries((await db.prepare('SELECT id, lifecycle_status AS s FROM media_objects').all()).map((r) => [r.id.slice(-2), r.s]));
- assert.deepStrictEqual(state, { D1: 'deleted', D2: 'deleted', D3: 'ready', D4: 'deleted', D5: 'deleted', D6: 'deleted', D7: 'ready', D8: 'ready' });
+ assert.deepStrictEqual(state, { D1: 'deleted', D2: 'deleted', D3: 'ready', D4: 'deleted', D5: 'deleted', D6: 'deleted', D7: 'deleted', D8: 'ready' });
assert.ok(!fs.existsSync(vodFile) && !fs.existsSync(clipFile), 'the bytes are gone');
assert.ok(!fs.existsSync(vodFile) && !fs.existsSync(clipFile), 'the bytes are gone');
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM vods').get()).n + (await db.prepare('SELECT COUNT(*) AS n FROM clips').get()).n, 0);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM vods').get()).n + (await db.prepare('SELECT COUNT(*) AS n FROM clips').get()).n, 0);
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
const conf = sent[1];
const conf = sent[1];
assert.strictEqual(conf.path, '/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1/confirmations');
assert.strictEqual(conf.path, '/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1/confirmations');
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
- assert.deepStrictEqual(conf.body.erased, { objects: 2, vods: 1, clips: 1, thumbnails: 1 });
- assert.deepStrictEqual(conf.body.retained, { held_media: 1, frozen_screenshots: 1 });
+ assert.deepStrictEqual(conf.body.erased, { objects: 3, vods: 1, clips: 1, thumbnails: 1 });
+ assert.deepStrictEqual(conf.body.retained, { held_media: 1 });
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(sent.length, 2);
assert.strictEqual(sent.length, 2);
} finally {
} finally {
@@ -9,10 +9,9 @@ const https = require('https');
@@ -9,10 +9,9 @@ const https = require('https');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-// The end-to-end check below writes where the service writes: point the paste
-// storage at scratch before anything loads the config.
+// The end-to-end check writes native objects under scratch storage.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-avatar-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-avatar-'));
-process.env.PASTES_PATH = path.join(tmp, 'pastes');
+process.env.OBJECTS_PATH = path.join(tmp, 'objects');
process.env.MEDIA_PUBLIC_URL = 'https://media.test';
process.env.MEDIA_PUBLIC_URL = 'https://media.test';
(async () => {
(async () => {
@@ -48,19 +47,13 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -48,19 +47,13 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
await rejects(toAvatar(Buffer.from('<svg onload=alert(1)>not a picture</svg><script>')), /./, 'non-image bytes are refused');
await rejects(toAvatar(Buffer.from('<svg onload=alert(1)>not a picture</svg><script>')), /./, 'non-image bytes are refused');
await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
- // ── Shared paste storage: the screenshots dir and the slug have one source ──
- // (server/pastes/storage.js — the paste router is gone, the avatar ingest reads the leaf directly)
- const storage = require('../server/pastes/storage');
const config = require('../server/config');
const config = require('../server/config');
- assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');
- const slugA = await storage.generateSlug();
- const slugB = await storage.generateSlug();
- for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);
- assert.notStrictEqual(slugA, slugB, 'two minted slugs differ (uniqueness is checked against the pastes table)');
// ── Through the route, wired the way server/index.js wires it: { db, config } only ──
// ── Through the route, wired the way server/index.js wires it: { db, config } only ──
const db = require('../server/db/database');
const db = require('../server/db/database');
const ingest = require('../server/avatars/ingest');
const ingest = require('../server/avatars/ingest');
+ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });
+ await require('../server/events').initWriter();
// DNS and the socket are stubbed: nothing leaves the host.
// DNS and the socket are stubbed: nothing leaves the host.
const realLookup = dns.promises.lookup;
const realLookup = dns.promises.lookup;
const realGet = https.get;
const realGet = https.get;
@@ -83,22 +76,31 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -83,22 +76,31 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
const first = await post({ url: 'https://pics.example/me.png', user_id: 7, username: 'ada' });
const first = await post({ url: 'https://pics.example/me.png', user_id: 7, username: 'ada' });
assert.strictEqual(first.status, 200, JSON.stringify(first.body));
assert.strictEqual(first.status, 200, JSON.stringify(first.body));
assert.strictEqual(first.body.ok, true);
assert.strictEqual(first.body.ok, true);
- assert.match(first.body.slug, /^[a-z]+-[a-z]+-[0-9]{2}$/);
- assert.strictEqual(first.body.url, `${config.publicUrl}/p/${first.body.slug}/screenshot`);
+ assert.match(first.body.object_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(first.body.url, `${config.publicUrl}/o/${first.body.object_id}`);
assert.deepStrictEqual([first.body.width, first.body.height], [512, 512]);
assert.deepStrictEqual([first.body.width, first.body.height], [512, 512]);
assert.ok(first.body.bytes > 0);
assert.ok(first.body.bytes > 0);
- const row = await db.get('SELECT * FROM pastes WHERE slug = ?', [first.body.slug]);
- assert.ok(row, 'the avatar row was inserted');
- assert.deepStrictEqual([row.app_id, row.user_id, row.type, row.visibility], ['network', 7, 'screenshot', 'unlisted']);
- assert.strictEqual(row.title, 'Avatar of ada');
- assert.ok(row.screenshot_path.startsWith(storage.SCREENSHOTS_DIR), `the file lands in the shared screenshots dir (${row.screenshot_path})`);
- assert.deepStrictEqual((await sharp(fs.readFileSync(row.screenshot_path)).metadata()).format, 'webp', 'the bytes on disk are the re-encoded WebP');
- assert.strictEqual(JSON.parse(row.metadata).kind, 'avatar');
- assert.ok(row.object_id, 'the avatar row carries its media object');
- assert.ok(await db.get('SELECT id FROM media_objects WHERE id = ?', [row.object_id]), 'and that object exists');
- const second = await post({ url: 'https://pics.example/me2.png', user_id: 8 });
+ const check = async (body, owner) => {
+ const obj = await db.get('SELECT * FROM media_objects WHERE id = ?', [body.object_id]);
+ assert.ok(obj, 'avatar object exists');
+ const app = await db.getApp('live');
+ assert.deepStrictEqual([obj.kind, obj.app_id, obj.namespace, obj.visibility, obj.lifecycle_status, obj.owner_subject],
+ ['avatar', 'live', `${db.rootNamespace(app)}.avatars`, 'unlisted', 'ready', owner]);
+ const loc = await db.get("SELECT * FROM media_locations WHERE object_id = ? AND provider = 'local' AND state = 'present'", [obj.id]);
+ assert.ok(loc && loc.key.startsWith(config.objects.path) && fs.existsSync(loc.key), 'present local object file');
+ const meta = await sharp(fs.readFileSync(loc.key)).metadata();
+ assert.deepStrictEqual([meta.format, meta.width, meta.height], ['webp', 512, 512]);
+ assert.ok((await db.all('SELECT envelope FROM event_outbox')).some((r) => { const e = typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope; return e.event_type === 'media.object.uploaded' && JSON.stringify(e).includes(obj.id); }), 'upload event queued');
+ };
+ await check(first.body, null);
+ const subject = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+ const second = await post({ url: 'https://pics.example/me2.png', user_id: 8, subject });
assert.strictEqual(second.status, 200, JSON.stringify(second.body));
assert.strictEqual(second.status, 200, JSON.stringify(second.body));
- assert.notStrictEqual(second.body.slug, first.body.slug, 'a second avatar gets its own slug');
+ assert.notStrictEqual(second.body.object_id, first.body.object_id);
+ await check(second.body, subject);
+ const invalid = await post({ url: 'https://pics.example/me3.png', user_id: 9, subject: 'not-a-subject' });
+ assert.strictEqual(invalid.status, 200, JSON.stringify(invalid.body));
+ await check(invalid.body, null);
server.close();
server.close();
} finally {
} finally {
dns.promises.lookup = realLookup;
dns.promises.lookup = realLookup;
@@ -139,7 +139,6 @@ const { spawnSync, execFileSync } = require('child_process');
@@ -139,7 +139,6 @@ const { spawnSync, execFileSync } = require('child_process');
await db.run("INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE VALUES (2, 'live', 'Still recording', ?, 1, 'public', 1)", [bytes.rec]);
await db.run("INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, is_recording) OVERRIDING SYSTEM VALUE VALUES (2, 'live', 'Still recording', ?, 1, 'public', 1)", [bytes.rec]);
await db.run("INSERT INTO clips (id, app_id, vod_id, title, file_path, duration_seconds, status) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 1, 'Restored clip', ?, 10, 'ready')", [bytes.clip]);
await db.run("INSERT INTO clips (id, app_id, vod_id, title, file_path, duration_seconds, status) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 1, 'Restored clip', ?, 10, 'ready')", [bytes.clip]);
await db.run("INSERT INTO assets (id, app_id, kind, name, file_path, mime) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'emote', 'drillWave', ?, 'image/png')", [bytes.asset]);
await db.run("INSERT INTO assets (id, app_id, kind, name, file_path, mime) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'emote', 'drillWave', ?, 'image/png')", [bytes.asset]);
- await db.run("INSERT INTO pastes (id, app_id, slug, type, title, screenshot_path, visibility) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'shot1', 'screenshot', 'Shot', ?, 'public')", [bytes.shot]);
await db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('f1.txt', 'live', 'f1.txt', 13, 'text/plain')");
await db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('f1.txt', 'live', 'f1.txt', 13, 'text/plain')");
await require('./helpers/project-rows').projectRows({ onlyMissing: true });
await require('./helpers/project-rows').projectRows({ onlyMissing: true });
const COUNTED = ['media_objects', 'vods', 'clips', 'apps', 'media_locations', 'media_jobs', 'event_outbox'];
const COUNTED = ['media_objects', 'vods', 'clips', 'apps', 'media_locations', 'media_jobs', 'event_outbox'];
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
* OpenVibe.Media — test-only legacy row projection
* OpenVibe.Media — test-only legacy row projection
*
*
* One media_object (+ locations, relationships, thumbnail variant) per existing
* One media_object (+ locations, relationships, thumbnail variant) per existing
- * vod, clip, file, screenshot/avatar paste and vod/clip thumbnail, derived by the
+ * vod, clip, file and vod/clip thumbnail, derived by the
* same sync functions every write uses (model.withObject). Idempotent: objects are keyed by their
* same sync functions every write uses (model.withObject). Idempotent: objects are keyed by their
* legacy ref, so a re-run updates in place and creates nothing new.
* legacy ref, so a re-run updates in place and creates nothing new.
*
*
@@ -24,7 +24,7 @@ function emptyCounts() {
@@ -24,7 +24,7 @@ function emptyCounts() {
async function projectRows({ dryRun = false, onlyMissing = false } = {}) {
async function projectRows({ dryRun = false, onlyMissing = false } = {}) {
const report = {
const report = {
started_at: new Date().toISOString(), dry_run: !!dryRun, only_missing: !!onlyMissing,
started_at: new Date().toISOString(), dry_run: !!dryRun, only_missing: !!onlyMissing,
- counts: { vod: emptyCounts(), clip: emptyCounts(), file: emptyCounts(), screenshot: emptyCounts(), avatar: emptyCounts(), thumbnail: emptyCounts() },
+ counts: { vod: emptyCounts(), clip: emptyCounts(), file: emptyCounts(), thumbnail: emptyCounts() },
locations: { present: 0, missing: 0, pending: 0 },
locations: { present: 0, missing: 0, pending: 0 },
skipped: [],
skipped: [],
errors: [],
errors: [],
@@ -69,11 +69,6 @@ async function projectRows({ dryRun = false, onlyMissing = false } = {}) {
@@ -69,11 +69,6 @@ async function projectRows({ dryRun = false, onlyMissing = false } = {}) {
await each('files', `SELECT * FROM files${where} ORDER BY created_at, key`, async (row) => {
await each('files', `SELECT * FROM files${where} ORDER BY created_at, key`, async (row) => {
tally('file', 'files', row.key, await model.syncFile(row));
tally('file', 'files', row.key, await model.syncFile(row));
});
});
- await each('pastes', `SELECT * FROM pastes${where ? where + ' AND' : ' WHERE'} type = 'screenshot' ORDER BY id`, async (row) => {
- const r = await model.syncPaste(row);
- const kind = (r && r.kind) || (String(row.metadata || '').includes('"kind":"avatar"') ? 'avatar' : 'screenshot');
- tally(kind, 'pastes', row.id, r);
- });
if (dryRun) throw DRY;
if (dryRun) throw DRY;
});
});
} catch (err) {
} catch (err) {
@@ -47,7 +47,6 @@ const SEED_PG = `
@@ -47,7 +47,6 @@ const SEED_PG = `
const put = (dir, name) => { const f = path.join(process.env[dir], name); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'bytes of ' + name); return f; };
const put = (dir, name) => { const f = path.join(process.env[dir], name); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'bytes of ' + name); return f; };
await db.run("INSERT INTO vods (app_id, user_id, title, file_path, file_size, thumbnail_url, duration_seconds, is_public, visibility) VALUES ('live', 1, 'N-1 VOD', ?, 18, '/t/vod-1-1.jpg', 60, 1, 'public')", [put('VOD_PATH', 'vod-1.mp4')]);
await db.run("INSERT INTO vods (app_id, user_id, title, file_path, file_size, thumbnail_url, duration_seconds, is_public, visibility) VALUES ('live', 1, 'N-1 VOD', ?, 18, '/t/vod-1-1.jpg', 60, 1, 'public')", [put('VOD_PATH', 'vod-1.mp4')]);
await db.run("INSERT INTO clips (app_id, vod_id, user_id, title, file_path, duration_seconds, status, is_public, visibility) VALUES ('live', 1, 1, 'N-1 clip', ?, 10, 'ready', 1, 'public')", [put('CLIPS_PATH', 'clip-1.mp4')]);
await db.run("INSERT INTO clips (app_id, vod_id, user_id, title, file_path, duration_seconds, status, is_public, visibility) VALUES ('live', 1, 1, 'N-1 clip', ?, 10, 'ready', 1, 'public')", [put('CLIPS_PATH', 'clip-1.mp4')]);
- await db.run("INSERT INTO pastes (app_id, slug, type, title, content, visibility) VALUES ('live', 'n1paste', 'paste', 'N-1 paste', 'hello from N-1', 'public')");
await db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('n1file.txt', 'live', 'n1file.txt', 17, 'text/plain')");
await db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('n1file.txt', 'live', 'n1file.txt', 17, 'text/plain')");
put('FILES_PATH', 'live/n1file.txt'); put('THUMBNAILS_PATH', 'vod-1-1.jpg');
put('FILES_PATH', 'live/n1file.txt'); put('THUMBNAILS_PATH', 'vod-1-1.jpg');
// N-1 releases before C-75's retirement project rows with server/objects/backfill.js; later ones with the test helper.
// N-1 releases before C-75's retirement project rows with server/objects/backfill.js; later ones with the test helper.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Object-first writes (WS-G task 1, C-75 retired 2026-10-10): every write to an inherited
// Object-first writes (WS-G task 1, C-75 retired 2026-10-10): every write to an inherited
-// vods/clips/files/pastes row makes or updates its media_object in the SAME PostgreSQL transaction
+// vods/clips/files row makes or updates its media_object in the SAME PostgreSQL transaction
// (objects/model.js withObject). For each main write path the object is there, linked and agreeing
// (objects/model.js withObject). For each main write path the object is there, linked and agreeing
// with the row as soon as the write returns; when the object cannot be written, the row write
// with the row as soon as the write returns; when the object cannot be written, the row write
// rolls back with it (neither row).
// rolls back with it (neither row).
@@ -52,7 +52,7 @@ const sharp = require('sharp');
@@ -52,7 +52,7 @@ const sharp = require('sharp');
assert.ok(row.object_id, `${label}: row names its object`);
assert.ok(row.object_id, `${label}: row names its object`);
const obj = await model.getObject(row.object_id);
const obj = await model.getObject(row.object_id);
assert.ok(obj, `${label}: object exists right after the write`);
assert.ok(obj, `${label}: object exists right after the write`);
- const p = { vods: model.vodProjection, clips: model.clipProjection, files: model.fileProjection, pastes: model.pasteProjection }[table](row);
+ const p = { vods: model.vodProjection, clips: model.clipProjection, files: model.fileProjection }[table](row);
for (const [field, value] of Object.entries({ kind: p.kind, visibility: p.visibility, lifecycle_status: p.lifecycle_status, size_bytes: Number(p.size_bytes) || 0, legacy_ref: p.legacy_ref, app_id: p.app_id, owner_app: p.owner_app || p.app_id, owner_user_id: p.owner_user_id })) {
for (const [field, value] of Object.entries({ kind: p.kind, visibility: p.visibility, lifecycle_status: p.lifecycle_status, size_bytes: Number(p.size_bytes) || 0, legacy_ref: p.legacy_ref, app_id: p.app_id, owner_app: p.owner_app || p.app_id, owner_user_id: p.owner_user_id })) {
assert.strictEqual(String(obj[field] ?? ''), String(value ?? ''), `${label}: object ${field}`);
assert.strictEqual(String(obj[field] ?? ''), String(value ?? ''), `${label}: object ${field}`);
}
}
@@ -184,7 +184,7 @@ const sharp = require('sharp');
@@ -184,7 +184,7 @@ const sharp = require('sharp');
assert.strictEqual((await model.getObject(doomedObj)).lifecycle_status, 'deleted', 'a deleted row takes its object with it (trigger, same statement)');
assert.strictEqual((await model.getObject(doomedObj)).lifecycle_status, 'deleted', 'a deleted row takes its object with it (trigger, same statement)');
console.log('✅ clip writes (upload, create, re-cut ready/failed, update, delete) commit with their object');
console.log('✅ clip writes (upload, create, re-cut ready/failed, update, delete) commit with their object');
- // ── 3. Files, screenshot pastes, avatars ──
+ // ── 3. Files ──
r = await call('POST', '/files', form('file', Buffer.from('hello object model'), 'text/plain', 'notes.txt'));
r = await call('POST', '/files', form('file', Buffer.from('hello object model'), 'text/plain', 'notes.txt'));
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
const fileKey = r.body.key;
const fileKey = r.body.key;
@@ -195,27 +195,7 @@ const sharp = require('sharp');
@@ -195,27 +195,7 @@ const sharp = require('sharp');
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
- // The read-only paste API is retired (T10 step 2): POST /pastes has no route and writes nothing.
- const pastesBefore = [await count('pastes'), await count('media_objects')];
- r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));
- assert.strictEqual(r.status, 404, 'no paste-write route remains');
- assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');
- // A screenshot paste row (imported before the move) still commits with its object.
- const slug = 'shot1';
- const shotFile = path.join(process.env.PASTES_PATH, 'shot1.png');
- fs.writeFileSync(shotFile, png);
- await db.withObject('paste', (x) => x.lastInsertRowid, async () => await db.run(`INSERT INTO pastes (app_id, slug, type, title, content, language, visibility, screenshot_path)
- VALUES ('live', ?, 'screenshot', 'Shot', '', 'text', 'unlisted', ?) RETURNING id`, [slug, shotFile]));
- ({ obj } = await linked('pastes', 'slug', slug, 'screenshot paste'));
- assert.deepStrictEqual([obj.kind, obj.visibility, obj.legacy_ref], ['screenshot', 'unlisted', `legacy:live:paste:${slug}`]);
-
- // Avatar ingest's write (server/avatars/ingest.js): the avatar row and its object together.
- const avatarFile = path.join(process.env.PASTES_PATH, 'avatar.webp');
- fs.writeFileSync(avatarFile, png);
- const avatarId = Number((await db.withObject('paste', (x) => x.lastInsertRowid, async () => await db.run(`INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata)
- VALUES ('network', 'av1', 42, 'screenshot', 'Avatar', '', 'text', 'unlisted', ?, ?) RETURNING id`, [avatarFile, JSON.stringify({ kind: 'avatar', mime_type: 'image/webp' })]))).lastInsertRowid);
- assert.deepStrictEqual([(await linked('pastes', 'id', avatarId, 'avatar')).obj.kind, (await linked('pastes', 'id', avatarId, 'avatar')).obj.legacy_ref], ['avatar', 'legacy:network:avatar:av1']);
- console.log('✅ file, screenshot-paste and avatar writes commit with their object; deletes mark it deleted');
+ console.log('✅ file writes commit with their object; deletes mark it deleted');
// ── 4. Atomicity: an object write that fails takes the row write with it ──
// ── 4. Atomicity: an object write that fails takes the row write with it ──
// Every object write fails (as a trigger refusing it would): the model's INSERT/UPDATE of media_objects throws.
// Every object write fails (as a trigger refusing it would): the model's INSERT/UPDATE of media_objects throws.
@@ -224,8 +204,8 @@ const sharp = require('sharp');
@@ -224,8 +204,8 @@ const sharp = require('sharp');
if (/^\s*(INSERT INTO|UPDATE) media_objects\b/.test(sql)) throw new Error('object write refused (test)');
if (/^\s*(INSERT INTO|UPDATE) media_objects\b/.test(sql)) throw new Error('object write refused (test)');
return await realRun(sql, params);
return await realRun(sql, params);
};
};
- const before = { vods: await count('vods'), clips: await count('clips'), files: await count('files'), pastes: await count('pastes'), objects: await count('media_objects') };
- const unchanged = async (label) => assert.deepStrictEqual({ vods: await count('vods'), clips: await count('clips'), files: await count('files'), pastes: await count('pastes'), objects: await count('media_objects') }, before, label);
+ const before = { vods: await count('vods'), clips: await count('clips'), files: await count('files'), objects: await count('media_objects') };
+ const unchanged = async (label) => assert.deepStrictEqual({ vods: await count('vods'), clips: await count('clips'), files: await count('files'), objects: await count('media_objects') }, before, label);
await assert.rejects(async () => await db.createVod({ app_id: 'live', title: 'No object' }), /object write refused/);
await assert.rejects(async () => await db.createVod({ app_id: 'live', title: 'No object' }), /object write refused/);
await unchanged('createVod: neither row');
await unchanged('createVod: neither row');
@@ -1,6 +1,5 @@
@@ -1,6 +1,5 @@
'use strict';
'use strict';
-// Canonical object model (roadmap Wave 4): the test helper projects every vod, clip, file, screenshot,
-// avatar and thumbnail row onto media_objects + media_locations without touching bytes or providers;
+// Canonical object model (roadmap Wave 4): the test helper projects every vod, clip, file and thumbnail row onto media_objects + media_locations without touching bytes or providers;
// it is idempotent; the write hooks keep the model current; holds block deletion and tier moves on
// it is idempotent; the write hooks keep the model current; holds block deletion and tier moves on
// every path; the public object-size invariant is recorded as policy.
// every path; the public object-size invariant is recorded as policy.
const assert = require('assert');
const assert = require('assert');
@@ -59,13 +58,7 @@ const path = require('path');
@@ -59,13 +58,7 @@ const path = require('path');
await d.prepare("INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, status) OVERRIDING SYSTEM VALUE VALUES (2, 'live', 1, 9, 'C2', '', 'processing') RETURNING id").run();
await d.prepare("INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, status) OVERRIDING SYSTEM VALUE VALUES (2, 'live', 1, 9, 'C2', '', 'processing') RETURNING id").run();
write(path.join(process.env.FILES_PATH, 'live', 'abc123-a.txt'), 12);
write(path.join(process.env.FILES_PATH, 'live', 'abc123-a.txt'), 12);
await d.prepare("INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256) VALUES ('abc123-a.txt', 'live', 3, 'a.txt', 12, 'text/plain', ?)").run('a'.repeat(64));
await d.prepare("INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256) VALUES ('abc123-a.txt', 'live', 3, 'a.txt', 12, 'text/plain', ?)").run('a'.repeat(64));
- const shot = write(path.join(process.env.PASTES_PATH, 'screenshots', 's1.png'), 40);
- const avatar = write(path.join(process.env.PASTES_PATH, 'screenshots', 'avatar-n4.webp'), 30);
- await d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'shot-one', 2, 'screenshot', 'S', '', 'public', ?, NULL) RETURNING id").run(shot);
- await d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata) OVERRIDING SYSTEM VALUE VALUES (2, 'network', 'avatar-four', 4, 'screenshot', 'Avatar', '', 'unlisted', ?, ?) RETURNING id").run(avatar, JSON.stringify({ kind: 'avatar', mime_type: 'image/webp' }));
- await d.prepare("INSERT INTO pastes (id, app_id, slug, type, title, content, visibility) OVERRIDING SYSTEM VALUE VALUES (3, 'live', 'text-three', 'paste', 'T', 'hello', 'public') RETURNING id").run();
- await d.prepare("INSERT INTO pastes (id, app_id, slug, type, title, content, visibility, screenshot_path) OVERRIDING SYSTEM VALUE VALUES (4, 'live', 'shot-nopath', 'screenshot', 'S', '', 'public', NULL) RETURNING id").run();
- const mtimes = [vodFile, clipFile, shot, avatar].map(p => fs.statSync(p).mtimeMs);
+ const mtimes = [vodFile, clipFile].map(p => fs.statSync(p).mtimeMs);
// ── Dry run: a full report, nothing written ──
// ── Dry run: a full report, nothing written ──
const dry = await projectRows({ dryRun: true });
const dry = await projectRows({ dryRun: true });
@@ -80,12 +73,10 @@ const path = require('path');
@@ -80,12 +73,10 @@ const path = require('path');
assert.deepStrictEqual(r.counts.vod, { seen: 7, created: 6, updated: 0, skipped: 1 });
assert.deepStrictEqual(r.counts.vod, { seen: 7, created: 6, updated: 0, skipped: 1 });
assert.deepStrictEqual(r.counts.clip, { seen: 2, created: 2, updated: 0, skipped: 0 });
assert.deepStrictEqual(r.counts.clip, { seen: 2, created: 2, updated: 0, skipped: 0 });
assert.deepStrictEqual(r.counts.file, { seen: 1, created: 1, updated: 0, skipped: 0 });
assert.deepStrictEqual(r.counts.file, { seen: 1, created: 1, updated: 0, skipped: 0 });
- assert.deepStrictEqual(r.counts.screenshot, { seen: 2, created: 1, updated: 0, skipped: 1 });
- assert.deepStrictEqual(r.counts.avatar, { seen: 1, created: 1, updated: 0, skipped: 0 });
assert.deepStrictEqual(r.counts.thumbnail, { seen: 2, created: 1, updated: 0, skipped: 1 });
assert.deepStrictEqual(r.counts.thumbnail, { seen: 2, created: 1, updated: 0, skipped: 1 });
- assert.deepStrictEqual(r.skipped.map(s => s.reason).sort(), ['clips-only recording (ephemeral, never published)', 'external thumbnail url', 'screenshot paste without a file path']);
+ assert.deepStrictEqual(r.skipped.map(s => s.reason).sort(), ['clips-only recording (ephemeral, never published)', 'external thumbnail url']);
assert.deepStrictEqual(r.errors, []);
assert.deepStrictEqual(r.errors, []);
- assert.deepStrictEqual([vodFile, clipFile, shot, avatar].map(p => fs.statSync(p).mtimeMs), mtimes, 'no bytes touched');
+ assert.deepStrictEqual([vodFile, clipFile].map(p => fs.statSync(p).mtimeMs), mtimes, 'no bytes touched');
const v1 = await objOf('vods', 1), v2 = await objOf('vods', 2), v3 = await objOf('vods', 3), v4 = await objOf('vods', 4), v6 = await objOf('vods', 6), v7 = await objOf('vods', 7);
const v1 = await objOf('vods', 1), v2 = await objOf('vods', 2), v3 = await objOf('vods', 3), v4 = await objOf('vods', 4), v6 = await objOf('vods', 6), v7 = await objOf('vods', 7);
for (const o of [v1, v2, v3, v4, v6, v7]) {
for (const o of [v1, v2, v3, v4, v6, v7]) {
@@ -119,11 +110,6 @@ const path = require('path');
@@ -119,11 +110,6 @@ const path = require('path');
const f = await objOf('files', 'abc123-a.txt');
const f = await objOf('files', 'abc123-a.txt');
assert.deepStrictEqual([f.kind, f.visibility, f.content_hash, f.mime_type, f.legacy_ref, (await locs(f)).local.checksum], ['file', 'public', 'a'.repeat(64), 'text/plain', 'legacy:live:file:abc123-a.txt', 'a'.repeat(64)]);
assert.deepStrictEqual([f.kind, f.visibility, f.content_hash, f.mime_type, f.legacy_ref, (await locs(f)).local.checksum], ['file', 'public', 'a'.repeat(64), 'text/plain', 'legacy:live:file:abc123-a.txt', 'a'.repeat(64)]);
- const s1 = await objOf('pastes', 1), av = await objOf('pastes', 2);
- assert.deepStrictEqual([s1.kind, s1.legacy_ref, s1.size_bytes], ['screenshot', 'legacy:live:paste:shot-one', 40]);
- assert.deepStrictEqual([av.kind, av.app_id, av.legacy_ref, av.visibility, av.mime_type], ['avatar', 'network', 'legacy:network:avatar:avatar-four', 'unlisted', 'image/webp']);
- assert.strictEqual((await d.prepare('SELECT object_id FROM pastes WHERE id = 3').get()).object_id, null, 'text pastes have no bytes object');
-
const tv = await model.getVariant(v1.id, 'thumbnail');
const tv = await model.getVariant(v1.id, 'thumbnail');
const th = await model.getObject(tv.derived_object_id);
const th = await model.getObject(tv.derived_object_id);
assert.deepStrictEqual([th.kind, th.legacy_ref, th.visibility, (await locs(th)).local.state], ['thumbnail', 'legacy:live:thumbnail:vod-1-111.jpg', 'public', 'present']);
assert.deepStrictEqual([th.kind, th.legacy_ref, th.visibility, (await locs(th)).local.state], ['thumbnail', 'legacy:live:thumbnail:vod-1-111.jpg', 'public', 'present']);
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });
+ const ids = [
+ 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3',
+ 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4',
+ 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5',
+ ];
+ for (const [i, kind, slug, visibility] of [[0, 'screenshot', 'old-shot', 'public'], [1, 'avatar', 'old-avatar', 'unlisted'], [2, 'screenshot', 'private-shot', 'private']]) {
+ await db.run('INSERT INTO media_objects (id, app_id, namespace, kind, visibility, lifecycle_status, legacy_ref) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [ids[i], 'live', 'live', kind, visibility, 'ready', `legacy:live:${kind === 'avatar' ? 'avatar' : 'paste'}:${slug}`]);
+ }
+ const app = express();
+ app.use(require('../server/public/routes'));
+ const server = http.createServer(app);
+ try {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const config = require('../server/config');
+ for (const [slug, target] of [
+ ['old-shot', `/o/${ids[0]}`],
+ ['old-avatar', `/o/${ids[1]}`],
+ ['private-shot', `${config.pastes.movedTo}/p/private-shot/screenshot`],
+ ['unknown-shot', `${config.pastes.movedTo}/p/unknown-shot/screenshot`],
+ ]) {
+ const response = await fetch(`${base}/p/${slug}/screenshot`, { redirect: 'manual' });
+ assert.deepStrictEqual([response.status, response.headers.get('location')], [301, target], slug);
+ }
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+ console.log('paste screenshot redirect: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+// Server and scripts must leave the frozen paste tables to Community.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '..');
+const tableSql = /\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+(?:"?\w+"?\.)?"?(?:pastes|paste_likes|paste_comments)"?\b/gi;
+const found = [];
+for (const folder of ['server', 'scripts']) {
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const file = path.join(dir, entry.name);
+ if (entry.isDirectory()) walk(file);
+ else if (entry.isFile() && /\.(?:js|sh)$/.test(entry.name)) {
+ const source = fs.readFileSync(file, 'utf8');
+ for (const match of source.matchAll(tableSql)) found.push(`${path.relative(root, file)}: ${match[0]}`);
+ }
+ }
+ };
+ walk(path.join(root, folder));
+}
+assert.deepStrictEqual(found, [], `legacy paste table SQL remains:\n${found.join('\n')}`);
+console.log('paste tables unread: all checks passed');
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug and
// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug and
// /p/:slug/raw routes are unconditional 301s to Community — no env var needed (the default is
// /p/:slug/raw routes are unconditional 301s to Community — no env var needed (the default is
// config.pastes.movedTo) and no row is read: a known, missing or private slug all go there.
// config.pastes.movedTo) and no row is read: a known, missing or private slug all go there.
-// Screenshot bytes are still served here.
+// Legacy screenshot objects redirect to their object URLs.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -16,10 +16,8 @@ const express = require('express');
@@ -16,10 +16,8 @@ const express = require('express');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-moved-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-moved-'));
const db = require('../server/db/database');
const db = require('../server/db/database');
await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
- const shot = path.join(tmp, 'shot.png');
- fs.writeFileSync(shot, Buffer.from('\x89PNG\r\n\x1a\n'));
- await db.getDb().prepare("INSERT INTO pastes (app_id, slug, type, title, content, visibility, screenshot_path) VALUES ('live', 'text-1', 'paste', 't', 'hello', 'public', NULL), ('live', 'shot-1', 'screenshot', 's', '', 'public', ?) RETURNING id").run(shot);
-
+ const objectId = 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+ await db.run("INSERT INTO media_objects (id, app_id, namespace, kind, visibility, lifecycle_status, legacy_ref) VALUES (?, 'live', 'live', 'screenshot', 'public', 'ready', 'legacy:live:paste:shot-1')", [objectId]);
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use(require('../server/public/routes'));
app.use(require('../server/public/routes'));
@@ -33,13 +31,10 @@ const express = require('express');
@@ -33,13 +31,10 @@ const express = require('express');
// The app API is gone: no method on /api/v1/:app/pastes is mounted, so a write has no route to
// The app API is gone: no method on /api/v1/:app/pastes is mounted, so a write has no route to
// answer 410 and creates nothing, and a read has no route either.
// answer 410 and creates nothing, and a read has no route either.
- const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;
- const rows = await pasteCount();
let r = await post();
let r = await post();
assert.strictEqual(r.status, 404, 'no paste-write route remains');
assert.strictEqual(r.status, 404, 'no paste-write route remains');
r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
assert.strictEqual(r.status, 404, 'no paste-read route remains');
assert.strictEqual(r.status, 404, 'no paste-read route remains');
- assert.strictEqual(await pasteCount(), rows, 'nothing creates a paste');
// The page and its text are Community's: a permanent 301, with no env var set.
// The page and its text are Community's: a permanent 301, with no env var set.
r = await get('/p/text-1');
r = await get('/p/text-1');
@@ -57,11 +52,8 @@ const express = require('express');
@@ -57,11 +52,8 @@ const express = require('express');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/a%2Fb%20c', 'the slug is URL-encoded, never interpolated raw');
assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/a%2Fb%20c', 'the slug is URL-encoded, never interpolated raw');
- // The frozen rows still serve their screenshot bytes (Live/Community still link them).
r = await get('/p/shot-1/screenshot');
r = await get('/p/shot-1/screenshot');
- assert.strictEqual(r.status, 200, 'screenshot bytes are still served here');
- assert.ok(await db.getPasteBySlug('text-1'), 'no request deletes anything');
-
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, `/o/${objectId}`]);
server.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
console.log('pastes moved: all checks passed');
console.log('pastes moved: all checks passed');
@@ -45,12 +45,6 @@ const http = require('http');
@@ -45,12 +45,6 @@ const http = require('http');
VALUES (?, 'live', 1, ?, ?, ?, ?, ?, 'ready') RETURNING id`);
VALUES (?, 'live', 1, ?, ?, ?, ?, ?, 'ready') RETURNING id`);
await insClip.run(10, 5, 'Public clip', clipFile('clip-10.webm'), 1, 'public');
await insClip.run(10, 5, 'Public clip', clipFile('clip-10.webm'), 1, 'public');
await insClip.run(11, 5, 'Private clip', clipFile('clip-11-secret.webm'), 0, 'private');
await insClip.run(11, 5, 'Private clip', clipFile('clip-11-secret.webm'), 0, 'private');
- const insPaste = raw.prepare(`INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility, screenshot_path)
- VALUES (?, 'live', 5, 'P', ?, ?, 'text', ?, ?) RETURNING id`);
- await insPaste.run('pub-text', 'paste', 'hello', 'public', null);
- await insPaste.run('priv-text', 'paste', 'secret', 'private', null);
- await insPaste.run('priv-shot', 'screenshot', 'caption', 'private', '/nonexistent.png');
-
const mkObj = async (visibility, lifecycle_status) => await model.createObject({
const mkObj = async (visibility, lifecycle_status) => await model.createObject({
app_id: 'live', namespace: 'live', kind: 'file', owner_subject: null, owner_app: 'live', owner_user_id: 5,
app_id: 'live', namespace: 'live', kind: 'file', owner_subject: null, owner_app: 'live', owner_user_id: 5,
visibility, lifecycle_status, mime_type: 'text/plain', size_bytes: 5, metadata: {},
visibility, lifecycle_status, mime_type: 'text/plain', size_bytes: 5, metadata: {},
@@ -174,13 +168,7 @@ const http = require('http');
@@ -174,13 +168,7 @@ const http = require('http');
assert.strictEqual((await get('/api/v1/live/clips/11', asUser(5))).status, 200);
assert.strictEqual((await get('/api/v1/live/clips/11', asUser(5))).status, 200);
assert.strictEqual((await get('/api/v1/live/vods/6', asUser(77))).status, 200, 'unlisted stays reachable by id');
assert.strictEqual((await get('/api/v1/live/vods/6', asUser(77))).status, 200, 'unlisted stays reachable by id');
- const views = async () => (await raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get()).views;
- const before = await views();
- // The read-only paste API is retired (T10 step 2): the v1 path is unmounted for everyone,
- // so it cannot confirm a private paste exists either.
assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 404, 'the paste API is gone');
assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 404, 'the paste API is gone');
- assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(77))).status, 404);
- assert.strictEqual(await views(), before, 'a refused read counts no view');
console.log('✅ v1 detail: acting non-owners get the missing answer');
console.log('✅ v1 detail: acting non-owners get the missing answer');
server.close();
server.close();
@@ -58,10 +58,6 @@ const http = require('http');
@@ -58,10 +58,6 @@ const http = require('http');
await model.sync('clip', 13);
await model.sync('clip', 13);
const vod = async (id) => await db.get('SELECT * FROM vods WHERE id = ?', [id]);
const vod = async (id) => await db.get('SELECT * FROM vods WHERE id = ?', [id]);
const clip = async (id) => await db.get('SELECT * FROM clips WHERE id = ?', [id]);
const clip = async (id) => await db.get('SELECT * FROM clips WHERE id = ?', [id]);
- await raw().prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility, screenshot_path)
- VALUES ('shot1', 'live', 'S', 'screenshot', '', 'text', 'public', ?) RETURNING id`).run(file(dir('pastes/screenshots'), 'shot1.png', 20));
- await model.sync('paste', (await db.get("SELECT id FROM pastes WHERE slug = 'shot1'")).id);
-
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
@@ -172,17 +168,6 @@ const http = require('http');
@@ -172,17 +168,6 @@ const http = require('http');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
- // ── A held screenshot paste: the v1 paste API is retired (T10 step 2), so the hold is
- // enforced by the delete trigger alone — the row, its bytes and its object stay. ──
- const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
- await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
- await assert.rejects(async () => await db.run("DELETE FROM pastes WHERE slug = 'shot1'"), /retention hold/, 'row SQL is refused by the trigger');
- const probe = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/live/pastes/shot1`, { method: 'DELETE', headers: { authorization: 'Bearer live-key' }, redirect: 'manual' });
- assert.strictEqual(probe.status, 404, 'the paste API has no delete route any more');
- assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
- const shotObj = await model.getObject(shot.object_id);
- assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');
- console.log('✅ a held screenshot paste: the delete trigger refuses it; row, bytes and object stay');
// ── Listing and release ──
// ── Listing and release ──
r = await call('GET', H);
r = await call('GET', H);
@@ -81,7 +81,6 @@ const crypto = require('crypto');
@@ -81,7 +81,6 @@ const crypto = require('crypto');
}
}
const HIDDEN_TITLES = ['Secret VOD title', 'Unlisted VOD title', 'Secret clip title', 'Unlisted clip title'];
const HIDDEN_TITLES = ['Secret VOD title', 'Unlisted VOD title', 'Secret clip title', 'Unlisted clip title'];
const HIDDEN_THUMBS = [`vod-${privVod}-222.jpg`, `vod-${unlVod}-666.jpg`, `clip-${privClip}-2.jpg`, `clip-${unlClip}-3.jpg`];
const HIDDEN_THUMBS = [`vod-${privVod}-222.jpg`, `vod-${unlVod}-666.jpg`, `clip-${privClip}-2.jpg`, `clip-${unlClip}-3.jpg`];
- await raw.prepare("INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility) VALUES ('priv-page', 'live', 5, 'Secret paste title', 'paste', 'secret text', 'text', 'private') RETURNING id").run();
const app = express();
const app = express();
app.put('/api/v2/:app/objects/:id/content', ...require('../server/objects/routes').contentHandlers);
app.put('/api/v2/:app/objects/:id/content', ...require('../server/objects/routes').contentHandlers);
@@ -140,7 +139,7 @@ const crypto = require('crypto');
@@ -140,7 +139,7 @@ const crypto = require('crypto');
for (const p of ['/', '/browse', '/browse?tab=videos', '/browse?tab=clips', '/browse?tab=thumbnails', '/?tab=thumbnails&page=1', '/sitemap.xml', '/llms.txt', '/updates']) {
for (const p of ['/', '/browse', '/browse?tab=videos', '/browse?tab=clips', '/browse?tab=thumbnails', '/?tab=thumbnails&page=1', '/sitemap.xml', '/llms.txt', '/updates']) {
const r = await get(p);
const r = await get(p);
assert.ok(r.status < 500, `${p} answers`);
assert.ok(r.status < 500, `${p} answers`);
- for (const t of [...HIDDEN_TITLES, ...HIDDEN_THUMBS, 'Secret paste title']) assert.ok(!r.text.includes(t), `${p} lists ${t}`);
+ for (const t of [...HIDDEN_TITLES, ...HIDDEN_THUMBS]) assert.ok(!r.text.includes(t), `${p} lists ${t}`);
if (/thumbnails/.test(p)) {
if (/thumbnails/.test(p)) {
assert.ok(r.text.includes(`vod-${pubVod}-111.jpg`) && r.text.includes(`clip-${pubClip}-1.jpg`), `${p}: public recordings' thumbnails are listed`);
assert.ok(r.text.includes(`vod-${pubVod}-111.jpg`) && r.text.includes(`clip-${pubClip}-1.jpg`), `${p}: public recordings' thumbnails are listed`);
}
}
@@ -22,7 +22,7 @@ const https = require('https');
@@ -22,7 +22,7 @@ const https = require('https');
(async () => {
(async () => {
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-ssrf-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-ssrf-'));
- process.env.PASTES_PATH = path.join(tmp, 'pastes');
+ process.env.OBJECTS_PATH = path.join(tmp, 'objects');
process.env.MEDIA_PUBLIC_URL = 'https://media.test';
process.env.MEDIA_PUBLIC_URL = 'https://media.test';
delete process.env.MEDIA_RTMP_PULL_ALLOW;
delete process.env.MEDIA_RTMP_PULL_ALLOW;
@@ -166,7 +166,7 @@ const https = require('https');
@@ -166,7 +166,7 @@ const https = require('https');
const express = require('express');
const express = require('express');
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
- app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config: require('../server/config'), screenshotsDir: path.join(tmp, 'shots'), generateSlug: () => 'slug1', log: { warn() {} } }));
+ app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config: require('../server/config'), log: { warn() {} } }));
const server = http.createServer(app);
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
for (const url of ['https://169.254.169.254/latest/meta-data/iam/security-credentials/', 'https://[::ffff:7f00:1]/a.png', 'https://2130706433/a.png', 'https://localhost/a.png', 'https://mixed.example/a.png']) {
for (const url of ['https://169.254.169.254/latest/meta-data/iam/security-credentials/', 'https://[::ffff:7f00:1]/a.png', 'https://2130706433/a.png', 'https://localhost/a.png', 'https://mixed.example/a.png']) {
@@ -174,8 +174,8 @@ const https = require('https');
@@ -174,8 +174,8 @@ const https = require('https');
const body = await r.json();
const body = await r.json();
assert.deepStrictEqual([r.status, body.ok], [422, false], `${url}: ${JSON.stringify(body)}`);
assert.deepStrictEqual([r.status, body.ok], [422, false], `${url}: ${JSON.stringify(body)}`);
}
}
- assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM pastes WHERE app_id = 'network'")).n, 0, 'no avatar was stored');
- assert.ok(!fs.existsSync(path.join(tmp, 'shots')) || fs.readdirSync(path.join(tmp, 'shots')).length === 0, 'no file was written');
+ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM media_objects WHERE kind = 'avatar'")).n, 0, 'no avatar object was stored');
+ assert.ok(!fs.existsSync(require('../server/config').objects.path) || fs.readdirSync(require('../server/config').objects.path).length === 0, 'no object file was written');
server.close();
server.close();
console.log('✅ POST /internal/avatar-ingest refuses internal URLs and stores nothing');
console.log('✅ POST /internal/avatar-ingest refuses internal URLs and stores nothing');
@@ -27,6 +27,7 @@ const path = require('path');
@@ -27,6 +27,7 @@ const path = require('path');
const h = await db.getAppStatSeries('live', 'hours', 7);
const h = await db.getAppStatSeries('live', 'hours', 7);
assert.strictEqual(h.total, 3);
assert.strictEqual(h.total, 3);
assert.strictEqual(await db.getAppStatSeries('live', 'nope', 7), null);
assert.strictEqual(await db.getAppStatSeries('live', 'nope', 7), null);
+ assert.strictEqual(await db.getAppStatSeries('live', 'pastes', 7), null);
assert.strictEqual((await db.getAppStatSeries('live', 'vods', 9999)).points.length, 365);
assert.strictEqual((await db.getAppStatSeries('live', 'vods', 9999)).points.length, 365);
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
@@ -137,7 +137,7 @@ const crypto = require('crypto');
@@ -137,7 +137,7 @@ const crypto = require('crypto');
walk(tmp);
walk(tmp);
return out.sort();
return out.sort();
};
};
- const TABLES = ['vods', 'clips', 'files', 'pastes', 'media_objects', 'media_locations', 'media_holds', 'media_uploads', 'media_upload_parts'];
+ const TABLES = ['vods', 'clips', 'files', 'media_objects', 'media_locations', 'media_holds', 'media_uploads', 'media_upload_parts'];
const snapshotDb = async () => Object.fromEntries((await Promise.all(TABLES.map(async t => [t, crypto.createHash('sha256').update(JSON.stringify(await raw.prepare(`SELECT * FROM ${t} ORDER BY ${t}::text`).all())).digest('hex')]))));
const snapshotDb = async () => Object.fromEntries((await Promise.all(TABLES.map(async t => [t, crypto.createHash('sha256').update(JSON.stringify(await raw.prepare(`SELECT * FROM ${t} ORDER BY ${t}::text`).all())).digest('hex')]))));
const snapshotBuckets = () => JSON.stringify(Object.entries(buckets).map(([b, m]) => [b, [...m.keys()].sort()]));
const snapshotBuckets = () => JSON.stringify(Object.entries(buckets).map(([b, m]) => [b, [...m.keys()].sort()]));
const before = { files: snapshotFiles(), db: await snapshotDb(), buckets: snapshotBuckets() };
const before = { files: snapshotFiles(), db: await snapshotDb(), buckets: snapshotBuckets() };