import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; const FBQ_URL = Deno.env.get('FBQ_URL') ?? ''; const FBQ_TOKEN = Deno.env.get('FBQ_TOKEN') ?? ''; const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? ''; const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; const SOURCE = 'srv'; function cors(origin: string) { return { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Operation, X-Path, X-Files', }; } function json(body: unknown, status = 200, origin = '*') { return new Response(JSON.stringify(body), { status, headers: { ...cors(origin), 'Content-Type': 'application/json' }, }); } function normalizePath(path: string) { const raw = String(path || '/').trim(); const parts = raw.split('/').filter(Boolean); return '/' + parts.join('/'); } function safeSegment(value: string, fallback = '') { const cleaned = String(value || '') .trim() .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') .replace(/\s+/g, ' ') .replace(/^-+|-+$/g, ''); return cleaned || fallback; } type ExternalProject = { id: string; name: string; companyName: string; virtualName: string; virtualRoot: string; realRoot: string; }; type Access = | { role: 'team'; roots: string[] } | { role: 'client'; roots: string[] } | { role: 'external'; roots: string[]; projects: ExternalProject[] }; function pathAllowed(path: string, access: Access): boolean { if (access.roots.includes('*')) return true; const p = normalizePath(path).replace(/\/+$/, '') || '/'; if (access.role === 'client' && p === '/Clients' && access.roots.some(r => r.startsWith('/Clients/'))) return true; if (access.role === 'external' && p === '/') return true; return access.roots.some(r => p === r || p.startsWith(r.replace(/\/$/, '') + '/')); } async function getExternalProjects(sb: ReturnType, userId: string): Promise { const { data, error } = await sb .from('project_members') .select('project:projects(id, name, company:companies(name))') .eq('profile_id', userId); if (error) throw error; const seen = new Map(); return (data ?? []) .map((row: any) => { const projectId = row?.project?.id; const projectName = row?.project?.name; const companyName = row?.project?.company?.name; if (!projectId || !projectName || !companyName) return null; const baseName = safeSegment(projectName, projectId); const count = seen.get(baseName) ?? 0; seen.set(baseName, count + 1); const virtualName = count === 0 ? baseName : `${baseName} (${safeSegment(companyName, projectId)})`; const companyFolder = safeSegment(companyName, companyName); const projectFolder = safeSegment(projectName, projectId); return { id: projectId, name: projectName, companyName, virtualName, virtualRoot: `/${virtualName}`, realRoot: `/Clients/${companyFolder}/Projects/${projectFolder}`, }; }) .filter(Boolean) .sort((a, b) => a!.virtualName.localeCompare(b!.virtualName)) as ExternalProject[]; } async function getUserAccess(userId: string): Promise { const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY); const { data: profile } = await sb.from('profiles').select('role, company_id').eq('id', userId).single(); if (!profile) return null; if (profile.role === 'team') return { role: 'team', roots: ['*'] }; if (profile.role === 'external') { const projects = await getExternalProjects(sb, userId); return { role: 'external', roots: ['/Team', ...projects.map(project => project.virtualRoot)], projects, }; } if (profile.role === 'client') { const roots: string[] = []; if (profile.company_id) { const { data: co } = await sb.from('companies').select('name').eq('id', profile.company_id).single(); if (co?.name) roots.push(`/Clients/${co.name}`); } const { data: memberships } = await sb.from('company_members').select('company:companies(name)').eq('profile_id', userId); (memberships ?? []).forEach((m: any) => { const n = m?.company?.name; if (n && !roots.includes(`/Clients/${n}`)) roots.push(`/Clients/${n}`); }); return roots.length ? { role: 'client', roots } : null; } return null; } async function listVirtualClientsRoot(roots: string[]) { const folders = roots .filter((root) => root.startsWith('/Clients/')) .map((root) => root.replace(/\/+$/, '')) .map((root) => { const name = root.split('/').filter(Boolean).pop() ?? ''; return { name, filename: name, path: root, type: 'directory', isDir: true, size: 0, }; }) .sort((a, b) => a.name.localeCompare(b.name)); return { folders, files: [] }; } function listVirtualExternalRoot(projects: ExternalProject[]) { return { folders: [ { name: 'Team', filename: 'Team', path: '/Team', type: 'directory', isDir: true, size: 0 }, ...projects.map((project) => ({ name: project.virtualName, filename: project.virtualName, path: project.virtualRoot, type: 'directory', isDir: true, size: 0, })), ], files: [], }; } function resolvePath(path: string, access: Access) { const normalized = normalizePath(path); if (access.role !== 'external') return normalized; if (normalized === '/') return normalized; if (normalized === '/Team' || normalized.startsWith('/Team/')) { return normalized.replace(/^\/Team(?=\/|$)/, '/Team'); } const project = access.projects.find((entry) => normalized === entry.virtualRoot || normalized.startsWith(entry.virtualRoot + '/')); if (!project) return normalized; const suffix = normalized.slice(project.virtualRoot.length); return `${project.realRoot}${suffix}` || project.realRoot; } const fbqHeaders = { Authorization: `Bearer ${FBQ_TOKEN}` }; Deno.serve(async (req) => { const origin = req.headers.get('origin') ?? '*'; if (req.method === 'OPTIONS') return new Response(null, { headers: cors(origin) }); const authHeader = req.headers.get('authorization') ?? ''; if (!authHeader.startsWith('Bearer ')) return json({ error: 'Unauthorized' }, 401, origin); const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY); const { data: { user }, error } = await sb.auth.getUser(authHeader.slice(7)); if (error || !user) return json({ error: 'Unauthorized' }, 401, origin); const access = await getUserAccess(user.id); if (!access) return json({ error: 'Forbidden' }, 403, origin); const op = req.headers.get('x-operation') ?? ''; const path = req.headers.get('x-path') ?? '/'; if (!pathAllowed(path, access)) return json({ error: 'Forbidden' }, 403, origin); const resolvedPath = resolvePath(path, access); if (op === 'list') { if (access.role === 'client' && path.replace(/\/+$/, '') === '/Clients' && !access.roots.includes('*')) { return json(await listVirtualClientsRoot(access.roots), 200, origin); } if (access.role === 'external' && normalizePath(path) === '/') { return json(listVirtualExternalRoot(access.projects), 200, origin); } const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders }); const body = await res.text(); return new Response(body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } }); } if (op === 'archive') { const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[]; const params = new URLSearchParams({ source: SOURCE }); files.map(f => resolvePath(f, access)).forEach(f => params.append('files[]', f)); const res = await fetch(`${FBQ_URL}/api/archive?${params.toString()}`, { headers: fbqHeaders }); const cd = `attachment; filename="download.zip"`; return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/zip', 'Content-Disposition': cd } }); } if (op === 'download') { const rawUrl = `${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`; const rawRes = await fetch(rawUrl, { headers: fbqHeaders }); if (rawRes.ok) { const ct = rawRes.headers.get('content-type') ?? 'application/octet-stream'; const cd = rawRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`; return new Response(rawRes.body, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } }); } const rawErrorText = await rawRes.text(); const shouldFallbackToResourcesDownload = rawRes.status === 400 && rawErrorText.toLowerCase().includes('no files specified'); if (!shouldFallbackToResourcesDownload) { return new Response(rawErrorText, { status: rawRes.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } }); } const directDownloadUrl = `${FBQ_URL}/api/resources/download?source=${SOURCE}&file=${encodeURIComponent(resolvedPath)}`; const directRes = await fetch(directDownloadUrl, { headers: fbqHeaders }); const directCt = directRes.headers.get('content-type') ?? 'application/octet-stream'; const directCd = directRes.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop() ?? 'download'}"`; return new Response(directRes.body, { status: directRes.status, headers: { ...cors(origin), 'Content-Type': directCt, 'Content-Disposition': directCd }, }); } if (op === 'upload') { const body = await req.arrayBuffer(); const ct = req.headers.get('content-type') ?? ''; const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { method: 'POST', headers: { ...fbqHeaders, 'Content-Type': ct }, body, }); const text = await res.text(); return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } }); } if (op === 'delete') { const files = JSON.parse(req.headers.get('x-files') ?? '[]') as string[]; await Promise.all(files.map(f => fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(resolvePath(f, access))}`, { method: 'DELETE', headers: fbqHeaders }) )); return json({ ok: true }, 200, origin); } if (op === 'copy' || op === 'move') { const srcPaths = JSON.parse(req.headers.get('x-files') ?? '[]') as string[]; const destDir = resolvedPath.replace(/\/+$/, ''); const items = srcPaths.map(src => { const resolved = resolvePath(src, access); const name = resolved.split('/').filter(Boolean).pop() ?? ''; return { fromSource: SOURCE, fromPath: resolved, toSource: SOURCE, toPath: `${destDir}/${name}` }; }); const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}`, { method: 'PATCH', headers: { ...fbqHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ action: op, items, overwrite: false }), }); const text = await res.text(); return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } }); } if (op === 'mkdir') { const dirPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/'; const res = await fetch(`${FBQ_URL}/api/resources?source=${SOURCE}&path=${encodeURIComponent(dirPath)}&isDir=true`, { method: 'POST', headers: fbqHeaders, }); const text = await res.text(); return new Response(text, { status: res.status, headers: { ...cors(origin), 'Content-Type': 'application/json' } }); } return json({ error: 'Unknown operation' }, 400, origin); });