import { createClient } from '@supabase/supabase-js'; const FB_SOURCE = 'files'; function json(res, status, body) { res.status(status).setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store'); res.send(JSON.stringify(body)); } function normalizePath(path) { const raw = String(path || '/').trim(); const parts = raw.split('/').filter(Boolean); const clean = []; for (const part of parts) { if (part === '.') continue; if (part === '..') throw new Error('Invalid path: path traversal not allowed'); clean.push(part); } return `/${clean.join('/')}`; } function joinPath(...parts) { return normalizePath(parts.join('/')); } function parentDir(path) { const parts = normalizePath(path).split('/').filter(Boolean); parts.pop(); return `/${parts.join('/')}`; } function basename(path) { const parts = normalizePath(path).split('/').filter(Boolean); return parts[parts.length - 1] || ''; } function safeName(value, fallback = '') { const cleaned = String(value || '') .trim() .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') .replace(/\s+/g, ' ') .replace(/^-+|-+$/g, ''); return cleaned || fallback; } function getConfig() { const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); return { url, token: process.env.FILEBROWSER_TOKEN || '', teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'), clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'), configured: Boolean(url), }; } function getToken(config) { if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured'); return config.token; } async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { const token = getToken(config); const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); const url = `${config.url}${endpoint}?${qs}`; const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}`, ...headers }, body, }); if (!res.ok) { const text = await res.text(); const err = new Error(text || `FileBrowser ${res.status}`); err.status = res.status; throw err; } const text = await res.text(); try { return text ? JSON.parse(text) : null; } catch { return text; } } async function createCallerClient(authHeader) { const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseAnonKey) throw new Error('Supabase env not configured'); return createClient(supabaseUrl, supabaseAnonKey, { auth: { persistSession: false, autoRefreshToken: false }, global: { headers: { Authorization: authHeader } }, }); } async function requirePortalUser(authHeader) { const callerClient = await createCallerClient(authHeader); const { data: userData, error: userError } = await callerClient.auth.getUser(); if (userError || !userData?.user) return { ok: false, status: 401, message: 'Unauthorized' }; const { data: profile, error: profileError } = await callerClient .from('profiles') .select('id, name, role, company:companies(id, name)') .eq('id', userData.user.id) .single(); if (profileError) return { ok: false, status: 500, message: profileError.message }; if (!['team', 'external', 'client'].includes(profile?.role)) { return { ok: false, status: 403, message: 'Forbidden' }; } // Mirror AuthContext: load all companies (FK + company_members) let clientCompanies = []; if (profile.role === 'client') { const seen = new Set(); if (profile.company?.id) { clientCompanies.push(profile.company); seen.add(profile.company.id); } const { data: memberships } = await callerClient .from('company_members') .select('company:companies(id, name)') .eq('profile_id', userData.user.id); for (const m of memberships || []) { if (m.company?.id && !seen.has(m.company.id)) { clientCompanies.push(m.company); seen.add(m.company.id); } } } return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } }; } async function getExternalProjects(callerClient, userId) { const { data, error } = await callerClient .from('project_members') .select('project:projects(id, name, company:companies(name))') .eq('profile_id', userId); if (error) throw new Error(error.message); return (data || []).map(r => r.project).filter(Boolean); } function resolveUserRoot(config, profile) { if (profile.role === 'team') return config.teamRoot; if (profile.role === 'client') { const companyFolder = safeName(profile.company?.name, profile.id); return joinPath(config.clientRoot, companyFolder); } if (profile.role === 'external') return config.externalSubsRoot; return '/'; } function resolveClientPath(config, vPath, companies) { const parts = normalizePath(vPath).split('/').filter(Boolean); if (parts.length === 0) return { virtual: true }; const companyFolder = parts[0]; const match = companies.find(c => safeName(c.name, '') === companyFolder); if (!match) { const err = new Error('Access denied'); err.status = 403; throw err; } const rest = parts.slice(1); const base = joinPath(config.clientRoot, companyFolder); const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base; return { virtual: false, fbPath }; } function buildClientVirtualEntries(companies) { return companies.map(c => { const name = safeName(c.name, c.id); return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` }; }); } function resolveExternalPath(config, vPath, profile, projects) { const myFolder = safeName(profile.name, profile.id); const parts = normalizePath(vPath).split('/').filter(Boolean); if (parts.length === 0) return { virtual: true }; // Their personal Team folder if (parts[0] === myFolder) { const fbPath = joinPath(config.externalSubsRoot, ...parts); return { virtual: false, fbPath }; } // Assigned client projects — flattened: Projects/{project}/... if (parts[0] === 'Projects') { if (parts.length < 2) return { virtual: true }; const [, projectFolder, ...rest] = parts; const match = projects.find(p => safeName(p.name, '') === projectFolder); if (!match) { const err = new Error('Access denied to this project'); err.status = 403; throw err; } const company = safeName(match.company?.name, ''); const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder); const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base; return { virtual: false, fbPath }; } const err = new Error('Access denied'); err.status = 403; throw err; } function buildExternalVirtualEntries(vPath, profile, projects) { const myFolder = safeName(profile.name, profile.id); const parts = normalizePath(vPath).split('/').filter(Boolean); if (parts.length === 0) { const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }]; if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' }); return entries; } if (parts[0] === 'Projects' && parts.length === 1) { const seen = new Set(); return projects .map(p => safeName(p.name, '')) .filter(name => name && !seen.has(name) && seen.add(name)) .map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` })); } return []; } function normalizeQuantumItems(data, virtualPath) { const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' })); const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' })); const items = [...dirs, ...files].map(item => ({ name: item.name, type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file', size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0), mtime: item.modified || null, path: joinPath(virtualPath, item.name), })); return items.sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; if (a.name === '00 Project Files') return -1; if (b.name === '00 Project Files') return 1; return a.name.localeCompare(b.name); }); } function toListResponse(vPath, entries, { readOnly = false } = {}) { return { configured: true, path: vPath, canGoUp: vPath !== '/', parentPath: parentDir(vPath), entries, readOnly, }; } export default async function handler(req, res) { try { const authHeader = req.headers.authorization || ''; if (!authHeader) return json(res, 401, { error: 'No authorization header' }); const auth = await requirePortalUser(authHeader); if (!auth.ok) return json(res, auth.status, { error: auth.message }); const config = getConfig(); if (!config.configured || !config.token) { return json(res, 200, { configured: false, error: 'FileBrowser not configured.', requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'], }); } const action = req.query.action || (req.method === 'GET' ? 'list' : ''); const requestedPath = req.query.path || req.body?.path || '/'; let externalProjects = []; if (auth.profile.role === 'external') { externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id); } function toFbPath(vPath = requestedPath) { if (auth.profile.role === 'external') { return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects); } if (auth.profile.role === 'client') { return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies); } const root = resolveUserRoot(config, auth.profile); return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) }; } if (req.method === 'GET' && action === 'config') { return json(res, 200, { configured: true, role: auth.profile.role, url: config.url }); } if (req.method === 'GET' && action === 'list') { const vPath = normalizePath(requestedPath); if (auth.profile.role === 'external') { const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects); if (resolved.virtual) { return json(res, 200, toListResponse(vPath, buildExternalVirtualEntries(vPath, auth.profile, externalProjects), { readOnly: true })); } const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } }); return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); } if (auth.profile.role === 'client') { const resolved = resolveClientPath(config, vPath, auth.profile.clientCompanies); if (resolved.virtual) { return json(res, 200, toListResponse(vPath, buildClientVirtualEntries(auth.profile.clientCompanies))); } const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } }); return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); } const root = resolveUserRoot(config, auth.profile); const fbPath = joinPath(root, vPath); const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbPath } }); return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath))); } if (req.method === 'GET' && action === 'download') { const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); const token = getToken(config); const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`; return json(res, 200, { url: downloadUrl, token }); } if (req.method === 'POST' && action === 'upload-token') { const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' }); const token = getToken(config); return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath }); } if (req.method === 'POST' && action === 'mkdir') { const folderName = safeName(req.body?.name, ''); if (!folderName) return json(res, 400, { error: 'Folder name required' }); const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot create folder in virtual directory' }); await fbFetch(config, 'POST', '/api/resources', { params: { path: joinPath(resolved.fbPath, folderName), isDir: 'true' }, }); return json(res, 200, { success: true }); } if (req.method === 'DELETE' && action === 'delete') { const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' }); if (!basename(resolved.fbPath)) return json(res, 400, { error: 'Cannot delete root' }); await fbFetch(config, 'DELETE', '/api/resources', { params: { path: resolved.fbPath } }); return json(res, 200, { success: true }); } if (req.method === 'POST' && action === 'rename') { const newName = safeName(req.body?.name, ''); if (!newName) return json(res, 400, { error: 'New name required' }); const resolved = toFbPath(); if (resolved.virtual) return json(res, 400, { error: 'Cannot rename virtual directory' }); const newFbPath = joinPath(parentDir(resolved.fbPath), newName); await fbFetch(config, 'PATCH', '/api/resources', { params: {}, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'rename', items: [{ fromSource: FB_SOURCE, fromPath: resolved.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], overwrite: false, }), }); return json(res, 200, { success: true }); } if (req.method === 'POST' && action === 'archive-move') { // Moves srcPath into dstParentPath. If destination already exists, merges contents recursively. const srcVPath = req.body?.srcPath; const dstParentVPath = req.body?.dstParentPath; if (!srcVPath || !dstParentVPath) return json(res, 400, { error: 'srcPath and dstParentPath required' }); const resolvedSrc = toFbPath(srcVPath); const resolvedDstParent = toFbPath(dstParentVPath); if (resolvedSrc.virtual || resolvedDstParent.virtual) return json(res, 400, { error: 'Cannot operate on virtual directories' }); async function fbExists(path) { try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; } catch (e) { if (e.status === 404) return false; throw e; } } async function fbMkdir(path) { await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {}); } async function mergeMove(fbSrc, fbDstParent) { const name = basename(fbSrc); const fbDst = joinPath(fbDstParent, name); const destExists = await fbExists(fbDst); if (!destExists) { await fbFetch(config, 'PATCH', '/api/resources', { params: {}, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], overwrite: false }), }); } else { const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } }); const dirs = (data?.folders || []).map(f => f.name); const files = (data?.files || []).map(f => f.name); await fbMkdir(fbDst); for (const dir of dirs) await mergeMove(joinPath(fbSrc, dir), fbDst); for (const file of files) { await fbFetch(config, 'PATCH', '/api/resources', { params: {}, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], overwrite: true }), }).catch(() => {}); } await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {}); } } await mergeMove(resolvedSrc.fbPath, resolvedDstParent.fbPath); return json(res, 200, { success: true }); } if (req.method === 'POST' && action === 'move') { const srcPath = req.body?.srcPath; const dstPath = req.body?.dstPath; if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' }); const resolvedSrc = toFbPath(srcPath); const resolvedDst = toFbPath(dstPath); if (resolvedSrc.virtual || resolvedDst.virtual) return json(res, 400, { error: 'Cannot move virtual directories' }); const itemName = basename(resolvedSrc.fbPath); const newFbPath = joinPath(resolvedDst.fbPath, itemName); await fbFetch(config, 'PATCH', '/api/resources', { params: {}, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], overwrite: false, }), }); return json(res, 200, { success: true }); } return json(res, 405, { error: 'Method not allowed' }); } catch (error) { return json(res, error.status || 500, { error: error.message || 'Unexpected error' }); } }