openvibers/openvibe.mediahub Commits

The drive has a bin for 30 days, and the API has its rename, move, folder delete and search (#306)

OP OpenVibers committed 6 days ago · 5a4f4633a9 · 18 files, +613 −59

Delete now bins a file: its share links stop at once, its bytes stay in
Media, and it still counts against storage. /files/bin lists the bin with
Restore and Delete forever per file and Empty the bin; a due job purges
files binned more than 30 days ago in batches of 200.

The API gains PATCH /files/:id (name, folder_id), PATCH /folders/:id,
DELETE /folders/:id (409 folder.not_empty), GET /files?q= across every
folder, POST /files/:id/restore and DELETE /files/:id?forever=1; a sync
key may rename and move but not delete a folder. Migration 0023 adds
binned_at and its index.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b6532a5..9f2e316 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 easy
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
@@@ -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
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
-- 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
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.
ddiff --git a/README.md b/README.md
iindex 05902c9..e078047 100644
---- a/README.md
++++ b/README.md
@@@ -137,6 +137,12 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never h
resume 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).
ddiff --git a/migrations/0023_files_bin.sql b/migrations/0023_files_bin.sql
nnew file mode 100644
iindex 0000000..c9ed01f
---- /dev/null
++++ b/migrations/0023_files_bin.sql
@@@ -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;
ddiff --git a/public/css/drive.css b/public/css/drive.css
iindex c1508a0..226f9dd 100644
---- a/public/css/drive.css
++++ b/public/css/drive.css
@@@ -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; }
ddiff --git a/server/drive/agent-descriptor.js b/server/drive/agent-descriptor.js
iindex edb28c6..a66bbb6 100644
---- a/server/drive/agent-descriptor.js
++++ b/server/drive/agent-descriptor.js
@@@ -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',
ddiff --git a/server/drive/bin.js b/server/drive/bin.js
nnew file mode 100644
iindex 0000000..9f35d65
---- /dev/null
++++ b/server/drive/bin.js
@@@ -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 };
ddiff --git a/server/drive/rules.js b/server/drive/rules.js
iindex f7ff0f6..4d56703 100644
---- a/server/drive/rules.js
++++ b/server/drive/rules.js
@@@ -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,
};
ddiff --git a/server/drive/service.js b/server/drive/service.js
iindex e419588..8ae0a2a 100644
---- a/server/drive/service.js
++++ b/server/drive/service.js
@@@ -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,
ddiff --git a/server/drive/store.js b/server/drive/store.js
iindex cf49a21..d5bf1fc 100644
---- a/server/drive/store.js
++++ b/server/drive/store.js
@@@ -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_i
const 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,
ddiff --git a/server/drive/ui.js b/server/drive/ui.js
iindex f70dcb7..e906717 100644
---- a/server/drive/ui.js
++++ b/server/drive/ui.js
@@@ -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>`;
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 95ccbc0..13cf952 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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.',
});
});
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 1febf6a..111cdc7 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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') },
] },
],
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex 4053921..b949253 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -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(),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex cfb1535..70250e8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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-cop
const 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) => {
ddiff --git a/server/index.js b/server/index.js
iindex ff2c628..b89589c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 };
}
ddiff --git a/test/drive-bin.test.js b/test/drive-bin.test.js
nnew file mode 100644
iindex 0000000..a352ca9
---- /dev/null
++++ b/test/drive-bin.test.js
@@@ -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); });
ddiff --git a/test/drive-pages.test.js b/test/drive-pages.test.js
iindex 9f65645..c887126 100644
---- a/test/drive-pages.test.js
++++ b/test/drive-pages.test.js
@@@ -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');
ddiff --git a/test/drive-shares.test.js b/test/drive-shares.test.js
iindex 109ab4c..db956de 100644
---- a/test/drive-shares.test.js
++++ b/test/drive-shares.test.js
@@@ -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();