openvibers/openvibe.media Commits

Admin storage + paste admin APIs (per-app, app-key auth)

OP OpenVibers committed 17 Aug 2026 · 5a6332a5f6 · 7 files, +688 −3


Changes

ddiff --git a/README.md b/README.md
iindex 969128f..dcba234 100644
---- a/README.md
++++ b/README.md
@@@ -34,8 +34,9 @@ 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/routes.js /api/v1/:app/pastes
++ pastes/routes.js /api/v1/:app/pastes (+ admin: stats/forks/bulk/censor)
files/routes.js /api/v1/:app/files
++ admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
public/routes.js public /v /c /p /t /f
vendor/openvibe-shared/ vendored shared helpers (do not edit; re-sync from canonical)
@@@ -130,6 +131,16 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |
| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |
++**Paste admin** (app-key auth only — the app's server fronts its admins):
++
++| method | path | notes |
++|---|---|---|
++| GET | `/pastes/admin/stats` | app-scoped `{ total, textPastes, screenshots, forks, totalViews, totalCopies, totalLikes }` |
++| GET | `/pastes/admin/forks?limit&offset` | list forked pastes |
++| DELETE | `/pastes/admin/forks` | delete ALL forks (screenshots unlinked too) → `{ deleted }` |
++| POST | `/pastes/bulk` | `{ slugs: [...], action: delete\|public\|unlisted\|private }` (max 500) → `{ done, skipped }` |
++| POST | `/pastes/:slug/censor` | multipart `screenshot` (PNG/JPEG/WebP ≤ 16 MB) replaces a screenshot paste's image (old file deleted); `:slug` also accepts a numeric paste id |
++
Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,
`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers
are trusted server-to-server. AI summary/tags generation was **dropped** (Live
@@@ -150,6 +161,26 @@ owns AI) but the columns remain for imported rows.
|---|---|---|
| POST | `/thumbnails/:kind/:id` | kind `vod`/`clip`: multipart `thumbnail` (or `{image: base64}`) uploads a custom image, or with no body (re)generates from the media (VOD @10%, clip near first frame); kind `live`: upload a broadcaster frame, stored under the stable name `stream-<app>-<id>.jpg` → `{ url }` |
++### Admin storage (`/admin/storage`, app-key auth only)
++
++Storage-management endpoints ported from the predecessor's admin panel. Auth
++is the app API key only (no user JWTs) — admins reach these through their
++app's own server, which holds the key. DB-derived stats are scoped to the
++calling app; **disk totals and directory sizes are host-wide** (the data
++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/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 |
++| PUT | `/admin/storage/tiers/settings` | update any `storage_tier.*` knob (see `vod-storage.js` `DEFAULTS`: `enabled`, `minAgeDays`, `maxViewsForCold`, `minLastAccessDays`, `sweepIntervalMs`, `hotDiskPressurePct`, `localLowWaterPct`, `maxPerSweep`, `r2Enabled`, `r2MinViews`, …); persisted in `media_settings`, sweep timer restarted → `{ ok, settings }` |
++| POST | `/admin/storage/tiers/sweep` | run the tiering sweep now → sweep summary |
++| POST | `/admin/storage/tiers/move` | `{ vod_id, target: local\|hot\|b2\|cold\|r2 }` — reuses the storage engine's verified move logic; VOD must belong to the app |
++| POST | `/admin/storage/tiers/bulk-move` | `{ ids: [...], target }` (max 50) → `{ moved, bytes, errors? }` |
++| GET | `/admin/storage/buckets` | sanitized bucket status per provider: `{ configured, endpoint, bucket, region, healthy, reachable }` via a live HeadBucket probe — **credentials are never returned** |
++
### Webhooks (outbound)
`POST` to the app's `webhook_url` with body
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex 1066822..78d3b8c 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -2,7 +2,9 @@
# 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) → file upload →
++# clip cut → /v range playback → paste (+ /p page + /raw) →
++# paste admin (screenshot censor + stats) → 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
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@@ -115,6 +117,70 @@ RAW=$(curl -s "$BASE/p/$SLUG/raw")
check "/p/$SLUG/raw returns content" "hello from the smoke test
line two" "$RAW"
++echo "-- pastes admin: screenshot + censor + stats"
++R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \
++ -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")
++SSLUG=$(jsonget "$R" slug)
++[ -n "$SSLUG" ] && ok "screenshot paste created slug=$SSLUG" || bad "screenshot paste ($R)"
++R=$(curl -s -X POST "$BASE/api/v1/live/pastes/$SSLUG/censor" -H "$AUTH" \
++ -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg")
++check "censor replaces screenshot" "$SSLUG" "$(jsonget "$R" paste.slug)"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/p/$SSLUG/screenshot")
++check "censored screenshot serves" "200" "$CODE"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes/$SLUG/censor" -H "$AUTH" \
++ -F "screenshot=@$WORK/thumb.jpg;type=image/jpeg")
++check "censor on text paste → 400" "400" "$CODE"
++R=$(curl -s "$BASE/api/v1/live/pastes/admin/stats" -H "$AUTH")
++PT=$(jsonget "$R" stats.total); PS=$(jsonget "$R" stats.screenshots)
++[ "${PT:-0}" -ge 2 ] && ok "paste stats total=$PT" || bad "paste stats total (got '$PT')"
++[ "${PS:-0}" -ge 1 ] && ok "paste stats screenshots=$PS" || bad "paste stats screenshots (got '$PS')"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes/admin/stats")
++check "paste admin without key → 401" "401" "$CODE"
++
++echo "-- admin storage: overview + vod listing"
++R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")
++check "storage overview scoped to app" "live" "$(jsonget "$R" app_id)"
++DT=$(jsonget "$R" disk.total)
++[ "${DT:-0}" -gt 0 ] && ok "disk total reported ($DT bytes)" || bad "disk total (got '$DT')"
++BD=$(jsonget "$R" breakdown.0.name)
++[ -n "$BD" ] && ok "directory breakdown present (first: $BD)" || bad "directory breakdown ($R)"
++VC=$(jsonget "$R" vodStats.count)
++[ "${VC:-0}" -ge 1 ] && ok "vodStats.count=$VC" || bad "vodStats.count (got '$VC')"
++R=$(curl -s "$BASE/api/v1/live/admin/storage/vods?sort=date&order=desc" -H "$AUTH")
++VT=$(jsonget "$R" total)
++[ "${VT:-0}" -ge 1 ] && ok "admin vod listing total=$VT" || bad "admin vod listing ($R)"
++check "listed vod is on local tier" "local" "$(jsonget "$R" vods.0.actualTier)"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/admin/storage")
++check "admin storage without key → 401" "401" "$CODE"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/admin/storage" -H "Authorization: Bearer $GAMES_KEY")
++check "games key on live admin → 403" "403" "$CODE"
++
++echo "-- admin storage: tier settings round-trip + buckets"
++R=$(curl -s -X PUT "$BASE/api/v1/live/admin/storage/tiers/settings" -H "$AUTH" \
++ -H 'Content-Type: application/json' -d '{"minAgeDays":9,"maxViewsForCold":7}')
++check "PUT tiers/settings applies minAgeDays" "9" "$(jsonget "$R" settings.minAgeDays)"
++check "PUT tiers/settings applies maxViewsForCold" "7" "$(jsonget "$R" settings.maxViewsForCold)"
++R=$(curl -s "$BASE/api/v1/live/admin/storage/tiers" -H "$AUTH")
++check "GET tiers round-trips setting" "9" "$(jsonget "$R" settings.minAgeDays)"
++check "GET tiers reports app scope" "live" "$(jsonget "$R" app.app_id)"
++LC=$(jsonget "$R" app.tiers.local.count)
++[ "${LC:-0}" -ge 1 ] && ok "app-scoped local tier count=$LC" || bad "app tier count (got '$LC')"
++R=$(curl -s "$BASE/api/v1/live/admin/storage/buckets" -H "$AUTH")
++check "buckets: b2 unconfigured in smoke" "false" "$(jsonget "$R" buckets.b2.configured)"
++check "buckets: r2 unconfigured in smoke" "false" "$(jsonget "$R" buckets.r2.configured)"
++
++echo "-- admin storage: bulk delete"
++R=$(curl -s -X POST "$BASE/api/v1/live/vods" -H "$AUTH" -H 'Content-Type: application/json' \
++ -d '{"title":"Bulk delete target"}')
++DELID=$(jsonget "$R" id)
++[ -n "$DELID" ] && ok "scratch vod id=$DELID" || bad "scratch vod create ($R)"
++R=$(curl -s -X DELETE "$BASE/api/v1/live/admin/storage/vods/bulk" -H "$AUTH" \
++ -H 'Content-Type: application/json' -d "{\"ids\":[$DELID]}")
++check "bulk delete deleted=1" "1" "$(jsonget "$R" deleted)"
++check "bulk delete per-id ok" "true" "$(jsonget "$R" results.0.ok)"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods/$DELID" -H "$AUTH")
++check "bulk-deleted vod gone" "404" "$CODE"
++
echo "-- files: upload + public fetch"
echo "smoke-file-payload-$(date +%s)" > "$WORK/upload.txt"
R=$(curl -s -X POST "$BASE/api/v1/live/files" -H "$AUTH" -F "file=@$WORK/upload.txt;type=text/plain")
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
nnew file mode 100644
iindex 0000000..579e733
---- /dev/null
++++ b/server/admin/routes.js
@@@ -0,0 +1,411 @@
++/**
++ * OpenVibe.Media — Admin storage API (mounted at /api/v1/:app/admin/storage)
++ *
++ * Storage-management endpoints ported from the predecessor's admin panel,
++ * app-key auth only: admins reach these through their app's server, which
++ * holds the key. DB-derived stats are scoped to the calling app; disk totals
++ * and directory sizes are host-wide (the data dirs are shared across apps) —
++ * responses say so in a `note` field.
++ *
++ * GET / disk usage + per-directory breakdown + per-app DB stats
++ * GET /vods detailed VOD listing (?limit&offset&sort&order&provider&tier)
++ * DELETE /vods/bulk { ids: [...] } — delete VODs everywhere, per-id results
++ * GET /tiers tier/offload status (global + app-scoped counts, job state)
++ * PUT /tiers/settings update storage_tier.* settings (persisted in media_settings)
++ * POST /tiers/sweep trigger a tiering sweep now
++ * POST /tiers/move { vod_id, target: local|hot|b2|cold|r2 }
++ * POST /tiers/bulk-move { ids: [...], target } (max 50)
++ * GET /buckets sanitized B2/R2 bucket config + live reachability probe
++ */
++'use strict';
++
++const express = require('express');
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const config = require('../config');
++const vodStorage = require('../vod/vod-storage');
++const recorder = require('../vod/recorder');
++const tools = require('../vod/media-tools');
++const thumbService = require('../thumbnails/thumbnail-service');
++const { tenantAuth, tenantCors } = require('../auth');
++
++const router = express.Router({ mergeParams: true });
++router.use(tenantCors);
++router.use(tenantAuth()); // app API key only — no user-JWT access to admin storage
++
++const SHARED_DIRS_NOTE = 'Disk totals and directory sizes are host-wide (data directories are shared across apps); DB-derived stats are scoped to this app.';
++
++/** Recursively compute { bytes, files } for a directory (predecessor helper). */
++function dirStatsRecursive(dirPath) {
++ let bytes = 0, files = 0;
++ try {
++ const resolved = path.resolve(dirPath);
++ if (!fs.existsSync(resolved)) return { bytes: 0, files: 0 };
++ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
++ const full = path.join(resolved, entry.name);
++ if (entry.isDirectory()) {
++ const sub = dirStatsRecursive(full);
++ bytes += sub.bytes;
++ files += sub.files;
++ } else if (entry.isFile()) {
++ try { bytes += fs.statSync(full).size; files++; } catch { /* race / perms */ }
++ }
++ }
++ } catch { /* missing / inaccessible */ }
++ return { bytes, files };
++}
++
++function _providerCounts(table, appId, withBytes) {
++ const rows = db.all(`
++ SELECT COALESCE(storage_provider, 'local') AS provider,
++ COUNT(*) AS count${withBytes ? ', COALESCE(SUM(file_size), 0) AS bytes' : ''}
++ FROM ${table} WHERE app_id = ?
++ GROUP BY COALESCE(storage_provider, 'local')
++ `, [appId]);
++ const out = {};
++ for (const r of rows) out[r.provider] = withBytes ? { count: r.count, bytes: r.bytes } : { count: r.count };
++ return out;
++}
++
++// ── GET / — disk usage & per-directory breakdown ─────────────
++router.get('/', (req, res) => {
++ try {
++ const disk = vodStorage.diskUsage(config.vod.path);
++
++ // Shared on-disk directories (host-wide — files aren't segregated per app).
++ const directories = [
++ { name: 'vods', path: config.vod.path },
++ { name: 'clips', path: config.vod.clipsPath },
++ { name: 'pastes', path: config.pastes.path },
++ { name: 'thumbnails', path: config.thumbnails.path },
++ { name: 'files', path: config.files.path },
++ ];
++ const breakdown = directories.map(d => ({ name: d.name, ...dirStatsRecursive(d.path) }));
++
++ let dbBytes = 0;
++ try { dbBytes = fs.statSync(config.db.path).size; } catch { /* */ }
++
++ // App-scoped DB stats
++ const vodStats = db.get(`
++ SELECT COUNT(*) AS count, COALESCE(SUM(file_size), 0) AS bytes,
++ COALESCE(MIN(created_at), '') AS oldest, COALESCE(MAX(created_at), '') AS newest
++ FROM vods WHERE app_id = ?
++ `, [req.appId]) || {};
++ const clipStats = db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};
++ const pasteStats = 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 = db.get('SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?', [req.appId]) || {};
++
++ res.json({
++ app_id: req.appId,
++ note: SHARED_DIRS_NOTE,
++ disk,
++ database: { bytes: dbBytes },
++ breakdown,
++ vodStats,
++ clipStats,
++ pasteStats,
++ fileStats,
++ byProvider: {
++ vods: _providerCounts('vods', req.appId, true),
++ clips: _providerCounts('clips', req.appId, false), // clips carry no file_size column
++ },
++ });
++ } catch (err) {
++ console.error('[Admin] Storage error:', err.message);
++ res.status(500).json({ error: 'Failed to analyze storage' });
++ }
++});
++
++// ── GET /vods — detailed VOD listing ─────────────────────────
++const PROVIDER_ALIASES = { hot: 'local', local: 'local', cold: 'b2', b2: 'b2', r2: 'r2' };
++
++router.get('/vods', (req, res) => {
++ try {
++ const sort = req.query.sort || 'size'; // size, date, duration, tier, views, accessed
++ const order = String(req.query.order || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
++ const limit = Math.min(parseInt(req.query.limit || '100', 10) || 100, 500);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10) || 0, 0);
++
++ let orderBy;
++ switch (sort) {
++ case 'date': orderBy = `created_at ${order}`; break;
++ case 'duration': orderBy = `duration_seconds ${order}`; break;
++ case 'tier': orderBy = `COALESCE(storage_provider, 'local') ${order}, file_size DESC`; break;
++ case 'views': orderBy = `view_count ${order}`; break;
++ case 'accessed': orderBy = `last_accessed_at ${order}`; break;
++ default: orderBy = `file_size ${order}`; break;
++ }
++
++ const conds = ['app_id = ?'];
++ const params = [req.appId];
++ // `provider` filters storage_provider directly; `tier` accepts the
++ // predecessor's hot/cold vocabulary as aliases.
++ const providerFilter = PROVIDER_ALIASES[String(req.query.provider || req.query.tier || '').toLowerCase()];
++ if (providerFilter) { conds.push("COALESCE(storage_provider, 'local') = ?"); params.push(providerFilter); }
++
++ const vods = db.all(`
++ SELECT id, title, file_path, file_size, duration_seconds, is_public, visibility,
++ is_recording, clips_only, created_at, view_count, health_status,
++ storage_provider, storage_tier, storage_key, last_accessed_at,
++ stream_id, user_id, thumbnail_url
++ FROM vods
++ WHERE ${conds.join(' AND ')}
++ ORDER BY ${orderBy}
++ LIMIT ? OFFSET ?
++ `, [...params, limit, offset]);
++ const total = db.get(`SELECT COUNT(*) AS c FROM vods WHERE ${conds.join(' AND ')}`, params).c;
++
++ // Reconcile DB tier with what's actually on disk / in object storage.
++ const enriched = vods.map(v => {
++ let diskSize = 0, exists = false, tier = vodStorage.providerOf(v);
++ if (v.file_path) {
++ const localPath = vodStorage.localPathForVod(v);
++ if (fs.existsSync(localPath)) {
++ try { diskSize = fs.statSync(localPath).size; } catch { /* */ }
++ exists = true;
++ tier = 'local';
++ } else if (vodStorage.isRemote(v)) {
++ diskSize = v.file_size || 0;
++ exists = true;
++ } else {
++ tier = 'missing';
++ }
++ }
++ return { ...v, diskSize, fileExists: exists, actualTier: tier };
++ });
++
++ // Per-user summary (app-local user ids; Media holds no user profiles)
++ const userSummary = db.all(`
++ SELECT user_id, COUNT(*) AS vodCount, COALESCE(SUM(file_size), 0) AS totalSize
++ FROM vods WHERE app_id = ?
++ GROUP BY user_id
++ ORDER BY totalSize DESC
++ LIMIT 20
++ `, [req.appId]);
++
++ res.json({ vods: enriched, total, limit, offset, userSummary });
++ } catch (err) {
++ console.error('[Admin] VOD storage error:', err.message);
++ res.status(500).json({ error: 'Failed to list VOD storage' });
++ }
++});
++
++// ── DELETE /vods/bulk — bulk delete VODs by id ───────────────
++router.delete('/vods/bulk', async (req, res) => {
++ try {
++ const { ids } = req.body || {};
++ if (!Array.isArray(ids) || ids.length === 0) {
++ return res.status(400).json({ error: 'ids array required' });
++ }
++ if (ids.length > 200) {
++ return res.status(400).json({ error: 'Max 200 VODs per bulk delete' });
++ }
++
++ let deleted = 0, freed = 0;
++ const results = [];
++ for (const rawId of ids) {
++ const id = parseInt(rawId, 10);
++ const result = { id: Number.isFinite(id) ? id : rawId, ok: false };
++ try {
++ const vod = Number.isFinite(id) ? db.getVodById(id, req.appId) : null;
++ if (!vod) { result.error = 'VOD not found'; results.push(result); continue; }
++
++ if (recorder.isRecording(vod.id)) recorder.stopRecording(vod.id);
++ try { require('../vod/routes').activeChunkUploads.delete(vod.id); } catch { /* */ }
++
++ // Delete media everywhere (local + B2 + R2) + sidecars/master
++ if (vod.file_path) {
++ freed += vod.file_size || 0;
++ await vodStorage.deleteVodObjects(vod);
++ tools.cleanupSeekableFile(vod.file_path);
++ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }
++ }
++ // Thumbnail file
++ if (vod.thumbnail_url && String(vod.thumbnail_url).startsWith('/t/')) {
++ const thumbFile = path.join(thumbService.THUMB_DIR, path.basename(vod.thumbnail_url));
++ try { if (fs.existsSync(thumbFile)) fs.unlinkSync(thumbFile); } catch { /* */ }
++ }
++
++ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);
++ db.run("DELETE FROM content_views WHERE content_type = 'vod' AND content_id = ?", [vod.id]);
++ result.ok = true;
++ deleted++;
++ } catch (err) {
++ result.error = err.message;
++ }
++ results.push(result);
++ }
++
++ console.log(`[Admin] Bulk VOD delete (${req.appId}): ${deleted}/${ids.length} deleted, ${(freed / 1048576).toFixed(1)} MB freed`);
++ res.json({ deleted, freed, results });
++ } catch (err) {
++ console.error('[Admin] Bulk VOD delete error:', err.message);
++ res.status(500).json({ error: 'Bulk delete failed' });
++ }
++});
++
++// ═══════════════════════════════════════════════════════════════
++// Storage tiers — local / Backblaze B2 / Cloudflare R2
++// ═══════════════════════════════════════════════════════════════
++
++// ── GET /tiers — tier/offload status ─────────────────────────
++router.get('/tiers', (req, res) => {
++ try {
++ const status = vodStorage.getStatus(); // settings, providers, disk, global tier counts, sweepRunning
++
++ // App-scoped tier counts
++ const appCounts = db.get(`
++ SELECT
++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) AS localCount,
++ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) AS b2Count,
++ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) AS r2Count,
++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) AS localBytes,
++ SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) AS b2Bytes,
++ SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) AS r2Bytes
++ FROM vods WHERE app_id = ?
++ `, [req.appId]) || {};
++
++ // VODs of this app currently eligible for the next cold-offload sweep
++ const s = status.settings;
++ const pendingOffload = db.get(`
++ SELECT COUNT(*) AS c FROM vods
++ WHERE app_id = ?
++ AND COALESCE(storage_provider, 'local') = 'local'
++ AND COALESCE(is_recording, 0) = 0
++ AND created_at <= datetime('now', ?)
++ AND COALESCE(view_count, 0) <= ?
++ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))
++ `, [req.appId, `-${s.minAgeDays} days`, s.maxViewsForCold, `-${s.minLastAccessDays} days`])?.c || 0;
++
++ res.json({
++ ...status,
++ note: 'providers/local/tiers/clipTiers are service-wide; `app` is scoped to this app.',
++ app: {
++ app_id: req.appId,
++ tiers: {
++ local: { count: appCounts.localCount || 0, bytes: appCounts.localBytes || 0 },
++ b2: { count: appCounts.b2Count || 0, bytes: appCounts.b2Bytes || 0 },
++ r2: { count: appCounts.r2Count || 0, bytes: appCounts.r2Bytes || 0 },
++ },
++ pendingOffload,
++ },
++ });
++ } catch (err) {
++ console.error('[Admin] Storage tier status error:', err.message);
++ res.status(500).json({ error: 'Failed to get tier status' });
++ }
++});
++
++// ── PUT /tiers/settings — update tier settings ───────────────
++router.put('/tiers/settings', (req, res) => {
++ try {
++ const allowed = Object.keys(vodStorage.DEFAULTS);
++ const updates = {};
++ for (const key of allowed) {
++ if (req.body?.[key] !== undefined) {
++ let val = req.body[key];
++ // Coerce types to match defaults
++ if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);
++ if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = !!val;
++ vodStorage.setSetting(key, val);
++ updates[key] = val;
++ }
++ }
++ // Restart sweep timer with new settings
++ vodStorage.stop();
++ vodStorage.start();
++ console.log(`[Admin] Storage tier settings updated (${req.appId}):`, updates);
++ res.json({ ok: true, settings: vodStorage.getSettings() });
++ } catch (err) {
++ console.error('[Admin] Storage tier settings error:', err.message);
++ res.status(500).json({ error: 'Failed to update settings' });
++ }
++});
++
++// ── POST /tiers/sweep — trigger a sweep now ──────────────────
++router.post('/tiers/sweep', async (req, res) => {
++ try {
++ const result = await vodStorage.runSweep();
++ console.log(`[Admin] Manual sweep triggered (${req.appId}):`, result);
++ res.json(result);
++ } catch (err) {
++ console.error('[Admin] Sweep error:', err.message);
++ res.status(500).json({ error: 'Sweep failed' });
++ }
++});
++
++// target → mover (predecessor's hot/cold vocabulary kept as aliases)
++const MOVERS = {
++ local: (id) => vodStorage.moveToHot(id),
++ hot: (id) => vodStorage.moveToHot(id),
++ b2: (id) => vodStorage.moveToCold(id),
++ cold: (id) => vodStorage.moveToCold(id),
++ r2: (id) => vodStorage.promoteToR2(id),
++};
++
++async function _moveScoped(appId, rawId, target) {
++ const id = parseInt(rawId, 10);
++ if (!Number.isFinite(id) || !db.getVodById(id, appId)) return { ok: false, error: 'VOD not found' };
++ return MOVERS[target](id);
++}
++
++// ── POST /tiers/move — move one VOD between tiers ────────────
++router.post('/tiers/move', async (req, res) => {
++ try {
++ const { vod_id, vodId, target } = req.body || {};
++ const id = vod_id ?? vodId; // vodId = predecessor alias
++ if (id == null || !MOVERS[target]) {
++ return res.status(400).json({ error: 'vod_id and target (local|hot|b2|cold|r2) required' });
++ }
++ const result = await _moveScoped(req.appId, id, target);
++ if (!result.ok && result.error === 'VOD not found') return res.status(404).json(result);
++ console.log(`[Admin] VOD ${id} move to ${target} (${req.appId}):`, result);
++ res.json(result);
++ } catch (err) {
++ console.error('[Admin] Tier move error:', err.message);
++ res.status(500).json({ error: 'Move failed' });
++ }
++});
++
++// ── POST /tiers/bulk-move — bulk move VODs to a tier ─────────
++router.post('/tiers/bulk-move', async (req, res) => {
++ try {
++ const { ids, target } = req.body || {};
++ if (!Array.isArray(ids) || ids.length === 0 || !MOVERS[target]) {
++ return res.status(400).json({ error: 'ids array and target (local|hot|b2|cold|r2) required' });
++ }
++ if (ids.length > 50) {
++ return res.status(400).json({ error: 'Max 50 VODs per bulk move' });
++ }
++ let moved = 0, bytesTotal = 0;
++ const errors = [];
++ for (const id of ids) {
++ const result = await _moveScoped(req.appId, id, target);
++ if (result.ok) { moved++; bytesTotal += result.bytes || 0; }
++ else errors.push({ id, error: result.error });
++ }
++ console.log(`[Admin] Bulk move → ${target} (${req.appId}): ${moved}/${ids.length}`);
++ res.json({ moved, bytes: bytesTotal, errors: errors.length ? errors : undefined });
++ } catch (err) {
++ console.error('[Admin] Bulk move error:', err.message);
++ res.status(500).json({ error: 'Bulk move failed' });
++ }
++});
++
++// ── GET /buckets — bucket config + reachability (no creds) ───
++router.get('/buckets', async (req, res) => {
++ try {
++ const buckets = await vodStorage.bucketStatus();
++ res.json({ buckets, checkedAt: new Date().toISOString() });
++ } catch (err) {
++ console.error('[Admin] Bucket status error:', err.message);
++ res.status(500).json({ error: 'Failed to check buckets' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 5331b20..799e3ef 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -381,6 +381,29 @@ function getLastPasteTime(appId, userId, 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). */
++function getPasteStats(appId) {
++ const row = 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) AS totalViews,
++ COALESCE(SUM(copies), 0) AS totalCopies,
++ COALESCE(SUM(likes), 0) 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 ────────────────────────────────────
function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
@@@ -501,7 +524,7 @@ module.exports = {
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
// pastes
getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
-- countUserPastesToday, getLastPasteTime,
++ countUserPastesToday, getLastPasteTime, getPasteStats,
createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
// files
ddiff --git a/server/index.js b/server/index.js
iindex ffaa466..9b91af7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -59,6 +59,7 @@ app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
++app.use('/api/v1/:app/admin/storage', require('./admin/routes'));
app.use('/', require('./public/routes')); // /v /c /p /t /f
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex a61933c..a0b13f7 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -19,6 +19,13 @@
* POST /:slug/comments add comment
* DELETE /:slug/comments/:id delete comment
*
++ * Admin endpoints (app-key auth only — the app's server fronts its admins):
++ * GET /admin/stats app-scoped paste stats
++ * GET /admin/forks list forked pastes
++ * DELETE /admin/forks delete ALL forks (+ screenshots)
++ * POST /bulk { slugs, action: delete|public|unlisted|private }
++ * POST /:slug/censor replace a screenshot with a censored image (multipart)
++ *
* Rate limits (cooldown/daily) apply to user-JWT callers; app-key callers are
* trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)
* but the columns remain for imported rows.
@@@ -276,6 +283,117 @@ router.get('/config', tenantAuth({ allowUser: true }), (req, res) => {
}
});
++// ═════════════════════════════════════════════════════════════
++// ── Admin (app-key only) — registered before /:slug ─────────
++// ═════════════════════════════════════════════════════════════
++
++// ── Paste stats ─────────────────────────────────────────────
++router.get('/admin/stats', tenantAuth(), (req, res) => {
++ try {
++ res.json({ stats: db.getPasteStats(req.appId) });
++ } catch (err) {
++ console.error('[Pastes] Stats error:', err.message);
++ res.status(500).json({ error: 'Failed to get paste stats' });
++ }
++});
++
++// ── List forked pastes ──────────────────────────────────────
++router.get('/admin/forks', tenantAuth(), (req, res) => {
++ try {
++ const limit = Math.min(Math.max(parseInt(req.query.limit) || 100, 1), 500);
++ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
++ const forks = db.all(`
++ SELECT id, slug, user_id, type, title, forked_from, visibility,
++ views, copies, likes, created_at
++ FROM pastes
++ WHERE app_id = ? AND forked_from IS NOT NULL
++ ORDER BY created_at DESC LIMIT ? OFFSET ?
++ `, [req.appId, limit, offset]);
++ const total = db.get('SELECT COUNT(*) AS c FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]).c;
++ res.json({ forks, total, limit, offset });
++ } catch (err) {
++ console.error('[Pastes] List forks error:', err.message);
++ res.status(500).json({ error: 'Failed to list forks' });
++ }
++});
++
++// ── Delete ALL forks (ported predecessor semantics) ─────────
++router.delete('/admin/forks', tenantAuth(), (req, res) => {
++ try {
++ const forks = db.all('SELECT id, screenshot_path FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]);
++ for (const f of forks) removePasteScreenshot(f); // unlink screenshots so they don't leak on disk
++ db.run('DELETE FROM pastes WHERE app_id = ? AND forked_from IS NOT NULL', [req.appId]);
++ res.json({ success: true, deleted: forks.length });
++ } catch (err) {
++ console.error('[Pastes] Delete forks error:', err.message);
++ res.status(500).json({ error: 'Failed to delete forks' });
++ }
++});
++
++// ── Bulk action: delete | public | unlisted | private ───────
++router.post('/bulk', tenantAuth(), (req, res) => {
++ try {
++ const { slugs, action } = req.body || {};
++ if (!Array.isArray(slugs) || !slugs.length) return res.status(400).json({ error: 'No slugs provided' });
++ if (!['delete', 'public', 'unlisted', 'private'].includes(action)) return res.status(400).json({ error: 'Invalid action' });
++ let done = 0, skipped = 0;
++ for (const slug of slugs.slice(0, 500)) {
++ const paste = db.getPasteBySlug(String(slug), req.appId);
++ if (!paste) { skipped++; continue; }
++ if (action === 'delete') {
++ removePasteScreenshot(paste);
++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
++ } else {
++ db.run('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [action, paste.id]);
++ }
++ done++;
++ }
++ res.json({ done, skipped });
++ } catch (err) {
++ console.error('[Pastes] Bulk error:', err.message);
++ res.status(500).json({ error: 'Bulk action failed' });
++ }
++});
++
++// ── Censor a screenshot (replace with an uploaded image) ────
++router.post('/:slug/censor', tenantAuth(), (req, res) => {
++ // Accept slug or numeric paste id (the predecessor keyed this by slug).
++ let paste = db.getPasteBySlug(String(req.params.slug), req.appId);
++ if (!paste && /^\d+$/.test(String(req.params.slug))) {
++ paste = db.get('SELECT * FROM pastes WHERE id = ? AND app_id = ?', [parseInt(req.params.slug, 10), req.appId]);
++ }
++ if (!paste) return res.status(404).json({ error: 'Paste not found' });
++ if (paste.type !== 'screenshot' || !paste.screenshot_path) {
++ return res.status(400).json({ error: 'Not a screenshot paste' });
++ }
++
++ const censorUpload = multer({
++ storage: screenshotStorage,
++ limits: { fileSize: 16 * 1024 * 1024 }, // 16 MB for censored exports
++ fileFilter: (_req, file, cb) => {
++ if (/^image\/(png|jpeg|webp)$/.test(file.mimetype)) cb(null, true);
++ else cb(new Error('Only PNG, JPEG, or WebP images allowed'));
++ },
++ }).single('screenshot');
++
++ censorUpload(req, res, (err) => {
++ if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
++ if (!req.file) return res.status(400).json({ error: 'Censored image is required' });
++
++ try {
++ // Delete the old screenshot file, then swap in the censored one.
++ try { fs.unlinkSync(paste.screenshot_path); } catch { /* */ }
++ db.run('UPDATE pastes SET screenshot_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
++ [req.file.path, paste.id]);
++ res.json({ paste: pastePublic(db.getPasteBySlug(paste.slug, req.appId)) });
++ } catch (err2) {
++ console.error('[Pastes] Censor error:', err2.message);
++ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
++ res.status(500).json({ error: 'Failed to save censored image' });
++ }
++ });
++});
++
// ── Get single paste by slug ────────────────────────────────
router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
ddiff --git a/server/vod/vod-storage.js b/server/vod/vod-storage.js
iindex 22d2be6..0bad9a3 100644
---- a/server/vod/vod-storage.js
++++ b/server/vod/vod-storage.js
@@@ -730,6 +730,40 @@ function stop() {
// ── Status ───────────────────────────────────────────────────
++/**
++ * Sanitized bucket configuration + a cheap live HeadBucket reachability probe
++ * per provider. NEVER includes credentials (endpoint/bucket/region only).
++ */
++async function bucketStatus() {
++ const out = {};
++ for (const name of REMOTE_PROVIDERS) {
++ const p = PROVIDER_ENV[name];
++ const configured = providerConfigured(name);
++ const entry = {
++ configured,
++ endpoint: p.endpoint || null,
++ bucket: p.bucket || null,
++ region: p.region || null,
++ healthy: providerHealthy[name] !== false, // last known state
++ reachable: false, // live probe below
++ };
++ if (configured) {
++ try {
++ loadSdk();
++ await clientFor(name).send(new S3.HeadBucketCommand({ Bucket: p.bucket }));
++ entry.reachable = true;
++ providerHealthy[name] = true;
++ } catch (err) {
++ entry.error = err.name || err.message;
++ providerHealthy[name] = false;
++ entry.healthy = false;
++ }
++ }
++ out[name] = entry;
++ }
++ return out;
++}
++
function getStatus() {
const settings = getSettings();
const localDisk = diskUsage(config.vod.path);
@@@ -801,6 +835,7 @@ module.exports = {
start,
stop,
getStatus,
++ bucketStatus,
getSettings,
setSetting,
diskUsage,