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>
Showing 22 changed files with +1,478 and −26
modified
README.md
+16 −1
@@ -15,7 +15,8 @@ | ||
| 15 | 15 |  |
| 16 | 16 |  |
| 17 | 17 |  |
| 18 | − | |
| 18 | + | |
| 19 | + | |
| 19 | 20 |  |
| 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 | |