From 2f3dd80881ee3ee454fd2a268f1e3f02d43a508f Mon Sep 17 00:00:00 2001 From: screentinker Date: Sun, 12 Jul 2026 21:23:25 -0500 Subject: [PATCH] =?UTF-8?q?feat(agency):=20per-token=20upload=20folder=20?= =?UTF-8?q?=E2=80=94=20auto-created,=20subtree-confined=20(#158)=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agency-portal uploads previously all landed at the workspace library root, unsorted. Instead of the issue's whole-workspace folder dropdown (which would leak every folder name to an external party), bind ONE folder per agency token — admin-controlled and agency-invisible — and scope the portal picker strictly to that folder's own subtree (Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before. Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the token key IS that workspace's private link and the bound folder lives in that workspace. An admin with N workspaces mints one token per workspace (each with its own auto-folder). No workspace-switcher in the portal — the token is the tenant boundary. Backend: - api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls back to root). - lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents). - routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound folder and 403s any folder_id outside the subtree. - routes/tokens.js: create auto-creates "Agency — " (or binds a picked folder, validated same-workspace, respecting the 100-folder cap) inside the token tx; new PUT /:id/upload-folder to rebind; listing surfaces the bound folder name. - middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest writes folder_id. Frontend: - Agency portal: folder +
diff --git a/frontend/js/agency-portal.js b/frontend/js/agency-portal.js index ff089bc..8744bae 100644 --- a/frontend/js/agency-portal.js +++ b/frontend/js/agency-portal.js @@ -59,6 +59,25 @@ // #73: the placement card reacts to the playlist selector - "where does THIS playlist go?" sel.onchange = () => loadLayoutForPlaylist(sel.value); loadLayoutForPlaylist(sel.value); // initial selection + loadFolders(); + } + + // #158 (Hybrid-C): the folders this token may drop into = its bound folder + descendants. + // Only offer the picker when there's a real choice (a subfolder exists); with just the bound + // folder, uploads default to it server-side and the picker stays hidden. The bound "root" is + // the one node whose parent isn't in the returned set, so we can label it "Main folder" + // (default, value="") and list the descendants under it — without the portal ever learning + // the token's folder id. + async function loadFolders() { + const row = $('folderRow'), sel = $('folderSelect'); + let folders; + try { folders = await (await agencyFetch('/folders')).json(); } catch (e) { return; } + const ids = new Set(folders.map(f => f.id)); + const descendants = folders.filter(f => f.parent_id && ids.has(f.parent_id)); // exclude the root + if (!descendants.length) { row.style.display = 'none'; return; } + sel.innerHTML = '' + + descendants.map(f => ``).join(''); + row.style.display = 'block'; } // Visual placement guide for the SELECTED playlist: draw its layout to scale, highlight the @@ -115,6 +134,8 @@ try { const fd = new FormData(); fd.append('file', file); + const folderId = $('folderSelect') && $('folderSelect').value; + if (folderId) fd.append('folder_id', folderId); // #158: a subfolder of the bound folder; empty = the bound default const res = await agencyFetch('/content', { method: 'POST', body: fd }); if (!res.ok) { portalMsg('Upload failed. Try again.', 'err'); return; } const content = await res.json(); diff --git a/frontend/js/api.js b/frontend/js/api.js index 019e28e..379fc6a 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -180,6 +180,7 @@ export const api = { createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }), revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }), setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists + setTokenUploadFolder: (id, upload_folder_id) => request('/tokens/' + id + '/upload-folder', { method: 'PUT', body: JSON.stringify({ upload_folder_id }) }), // #158: rebind agency token upload folder (null = root) // Current user getMe: () => request('/auth/me'), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index bec630d..919d570 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -382,6 +382,13 @@ export default { 'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)', 'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.', 'apitoken.auto_publish_on': 'Auto-Veröffentlichung an', + 'apitoken.agency_folder_label': 'Upload-Ordner', + 'apitoken.agency_folder_hint': 'Wohin die Uploads dieser Agentur gelangen. Automatisch belassen, um einen nach dem Token benannten Ordner zu erstellen; die Agentur kann dessen Unterordner ansteuern, aber nichts anderes.', + 'apitoken.agency_folder_auto': 'Automatisch erstellen (Agency — )', + 'apitoken.folder_label': 'Ordner:', + 'apitoken.folder_root': 'Bibliotheks-Stammverzeichnis', + 'apitoken.edit_folder': 'Ordner', + 'apitoken.folder_updated': 'Upload-Ordner aktualisiert', 'apitoken.create': 'Token erstellen', 'apitoken.none': 'Noch keine Tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ce97275..a9b1f64 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -524,6 +524,13 @@ export default { 'apitoken.auto_publish_label': 'Auto-publish (skip my approval)', 'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.', 'apitoken.auto_publish_on': 'auto-publish on', + 'apitoken.agency_folder_label': 'Upload folder', + 'apitoken.agency_folder_hint': "Where this agency's uploads land. Leave on auto to create a folder named after the token; the agency can target subfolders of it but nothing else.", + 'apitoken.agency_folder_auto': 'Create one automatically (Agency — )', + 'apitoken.folder_label': 'Folder:', + 'apitoken.folder_root': 'Library root', + 'apitoken.edit_folder': 'Folder', + 'apitoken.folder_updated': 'Upload folder updated', 'apitoken.create': 'Create token', 'apitoken.none': 'No tokens yet.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 777bf39..560ac14 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -381,6 +381,13 @@ export default { 'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)', 'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.', 'apitoken.auto_publish_on': 'publicación automática activada', + 'apitoken.agency_folder_label': 'Carpeta de subida', + 'apitoken.agency_folder_hint': 'Dónde se guardan las subidas de esta agencia. Deja en automático para crear una carpeta con el nombre del token; la agencia puede usar subcarpetas de ella, pero nada más.', + 'apitoken.agency_folder_auto': 'Crear una automáticamente (Agency — )', + 'apitoken.folder_label': 'Carpeta:', + 'apitoken.folder_root': 'Raíz de la biblioteca', + 'apitoken.edit_folder': 'Carpeta', + 'apitoken.folder_updated': 'Carpeta de subida actualizada', 'apitoken.create': 'Crear token', 'apitoken.none': 'Aún no hay tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index bbb6016..1525925 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -382,6 +382,13 @@ export default { 'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)', 'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.', 'apitoken.auto_publish_on': 'publication automatique activée', + 'apitoken.agency_folder_label': 'Dossier de téléversement', + 'apitoken.agency_folder_hint': "Où atterrissent les téléversements de cette agence. Laissez sur automatique pour créer un dossier nommé d'après le jeton ; l'agence peut cibler ses sous-dossiers, mais rien d'autre.", + 'apitoken.agency_folder_auto': 'En créer un automatiquement (Agency — )', + 'apitoken.folder_label': 'Dossier :', + 'apitoken.folder_root': 'Racine de la bibliothèque', + 'apitoken.edit_folder': 'Dossier', + 'apitoken.folder_updated': 'Dossier de téléversement mis à jour', 'apitoken.create': 'Créer un jeton', 'apitoken.none': 'Aucun jeton pour le moment.', 'apitoken.col_token': 'Jeton', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index db9e87f..3747a32 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -382,6 +382,13 @@ export default { 'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)', 'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.', 'apitoken.auto_publish_on': 'publicação automática ativada', + 'apitoken.agency_folder_label': 'Pasta de upload', + 'apitoken.agency_folder_hint': 'Onde os uploads desta agência ficam. Deixe em automático para criar uma pasta com o nome do token; a agência pode usar subpastas dela, mas nada além disso.', + 'apitoken.agency_folder_auto': 'Criar uma automaticamente (Agency — )', + 'apitoken.folder_label': 'Pasta:', + 'apitoken.folder_root': 'Raiz da biblioteca', + 'apitoken.edit_folder': 'Pasta', + 'apitoken.folder_updated': 'Pasta de upload atualizada', 'apitoken.create': 'Criar token', 'apitoken.none': 'Ainda não há tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 3c8027a..5bff420 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -88,6 +88,9 @@ export async function render(container) { ${t('apitoken.auto_publish_label')}

${t('apitoken.auto_publish_hint')}

+ +

${t('apitoken.agency_folder_hint')}

+

${t('settings.loading_users')}

@@ -372,14 +375,14 @@ export async function render(container) { ${esc(tok.name || '')} ${esc(scopeLabel(tok.scope))}${ tok.scope === 'agency' && Array.isArray(tok.targets) - ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}
` + ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}
${t('apitoken.folder_label')} ${tok.upload_folder ? esc(tok.upload_folder) : esc(t('apitoken.folder_root'))}
` : ''} ${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} ${tok.revoked_at ? `${t('apitoken.revoked')}` - : `${tok.scope === 'agency' ? ` ` : ''}`} + : `${tok.scope === 'agency' ? ` ` : ''}`} `).join('')} @@ -433,6 +436,35 @@ export async function render(container) { }; document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; }; })); + + // #158: rebind an agency token's upload folder -> PUT /:id/upload-folder (null = root). + el.querySelectorAll('.edit-folder-btn').forEach(btn => btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const current = btn.dataset.folder || ''; + const panel = document.getElementById('tokenEditPanel'); + const folders = await api.getFolders().catch(() => []); + panel.style.display = 'block'; + panel.innerHTML = ` +
+

