SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%

feat: v2.0 — file versioning, in-browser editor, file requests, storage insights

- node_versions table: replace/edit archives previous blob (20 kept/file, CAS refs)
- versions API: list, download, restore, delete; Info panel + Version history UI
- upload conflicts: Keep both / Replace (new version) / Skip, batch-applicable
- in-browser text editor (code/md/json/csv), ⌘S saves as version, New text file
- file requests: public /r/<token> pages receive uploads into a chosen folder
  (12-char base58, expiry, max-files quota, QR, instant close, activity log)
- storage insights: dedup savings, version footprint, largest files, duplicates
- 27 tests green (unit + e2e incl. stranger upload + version restore)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 6 h ago (Aug 10, 2026) parent 044fa17

Showing 22 changed files with +1,478 and −26

modified README.md +16 −1
@@ -15,7 +15,8 @@
15 15 ![Node](https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white)
16 16 ![Fastify](https://img.shields.io/badge/fastify-5-000000?logo=fastify)
17 17 ![SQLite](https://img.shields.io/badge/sqlite-FTS5%20%2B%20WAL-003B57?logo=sqlite)
18 ![Tests](https://img.shields.io/badge/tests-20%20passing-22d3aa)
18 +![Version](https://img.shields.io/badge/version-2.0.0-b48cff)
19 +![Tests](https://img.shields.io/badge/tests-27%20passing-22d3aa)
19 20 ![License](https://img.shields.io/badge/license-MIT-4f8cff)
20 21
21 22 **Personal cloud drive of [Simon-Pierre Boucher](mailto:contact@spboucher.ai)** — a self-hosted
@@ -42,6 +43,20 @@ the only public surfaces are explicitly created share links.
42 43 - **Search** — SQLite FTS5 over names, tags **and file contents** (pdftotext, office→text, code).
43 44 - **CLI**`spbdrive init/ls/up/down/mkdir/mv/rm/restore/share/shares/revoke/search/push/doctor`.
44 45
46 +### New in v2.0
47 +
48 +- **File versioning** — replacing or editing a file archives the previous content
49 + (up to 20 versions per file, CAS-backed so identical bytes cost nothing). Browse,
50 + download, restore or delete versions from the Info panel / right-click → *Version history*.
51 + Upload conflicts now offer **Keep both / Replace (new version) / Skip**, batch-applicable.
52 +- **In-browser text editor** — edit code / Markdown / JSON / CSV directly (⌘S saves as a new
53 + version), plus *New → Text file*. Keyboard shortcut `E`.
54 +- **File requests**`https://drive.spboucher.ai/r/<token>` (12-char base58, ~70 bits) lets
55 + anyone **send** files straight into a folder of your choice without seeing its contents:
56 + optional expiry + max-file quota, QR code, instant close, everything logged in Activity.
57 +- **Storage insights** — dedup savings, version-history footprint, trash weight, top-20
58 + largest files and duplicate-file groups with one-click cleanup (sidebar → *Storage*).
59 +
45 60 ## Layout
46 61
47 62 ```
modified package.json +1 −1
@@ -1,6 +1,6 @@
1 1 {
2 2 "name": "spbdrive",
3 "version": "1.0.0",
3 + "version": "2.0.0",
4 4 "description": "SPB Drive — Personal Cloud Drive for Simon-Pierre Boucher",
5 5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 6 "license": "MIT",
modified src/api/v1.mjs +184 −5
@@ -10,6 +10,7 @@
10 10 * ─────────────────────────────────────────────
11 11 */
12 12
13 +import { Readable } from 'node:stream';
13 14 import QRCode from 'qrcode';
14 15 import mime from 'mime';
15 16 import archiver from 'archiver';
@@ -22,18 +23,24 @@ import {
22 23 SESSION_COOKIE, createApiToken, createSession, destroySession, listApiTokens,
23 24 listSessions, revokeAllSessions, revokeApiToken, revokeSession,
24 25 } from '../auth/session.mjs';
25 import { storageStats } from '../storage/blobs.mjs';
26 +import { putBlob, storageStats } from '../storage/blobs.mjs';
26 27 import {
27 ROOT_ID, childrenOf, copyNode, deleteForever, duplicateNode, getNode,
28 listDescendantFiles, listTrash, mkdir, moveNode, mustGetNode, pathOf,
28 + ROOT_ID, childByName, childrenOf, copyNode, createFileNode, deleteForever, duplicateNode, getNode,
29 + listDescendantFiles, listTrash, mkdir, moveNode, mustGetNode, pathOf, pathString,
29 30 recentFiles, renameNode, resolvePath, restoreNode, trashNode,
30 31 } from '../storage/nodes.mjs';
32 +import {
33 + deleteVersion, getVersion, listVersions, restoreVersion, setNodeBlob,
34 +} from '../storage/versions.mjs';
31 35 import {
32 36 abortUpload, chunksPresent, completeUpload, getUpload, initUpload, writeChunk,
33 37 } from '../storage/upload.mjs';
34 38 import {
35 39 createShare, getShareById, listShares, revokeShare, shareEvents, sharesForNode, updateShare,
36 40 } from '../shares/shares.mjs';
41 +import {
42 + closeRequest, createRequest, getRequestById, listRequests,
43 +} from '../shares/requests.mjs';
37 44 import { searchNodes } from '../search/search.mjs';
38 45 import { extractNodeText } from '../search/extract.mjs';
39 46 import { updateFtsTags } from '../search/index-sync.mjs';
@@ -43,7 +50,7 @@ import {
43 50 handleMarkdown, handleOfficePdf, handlePeaks, handleRawText, handleText,
44 51 handleVideoFile, handleVideoStatus,
45 52 } from '../preview/handlers.mjs';
46 import { apiError, makeRateLimiter } from '../web/http-helpers.mjs';
53 +import { apiError, makeRateLimiter, sendBlob } from '../web/http-helpers.mjs';
47 54
48 55 /** Serialize a node row for API responses. */
49 56 export function nodeJson(node) {
@@ -254,6 +261,74 @@ export function registerApiV1(app) {
254 261 app.post('/api/v1/nodes/:id/duplicate', async (req) =>
255 262 ({ node: nodeJson(duplicateNode(Number(req.params.id), { ip: req.ip })) }));
256 263
264 + // ── Version history ─────────────────────────────────────────────────
265 + app.get('/api/v1/nodes/:id/versions', async (req, reply) => {
266 + const node = getNode(Number(req.params.id));
267 + if (!node || node.type !== 'file') return apiError(reply, 404, 'not_found', 'No such file');
268 + return {
269 + versions: listVersions(node.id).map((v) => ({
270 + id: v.id, size: v.size, mime: v.mime, created: v.created,
271 + replacedAt: v.replaced_at, origin: v.origin,
272 + })),
273 + };
274 + });
275 +
276 + app.post('/api/v1/nodes/:id/versions/:vid/restore', async (req) => {
277 + const node = restoreVersion(Number(req.params.id), Number(req.params.vid), { ip: req.ip });
278 + extractNodeText(node);
279 + return { node: nodeJson(node) };
280 + });
281 +
282 + app.delete('/api/v1/nodes/:id/versions/:vid', async (req, reply) => {
283 + if (!deleteVersion(Number(req.params.id), Number(req.params.vid))) {
284 + return apiError(reply, 404, 'not_found', 'No such version');
285 + }
286 + return { ok: true };
287 + });
288 +
289 + app.get('/api/v1/nodes/:id/versions/:vid/dl', async (req, reply) => {
290 + const node = getNode(Number(req.params.id));
291 + const version = node ? getVersion(node.id, Number(req.params.vid)) : null;
292 + if (!version) return apiError(reply, 404, 'not_found', 'No such version');
293 + const stamp = new Date(version.replaced_at).toISOString().slice(0, 10);
294 + const dot = node.name.lastIndexOf('.');
295 + const versionedName = dot > 0
296 + ? `${node.name.slice(0, dot)} (v${stamp})${node.name.slice(dot)}`
297 + : `${node.name} (v${stamp})`;
298 + return sendBlob(req, reply, {
299 + sha: version.blob_sha, mime: version.mime, filename: versionedName, download: true,
300 + });
301 + });
302 +
303 + // ── In-browser editor: create + save text files ─────────────────────
304 + const TEXT_EDIT_LIMIT = 16 * 1024 * 1024;
305 +
306 + app.post('/api/v1/files', async (req, reply) => {
307 + const { parentId, name, content = '' } = req.body ?? {};
308 + if (typeof content !== 'string' || content.length > TEXT_EDIT_LIMIT) {
309 + return apiError(reply, 413, 'too_large', 'Content too large');
310 + }
311 + const buf = Buffer.from(content, 'utf8');
312 + const { sha, size } = await putBlob(Readable.from([buf]));
313 + const guessed = mime.getType(String(name ?? '')) || 'text/plain';
314 + const node = createFileNode(Number(parentId ?? ROOT_ID), String(name ?? ''), {
315 + sha, size, mime: guessed, conflict: 'keep-both', ip: req.ip,
316 + });
317 + if (node) extractNodeText(node);
318 + return { node: nodeJson(node) };
319 + });
320 +
321 + app.put('/api/v1/nodes/:id/content', async (req, reply) => {
322 + const node = getNode(Number(req.params.id));
323 + if (!node || node.type !== 'file') return apiError(reply, 404, 'not_found', 'No such file');
324 + const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body ?? ''), 'utf8');
325 + if (body.length > TEXT_EDIT_LIMIT) return apiError(reply, 413, 'too_large', 'Content too large');
326 + const { sha, size } = await putBlob(Readable.from([body]));
327 + const updated = setNodeBlob(node.id, { sha, size, mime: node.mime, origin: 'edit', ip: req.ip });
328 + extractNodeText(updated);
329 + return { node: nodeJson(updated) };
330 + });
331 +
257 332 app.get('/api/v1/trash', async () => ({ items: listTrash().map(nodeJson) }));
258 333 app.post('/api/v1/trash/empty', async (req) => {
259 334 const items = listTrash();
@@ -272,7 +347,14 @@ export function registerApiV1(app) {
272 347 const parts = String(relPath).split('/').filter(Boolean).slice(0, -1);
273 348 for (const part of parts) parent = mkdir(parent, part, { ip: req.ip }).id;
274 349 }
275 return initUpload({ parentId: parent, name: String(name ?? ''), size });
350 + const session = await initUpload({ parentId: parent, name: String(name ?? ''), size });
351 + // Tell the client about a same-name file so it can offer
352 + // Keep both / Replace (new version) / Skip before completing.
353 + const existing = childByName(parent, String(name ?? ''));
354 + session.conflictsWith = existing?.type === 'file'
355 + ? { id: existing.id, name: existing.name, size: existing.size, modified: existing.modified }
356 + : null;
357 + return session;
276 358 });
277 359
278 360 app.get('/api/v1/upload/:id', async (req) => {
@@ -424,6 +506,103 @@ export function registerApiV1(app) {
424 506 return reply.send(svg);
425 507 });
426 508
509 + // ── File requests (receive files from others) ───────────────────────
510 + const requestJson = (r) => ({
511 + id: r.id,
512 + token: r.token,
513 + url: `${config.publicUrl}/r/${r.token}`,
514 + folderId: r.folder_id,
515 + folderName: r.folder_name ?? getNode(r.folder_id)?.name ?? '',
516 + label: r.label,
517 + created: r.created,
518 + expiresAt: r.expires_at,
519 + maxFiles: r.max_files,
520 + received: r.received,
521 + closedAt: r.closed_at,
522 + });
523 +
524 + app.get('/api/v1/requests', async () => ({ requests: listRequests().map(requestJson) }));
525 +
526 + app.post('/api/v1/requests', async (req) => {
527 + const { folderId, label, expiresAt, maxFiles } = req.body ?? {};
528 + const request = createRequest(Number(folderId ?? ROOT_ID), {
529 + label: label ?? null,
530 + expiresAt: expiresAt ?? null,
531 + maxFiles: maxFiles ?? null,
532 + ip: req.ip,
533 + });
534 + return { request: requestJson(request) };
535 + });
536 +
537 + app.delete('/api/v1/requests/:id', async (req, reply) => {
538 + if (!closeRequest(Number(req.params.id), { ip: req.ip })) {
539 + return apiError(reply, 404, 'not_found', 'No such request');
540 + }
541 + return { ok: true };
542 + });
543 +
544 + app.get('/api/v1/requests/:id/qr', async (req, reply) => {
545 + const request = getRequestById(Number(req.params.id));
546 + if (!request) return apiError(reply, 404, 'not_found', 'No such request');
547 + const svg = await QRCode.toString(`${config.publicUrl}/r/${request.token}`, {
548 + type: 'svg', margin: 1, width: 240,
549 + color: { dark: '#0b0e14', light: '#ffffff' },
550 + });
551 + reply.type('image/svg+xml');
552 + return reply.send(svg);
553 + });
554 +
555 + // ── Storage insights: dedup savings, duplicates, largest files ──────
556 + app.get('/api/v1/stats/detailed', async () => {
557 + const db = getDb();
558 + const dupGroups = db
559 + .prepare(
560 + `SELECT blob_sha AS sha, COUNT(*) AS copies, MAX(size) AS size
561 + FROM nodes WHERE type = 'file' AND trashed_at IS NULL AND blob_sha IS NOT NULL
562 + GROUP BY blob_sha HAVING copies > 1
563 + ORDER BY (copies - 1) * size DESC LIMIT 50`,
564 + )
565 + .all();
566 + const nodesForSha = db.prepare(
567 + "SELECT * FROM nodes WHERE blob_sha = ? AND trashed_at IS NULL AND type = 'file' LIMIT 10",
568 + );
569 + const duplicates = dupGroups.map((g) => ({
570 + sha: g.sha,
571 + copies: g.copies,
572 + size: g.size,
573 + savedBytes: (g.copies - 1) * g.size,
574 + nodes: nodesForSha.all(g.sha).map((n) => ({
575 + id: n.id, parentId: n.parent_id, name: n.name, path: pathString(n.id),
576 + })),
577 + }));
578 + const largest = db
579 + .prepare(
580 + `SELECT * FROM nodes WHERE type = 'file' AND trashed_at IS NULL
581 + ORDER BY size DESC LIMIT 20`,
582 + )
583 + .all()
584 + .map((n) => ({ ...nodeJson(n), path: pathString(n.id) }));
585 + const dedupSaved = db
586 + .prepare('SELECT COALESCE(SUM((refcount - 1) * size), 0) AS bytes FROM blobs WHERE refcount > 1')
587 + .get().bytes;
588 + const versions = db
589 + .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM node_versions')
590 + .get();
591 + const trash = db
592 + .prepare("SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM nodes WHERE trashed_at IS NOT NULL AND type = 'file'")
593 + .get();
594 + return {
595 + ...storageStats(),
596 + dedupSavedBytes: dedupSaved,
597 + duplicates,
598 + largest,
599 + versionCount: versions.n,
600 + versionBytes: versions.bytes,
601 + trashCount: trash.n,
602 + trashBytes: trash.bytes,
603 + };
604 + });
605 +
427 606 // ── Search & activity ───────────────────────────────────────────────
428 607 app.get('/api/v1/search', async (req) => {
429 608 const { q = '', type, tag, folderId, starred, shared, after, before, minSize, maxSize } = req.query;
modified src/db/db.mjs +16 −0
@@ -35,6 +35,7 @@ export function getDb(file) {
35 35 db.pragma('synchronous = NORMAL');
36 36
37 37 db.exec(readFileSync(path.join(HERE, 'schema.sql'), 'utf8'));
38 + migrate(db);
38 39
39 40 const root = db.prepare('SELECT id FROM nodes WHERE id = 1').get();
40 41 if (!root) {
@@ -46,6 +47,21 @@ export function getDb(file) {
46 47 return db;
47 48 }
48 49
50 +/**
51 + * Column-level migrations for tables that predate v2 (CREATE IF NOT EXISTS
52 + * won't touch existing tables, so new columns are added here).
53 + */
54 +function migrate(database) {
55 + const addColumn = (table, column, ddl) => {
56 + const present = database
57 + .prepare(`SELECT 1 FROM pragma_table_info(?) WHERE name = ?`)
58 + .get(table, column);
59 + if (!present) database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
60 + };
61 + // v2: public file-request uploads are tagged with their request id.
62 + addColumn('uploads', 'source', 'TEXT');
63 +}
64 +
49 65 /** Close the database (tests / graceful shutdown). */
50 66 export function closeDb() {
51 67 if (db) {
modified src/db/schema.sql +28 −0
@@ -136,6 +136,34 @@ CREATE TABLE IF NOT EXISTS uploads (
136 136 created INTEGER NOT NULL
137 137 );
138 138
139 +-- Version history: superseded blobs kept when a file is replaced or edited.
140 +-- Each row holds one refcount on its blob (released when the row is pruned).
141 +CREATE TABLE IF NOT EXISTS node_versions (
142 + id INTEGER PRIMARY KEY AUTOINCREMENT,
143 + node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
144 + blob_sha TEXT NOT NULL REFERENCES blobs(sha),
145 + size INTEGER NOT NULL,
146 + mime TEXT,
147 + created INTEGER NOT NULL,
148 + replaced_at INTEGER NOT NULL,
149 + origin TEXT NOT NULL DEFAULT 'replace' CHECK (origin IN ('replace', 'edit', 'restore'))
150 +);
151 +
152 +CREATE INDEX IF NOT EXISTS idx_versions_node ON node_versions(node_id, replaced_at DESC);
153 +
154 +-- File requests: public links that let anyone upload INTO a chosen folder.
155 +CREATE TABLE IF NOT EXISTS file_requests (
156 + id INTEGER PRIMARY KEY AUTOINCREMENT,
157 + token TEXT NOT NULL UNIQUE,
158 + folder_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
159 + label TEXT,
160 + created INTEGER NOT NULL,
161 + expires_at INTEGER,
162 + max_files INTEGER,
163 + received INTEGER NOT NULL DEFAULT 0,
164 + closed_at INTEGER
165 +);
166 +
139 167 -- Full-text search over name, tags and extracted text content.
140 168 CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
141 169 name, tags, content,
modified src/server.mjs +5 −1
@@ -25,6 +25,7 @@ import { gcBlobs, storageStats } from './storage/blobs.mjs';
25 25 import { purgeTrash } from './storage/nodes.mjs';
26 26 import { pruneUploads } from './storage/upload.mjs';
27 27 import { pruneShares } from './shares/shares.mjs';
28 +import { pruneRequests } from './shares/requests.mjs';
28 29 import { vacuumFts } from './search/search.mjs';
29 30 import { scheduleMissingExtractions } from './search/extract.mjs';
30 31 import { queueDepth } from './preview/queue.mjs';
@@ -59,6 +60,8 @@ async function main() {
59 60
60 61 // Upload chunks arrive as raw octet streams; handlers read req.raw directly.
61 62 app.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, null));
63 + // The in-browser editor saves file content as text/plain.
64 + app.addContentTypeParser('text/plain', { parseAs: 'buffer' }, (req, body, done) => done(null, body));
62 65
63 66 // ── Authentication decorator (session cookie or Bearer API token) ────
64 67 app.decorateRequest('authed', false);
@@ -116,11 +119,12 @@ async function main() {
116 119 const purged = purgeTrash(config.trashRetentionDays);
117 120 const gcd = await gcBlobs();
118 121 const shares = pruneShares();
122 + const requests = pruneRequests();
119 123 const sessions = pruneSessions();
120 124 const uploads = await pruneUploads();
121 125 vacuumFts();
122 126 const queued = scheduleMissingExtractions();
123 app.log.info({ purged, gcd, shares, sessions, uploads, queued }, 'maintenance done');
127 + app.log.info({ purged, gcd, shares, requests, sessions, uploads, queued }, 'maintenance done');
124 128 } catch (err) {
125 129 app.log.error({ err }, 'maintenance failed');
126 130 }
added src/shares/requests.mjs +109 −0
@@ -0,0 +1,109 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/shares/requests.mjs
8 + * Purpose : File requests — public links that receive uploads into a folder
9 + * (base58 tokens, expiry, max-file quota, close/revoke)
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +import { timingSafeEqual } from 'node:crypto';
15 +import { getDb, logActivity } from '../db/db.mjs';
16 +import { mustGetNode } from '../storage/nodes.mjs';
17 +import { makeToken } from './shares.mjs';
18 +
19 +// 58^12 ≈ 1.4e21 ≈ 70 bits of entropy — stronger than shares since a
20 +// request link WRITES into the drive.
21 +const TOKEN_LEN = 12;
22 +
23 +/**
24 + * Create a file request pointing at a folder.
25 + * @param {{label?: string|null, expiresAt?: number|null, maxFiles?: number|null, ip?: string}} opts
26 + */
27 +export function createRequest(folderId, opts = {}) {
28 + const folder = mustGetNode(folderId);
29 + if (folder.type !== 'folder' || folder.trashed_at) {
30 + throw Object.assign(new Error('Target must be a live folder'), { statusCode: 400 });
31 + }
32 + const token = makeToken(TOKEN_LEN);
33 + const info = getDb()
34 + .prepare(
35 + `INSERT INTO file_requests (token, folder_id, label, created, expires_at, max_files)
36 + VALUES (?, ?, ?, ?, ?, ?)`,
37 + )
38 + .run(token, folderId, opts.label ?? null, Date.now(), opts.expiresAt ?? null, opts.maxFiles ?? null);
39 + logActivity('request.create', { nodeId: folderId, detail: token, ip: opts.ip ?? '' });
40 + return getRequestById(Number(info.lastInsertRowid));
41 +}
42 +
43 +export function getRequestById(id) {
44 + return getDb().prepare('SELECT * FROM file_requests WHERE id = ?').get(id) ?? null;
45 +}
46 +
47 +/** Constant-time token lookup (row returned regardless of validity). */
48 +export function getRequestByToken(token) {
49 + if (typeof token !== 'string' || token.length !== TOKEN_LEN) return null;
50 + const row = getDb().prepare('SELECT * FROM file_requests WHERE token = ?').get(token);
51 + if (!row) return null;
52 + const a = Buffer.from(row.token);
53 + const b = Buffer.from(token);
54 + if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
55 + return row;
56 +}
57 +
58 +/**
59 + * @returns {{ok: true} | {ok: false, reason: 'missing'|'closed'|'expired'|'full'|'gone'}}
60 + */
61 +export function requestValidity(request) {
62 + if (!request) return { ok: false, reason: 'missing' };
63 + if (request.closed_at) return { ok: false, reason: 'closed' };
64 + if (request.expires_at && request.expires_at < Date.now()) return { ok: false, reason: 'expired' };
65 + if (request.max_files !== null && request.received >= request.max_files) {
66 + return { ok: false, reason: 'full' };
67 + }
68 + const folder = getDb().prepare('SELECT * FROM nodes WHERE id = ?').get(request.folder_id);
69 + if (!folder || folder.trashed_at) return { ok: false, reason: 'gone' };
70 + return { ok: true };
71 +}
72 +
73 +/** Count one received file (called on completed public upload). */
74 +export function recordReceived(requestId, { name = '', ip = '' } = {}) {
75 + const db = getDb();
76 + db.prepare('UPDATE file_requests SET received = received + 1 WHERE id = ?').run(requestId);
77 + const request = getRequestById(requestId);
78 + logActivity('request.upload', { nodeId: request?.folder_id, detail: name, ip });
79 +}
80 +
81 +/** All requests with target-folder info, newest first (manager view). */
82 +export function listRequests() {
83 + return getDb()
84 + .prepare(
85 + `SELECT r.*, n.name AS folder_name FROM file_requests r
86 + JOIN nodes n ON n.id = r.folder_id ORDER BY r.created DESC`,
87 + )
88 + .all();
89 +}
90 +
91 +/** Close a request immediately (public link dies on the next call). */
92 +export function closeRequest(id, { ip = '' } = {}) {
93 + const request = getRequestById(id);
94 + if (!request) return false;
95 + getDb().prepare('UPDATE file_requests SET closed_at = ? WHERE id = ?').run(Date.now(), id);
96 + logActivity('request.close', { nodeId: request.folder_id, detail: request.token, ip });
97 + return true;
98 +}
99 +
100 +/** Delete closed/expired requests older than 90 days (daily job). */
101 +export function pruneRequests() {
102 + const cutoff = Date.now() - 90 * 86_400_000;
103 + return getDb()
104 + .prepare(
105 + `DELETE FROM file_requests WHERE (closed_at IS NOT NULL AND closed_at < ?)
106 + OR (expires_at IS NOT NULL AND expires_at < ?)`,
107 + )
108 + .run(cutoff, cutoff).changes;
109 +}
modified src/shares/shares.mjs +5 −4
@@ -19,14 +19,15 @@ import { mustGetNode } from '../storage/nodes.mjs';
19 19 const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
20 20 const TOKEN_LEN = 10;
21 21
22 function makeToken() {
23 const bytes = randomBytes(TOKEN_LEN * 2);
22 +/** Uniform base58 token of the given length (default: share length). */
23 +export function makeToken(len = TOKEN_LEN) {
24 + const bytes = randomBytes(len * 2);
24 25 let out = '';
25 for (let i = 0; out.length < TOKEN_LEN && i < bytes.length; i += 1) {
26 + for (let i = 0; out.length < len && i < bytes.length; i += 1) {
26 27 // Rejection sampling keeps the distribution uniform across the alphabet.
27 28 if (bytes[i] < 232) out += BASE58[bytes[i] % 58];
28 29 }
29 while (out.length < TOKEN_LEN) out += BASE58[randomBytes(1)[0] % 58];
30 + while (out.length < len) out += BASE58[randomBytes(1)[0] % 58];
30 31 return out;
31 32 }
32 33
modified src/storage/nodes.mjs +6 −3
@@ -12,6 +12,7 @@
12 12
13 13 import { getDb, logActivity } from '../db/db.mjs';
14 14 import { refBlob, unrefBlob } from './blobs.mjs';
15 +import { setNodeBlob, dropVersionsForNode } from './versions.mjs';
15 16 import { upsertFtsName, deleteFtsEntry } from '../search/index-sync.mjs';
16 17
17 18 export const ROOT_ID = 1;
@@ -136,6 +137,8 @@ export function mkdir(parentId, name, { ip = '' } = {}) {
136 137
137 138 /**
138 139 * Create a file node pointing at a stored blob.
140 + * Replacing an existing file keeps the node (and its shares/tags/id) and
141 + * archives the previous content as a version.
139 142 * @param {'keep-both'|'replace'|'skip'} conflict
140 143 * @returns {object|null} the node, or null when skipped
141 144 */
@@ -147,10 +150,9 @@ export function createFileNode(parentId, name, { sha, size, mime, conflict = 'ke
147 150 if (existing) {
148 151 if (conflict === 'skip') return null;
149 152 if (conflict === 'replace' && existing.type === 'file') {
150 trashNode(existing.id, { ip });
151 } else {
152 finalName = uniqueName(parentId, name);
153 + return setNodeBlob(existing.id, { sha, size, mime, origin: 'replace', ip });
153 154 }
155 + finalName = uniqueName(parentId, name);
154 156 }
155 157 const now = Date.now();
156 158 const info = getDb()
@@ -292,6 +294,7 @@ export function deleteForever(id, { ip = '' } = {}) {
292 294 const kids = db.prepare('SELECT * FROM nodes WHERE parent_id = ?').all(n.id);
293 295 for (const kid of kids) wipe(kid);
294 296 if (n.blob_sha) unrefBlob(n.blob_sha);
297 + dropVersionsForNode(n.id);
295 298 deleteFtsEntry(n.id);
296 299 db.prepare('DELETE FROM nodes WHERE id = ?').run(n.id);
297 300 };
modified src/storage/upload.mjs +8 −0
@@ -44,6 +44,14 @@ export async function initUpload({ parentId, name, size }) {
44 44 return { uploadId: id, chunkSize: config.chunkSize, nChunks, have: [] };
45 45 }
46 46
47 +/**
48 + * Tag an upload session with its origin (e.g. 'req:<fileRequestId>') so
49 + * public file-request endpoints can only touch their own sessions.
50 + */
51 +export function markUploadSource(id, source) {
52 + getDb().prepare('UPDATE uploads SET source = ? WHERE id = ?').run(source, String(id));
53 +}
54 +
47 55 /** Look up an upload session row or throw 404. */
48 56 export function getUpload(id) {
49 57 const row = getDb().prepare('SELECT * FROM uploads WHERE id = ?').get(String(id));
added src/storage/versions.mjs +112 −0
@@ -0,0 +1,112 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/storage/versions.mjs
8 + * Purpose : File version history — keep superseded blobs on replace/edit,
9 + * list, restore, prune (each version row holds one blob ref)
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +import { getDb, logActivity } from '../db/db.mjs';
15 +import { refBlob, unrefBlob } from './blobs.mjs';
16 +
17 +/** Versions kept per node; older ones are pruned (blob unref'd). */
18 +export const MAX_VERSIONS = 20;
19 +
20 +function fileNode(id) {
21 + const node = getDb().prepare("SELECT * FROM nodes WHERE id = ? AND type = 'file'").get(id);
22 + if (!node) throw Object.assign(new Error('No such file'), { statusCode: 404 });
23 + return node;
24 +}
25 +
26 +/** Version history for a node, newest first. */
27 +export function listVersions(nodeId) {
28 + return getDb()
29 + .prepare('SELECT * FROM node_versions WHERE node_id = ? ORDER BY replaced_at DESC, id DESC')
30 + .all(nodeId);
31 +}
32 +
33 +export function getVersion(nodeId, versionId) {
34 + return getDb()
35 + .prepare('SELECT * FROM node_versions WHERE id = ? AND node_id = ?')
36 + .get(versionId, nodeId) ?? null;
37 +}
38 +
39 +/**
40 + * Snapshot a node's CURRENT blob into its history (called just before the
41 + * blob is swapped). Takes its own ref on the blob and prunes old versions.
42 + */
43 +function pushVersion(node, origin) {
44 + if (!node.blob_sha) return;
45 + const db = getDb();
46 + db.prepare(
47 + `INSERT INTO node_versions (node_id, blob_sha, size, mime, created, replaced_at, origin)
48 + VALUES (?, ?, ?, ?, ?, ?, ?)`,
49 + ).run(node.id, node.blob_sha, node.size, node.mime, node.modified, Date.now(), origin);
50 + refBlob(node.blob_sha);
51 + // Prune beyond the cap, oldest first.
52 + const excess = db
53 + .prepare(
54 + `SELECT id, blob_sha FROM node_versions WHERE node_id = ?
55 + ORDER BY replaced_at DESC, id DESC LIMIT -1 OFFSET ?`,
56 + )
57 + .all(node.id, MAX_VERSIONS);
58 + for (const row of excess) deleteVersionRow(row);
59 +}
60 +
61 +function deleteVersionRow(row) {
62 + unrefBlob(row.blob_sha);
63 + getDb().prepare('DELETE FROM node_versions WHERE id = ?').run(row.id);
64 +}
65 +
66 +/**
67 + * Swap a file node's content to a new stored blob, archiving the current one.
68 + * The caller must have already put the blob in the CAS (no ref taken yet).
69 + * @param {'replace'|'edit'|'restore'} origin
70 + * @returns {object} the updated node row
71 + */
72 +export function setNodeBlob(nodeId, { sha, size, mime, origin = 'replace', ip = '' }) {
73 + const node = fileNode(nodeId);
74 + const now = Date.now();
75 + const db = getDb();
76 + if (node.blob_sha === sha) {
77 + db.prepare('UPDATE nodes SET modified = ? WHERE id = ?').run(now, nodeId);
78 + return fileNode(nodeId);
79 + }
80 + pushVersion(node, origin);
81 + refBlob(sha);
82 + unrefBlob(node.blob_sha);
83 + db.prepare('UPDATE nodes SET blob_sha = ?, size = ?, mime = ?, modified = ? WHERE id = ?')
84 + .run(sha, size, mime ?? node.mime, now, nodeId);
85 + logActivity(`file.${origin}`, { nodeId, detail: node.name, ip });
86 + return fileNode(nodeId);
87 +}
88 +
89 +/**
90 + * Make an old version current again. The current content is archived
91 + * (origin 'restore') and the version row stays in the history.
92 + */
93 +export function restoreVersion(nodeId, versionId, { ip = '' } = {}) {
94 + const version = getVersion(nodeId, versionId);
95 + if (!version) throw Object.assign(new Error('No such version'), { statusCode: 404 });
96 + return setNodeBlob(nodeId, {
97 + sha: version.blob_sha, size: version.size, mime: version.mime, origin: 'restore', ip,
98 + });
99 +}
100 +
101 +/** Delete one version from history (releases its blob ref). */
102 +export function deleteVersion(nodeId, versionId) {
103 + const version = getVersion(nodeId, versionId);
104 + if (!version) return false;
105 + deleteVersionRow(version);
106 + return true;
107 +}
108 +
109 +/** Release every version blob for a node (used by hard delete). */
110 +export function dropVersionsForNode(nodeId) {
111 + for (const row of listVersions(nodeId)) deleteVersionRow(row);
112 +}
modified src/web/assets/app.css +58 −0
@@ -542,6 +542,63 @@ video.pv-video { max-width: 100%; max-height: 100%; border-radius: var(--radius)
542 542 .shares-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
543 543 .shares-table .url-cell { display: flex; gap: 6px; align-items: center; font: 11.5px var(--font-mono); }
544 544
545 +/* ── Text editor overlay ────────────────────────────────────── */
546 +.editor-overlay { background: var(--bg); }
547 +.editor-body { flex: 1; min-height: 0; display: flex; padding: 0 18px 18px; }
548 +.editor-text {
549 + flex: 1; resize: none; border-radius: var(--radius);
550 + background: var(--surface); border: 1px solid var(--border);
551 + font: 13px/1.65 var(--font-mono); padding: 18px 22px; tab-size: 4;
552 +}
553 +.editor-text:focus { border-color: var(--accent); outline: none; }
554 +.editor-overlay .preview-head { color: var(--text); }
555 +.editor-overlay .preview-head .size { color: var(--muted); }
556 +.editor-overlay .preview-head .btn { background: var(--surface-2); border-color: var(--border); color: var(--text); }
557 +.editor-overlay .preview-head .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
558 +.editor-dirty { color: var(--warning); font-size: 12px; white-space: nowrap; }
559 +.editor-status { color: var(--muted); font-size: 12px; align-self: center; white-space: nowrap; }
560 +
561 +/* ── Storage insights page ──────────────────────────────────── */
562 +.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 18px; }
563 +.stat-tile { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
564 +.stat-tile .v { font-size: 21px; font-weight: 700; font-variant-numeric: tabular-nums; }
565 +.stat-tile .l { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 2px; }
566 +.stat-tile .s { font-size: 11.5px; color: var(--muted); margin-top: 5px; }
567 +.storage-page { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
568 +.storage-page .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
569 +.storage-page .panel h3 { margin: 0 0 12px; font-size: 14.5px; }
570 +.big-file { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-radius: var(--radius-sm); cursor: pointer; }
571 +.big-file:hover { background: var(--surface-2); }
572 +.big-file svg { width: 16px; height: 16px; flex: none; }
573 +.big-file .bf-main { flex: 1; min-width: 0; }
574 +.big-file .bf-name { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
575 +.big-file .bf-path { color: var(--muted); font-size: 11px; }
576 +.big-file .bf-bar { height: 4px; border-radius: 2px; background: var(--surface-2); overflow: hidden; margin-top: 3px; }
577 +.big-file .bf-bar i { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
578 +.big-file .bf-size { font: 11.5px var(--font-mono); color: var(--muted); white-space: nowrap; }
579 +.dup-group { border-top: 1px solid var(--border); padding: 9px 0; }
580 +.dup-group:first-of-type { border-top: none; }
581 +.dup-head { font-size: 12.5px; margin-bottom: 5px; }
582 +.dup-row { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 0; }
583 +.dup-row .crumb { flex: 1; min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); font-size: 11.5px; }
584 +
585 +/* ── Public file-request page ───────────────────────────────── */
586 +.request-main { max-width: 620px; justify-content: center; }
587 +.request-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; }
588 +.request-card h1 { font-size: 19px; margin: 0 0 6px; }
589 +.request-label { color: var(--accent); font-size: 14px; margin: 0 0 6px; }
590 +.request-sub { color: var(--muted); font-size: 13px; margin: 0 0 20px; }
591 +.request-drop {
592 + border: 2px dashed var(--border); border-radius: var(--radius); padding: 42px 20px;
593 + display: flex; flex-direction: column; align-items: center; gap: 12px;
594 + color: var(--muted); cursor: pointer; text-align: center;
595 + transition: border-color 150ms var(--ease), background 150ms var(--ease);
596 +}
597 +.request-drop:hover, .request-drop.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
598 +.request-drop svg { width: 38px; height: 38px; }
599 +.request-card .upload-list { margin-top: 14px; max-height: 320px; }
600 +.request-card .upload-item { border-top: 1px solid var(--border); padding: 9px 4px; }
601 +
545 602 /* ── Responsive ─────────────────────────────────────────────── */
546 603 .sidebar-toggle { display: none; }
547 604 @media (max-width: 900px) {
@@ -562,6 +619,7 @@ video.pv-video { max-width: 100%; max-height: 100%; border-radius: var(--radius)
562 619 .list-header > :nth-child(n+4), .row > :nth-child(n+4) { display: none; }
563 620 .upload-panel { width: calc(100vw - 24px); right: 12px; }
564 621 .pdf-rail { display: none; }
622 + .storage-page { grid-template-columns: 1fr; }
565 623 }
566 624
567 625 @media print {
modified src/web/assets/js/app.js +327 −2
@@ -18,6 +18,7 @@ import {
18 18 import { UI, nodeIcon, folderIcon, fileIcon, EMPTY_ART } from './icons.js';
19 19 import { UploadManager, collectDropped, bindPasteUpload } from './upload.js';
20 20 import { Viewer } from './viewer.js';
21 +import { Editor, isEditable } from './editor.js';
21 22
22 23 const FOLDER_COLORS = ['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'purple', 'pink'];
23 24
@@ -44,8 +45,40 @@ const content = $('content');
44 45 const toolbar = $('toolbar');
45 46
46 47 // ── Boot ─────────────────────────────────────────────────────────────
48 +// One conflict answer can apply to the whole upload batch.
49 +let batchConflictChoice = null;
50 +let conflictQueue = Promise.resolve();
51 +
52 +function askUploadConflict(item, existing) {
53 + const run = async () => {
54 + if (batchConflictChoice) return batchConflictChoice;
55 + return new Promise((resolve) => {
56 + const applyAll = h('input', { type: 'checkbox' });
57 + modal({
58 + title: 'File already exists',
59 + body: h('div', {},
60 + h('p', { style: { color: 'var(--muted)', margin: '0 0 10px' } },
61 + `"${existing.name}" (${fmtSize(existing.size)}) is already in this folder.`),
62 + h('p', { style: { fontSize: '12.5px', margin: '0 0 10px' } },
63 + 'Replace keeps the old content in the file’s version history.'),
64 + h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
65 + applyAll, 'Apply to all remaining conflicts')),
66 + onClose: () => resolve('skip'),
67 + actions: [
68 + { label: 'Skip', onClick: () => { if (applyAll.checked) batchConflictChoice = 'skip'; resolve('skip'); } },
69 + { label: 'Replace', danger: true, onClick: () => { if (applyAll.checked) batchConflictChoice = 'replace'; resolve('replace'); } },
70 + { label: 'Keep both', primary: true, onClick: () => { if (applyAll.checked) batchConflictChoice = 'keep-both'; resolve('keep-both'); } },
71 + ],
72 + });
73 + });
74 + };
75 + conflictQueue = conflictQueue.then(run, run);
76 + return conflictQueue;
77 +}
78 +
47 79 const uploads = new UploadManager({
48 onFinished: () => { refreshCurrent(); refreshSidebar(); },
80 + onFinished: () => { batchConflictChoice = null; refreshCurrent(); refreshSidebar(); },
81 + resolveConflict: askUploadConflict,
49 82 });
50 83
51 84 async function boot() {
@@ -82,6 +115,8 @@ function onRoute() {
82 115 tag: () => loadTag(Number(state.route.id)),
83 116 search: () => loadSearch(decodeURIComponent(String(state.route.id ?? ''))),
84 117 activity: loadActivity,
118 + requests: loadRequests,
119 + storage: loadStorage,
85 120 settings: loadSettings,
86 121 };
87 122 (views[state.route.view] ?? views.folder)();
@@ -106,9 +141,12 @@ function wireChrome() {
106 141 const r = e.currentTarget.getBoundingClientRect();
107 142 contextMenu(r.left, r.bottom + 4, [
108 143 { label: 'New folder', icon: UI.folderNew, kbd: 'N', onClick: newFolderDialog },
144 + { label: 'New text file', icon: UI.edit, onClick: newTextFileDialog },
109 145 { sep: true },
110 146 { label: 'Upload files', icon: UI.upload, onClick: () => pickFiles() },
111 147 { label: 'Upload folder', icon: UI.move, onClick: () => $('folderPick').click() },
148 + { sep: true },
149 + { label: 'Request files from someone…', icon: UI.inbox, onClick: () => newRequestDialog(currentFolderId()) },
112 150 ]);
113 151 };
114 152
@@ -194,6 +232,8 @@ async function refreshSidebar() {
194 232 ['Recent', UI.clock, 'recent'],
195 233 ['Starred', UI.starO, 'starred'],
196 234 ['Shared', UI.link, 'shared'],
235 + ['File requests', UI.inbox, 'requests'],
236 + ['Storage', UI.storage, 'storage'],
197 237 ['Activity', UI.activity, 'activity'],
198 238 ['Trash', UI.trash, 'trash'],
199 239 ];
@@ -670,6 +710,10 @@ function openViewer(node) {
670 710 dlUrl: (n) => `/dl/${n.id}`,
671 711 actions: [
672 712 { title: 'Download', icon: UI.download, onClick: (n) => { location.href = `/dl/${n.id}`; } },
713 + {
714 + title: 'Edit', icon: UI.edit, visible: (n) => isEditable(n),
715 + onClick: (n, v) => { v.close(); openEditor(n); },
716 + },
673 717 { title: 'Share', icon: UI.share, onClick: (n) => shareDialog(n) },
674 718 {
675 719 title: 'Star', icon: UI.starO,
@@ -685,6 +729,12 @@ function openViewer(node) {
685 729 return viewer;
686 730 }
687 731
732 +function openEditor(node) {
733 + return new Editor(node, {
734 + onSaved: () => { refreshCurrent(); },
735 + });
736 +}
737 +
688 738 // ── Context menu ─────────────────────────────────────────────────────
689 739 function nodeContextMenu(e, node) {
690 740 const multi = state.selection.size > 1;
@@ -715,6 +765,7 @@ function nodeContextMenu(e, node) {
715 765
716 766 contextMenu(e.clientX, e.clientY, [
717 767 !multi && { label: node.type === 'folder' ? 'Open' : 'Preview', icon: UI.eye, kbd: '↵', onClick: () => openNode(node) },
768 + !multi && isEditable(node) && { label: 'Edit', icon: UI.edit, kbd: 'E', onClick: () => openEditor(node) },
718 769 !multi && node.type === 'file' && { label: 'Download', icon: UI.download, onClick: () => { location.href = `/dl/${node.id}`; } },
719 770 multi && { label: `Download ${ids.length} as ZIP`, icon: UI.download, onClick: () => downloadIds(ids) },
720 771 { label: 'Share', icon: UI.share, onClick: () => shareDialog(node) },
@@ -722,6 +773,8 @@ function nodeContextMenu(e, node) {
722 773 !multi && { label: 'Rename', icon: UI.rename, kbd: 'F2', onClick: () => inlineRename(node) },
723 774 { label: 'Move to…', icon: UI.move, onClick: () => moveDialog(ids) },
724 775 !multi && { label: 'Duplicate', icon: UI.duplicate, onClick: () => duplicateNode(node) },
776 + !multi && node.type === 'file' && { label: 'Version history', icon: UI.history, onClick: () => versionsDialog(node) },
777 + !multi && node.type === 'folder' && { label: 'Request files here…', icon: UI.inbox, onClick: () => newRequestDialog(node.id) },
725 778 { label: node.starred && !multi ? 'Unstar' : 'Star', icon: UI.starO, kbd: 'S', onClick: () => Promise.all(ids.map((id) => toggleStar(nodeById(id) ?? node))).then(refreshCurrent) },
726 779 { label: 'Tags…', icon: UI.tag, onClick: () => tagDialog(node) },
727 780 !multi && { label: 'Details', icon: UI.info, onClick: () => showInfo(node, true) },
@@ -771,6 +824,31 @@ async function newFolderDialog() {
771 824 });
772 825 }
773 826
827 +async function newTextFileDialog() {
828 + const stamp = new Date().toISOString().slice(0, 10);
829 + const input = h('input', { type: 'text', value: `notes-${stamp}.md` });
830 + modal({
831 + title: 'New text file',
832 + body: h('div', {}, input),
833 + actions: [
834 + { label: 'Cancel', onClick: () => {} },
835 + {
836 + label: 'Create & edit', primary: true,
837 + onClick: async () => {
838 + const name = input.value.trim();
839 + if (!name) return false;
840 + const { node } = await post('/api/v1/files', { parentId: currentFolderId(), name, content: '' });
841 + refreshCurrent();
842 + openEditor(node);
843 + return true;
844 + },
845 + },
846 + ],
847 + });
848 + const dot = input.value.lastIndexOf('.');
849 + input.setSelectionRange(0, dot > 0 ? dot : input.value.length);
850 +}
851 +
774 852 async function toggleStar(node) {
775 853 const next = !node.starred;
776 854 node.starred = next; // optimistic
@@ -1063,6 +1141,70 @@ async function updateInfoPanel() {
1063 1141 h('span', { html: UI.share, style: { display: 'contents' } }), 'New share link'));
1064 1142 panel.append(h('div.info-section', {}, h('h4', {}, `Shares (${shares.length})`), wrap));
1065 1143 } catch {}
1144 +
1145 + // Version history section (files only)
1146 + if (node.type === 'file') {
1147 + try {
1148 + const { versions } = await getJSON(`/api/v1/nodes/${node.id}/versions`);
1149 + const wrap = h('div');
1150 + if (!versions.length) {
1151 + wrap.append(h('p.muted', { style: { fontSize: '12px', margin: 0 } },
1152 + 'No previous versions yet — replacing or editing this file keeps its history here.'));
1153 + }
1154 + for (const v of versions.slice(0, 5)) wrap.append(versionRow(node, v, () => updateInfoPanel()));
1155 + if (versions.length > 5) {
1156 + wrap.append(h('button.btn', { style: { marginTop: '6px' }, onclick: () => versionsDialog(node) },
1157 + `All ${versions.length} versions…`));
1158 + }
1159 + panel.append(h('div.info-section', {}, h('h4', {}, `Versions (${versions.length})`), wrap));
1160 + } catch {}
1161 + }
1162 +}
1163 +
1164 +// ── Version history ──────────────────────────────────────────────────
1165 +function versionRow(node, v, onChange) {
1166 + return h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '6px', fontSize: '12px' } },
1167 + h('div', { style: { flex: 1, minWidth: 0 } },
1168 + h('div', {}, `${fmtDate(v.replacedAt)} · ${fmtSize(v.size)}`),
1169 + h('div', { style: { color: 'var(--muted)', fontSize: '11px' } }, ({
1170 + replace: 'replaced by upload', edit: 'edited in browser', restore: 'superseded by restore',
1171 + })[v.origin] ?? v.origin)),
1172 + h('a.btn.icon.ghost', { html: UI.download, title: 'Download this version', href: `/api/v1/nodes/${node.id}/versions/${v.id}/dl` }),
1173 + h('button.btn.icon.ghost', {
1174 + html: UI.restore, title: 'Restore this version',
1175 + onclick: async () => {
1176 + await post(`/api/v1/nodes/${node.id}/versions/${v.id}/restore`, {});
1177 + toast('Version restored — current content kept in history');
1178 + refreshCurrent();
1179 + onChange?.();
1180 + },
1181 + }),
1182 + h('button.btn.icon.ghost.danger', {
1183 + html: UI.close, title: 'Delete this version',
1184 + onclick: async () => {
1185 + await del(`/api/v1/nodes/${node.id}/versions/${v.id}`);
1186 + toast('Version deleted');
1187 + onChange?.();
1188 + },
1189 + }));
1190 +}
1191 +
1192 +async function versionsDialog(node) {
1193 + const { versions } = await getJSON(`/api/v1/nodes/${node.id}/versions`);
1194 + const list = h('div', { style: { maxHeight: '55vh', overflow: 'auto' } });
1195 + const rerender = async () => {
1196 + const fresh = await getJSON(`/api/v1/nodes/${node.id}/versions`);
1197 + list.innerHTML = '';
1198 + if (!fresh.versions.length) list.append(h('p.muted', {}, 'No previous versions.'));
1199 + for (const v of fresh.versions) list.append(versionRow(node, v, rerender));
1200 + };
1201 + if (!versions.length) list.append(h('p.muted', {}, 'No previous versions.'));
1202 + for (const v of versions) list.append(versionRow(node, v, rerender));
1203 + modal({
1204 + title: `Version history — ${node.name}`,
1205 + body: list,
1206 + actions: [{ label: 'Close', primary: true, onClick: () => {} }],
1207 + });
1066 1208 }
1067 1209
1068 1210 // ── Share dialog & manager ───────────────────────────────────────────
@@ -1188,6 +1330,186 @@ async function showShareEvents(share) {
1188 1330 modal({ title: `Visits — /s/${share.token}`, body: list, wide: true, actions: [{ label: 'Close', primary: true, onClick: () => {} }] });
1189 1331 }
1190 1332
1333 +// ── File requests (receive files) ────────────────────────────────────
1334 +function folderPickerTree(onPick, preselect) {
1335 + const tree = h('div.picker-tree');
1336 + const byParent = new Map();
1337 + for (const f of state.tree) {
1338 + if (!byParent.has(f.parent_id)) byParent.set(f.parent_id, []);
1339 + byParent.get(f.parent_id).push(f);
1340 + }
1341 + const build = (parentId, depth) => {
1342 + for (const folder of byParent.get(parentId) ?? []) {
1343 + const row = h(`button.nav-item${folder.id === preselect ? '.active' : ''}`, {
1344 + style: { paddingLeft: `${12 + depth * 16}px` },
1345 + onclick: (e) => {
1346 + tree.querySelectorAll('.active').forEach((n) => n.classList.remove('active'));
1347 + e.currentTarget.classList.add('active');
1348 + onPick(folder.id);
1349 + },
1350 + },
1351 + h('span', { html: folderIcon(folder), style: { display: 'contents' } }),
1352 + folder.id === state.rootId ? 'My Drive' : folder.name);
1353 + tree.append(row);
1354 + build(folder.id, depth + 1);
1355 + }
1356 + };
1357 + build(null, 0);
1358 + return tree;
1359 +}
1360 +
1361 +function newRequestDialog(folderId = state.rootId) {
1362 + let chosen = folderId;
1363 + const tree = folderPickerTree((id) => { chosen = id; }, folderId);
1364 + const label = h('input', { type: 'text', placeholder: 'What are you asking for? (shown to the sender)' });
1365 + const expiry = h('select', {},
1366 + ...[['', 'Never'], ['86400000', '1 day'], ['604800000', '7 days'], ['2592000000', '30 days']]
1367 + .map(([v, l]) => h('option', { value: v, selected: v === '604800000' }, l)));
1368 + const maxFiles = h('input', { type: 'number', min: 1, placeholder: 'Unlimited' });
1369 + const result = h('div');
1370 + modal({
1371 + title: 'Request files from someone',
1372 + body: h('div', {},
1373 + h('p', { style: { color: 'var(--muted)', fontSize: '12.5px', margin: '0 0 12px' } },
1374 + 'Anyone with the link can upload into the chosen folder — they never see its contents.'),
1375 + h('label.field', {}, h('span', {}, 'Destination folder'), tree),
1376 + h('label.field', {}, h('span', {}, 'Note to sender'), label),
1377 + h('label.field', {}, h('span', {}, 'Expires'), expiry),
1378 + h('label.field', {}, h('span', {}, 'Max files'), maxFiles),
1379 + result),
1380 + actions: [
1381 + { label: 'Close', onClick: () => {} },
1382 + {
1383 + label: 'Create link', primary: true,
1384 + onClick: async () => {
1385 + const { request } = await post('/api/v1/requests', {
1386 + folderId: chosen,
1387 + label: label.value || null,
1388 + expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,
1389 + maxFiles: maxFiles.value ? Number(maxFiles.value) : null,
1390 + });
1391 + result.innerHTML = '';
1392 + result.append(
1393 + h('div.share-url', {},
1394 + h('input', { type: 'text', value: request.url, readonly: true, onclick: (e) => e.target.select() }),
1395 + h('button.btn.primary', { onclick: () => copyText(request.url, 'Request URL copied') }, 'Copy')),
1396 + h('div.share-qr', {}, h('img', { src: `/api/v1/requests/${request.id}/qr`, width: 160, height: 160, alt: 'QR code' })),
1397 + );
1398 + if (state.route.view === 'requests') loadRequests();
1399 + return false; // keep open to show the URL
1400 + },
1401 + },
1402 + ],
1403 + });
1404 +}
1405 +
1406 +async function loadRequests() {
1407 + const { requests } = await getJSON('/api/v1/requests');
1408 + state.nodes = [];
1409 + toolbar.innerHTML = '';
1410 + toolbar.append(
1411 + h('div.crumbs', {}, h('span.crumb.current', {}, `File requests (${requests.filter((r) => !r.closedAt).length} active)`)),
1412 + h('button.btn.primary', { onclick: () => newRequestDialog(state.rootId) }, '+ New request'),
1413 + );
1414 + content.innerHTML = '';
1415 + if (!requests.length) {
1416 + content.append(emptyState('search', 'No file requests yet', 'Create a link that lets someone send files straight into a folder'));
1417 + return;
1418 + }
1419 + const table = h('table.shares-table', {},
1420 + h('thead', {}, h('tr', {},
1421 + ...['Folder', 'Link', 'Note', 'Received', 'Expires', 'Status', ''].map((c) => h('th', {}, c)))));
1422 + const tbody = h('tbody');
1423 + for (const r of requests) {
1424 + const dead = r.closedAt || (r.expiresAt && r.expiresAt < Date.now())
1425 + || (r.maxFiles && r.received >= r.maxFiles);
1426 + tbody.append(h('tr', { style: dead ? { opacity: 0.55 } : {} },
1427 + h('td', {}, h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
1428 + h('span', { html: folderIcon({}), style: { display: 'contents' } }),
1429 + h('button.crumb', { onclick: () => go(`folder/${r.folderId}`) }, r.folderName || 'My Drive'))),
1430 + h('td', {}, h('div.url-cell', {},
1431 + h('span', {}, `/r/${r.token}`),
1432 + h('button.btn.icon.ghost', { html: UI.copy, title: 'Copy', onclick: () => copyText(r.url, 'URL copied') }),
1433 + h('button.btn.icon.ghost', {
1434 + html: UI.qr, title: 'QR code',
1435 + onclick: () => modal({
1436 + title: 'QR code',
1437 + body: h('div.share-qr', {}, h('img', { src: `/api/v1/requests/${r.id}/qr`, width: 220, height: 220, alt: 'QR code' }),
1438 + h('p.mono', { style: { fontSize: '11px', color: 'var(--muted)' } }, r.url)),
1439 + actions: [{ label: 'Close', primary: true, onClick: () => {} }],
1440 + }),
1441 + }))),
1442 + h('td', {}, r.label ?? '—'),
1443 + h('td', {}, `${r.received}${r.maxFiles ? ` / ${r.maxFiles}` : ''}`),
1444 + h('td', {}, r.closedAt ? 'closed' : !r.expiresAt ? 'never' : r.expiresAt < Date.now() ? 'expired' : countdown(r.expiresAt)),
1445 + h('td', {}, h('span.chip', { style: dead ? { color: 'var(--danger)', borderColor: 'var(--danger)' } : { color: 'var(--accent-2)', borderColor: 'var(--accent-2)' } },
1446 + dead ? 'inactive' : 'active')),
1447 + h('td', {}, !r.closedAt ? h('button.btn.icon.ghost.danger', {
1448 + html: UI.close, title: 'Close link',
1449 + onclick: async () => { await del(`/api/v1/requests/${r.id}`); toast('Request closed'); loadRequests(); },
1450 + }) : ''),
1451 + ));
1452 + }
1453 + table.append(tbody);
1454 + content.append(table);
1455 +}
1456 +
1457 +// ── Storage insights ─────────────────────────────────────────────────
1458 +async function loadStorage() {
1459 + toolbar.innerHTML = '';
1460 + toolbar.append(h('div.crumbs', {}, h('span.crumb.current', {}, 'Storage')));
1461 + content.innerHTML = '<div class="grid">' + '<div class="skeleton"></div>'.repeat(3) + '</div>';
1462 + const s = await getJSON('/api/v1/stats/detailed');
1463 + content.innerHTML = '';
1464 +
1465 + const stat = (label, value, sub) => h('div.stat-tile', {},
1466 + h('div.v', {}, value), h('div.l', {}, label), sub ? h('div.s', {}, sub) : '');
1467 + content.append(h('div.stat-row', {},
1468 + stat('Used space', fmtSize(s.usedBytes), `${s.nodeCount} items · ${s.blobCount} unique blobs`),
1469 + stat('Saved by dedup', fmtSize(s.dedupSavedBytes), 'identical content stored once'),
1470 + stat('Version history', fmtSize(s.versionBytes), `${s.versionCount} kept version(s)`),
1471 + stat('In trash', fmtSize(s.trashBytes), `${s.trashCount} file(s) — auto-purged after 30 days`),
1472 + ));
1473 +
1474 + // Largest files
1475 + const largestWrap = h('div.panel', {}, h('h3', {}, 'Largest files'));
1476 + const maxSize = s.largest[0]?.size || 1;
1477 + for (const f of s.largest) {
1478 + largestWrap.append(h('div.big-file', {
1479 + onclick: () => go(`folder/${f.parentId}`), title: `Open ${f.path}`,
1480 + },
1481 + h('span', { html: nodeIcon(f), style: { display: 'contents' } }),
1482 + h('div.bf-main', {},
1483 + h('div.bf-name', {}, f.name, h('span.bf-path', {}, ` ${f.path.slice(0, f.path.lastIndexOf('/') + 1)}`)),
1484 + h('div.bf-bar', {}, h('i', { style: { width: `${(f.size / maxSize) * 100}%` } }))),
1485 + h('span.bf-size', {}, fmtSize(f.size))));
1486 + }
1487 + if (!s.largest.length) largestWrap.append(h('p.muted', {}, 'No files yet.'));
1488 +
1489 + // Duplicates
1490 + const dupWrap = h('div.panel', {}, h('h3', {}, 'Duplicate files'),
1491 + h('p.muted', { style: { fontSize: '12.5px', margin: '0 0 10px' } },
1492 + 'Copies share one blob on disk (no wasted space) — listed here so you can tidy up.'));
1493 + if (!s.duplicates.length) dupWrap.append(h('p.muted', {}, 'No duplicates — nice and tidy.'));
1494 + for (const g of s.duplicates) {
1495 + const group = h('div.dup-group', {},
1496 + h('div.dup-head', {},
1497 + h('strong', {}, `${g.copies} copies`), ` · ${fmtSize(g.size)} each`,
1498 + h('span.mono', { style: { color: 'var(--muted)', fontSize: '10.5px', marginLeft: '8px' } }, g.sha.slice(0, 12))));
1499 + for (const n of g.nodes) {
1500 + group.append(h('div.dup-row', {},
1501 + h('button.crumb', { onclick: () => go(`folder/${n.parentId}`) }, n.path),
1502 + h('button.btn.icon.ghost.danger', {
1503 + html: UI.trash, title: 'Move this copy to trash',
1504 + onclick: async () => { await del(`/api/v1/nodes/${n.id}`); toast('Moved to trash'); loadStorage(); },
1505 + })));
1506 + }
1507 + dupWrap.append(group);
1508 + }
1509 +
1510 + content.append(h('div.storage-page', {}, largestWrap, dupWrap));
1511 +}
1512 +
1191 1513 // ── Activity ─────────────────────────────────────────────────────────
1192 1514 const ACT_ICONS = {
1193 1515 'file.upload': UI.upload, 'file.download_zip': UI.download, 'folder.create': UI.folderNew,
@@ -1195,6 +1517,8 @@ const ACT_ICONS = {
1195 1517 'node.restore': UI.restore, 'node.delete_forever': UI.trash, 'node.star': UI.starO,
1196 1518 'share.create': UI.share, 'share.visit': UI.eye, 'share.download': UI.download,
1197 1519 'share.revoke': UI.close, 'auth.login': UI.check, 'auth.login_failed': UI.close,
1520 + 'file.edit': UI.edit, 'file.replace': UI.upload, 'file.restore': UI.history,
1521 + 'request.create': UI.inbox, 'request.upload': UI.inbox, 'request.close': UI.close,
1198 1522 };
1199 1523
1200 1524 async function loadActivity() {
@@ -1372,6 +1696,7 @@ function onGlobalKey(e) {
1372 1696 return;
1373 1697 }
1374 1698 if (e.key.toLowerCase() === 's' && node && !e.metaKey && !e.ctrlKey) { toggleStar(node); return; }
1699 + if (e.key.toLowerCase() === 'e' && node && !e.metaKey && !e.ctrlKey && isEditable(node)) { openEditor(node); return; }
1375 1700 if (e.key.toLowerCase() === 'a' && (e.metaKey || e.ctrlKey)) {
1376 1701 e.preventDefault();
1377 1702 state.selection = new Set(state.nodes.map((n) => n.id));
@@ -1407,7 +1732,7 @@ function showShortcuts() {
1407 1732 ['Navigate', '← → ↑ ↓'], ['Open / enter folder', '↵'], ['Quick look', 'Space'],
1408 1733 ['Rename', 'F2'], ['Move to trash', 'Del'], ['Select all', '⌘A'],
1409 1734 ['Extend selection', 'Shift+click'], ['Toggle item', '⌘+click'],
1410 ['Star', 'S'], ['Toggle view', 'V'], ['New folder', 'N'],
1735 + ['Star', 'S'], ['Edit text file', 'E'], ['Toggle view', 'V'], ['New folder', 'N'],
1411 1736 ['Search', '/'], ['This cheat sheet', '?'], ['Close / cancel', 'Esc'],
1412 1737 ];
1413 1738 modal({
added src/web/assets/js/editor.js +138 −0
@@ -0,0 +1,138 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/editor.js
8 + * Purpose : In-browser text editor overlay — edit code/markdown/text files,
9 + * ⌘S saves as a new version
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +import { h, fmtSize, toast } from './ui.js';
15 +import { UI } from './icons.js';
16 +
17 +const EDITABLE_STRATEGIES = new Set(['code', 'markdown', 'structured', 'csv']);
18 +const EDIT_SIZE_LIMIT = 4 * 1024 * 1024;
19 +
20 +/** Can this node be opened in the text editor? */
21 +export function isEditable(node) {
22 + return node.type === 'file'
23 + && EDITABLE_STRATEGIES.has(node.strategy)
24 + && node.size <= EDIT_SIZE_LIMIT;
25 +}
26 +
27 +/** Full-screen editor overlay for one file node. */
28 +export class Editor {
29 + /** @param {object} node @param {{onSaved?: (node) => void}} opts */
30 + constructor(node, opts = {}) {
31 + this.node = node;
32 + this.opts = opts;
33 + this.dirty = false;
34 + this.saving = false;
35 + this.build();
36 + this.load();
37 + }
38 +
39 + build() {
40 + this.textarea = h('textarea.editor-text', {
41 + spellcheck: 'false', autocapitalize: 'off', autocomplete: 'off',
42 + placeholder: 'Loading…', disabled: true,
43 + });
44 + this.status = h('span.editor-status', {}, 'Loading…');
45 + this.saveBtn = h('button.btn.primary', {
46 + onclick: () => this.save(),
47 + }, 'Save');
48 + this.overlay = h('div.preview-overlay.editor-overlay', { role: 'dialog', 'aria-label': 'Editor' },
49 + h('div.preview-head', {},
50 + h('span.title', {}, this.node.name),
51 + h('span.size', {}, fmtSize(this.node.size)),
52 + h('span.editor-dirty', { style: { display: 'none' } }, '● unsaved'),
53 + h('div.p-actions', {},
54 + this.status,
55 + this.saveBtn,
56 + h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }))),
57 + h('div.editor-body', {}, this.textarea));
58 +
59 + this.textarea.addEventListener('input', () => this.markDirty(true));
60 + this.textarea.addEventListener('keydown', (e) => {
61 + // Tab inserts a real tab instead of moving focus.
62 + if (e.key === 'Tab') {
63 + e.preventDefault();
64 + const { selectionStart: s, selectionEnd: eEnd, value } = this.textarea;
65 + this.textarea.value = `${value.slice(0, s)}\t${value.slice(eEnd)}`;
66 + this.textarea.selectionStart = this.textarea.selectionEnd = s + 1;
67 + this.markDirty(true);
68 + }
69 + });
70 + this.onKey = (e) => {
71 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
72 + e.preventDefault();
73 + this.save();
74 + return;
75 + }
76 + if (e.key === 'Escape') {
77 + e.stopPropagation();
78 + this.close();
79 + }
80 + };
81 + document.addEventListener('keydown', this.onKey, true);
82 + document.body.append(this.overlay);
83 + }
84 +
85 + async load() {
86 + try {
87 + const res = await fetch(`/api/v1/preview/${this.node.id}/raw`, { credentials: 'same-origin' });
88 + if (!res.ok) throw new Error(`HTTP ${res.status}`);
89 + this.textarea.value = await res.text();
90 + this.textarea.disabled = false;
91 + this.textarea.focus();
92 + this.markDirty(false);
93 + } catch (err) {
94 + this.status.textContent = `Could not load — ${err.message}`;
95 + }
96 + }
97 +
98 + markDirty(dirty) {
99 + this.dirty = dirty;
100 + this.overlay.querySelector('.editor-dirty').style.display = dirty ? '' : 'none';
101 + const lines = (this.textarea.value.match(/\n/g)?.length ?? 0) + 1;
102 + this.status.textContent = `${lines} lines · ${fmtSize(new Blob([this.textarea.value]).size)}`;
103 + }
104 +
105 + async save() {
106 + if (this.saving || this.textarea.disabled) return;
107 + this.saving = true;
108 + this.saveBtn.disabled = true;
109 + this.status.textContent = 'Saving…';
110 + try {
111 + const res = await fetch(`/api/v1/nodes/${this.node.id}/content`, {
112 + method: 'PUT',
113 + headers: { 'content-type': 'text/plain', 'x-spbdrive-csrf': '1' },
114 + body: this.textarea.value,
115 + credentials: 'same-origin',
116 + });
117 + const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
118 + if (!res.ok) throw new Error(data?.error?.message ?? `HTTP ${res.status}`);
119 + this.node = data.node;
120 + this.markDirty(false);
121 + toast('Saved — previous content kept in version history');
122 + this.opts.onSaved?.(data.node);
123 + } catch (err) {
124 + toast(`Save failed — ${err.message}`, { error: true });
125 + this.status.textContent = 'Save failed';
126 + } finally {
127 + this.saving = false;
128 + this.saveBtn.disabled = false;
129 + }
130 + }
131 +
132 + close() {
133 + if (this.dirty && !window.confirm('Discard unsaved changes?')) return;
134 + document.removeEventListener('keydown', this.onKey, true);
135 + this.overlay.remove();
136 + this.opts.onClose?.();
137 + }
138 +}
modified src/web/assets/js/icons.js +4 −0
@@ -89,6 +89,10 @@ export const UI = {
89 89 expand: `<svg viewBox="0 0 24 24" ${S}><path d="M4 9V4h5m11 5V4h-5M4 15v5h5m11-5v5h-5"/></svg>`,
90 90 folderNew: `<svg viewBox="0 0 24 24" ${S}><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.6l2 2.4h8.4A1.5 1.5 0 0 1 21 8.9V18a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18Z"/><path d="M12 10.5v6m-3-3h6"/></svg>`,
91 91 logout: `<svg viewBox="0 0 24 24" ${S}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4m7 13 5-4-5-4m5 4H9"/></svg>`,
92 + edit: `<svg viewBox="0 0 24 24" ${S}><path d="M12 20h8M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/></svg>`,
93 + history: `<svg viewBox="0 0 24 24" ${S}><path d="M4 10a8 8 0 1 1 2.3 6.3M4 10V5m0 5h5"/><path d="M12 8.5V12l2.5 1.7"/></svg>`,
94 + inbox: `<svg viewBox="0 0 24 24" ${S}><path d="M3 13h5l1.5 2.5h5L16 13h5"/><path d="M5 5h14a1.5 1.5 0 0 1 1.5 1.5V19a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V6.5A1.5 1.5 0 0 1 5 5Z"/><path d="M12 8v3m0 0 2-2m-2 2-2-2"/></svg>`,
95 + storage: `<svg viewBox="0 0 24 24" ${S}><ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/><path d="M4.5 5.5V12c0 1.5 3.4 2.8 7.5 2.8s7.5-1.3 7.5-2.8V5.5"/><path d="M4.5 12v6.5c0 1.5 3.4 2.8 7.5 2.8s7.5-1.3 7.5-2.8V12"/></svg>`,
92 96 };
93 97
94 98 /** Empty-state illustrations (custom minimal SVG art). */
added src/web/assets/js/request-page.js +110 −0
@@ -0,0 +1,110 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/web/assets/js/request-page.js
8 + * Purpose : Public file-request page — chunked uploads from strangers
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { h, fmtSize, toast } from './ui.js';
14 +
15 +const page = document.querySelector('.share-page');
16 +const token = page.dataset.token;
17 +const maxFiles = page.dataset.maxFiles ? Number(page.dataset.maxFiles) : null;
18 +let uploadedCount = Number(page.dataset.received || 0);
19 +
20 +const drop = document.getElementById('requestDrop');
21 +const list = document.getElementById('requestList');
22 +const pick = document.getElementById('requestPick');
23 +
24 +const base = `/r/${encodeURIComponent(token)}`;
25 +
26 +async function jsonOrThrow(res) {
27 + const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
28 + if (!res.ok) throw new Error(data?.error?.message ?? `HTTP ${res.status}`);
29 + return data;
30 +}
31 +
32 +async function uploadFile(file) {
33 + if (maxFiles !== null && uploadedCount >= maxFiles) {
34 + toast('This link has reached its file limit', { error: true });
35 + return;
36 + }
37 + const row = h('div.upload-item', {},
38 + h('div.fname', {},
39 + h('span.n', {}, file.name),
40 + h('span.s', {}, 'Starting…'),
41 + h('div.prog', {}, h('i'))));
42 + list.append(row);
43 + const setStatus = (text) => { row.querySelector('.s').textContent = text; };
44 + const setPct = (pct) => { row.querySelector('.prog i').style.width = `${pct}%`; };
45 +
46 + try {
47 + const init = await jsonOrThrow(await fetch(`${base}/upload/init`, {
48 + method: 'POST',
49 + headers: { 'content-type': 'application/json' },
50 + body: JSON.stringify({ name: file.name, size: file.size }),
51 + }));
52 + const { uploadId, chunkSize, nChunks } = init;
53 + for (let n = 0; n < nChunks; n += 1) {
54 + const slice = file.slice(n * chunkSize, Math.min((n + 1) * chunkSize, file.size));
55 + let attempt = 0;
56 + for (;;) {
57 + try {
58 + const res = await fetch(`${base}/upload/${uploadId}/chunk/${n}`, {
59 + method: 'PUT',
60 + headers: { 'content-type': 'application/octet-stream' },
61 + body: slice,
62 + });
63 + if (!res.ok) throw new Error(`HTTP ${res.status}`);
64 + break;
65 + } catch (err) {
66 + attempt += 1;
67 + if (attempt > 3) throw err;
68 + await new Promise((r) => setTimeout(r, 1000 * attempt));
69 + }
70 + }
71 + const sent = Math.min((n + 1) * chunkSize, file.size);
72 + setPct(file.size ? (sent / file.size) * 100 : 100);
73 + setStatus(`${fmtSize(sent)} / ${fmtSize(file.size)}`);
74 + }
75 + const done = await jsonOrThrow(await fetch(`${base}/upload/${uploadId}/complete`, {
76 + method: 'POST',
77 + headers: { 'content-type': 'application/json' },
78 + body: JSON.stringify({ mime: file.type || undefined }),
79 + }));
80 + uploadedCount += 1;
81 + row.classList.add('done');
82 + setPct(100);
83 + setStatus(`Sent · ${done.name}`);
84 + } catch (err) {
85 + row.classList.add('err');
86 + setStatus(`Failed — ${err.message}`);
87 + }
88 +}
89 +
90 +async function handleFiles(files) {
91 + const queue = [...files];
92 + if (!queue.length) return;
93 + if (maxFiles !== null && uploadedCount + queue.length > maxFiles) {
94 + toast(`Only ${Math.max(0, maxFiles - uploadedCount)} more file(s) allowed on this link`, { error: true });
95 + queue.length = Math.max(0, maxFiles - uploadedCount);
96 + }
97 + for (const file of queue) await uploadFile(file);
98 +}
99 +
100 +drop.addEventListener('click', () => pick.click());
101 +drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick.click(); } });
102 +pick.addEventListener('change', (e) => { handleFiles(e.target.files); e.target.value = ''; });
103 +
104 +for (const ev of ['dragenter', 'dragover']) {
105 + drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('active'); });
106 +}
107 +for (const ev of ['dragleave', 'drop']) {
108 + drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('active'); });
109 +}
110 +drop.addEventListener('drop', (e) => handleFiles(e.dataTransfer?.files ?? []));
modified src/web/assets/js/upload.js +20 −2
@@ -51,7 +51,10 @@ export async function collectDropped(dataTransfer) {
51 51 }
52 52
53 53 export class UploadManager {
54 /** @param {{onFinished: (parentId: number) => void}} hooks */
54 + /**
55 + * @param {{onFinished?: (parentId: number) => void,
56 + * resolveConflict?: (item, existing) => Promise<'keep-both'|'replace'|'skip'>}} hooks
57 + */
55 58 constructor(hooks = {}) {
56 59 this.hooks = hooks;
57 60 this.items = [];
@@ -106,6 +109,20 @@ export class UploadManager {
106 109 size: item.file.size,
107 110 });
108 111 item.uploadId = init.uploadId;
112 +
113 + // Same-name file already there? Ask: keep both / replace (new version) / skip.
114 + let conflict = 'keep-both';
115 + if (init.conflictsWith && this.hooks.resolveConflict) {
116 + conflict = await this.hooks.resolveConflict(item, init.conflictsWith) ?? 'keep-both';
117 + if (conflict === 'skip') {
118 + await api(`/api/v1/upload/${item.uploadId}`, { method: 'DELETE' }).catch(() => {});
119 + item.state = 'skipped';
120 + this.updateItem(item);
121 + this.updateHead();
122 + return;
123 + }
124 + }
125 +
109 126 const { chunkSize, nChunks } = init;
110 127 let have = new Set(init.have);
111 128
@@ -141,7 +158,7 @@ export class UploadManager {
141 158 }
142 159
143 160 await post(`/api/v1/upload/${item.uploadId}/complete`, {
144 conflict: 'keep-both',
161 + conflict,
145 162 mime: item.file.type || undefined,
146 163 });
147 164 item.state = 'done';
@@ -231,6 +248,7 @@ export class UploadManager {
231 248 done: `Done · ${fmtSize(item.file.size)}`,
232 249 error: `Failed — ${item.error ?? 'error'}`,
233 250 cancelled: 'Cancelled',
251 + skipped: 'Skipped (already exists)',
234 252 }[item.state];
235 253 item.el.querySelector('.s').textContent = status;
236 254 item.el.querySelector('[title="Retry"]').style.display = item.state === 'error' ? '' : 'none';
modified src/web/assets/js/viewer.js +15 −7
@@ -905,13 +905,7 @@ export class Viewer {
905 905 this.title = h('span.title');
906 906 this.sizeEl = h('span.size');
907 907 const actions = h('div.p-actions');
908 for (const action of this.opts.actions ?? []) {
909 actions.append(h('button.btn.icon', {
910 title: action.title, html: action.icon,
911 onclick: () => action.onClick(this.items[this.index], this),
912 }));
913 }
914 actions.append(h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }));
908 + this.actionsHost = actions;
915 909 this.stage = h('div.preview-stage');
916 910 this.count = h('div.preview-count');
917 911 this.body = h('div.preview-body', {},
@@ -930,9 +924,23 @@ export class Viewer {
930 924 document.body.append(this.overlay);
931 925 }
932 926
927 + /** Rebuild header actions for the current node (honors action.visible). */
928 + renderActions(node) {
929 + this.actionsHost.innerHTML = '';
930 + for (const action of this.opts.actions ?? []) {
931 + if (action.visible && !action.visible(node)) continue;
932 + this.actionsHost.append(h('button.btn.icon', {
933 + title: action.title, html: action.icon,
934 + onclick: () => action.onClick(this.items[this.index], this),
935 + }));
936 + }
937 + this.actionsHost.append(h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }));
938 + }
939 +
933 940 async show() {
934 941 const node = this.items[this.index];
935 942 if (!node) { this.close(); return; }
943 + this.renderActions(node);
936 944 this.title.textContent = node.name;
937 945 this.sizeEl.textContent = fmtSize(node.size);
938 946 this.count.textContent = this.items.length > 1 ? `${this.index + 1} / ${this.items.length}` : '';
modified src/web/routes.mjs +94 −0
@@ -25,6 +25,11 @@ import { childrenOf, getNode, isDescendant, listDescendantFiles, pathOf } from '
25 25 import {
26 26 getShareByToken, recordShareEvent, shareValidity, verifySharePassword,
27 27 } from '../shares/shares.mjs';
28 +import { getRequestByToken, recordReceived, requestValidity } from '../shares/requests.mjs';
29 +import {
30 + abortUpload, chunksPresent, completeUpload, getUpload, initUpload, markUploadSource, writeChunk,
31 +} from '../storage/upload.mjs';
32 +import { extractNodeText } from '../search/extract.mjs';
28 33 import { previewStrategy, thumbKind, iconFamily } from '../preview/router.mjs';
29 34 import {
30 35 describePreview, handleArchive, handleArchiveMember, handleExif, handleFileSend,
@@ -406,6 +411,95 @@ export function registerWebRoutes(app) {
406 411 }
407 412 }
408 413
414 + // ── Public file-request pages: /r/:token receives uploads ──────────
415 + const requestLimiter = makeRateLimiter({ windowMs: 60_000, max: 240 });
416 +
417 + /** Resolve + validate a file request. Replies and returns null when off. */
418 + const requestCtx = (req, reply, { wantPage = false } = {}) => {
419 + if (!requestLimiter(req, reply)) return null;
420 + const request = getRequestByToken(String(req.params.token ?? ''));
421 + const validity = requestValidity(request);
422 + if (!validity.ok) {
423 + if (wantPage) expiredPage(reply, validity.reason);
424 + else reply.code(410).send({ error: { code: 'unavailable', message: 'This link is no longer accepting files' } });
425 + return null;
426 + }
427 + return request;
428 + };
429 +
430 + /** The upload session must belong to THIS request (no cross-tenant reuse). */
431 + const requestUpload = (request, uploadId, reply) => {
432 + const up = getUpload(uploadId);
433 + if (up.source !== `req:${request.id}`) {
434 + reply.code(404).send({ error: { code: 'not_found', message: 'Unknown upload' } });
435 + return null;
436 + }
437 + return up;
438 + };
439 +
440 + app.get('/r/:token', async (req, reply) => {
441 + const request = requestCtx(req, reply, { wantPage: true });
442 + if (!request) return undefined;
443 + pageHeaders(reply);
444 + return reply.send(render('request.njk', {
445 + token: request.token,
446 + label: request.label,
447 + maxFiles: request.max_files,
448 + received: request.received,
449 + expiresAt: request.expires_at,
450 + }));
451 + });
452 +
453 + app.post('/r/:token/upload/init', async (req, reply) => {
454 + const request = requestCtx(req, reply);
455 + if (!request) return undefined;
456 + const { name, size } = req.body ?? {};
457 + const session = await initUpload({
458 + parentId: request.folder_id, name: String(name ?? ''), size,
459 + });
460 + markUploadSource(session.uploadId, `req:${request.id}`);
461 + return session;
462 + });
463 +
464 + app.get('/r/:token/upload/:id', async (req, reply) => {
465 + const request = requestCtx(req, reply);
466 + if (!request) return undefined;
467 + const up = requestUpload(request, req.params.id, reply);
468 + if (!up) return undefined;
469 + return { uploadId: up.id, nChunks: up.n_chunks, chunkSize: up.chunk_size, have: await chunksPresent(up.id) };
470 + });
471 +
472 + app.put('/r/:token/upload/:id/chunk/:n', async (req, reply) => {
473 + const request = requestCtx(req, reply);
474 + if (!request) return undefined;
475 + if (!requestUpload(request, req.params.id, reply)) return undefined;
476 + await writeChunk(req.params.id, req.params.n, req.raw);
477 + return { ok: true };
478 + });
479 +
480 + app.post('/r/:token/upload/:id/complete', async (req, reply) => {
481 + const request = requestCtx(req, reply);
482 + if (!request) return undefined;
483 + if (!requestUpload(request, req.params.id, reply)) return undefined;
484 + const up = getUpload(req.params.id);
485 + const guessedMime = req.body?.mime
486 + || (await import('mime')).default.getType(up.name)
487 + || 'application/octet-stream';
488 + // Never overwrite the owner's files from a public link.
489 + const node = await completeUpload(req.params.id, { conflict: 'keep-both', mime: guessedMime, ip: req.ip });
490 + recordReceived(request.id, { name: node?.name ?? up.name, ip: req.ip });
491 + if (node) extractNodeText(node);
492 + return { ok: true, name: node?.name ?? up.name };
493 + });
494 +
495 + app.delete('/r/:token/upload/:id', async (req, reply) => {
496 + const request = requestCtx(req, reply);
497 + if (!request) return undefined;
498 + if (!requestUpload(request, req.params.id, reply)) return undefined;
499 + await abortUpload(req.params.id);
500 + return { ok: true };
501 + });
502 +
409 503 // ── Error pages ─────────────────────────────────────────────────────
410 504 app.setNotFoundHandler((req, reply) => {
411 505 if (req.url.startsWith('/api/') || req.headers.accept?.includes('application/json')) {
added src/web/views/request.njk +52 −0
@@ -0,0 +1,52 @@
1 +{#
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : src/web/views/request.njk
8 + Purpose : Public file-request page — anyone can drop files for Simon-Pierre
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +#}
12 +{% extends "base.njk" %}
13 +{% block title %}Send files · SPB Drive{% endblock %}
14 +{% block og %}
15 + <meta property="og:site_name" content="SPB Drive">
16 + <meta property="og:title" content="Send files to Simon-Pierre Boucher">
17 + <meta property="og:type" content="website">
18 + <meta property="og:url" content="{{ publicUrl }}/r/{{ token }}">
19 + <meta property="og:description" content="Drop your files here — they go straight to Simon-Pierre's drive.">
20 + <meta name="twitter:card" content="summary">
21 +{% endblock %}
22 +{% block body %}
23 +<div class="share-page"
24 + data-token="{{ token }}"
25 + data-max-files="{{ maxFiles or '' }}"
26 + data-received="{{ received }}">
27 + <header class="share-topbar">
28 + <div class="brand"><div class="mark">SPB</div><span>SPB Drive</span></div>
29 + </header>
30 + <main class="share-main request-main">
31 + <div class="request-card">
32 + <h1>Send files to Simon-Pierre Boucher</h1>
33 + {% if label %}<p class="request-label">“{{ label }}”</p>{% endif %}
34 + <p class="request-sub">
35 + Files land directly in Simon-Pierre's private drive. Nobody else can see them.
36 + {% if maxFiles %}<br>Up to {{ maxFiles }} file(s) accepted on this link.{% endif %}
37 + </p>
38 + <div class="request-drop" id="requestDrop" tabindex="0" role="button" aria-label="Choose files">
39 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 16V4m0 0 5 5m-5-5-5 5"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
40 + <div><strong>Drop files here</strong> or click to browse</div>
41 + </div>
42 + <div class="upload-list" id="requestList"></div>
43 + <input type="file" id="requestPick" multiple hidden>
44 + </div>
45 + </main>
46 + <footer class="share-footer">
47 + SPB Drive · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
48 + </footer>
49 +</div>
50 +<div class="toasts" id="toasts"></div>
51 +<script type="module" src="/assets/js/request-page.js"></script>
52 +{% endblock %}
modified test/e2e.test.mjs +82 −0
@@ -173,6 +173,88 @@ describe('e2e: upload → thumbnail → protected share → download', () => {
173 173 expect(dead).not.toContain('e2e.png');
174 174 });
175 175
176 + it('creates a text file, edits it, and restores the old version', async () => {
177 + // Create
178 + let res = await fetch(`${B}/api/v1/files`, {
179 + method: 'POST', headers: HJ(),
180 + body: JSON.stringify({ parentId: 1, name: 'note.md', content: '# first draft' }),
181 + });
182 + const { node } = await res.json();
183 + expect(res.status).toBe(200);
184 + expect(node.strategy).toBe('markdown');
185 +
186 + // Edit via the editor endpoint
187 + res = await fetch(`${B}/api/v1/nodes/${node.id}/content`, {
188 + method: 'PUT', headers: { ...H(), 'content-type': 'text/plain' },
189 + body: '# second draft — improved',
190 + });
191 + expect(res.status).toBe(200);
192 +
193 + // Old content is a version
194 + res = await fetch(`${B}/api/v1/nodes/${node.id}/versions`, { headers: { cookie } });
195 + const { versions } = await res.json();
196 + expect(versions).toHaveLength(1);
197 + expect(versions[0].origin).toBe('edit');
198 +
199 + // Download the old version bytes
200 + res = await fetch(`${B}/api/v1/nodes/${node.id}/versions/${versions[0].id}/dl`, { headers: { cookie } });
201 + expect(await res.text()).toBe('# first draft');
202 +
203 + // Restore it
204 + res = await fetch(`${B}/api/v1/nodes/${node.id}/versions/${versions[0].id}/restore`, {
205 + method: 'POST', headers: HJ(), body: '{}',
206 + });
207 + expect(res.status).toBe(200);
208 + res = await fetch(`${B}/api/v1/preview/${node.id}/raw`, { headers: { cookie } });
209 + expect(await res.text()).toBe('# first draft');
210 + });
211 +
212 + it('accepts a stranger upload through a file request, then dies on close', async () => {
213 + // Owner creates a folder + request link
214 + let res = await fetch(`${B}/api/v1/nodes`, {
215 + method: 'POST', headers: HJ(),
216 + body: JSON.stringify({ parentId: 1, name: 'Inbox', type: 'folder' }),
217 + });
218 + const folder = (await res.json()).node;
219 + res = await fetch(`${B}/api/v1/requests`, {
220 + method: 'POST', headers: HJ(),
221 + body: JSON.stringify({ folderId: folder.id, label: 'send me stuff', maxFiles: 3 }),
222 + });
223 + const { request } = await res.json();
224 + expect(request.token).toHaveLength(12);
225 +
226 + // A stranger (no cookies at all) uploads through /r/<token>
227 + const payloadBytes = Buffer.from('hello from a stranger');
228 + res = await fetch(`${B}/r/${request.token}/upload/init`, {
229 + method: 'POST', headers: { 'content-type': 'application/json' },
230 + body: JSON.stringify({ name: 'from-stranger.txt', size: payloadBytes.length }),
231 + });
232 + const init = await res.json();
233 + expect(res.status).toBe(200);
234 + res = await fetch(`${B}/r/${request.token}/upload/${init.uploadId}/chunk/0`, {
235 + method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: payloadBytes,
236 + });
237 + expect(res.status).toBe(200);
238 + res = await fetch(`${B}/r/${request.token}/upload/${init.uploadId}/complete`, {
239 + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
240 + });
241 + expect(res.status).toBe(200);
242 +
243 + // The file landed in the owner's folder
244 + res = await fetch(`${B}/api/v1/nodes/${folder.id}/children`, { headers: { cookie } });
245 + const { children } = await res.json();
246 + expect(children.some((c) => c.name === 'from-stranger.txt')).toBe(true);
247 +
248 + // Public upload sessions can't be reused via the private API path check:
249 + // closing the request kills the public page instantly.
250 + const { requests } = await (await fetch(`${B}/api/v1/requests`, { headers: { cookie } })).json();
251 + const mine = requests.find((r) => r.token === request.token);
252 + await fetch(`${B}/api/v1/requests/${mine.id}`, { method: 'DELETE', headers: H() });
253 + res = await fetch(`${B}/r/${request.token}`);
254 + expect(res.status).toBe(410);
255 + expect(await res.text()).not.toContain('Inbox'); // leaks no folder name
256 + });
257 +
176 258 it('locks out after 5 wrong passwords', async () => {
177 259 for (let i = 0; i < 5; i += 1) {
178 260 await fetch(`${B}/api/v1/auth/login`, {
modified test/unit.test.mjs +88 −0
@@ -22,9 +22,15 @@ import {
22 22 mkdir, createFileNode, renameNode, moveNode, copyNode, trashNode, restoreNode,
23 23 deleteForever, uniqueName, validateName, isDescendant, listDescendantFiles, ROOT_ID,
24 24 } from '../src/storage/nodes.mjs';
25 +import {
26 + listVersions, restoreVersion, deleteVersion,
27 +} from '../src/storage/versions.mjs';
25 28 import {
26 29 createShare, getShareByToken, shareValidity, verifySharePassword, revokeShare,
27 30 } from '../src/shares/shares.mjs';
31 +import {
32 + closeRequest, createRequest, getRequestByToken, recordReceived, requestValidity,
33 +} from '../src/shares/requests.mjs';
28 34 import { parseRange } from '../src/web/http-helpers.mjs';
29 35 import { searchNodes } from '../src/search/search.mjs';
30 36
@@ -108,6 +114,88 @@ describe('tree ops', () => {
108 114 });
109 115 });
110 116
117 +describe('file versioning', () => {
118 + it('replace keeps the node id and archives the old content as a version', async () => {
119 + const dir = mkdir(ROOT_ID, 'versions');
120 + const v1 = await put('version-one');
121 + const file = createFileNode(dir.id, 'doc.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' });
122 +
123 + const v2 = await put('version-two-longer');
124 + const replaced = createFileNode(dir.id, 'doc.txt', {
125 + sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace',
126 + });
127 + expect(replaced.id).toBe(file.id); // same node — shares/tags survive
128 + expect(replaced.blob_sha).toBe(v2.sha);
129 +
130 + const versions = listVersions(file.id);
131 + expect(versions).toHaveLength(1);
132 + expect(versions[0].blob_sha).toBe(v1.sha);
133 + expect(versions[0].origin).toBe('replace');
134 + // The version row holds a ref — old bytes survive GC.
135 + await gcBlobs();
136 + expect(readFileSync(blobPath(v1.sha), 'utf8')).toBe('version-one');
137 + });
138 +
139 + it('restoring a version archives the current content and keeps history', async () => {
140 + const dir = mkdir(ROOT_ID, 'versions-restore');
141 + const v1 = await put('restore-old');
142 + const file = createFileNode(dir.id, 'r.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' });
143 + const v2 = await put('restore-new');
144 + createFileNode(dir.id, 'r.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace' });
145 +
146 + const oldVersion = listVersions(file.id)[0];
147 + const restored = restoreVersion(file.id, oldVersion.id);
148 + expect(restored.blob_sha).toBe(v1.sha);
149 + const origins = listVersions(file.id).map((v) => v.origin).sort();
150 + expect(origins).toContain('restore'); // the superseded v2 content
151 + });
152 +
153 + it('deleting a version and deleting the node release blob refs', async () => {
154 + const dir = mkdir(ROOT_ID, 'versions-gc');
155 + const v1 = await put('gc-version-1');
156 + const file = createFileNode(dir.id, 'g.txt', { sha: v1.sha, size: v1.size, mime: 'text/plain' });
157 + const v2 = await put('gc-version-2');
158 + createFileNode(dir.id, 'g.txt', { sha: v2.sha, size: v2.size, mime: 'text/plain', conflict: 'replace' });
159 +
160 + const version = listVersions(file.id)[0];
161 + expect(deleteVersion(file.id, version.id)).toBe(true);
162 + deleteForever(file.id);
163 + const refs = getDb().prepare('SELECT sha, refcount FROM blobs WHERE sha IN (?, ?)').all(v1.sha, v2.sha);
164 + expect(refs).toHaveLength(2);
165 + for (const row of refs) expect(row.refcount).toBe(0);
166 + });
167 +});
168 +
169 +describe('file requests', () => {
170 + it('creates 12-char tokens and enforces the lifecycle', () => {
171 + const dir = mkdir(ROOT_ID, 'inbox');
172 + const request = createRequest(dir.id, { label: 'CV', maxFiles: 2 });
173 + expect(request.token).toMatch(/^[1-9A-HJ-NP-Za-km-z]{12}$/);
174 + expect(requestValidity(request).ok).toBe(true);
175 + expect(requestValidity(getRequestByToken(request.token)).ok).toBe(true);
176 +
177 + recordReceived(request.id, { name: 'a.pdf' });
178 + recordReceived(request.id, { name: 'b.pdf' });
179 + expect(requestValidity(getRequestByToken(request.token))).toEqual({ ok: false, reason: 'full' });
180 +
181 + const expired = createRequest(dir.id, { expiresAt: Date.now() - 1000 });
182 + expect(requestValidity(expired)).toEqual({ ok: false, reason: 'expired' });
183 +
184 + const open = createRequest(dir.id, {});
185 + closeRequest(open.id);
186 + expect(requestValidity(getRequestByToken(open.token))).toEqual({ ok: false, reason: 'closed' });
187 + });
188 +
189 + it('refuses non-folder targets and dies with a trashed folder', () => {
190 + const dir = mkdir(ROOT_ID, 'inbox-dead');
191 + const request = createRequest(dir.id, {});
192 + trashNode(dir.id);
193 + expect(requestValidity(getRequestByToken(request.token))).toEqual({ ok: false, reason: 'gone' });
194 + restoreNode(dir.id);
195 + expect(requestValidity(getRequestByToken(request.token)).ok).toBe(true);
196 + });
197 +});
198 +
111 199 describe('shares', () => {
112 200 it('creates 10-char base58 tokens and validates lifecycle', async () => {
113 201 const folder = mkdir(ROOT_ID, 'share-me');
114 202