openvibers/openvibe.mediahub Commits
The drive has a bin for 30 days, and the API has its rename, move, folder delete and search (#306)
Changes
@@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa## Unreleased++- The drive has a bin, and the API has the drive's full controls (2026-11-07). Deleting a file — from its page, its++ card, or `DELETE /api/v1/files/:id` — puts it in the bin for 30 days instead of deleting it: its share links stop at++ once, its bytes stay in OpenVibe.Media, and it still counts against storage. `/files/bin` in the sidebar (with a++ count) lists what is there, each file with Restore and Delete forever, and an Empty the bin button; a file binned more++ than 30 days ago is purged hourly in batches of 200 (the row is soft-deleted and Media is asked for the object). The++ API now also takes `PATCH /api/v1/files/:id` (`name`, `folder_id`; null is the top), `PATCH /api/v1/folders/:id`++ (`name`), `DELETE /api/v1/folders/:id` (409 `folder.not_empty`), `GET /api/v1/files?q=` across every folder,++ `POST /api/v1/files/:id/restore` and `DELETE /api/v1/files/:id?forever=1`; a sync key may rename and move but may not++ delete a folder. Migration `0023_files_bin.sql`.- openvibe.download looks and works like a drive (2026-11-07, the owner: "a competitor to Google Drive … sleek and easyto use and intuitive and minimalist while being powerful … full controls"). `/files` is a sidebar (New, My files,Shared links, Backups, the storage meter) beside the files: folder tiles, then file cards with the picture or a@@@ -11,8 +20,8 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its parows in the list view. Search covers every folder (`?q=`, and it filters what is on screen as you type); sort bynewest, oldest, name or size, remembered. Files dropped anywhere on the page upload into the folder on screen, manyat once, with a tray that shows each one's progress and refreshes the list when they are in. Every file and folder-- has a menu: Download, Share a link, Move to…, Rename, Delete; a folder deletes only when it is empty (there is no bin-- yet). On a phone, New is a floating button that opens a bottom sheet, and a menu opens as a sheet over the tab bar.++ has a menu: Download, Share a link, Move to…, Rename, Delete; a folder deletes only when it is empty. On a phone, New++ is a floating button that opens a bottom sheet, and a menu opens as a sheet over the tab bar.A file's page shows the file large beside what you can do with it: who can open a link (three choices, drawn ascards), expiry as chips, Move, Rename, the facts and Delete. Fixed on the way: a folder made inside a folder went tothe top level. Everything still works with JavaScript off.@@@ -137,6 +137,12 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never hresume after a dropped connection.- **Downloads:** MediaHub checks access, then streams from Media with its own headers. Range requests pass through,so downloads resume.++- **The bin:** `DELETE /api/v1/files/:id` (or the page's Delete) sets `binned_at` and revokes the file's share links;++ the bytes stay in Media and still count against storage for 30 days. `POST /api/v1/files/:id/restore` brings it++ back; `DELETE /api/v1/files/:id?forever=1` deletes it now. A due job (`server/drive/bin.js`) purges files binned++ more than 30 days ago, 200 at a time. `PATCH /api/v1/files/:id` renames/moves, `PATCH`/`DELETE /api/v1/folders/:id`++ rename/delete an empty folder, and `GET /api/v1/files?q=` searches every folder. All of it is documented in++ `/openapi.json` and migration `0023_files_bin.sql` adds the column and its index.MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- The bin (2026-11-07): a deleted file is kept for 30 days so its owner can restore it. The row and its bytes stay++-- where they are; binned_at is when it went to the bin, and a due job soft-deletes the row and asks Media to delete++-- the object once it is older than that. A binned row still counts against storage until it is purged.++ALTER TABLE mh_files ADD COLUMN binned_at timestamptz;++++-- What the bin page lists and what the due purge scans: this person's binned files, newest first.++CREATE INDEX mh_files_binned ON mh_files (owner) WHERE binned_at IS NOT NULL AND deleted_at IS NULL;@@@ -272,6 +272,10 @@.fv-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }.fv-facts code { font-size: 12px; }++/* The bin: the same card, without the file link, with Restore and Delete forever under it. */++.dr-bin-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 12px 12px; }++.dr-bin-note { margin: 0 0 18px; font-size: 13.5px; line-height: 1.5; color: var(--dr-muted); }++/* Phones and narrow tablets: the files first and full width, New as a floating button over a bottom sheet. */@media (max-width: 759px) {.dr { grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 0; }@@@ -6,7 +6,7 @@module.exports = {id: 'download',name: 'OpenVibe.Download',-- purpose: 'A private drive on the OpenVibe network: keep your own files in your account, share them with a link that expires, and back up a folder from your computer.',++ purpose: 'A private drive on the OpenVibe network: keep your own files in your account, rename and move them, delete them to a 30-day bin, share them with a link that expires, and back up a folder from your computer.',home: 'https://openvibe.download',site: 'openvibe.download',api: '/api/v1',nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++/**++ * The bin's due purge: a file binned more than 30 days ago is deleted forever — its row soft-deleted and++ * OpenVibe.Media asked to delete the object — in batches of 200.++ *++ * The job is due-based, not a 30-day timer: every hour it asks the drive what is due, so a restart never loses or++ * delays anything past the next tick, and a bin older than 30 days goes on the first run after boot. It is mounted++ * from server/index.js next to the Pics and drops sweeps, and stopped with the process.++ */++const { BIN_DAYS } = require('./rules');++++const BATCH = 200;++const EVERY_MS = 3_600_000;++++function createBinPurge({ drive, log = console, everyMs = EVERY_MS }) {++ let timer = null;++ let running = null;++++ /** One due pass, in batches; answers how many files were purged. Concurrent calls share the run. */++ function run() {++ if (running) return running;++ running = drive.purgeDue({ batch: BATCH })++ .then((n) => {++ if (n) log.log(`[MediaHub] bin: purged ${n} file(s) binned more than ${BIN_DAYS} days ago`);++ return n;++ })++ .catch((err) => {++ log.warn(`[MediaHub] bin purge failed: ${err && err.message}`);++ return 0;++ })++ .finally(() => { running = null; });++ return running;++ }++++ return {++ BIN_DAYS, BATCH, run,++ start() {++ if (timer) return;++ run();++ timer = setInterval(run, everyMs);++ if (timer.unref) timer.unref();++ },++ stop() {++ if (timer) clearInterval(timer);++ timer = null;++ },++ };++}++++module.exports = { createBinPurge, BIN_DAYS, BATCH, EVERY_MS };@@@ -48,6 +48,9 @@ const EXPIRY_MIN_HOURS = 1;const EXPIRY_MAX_HOURS = 30 * 24;const EXPIRY_DEFAULT_HOURS = 7 * 24;++// A deleted file waits in the bin this long before the due purge deletes it and asks Media to delete the object.++const BIN_DAYS = 30;++/** The declared content type, normalised, or null when it is not a media type at all. */function normalizeType(value) {const t = String(value == null ? '' : value).split(';')[0].trim().toLowerCase();@@@ -147,5 +150,6 @@ module.exports = {ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,++ BIN_DAYS,normalizeType, safeContentType, cleanName, cleanText, fileName, headerSafeName, contentDisposition, expiryHours, formatBytes,};@@@ -124,27 +124,86 @@ function createService({ s, media, identity, config, log = console }) {return row;}-- /** One of the person's own files, or null. A file is private: nobody else's id resolves here. */++ /** One of the person's own live files, or null. A file is private: nobody else's id resolves here. */const ownFile = async (owner, id) => {if (!rules.isFileId(id)) return null;const row = await store.getFile(s, id);-- return row && row.owner === owner && !row.deleted_at ? row : null;++ return row && row.owner === owner && !row.deleted_at && !row.binned_at ? row : null;++ };++++ /** One of the person's own files in the bin, or null. */++ const ownBinnedFile = async (owner, id) => {++ if (!rules.isFileId(id)) return null;++ const row = await store.getFile(s, id);++ return row && row.owner === owner && !row.deleted_at && row.binned_at ? row : null;};/**-- * Delete a file: its shares go at once, the row is soft-deleted here and the object is deleted in Media.-- * The bytes are Media's to keep or purge (Media's own retention), so this does not claim they are gone.++ * Delete a file: its share links go at once, and the row goes to the bin for 30 days. The bytes stay in Media++ * until the due purge takes it, so a restore costs nothing. A binned row still counts against storage.*/-- async function deleteFile(owner, id, { by = null } = {}) {++ async function deleteFile(owner, id) {const row = await ownFile(owner, id);if (!row) return { error: 'file.not_found' };await store.deleteSharesForFile(s, id);-- await store.softDeleteFile(s, id, nowIso());++ await store.binFile(s, id, nowIso());++ return { file: await store.getFile(s, id) };++ }++++ /** Put a binned file back in the drive. Its share links are gone for good; its folder may be too. */++ async function restoreFile(owner, id) {++ const row = await ownBinnedFile(owner, id);++ if (!row) return { error: 'file.not_found', detail: 'No such file in the bin.' };++ await store.restoreFile(s, id);++ // The folder may have been deleted while the file sat in the bin; put it back at the top rather than nowhere.++ if (row.folder_id && !await store.getFolder(s, row.folder_id)) await store.moveFile(s, id, owner, null);++ return { file: await store.getFile(s, id) };++ }++++ /** What deleteFile did before the bin: the shares go, the row is soft-deleted and Media is asked for the object. */++ async function hardDelete(row) {++ await store.deleteSharesForFile(s, row.id);++ await store.softDeleteFile(s, row.id, nowIso());const removed = await media.remove(row.object_id);if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);return { file: row, media: removed };}++ /** Delete one binned file forever. Only a file already in the bin resolves here. */++ async function purgeFile(owner, id) {++ const row = await ownBinnedFile(owner, id);++ if (!row) return { error: 'file.not_found', detail: 'No such file in the bin.' };++ return hardDelete(row);++ }++++ /** Empty this person's bin, a batch at a time. */++ async function emptyBin(owner, { batch = 200 } = {}) {++ let total = 0;++ for (;;) {++ const rows = await store.listBinned(s, owner, { limit: batch });++ if (!rows.length) return total;++ for (const row of rows) await hardDelete(row);++ total += rows.length;++ if (rows.length < batch) return total;++ }++ }++++ /**++ * The due purge: every file binned more than BIN_DAYS ago is deleted forever, in batches of `batch` (the job asks++ * for 200). Returns how many went. The clock is the store's, so a test can move it.++ */++ async function purgeDue({ batch = 200 } = {}) {++ const cutoff = new Date(s.now() - rules.BIN_DAYS * DAY_MS).toISOString();++ let total = 0;++ for (;;) {++ const rows = await store.dueBinned(s, cutoff, batch);++ if (!rows.length) return total;++ for (const row of rows) await hardDelete(row);++ total += rows.length;++ if (rows.length < batch) return total;++ }++ }++/** Rename one of the person's files. The name follows the same rule as an upload's. */async function renameFile(owner, id, name) {const row = await ownFile(owner, id);@@@ -164,6 +223,27 @@ function createService({ s, media, identity, config, log = console }) {return { file: await store.getFile(s, row.id) };}++ /**++ * The API's PATCH /files/:id: a new name and/or a new folder, in one write. An absent field is left alone; a++ * folder_id of null moves the file to the top.++ */++ async function updateFile(owner, id, { name, folderId } = {}) {++ const row = await ownFile(owner, id);++ if (!row) return { error: 'file.not_found' };++ let clean = row.name;++ if (name !== undefined) {++ clean = rules.cleanName(name);++ if (!clean) return { error: 'file.invalid_name', detail: 'A file needs a name.' };++ }++ let target = row.folder_id || null;++ if (folderId !== undefined) {++ if (folderId && !await ownFolder(owner, folderId)) return { error: 'folder.not_found', detail: 'That folder does not exist.' };++ target = folderId || null;++ }++ await store.updateFile(s, row.id, owner, { name: clean, folderId: target });++ return { file: await store.getFile(s, row.id) };++ }++/** The person's files whose names hold the words, from every folder. */const searchFiles = (owner, words, opts) => {const clean = rules.cleanText(words, 100);@@@ -205,8 +285,8 @@ function createService({ s, media, identity, config, log = console }) {}/**-- * Delete an empty folder. A folder that still holds something is refused rather than emptied: there is no bin-- * yet, and a file deleted here is deleted in Media too.++ * Delete an empty folder. A folder that still holds something is refused rather than emptied: a file in the bin++ * is already out of the folder, so an emptied folder can go and a restore puts the file back at the top.*/async function deleteFolder(owner, id) {const folder = await ownFolder(owner, id);@@@ -318,11 +398,8 @@ function createService({ s, media, identity, config, log = console }) {if (share.file_id) {const file = await store.getFile(s, share.file_id);if (!file) return { error: 'file.not_found' };-- await store.deleteSharesForFile(s, file.id);-- await store.softDeleteFile(s, file.id, nowIso());-- const removed = await media.remove(file.object_id);-- if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${file.object_id}: ${removed.status} ${removed.code}`);-- return { kind: 'file', file, media: removed };++ const out = await hardDelete(file);++ return { kind: 'file', ...out };}const files = await store.listFiles(s, share.owner, { folderId: share.folder_id, limit: 1000 });await store.deleteSharesForFolder(s, share.folder_id);@@@ -337,6 +414,8 @@ function createService({ s, media, identity, config, log = console }) {// ── Reads the pages and the API both need (no SQL outside this module) ──const listFiles = (owner, opts) => store.listFiles(s, owner, opts);++ const listBinned = (owner, opts) => store.listBinned(s, owner, opts);++ const binCount = (owner) => store.countBinned(s, owner);const listFolders = (owner) => store.listFolders(s, owner);const listShares = (owner) => store.listShares(s, owner);const usageDays = (owner) => store.usageFor(s, owner);@@@ -345,8 +424,9 @@ function createService({ s, media, identity, config, log = console }) {return {uploadAllowed, shareAllowed, usage, dayOf,-- recordFile, ownFile, deleteFile, renameFile, moveFile, searchFiles,-- listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,++ recordFile, ownFile, ownBinnedFile, deleteFile, restoreFile, purgeFile, purgeDue, emptyBin, updateFile,++ renameFile, moveFile, searchFiles,++ listFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,ownFolder, createFolder, renameFolder, deleteFolder, folderPath,createShare, ownShare, revokeShare,report, restoreShare, removeShared,@@@ -33,7 +33,7 @@ const getFile = (s, id) => s.db.maybe(`SELECT * FROM ${FILES} WHERE id = $1`, [i/** This person's live files: newest first, a page at a time, optionally one folder (null = the root). */async function listFiles(s, owner, { folderId = null, limit = 100, before = null } = {}) {const values = [owner];-- const parts = ['owner = $1', 'deleted_at IS NULL'];++ const parts = ['owner = $1', 'deleted_at IS NULL', 'binned_at IS NULL'];if (folderId === null) parts.push('folder_id IS NULL');else { values.push(folderId); parts.push(`folder_id = $${values.length}`); }let sql = `SELECT * FROM ${FILES} WHERE ${parts.join(' AND ')}`;@@@ -46,19 +46,38 @@ async function listFiles(s, owner, { folderId = null, limit = 100, before = null/** Every size the person still holds — what the quota bar is drawn from. */const storedBytes = (s, owner) => s.db.value(`SELECT coalesce(sum(size), 0) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);--const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);++const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL`, [owner]).then(Number);/** Soft delete: the row stays (it is what the Media object is written from), the person stops seeing it. */const softDeleteFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, [id, at]);++/** The bin: the row and its bytes stay, hidden from the drive, until it is restored or purged. */++const binFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET binned_at = $2 WHERE id = $1 AND deleted_at IS NULL AND binned_at IS NULL`, [id, at]);++const restoreFile = (s, id) => s.db.exec(`UPDATE ${FILES} SET binned_at = NULL WHERE id = $1 AND deleted_at IS NULL AND binned_at IS NOT NULL`, [id]);++++/** This person's binned files, the newest deletion first. */++const listBinned = (s, owner, { limit = 200 } = {}) => s.db.many(++ `SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NOT NULL ORDER BY binned_at DESC, id DESC LIMIT $2`,++ [owner, limit]);++const countBinned = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NOT NULL`, [owner]).then(Number);++++/** What the due purge takes, oldest first: binned before `beforeIso`, a batch at a time. */++const dueBinned = (s, beforeIso, limit = 200) => s.db.many(++ `SELECT * FROM ${FILES} WHERE deleted_at IS NULL AND binned_at IS NOT NULL AND binned_at < $1 ORDER BY binned_at ASC, id ASC LIMIT $2`,++ [beforeIso, limit]);++/** Rename or move one of the person's live files (the owner is in the WHERE: nobody else's file changes). */--const renameFile = (s, id, owner, name) => s.db.exec(`UPDATE ${FILES} SET name = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL`, [id, owner, name]);--const moveFile = (s, id, owner, folderId) => s.db.exec(`UPDATE ${FILES} SET folder_id = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL`, [id, owner, folderId]);++const renameFile = (s, id, owner, name) => s.db.exec(`UPDATE ${FILES} SET name = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL AND binned_at IS NULL`, [id, owner, name]);++const moveFile = (s, id, owner, folderId) => s.db.exec(`UPDATE ${FILES} SET folder_id = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL AND binned_at IS NULL`, [id, owner, folderId]);++/** One update for the API's PATCH { name?, folder_id? }. */++const updateFile = (s, id, owner, { name, folderId }) => s.db.exec(++ `UPDATE ${FILES} SET name = $3, folder_id = $4 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL AND binned_at IS NULL`,++ [id, owner, name, folderId]);/** This person's live files whose name holds the words, in every folder, newest first. */function searchFiles(s, owner, words, { limit = 100 } = {}) {const like = `%${String(words).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;-- return s.db.many(`SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND name ILIKE $2 ORDER BY id DESC LIMIT $3`, [owner, like, limit]);++ return s.db.many(`SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL AND name ILIKE $2 ORDER BY id DESC LIMIT $3`, [owner, like, limit]);}// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────@@@ -73,9 +92,9 @@ const getFolder = (s, id) => s.db.maybe(`SELECT * FROM ${FOLDERS} WHERE id = $1`const listFolders = (s, owner) => s.db.many(`SELECT * FROM ${FOLDERS} WHERE owner = $1 ORDER BY lower(name) ASC, id ASC`, [owner]);const countFolders = (s, owner) => s.db.value(`SELECT count(*) FROM ${FOLDERS} WHERE owner = $1`, [owner]).then(Number);const renameFolder = (s, id, owner, name) => s.db.exec(`UPDATE ${FOLDERS} SET name = $3 WHERE id = $1 AND owner = $2`, [id, owner, name]);--/** Whether a folder still holds a live file or a folder. */++/** Whether a folder still holds a live file or a folder. A binned file does not: it is out of the drive until it is restored. */const folderInUse = (s, id) => s.db.value(-- `SELECT EXISTS (SELECT 1 FROM ${FILES} WHERE folder_id = $1 AND deleted_at IS NULL) OR EXISTS (SELECT 1 FROM ${FOLDERS} WHERE parent_id = $1)`, [id]);++ `SELECT EXISTS (SELECT 1 FROM ${FILES} WHERE folder_id = $1 AND deleted_at IS NULL AND binned_at IS NULL) OR EXISTS (SELECT 1 FROM ${FOLDERS} WHERE parent_id = $1)`, [id]);const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────@@@ -176,7 +195,8 @@ const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_iconst dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);module.exports = {-- insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile, renameFile, moveFile, searchFiles,++ insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile, binFile, restoreFile, updateFile,++ listBinned, countBinned, dueBinned, renameFile, moveFile, searchFiles,insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,deleteSharesForFile, deleteSharesForFolder,@@@ -37,6 +37,7 @@ const PATHS = {pencil: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16zM13.5 6.5l4 4',move: 'M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.2h7.5A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5zM9 13.5h6.5M13 11l2.5 2.5L13 16',trash: 'M4.5 7h15M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13M10 11v5.5M14 11v5.5',++ restore: 'M4 10a8 8 0 1 1 2.3 6M4 4.5V10h5.5',home: 'M4 11 12 4l8 7v8.5a.5.5 0 0 1-.5.5H15v-6H9v6H4.5a.5.5 0 0 1-.5-.5z',drive: 'M3.5 14.5 7 5h10l3.5 9.5M3.5 14.5h17V19a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1zM16.5 17.25h.01',chevron: 'M9.5 6l6 6-6 6',@@@ -125,7 +126,18 @@ function folderTile(f, { count = null } = {}) {}/** A file card (the grid) that is also a row (the list): the same markup, laid out by the view. */--function fileCard(row, { where = null } = {}) {++function fileCard(row, { where = null, bin = false } = {}) {++ // In the bin the card is not a link to a file page: the file is out of the drive until it is restored.++ if (bin) {++ return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">++${thumb(row, { big: true })}++<span class="dr-file-text"><span class="dr-name">${row.name}</span>++<span class="dr-meta"><span>${rules.formatBytes(row.size)}</span><span>Deleted ${when(row.binned_at)}</span>${where ? html`<span class="dr-where">${icon('folder')}${where}</span>` : ''}</span></span>++<div class="dr-bin-actions">++<form method="post" action="/files/${row.id}/restore"><button class="dr-btn is-quiet" type="submit">${icon('restore')}Restore</button></form>++<form method="post" action="/files/${row.id}/purge" data-confirm="Delete “${row.name}” forever? Its bytes are deleted from OpenVibe.Media and this cannot be undone."><button class="dr-btn is-quiet is-danger" type="submit">${icon('trash')}Delete forever</button></form>++</div></li>`;++ }return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}"><a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}<span class="dr-file-text"><span class="dr-name">${row.name}</span>@@@ -136,7 +148,7 @@ function fileCard(row, { where = null } = {}) {<a href="/files/${row.id}#share">${icon('link')}Share a link</a><a href="/files/${row.id}#move">${icon('move')}Move to…</a><form class="dr-rename" method="post" action="/files/${row.id}/rename"><label>${icon('pencil')}<span class="sr-only">New name for ${row.name}</span><input name="name" type="text" value="${row.name}" maxlength="${rules.NAME_MAX}" required></label><button type="submit">Rename</button></form>--<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? Its share links stop working, and this cannot be undone."><button class="is-danger" type="submit">${icon('trash')}Delete</button></form>++<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? It goes to the bin for ${rules.BIN_DAYS} days, and its share links stop working at once."><button class="is-danger" type="submit">${icon('trash')}Delete</button></form></div></details></li>`;}@@@ -6,12 +6,16 @@* in ./principal.js). A write made with the session cookie must come from openvibe.download itself (same-origin).** GET /ping anyone liveness-- * GET /files?folder_id=&limit=&before= person your drive (one folder, or the root)++ * GET /files?folder_id=&limit=&before=&q= person your drive (one folder, the root, or every folder by name)* POST /files person start an upload: { name, size, content_type, folder_id }* GET /files/:id person one of your files-- * DELETE /files/:id person delete it (and ask Media to delete the object)++ * PATCH /files/:id person { name?, folder_id? (null = the top) }++ * DELETE /files/:id person put it in the bin (or ?forever=1: delete a binned file now)++ * POST /files/:id/restore person take a file back out of the bin* GET /folders person your folders* POST /folders person make one: { name, parent_id }++ * PATCH /folders/:id person rename it: { name }++ * DELETE /folders/:id person delete an empty one (a sync key may not)* POST /shares person share a file or a folder: { file_id|folder_id, hours, usernames }* DELETE /shares/:slug person revoke one* GET /usage person what you hold and what is left@@@ -177,6 +181,7 @@ function createApi(ctx) {const wireFile = (row) => ({id: row.id, name: row.name, size: Number(row.size), content_type: row.content_type,sha256: row.sha256, folder_id: row.folder_id, created_at: row.created_at,++ binned_at: row.binned_at ? new Date(row.binned_at).toISOString() : null,object: { media_object_id: row.object_id },});const wireFolder = (row) => ({ id: row.id, name: row.name, parent_id: row.parent_id, created_at: row.created_at });@@@ -197,18 +202,21 @@ function createApi(ctx) {// ── Files ───────────────────────────────────────────────r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {const owner = personOf(req);++ // `q` searches every folder; folder_id is ignored then. Without it, one folder (or the root) is listed.++ const q = rules.cleanText(req.query.q || '', 100);const folderId = req.query.folder_id ? String(req.query.folder_id) : null;-- if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');++ if (!q && folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');const limit = Math.min(200, Math.max(1, Number.parseInt(req.query.limit, 10) || 100));const before = rules.isFileId(req.query.before) ? String(req.query.before) : null;-- const rows = await drive.listFiles(owner, { folderId, limit, before });-- const folders = await drive.listFolders(owner);++ const rows = q ? await drive.searchFiles(owner, q, { limit }) : await drive.listFiles(owner, { folderId, limit, before });++ const folders = q ? [] : (await drive.listFolders(owner)).filter((f) => (f.parent_id || null) === folderId);return res.json({-- folder_id: folderId,-- folders: folders.filter((f) => (f.parent_id || null) === folderId).map(wireFolder),++ folder_id: q ? null : folderId,++ ...(q ? { q } : {}),++ folders: folders.map(wireFolder),files: rows.map(wireFile),usage: await drive.usage(owner),-- note: 'Your files only. A file is private until you make a share link for it.',++ note: q ? 'Search covers every folder; files in the bin are not listed.' : 'Your files only. A file is private until you make a share link for it.',});});@@@ -244,12 +252,37 @@ function createApi(ctx) {return res.json({ file: wireFile(row), shares: (await drive.listShares(row.owner)).filter((x) => x.file_id === row.id).map(wireShare) });});++ r.patch('/files/:id', person, async (req, res) => {++ const b = req.body || {};++ if (b.name === undefined && b.folder_id === undefined) return problem(req, res, 422, 'file.invalid', 'Nothing to change: send a name, a folder_id, or both.');++ if (b.folder_id !== undefined && b.folder_id !== null && !rules.isFolderId(b.folder_id)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id, or null for the top.');++ const out = await drive.updateFile(personOf(req), String(req.params.id || ''), {++ name: b.name,++ folderId: b.folder_id === undefined ? undefined : (b.folder_id ? String(b.folder_id) : null),++ });++ if (out.error) return problem(req, res, refused(out.error), out.error, out.detail);++ return res.json({ file: wireFile(out.file) });++ });++r.delete('/files/:id', person, async (req, res) => {-- const out = await drive.deleteFile(personOf(req), String(req.params.id || ''));++ const id = String(req.params.id || '');++ const forever = req.query.forever === '1' || req.query.forever === 'true';++ if (forever) {++ const out = await drive.purgeFile(personOf(req), id);++ if (out.error) return problem(req, res, 404, 'file.not_found', out.detail || 'No such file in the bin.');++ return res.status(204).end();++ }++ const out = await drive.deleteFile(personOf(req), id);if (out.error) return problem(req, res, 404, 'file.not_found', 'No such file.');return res.status(204).end();});++ r.post('/files/:id/restore', person, async (req, res) => {++ const out = await drive.restoreFile(personOf(req), String(req.params.id || ''));++ if (out.error) return problem(req, res, 404, 'file.not_found', out.detail || 'No such file in the bin.');++ return res.json({ file: wireFile(out.file) });++ });++// ── Folders ─────────────────────────────────────────────r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {const rows = await drive.listFolders(personOf(req));@@@ -266,6 +299,19 @@ function createApi(ctx) {return res.status(201).json({ folder: wireFolder(out.folder) });});++ r.patch('/folders/:id', person, async (req, res) => {++ const out = await drive.renameFolder(personOf(req), String(req.params.id || ''), (req.body || {}).name);++ if (out.error) return problem(req, res, out.error === 'folder.not_found' ? 404 : out.error === 'folder.exists' ? 409 : 422, out.error, out.detail);++ return res.json({ folder: wireFolder(out.folder) });++ });++++ /** A sync key backs files up: it may rename or move, but deleting a folder is the person's own call. */++ r.delete('/folders/:id', person, notSyncKey, async (req, res) => {++ const out = await drive.deleteFolder(personOf(req), String(req.params.id || ''));++ if (out.error) return problem(req, res, out.error === 'folder.not_found' ? 404 : 409, out.error, out.detail);++ return res.status(204).end();++ });++// ── Shares ──────────────────────────────────────────────/** A share link is made by the person themself (signed in, or their Network token), never by a sync key. */function notSyncKey(req, res, next) {@@@ -304,7 +350,7 @@ function createApi(ctx) {return res.json({usage: await drive.usage(owner),days: (await drive.usageDays(owner)).map((d) => ({ day: d.day, uploads: Number(d.uploads) })),-- note: 'Media\'s own quotas are per tenant (this whole service); these numbers are per person.',++ note: 'Media\'s own quotas are per tenant (this whole service); these numbers are per person. Bytes in the bin still count against storage until they are purged.',});});@@@ -84,7 +84,7 @@ function createDiscoveryRoutes(ctx) {markdown: true,base: site,summary: 'OpenVibe.Download: a private drive on your OpenVibe account, with share links you control and folder backups.',-- details: 'Upload files to your own drive, share a file or folder with named OpenVibe accounts, anyone signed in, or a public download page (links expire and can be revoked), and keep a folder on your computer backed up with the openvibe-download command (a sync key from /sync). Every page is server-rendered and readable without JavaScript.',++ details: 'Upload files to your own drive, share a file or folder with named OpenVibe accounts, anyone signed in, or a public download page (links expire and can be revoked), and keep a folder on your computer backed up with the openvibe-download command (a sync key from /sync). A deleted file waits in the bin for 30 days before it is deleted for good. Every page is server-rendered and readable without JavaScript.',sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Download', url: abs('/'), note: 'Your files, shared on your terms.' },@@@ -92,6 +92,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'What shipped on OpenVibe.MediaHub', url: abs('/updates') },] },{ title: 'Machine-readable', links: [++ { title: 'API (OpenAPI 3.1 JSON)', url: abs('/openapi.json') },{ title: 'Release metadata (JSON)', url: abs('/release.json') },] },],@@@ -68,11 +68,11 @@ route('get', '/ping', {// ── Files ──────────────────────────────────────────────────────────────────────────────────────route('get', '/files', {-- operationId: 'listFiles', summary: 'One folder of your drive (or the root)', tags: ['files'], security: PERSON,-- description: 'A person\'s own files: no app, agent or service token can hold them. `before` is the last file id of the previous page.',-- parameters: [query('folder_id', str(), 'A folder id; absent for the root'), query('limit', integer({ minimum: 1, maximum: 200, default: 100 })), query('before', str(), 'The last file id of the previous page')],-- responses: { 200: json('The folder, its subfolders, files and usage', { type: 'object', properties: {-- folder_id: nullable('string'), folders: arrayOf(ref('Folder')), files: arrayOf(ref('File')), usage: ref('Usage'), note: str(),++ operationId: 'listFiles', summary: 'One folder of your drive (or the root), or a search of every folder', tags: ['files'], security: PERSON,++ description: 'A person\'s own files: no app, agent or service token can hold them. `before` is the last file id of the previous page. With `q`, every folder is searched and `folder_id` is ignored; files in the bin are never listed.',++ parameters: [query('folder_id', str(), 'A folder id; absent for the root'), query('limit', integer({ minimum: 1, maximum: 200, default: 100 })), query('before', str(), 'The last file id of the previous page'), query('q', str(), 'Words a file\'s name holds, searched in every folder')],++ responses: { 200: json('The folder, its subfolders, files and usage (the search answer has q and no folders)', { type: 'object', properties: {++ folder_id: nullable('string'), q: str(), folders: arrayOf(ref('Folder')), files: arrayOf(ref('File')), usage: ref('Usage'), note: str(),} }), ...errors() },});route('post', '/files', {@@@ -88,10 +88,24 @@ route('get', '/files/{id}', {parameters: [path('id', str())],responses: { 200: json('The file and its shares', { type: 'object', properties: { file: ref('File'), shares: arrayOf(ref('Share')) } }), ...errors() },});++route('patch', '/files/{id}', {++ operationId: 'updateFile', summary: 'Rename or move one of your files', tags: ['files'], security: PERSON,++ description: 'Send a name, a folder_id, or both. `folder_id: null` moves the file to the top of the drive. A sync key may rename and move its owner\'s files.',++ parameters: [path('id', str())],++ requestBody: body({ type: 'object', properties: { name: str(), folder_id: nullable('string') } }),++ responses: { 200: json('The file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 422, 429]) },++});route('delete', '/files/{id}', {-- operationId: 'deleteFile', summary: 'Delete one of your files (and ask Media to delete the object)', tags: ['files'], security: PERSON,++ operationId: 'deleteFile', summary: 'Put one of your files in the bin, or delete a binned file forever', tags: ['files'], security: PERSON,++ description: 'Without `forever`, the file goes to the bin for 30 days: its share links stop at once and the bytes stay in Media, still counted against storage. With `forever=1`, a file already in the bin is deleted for good and Media is asked to delete the object.',++ parameters: [path('id', str()), query('forever', { type: 'boolean' }, 'Delete a binned file now instead of binning it (1 or true)')],++ responses: { 204: { description: 'Binned (or deleted forever)' }, ...errors([401, 403, 404, 429]) },++});++route('post', '/files/{id}/restore', {++ operationId: 'restoreFile', summary: 'Take a file back out of the bin', tags: ['files'], security: PERSON,++ description: 'Restores a binned file. Its share links stay revoked; if its folder is gone it comes back at the top of the drive.',parameters: [path('id', str())],-- responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 429]) },++ responses: { 200: json('The restored file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 429]) },});// ── Folders ────────────────────────────────────────────────────────────────────────────────────@@@ -104,6 +118,18 @@ route('post', '/folders', {requestBody: body({ type: 'object', required: ['name'], properties: { name: str(), parent_id: str() } }),responses: { 201: json('The folder', { type: 'object', properties: { folder: ref('Folder') } }), ...errors([401, 403, 409, 422, 429]) },});++route('patch', '/folders/{id}', {++ operationId: 'renameFolder', summary: 'Rename one of your folders', tags: ['folders'], security: PERSON,++ parameters: [path('id', str())],++ requestBody: body({ type: 'object', required: ['name'], properties: { name: str() } }),++ responses: { 200: json('The folder', { type: 'object', properties: { folder: ref('Folder') } }), ...errors([401, 403, 404, 409, 422, 429]) },++});++route('delete', '/folders/{id}', {++ operationId: 'deleteFolder', summary: 'Delete an empty folder', tags: ['folders'], security: PERSON,++ description: 'A folder that still holds a file or a folder answers 409 folder.not_empty: empty it first. A sync key may not delete folders.',++ parameters: [path('id', str())],++ responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 409, 429]) },++});// ── Shares ────────────────────────────────────────────────────────────────────────────────────route('post', '/shares', {@@@ -202,7 +228,7 @@ function openapi({ servers = DEFAULT_SERVERS } = {}) {schemas: {File: { type: 'object', required: ['id', 'name', 'size', 'content_type'], properties: {id: str(), name: str(), size: integer(), content_type: str(), sha256: nullable('string'), folder_id: nullable('string'),-- created_at: str(), object: { type: 'object', properties: { media_object_id: nullable('string') } },++ created_at: str(), binned_at: nullable('string', { format: 'date-time' }), object: { type: 'object', properties: { media_object_id: nullable('string') } },} },Folder: { type: 'object', required: ['id', 'name'], properties: {id: str(), name: str(), parent_id: nullable('string'), created_at: str(),@@@ -298,6 +298,13 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is}const pathBar = (path, last = null) => html`<nav class="dr-path" aria-label="Where you are"><a href="/files">${ui.icon('home')}<span>My files</span></a>${path.map((f, i) => html`${ui.icon('chevron')}${i === path.length - 1 && !last ? html`<span aria-current="page">${f.name}</span>` : html`<a href="${folderHref(f.id)}">${f.name}</a>`}`)}${last ? html`${ui.icon('chevron')}<span aria-current="page">${last}</span>` : ''}</nav>`;const whoOf = (sh) => (sh.public ? ['globe', 'Anyone with the link'] : sh.allowed_subjects ? ['people', 'Only the people you named'] : ['lock', 'Signed-in people with the link']);++ /** The sidebar's places; `current` is 'files', 'bin' or null. The counts are the person's own live numbers. */++ const places = (current, liveShares, binCount) => html`<nav class="dr-places" aria-label="Places">++<a href="/files"${current === 'files' ? raw(' aria-current="page"') : ''}>${ui.icon('home')}<span>My files</span></a>++<a href="/files#shared">${ui.icon('link')}<span>Shared links</span>${liveShares.length ? html`<em>${liveShares.length}</em>` : ''}</a>++<a href="/files/bin"${current === 'bin' ? raw(' aria-current="page"') : ''}>${ui.icon('trash')}<span>Bin</span>${binCount ? html`<em>${binCount}</em>` : ''}</a>++<a href="/sync">${ui.icon('sync')}<span>Backups</span></a>++</nav>`;/** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */const uploadForm = (folder) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>@@@ -341,6 +348,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}}}const usage = await drive.usage(owner);++ const binCount = await drive.binCount(owner);const title = q ? `“${q}”` : folder ? folder.name : 'My files';const empty = !files.length && !folders.length;const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;@@@ -359,11 +367,7 @@ ${uploadForm(folder)}${folderForm(folder)}<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a></div></details>--<nav class="dr-places" aria-label="Places">--<a href="/files"${!folder && !q ? raw(' aria-current="page"') : ''}>${ui.icon('home')}<span>My files</span></a>--<a href="/files#shared">${ui.icon('link')}<span>Shared links</span>${liveShares.length ? html`<em>${liveShares.length}</em>` : ''}</a>--<a href="/sync">${ui.icon('sync')}<span>Backups</span></a>--</nav>++${places(!folder && !q ? 'files' : null, liveShares, binCount)}${ui.meter(usage)}</aside><div class="dr-main" data-main>@@@ -397,6 +401,38 @@ ${showShares ? html`<section class="dr-sec" id="shared" aria-labelledby="h-share});});++ // ── The bin (2026-11-07): deleted files wait here 30 days before their bytes go ──++ r.get('/files/bin', mustSignIn('/files'), async (req, res) => {++ const owner = requesterOf(req);++ const [files, binCount, liveShares, usage, allFolders] = await Promise.all([++ drive.listBinned(owner, { limit: 200 }),++ drive.binCount(owner),++ drive.listShares(owner).then((rows) => rows.filter((sh) => drive.stateOf(sh, s.iso()) === 'live')),++ drive.usage(owner),++ drive.listFolders(owner),++ ]);++ const labels = folderLabels(allFolders);++ return page(req, res, {++ title: 'Bin · Your files',++ scripts: ['js/upload.js'],++ css: ['css/drive.css'],++ body: html`<div class="dr" data-drive data-place="Bin">++<aside class="dr-side" aria-label="Your drive">++${places('bin', liveShares, binCount)}++${ui.meter(usage)}++</aside>++<div class="dr-main" data-main>++<header class="dr-bar"><div class="dr-bar-row">++<h1 class="dr-title">Bin</h1>++${files.length ? html`<div class="dr-tools"><form method="post" action="/files/bin/empty" data-confirm="Empty the bin? Everything in it is deleted forever, and OpenVibe.Media is asked to delete the bytes."><button class="dr-btn is-danger" type="submit">Empty the bin</button></form></div>` : ''}++</div></header>++<p class="dr-bin-note">Deleted files stay here for ${rules.BIN_DAYS} days so you can restore them. Until they are deleted forever they still count against your storage.</p>++${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fileCard(row, { bin: true, where: row.folder_id ? labels.get(row.folder_id) || 'A folder' : 'My files' }))}</ul>` : html`<p class="dr-bin-note">The bin is empty. Files you delete go here first.</p>`}++</div>++</div>`,++ });++ });++r.get('/files/:id', mustSignIn('/files'), async (req, res) => {const owner = requesterOf(req);const row = await drive.ownFile(owner, String(req.params.id || ''));@@@ -458,8 +494,8 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop</dl><section class="fv-card is-danger" id="delete" aria-labelledby="h-delete"><h2 id="h-delete">${ui.icon('trash')}Delete</h2>--<p>Every share link to this file stops working at once, and OpenVibe.Media is asked to delete the bytes. There is no undo.</p>--<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? Its share links stop working, and this cannot be undone."><button class="dr-btn is-danger" type="submit">Delete this file</button></form></section>++<p>Every share link to this file stops working at once. The file goes to the bin for ${rules.BIN_DAYS} days, where you can restore it, and then it is deleted for good.</p>++<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? It goes to the bin for ${rules.BIN_DAYS} days, and its share links stop working at once."><button class="dr-btn is-danger" type="submit">Delete this file</button></form></section></div></div></div>`,@@@ -527,7 +563,28 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-copconst row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));if (!row) return notFoundFile(req, res);await drive.deleteFile(requesterOf(req), row.id);-- return res.redirect(303, '/files');++ return res.redirect(303, folderHref(row.folder_id));++ });++++ // ── The bin's own forms: restore, delete forever, empty ──++ r.post('/files/:id/restore', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const out = await drive.restoreFile(requesterOf(req), String(req.params.id || ''));++ if (out.error) return notFoundFile(req, res);++ return res.redirect(303, '/files/bin');++ });++++ r.post('/files/:id/purge', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const out = await drive.purgeFile(requesterOf(req), String(req.params.id || ''));++ if (out.error) return notFoundFile(req, res);++ return res.redirect(303, '/files/bin');++ });++++ r.post('/files/bin/empty', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ await drive.emptyBin(requesterOf(req));++ return res.redirect(303, '/files/bin');});r.post('/files/:id/share', mustSignIn('/files'), budgeted('media-hub.share.create'), async (req, res) => {@@@ -60,6 +60,9 @@ async function start() {dropSweep.unref();timers.push(dropSweep);}++ // The bin (server/drive/bin.js): files binned more than 30 days ago are purged, hourly, in batches of 200.++ const binPurge = require('./drive/bin').createBinPurge({ drive: ctx.drive, log: ctx.log });++ binPurge.start();// OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.if (ctx.videoAi.enabled) {const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);@@@ -77,7 +80,7 @@ async function start() {ctx.eventsOutbox.start();// Public uploads in OpenVibe.Search (server/video/search-index.js; off without MEDIAHUB_SEARCH_URL).ctx.videoSearch.start();-- createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => ctx.videoSearch.stop(), () => ctx.eventsOutbox.stop(), () => { if (subscriptions) subscriptions.stop(); }] });++ createLifecycle({ server, ctx, timers, extra: [() => binPurge.stop(), () => videoIndexNow.stop(), () => ctx.videoSearch.stop(), () => ctx.eventsOutbox.stop(), () => { if (subscriptions) subscriptions.stop(); }] });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,207 @@++'use strict';++/**++ * The bin: a deleted file waits 30 days, still counted against storage, its share links dead at once; restoring it++ * puts it back in its folder without bringing a link back; deleting it forever (or the due job) soft-deletes the row++ * and asks Media to delete the object, in batches of 200. The store's clock is injected, so the 30 days can pass.++ *++ * Also here: the API's PATCH /files/:id, PATCH/DELETE /folders/:id, GET /files?q=, restore and ?forever=1, and the++ * sync key's limits on them.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++const DAY = 24 * 3_600_000;++++(async () => {++ let clock = Date.now() + 1000;++ const t = await boot({ now: () => clock, env: { MEDIAHUB_QUOTA_BYTES: String(64 * 1024 * 1024) } });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++++ const put = async (as, name, o = {}) => (await upload(t, as, bytes(60, name), { name, contentType: 'text/plain', ...o })).json().file;++ const folder = async (as, name, parentId = null) => (await t.get('/api/v1/folders', { as, json: { name, parent_id: parentId || undefined }, headers: SAME })).json().folder;++ const bin = (as, id) => t.get(`/api/v1/files/${id}`, { as, method: 'DELETE', headers: SAME });++ const binPage = (as = kim) => t.get('/files/bin', { as });++ const patch = (as, path, json, o = {}) => t.get(path, { as, method: 'PATCH', json, headers: SAME, ...o });++++ let binnedFile = null;++++ await check('deleting from the page puts the file in the bin, keeps its bytes and kills its links', async () => {++ const file = await put(kim, 'binned-page.txt');++ binnedFile = file;++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);++ const before = (await t.get('/api/v1/usage', { as: kim })).json().usage;++++ const deleted = await t.get(`/files/${file.id}/delete`, { as: kim, form: {}, headers: SAME });++ assert.strictEqual(deleted.status, 303);++ assert.strictEqual(deleted.headers.get('location'), '/files');++++ const drive = (await t.get('/files', { as: kim })).text;++ assert.ok(!drive.includes('binned-page.txt'), 'it is out of the drive');++ assert.ok(drive.includes('href="/files/bin"'), 'the sidebar has the bin');++ assert.match(drive, /href="\/files\/bin"[^\n]*<em>1<\/em>/, 'with a count');++ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 404, 'its page is gone');++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'its link is dead at once');++++ const after = (await t.get('/api/v1/usage', { as: kim })).json().usage;++ assert.strictEqual(after.files, before.files - 1, 'one fewer live file');++ assert.strictEqual(after.bytes_used, before.bytes_used, 'but the bytes still count against storage');++++ const page = await binPage();++ assert.strictEqual(page.status, 200);++ for (const bit of ['binned-page.txt', `action="/files/${file.id}/restore"`, `action="/files/${file.id}/purge"`, 'Delete forever', 'Empty the bin', '30 days', 'still count against your storage']) {++ assert.ok(page.text.includes(bit), `the bin page has ${bit}`);++ }++ });++++ await check('restore brings the file back to its folder; the share link stays dead', async () => {++ const work = await folder(kim, 'Restore work');++ const file = await put(kim, 'restore-me.txt', { folder: work.id });++ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;++ assert.strictEqual((await bin(kim, file.id)).status, 204);++ assert.ok(!(await t.get(`/files?folder=${work.id}`, { as: kim })).text.includes('restore-me.txt'));++++ const restored = await t.get(`/files/${file.id}/restore`, { as: kim, form: {}, headers: SAME });++ assert.strictEqual(restored.status, 303);++ assert.strictEqual(restored.headers.get('location'), '/files/bin');++ assert.ok((await t.get(`/files?folder=${work.id}`, { as: kim })).text.includes('restore-me.txt'), 'back in its folder');++ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'the link is not restored');++ assert.strictEqual((await t.get(`/files/${file.id}/restore`, { as: kim, form: {}, headers: SAME })).status, 404, 'a second restore is a 404');++++ // The folder can go while a file in it sits in the bin; restoring then puts the file at the top.++ const gone = await folder(kim, 'Gone folder');++ const orphan = await put(kim, 'orphan.txt', { folder: gone.id });++ assert.strictEqual((await bin(kim, orphan.id)).status, 204);++ assert.strictEqual((await t.get(`/api/v1/folders/${gone.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'the empty-looking folder goes');++ assert.strictEqual((await t.get(`/api/v1/files/${orphan.id}/restore`, { as: kim, json: {}, headers: SAME })).status, 200);++ const row = (await t.get(`/api/v1/files/${orphan.id}`, { as: kim })).json().file;++ assert.strictEqual(row.folder_id, null, 'it comes back at the top, not in a folder that is gone');++ });++++ await check('the API can rename and move a file, and search every folder', async () => {++ const a = await folder(kim, 'API A');++ const b = await folder(kim, 'API B');++ const file = await put(kim, 'api-name.txt', { folder: a.id });++++ const renamed = await patch(kim, `/api/v1/files/${file.id}`, { name: 'api-new.txt', folder_id: b.id });++ assert.strictEqual(renamed.status, 200, renamed.text);++ assert.strictEqual(renamed.json().file.name, 'api-new.txt');++ assert.strictEqual(renamed.json().file.folder_id, b.id, 'renamed and moved in one call');++ assert.ok((await t.get(`/files?folder=${b.id}`, { as: kim })).text.includes('api-new.txt'));++++ const top = await patch(kim, `/api/v1/files/${file.id}`, { folder_id: null });++ assert.strictEqual(top.status, 200);++ assert.strictEqual(top.json().file.folder_id, null, 'null means the top');++ assert.strictEqual((await patch(kim, `/api/v1/files/${file.id}`, { name: ' ' })).status, 422);++ assert.strictEqual((await patch(kim, `/api/v1/files/${file.id}`, { folder_id: 'nope' })).json().code, 'folder.invalid');++ assert.strictEqual((await patch(kim, `/api/v1/files/${file.id}`, {})).json().code, 'file.invalid', 'nothing to change');++ assert.strictEqual((await patch(kim, '/api/v1/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', { name: 'x' })).status, 404);++ assert.strictEqual((await patch(sam, `/api/v1/files/${file.id}`, { name: 'not-mine.txt' })).status, 404, 'somebody else\'s file is 404');++++ // Search covers every folder; a binned file is not in it; a typed % is a character, not a wildcard.++ const found = await t.get('/api/v1/files?q=api-new', { as: kim });++ assert.strictEqual(found.status, 200);++ assert.strictEqual(found.json().folder_id, null);++ assert.strictEqual(found.json().q, 'api-new');++ assert.deepStrictEqual(found.json().files.map((f) => f.name), ['api-new.txt']);++ assert.deepStrictEqual(found.json().folders, []);++ assert.deepStrictEqual((await t.get('/api/v1/files?q=api-new', { as: sam })).json().files, [], 'nobody else\'s search');++ await bin(kim, file.id);++ assert.deepStrictEqual((await t.get('/api/v1/files?q=api-new', { as: kim })).json().files, [], 'binned files do not match');++++ const percent = await put(kim, '100% done.txt');++ assert.strictEqual((await t.get('/api/v1/files?q=100%25', { as: kim })).json().files.length, 1, 'a % is itself');++ assert.ok((await t.get('/api/v1/files?q=done', { as: kim })).json().files.some((f) => f.id === percent.id));++ });++++ await check('the API renames and deletes folders; a sync key may rename but not delete one', async () => {++ const made = await folder(kim, 'Folder One');++ const renamed = await patch(kim, `/api/v1/folders/${made.id}`, { name: 'Folder Two' });++ assert.strictEqual(renamed.status, 200, renamed.text);++ assert.strictEqual(renamed.json().folder.name, 'Folder Two');++ const other = await folder(kim, 'Folder Three');++ assert.strictEqual((await patch(kim, `/api/v1/folders/${made.id}`, { name: 'Folder Three' })).status, 409, 'no two of a name in one place');++ assert.strictEqual((await patch(kim, '/api/v1/folders/fld_01JABCDEFGHJKMNPQRSTVWXYZ0', { name: 'x' })).status, 404);++++ const file = await put(kim, 'holds-the-folder.txt', { folder: made.id });++ assert.strictEqual((await t.get(`/api/v1/folders/${made.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 409, 'a folder with a file stays');++ assert.strictEqual((await t.get(`/api/v1/folders/${made.id}`, { as: kim, method: 'DELETE', headers: SAME })).json().code, 'folder.not_empty');++ assert.strictEqual((await t.get(`/api/v1/folders/${other.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'an empty one goes');++ assert.strictEqual((await t.get(`/api/v1/folders/${other.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 404);++++ const madeKey = await t.get('/sync/keys', { as: kim, form: { name: 'bin-test' }, headers: SAME });++ const key = (/ovdl_[A-Za-z0-9_-]{43}/.exec(madeKey.text) || [])[0];++ assert.ok(key, 'a sync key is made');++ assert.strictEqual((await patch(null, `/api/v1/files/${file.id}`, { name: 'key-rename.txt' }, { bearer: key })).status, 200, 'a key renames a file');++ assert.strictEqual((await patch(null, `/api/v1/folders/${made.id}`, { name: 'Key Folder' }, { bearer: key })).status, 200, 'and a folder');++ const refused = await t.get(`/api/v1/folders/${made.id}`, { bearer: key, method: 'DELETE', headers: SAME });++ assert.strictEqual(refused.status, 403);++ assert.strictEqual(refused.json().code, 'sync_key.not_allowed', 'but it may not delete one');++ });++++ await check('delete forever asks Media for the bytes; a live file cannot be purged; the API restores', async () => {++ const file = await put(kim, 'forever.txt');++ const live = await t.get(`/api/v1/files/${file.id}?forever=1`, { as: kim, method: 'DELETE', headers: SAME });++ assert.strictEqual(live.status, 404, 'a file that is not in the bin is not purged');++ const restored = await t.get(`/api/v1/files/${file.id}/restore`, { as: kim, json: {}, headers: SAME });++ assert.strictEqual(restored.status, 404, 'and a live file cannot be restored');++++ assert.strictEqual((await bin(kim, file.id)).status, 204);++ const before = t.media.deleted.length;++ const purge = await t.get(`/api/v1/files/${file.id}?forever=1`, { as: kim, method: 'DELETE', headers: SAME });++ assert.strictEqual(purge.status, 204, purge.text);++ assert.strictEqual(t.media.deleted.length, before + 1, 'Media was asked to delete the object');++ assert.strictEqual(t.media.deleted[t.media.deleted.length - 1], file.object.media_object_id);++ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).status, 404);++ assert.ok(!(await binPage()).text.includes('forever.txt'));++++ // Empty the bin deletes everything left in it for good.++ const one = await put(kim, 'empty-one.txt');++ const two = await put(kim, 'empty-two.txt');++ await bin(kim, one.id);++ await bin(kim, two.id);++ const beforeEmpty = t.media.deleted.length;++ const binned = Number(await t.ctx.s.db.value('SELECT count(*) FROM mh_files WHERE owner = $1 AND binned_at IS NOT NULL AND deleted_at IS NULL', [`user:${kim.subject}`]));++ assert.ok(binned >= 2, `the bin holds the empty-one/two and whatever came before (${binned})`);++ const emptied = await t.get('/files/bin/empty', { as: kim, form: {}, headers: SAME });++ assert.strictEqual(emptied.status, 303);++ assert.strictEqual(t.media.deleted.length, beforeEmpty + binned, 'every binned object went');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM mh_files WHERE owner = $1 AND binned_at IS NOT NULL AND deleted_at IS NULL', [`user:${kim.subject}`])), 0);++ assert.ok(!(await binPage()).text.includes('empty-one.txt'));++ assert.ok(!(await binPage()).text.includes('empty-two.txt'));++ });++++ await check('the due job purges only after 30 days, in batches of 200', async () => {++ const file = await put(kim, 'due.txt');++ await bin(kim, file.id);++ const started = clock;++ clock += 29 * DAY;++ assert.strictEqual(await t.ctx.drive.purgeDue({ batch: 200 }), 0, 'not due yet');++ clock += 2 * DAY;++ const purged = await t.ctx.drive.purgeDue({ batch: 200 });++ assert.ok(purged >= 1, `the due file went (${purged})`);++ assert.ok(t.media.deleted.includes(file.object.media_object_id));++ clock = started;++ assert.ok(!(await binPage()).text.includes('due.txt'));++ const row = await t.ctx.s.db.maybe('SELECT deleted_at, binned_at FROM mh_files WHERE id = $1', [file.id]);++ assert.ok(row.deleted_at, 'the row is soft-deleted');++ assert.ok(row.binned_at, 'and still says when it was binned');++++ // 205 due rows is two batches (200 then 5), not a 30-day timer.++ const old = new Date(clock - 40 * DAY).toISOString();++ await t.ctx.s.db.exec(++ `INSERT INTO mh_files (id, owner, object_id, name, size, content_type, sha256, folder_id, created_at, binned_at)++ SELECT 'fil_' || lpad(g::text, 26, '0'), $1, $2, 'old-' || g || '.bin', 1, 'text/plain', NULL, NULL, $3, $4++ FROM generate_series(1, 205) g`,++ [`user:${kim.subject}`, file.object.media_object_id, old, old]);++ const bulk = await t.ctx.drive.purgeDue({ batch: 200 });++ assert.strictEqual(bulk, 205, 'all batches, 200 at a time');++ const left = await t.ctx.s.db.value("SELECT count(*) FROM mh_files WHERE name LIKE 'old-%.bin' AND deleted_at IS NULL");++ assert.strictEqual(Number(left), 0);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -15,7 +15,7 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');const sam = t.network.addUser('sam');await check('a guest gets a sign-in page for every file page, never a file', async () => {-- for (const p of ['/files', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {++ for (const p of ['/files', '/files/bin', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {const r = await t.get(p);assert.strictEqual(r.status, 401, p);assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);@@@ -169,6 +169,25 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');assert.ok(!(await t.get('/api/v1/folders', { as: lee })).json().folders.some((f) => f.id === invoices.id));});++ await check('a deleted file waits in the bin, and the drive links it there with a count', async () => {++ const file = (await upload(t, kim, bytes(70, 'bin-page'), { name: 'bin-page.txt', contentType: 'text/plain' })).json().file;++ const page = await t.get(`/files/${file.id}`, { as: kim });++ assert.ok(page.text.includes('goes to the bin for 30 days'), 'the file page says where a delete goes');++ assert.ok(page.text.includes('share links stop working at once'), 'and what happens to its links');++ const deleted = await t.get(`/files/${file.id}/delete`, { as: kim, form: {}, headers: SAME });++ assert.strictEqual(deleted.status, 303);++ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 404);++ const drive = await t.get('/files', { as: kim });++ assert.match(drive.text, /href="\/files\/bin"[^\n]*<em>1<\/em>/, 'the sidebar counts it');++ const bin = await t.get('/files/bin', { as: kim });++ assert.strictEqual(bin.status, 200);++ assert.ok(bin.text.includes('bin-page.txt'), 'the bin lists it');++ assert.ok(bin.text.includes(`action="/files/${file.id}/restore"`) && bin.text.includes(`action="/files/${file.id}/purge"`), 'with Restore and Delete forever');++ assert.ok(bin.text.includes('action="/files/bin/empty"'), 'and a way to empty it');++ await t.get(`/files/${file.id}/restore`, { as: kim, form: {}, headers: SAME });++ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200, 'restored to the drive');++ });++await check('the update log and the crawl artifacts still work, and a guest can read the rules', async () => {assert.strictEqual((await t.get('/updates')).status, 200);const robots = await t.get('/robots.txt');@@@ -155,15 +155,16 @@ const HOUR = 3_600_000;assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201);});-- await check('deleting a file takes its share links with it', async () => {++ await check('deleting a file takes its share links with it and puts it in the bin', async () => {const ola = t.network.addUser('ola');const f = await put(ola, 'doomed.bin');const slug = (await share(t, ola, { file_id: f.id, hours: 24 })).json().share.slug;assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);++ const before = t.media.deleted.length;assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola, method: 'DELETE', headers: SAME })).status, 204);assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola })).status, 404, 'and the file is gone for its owner too');-- assert.ok(t.media.deleted.length > 0, 'Media was asked to delete the object');++ assert.strictEqual(t.media.deleted.length, before, 'Media keeps the bytes: it is only in the bin');});await t.close();
@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## Unreleased
+- The drive has a bin, and the API has the drive's full controls (2026-11-07). Deleting a file — from its page, its
+ card, or `DELETE /api/v1/files/:id` — puts it in the bin for 30 days instead of deleting it: its share links stop at
+ once, its bytes stay in OpenVibe.Media, and it still counts against storage. `/files/bin` in the sidebar (with a
+ count) lists what is there, each file with Restore and Delete forever, and an Empty the bin button; a file binned more
+ than 30 days ago is purged hourly in batches of 200 (the row is soft-deleted and Media is asked for the object). The
+ API now also takes `PATCH /api/v1/files/:id` (`name`, `folder_id`; null is the top), `PATCH /api/v1/folders/:id`
+ (`name`), `DELETE /api/v1/folders/:id` (409 `folder.not_empty`), `GET /api/v1/files?q=` across every folder,
+ `POST /api/v1/files/:id/restore` and `DELETE /api/v1/files/:id?forever=1`; a sync key may rename and move but may not
+ delete a folder. Migration `0023_files_bin.sql`.
- openvibe.download looks and works like a drive (2026-11-07, the owner: "a competitor to Google Drive … sleek and easy
- openvibe.download looks and works like a drive (2026-11-07, the owner: "a competitor to Google Drive … sleek and easy
to use and intuitive and minimalist while being powerful … full controls"). `/files` is a sidebar (New, My files,
to use and intuitive and minimalist while being powerful … full controls"). `/files` is a sidebar (New, My files,
Shared links, Backups, the storage meter) beside the files: folder tiles, then file cards with the picture or a
Shared links, Backups, the storage meter) beside the files: folder tiles, then file cards with the picture or a
@@ -11,8 +20,8 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -11,8 +20,8 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
rows in the list view. Search covers every folder (`?q=`, and it filters what is on screen as you type); sort by
rows in the list view. Search covers every folder (`?q=`, and it filters what is on screen as you type); sort by
newest, oldest, name or size, remembered. Files dropped anywhere on the page upload into the folder on screen, many
newest, oldest, name or size, remembered. Files dropped anywhere on the page upload into the folder on screen, many
at once, with a tray that shows each one's progress and refreshes the list when they are in. Every file and folder
at once, with a tray that shows each one's progress and refreshes the list when they are in. Every file and folder
- has a menu: Download, Share a link, Move to…, Rename, Delete; a folder deletes only when it is empty (there is no bin
- yet). On a phone, New is a floating button that opens a bottom sheet, and a menu opens as a sheet over the tab bar.
+ has a menu: Download, Share a link, Move to…, Rename, Delete; a folder deletes only when it is empty. On a phone, New
+ is a floating button that opens a bottom sheet, and a menu opens as a sheet over the tab bar.
A file's page shows the file large beside what you can do with it: who can open a link (three choices, drawn as
A file's page shows the file large beside what you can do with it: who can open a link (three choices, drawn as
cards), expiry as chips, Move, Rename, the facts and Delete. Fixed on the way: a folder made inside a folder went to
cards), expiry as chips, Move, Rename, the facts and Delete. Fixed on the way: a folder made inside a folder went to
the top level. Everything still works with JavaScript off.
the top level. Everything still works with JavaScript off.
@@ -137,6 +137,12 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never h
@@ -137,6 +137,12 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never h
resume after a dropped connection.
resume after a dropped connection.
- **Downloads:** MediaHub checks access, then streams from Media with its own headers. Range requests pass through,
- **Downloads:** MediaHub checks access, then streams from Media with its own headers. Range requests pass through,
so downloads resume.
so downloads resume.
+- **The bin:** `DELETE /api/v1/files/:id` (or the page's Delete) sets `binned_at` and revokes the file's share links;
+ the bytes stay in Media and still count against storage for 30 days. `POST /api/v1/files/:id/restore` brings it
+ back; `DELETE /api/v1/files/:id?forever=1` deletes it now. A due job (`server/drive/bin.js`) purges files binned
+ more than 30 days ago, 200 at a time. `PATCH /api/v1/files/:id` renames/moves, `PATCH`/`DELETE /api/v1/folders/:id`
+ rename/delete an empty folder, and `GET /api/v1/files?q=` searches every folder. All of it is documented in
+ `/openapi.json` and migration `0023_files_bin.sql` adds the column and its index.
MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- The bin (2026-11-07): a deleted file is kept for 30 days so its owner can restore it. The row and its bytes stay
+-- where they are; binned_at is when it went to the bin, and a due job soft-deletes the row and asks Media to delete
+-- the object once it is older than that. A binned row still counts against storage until it is purged.
+ALTER TABLE mh_files ADD COLUMN binned_at timestamptz;
+
+-- What the bin page lists and what the due purge scans: this person's binned files, newest first.
+CREATE INDEX mh_files_binned ON mh_files (owner) WHERE binned_at IS NOT NULL AND deleted_at IS NULL;
@@ -272,6 +272,10 @@
@@ -272,6 +272,10 @@
.fv-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }
.fv-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }
.fv-facts code { font-size: 12px; }
.fv-facts code { font-size: 12px; }
+/* The bin: the same card, without the file link, with Restore and Delete forever under it. */
+.dr-bin-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 12px 12px; }
+.dr-bin-note { margin: 0 0 18px; font-size: 13.5px; line-height: 1.5; color: var(--dr-muted); }
+
/* Phones and narrow tablets: the files first and full width, New as a floating button over a bottom sheet. */
/* Phones and narrow tablets: the files first and full width, New as a floating button over a bottom sheet. */
@media (max-width: 759px) {
@media (max-width: 759px) {
.dr { grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 0; }
.dr { grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 0; }
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
module.exports = {
module.exports = {
id: 'download',
id: 'download',
name: 'OpenVibe.Download',
name: 'OpenVibe.Download',
- purpose: 'A private drive on the OpenVibe network: keep your own files in your account, share them with a link that expires, and back up a folder from your computer.',
+ purpose: 'A private drive on the OpenVibe network: keep your own files in your account, rename and move them, delete them to a 30-day bin, share them with a link that expires, and back up a folder from your computer.',
home: 'https://openvibe.download',
home: 'https://openvibe.download',
site: 'openvibe.download',
site: 'openvibe.download',
api: '/api/v1',
api: '/api/v1',
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * The bin's due purge: a file binned more than 30 days ago is deleted forever — its row soft-deleted and
+ * OpenVibe.Media asked to delete the object — in batches of 200.
+ *
+ * The job is due-based, not a 30-day timer: every hour it asks the drive what is due, so a restart never loses or
+ * delays anything past the next tick, and a bin older than 30 days goes on the first run after boot. It is mounted
+ * from server/index.js next to the Pics and drops sweeps, and stopped with the process.
+ */
+const { BIN_DAYS } = require('./rules');
+
+const BATCH = 200;
+const EVERY_MS = 3_600_000;
+
+function createBinPurge({ drive, log = console, everyMs = EVERY_MS }) {
+ let timer = null;
+ let running = null;
+
+ /** One due pass, in batches; answers how many files were purged. Concurrent calls share the run. */
+ function run() {
+ if (running) return running;
+ running = drive.purgeDue({ batch: BATCH })
+ .then((n) => {
+ if (n) log.log(`[MediaHub] bin: purged ${n} file(s) binned more than ${BIN_DAYS} days ago`);
+ return n;
+ })
+ .catch((err) => {
+ log.warn(`[MediaHub] bin purge failed: ${err && err.message}`);
+ return 0;
+ })
+ .finally(() => { running = null; });
+ return running;
+ }
+
+ return {
+ BIN_DAYS, BATCH, run,
+ start() {
+ if (timer) return;
+ run();
+ timer = setInterval(run, everyMs);
+ if (timer.unref) timer.unref();
+ },
+ stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ },
+ };
+}
+
+module.exports = { createBinPurge, BIN_DAYS, BATCH, EVERY_MS };
@@ -48,6 +48,9 @@ const EXPIRY_MIN_HOURS = 1;
@@ -48,6 +48,9 @@ const EXPIRY_MIN_HOURS = 1;
const EXPIRY_MAX_HOURS = 30 * 24;
const EXPIRY_MAX_HOURS = 30 * 24;
const EXPIRY_DEFAULT_HOURS = 7 * 24;
const EXPIRY_DEFAULT_HOURS = 7 * 24;
+// A deleted file waits in the bin this long before the due purge deletes it and asks Media to delete the object.
+const BIN_DAYS = 30;
+
/** The declared content type, normalised, or null when it is not a media type at all. */
/** The declared content type, normalised, or null when it is not a media type at all. */
function normalizeType(value) {
function normalizeType(value) {
const t = String(value == null ? '' : value).split(';')[0].trim().toLowerCase();
const t = String(value == null ? '' : value).split(';')[0].trim().toLowerCase();
@@ -147,5 +150,6 @@ module.exports = {
@@ -147,5 +150,6 @@ module.exports = {
ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,
ID_RE, SLUG_RE, isFileId, isFolderId, isUploadId, isSlug, isUsername,
NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,
NAME_MAX, FOLDER_NAME_MAX, REASONS, REASON_TEXT, SUSPEND_AT_REPORTS, SUSPEND_IMMEDIATELY,
EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,
EXPIRY_MIN_HOURS, EXPIRY_MAX_HOURS, EXPIRY_DEFAULT_HOURS, EXPIRY_CHOICES,
+ BIN_DAYS,
normalizeType, safeContentType, cleanName, cleanText, fileName, headerSafeName, contentDisposition, expiryHours, formatBytes,
normalizeType, safeContentType, cleanName, cleanText, fileName, headerSafeName, contentDisposition, expiryHours, formatBytes,
};
};
@@ -124,27 +124,86 @@ function createService({ s, media, identity, config, log = console }) {
@@ -124,27 +124,86 @@ function createService({ s, media, identity, config, log = console }) {
return row;
return row;
}
}
- /** One of the person's own files, or null. A file is private: nobody else's id resolves here. */
+ /** One of the person's own live files, or null. A file is private: nobody else's id resolves here. */
const ownFile = async (owner, id) => {
const ownFile = async (owner, id) => {
if (!rules.isFileId(id)) return null;
if (!rules.isFileId(id)) return null;
const row = await store.getFile(s, id);
const row = await store.getFile(s, id);
- return row && row.owner === owner && !row.deleted_at ? row : null;
+ return row && row.owner === owner && !row.deleted_at && !row.binned_at ? row : null;
+ };
+
+ /** One of the person's own files in the bin, or null. */
+ const ownBinnedFile = async (owner, id) => {
+ if (!rules.isFileId(id)) return null;
+ const row = await store.getFile(s, id);
+ return row && row.owner === owner && !row.deleted_at && row.binned_at ? row : null;
};
};
/**
/**
- * Delete a file: its shares go at once, the row is soft-deleted here and the object is deleted in Media.
- * The bytes are Media's to keep or purge (Media's own retention), so this does not claim they are gone.
+ * Delete a file: its share links go at once, and the row goes to the bin for 30 days. The bytes stay in Media
+ * until the due purge takes it, so a restore costs nothing. A binned row still counts against storage.
*/
*/
- async function deleteFile(owner, id, { by = null } = {}) {
+ async function deleteFile(owner, id) {
const row = await ownFile(owner, id);
const row = await ownFile(owner, id);
if (!row) return { error: 'file.not_found' };
if (!row) return { error: 'file.not_found' };
await store.deleteSharesForFile(s, id);
await store.deleteSharesForFile(s, id);
- await store.softDeleteFile(s, id, nowIso());
+ await store.binFile(s, id, nowIso());
+ return { file: await store.getFile(s, id) };
+ }
+
+ /** Put a binned file back in the drive. Its share links are gone for good; its folder may be too. */
+ async function restoreFile(owner, id) {
+ const row = await ownBinnedFile(owner, id);
+ if (!row) return { error: 'file.not_found', detail: 'No such file in the bin.' };
+ await store.restoreFile(s, id);
+ // The folder may have been deleted while the file sat in the bin; put it back at the top rather than nowhere.
+ if (row.folder_id && !await store.getFolder(s, row.folder_id)) await store.moveFile(s, id, owner, null);
+ return { file: await store.getFile(s, id) };
+ }
+
+ /** What deleteFile did before the bin: the shares go, the row is soft-deleted and Media is asked for the object. */
+ async function hardDelete(row) {
+ await store.deleteSharesForFile(s, row.id);
+ await store.softDeleteFile(s, row.id, nowIso());
const removed = await media.remove(row.object_id);
const removed = await media.remove(row.object_id);
if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);
if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);
return { file: row, media: removed };
return { file: row, media: removed };
}
}
+ /** Delete one binned file forever. Only a file already in the bin resolves here. */
+ async function purgeFile(owner, id) {
+ const row = await ownBinnedFile(owner, id);
+ if (!row) return { error: 'file.not_found', detail: 'No such file in the bin.' };
+ return hardDelete(row);
+ }
+
+ /** Empty this person's bin, a batch at a time. */
+ async function emptyBin(owner, { batch = 200 } = {}) {
+ let total = 0;
+ for (;;) {
+ const rows = await store.listBinned(s, owner, { limit: batch });
+ if (!rows.length) return total;
+ for (const row of rows) await hardDelete(row);
+ total += rows.length;
+ if (rows.length < batch) return total;
+ }
+ }
+
+ /**
+ * The due purge: every file binned more than BIN_DAYS ago is deleted forever, in batches of `batch` (the job asks
+ * for 200). Returns how many went. The clock is the store's, so a test can move it.
+ */
+ async function purgeDue({ batch = 200 } = {}) {
+ const cutoff = new Date(s.now() - rules.BIN_DAYS * DAY_MS).toISOString();
+ let total = 0;
+ for (;;) {
+ const rows = await store.dueBinned(s, cutoff, batch);
+ if (!rows.length) return total;
+ for (const row of rows) await hardDelete(row);
+ total += rows.length;
+ if (rows.length < batch) return total;
+ }
+ }
+
/** Rename one of the person's files. The name follows the same rule as an upload's. */
/** Rename one of the person's files. The name follows the same rule as an upload's. */
async function renameFile(owner, id, name) {
async function renameFile(owner, id, name) {
const row = await ownFile(owner, id);
const row = await ownFile(owner, id);
@@ -164,6 +223,27 @@ function createService({ s, media, identity, config, log = console }) {
@@ -164,6 +223,27 @@ function createService({ s, media, identity, config, log = console }) {
return { file: await store.getFile(s, row.id) };
return { file: await store.getFile(s, row.id) };
}
}
+ /**
+ * The API's PATCH /files/:id: a new name and/or a new folder, in one write. An absent field is left alone; a
+ * folder_id of null moves the file to the top.
+ */
+ async function updateFile(owner, id, { name, folderId } = {}) {
+ const row = await ownFile(owner, id);
+ if (!row) return { error: 'file.not_found' };
+ let clean = row.name;
+ if (name !== undefined) {
+ clean = rules.cleanName(name);
+ if (!clean) return { error: 'file.invalid_name', detail: 'A file needs a name.' };
+ }
+ let target = row.folder_id || null;
+ if (folderId !== undefined) {
+ if (folderId && !await ownFolder(owner, folderId)) return { error: 'folder.not_found', detail: 'That folder does not exist.' };
+ target = folderId || null;
+ }
+ await store.updateFile(s, row.id, owner, { name: clean, folderId: target });
+ return { file: await store.getFile(s, row.id) };
+ }
+
/** The person's files whose names hold the words, from every folder. */
/** The person's files whose names hold the words, from every folder. */
const searchFiles = (owner, words, opts) => {
const searchFiles = (owner, words, opts) => {
const clean = rules.cleanText(words, 100);
const clean = rules.cleanText(words, 100);
@@ -205,8 +285,8 @@ function createService({ s, media, identity, config, log = console }) {
@@ -205,8 +285,8 @@ function createService({ s, media, identity, config, log = console }) {
}
}
/**
/**
- * Delete an empty folder. A folder that still holds something is refused rather than emptied: there is no bin
- * yet, and a file deleted here is deleted in Media too.
+ * Delete an empty folder. A folder that still holds something is refused rather than emptied: a file in the bin
+ * is already out of the folder, so an emptied folder can go and a restore puts the file back at the top.
*/
*/
async function deleteFolder(owner, id) {
async function deleteFolder(owner, id) {
const folder = await ownFolder(owner, id);
const folder = await ownFolder(owner, id);
@@ -318,11 +398,8 @@ function createService({ s, media, identity, config, log = console }) {
@@ -318,11 +398,8 @@ function createService({ s, media, identity, config, log = console }) {
if (share.file_id) {
if (share.file_id) {
const file = await store.getFile(s, share.file_id);
const file = await store.getFile(s, share.file_id);
if (!file) return { error: 'file.not_found' };
if (!file) return { error: 'file.not_found' };
- await store.deleteSharesForFile(s, file.id);
- await store.softDeleteFile(s, file.id, nowIso());
- const removed = await media.remove(file.object_id);
- if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${file.object_id}: ${removed.status} ${removed.code}`);
- return { kind: 'file', file, media: removed };
+ const out = await hardDelete(file);
+ return { kind: 'file', ...out };
}
}
const files = await store.listFiles(s, share.owner, { folderId: share.folder_id, limit: 1000 });
const files = await store.listFiles(s, share.owner, { folderId: share.folder_id, limit: 1000 });
await store.deleteSharesForFolder(s, share.folder_id);
await store.deleteSharesForFolder(s, share.folder_id);
@@ -337,6 +414,8 @@ function createService({ s, media, identity, config, log = console }) {
@@ -337,6 +414,8 @@ function createService({ s, media, identity, config, log = console }) {
// ── Reads the pages and the API both need (no SQL outside this module) ──
// ── Reads the pages and the API both need (no SQL outside this module) ──
const listFiles = (owner, opts) => store.listFiles(s, owner, opts);
const listFiles = (owner, opts) => store.listFiles(s, owner, opts);
+ const listBinned = (owner, opts) => store.listBinned(s, owner, opts);
+ const binCount = (owner) => store.countBinned(s, owner);
const listFolders = (owner) => store.listFolders(s, owner);
const listFolders = (owner) => store.listFolders(s, owner);
const listShares = (owner) => store.listShares(s, owner);
const listShares = (owner) => store.listShares(s, owner);
const usageDays = (owner) => store.usageFor(s, owner);
const usageDays = (owner) => store.usageFor(s, owner);
@@ -345,8 +424,9 @@ function createService({ s, media, identity, config, log = console }) {
@@ -345,8 +424,9 @@ function createService({ s, media, identity, config, log = console }) {
return {
return {
uploadAllowed, shareAllowed, usage, dayOf,
uploadAllowed, shareAllowed, usage, dayOf,
- recordFile, ownFile, deleteFile, renameFile, moveFile, searchFiles,
- listFiles, listFolders, listShares, usageDays, shareBySlug, reportsFor,
+ recordFile, ownFile, ownBinnedFile, deleteFile, restoreFile, purgeFile, purgeDue, emptyBin, updateFile,
+ renameFile, moveFile, searchFiles,
+ listFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,
ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
createShare, ownShare, revokeShare,
createShare, ownShare, revokeShare,
report, restoreShare, removeShared,
report, restoreShare, removeShared,
@@ -33,7 +33,7 @@ const getFile = (s, id) => s.db.maybe(`SELECT * FROM ${FILES} WHERE id = $1`, [i
@@ -33,7 +33,7 @@ const getFile = (s, id) => s.db.maybe(`SELECT * FROM ${FILES} WHERE id = $1`, [i
/** This person's live files: newest first, a page at a time, optionally one folder (null = the root). */
/** This person's live files: newest first, a page at a time, optionally one folder (null = the root). */
async function listFiles(s, owner, { folderId = null, limit = 100, before = null } = {}) {
async function listFiles(s, owner, { folderId = null, limit = 100, before = null } = {}) {
const values = [owner];
const values = [owner];
- const parts = ['owner = $1', 'deleted_at IS NULL'];
+ const parts = ['owner = $1', 'deleted_at IS NULL', 'binned_at IS NULL'];
if (folderId === null) parts.push('folder_id IS NULL');
if (folderId === null) parts.push('folder_id IS NULL');
else { values.push(folderId); parts.push(`folder_id = $${values.length}`); }
else { values.push(folderId); parts.push(`folder_id = $${values.length}`); }
let sql = `SELECT * FROM ${FILES} WHERE ${parts.join(' AND ')}`;
let sql = `SELECT * FROM ${FILES} WHERE ${parts.join(' AND ')}`;
@@ -46,19 +46,38 @@ async function listFiles(s, owner, { folderId = null, limit = 100, before = null
@@ -46,19 +46,38 @@ async function listFiles(s, owner, { folderId = null, limit = 100, before = null
/** Every size the person still holds — what the quota bar is drawn from. */
/** Every size the person still holds — what the quota bar is drawn from. */
const storedBytes = (s, owner) => s.db.value(`SELECT coalesce(sum(size), 0) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);
const storedBytes = (s, owner) => s.db.value(`SELECT coalesce(sum(size), 0) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);
-const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL`, [owner]).then(Number);
+const liveFileCount = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL`, [owner]).then(Number);
/** Soft delete: the row stays (it is what the Media object is written from), the person stops seeing it. */
/** Soft delete: the row stays (it is what the Media object is written from), the person stops seeing it. */
const softDeleteFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, [id, at]);
const softDeleteFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, [id, at]);
+/** The bin: the row and its bytes stay, hidden from the drive, until it is restored or purged. */
+const binFile = (s, id, at) => s.db.exec(`UPDATE ${FILES} SET binned_at = $2 WHERE id = $1 AND deleted_at IS NULL AND binned_at IS NULL`, [id, at]);
+const restoreFile = (s, id) => s.db.exec(`UPDATE ${FILES} SET binned_at = NULL WHERE id = $1 AND deleted_at IS NULL AND binned_at IS NOT NULL`, [id]);
+
+/** This person's binned files, the newest deletion first. */
+const listBinned = (s, owner, { limit = 200 } = {}) => s.db.many(
+ `SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NOT NULL ORDER BY binned_at DESC, id DESC LIMIT $2`,
+ [owner, limit]);
+const countBinned = (s, owner) => s.db.value(`SELECT count(*) FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NOT NULL`, [owner]).then(Number);
+
+/** What the due purge takes, oldest first: binned before `beforeIso`, a batch at a time. */
+const dueBinned = (s, beforeIso, limit = 200) => s.db.many(
+ `SELECT * FROM ${FILES} WHERE deleted_at IS NULL AND binned_at IS NOT NULL AND binned_at < $1 ORDER BY binned_at ASC, id ASC LIMIT $2`,
+ [beforeIso, limit]);
+
/** Rename or move one of the person's live files (the owner is in the WHERE: nobody else's file changes). */
/** Rename or move one of the person's live files (the owner is in the WHERE: nobody else's file changes). */
-const renameFile = (s, id, owner, name) => s.db.exec(`UPDATE ${FILES} SET name = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL`, [id, owner, name]);
-const moveFile = (s, id, owner, folderId) => s.db.exec(`UPDATE ${FILES} SET folder_id = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL`, [id, owner, folderId]);
+const renameFile = (s, id, owner, name) => s.db.exec(`UPDATE ${FILES} SET name = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL AND binned_at IS NULL`, [id, owner, name]);
+const moveFile = (s, id, owner, folderId) => s.db.exec(`UPDATE ${FILES} SET folder_id = $3 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL AND binned_at IS NULL`, [id, owner, folderId]);
+/** One update for the API's PATCH { name?, folder_id? }. */
+const updateFile = (s, id, owner, { name, folderId }) => s.db.exec(
+ `UPDATE ${FILES} SET name = $3, folder_id = $4 WHERE id = $1 AND owner = $2 AND deleted_at IS NULL AND binned_at IS NULL`,
+ [id, owner, name, folderId]);
/** This person's live files whose name holds the words, in every folder, newest first. */
/** This person's live files whose name holds the words, in every folder, newest first. */
function searchFiles(s, owner, words, { limit = 100 } = {}) {
function searchFiles(s, owner, words, { limit = 100 } = {}) {
const like = `%${String(words).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
const like = `%${String(words).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
- return s.db.many(`SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND name ILIKE $2 ORDER BY id DESC LIMIT $3`, [owner, like, limit]);
+ return s.db.many(`SELECT * FROM ${FILES} WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL AND name ILIKE $2 ORDER BY id DESC LIMIT $3`, [owner, like, limit]);
}
}
// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Folders ─────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -73,9 +92,9 @@ const getFolder = (s, id) => s.db.maybe(`SELECT * FROM ${FOLDERS} WHERE id = $1`
@@ -73,9 +92,9 @@ const getFolder = (s, id) => s.db.maybe(`SELECT * FROM ${FOLDERS} WHERE id = $1`
const listFolders = (s, owner) => s.db.many(`SELECT * FROM ${FOLDERS} WHERE owner = $1 ORDER BY lower(name) ASC, id ASC`, [owner]);
const listFolders = (s, owner) => s.db.many(`SELECT * FROM ${FOLDERS} WHERE owner = $1 ORDER BY lower(name) ASC, id ASC`, [owner]);
const countFolders = (s, owner) => s.db.value(`SELECT count(*) FROM ${FOLDERS} WHERE owner = $1`, [owner]).then(Number);
const countFolders = (s, owner) => s.db.value(`SELECT count(*) FROM ${FOLDERS} WHERE owner = $1`, [owner]).then(Number);
const renameFolder = (s, id, owner, name) => s.db.exec(`UPDATE ${FOLDERS} SET name = $3 WHERE id = $1 AND owner = $2`, [id, owner, name]);
const renameFolder = (s, id, owner, name) => s.db.exec(`UPDATE ${FOLDERS} SET name = $3 WHERE id = $1 AND owner = $2`, [id, owner, name]);
-/** Whether a folder still holds a live file or a folder. */
+/** Whether a folder still holds a live file or a folder. A binned file does not: it is out of the drive until it is restored. */
const folderInUse = (s, id) => s.db.value(
const folderInUse = (s, id) => s.db.value(
- `SELECT EXISTS (SELECT 1 FROM ${FILES} WHERE folder_id = $1 AND deleted_at IS NULL) OR EXISTS (SELECT 1 FROM ${FOLDERS} WHERE parent_id = $1)`, [id]);
+ `SELECT EXISTS (SELECT 1 FROM ${FILES} WHERE folder_id = $1 AND deleted_at IS NULL AND binned_at IS NULL) OR EXISTS (SELECT 1 FROM ${FOLDERS} WHERE parent_id = $1)`, [id]);
const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);
const deleteFolder = (s, id, owner) => s.db.exec(`DELETE FROM ${FOLDERS} WHERE id = $1 AND owner = $2`, [id, owner]);
// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Shares ──────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -176,7 +195,8 @@ const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_i
@@ -176,7 +195,8 @@ const setUploadFile = (s, id, fileId) => s.db.exec(`UPDATE ${UPLOADS} SET file_i
const dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);
const dropUpload = (s, id) => s.db.exec(`DELETE FROM ${UPLOADS} WHERE id = $1`, [id]);
module.exports = {
module.exports = {
- insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile, renameFile, moveFile, searchFiles,
+ insertFile, getFile, listFiles, storedBytes, liveFileCount, softDeleteFile, binFile, restoreFile, updateFile,
+ listBinned, countBinned, dueBinned, renameFile, moveFile, searchFiles,
insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,
insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
deleteSharesForFile, deleteSharesForFolder,
deleteSharesForFile, deleteSharesForFolder,
@@ -37,6 +37,7 @@ const PATHS = {
@@ -37,6 +37,7 @@ const PATHS = {
pencil: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16zM13.5 6.5l4 4',
pencil: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16zM13.5 6.5l4 4',
move: 'M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.2h7.5A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5zM9 13.5h6.5M13 11l2.5 2.5L13 16',
move: 'M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.2h7.5A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5zM9 13.5h6.5M13 11l2.5 2.5L13 16',
trash: 'M4.5 7h15M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13M10 11v5.5M14 11v5.5',
trash: 'M4.5 7h15M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13M10 11v5.5M14 11v5.5',
+ restore: 'M4 10a8 8 0 1 1 2.3 6M4 4.5V10h5.5',
home: 'M4 11 12 4l8 7v8.5a.5.5 0 0 1-.5.5H15v-6H9v6H4.5a.5.5 0 0 1-.5-.5z',
home: 'M4 11 12 4l8 7v8.5a.5.5 0 0 1-.5.5H15v-6H9v6H4.5a.5.5 0 0 1-.5-.5z',
drive: 'M3.5 14.5 7 5h10l3.5 9.5M3.5 14.5h17V19a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1zM16.5 17.25h.01',
drive: 'M3.5 14.5 7 5h10l3.5 9.5M3.5 14.5h17V19a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1zM16.5 17.25h.01',
chevron: 'M9.5 6l6 6-6 6',
chevron: 'M9.5 6l6 6-6 6',
@@ -125,7 +126,18 @@ function folderTile(f, { count = null } = {}) {
@@ -125,7 +126,18 @@ function folderTile(f, { count = null } = {}) {
}
}
/** A file card (the grid) that is also a row (the list): the same markup, laid out by the view. */
/** A file card (the grid) that is also a row (the list): the same markup, laid out by the view. */
-function fileCard(row, { where = null } = {}) {
+function fileCard(row, { where = null, bin = false } = {}) {
+ // In the bin the card is not a link to a file page: the file is out of the drive until it is restored.
+ if (bin) {
+ return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
+${thumb(row, { big: true })}
+<span class="dr-file-text"><span class="dr-name">${row.name}</span>
+<span class="dr-meta"><span>${rules.formatBytes(row.size)}</span><span>Deleted ${when(row.binned_at)}</span>${where ? html`<span class="dr-where">${icon('folder')}${where}</span>` : ''}</span></span>
+<div class="dr-bin-actions">
+<form method="post" action="/files/${row.id}/restore"><button class="dr-btn is-quiet" type="submit">${icon('restore')}Restore</button></form>
+<form method="post" action="/files/${row.id}/purge" data-confirm="Delete “${row.name}” forever? Its bytes are deleted from OpenVibe.Media and this cannot be undone."><button class="dr-btn is-quiet is-danger" type="submit">${icon('trash')}Delete forever</button></form>
+</div></li>`;
+ }
return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
<a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}
<a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}
<span class="dr-file-text"><span class="dr-name">${row.name}</span>
<span class="dr-file-text"><span class="dr-name">${row.name}</span>
@@ -136,7 +148,7 @@ function fileCard(row, { where = null } = {}) {
@@ -136,7 +148,7 @@ function fileCard(row, { where = null } = {}) {
<a href="/files/${row.id}#share">${icon('link')}Share a link</a>
<a href="/files/${row.id}#share">${icon('link')}Share a link</a>
<a href="/files/${row.id}#move">${icon('move')}Move to…</a>
<a href="/files/${row.id}#move">${icon('move')}Move to…</a>
<form class="dr-rename" method="post" action="/files/${row.id}/rename"><label>${icon('pencil')}<span class="sr-only">New name for ${row.name}</span><input name="name" type="text" value="${row.name}" maxlength="${rules.NAME_MAX}" required></label><button type="submit">Rename</button></form>
<form class="dr-rename" method="post" action="/files/${row.id}/rename"><label>${icon('pencil')}<span class="sr-only">New name for ${row.name}</span><input name="name" type="text" value="${row.name}" maxlength="${rules.NAME_MAX}" required></label><button type="submit">Rename</button></form>
-<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? Its share links stop working, and this cannot be undone."><button class="is-danger" type="submit">${icon('trash')}Delete</button></form>
+<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? It goes to the bin for ${rules.BIN_DAYS} days, and its share links stop working at once."><button class="is-danger" type="submit">${icon('trash')}Delete</button></form>
</div></details></li>`;
</div></details></li>`;
}
}
@@ -6,12 +6,16 @@
@@ -6,12 +6,16 @@
* in ./principal.js). A write made with the session cookie must come from openvibe.download itself (same-origin).
* in ./principal.js). A write made with the session cookie must come from openvibe.download itself (same-origin).
*
*
* GET /ping anyone liveness
* GET /ping anyone liveness
- * GET /files?folder_id=&limit=&before= person your drive (one folder, or the root)
+ * GET /files?folder_id=&limit=&before=&q= person your drive (one folder, the root, or every folder by name)
* POST /files person start an upload: { name, size, content_type, folder_id }
* POST /files person start an upload: { name, size, content_type, folder_id }
* GET /files/:id person one of your files
* GET /files/:id person one of your files
- * DELETE /files/:id person delete it (and ask Media to delete the object)
+ * PATCH /files/:id person { name?, folder_id? (null = the top) }
+ * DELETE /files/:id person put it in the bin (or ?forever=1: delete a binned file now)
+ * POST /files/:id/restore person take a file back out of the bin
* GET /folders person your folders
* GET /folders person your folders
* POST /folders person make one: { name, parent_id }
* POST /folders person make one: { name, parent_id }
+ * PATCH /folders/:id person rename it: { name }
+ * DELETE /folders/:id person delete an empty one (a sync key may not)
* POST /shares person share a file or a folder: { file_id|folder_id, hours, usernames }
* POST /shares person share a file or a folder: { file_id|folder_id, hours, usernames }
* DELETE /shares/:slug person revoke one
* DELETE /shares/:slug person revoke one
* GET /usage person what you hold and what is left
* GET /usage person what you hold and what is left
@@ -177,6 +181,7 @@ function createApi(ctx) {
@@ -177,6 +181,7 @@ function createApi(ctx) {
const wireFile = (row) => ({
const wireFile = (row) => ({
id: row.id, name: row.name, size: Number(row.size), content_type: row.content_type,
id: row.id, name: row.name, size: Number(row.size), content_type: row.content_type,
sha256: row.sha256, folder_id: row.folder_id, created_at: row.created_at,
sha256: row.sha256, folder_id: row.folder_id, created_at: row.created_at,
+ binned_at: row.binned_at ? new Date(row.binned_at).toISOString() : null,
object: { media_object_id: row.object_id },
object: { media_object_id: row.object_id },
});
});
const wireFolder = (row) => ({ id: row.id, name: row.name, parent_id: row.parent_id, created_at: row.created_at });
const wireFolder = (row) => ({ id: row.id, name: row.name, parent_id: row.parent_id, created_at: row.created_at });
@@ -197,18 +202,21 @@ function createApi(ctx) {
@@ -197,18 +202,21 @@ function createApi(ctx) {
// ── Files ───────────────────────────────────────────────
// ── Files ───────────────────────────────────────────────
r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {
r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {
const owner = personOf(req);
const owner = personOf(req);
+ // `q` searches every folder; folder_id is ignored then. Without it, one folder (or the root) is listed.
+ const q = rules.cleanText(req.query.q || '', 100);
const folderId = req.query.folder_id ? String(req.query.folder_id) : null;
const folderId = req.query.folder_id ? String(req.query.folder_id) : null;
- if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
+ if (!q && folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
const limit = Math.min(200, Math.max(1, Number.parseInt(req.query.limit, 10) || 100));
const limit = Math.min(200, Math.max(1, Number.parseInt(req.query.limit, 10) || 100));
const before = rules.isFileId(req.query.before) ? String(req.query.before) : null;
const before = rules.isFileId(req.query.before) ? String(req.query.before) : null;
- const rows = await drive.listFiles(owner, { folderId, limit, before });
- const folders = await drive.listFolders(owner);
+ const rows = q ? await drive.searchFiles(owner, q, { limit }) : await drive.listFiles(owner, { folderId, limit, before });
+ const folders = q ? [] : (await drive.listFolders(owner)).filter((f) => (f.parent_id || null) === folderId);
return res.json({
return res.json({
- folder_id: folderId,
- folders: folders.filter((f) => (f.parent_id || null) === folderId).map(wireFolder),
+ folder_id: q ? null : folderId,
+ ...(q ? { q } : {}),
+ folders: folders.map(wireFolder),
files: rows.map(wireFile),
files: rows.map(wireFile),
usage: await drive.usage(owner),
usage: await drive.usage(owner),
- note: 'Your files only. A file is private until you make a share link for it.',
+ note: q ? 'Search covers every folder; files in the bin are not listed.' : 'Your files only. A file is private until you make a share link for it.',
});
});
});
});
@@ -244,12 +252,37 @@ function createApi(ctx) {
@@ -244,12 +252,37 @@ function createApi(ctx) {
return res.json({ file: wireFile(row), shares: (await drive.listShares(row.owner)).filter((x) => x.file_id === row.id).map(wireShare) });
return res.json({ file: wireFile(row), shares: (await drive.listShares(row.owner)).filter((x) => x.file_id === row.id).map(wireShare) });
});
});
+ r.patch('/files/:id', person, async (req, res) => {
+ const b = req.body || {};
+ if (b.name === undefined && b.folder_id === undefined) return problem(req, res, 422, 'file.invalid', 'Nothing to change: send a name, a folder_id, or both.');
+ if (b.folder_id !== undefined && b.folder_id !== null && !rules.isFolderId(b.folder_id)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id, or null for the top.');
+ const out = await drive.updateFile(personOf(req), String(req.params.id || ''), {
+ name: b.name,
+ folderId: b.folder_id === undefined ? undefined : (b.folder_id ? String(b.folder_id) : null),
+ });
+ if (out.error) return problem(req, res, refused(out.error), out.error, out.detail);
+ return res.json({ file: wireFile(out.file) });
+ });
+
r.delete('/files/:id', person, async (req, res) => {
r.delete('/files/:id', person, async (req, res) => {
- const out = await drive.deleteFile(personOf(req), String(req.params.id || ''));
+ const id = String(req.params.id || '');
+ const forever = req.query.forever === '1' || req.query.forever === 'true';
+ if (forever) {
+ const out = await drive.purgeFile(personOf(req), id);
+ if (out.error) return problem(req, res, 404, 'file.not_found', out.detail || 'No such file in the bin.');
+ return res.status(204).end();
+ }
+ const out = await drive.deleteFile(personOf(req), id);
if (out.error) return problem(req, res, 404, 'file.not_found', 'No such file.');
if (out.error) return problem(req, res, 404, 'file.not_found', 'No such file.');
return res.status(204).end();
return res.status(204).end();
});
});
+ r.post('/files/:id/restore', person, async (req, res) => {
+ const out = await drive.restoreFile(personOf(req), String(req.params.id || ''));
+ if (out.error) return problem(req, res, 404, 'file.not_found', out.detail || 'No such file in the bin.');
+ return res.json({ file: wireFile(out.file) });
+ });
+
// ── Folders ─────────────────────────────────────────────
// ── Folders ─────────────────────────────────────────────
r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {
r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {
const rows = await drive.listFolders(personOf(req));
const rows = await drive.listFolders(personOf(req));
@@ -266,6 +299,19 @@ function createApi(ctx) {
@@ -266,6 +299,19 @@ function createApi(ctx) {
return res.status(201).json({ folder: wireFolder(out.folder) });
return res.status(201).json({ folder: wireFolder(out.folder) });
});
});
+ r.patch('/folders/:id', person, async (req, res) => {
+ const out = await drive.renameFolder(personOf(req), String(req.params.id || ''), (req.body || {}).name);
+ if (out.error) return problem(req, res, out.error === 'folder.not_found' ? 404 : out.error === 'folder.exists' ? 409 : 422, out.error, out.detail);
+ return res.json({ folder: wireFolder(out.folder) });
+ });
+
+ /** A sync key backs files up: it may rename or move, but deleting a folder is the person's own call. */
+ r.delete('/folders/:id', person, notSyncKey, async (req, res) => {
+ const out = await drive.deleteFolder(personOf(req), String(req.params.id || ''));
+ if (out.error) return problem(req, res, out.error === 'folder.not_found' ? 404 : 409, out.error, out.detail);
+ return res.status(204).end();
+ });
+
// ── Shares ──────────────────────────────────────────────
// ── Shares ──────────────────────────────────────────────
/** A share link is made by the person themself (signed in, or their Network token), never by a sync key. */
/** A share link is made by the person themself (signed in, or their Network token), never by a sync key. */
function notSyncKey(req, res, next) {
function notSyncKey(req, res, next) {
@@ -304,7 +350,7 @@ function createApi(ctx) {
@@ -304,7 +350,7 @@ function createApi(ctx) {
return res.json({
return res.json({
usage: await drive.usage(owner),
usage: await drive.usage(owner),
days: (await drive.usageDays(owner)).map((d) => ({ day: d.day, uploads: Number(d.uploads) })),
days: (await drive.usageDays(owner)).map((d) => ({ day: d.day, uploads: Number(d.uploads) })),
- note: 'Media\'s own quotas are per tenant (this whole service); these numbers are per person.',
+ note: 'Media\'s own quotas are per tenant (this whole service); these numbers are per person. Bytes in the bin still count against storage until they are purged.',
});
});
});
});
@@ -84,7 +84,7 @@ function createDiscoveryRoutes(ctx) {
@@ -84,7 +84,7 @@ function createDiscoveryRoutes(ctx) {
markdown: true,
markdown: true,
base: site,
base: site,
summary: 'OpenVibe.Download: a private drive on your OpenVibe account, with share links you control and folder backups.',
summary: 'OpenVibe.Download: a private drive on your OpenVibe account, with share links you control and folder backups.',
- details: 'Upload files to your own drive, share a file or folder with named OpenVibe accounts, anyone signed in, or a public download page (links expire and can be revoked), and keep a folder on your computer backed up with the openvibe-download command (a sync key from /sync). Every page is server-rendered and readable without JavaScript.',
+ details: 'Upload files to your own drive, share a file or folder with named OpenVibe accounts, anyone signed in, or a public download page (links expire and can be revoked), and keep a folder on your computer backed up with the openvibe-download command (a sync key from /sync). A deleted file waits in the bin for 30 days before it is deleted for good. Every page is server-rendered and readable without JavaScript.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Download', url: abs('/'), note: 'Your files, shared on your terms.' },
{ title: 'OpenVibe.Download', url: abs('/'), note: 'Your files, shared on your terms.' },
@@ -92,6 +92,7 @@ function createDiscoveryRoutes(ctx) {
@@ -92,6 +92,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'What shipped on OpenVibe.MediaHub', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.MediaHub', url: abs('/updates') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
+ { title: 'API (OpenAPI 3.1 JSON)', url: abs('/openapi.json') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
] },
] },
],
],
@@ -68,11 +68,11 @@ route('get', '/ping', {
@@ -68,11 +68,11 @@ route('get', '/ping', {
// ── Files ──────────────────────────────────────────────────────────────────────────────────────
// ── Files ──────────────────────────────────────────────────────────────────────────────────────
route('get', '/files', {
route('get', '/files', {
- operationId: 'listFiles', summary: 'One folder of your drive (or the root)', tags: ['files'], security: PERSON,
- description: 'A person\'s own files: no app, agent or service token can hold them. `before` is the last file id of the previous page.',
- parameters: [query('folder_id', str(), 'A folder id; absent for the root'), query('limit', integer({ minimum: 1, maximum: 200, default: 100 })), query('before', str(), 'The last file id of the previous page')],
- responses: { 200: json('The folder, its subfolders, files and usage', { type: 'object', properties: {
- folder_id: nullable('string'), folders: arrayOf(ref('Folder')), files: arrayOf(ref('File')), usage: ref('Usage'), note: str(),
+ operationId: 'listFiles', summary: 'One folder of your drive (or the root), or a search of every folder', tags: ['files'], security: PERSON,
+ description: 'A person\'s own files: no app, agent or service token can hold them. `before` is the last file id of the previous page. With `q`, every folder is searched and `folder_id` is ignored; files in the bin are never listed.',
+ parameters: [query('folder_id', str(), 'A folder id; absent for the root'), query('limit', integer({ minimum: 1, maximum: 200, default: 100 })), query('before', str(), 'The last file id of the previous page'), query('q', str(), 'Words a file\'s name holds, searched in every folder')],
+ responses: { 200: json('The folder, its subfolders, files and usage (the search answer has q and no folders)', { type: 'object', properties: {
+ folder_id: nullable('string'), q: str(), folders: arrayOf(ref('Folder')), files: arrayOf(ref('File')), usage: ref('Usage'), note: str(),
} }), ...errors() },
} }), ...errors() },
});
});
route('post', '/files', {
route('post', '/files', {
@@ -88,10 +88,24 @@ route('get', '/files/{id}', {
@@ -88,10 +88,24 @@ route('get', '/files/{id}', {
parameters: [path('id', str())],
parameters: [path('id', str())],
responses: { 200: json('The file and its shares', { type: 'object', properties: { file: ref('File'), shares: arrayOf(ref('Share')) } }), ...errors() },
responses: { 200: json('The file and its shares', { type: 'object', properties: { file: ref('File'), shares: arrayOf(ref('Share')) } }), ...errors() },
});
});
+route('patch', '/files/{id}', {
+ operationId: 'updateFile', summary: 'Rename or move one of your files', tags: ['files'], security: PERSON,
+ description: 'Send a name, a folder_id, or both. `folder_id: null` moves the file to the top of the drive. A sync key may rename and move its owner\'s files.',
+ parameters: [path('id', str())],
+ requestBody: body({ type: 'object', properties: { name: str(), folder_id: nullable('string') } }),
+ responses: { 200: json('The file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 422, 429]) },
+});
route('delete', '/files/{id}', {
route('delete', '/files/{id}', {
- operationId: 'deleteFile', summary: 'Delete one of your files (and ask Media to delete the object)', tags: ['files'], security: PERSON,
+ operationId: 'deleteFile', summary: 'Put one of your files in the bin, or delete a binned file forever', tags: ['files'], security: PERSON,
+ description: 'Without `forever`, the file goes to the bin for 30 days: its share links stop at once and the bytes stay in Media, still counted against storage. With `forever=1`, a file already in the bin is deleted for good and Media is asked to delete the object.',
+ parameters: [path('id', str()), query('forever', { type: 'boolean' }, 'Delete a binned file now instead of binning it (1 or true)')],
+ responses: { 204: { description: 'Binned (or deleted forever)' }, ...errors([401, 403, 404, 429]) },
+});
+route('post', '/files/{id}/restore', {
+ operationId: 'restoreFile', summary: 'Take a file back out of the bin', tags: ['files'], security: PERSON,
+ description: 'Restores a binned file. Its share links stay revoked; if its folder is gone it comes back at the top of the drive.',
parameters: [path('id', str())],
parameters: [path('id', str())],
- responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 429]) },
+ responses: { 200: json('The restored file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 429]) },
});
});
// ── Folders ────────────────────────────────────────────────────────────────────────────────────
// ── Folders ────────────────────────────────────────────────────────────────────────────────────
@@ -104,6 +118,18 @@ route('post', '/folders', {
@@ -104,6 +118,18 @@ route('post', '/folders', {
requestBody: body({ type: 'object', required: ['name'], properties: { name: str(), parent_id: str() } }),
requestBody: body({ type: 'object', required: ['name'], properties: { name: str(), parent_id: str() } }),
responses: { 201: json('The folder', { type: 'object', properties: { folder: ref('Folder') } }), ...errors([401, 403, 409, 422, 429]) },
responses: { 201: json('The folder', { type: 'object', properties: { folder: ref('Folder') } }), ...errors([401, 403, 409, 422, 429]) },
});
});
+route('patch', '/folders/{id}', {
+ operationId: 'renameFolder', summary: 'Rename one of your folders', tags: ['folders'], security: PERSON,
+ parameters: [path('id', str())],
+ requestBody: body({ type: 'object', required: ['name'], properties: { name: str() } }),
+ responses: { 200: json('The folder', { type: 'object', properties: { folder: ref('Folder') } }), ...errors([401, 403, 404, 409, 422, 429]) },
+});
+route('delete', '/folders/{id}', {
+ operationId: 'deleteFolder', summary: 'Delete an empty folder', tags: ['folders'], security: PERSON,
+ description: 'A folder that still holds a file or a folder answers 409 folder.not_empty: empty it first. A sync key may not delete folders.',
+ parameters: [path('id', str())],
+ responses: { 204: { description: 'Deleted' }, ...errors([401, 403, 404, 409, 429]) },
+});
// ── Shares ────────────────────────────────────────────────────────────────────────────────────
// ── Shares ────────────────────────────────────────────────────────────────────────────────────
route('post', '/shares', {
route('post', '/shares', {
@@ -202,7 +228,7 @@ function openapi({ servers = DEFAULT_SERVERS } = {}) {
@@ -202,7 +228,7 @@ function openapi({ servers = DEFAULT_SERVERS } = {}) {
schemas: {
schemas: {
File: { type: 'object', required: ['id', 'name', 'size', 'content_type'], properties: {
File: { type: 'object', required: ['id', 'name', 'size', 'content_type'], properties: {
id: str(), name: str(), size: integer(), content_type: str(), sha256: nullable('string'), folder_id: nullable('string'),
id: str(), name: str(), size: integer(), content_type: str(), sha256: nullable('string'), folder_id: nullable('string'),
- created_at: str(), object: { type: 'object', properties: { media_object_id: nullable('string') } },
+ created_at: str(), binned_at: nullable('string', { format: 'date-time' }), object: { type: 'object', properties: { media_object_id: nullable('string') } },
} },
} },
Folder: { type: 'object', required: ['id', 'name'], properties: {
Folder: { type: 'object', required: ['id', 'name'], properties: {
id: str(), name: str(), parent_id: nullable('string'), created_at: str(),
id: str(), name: str(), parent_id: nullable('string'), created_at: str(),
@@ -298,6 +298,13 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is
@@ -298,6 +298,13 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is
}
}
const pathBar = (path, last = null) => html`<nav class="dr-path" aria-label="Where you are"><a href="/files">${ui.icon('home')}<span>My files</span></a>${path.map((f, i) => html`${ui.icon('chevron')}${i === path.length - 1 && !last ? html`<span aria-current="page">${f.name}</span>` : html`<a href="${folderHref(f.id)}">${f.name}</a>`}`)}${last ? html`${ui.icon('chevron')}<span aria-current="page">${last}</span>` : ''}</nav>`;
const pathBar = (path, last = null) => html`<nav class="dr-path" aria-label="Where you are"><a href="/files">${ui.icon('home')}<span>My files</span></a>${path.map((f, i) => html`${ui.icon('chevron')}${i === path.length - 1 && !last ? html`<span aria-current="page">${f.name}</span>` : html`<a href="${folderHref(f.id)}">${f.name}</a>`}`)}${last ? html`${ui.icon('chevron')}<span aria-current="page">${last}</span>` : ''}</nav>`;
const whoOf = (sh) => (sh.public ? ['globe', 'Anyone with the link'] : sh.allowed_subjects ? ['people', 'Only the people you named'] : ['lock', 'Signed-in people with the link']);
const whoOf = (sh) => (sh.public ? ['globe', 'Anyone with the link'] : sh.allowed_subjects ? ['people', 'Only the people you named'] : ['lock', 'Signed-in people with the link']);
+ /** The sidebar's places; `current` is 'files', 'bin' or null. The counts are the person's own live numbers. */
+ const places = (current, liveShares, binCount) => html`<nav class="dr-places" aria-label="Places">
+<a href="/files"${current === 'files' ? raw(' aria-current="page"') : ''}>${ui.icon('home')}<span>My files</span></a>
+<a href="/files#shared">${ui.icon('link')}<span>Shared links</span>${liveShares.length ? html`<em>${liveShares.length}</em>` : ''}</a>
+<a href="/files/bin"${current === 'bin' ? raw(' aria-current="page"') : ''}>${ui.icon('trash')}<span>Bin</span>${binCount ? html`<em>${binCount}</em>` : ''}</a>
+<a href="/sync">${ui.icon('sync')}<span>Backups</span></a>
+</nav>`;
/** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */
/** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */
const uploadForm = (folder) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
const uploadForm = (folder) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
@@ -341,6 +348,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
@@ -341,6 +348,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
}
}
}
}
const usage = await drive.usage(owner);
const usage = await drive.usage(owner);
+ const binCount = await drive.binCount(owner);
const title = q ? `“${q}”` : folder ? folder.name : 'My files';
const title = q ? `“${q}”` : folder ? folder.name : 'My files';
const empty = !files.length && !folders.length;
const empty = !files.length && !folders.length;
const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;
const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;
@@ -359,11 +367,7 @@ ${uploadForm(folder)}
@@ -359,11 +367,7 @@ ${uploadForm(folder)}
${folderForm(folder)}
${folderForm(folder)}
<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a>
<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a>
</div></details>
</div></details>
-<nav class="dr-places" aria-label="Places">
-<a href="/files"${!folder && !q ? raw(' aria-current="page"') : ''}>${ui.icon('home')}<span>My files</span></a>
-<a href="/files#shared">${ui.icon('link')}<span>Shared links</span>${liveShares.length ? html`<em>${liveShares.length}</em>` : ''}</a>
-<a href="/sync">${ui.icon('sync')}<span>Backups</span></a>
-</nav>
+${places(!folder && !q ? 'files' : null, liveShares, binCount)}
${ui.meter(usage)}
${ui.meter(usage)}
</aside>
</aside>
<div class="dr-main" data-main>
<div class="dr-main" data-main>
@@ -397,6 +401,38 @@ ${showShares ? html`<section class="dr-sec" id="shared" aria-labelledby="h-share
@@ -397,6 +401,38 @@ ${showShares ? html`<section class="dr-sec" id="shared" aria-labelledby="h-share
});
});
});
});
+ // ── The bin (2026-11-07): deleted files wait here 30 days before their bytes go ──
+ r.get('/files/bin', mustSignIn('/files'), async (req, res) => {
+ const owner = requesterOf(req);
+ const [files, binCount, liveShares, usage, allFolders] = await Promise.all([
+ drive.listBinned(owner, { limit: 200 }),
+ drive.binCount(owner),
+ drive.listShares(owner).then((rows) => rows.filter((sh) => drive.stateOf(sh, s.iso()) === 'live')),
+ drive.usage(owner),
+ drive.listFolders(owner),
+ ]);
+ const labels = folderLabels(allFolders);
+ return page(req, res, {
+ title: 'Bin · Your files',
+ scripts: ['js/upload.js'],
+ css: ['css/drive.css'],
+ body: html`<div class="dr" data-drive data-place="Bin">
+<aside class="dr-side" aria-label="Your drive">
+${places('bin', liveShares, binCount)}
+${ui.meter(usage)}
+</aside>
+<div class="dr-main" data-main>
+<header class="dr-bar"><div class="dr-bar-row">
+<h1 class="dr-title">Bin</h1>
+${files.length ? html`<div class="dr-tools"><form method="post" action="/files/bin/empty" data-confirm="Empty the bin? Everything in it is deleted forever, and OpenVibe.Media is asked to delete the bytes."><button class="dr-btn is-danger" type="submit">Empty the bin</button></form></div>` : ''}
+</div></header>
+<p class="dr-bin-note">Deleted files stay here for ${rules.BIN_DAYS} days so you can restore them. Until they are deleted forever they still count against your storage.</p>
+${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fileCard(row, { bin: true, where: row.folder_id ? labels.get(row.folder_id) || 'A folder' : 'My files' }))}</ul>` : html`<p class="dr-bin-note">The bin is empty. Files you delete go here first.</p>`}
+</div>
+</div>`,
+ });
+ });
+
r.get('/files/:id', mustSignIn('/files'), async (req, res) => {
r.get('/files/:id', mustSignIn('/files'), async (req, res) => {
const owner = requesterOf(req);
const owner = requesterOf(req);
const row = await drive.ownFile(owner, String(req.params.id || ''));
const row = await drive.ownFile(owner, String(req.params.id || ''));
@@ -458,8 +494,8 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
@@ -458,8 +494,8 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
</dl>
</dl>
<section class="fv-card is-danger" id="delete" aria-labelledby="h-delete"><h2 id="h-delete">${ui.icon('trash')}Delete</h2>
<section class="fv-card is-danger" id="delete" aria-labelledby="h-delete"><h2 id="h-delete">${ui.icon('trash')}Delete</h2>
-<p>Every share link to this file stops working at once, and OpenVibe.Media is asked to delete the bytes. There is no undo.</p>
-<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? Its share links stop working, and this cannot be undone."><button class="dr-btn is-danger" type="submit">Delete this file</button></form></section>
+<p>Every share link to this file stops working at once. The file goes to the bin for ${rules.BIN_DAYS} days, where you can restore it, and then it is deleted for good.</p>
+<form method="post" action="/files/${row.id}/delete" data-confirm="Delete “${row.name}”? It goes to the bin for ${rules.BIN_DAYS} days, and its share links stop working at once."><button class="dr-btn is-danger" type="submit">Delete this file</button></form></section>
</div>
</div>
</div>
</div>
</div>`,
</div>`,
@@ -527,7 +563,28 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
@@ -527,7 +563,28 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
await drive.deleteFile(requesterOf(req), row.id);
await drive.deleteFile(requesterOf(req), row.id);
- return res.redirect(303, '/files');
+ return res.redirect(303, folderHref(row.folder_id));
+ });
+
+ // ── The bin's own forms: restore, delete forever, empty ──
+ r.post('/files/:id/restore', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const out = await drive.restoreFile(requesterOf(req), String(req.params.id || ''));
+ if (out.error) return notFoundFile(req, res);
+ return res.redirect(303, '/files/bin');
+ });
+
+ r.post('/files/:id/purge', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const out = await drive.purgeFile(requesterOf(req), String(req.params.id || ''));
+ if (out.error) return notFoundFile(req, res);
+ return res.redirect(303, '/files/bin');
+ });
+
+ r.post('/files/bin/empty', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ await drive.emptyBin(requesterOf(req));
+ return res.redirect(303, '/files/bin');
});
});
r.post('/files/:id/share', mustSignIn('/files'), budgeted('media-hub.share.create'), async (req, res) => {
r.post('/files/:id/share', mustSignIn('/files'), budgeted('media-hub.share.create'), async (req, res) => {
@@ -60,6 +60,9 @@ async function start() {
@@ -60,6 +60,9 @@ async function start() {
dropSweep.unref();
dropSweep.unref();
timers.push(dropSweep);
timers.push(dropSweep);
}
}
+ // The bin (server/drive/bin.js): files binned more than 30 days ago are purged, hourly, in batches of 200.
+ const binPurge = require('./drive/bin').createBinPurge({ drive: ctx.drive, log: ctx.log });
+ binPurge.start();
// OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.
// OpenVibe.Video's AI edit (server/video/ai-edit.js): captions, chapters and a summary for finished uploads.
if (ctx.videoAi.enabled) {
if (ctx.videoAi.enabled) {
const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);
const aiTimer = setInterval(() => { ctx.videoAi.sweep().catch((err) => console.warn('[OpenVibe.Video] AI sweep failed:', err && err.message)); }, 3 * 60_000);
@@ -77,7 +80,7 @@ async function start() {
@@ -77,7 +80,7 @@ async function start() {
ctx.eventsOutbox.start();
ctx.eventsOutbox.start();
// Public uploads in OpenVibe.Search (server/video/search-index.js; off without MEDIAHUB_SEARCH_URL).
// Public uploads in OpenVibe.Search (server/video/search-index.js; off without MEDIAHUB_SEARCH_URL).
ctx.videoSearch.start();
ctx.videoSearch.start();
- createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => ctx.videoSearch.stop(), () => ctx.eventsOutbox.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
+ createLifecycle({ server, ctx, timers, extra: [() => binPurge.stop(), () => videoIndexNow.stop(), () => ctx.videoSearch.stop(), () => ctx.eventsOutbox.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+'use strict';
+/**
+ * The bin: a deleted file waits 30 days, still counted against storage, its share links dead at once; restoring it
+ * puts it back in its folder without bringing a link back; deleting it forever (or the due job) soft-deletes the row
+ * and asks Media to delete the object, in batches of 200. The store's clock is injected, so the 30 days can pass.
+ *
+ * Also here: the API's PATCH /files/:id, PATCH/DELETE /folders/:id, GET /files?q=, restore and ?forever=1, and the
+ * sync key's limits on them.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+const DAY = 24 * 3_600_000;
+
+(async () => {
+ let clock = Date.now() + 1000;
+ const t = await boot({ now: () => clock, env: { MEDIAHUB_QUOTA_BYTES: String(64 * 1024 * 1024) } });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+
+ const put = async (as, name, o = {}) => (await upload(t, as, bytes(60, name), { name, contentType: 'text/plain', ...o })).json().file;
+ const folder = async (as, name, parentId = null) => (await t.get('/api/v1/folders', { as, json: { name, parent_id: parentId || undefined }, headers: SAME })).json().folder;
+ const bin = (as, id) => t.get(`/api/v1/files/${id}`, { as, method: 'DELETE', headers: SAME });
+ const binPage = (as = kim) => t.get('/files/bin', { as });
+ const patch = (as, path, json, o = {}) => t.get(path, { as, method: 'PATCH', json, headers: SAME, ...o });
+
+ let binnedFile = null;
+
+ await check('deleting from the page puts the file in the bin, keeps its bytes and kills its links', async () => {
+ const file = await put(kim, 'binned-page.txt');
+ binnedFile = file;
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);
+ const before = (await t.get('/api/v1/usage', { as: kim })).json().usage;
+
+ const deleted = await t.get(`/files/${file.id}/delete`, { as: kim, form: {}, headers: SAME });
+ assert.strictEqual(deleted.status, 303);
+ assert.strictEqual(deleted.headers.get('location'), '/files');
+
+ const drive = (await t.get('/files', { as: kim })).text;
+ assert.ok(!drive.includes('binned-page.txt'), 'it is out of the drive');
+ assert.ok(drive.includes('href="/files/bin"'), 'the sidebar has the bin');
+ assert.match(drive, /href="\/files\/bin"[^\n]*<em>1<\/em>/, 'with a count');
+ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 404, 'its page is gone');
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'its link is dead at once');
+
+ const after = (await t.get('/api/v1/usage', { as: kim })).json().usage;
+ assert.strictEqual(after.files, before.files - 1, 'one fewer live file');
+ assert.strictEqual(after.bytes_used, before.bytes_used, 'but the bytes still count against storage');
+
+ const page = await binPage();
+ assert.strictEqual(page.status, 200);
+ for (const bit of ['binned-page.txt', `action="/files/${file.id}/restore"`, `action="/files/${file.id}/purge"`, 'Delete forever', 'Empty the bin', '30 days', 'still count against your storage']) {
+ assert.ok(page.text.includes(bit), `the bin page has ${bit}`);
+ }
+ });
+
+ await check('restore brings the file back to its folder; the share link stays dead', async () => {
+ const work = await folder(kim, 'Restore work');
+ const file = await put(kim, 'restore-me.txt', { folder: work.id });
+ const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
+ assert.strictEqual((await bin(kim, file.id)).status, 204);
+ assert.ok(!(await t.get(`/files?folder=${work.id}`, { as: kim })).text.includes('restore-me.txt'));
+
+ const restored = await t.get(`/files/${file.id}/restore`, { as: kim, form: {}, headers: SAME });
+ assert.strictEqual(restored.status, 303);
+ assert.strictEqual(restored.headers.get('location'), '/files/bin');
+ assert.ok((await t.get(`/files?folder=${work.id}`, { as: kim })).text.includes('restore-me.txt'), 'back in its folder');
+ assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404, 'the link is not restored');
+ assert.strictEqual((await t.get(`/files/${file.id}/restore`, { as: kim, form: {}, headers: SAME })).status, 404, 'a second restore is a 404');
+
+ // The folder can go while a file in it sits in the bin; restoring then puts the file at the top.
+ const gone = await folder(kim, 'Gone folder');
+ const orphan = await put(kim, 'orphan.txt', { folder: gone.id });
+ assert.strictEqual((await bin(kim, orphan.id)).status, 204);
+ assert.strictEqual((await t.get(`/api/v1/folders/${gone.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'the empty-looking folder goes');
+ assert.strictEqual((await t.get(`/api/v1/files/${orphan.id}/restore`, { as: kim, json: {}, headers: SAME })).status, 200);
+ const row = (await t.get(`/api/v1/files/${orphan.id}`, { as: kim })).json().file;
+ assert.strictEqual(row.folder_id, null, 'it comes back at the top, not in a folder that is gone');
+ });
+
+ await check('the API can rename and move a file, and search every folder', async () => {
+ const a = await folder(kim, 'API A');
+ const b = await folder(kim, 'API B');
+ const file = await put(kim, 'api-name.txt', { folder: a.id });
+
+ const renamed = await patch(kim, `/api/v1/files/${file.id}`, { name: 'api-new.txt', folder_id: b.id });
+ assert.strictEqual(renamed.status, 200, renamed.text);
+ assert.strictEqual(renamed.json().file.name, 'api-new.txt');
+ assert.strictEqual(renamed.json().file.folder_id, b.id, 'renamed and moved in one call');
+ assert.ok((await t.get(`/files?folder=${b.id}`, { as: kim })).text.includes('api-new.txt'));
+
+ const top = await patch(kim, `/api/v1/files/${file.id}`, { folder_id: null });
+ assert.strictEqual(top.status, 200);
+ assert.strictEqual(top.json().file.folder_id, null, 'null means the top');
+ assert.strictEqual((await patch(kim, `/api/v1/files/${file.id}`, { name: ' ' })).status, 422);
+ assert.strictEqual((await patch(kim, `/api/v1/files/${file.id}`, { folder_id: 'nope' })).json().code, 'folder.invalid');
+ assert.strictEqual((await patch(kim, `/api/v1/files/${file.id}`, {})).json().code, 'file.invalid', 'nothing to change');
+ assert.strictEqual((await patch(kim, '/api/v1/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', { name: 'x' })).status, 404);
+ assert.strictEqual((await patch(sam, `/api/v1/files/${file.id}`, { name: 'not-mine.txt' })).status, 404, 'somebody else\'s file is 404');
+
+ // Search covers every folder; a binned file is not in it; a typed % is a character, not a wildcard.
+ const found = await t.get('/api/v1/files?q=api-new', { as: kim });
+ assert.strictEqual(found.status, 200);
+ assert.strictEqual(found.json().folder_id, null);
+ assert.strictEqual(found.json().q, 'api-new');
+ assert.deepStrictEqual(found.json().files.map((f) => f.name), ['api-new.txt']);
+ assert.deepStrictEqual(found.json().folders, []);
+ assert.deepStrictEqual((await t.get('/api/v1/files?q=api-new', { as: sam })).json().files, [], 'nobody else\'s search');
+ await bin(kim, file.id);
+ assert.deepStrictEqual((await t.get('/api/v1/files?q=api-new', { as: kim })).json().files, [], 'binned files do not match');
+
+ const percent = await put(kim, '100% done.txt');
+ assert.strictEqual((await t.get('/api/v1/files?q=100%25', { as: kim })).json().files.length, 1, 'a % is itself');
+ assert.ok((await t.get('/api/v1/files?q=done', { as: kim })).json().files.some((f) => f.id === percent.id));
+ });
+
+ await check('the API renames and deletes folders; a sync key may rename but not delete one', async () => {
+ const made = await folder(kim, 'Folder One');
+ const renamed = await patch(kim, `/api/v1/folders/${made.id}`, { name: 'Folder Two' });
+ assert.strictEqual(renamed.status, 200, renamed.text);
+ assert.strictEqual(renamed.json().folder.name, 'Folder Two');
+ const other = await folder(kim, 'Folder Three');
+ assert.strictEqual((await patch(kim, `/api/v1/folders/${made.id}`, { name: 'Folder Three' })).status, 409, 'no two of a name in one place');
+ assert.strictEqual((await patch(kim, '/api/v1/folders/fld_01JABCDEFGHJKMNPQRSTVWXYZ0', { name: 'x' })).status, 404);
+
+ const file = await put(kim, 'holds-the-folder.txt', { folder: made.id });
+ assert.strictEqual((await t.get(`/api/v1/folders/${made.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 409, 'a folder with a file stays');
+ assert.strictEqual((await t.get(`/api/v1/folders/${made.id}`, { as: kim, method: 'DELETE', headers: SAME })).json().code, 'folder.not_empty');
+ assert.strictEqual((await t.get(`/api/v1/folders/${other.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204, 'an empty one goes');
+ assert.strictEqual((await t.get(`/api/v1/folders/${other.id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 404);
+
+ const madeKey = await t.get('/sync/keys', { as: kim, form: { name: 'bin-test' }, headers: SAME });
+ const key = (/ovdl_[A-Za-z0-9_-]{43}/.exec(madeKey.text) || [])[0];
+ assert.ok(key, 'a sync key is made');
+ assert.strictEqual((await patch(null, `/api/v1/files/${file.id}`, { name: 'key-rename.txt' }, { bearer: key })).status, 200, 'a key renames a file');
+ assert.strictEqual((await patch(null, `/api/v1/folders/${made.id}`, { name: 'Key Folder' }, { bearer: key })).status, 200, 'and a folder');
+ const refused = await t.get(`/api/v1/folders/${made.id}`, { bearer: key, method: 'DELETE', headers: SAME });
+ assert.strictEqual(refused.status, 403);
+ assert.strictEqual(refused.json().code, 'sync_key.not_allowed', 'but it may not delete one');
+ });
+
+ await check('delete forever asks Media for the bytes; a live file cannot be purged; the API restores', async () => {
+ const file = await put(kim, 'forever.txt');
+ const live = await t.get(`/api/v1/files/${file.id}?forever=1`, { as: kim, method: 'DELETE', headers: SAME });
+ assert.strictEqual(live.status, 404, 'a file that is not in the bin is not purged');
+ const restored = await t.get(`/api/v1/files/${file.id}/restore`, { as: kim, json: {}, headers: SAME });
+ assert.strictEqual(restored.status, 404, 'and a live file cannot be restored');
+
+ assert.strictEqual((await bin(kim, file.id)).status, 204);
+ const before = t.media.deleted.length;
+ const purge = await t.get(`/api/v1/files/${file.id}?forever=1`, { as: kim, method: 'DELETE', headers: SAME });
+ assert.strictEqual(purge.status, 204, purge.text);
+ assert.strictEqual(t.media.deleted.length, before + 1, 'Media was asked to delete the object');
+ assert.strictEqual(t.media.deleted[t.media.deleted.length - 1], file.object.media_object_id);
+ assert.strictEqual((await t.get(`/api/v1/files/${file.id}`, { as: kim })).status, 404);
+ assert.ok(!(await binPage()).text.includes('forever.txt'));
+
+ // Empty the bin deletes everything left in it for good.
+ const one = await put(kim, 'empty-one.txt');
+ const two = await put(kim, 'empty-two.txt');
+ await bin(kim, one.id);
+ await bin(kim, two.id);
+ const beforeEmpty = t.media.deleted.length;
+ const binned = Number(await t.ctx.s.db.value('SELECT count(*) FROM mh_files WHERE owner = $1 AND binned_at IS NOT NULL AND deleted_at IS NULL', [`user:${kim.subject}`]));
+ assert.ok(binned >= 2, `the bin holds the empty-one/two and whatever came before (${binned})`);
+ const emptied = await t.get('/files/bin/empty', { as: kim, form: {}, headers: SAME });
+ assert.strictEqual(emptied.status, 303);
+ assert.strictEqual(t.media.deleted.length, beforeEmpty + binned, 'every binned object went');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM mh_files WHERE owner = $1 AND binned_at IS NOT NULL AND deleted_at IS NULL', [`user:${kim.subject}`])), 0);
+ assert.ok(!(await binPage()).text.includes('empty-one.txt'));
+ assert.ok(!(await binPage()).text.includes('empty-two.txt'));
+ });
+
+ await check('the due job purges only after 30 days, in batches of 200', async () => {
+ const file = await put(kim, 'due.txt');
+ await bin(kim, file.id);
+ const started = clock;
+ clock += 29 * DAY;
+ assert.strictEqual(await t.ctx.drive.purgeDue({ batch: 200 }), 0, 'not due yet');
+ clock += 2 * DAY;
+ const purged = await t.ctx.drive.purgeDue({ batch: 200 });
+ assert.ok(purged >= 1, `the due file went (${purged})`);
+ assert.ok(t.media.deleted.includes(file.object.media_object_id));
+ clock = started;
+ assert.ok(!(await binPage()).text.includes('due.txt'));
+ const row = await t.ctx.s.db.maybe('SELECT deleted_at, binned_at FROM mh_files WHERE id = $1', [file.id]);
+ assert.ok(row.deleted_at, 'the row is soft-deleted');
+ assert.ok(row.binned_at, 'and still says when it was binned');
+
+ // 205 due rows is two batches (200 then 5), not a 30-day timer.
+ const old = new Date(clock - 40 * DAY).toISOString();
+ await t.ctx.s.db.exec(
+ `INSERT INTO mh_files (id, owner, object_id, name, size, content_type, sha256, folder_id, created_at, binned_at)
+ SELECT 'fil_' || lpad(g::text, 26, '0'), $1, $2, 'old-' || g || '.bin', 1, 'text/plain', NULL, NULL, $3, $4
+ FROM generate_series(1, 205) g`,
+ [`user:${kim.subject}`, file.object.media_object_id, old, old]);
+ const bulk = await t.ctx.drive.purgeDue({ batch: 200 });
+ assert.strictEqual(bulk, 205, 'all batches, 200 at a time');
+ const left = await t.ctx.s.db.value("SELECT count(*) FROM mh_files WHERE name LIKE 'old-%.bin' AND deleted_at IS NULL");
+ assert.strictEqual(Number(left), 0);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -15,7 +15,7 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -15,7 +15,7 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
await check('a guest gets a sign-in page for every file page, never a file', async () => {
await check('a guest gets a sign-in page for every file page, never a file', async () => {
- for (const p of ['/files', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {
+ for (const p of ['/files', '/files/bin', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 401, p);
assert.strictEqual(r.status, 401, p);
assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);
assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);
@@ -169,6 +169,25 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -169,6 +169,25 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.ok(!(await t.get('/api/v1/folders', { as: lee })).json().folders.some((f) => f.id === invoices.id));
assert.ok(!(await t.get('/api/v1/folders', { as: lee })).json().folders.some((f) => f.id === invoices.id));
});
});
+ await check('a deleted file waits in the bin, and the drive links it there with a count', async () => {
+ const file = (await upload(t, kim, bytes(70, 'bin-page'), { name: 'bin-page.txt', contentType: 'text/plain' })).json().file;
+ const page = await t.get(`/files/${file.id}`, { as: kim });
+ assert.ok(page.text.includes('goes to the bin for 30 days'), 'the file page says where a delete goes');
+ assert.ok(page.text.includes('share links stop working at once'), 'and what happens to its links');
+ const deleted = await t.get(`/files/${file.id}/delete`, { as: kim, form: {}, headers: SAME });
+ assert.strictEqual(deleted.status, 303);
+ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 404);
+ const drive = await t.get('/files', { as: kim });
+ assert.match(drive.text, /href="\/files\/bin"[^\n]*<em>1<\/em>/, 'the sidebar counts it');
+ const bin = await t.get('/files/bin', { as: kim });
+ assert.strictEqual(bin.status, 200);
+ assert.ok(bin.text.includes('bin-page.txt'), 'the bin lists it');
+ assert.ok(bin.text.includes(`action="/files/${file.id}/restore"`) && bin.text.includes(`action="/files/${file.id}/purge"`), 'with Restore and Delete forever');
+ assert.ok(bin.text.includes('action="/files/bin/empty"'), 'and a way to empty it');
+ await t.get(`/files/${file.id}/restore`, { as: kim, form: {}, headers: SAME });
+ assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200, 'restored to the drive');
+ });
+
await check('the update log and the crawl artifacts still work, and a guest can read the rules', async () => {
await check('the update log and the crawl artifacts still work, and a guest can read the rules', async () => {
assert.strictEqual((await t.get('/updates')).status, 200);
assert.strictEqual((await t.get('/updates')).status, 200);
const robots = await t.get('/robots.txt');
const robots = await t.get('/robots.txt');
@@ -155,15 +155,16 @@ const HOUR = 3_600_000;
@@ -155,15 +155,16 @@ const HOUR = 3_600_000;
assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201);
assert.strictEqual((await share(t, nia, { file_id: f.id, hours: 1 })).status, 201);
});
});
- await check('deleting a file takes its share links with it', async () => {
+ await check('deleting a file takes its share links with it and puts it in the bin', async () => {
const ola = t.network.addUser('ola');
const ola = t.network.addUser('ola');
const f = await put(ola, 'doomed.bin');
const f = await put(ola, 'doomed.bin');
const slug = (await share(t, ola, { file_id: f.id, hours: 24 })).json().share.slug;
const slug = (await share(t, ola, { file_id: f.id, hours: 24 })).json().share.slug;
assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);
assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 200);
+ const before = t.media.deleted.length;
assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola, method: 'DELETE', headers: SAME })).status, 204);
assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola, method: 'DELETE', headers: SAME })).status, 204);
assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);
assert.strictEqual((await t.get(`/s/${slug}`, { as: sam })).status, 404);
assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola })).status, 404, 'and the file is gone for its owner too');
assert.strictEqual((await t.get(`/api/v1/files/${f.id}`, { as: ola })).status, 404, 'and the file is gone for its owner too');
- assert.ok(t.media.deleted.length > 0, 'Media was asked to delete the object');
+ assert.strictEqual(t.media.deleted.length, before, 'Media keeps the bytes: it is only in the bin');
});
});
await t.close();
await t.close();