${t('apitoken.edit_folder')}

+

${t('apitoken.agency_folder_hint')}

+ + + +
`; + document.getElementById('saveFolderBtn').onclick = async () => { + try { + await api.setTokenUploadFolder(id, document.getElementById('rebindFolder').value || null); + showToast(t('apitoken.folder_updated'), 'success'); + panel.style.display = 'none'; + loadTokens(); + } catch (err) { showToast(err.message, 'error'); } + }; + document.getElementById('cancelFolderBtn').onclick = () => { panel.style.display = 'none'; }; + })); } loadTokens(); @@ -453,6 +485,10 @@ export async function render(container) { ? `` : ``).join('') : `

${t('apitoken.agency_no_playlists')}

`; + // #158: offer existing folders to bind, or leave on the auto-create default. + const folders = await api.getFolders().catch(() => []); + const fsel = document.getElementById('tokUploadFolder'); + if (fsel && folders.length) fsel.insertAdjacentHTML('beforeend', folders.map(f => ``).join('')); } }); @@ -465,6 +501,9 @@ export async function render(container) { if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); payload.target_playlist_ids = ids; payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked; + // #158: blank = auto-create "Agency — "; a value binds that existing folder. + const fv = document.getElementById('tokUploadFolder')?.value; + if (fv) payload.upload_folder_id = fv; } const btn = document.getElementById('createTokenBtn'); btn.disabled = true; diff --git a/server/db/database.js b/server/db/database.js index 5362978..a84c8a3 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -221,6 +221,9 @@ const migrations = [ "CREATE TABLE IF NOT EXISTS api_token_targets (token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", // #73: per-agency-token auto-publish (DEFAULT 0 = draft, the fail-safe). "ALTER TABLE api_tokens ADD COLUMN auto_publish INTEGER NOT NULL DEFAULT 0", + // #158: agency uploads land in this bound folder (and its subtree). NULL = root (pre-#158 + // tokens, or admin unbound). ON DELETE SET NULL so deleting the folder falls back to root. + "ALTER TABLE api_tokens ADD COLUMN upload_folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL", // #73: agency-upload notification queue (batched digest). "CREATE TABLE IF NOT EXISTS agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, action TEXT NOT NULL, content_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), sent_at INTEGER)", "CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at)", diff --git a/server/lib/agency-targets.js b/server/lib/agency-targets.js index 697909b..85a1f64 100644 --- a/server/lib/agency-targets.js +++ b/server/lib/agency-targets.js @@ -26,4 +26,26 @@ function isZonedPlaylist(db, playlistId) { return !!db.prepare('SELECT 1 FROM playlist_items WHERE playlist_id = ? AND zone_id IS NOT NULL LIMIT 1').get(playlistId); } -module.exports = { listDesignatedPlaylists, isZonedPlaylist }; +// #158 (Hybrid-C): the folder subtree an agency token may upload into = its bound +// upload_folder_id PLUS every descendant. This one recursive query IS the confinement, +// used by BOTH GET /api/agency/folders (the portal dropdown) and POST /api/agency/content +// (the upload target check) — so the list the agency sees and the set it may write to can +// never drift apart. The anchor row is workspace-guarded; descendants inherit the workspace +// because folders.js forbids a cross-workspace parent, so the whole subtree stays in-workspace. +// Returns [] for a null/foreign/absent root (legacy or root-bound token -> uploads go to root, +// no dropdown). rootFolderId included in the result (an agency can upload to the folder itself). +function folderSubtree(db, rootFolderId, workspaceId) { + if (!rootFolderId) return []; + return db.prepare(` + WITH RECURSIVE sub(id) AS ( + SELECT id FROM content_folders WHERE id = ? AND workspace_id = ? + UNION + SELECT cf.id FROM content_folders cf JOIN sub ON cf.parent_id = sub.id + ) + SELECT cf.id, cf.name, cf.parent_id + FROM content_folders cf JOIN sub ON cf.id = sub.id + ORDER BY cf.name COLLATE NOCASE + `).all(rootFolderId, workspaceId); +} + +module.exports = { listDesignatedPlaylists, isZonedPlaylist, folderSubtree }; diff --git a/server/lib/content-ingest.js b/server/lib/content-ingest.js index c4694e8..83e68bc 100644 --- a/server/lib/content-ingest.js +++ b/server/lib/content-ingest.js @@ -23,7 +23,7 @@ function safeFilename(name) { // Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content // row. Returns the content row. Throws on a hard failure (the caller maps to 500); // thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before. -async function ingestUploadedFile({ file, userId, workspaceId }) { +async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) { const id = uuidv4(); const filepath = file.filename; let width = null, height = null, durationSec = null, thumbnailPath = null; @@ -67,9 +67,9 @@ async function ingestUploadedFile({ file, userId, workspaceId }) { } db.prepare(` - INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, userId, workspaceId, safeFilename(file.originalname), filepath, file.mimetype, file.size, durationSec, thumbnailPath, width, height); + INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height, folder_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, userId, workspaceId, safeFilename(file.originalname), filepath, file.mimetype, file.size, durationSec, thumbnailPath, width, height, folderId || null); return db.prepare('SELECT * FROM content WHERE id = ?').get(id); } diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index 84534f0..187f95f 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -71,7 +71,9 @@ function apiTokenAuth(req, res, next) { req.tokenScope = row.scope; // #73: auto_publish read from the TOKEN ROW (admin-set), so the agency endpoint can // never take it from the request body. `|| 0` keeps it fail-safe for any row predating it. - req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id, auto_publish: row.auto_publish || 0 }; + // #158: upload_folder_id (admin-set) confines agency uploads to a folder subtree; read from + // the token row, never the request body. `|| null` keeps it fail-safe for pre-#158 rows. + req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id, auto_publish: row.auto_publish || 0, upload_folder_id: row.upload_folder_id || null }; touchLastUsed(row.id); next(); } diff --git a/server/routes/agency.js b/server/routes/agency.js index 7c6d8ba..09bfb69 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -13,7 +13,7 @@ const { db } = require('../db/database'); const upload = require('../middleware/upload'); const { checkStorageLimit } = require('../middleware/subscription'); const { ingestUploadedFile } = require('../lib/content-ingest'); -const { listDesignatedPlaylists, isZonedPlaylist } = require('../lib/agency-targets'); +const { listDesignatedPlaylists, isZonedPlaylist, folderSubtree } = require('../lib/agency-targets'); const { listLayoutGeometry } = require('../lib/agency-layouts'); const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set @@ -52,12 +52,31 @@ router.param('playlistId', (req, res, next, playlistId) => { next(); }); +// #158: the folders THIS token may drop uploads into = its bound upload_folder_id + descendants +// (Hybrid-C). No bound folder -> [] (portal shows no picker, uploads go to root). The subtree +// query in lib/agency-targets.js is the confinement, shared with the upload check below so the +// list and the writable set can't drift. No :playlistId, so router.param doesn't apply. +router.get('/folders', (req, res) => { + res.json(folderSubtree(db, req.apiToken.upload_folder_id, req.workspaceId)); +}); + // Upload to the bound workspace via the SHARED ingest -> first-class content (identical -// thumbnail/dimensions/duration to a dashboard upload). +// thumbnail/dimensions/duration to a dashboard upload). #158: the file lands in the token's +// bound folder by default; the agency may target a SUBFOLDER of it via folder_id, but nothing +// outside that subtree (folder_id read from the multipart body, then confined to folderSubtree). router.post('/content', checkStorageLimit, upload.single('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId }); + // Default target = the bound folder (or root if none bound). A supplied folder_id must be + // WITHIN the bound subtree — never a sibling, a parent, or another workspace's folder. + let folderId = req.apiToken.upload_folder_id || null; + const requested = req.body && req.body.folder_id; + if (requested) { + const allowed = new Set(folderSubtree(db, req.apiToken.upload_folder_id, req.workspaceId).map(f => f.id)); + if (!allowed.has(requested)) return res.status(403).json({ error: 'folder is not in this agency token\'s upload area' }); + folderId = requested; + } + const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId, folderId }); res.status(201).json(content); } catch (e) { console.error('agency upload error:', e.message); diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 6c12985..d20bf5c 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -15,17 +15,46 @@ const { isPlatformRole } = require('../middleware/auth'); // #146: billing // #146: 'billing:read' is likewise off-ladder — reaches only /api/billing via requireBillingRead. const SCOPES = ['read', 'write', 'full', 'agency', 'billing:read']; +// #158: per-workspace folder cap (mirrors folders.js) — auto-creating an agency folder must +// respect the same ceiling so a token-mint can't blow past it. +const MAX_FOLDERS_PER_WORKSPACE = 100; + +// #158: resolve the folder an agency token uploads into. Either the admin PICKED an existing +// folder (must live in THIS workspace) or we AUTO-CREATE one named after the token. Returns the +// folder id, or throws { status, error } for a bad pick / folder-cap hit. Runs inside the token +// transaction so an auto-created folder and the token commit atomically. +function resolveAgencyUploadFolder(req, tokenName, pickedId) { + if (pickedId) { + const f = db.prepare('SELECT id, workspace_id FROM content_folders WHERE id = ?').get(pickedId); + if (!f || f.workspace_id !== req.workspaceId) throw { status: 400, error: 'upload_folder_id is not a folder in this workspace' }; + return pickedId; + } + if (!isPlatformRole(req.user.role)) { + const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE workspace_id = ?').get(req.workspaceId); + if (count >= MAX_FOLDERS_PER_WORKSPACE) throw { status: 429, error: `Folder limit reached (${MAX_FOLDERS_PER_WORKSPACE}). Pick an existing folder for this agency token or delete unused folders.` }; + } + const id = crypto.randomUUID(); + db.prepare('INSERT INTO content_folders (id, user_id, workspace_id, parent_id, name) VALUES (?, ?, ?, NULL, ?)') + .run(id, req.user.id, req.workspaceId, `Agency — ${tokenName}`.slice(0, 100)); + return id; +} + // List the caller's tokens in the active workspace. Never returns the secret/hash. router.get('/', (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' }); const rows = db.prepare(` - SELECT id, prefix, name, scope, auto_publish, workspace_id, created_at, last_used_at, revoked_at + SELECT id, prefix, name, scope, auto_publish, upload_folder_id, workspace_id, created_at, last_used_at, revoked_at FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC `).all(req.user.id, req.workspaceId); // #73: attach designated playlists for agency tokens so the admin sees the binding persist. const targetsStmt = db.prepare('SELECT p.id, p.name FROM api_token_targets t JOIN playlists p ON p.id = t.playlist_id WHERE t.token_id = ? ORDER BY p.name'); + // #158: attach the bound upload folder's name (may be null = root, or dangling after delete). + const folderStmt = db.prepare('SELECT name FROM content_folders WHERE id = ?'); for (const r of rows) { - if (r.scope === 'agency') r.targets = targetsStmt.all(r.id); + if (r.scope === 'agency') { + r.targets = targetsStmt.all(r.id); + r.upload_folder = r.upload_folder_id ? (folderStmt.get(r.upload_folder_id)?.name || null) : null; + } } res.json(rows); }); @@ -70,18 +99,27 @@ router.post('/', (req, res) => { } const secret = generateToken(); const id = crypto.randomUUID(); - db.transaction(() => { - db.prepare(` - INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) - `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish); - if (scope === 'agency') { - const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); - for (const pid of targetIds) ins.run(id, pid); - } - })(); + let uploadFolderId = null; + try { + db.transaction(() => { + // #158: agency uploads land in a bound folder — admin-picked (upload_folder_id) or + // auto-created "Agency — ". Resolved inside the tx so folder + token commit together. + if (scope === 'agency') uploadFolderId = resolveAgencyUploadFolder(req, name, req.body.upload_folder_id || null); + db.prepare(` + INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, upload_folder_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) + `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish, uploadFolderId); + if (scope === 'agency') { + const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); + for (const pid of targetIds) ins.run(id, pid); + } + })(); + } catch (e) { + if (e && e.status) return res.status(e.status).json({ error: e.error }); + throw e; + } // `token` is returned only here, never again. - res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds, auto_publish: !!autoPublish }); + res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds, auto_publish: !!autoPublish, upload_folder_id: uploadFolderId }); }); // Revoke one of the caller's own tokens (soft delete - takes effect on the next request). @@ -116,4 +154,20 @@ router.put('/:id/targets', (req, res) => { res.json({ id: tok.id, target_playlist_ids: ids }); }); +// #158: rebind an agency token's upload folder (admin can move where an agency's uploads land, +// or unbind to root). JWT-only, like the rest of this router. upload_folder_id: a folder in the +// token's workspace, or null = root. Does NOT auto-create — clearing is explicit here. +router.put('/:id/upload-folder', (req, res) => { + const tok = db.prepare('SELECT id, scope, workspace_id FROM api_tokens WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!tok) return res.status(404).json({ error: 'Token not found' }); + if (tok.scope !== 'agency') return res.status(400).json({ error: 'only agency tokens have an upload folder' }); + const folderId = req.body.upload_folder_id || null; + if (folderId) { + const f = db.prepare('SELECT id, workspace_id FROM content_folders WHERE id = ?').get(folderId); + if (!f || f.workspace_id !== tok.workspace_id) return res.status(400).json({ error: 'upload_folder_id is not a folder in this token\'s workspace' }); + } + db.prepare('UPDATE api_tokens SET upload_folder_id = ? WHERE id = ?').run(folderId, tok.id); + res.json({ id: tok.id, upload_folder_id: folderId }); +}); + module.exports = router; diff --git a/server/test/agency-folder.test.js b/server/test/agency-folder.test.js new file mode 100644 index 0000000..7d000a6 --- /dev/null +++ b/server/test/agency-folder.test.js @@ -0,0 +1,53 @@ +'use strict'; + +// #158 (Hybrid-C): an agency token uploads into a bound folder + its descendants and NOTHING +// else. folderSubtree() in lib/agency-targets.js IS that confinement — it backs both the portal +// dropdown (GET /api/agency/folders) and the upload target check (POST /api/agency/content), so +// if it over-returns, the agency can both SEE and WRITE outside its area. Every way it could +// leak is asserted here; the workspace guard on the anchor row and the parent-join recursion are +// the two lines that make these bites go red. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { folderSubtree } = require('../lib/agency-targets'); + +const db = new Database(':memory:'); +db.exec(` + CREATE TABLE content_folders (id TEXT PRIMARY KEY, parent_id TEXT, name TEXT, workspace_id TEXT); + INSERT INTO content_folders (id, parent_id, name, workspace_id) VALUES + ('root', NULL, 'Agency — Acme', 'wsA'), -- the bound folder + ('sub1', 'root', 'Campaign Q1', 'wsA'), -- child -> in + ('sub2', 'root', 'Campaign Q2', 'wsA'), -- child -> in + ('deep', 'sub1', 'Drafts', 'wsA'), -- grandchild -> in + ('sibling',NULL, 'Internal', 'wsA'), -- other root in same ws -> OUT (sibling leak) + ('sibkid', 'sibling','Confidential', 'wsA'), -- under the sibling -> OUT + ('foreign',NULL, 'Other tenant', 'wsB'); -- another workspace -> OUT +`); + +const ids = (root, ws) => folderSubtree(db, root, ws).map(r => r.id).sort(); + +test('#158 folderSubtree: bound folder + all descendants, nothing else', () => { + assert.deepEqual(ids('root', 'wsA'), ['deep', 'root', 'sub1', 'sub2'], + 'root sees itself + children + grandchild, NOT the sibling tree'); +}); + +test('#158 folderSubtree: a deeper bound folder is confined to its own subtree', () => { + assert.deepEqual(ids('sub1', 'wsA'), ['deep', 'sub1'], 'sub1 sees itself + its child only'); + assert.deepEqual(ids('sub2', 'wsA'), ['sub2'], 'a leaf folder sees only itself'); +}); + +test('#158 folderSubtree: workspace guard — a foreign-workspace anchor returns nothing', () => { + assert.deepEqual(ids('root', 'wsB'), [], 'root anchored to the wrong workspace -> empty (no cross-ws upload area)'); + assert.deepEqual(ids('foreign', 'wsA'), [], 'a wsB folder claimed under wsA -> empty'); +}); + +test('#158 folderSubtree: no bound folder -> root uploads, empty subtree', () => { + assert.deepEqual(folderSubtree(db, null, 'wsA'), [], 'null bound folder -> [] (uploads default to library root)'); +}); + +test('#158 folderSubtree: the sibling subtree is never reachable from root', () => { + const got = ids('root', 'wsA'); + assert.ok(!got.includes('sibling') && !got.includes('sibkid'), + 'neither the sibling folder nor its child may appear in the bound subtree'); +}); diff --git a/server/test/agency.test.js b/server/test/agency.test.js index 3bdb18d..1784fc4 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -191,3 +191,57 @@ test('#73 full-screen guardrail holds at UPLOAD time too (auto-publish has no dr const reDesig = await jfetch('/api/tokens', jpost(jwt, { name: 'AP2', scope: 'agency', target_playlist_ids: [plFS.id] })); assert.equal(reDesig.status, 400, 'already-zoned playlist rejected at designation'); }); + +test('#158 agency upload folder: auto-create, pick, subtree confinement, rebind', async () => { + const email = 'af' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const jwtAuth = { headers: { Authorization: 'Bearer ' + jwt } }; + const jput = (o) => ({ method: 'PUT', headers: { Authorization: 'Bearer ' + jwt, 'Content-Type': 'application/json' }, body: JSON.stringify(o) }); + const pl = (await jfetch('/api/playlists', jpost(jwt, { name: 'FolderTarget' }))).body; + + // (1) AUTO-CREATE: no upload_folder_id -> a folder "Agency — " is created and bound + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'Acme', scope: 'agency', target_playlist_ids: [pl.id] })); + assert.equal(tokRes.status, 201, 'agency token created'); + const boundId = tokRes.body.upload_folder_id; + assert.ok(boundId, 'a folder was auto-created and its id returned'); + const bound = (await jfetch('/api/folders', jwtAuth)).body.find(f => f.id === boundId); + assert.ok(bound && bound.name === 'Agency — Acme', 'auto-created folder is named after the token'); + const atok = tokRes.body.token; + + const up = async (folderId) => { + const fd = new FormData(); + fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png'); + if (folderId) fd.append('folder_id', folderId); + return fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + atok }, body: fd }); + }; + + // default upload (no folder_id) -> lands in the bound folder + const c1 = await (await up()).json(); + assert.equal(c1.folder_id, boundId, 'default upload lands in the bound folder'); + + // (2) subtree confinement: a subfolder is targetable; a sibling is not + const sub = (await jfetch('/api/folders', jpost(jwt, { name: 'Q1', parent_id: boundId }))).body; + const sibling = (await jfetch('/api/folders', jpost(jwt, { name: 'Internal' }))).body; + const listIds = (await jfetch('/api/agency/folders', { headers: { Authorization: 'Bearer ' + atok } })).body.map(f => f.id).sort(); + assert.deepEqual(listIds, [boundId, sub.id].sort(), 'GET /agency/folders returns ONLY the bound subtree, never the sibling'); + + const c2 = await (await up(sub.id)).json(); + assert.equal(c2.folder_id, sub.id, 'upload targeting an in-subtree folder lands there'); + const blocked = await up(sibling.id); + assert.equal(blocked.status, 403, 'upload to a sibling folder outside the bound subtree -> 403'); + + // (3) PICK an existing folder at creation (no auto-create); unknown pick -> 400 + const picked = (await jfetch('/api/folders', jpost(jwt, { name: 'Chosen' }))).body; + const tok2 = await jfetch('/api/tokens', jpost(jwt, { name: 'Picky', scope: 'agency', target_playlist_ids: [pl.id], upload_folder_id: picked.id })); + assert.equal(tok2.body.upload_folder_id, picked.id, 'admin-picked folder is bound as-is'); + const badPick = await jfetch('/api/tokens', jpost(jwt, { name: 'BadPick', scope: 'agency', target_playlist_ids: [pl.id], upload_folder_id: 'nonexistent' })); + assert.equal(badPick.status, 400, 'binding an unknown/cross-workspace folder at issuance -> 400'); + + // (4) REBIND to root -> uploads land at root, subtree goes empty + const rebind = await jfetch(`/api/tokens/${tokRes.body.id}/upload-folder`, jput({ upload_folder_id: null })); + assert.equal(rebind.status, 200, 'rebind ok'); + assert.equal(rebind.body.upload_folder_id, null, 'rebind cleared the binding (root)'); + const c3 = await (await up()).json(); + assert.equal(c3.folder_id, null, 'after unbinding, uploads land at library root'); + assert.deepEqual((await jfetch('/api/agency/folders', { headers: { Authorization: 'Bearer ' + atok } })).body, [], 'no bound folder -> empty subtree (portal shows no picker)'); +});