openvibers/openvibe.media Commits

Media stops reading the frozen paste tables (screenshots and avatars are objects) (#75)

OP OpenVibers committed 10 Oct 2026 · 1d9693b4bf · 35 files, +274 −510

Changes

ddiff --git a/README.md b/README.md
iindex b512fa5..e4205dc 100644
---- a/README.md
++++ b/README.md
@@@ -8,7 +8,7 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
`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
--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, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
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
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)
@@@ -296,23 +295,20 @@ deleted through Media any more.
`/p/:slug` and `/p/:slug/raw` are unconditional **301s** to Community: the target
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
--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); every
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)**.
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex ad86410..73ad08f 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -2,12 +2,12 @@
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
--`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 the
object `deleted` in the same statement whenever its row is deleted, which covers all of the
inherited delete paths.
@@@ -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
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
--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.
### 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
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex f3ae989..63ff03c 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -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
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 4844ef4..802d0a8 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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; }
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 6842212..373250a 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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),
ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex a90e7fe..8f92b2d 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -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, SIZE
const 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 };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 7bba1de..134924c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
// clips
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
-- // pastes
-- getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
-- countUserPastesToday, getLastPasteTime, getPasteStats,
-- createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
-- getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
// files
createFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,
// migration
ddiff --git a/server/index.js b/server/index.js
iindex 3994479..e9f10a3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/server/me/ops.js b/server/me/ops.js
iindex 631d3fa..c83f115 100644
---- a/server/me/ops.js
++++ b/server/me/ops.js
@@@ -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 NULL
AND lifecycle_status != 'deleted'${s.sql} GROUP BY app_id ORDER BY n DESC`, s.params);
ddiff --git a/server/objects/copy-report.js b/server/objects/copy-report.js
iindex 4c2b667..f2288e3 100644
---- a/server/objects/copy-report.js
++++ b/server/objects/copy-report.js
@@@ -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
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]),
-- 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
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
@@@ -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 || '-'})`);
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex 7b141f0..7110d74 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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,
ddiff --git a/server/objects/reconcile.js b/server/objects/reconcile.js
iindex 25fa625..1063db1 100644
---- a/server/objects/reconcile.js
++++ b/server/objects/reconcile.js
@@@ -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] : [])) {
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 828b78f..d0f9c92 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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}` });
ddiff --git a/server/pastes/storage.js b/server/pastes/storage.js
ddeleted file mode 100644
iindex 4fed5d7..0000000
---- a/server/pastes/storage.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 4aa9efe..e518ca6 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -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(cli
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.
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 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}
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
ORDER 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 &amp; thumbnails from all OpenVibe sites</span></header>
++<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files &amp; 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>
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex b45c46b..88f7e99 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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.par
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) => {
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');
}
ddiff --git a/server/views/routes.js b/server/views/routes.js
iindex 3e00bb1..dda897e 100644
---- a/server/views/routes.js
++++ b/server/views/routes.js
@@@ -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]);
ddiff --git a/server/views/service.js b/server/views/service.js
iindex 748b49f..ae9994f 100644
---- a/server/views/service.js
++++ b/server/views/service.js
@@@ -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 }}
ddiff --git a/server/vod/orphans-report.js b/server/vod/orphans-report.js
iindex 478893f..671ae81 100644
---- a/server/vod/orphans-report.js
++++ b/server/vod/orphans-report.js
@@@ -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));
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 777fee1..64b95c7 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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.js
const assert = require('assert');
@@@ -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_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_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 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 {
ddiff --git a/test/avatar-ingest.test.js b/test/avatar-ingest.test.js
iindex 189291d..32c96e8 100644
---- a/test/avatar-ingest.test.js
++++ b/test/avatar-ingest.test.js
@@@ -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;
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex 039ec37..60712ad 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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'];
ddiff --git a/test/helpers/project-rows.js b/test/helpers/project-rows.js
iindex 320c61d..944e3c9 100644
---- a/test/helpers/project-rows.js
++++ b/test/helpers/project-rows.js
@@@ -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) {
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex b6d0999..a8eb101 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -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.
ddiff --git a/test/object-first-writes.test.js b/test/object-first-writes.test.js
iindex 6200c46..06d7331 100644
---- a/test/object-first-writes.test.js
++++ b/test/object-first-writes.test.js
@@@ -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');
ddiff --git a/test/objects-model.test.js b/test/objects-model.test.js
iindex a70a3b7..a9ba199 100644
---- a/test/objects-model.test.js
++++ b/test/objects-model.test.js
@@@ -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']);
ddiff --git a/test/paste-screenshot-redirect.test.js b/test/paste-screenshot-redirect.test.js
nnew file mode 100644
iindex 0000000..e208004
---- /dev/null
++++ b/test/paste-screenshot-redirect.test.js
@@@ -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); });
ddiff --git a/test/paste-tables-unread.test.js b/test/paste-tables-unread.test.js
nnew file mode 100644
iindex 0000000..29fc638
---- /dev/null
++++ b/test/paste-tables-unread.test.js
@@@ -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');
ddiff --git a/test/pastes-moved.test.js b/test/pastes-moved.test.js
iindex ff6bd9d..7e1ad51 100644
---- a/test/pastes-moved.test.js
++++ b/test/pastes-moved.test.js
@@@ -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');
ddiff --git a/test/private-not-found.test.js b/test/private-not-found.test.js
iindex 3670d62..8304505 100644
---- a/test/private-not-found.test.js
++++ b/test/private-not-found.test.js
@@@ -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();
ddiff --git a/test/retention-holds.test.js b/test/retention-holds.test.js
iindex 5ccf9a9..7ed85fe 100644
---- a/test/retention-holds.test.js
++++ b/test/retention-holds.test.js
@@@ -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);
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex fef77b8..e46210a 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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`);
}
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 858d606..db9a61b 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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');
ddiff --git a/test/stat-series.test.js b/test/stat-series.test.js
iindex e44c7a2..dc29235 100644
---- a/test/stat-series.test.js
++++ b/test/stat-series.test.js
@@@ -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 { /* */ } }
ddiff --git a/test/storage-orphans.test.js b/test/storage-orphans.test.js
iindex dff2d03..769748c 100644
---- a/test/storage-orphans.test.js
++++ b/test/storage-orphans.test.js
@@@ -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() };