c9998d4a8d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
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<typeof createClient>, userId: string): Promise<ExternalProject[]> {
|
|
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<string, number>();
|
|
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<Access | null> {
|
|
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 res = await fetch(`${FBQ_URL}/api/raw?source=${SOURCE}&path=${encodeURIComponent(resolvedPath)}`, { headers: fbqHeaders });
|
|
const ct = res.headers.get('content-type') ?? 'application/octet-stream';
|
|
const cd = res.headers.get('content-disposition') ?? `attachment; filename="${resolvedPath.split('/').pop()}"`;
|
|
return new Response(res.body, { status: res.status, headers: { ...cors(origin), 'Content-Type': ct, 'Content-Disposition': cd } });
|
|
}
|
|
|
|
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);
|
|
});
|