openvibers/openvibe.media Commits
Admin storage + paste admin APIs (per-app, app-key auth)
Changes
@@@ -34,8 +34,9 @@ server/vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLIvod/health-scanner.js probe/decode/master-recovery primitivesvod/health-job.js background health scan + quarantine cleanup + master sweep-- pastes/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/thumbnailspublic/routes.js public /v /c /p /t /fvendor/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 callersare 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@@@ -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 -uROOT="$(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 testline 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")nnew file mode 100644@@@ -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;@@@ -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,// pastesgetPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,-- countUserPastesToday, getLastPasteTime,++ countUserPastesToday, getLastPasteTime, getPasteStats,createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,// files@@@ -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@@@ -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 {@@@ -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,
@@ -34,8 +34,9 @@ server/
@@ -34,8 +34,9 @@ server/
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-job.js background health scan + quarantine cleanup + master sweep
vod/health-job.js background health scan + quarantine cleanup + master sweep
- pastes/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
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
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
public/routes.js public /v /c /p /t /f
public/routes.js public /v /c /p /t /f
vendor/openvibe-shared/ vendored shared helpers (do not edit; re-sync from canonical)
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
@@ -130,6 +131,16 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |
| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |
| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |
| 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`,
Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,
`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers
`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
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.
@@ -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 }` |
| 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)
### Webhooks (outbound)
`POST` to the app's `webhook_url` with body
`POST` to the app's `webhook_url` with body
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
# OpenVibe.Media — end-to-end smoke test.
# OpenVibe.Media — end-to-end smoke test.
# Boots the service against a throwaway data dir, then exercises:
# Boots the service against a throwaway data dir, then exercises:
# vod create → chunk upload → complete → meta (duration+thumbnail) →
# vod create → chunk upload → complete → meta (duration+thumbnail) →
-# clip cut → /v range playback → paste (+ /p page + /raw) → 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.
# /f fetch → bad API key rejected → cross-tenant key rejected.
set -u
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -115,6 +117,70 @@ RAW=$(curl -s "$BASE/p/$SLUG/raw")
@@ -115,6 +117,70 @@ RAW=$(curl -s "$BASE/p/$SLUG/raw")
check "/p/$SLUG/raw returns content" "hello from the smoke test
check "/p/$SLUG/raw returns content" "hello from the smoke test
line two" "$RAW"
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 "-- files: upload + public fetch"
echo "smoke-file-payload-$(date +%s)" > "$WORK/upload.txt"
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")
R=$(curl -s -X POST "$BASE/api/v1/live/files" -H "$AUTH" -F "file=@$WORK/upload.txt;type=text/plain")
new file mode 100644
new file mode 100644
@@ -0,0 +1,411 @@
@@ -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;
@@ -381,6 +381,29 @@ function getLastPasteTime(appId, userId, ip) {
@@ -381,6 +381,29 @@ function getLastPasteTime(appId, userId, ip) {
return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;
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 ────────────────────────────────────
// ── Paste comment helpers ────────────────────────────────────
function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
@@ -501,7 +524,7 @@ module.exports = {
@@ -501,7 +524,7 @@ module.exports = {
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
createClip, getClipById, getClipByFileBasename, listClips, countClips, setClipVisibility, findDuplicateClip,
// pastes
// pastes
getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
- countUserPastesToday, getLastPasteTime,
+ countUserPastesToday, getLastPasteTime, getPasteStats,
createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
// files
// files
@@ -59,6 +59,7 @@ app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
@@ -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/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/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
app.use('/', require('./public/routes')); // /v /c /p /t /f
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
@@ -19,6 +19,13 @@
@@ -19,6 +19,13 @@
* POST /:slug/comments add comment
* POST /:slug/comments add comment
* DELETE /:slug/comments/:id delete 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
* 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)
* trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)
* but the columns remain for imported rows.
* but the columns remain for imported rows.
@@ -276,6 +283,117 @@ router.get('/config', tenantAuth({ allowUser: true }), (req, res) => {
@@ -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 ────────────────────────────────
// ── Get single paste by slug ────────────────────────────────
router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
@@ -730,6 +730,40 @@ function stop() {
@@ -730,6 +730,40 @@ function stop() {
// ── Status ───────────────────────────────────────────────────
// ── 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() {
function getStatus() {
const settings = getSettings();
const settings = getSettings();
const localDisk = diskUsage(config.vod.path);
const localDisk = diskUsage(config.vod.path);
@@ -801,6 +835,7 @@ module.exports = {
@@ -801,6 +835,7 @@ module.exports = {
start,
start,
stop,
stop,
getStatus,
getStatus,
+ bucketStatus,
getSettings,
getSettings,
setSetting,
setSetting,
diskUsage,
diskUsage,