// upload.js — drag-drop file upload into the current scope. // // Active only in server mode and only at paths where the cascade // declares drop_target: true (see zddc/internal/zddc/lookups.go // DropTargetAt + defaults.zddc.yaml). The loader captures the // X-ZDDC-Drop-Target response header on every directory listing // fetch and stamps state.scopeDropTarget; this module just reads it. // // At scopes where drop_target is false (or unset), the handlers // stay armed but ignore drops silently — no visible drop-zone // overlay. An operator can flip working/staging/incoming on or // extend the cascade to mark additional dirs as drop targets via // .zddc; the client follows automatically without code change. // // Wire model: // - dragenter on the document raises a counter; first-enter shows // the overlay. // - dragleave decrements; reaching zero hides the overlay. // - drop short-circuits: prevent default, PUT each file under the // current state.currentPath, surface per-file toast results, // refetch the listing on completion. // // The PUT uses fetch(``, method: 'PUT'). The // server's authorizeAction enforces write ACL on the parent; a 403 // surfaces as an error toast and the rest of the batch proceeds. // // Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap // are rejected client-side with a clear toast — the server would // accept them in chunks but browse's v1 PUT is a single body, and // dropping a 4 GB CAD bundle into the browser tab as a Blob is a // poor experience. Operators with larger uploads should use a // dedicated client (zddc-cli or the cache/mirror downstream). (function () { 'use strict'; if (!window.app || !window.app.modules) return; var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file var state = window.app.state; var enterCount = 0; var overlayEl = null; function ensureOverlay() { if (overlayEl) return overlayEl; overlayEl = document.createElement('div'); overlayEl.className = 'upload-overlay'; overlayEl.setAttribute('aria-hidden', 'true'); overlayEl.innerHTML = '
' + '
' + '
Drop to upload
' + '
' + '
'; document.body.appendChild(overlayEl); return overlayEl; } function currentScopeAllows() { if (!state || state.source !== 'server') return false; // state.scopeDropTarget is set by the loader on every listing // fetch from the X-ZDDC-Drop-Target response header; it's a // boolean read of the cascade's effective drop_target flag at // the current path. Defaults to false when the header is // absent (older server or non-server response). return !!state.scopeDropTarget; } function showOverlay() { var el = ensureOverlay(); var pathEl = el.querySelector('#uploadOverlayPath'); if (pathEl) pathEl.textContent = state.currentPath || '/'; el.classList.add('is-active'); } function hideOverlay() { if (overlayEl) overlayEl.classList.remove('is-active'); } function dragHasFiles(e) { if (!e.dataTransfer || !e.dataTransfer.types) return false; var types = e.dataTransfer.types; for (var i = 0; i < types.length; i++) { if (types[i] === 'Files') return true; } return false; } // Join a directory path and a relative path safely. dir is expected // to be /-prefixed and may or may not have a trailing /; rel is a // forward-slash relative path (no leading /). Each segment is // URI-encoded so spaces and friends survive the round trip. function joinUrl(dir, rel) { var base = dir || '/'; if (!base.endsWith('/')) base += '/'; return base + rel.split('/').map(encodeURIComponent).join('/'); } async function uploadOne(file, destDir, relPath) { if (file.size > UPLOAD_MAX_BYTES) { return { file: file, ok: false, status: 0, message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)' }; } try { var resp = await fetch(joinUrl(destDir, relPath), { method: 'PUT', body: file, credentials: 'same-origin', headers: { 'Content-Type': file.type || 'application/octet-stream' } }); return { file: file, ok: resp.ok, status: resp.status, message: resp.ok ? '' : ('HTTP ' + resp.status) }; } catch (e) { return { file: file, ok: false, status: 0, message: (e && e.message) ? e.message : 'network error' }; } } // ── Folder-upload helpers (webkitGetAsEntry recursion) ───────────────── // Browsers expose dropped folders only through the entries API. // walkEntry flattens a tree into [{ relPath, file }] so uploadOne // can PUT each file individually. The server's PUT auto-creates // intermediate directories, so no explicit mkdir is needed. function readAllEntries(reader) { return new Promise(function (resolve, reject) { var collected = []; function loop() { reader.readEntries(function (batch) { if (batch.length === 0) return resolve(collected); collected = collected.concat(batch); loop(); }, reject); } loop(); }); } function entryToFile(entry) { return new Promise(function (resolve, reject) { entry.file(resolve, reject); }); } async function walkEntry(entry, prefix, out) { if (entry.isFile) { try { var f = await entryToFile(entry); out.push({ relPath: prefix + entry.name, file: f }); } catch (_e) { /* skip unreadable file */ } } else if (entry.isDirectory) { var reader = entry.createReader(); var kids = await readAllEntries(reader); for (var i = 0; i < kids.length; i++) { await walkEntry(kids[i], prefix + entry.name + '/', out); } } } // Extract { relPath, file } pairs from a DataTransfer. Uses // webkitGetAsEntry when available (so folder uploads work); // falls back to dataTransfer.files for cases where entries // aren't exposed (some browsers / cross-origin). async function collectUploads(dt) { var out = []; if (dt.items && dt.items.length) { var entries = []; for (var i = 0; i < dt.items.length; i++) { var item = dt.items[i]; if (item.kind !== 'file') continue; var entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; if (entry) { entries.push(entry); } else { var f = item.getAsFile(); if (f) out.push({ relPath: f.name, file: f }); } } for (var j = 0; j < entries.length; j++) { await walkEntry(entries[j], '', out); } if (out.length) return out; } if (dt.files) { for (var k = 0; k < dt.files.length; k++) { out.push({ relPath: dt.files[k].name, file: dt.files[k] }); } } return out; } // Run a batch of uploads against an arbitrary destination directory. // Surfaces per-file errors as toasts; refreshes the tree afterward // so newly-uploaded entries appear. Returns { ok, fail } counts. async function uploadBatch(uploads, destDir) { var note = window.zddc && window.zddc.toast; if (note) { note('Uploading ' + uploads.length + ' item' + (uploads.length === 1 ? '' : 's') + '…', 'info'); } var ok = 0, fail = 0; for (var i = 0; i < uploads.length; i++) { var u = uploads[i]; var res = await uploadOne(u.file, destDir, u.relPath); if (res.ok) ok++; else { fail++; if (note) { note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error'); } } } if (note) { if (fail === 0) { note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's') + ' → ' + destDir, 'success'); } else if (ok === 0) { note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error'); } else { note(ok + ' uploaded, ' + fail + ' failed', 'warning'); } } return { ok: ok, fail: fail }; } // ── Create-new helpers ──────────────────────────────────────────────── // Both go through the same server endpoints used by upload: PUT // for files (with an empty/template body) and POST + X-ZDDC-Op: // mkdir for directories. Client-side enforcement is best-effort; // the server's ACL is the source of truth. async function makeDir(parentDir, name) { var url = joinUrl(parentDir, name); if (!url.endsWith('/')) url += '/'; var resp = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'X-ZDDC-Op': 'mkdir' } }); if (!resp.ok) throw new Error('HTTP ' + resp.status); } async function makeFile(parentDir, name, body, contentType) { var resp = await fetch(joinUrl(parentDir, name), { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': contentType || 'application/octet-stream' }, body: body == null ? '' : body }); if (!resp.ok) throw new Error('HTTP ' + resp.status); } // ── Delete + rename ───────────────────────────────────────────────────── // Both run through the same FS Access API + file-API endpoints used // by the create helpers above: // - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced // server-side; a 403/405 surfaces as an error toast. // - FS-API mode: FileSystemHandle.remove({recursive:true}) and // .move(newName) — both are Chromium-110+ features. We feature- // detect at the handle level; callers see a clear "not supported" // error message if the browser is too old. function pathForNode(node) { var tree = window.app.modules.tree; return tree ? tree.pathFor(node) : ''; } function isZipMember(node) { if (node.handle && node.handle.isZipEntry) return true; if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) { return true; } return false; } // True when this node's write API is reachable. The server can // still refuse the action on ACL grounds; this only gates the // menu's disabled-state for the cases where there's clearly no // write target at all. function canMutate(node) { if (!node || node.virtual) return false; if (isZipMember(node)) return false; if (state.source === 'server') return true; if (node.handle && typeof node.handle.remove === 'function') return true; return false; } async function removeNode(node) { if (!node) throw new Error('no node'); if (isZipMember(node)) { throw new Error('Cannot delete a file inside a zip archive.'); } if (node.virtual) { throw new Error('Virtual folder — nothing on disk to delete.'); } if (state.source === 'server') { var url = pathForNode(node); if (node.isDir && !url.endsWith('/')) url += '/'; var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' }); if (!resp.ok) { if (resp.status === 403) throw new Error('Permission denied (403).'); if (resp.status === 405) throw new Error('Delete not allowed for this entry.'); throw new Error('HTTP ' + resp.status); } return; } // FS-API path. FileSystemHandle.remove() is Chromium 110+ // (browsers that didn't ship it expose no equivalent — the // legacy removeEntry() lives on the PARENT directory handle // and we don't retain ancestor handles). if (node.handle && typeof node.handle.remove === 'function') { await node.handle.remove({ recursive: !!node.isDir }); return; } throw new Error('Delete not supported by this browser in offline mode.'); } async function renameNode(node, newName) { if (!node) throw new Error('no node'); if (!newName) throw new Error('Name required.'); if (newName === node.name) return; if (isZipMember(node)) { throw new Error('Cannot rename a file inside a zip archive.'); } if (node.virtual) { throw new Error('Virtual folder — nothing on disk to rename.'); } if (state.source === 'server') { var src = pathForNode(node); if (node.isDir && !src.endsWith('/')) src += '/'; // Destination = same parent, new basename. var lastSlash = src.replace(/\/$/, '').lastIndexOf('/'); var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/'; var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : ''); var resp = await fetch(src, { method: 'POST', credentials: 'same-origin', headers: { 'X-ZDDC-Op': 'move', 'X-ZDDC-Destination': dst } }); if (!resp.ok) { if (resp.status === 403) throw new Error('Permission denied (403).'); if (resp.status === 409) throw new Error('A file with that name already exists.'); throw new Error('HTTP ' + resp.status); } return; } // FS-API: handle.move(newName) is Chromium 110+. if (node.handle && typeof node.handle.move === 'function') { await node.handle.move(newName); return; } throw new Error('Rename not supported by this browser in offline mode.'); } // Refresh either the root listing (when the upload targeted the // current scope) or just one folder node's children (when the // upload targeted a subfolder via a per-row drop). async function refreshAfterUpload(targetDir) { var loader = window.app.modules.loader; var tree = window.app.modules.tree; if (!loader || !tree) return; if (state.currentPath && targetDir === state.currentPath) { try { var es = await loader.fetchServerChildren(state.currentPath); tree.setRoot(es); tree.render(); } catch (_e) { /* swallow */ } return; } // Find any tree node whose path matches targetDir and reload // its children. Walks state.nodes flat — n is small enough for // a linear scan. var dirNoSlash = (targetDir || '').replace(/\/$/, ''); var hit = null; state.nodes.forEach(function (n) { if (hit || !n.isDir) return; if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n; }); if (hit && hit.expanded) { try { var raw = await loader.fetchServerChildren(targetDir); tree.setChildren(hit.id, raw); tree.render(); } catch (_e) { /* swallow */ } } } // Document-level drop: targets the currently-viewed scope. The // per-row drop (events.js) calls uploadToDir directly with a // different destination. async function handleDrop(e) { e.preventDefault(); e.stopPropagation(); enterCount = 0; hideOverlay(); if (!currentScopeAllows()) return; var dt = e.dataTransfer; if (!dt) return; var uploads = await collectUploads(dt); if (!uploads.length) return; await uploadBatch(uploads, state.currentPath); await refreshAfterUpload(state.currentPath); } // Public entry for per-row drops or programmatic uploads. destDir // must be a server path (/-prefixed, slash-terminated optional). async function uploadToDir(destDir, dataTransfer) { var uploads = await collectUploads(dataTransfer); if (!uploads.length) return { ok: 0, fail: 0 }; var res = await uploadBatch(uploads, destDir); await refreshAfterUpload(destDir); return res; } function onEnter(e) { if (!dragHasFiles(e)) return; enterCount++; if (enterCount === 1 && currentScopeAllows()) { showOverlay(); } } function onLeave(e) { if (!dragHasFiles(e)) return; enterCount = Math.max(0, enterCount - 1); if (enterCount === 0) hideOverlay(); } function onOver(e) { if (!dragHasFiles(e)) return; // preventDefault on dragover is required for drop to fire. e.preventDefault(); if (e.dataTransfer && currentScopeAllows()) { e.dataTransfer.dropEffect = 'copy'; } else if (e.dataTransfer) { e.dataTransfer.dropEffect = 'none'; } } function init() { document.addEventListener('dragenter', onEnter, false); document.addEventListener('dragleave', onLeave, false); document.addEventListener('dragover', onOver, false); document.addEventListener('drop', handleDrop, false); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } window.app.modules.upload = { currentScopeAllows: currentScopeAllows, uploadToDir: uploadToDir, makeDir: makeDir, makeFile: makeFile, removeNode: removeNode, renameNode: renameNode, canMutate: canMutate, UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES }; })();