import { createClient } from '@supabase/supabase-js'; const FB_SOURCE = 'srv'; const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files']; const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey']; export const config = { api: { bodyParser: false, sizeLimit: '50mb' } }; 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 safeName(value, fallback = '') { const cleaned = String(value || '') .trim() .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-') .replace(/\s+/g, ' ') .replace(/^-+|-+$/g, ''); return cleaned || fallback; } async function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } function getConfig() { const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, ''); return { url, token: process.env.FILEBROWSER_TOKEN || '', clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'), configured: Boolean(url), }; } function getToken(config) { const token = String(config.token || '').trim(); if (!token) throw new Error('FILEBROWSER_TOKEN not configured'); return token; } async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); const url = `${config.url}${endpoint}?${qs}`; const token = getToken(config); const response = await fetch(url, { method, headers: { Authorization: `Bearer ${token}`, ...headers }, body, }); if (!response.ok) { const text = await response.text().catch(() => ''); const error = new Error(text || `FileBrowser ${response.status}`); error.status = response.status; throw error; } const text = await response.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, role, company:companies(id, name)') .eq('id', userData.user.id) .single(); if (profileError) return { ok: false, status: 500, message: profileError.message }; if (!['team', 'client'].includes(profile?.role)) { return { ok: false, status: 403, message: 'Forbidden' }; } const 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 row of memberships || []) { if (row.company?.id && !seen.has(row.company.id)) { seen.add(row.company.id); clientCompanies.push(row.company); } } } return { ok: true, profile: { ...profile, clientCompanies }, }; } function createAdminClient() { const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured'); return createClient(supabaseUrl, serviceKey, { auth: { persistSession: false, autoRefreshToken: false }, }); } function isAlreadyExistsError(error) { const message = String(error?.message || '').toLowerCase(); return error?.status === 409 || message.includes('exist') || message.includes('conflict'); } async function ensureDirectory(config, fullPath) { const parts = normalizePath(fullPath).split('/').filter(Boolean); let current = '/'; for (const part of parts) { current = joinPath(current, part); try { await fbFetch(config, 'POST', '/api/resources', { params: { path: current, isDir: 'true' }, }); } catch (error) { if (!isAlreadyExistsError(error)) throw error; } } } async function renameDirectory(config, fromPath, toPath) { const sourcePath = normalizePath(fromPath); const targetPath = normalizePath(toPath); if (sourcePath === targetPath) return { renamed: false, skipped: true, path: targetPath }; try { await fbFetch(config, 'PATCH', '/api/resources', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'rename', items: [{ fromSource: FB_SOURCE, fromPath: sourcePath, toSource: FB_SOURCE, toPath: targetPath }], overwrite: false, }), }); return { renamed: true, path: targetPath }; } catch (error) { if (error?.status === 404) { await ensureDirectory(config, targetPath); return { renamed: false, ensured: true, path: targetPath }; } throw error; } } export default async function handler(req, res) { const action = String(req.query?.action || '').trim(); if (req.method !== 'POST' || ![ 'ensure-company-folder', 'ensure-project-folder', 'ensure-request-folder', 'ensure-profile-folder', 'upload-request-file', 'rename-company-folder', 'rename-project-folder', 'rename-profile-folder', ].includes(action)) { return json(res, 405, { error: 'Method not allowed' }); } const authHeader = req.headers.authorization || ''; if (!authHeader.startsWith('Bearer ')) { return json(res, 401, { error: 'Unauthorized' }); } const config = getConfig(); if (!config.configured) { return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' }); } try { const rawBody = await readBody(req); const body = (() => { if (!rawBody.length) return {}; const contentType = String(req.headers['content-type'] || '').split(';')[0].trim(); if (contentType === 'application/json') { try { return JSON.parse(rawBody.toString()); } catch { return {}; } } return {}; })(); const auth = await requirePortalUser(authHeader); if (!auth.ok) return json(res, auth.status, { error: auth.message }); const admin = createAdminClient(); if (action === 'ensure-company-folder') { if (auth.profile.role !== 'team') { return json(res, 403, { error: 'Forbidden' }); } const companyId = String(body.companyId || '').trim(); if (!companyId) return json(res, 400, { error: 'companyId is required.' }); const { data: company, error: companyError } = await admin .from('companies') .select('id, name') .eq('id', companyId) .single(); if (companyError || !company) return json(res, 404, { error: 'Company not found.' }); const fullPath = joinPath(config.clientRoot, safeName(company.name, company.id)); await ensureDirectory(config, fullPath); return json(res, 200, { ok: true, configured: true, path: fullPath }); } if (action === 'rename-company-folder') { if (auth.profile.role !== 'team') { return json(res, 403, { error: 'Forbidden' }); } const companyId = String(body.companyId || '').trim(); const oldName = safeName(body.oldName, ''); const newName = safeName(body.newName, ''); if (!companyId || !oldName || !newName) return json(res, 400, { error: 'companyId, oldName, and newName are required.' }); const { data: company, error: companyError } = await admin .from('companies') .select('id') .eq('id', companyId) .single(); if (companyError || !company) return json(res, 404, { error: 'Company not found.' }); const result = await renameDirectory(config, joinPath(config.clientRoot, oldName), joinPath(config.clientRoot, newName)); return json(res, 200, { ok: true, configured: true, ...result }); } if (action === 'ensure-profile-folder') { if (auth.profile.role !== 'team') { return json(res, 403, { error: 'Forbidden' }); } const profileId = String(body.profileId || '').trim(); if (!profileId) return json(res, 400, { error: 'profileId is required.' }); const { data: profile, error: profileError } = await admin .from('profiles') .select('id, name, role') .eq('id', profileId) .single(); if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' }); if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' }); const fullPath = joinPath(config.subsRoot, safeName(profile.name, profile.id)); await ensureDirectory(config, fullPath); return json(res, 200, { ok: true, configured: true, path: fullPath }); } if (action === 'rename-profile-folder') { if (auth.profile.role !== 'team') { return json(res, 403, { error: 'Forbidden' }); } const profileId = String(body.profileId || '').trim(); const oldName = safeName(body.oldName, ''); const newName = safeName(body.newName, ''); if (!profileId || !oldName || !newName) return json(res, 400, { error: 'profileId, oldName, and newName are required.' }); const { data: profile, error: profileError } = await admin .from('profiles') .select('id, role') .eq('id', profileId) .single(); if (profileError || !profile) return json(res, 404, { error: 'Profile not found.' }); if (profile.role !== 'external') return json(res, 400, { error: 'Only subcontractor folders are supported here.' }); const result = await renameDirectory(config, joinPath(config.subsRoot, oldName), joinPath(config.subsRoot, newName)); return json(res, 200, { ok: true, configured: true, ...result }); } const projectId = String(req.query?.projectId || body.projectId || '').trim(); if (!projectId) return json(res, 400, { error: 'projectId is required.' }); const { data: project, error: projectError } = await admin .from('projects') .select('id, name, company_id, company:companies(id, name)') .eq('id', projectId) .single(); if (projectError || !project) return json(res, 404, { error: 'Project not found.' }); if (auth.profile.role === 'client') { const allowedCompanyIds = new Set((auth.profile.clientCompanies || []).map(company => company.id)); if (!allowedCompanyIds.has(project.company_id)) return json(res, 403, { error: 'Forbidden' }); } const companyName = safeName(project.company?.name, String(project.company_id || 'company')); const projectName = safeName(project.name, projectId); if (action === 'rename-project-folder') { const oldName = safeName(body.oldName, ''); const newName = safeName(body.newName, ''); if (!oldName || !newName) return json(res, 400, { error: 'oldName and newName are required.' }); const result = await renameDirectory( config, joinPath(config.clientRoot, companyName, 'Projects', oldName), joinPath(config.clientRoot, companyName, 'Projects', newName) ); return json(res, 200, { ok: true, configured: true, ...result }); } if (action === 'ensure-project-folder') { const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName); await ensureDirectory(config, fullPath); for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) { await ensureDirectory(config, joinPath(fullPath, folderName)); } return json(res, 200, { ok: true, configured: true, path: fullPath }); } const taskTitle = safeName(req.query?.taskTitle || body.taskTitle, ''); if (!taskTitle) return json(res, 400, { error: 'taskTitle is required.' }); const fullPath = joinPath(config.clientRoot, companyName, 'Projects', projectName, taskTitle); if (action === 'upload-request-file') { const bucket = String(body.bucket || 'submissions').trim(); const storagePath = String(body.storagePath || '').trim(); const fileName = safeName(body.fileName || req.query?.fileName || req.headers['x-file-name'], ''); if (!storagePath) return json(res, 400, { error: 'storagePath is required.' }); if (!fileName) return json(res, 400, { error: 'fileName is required.' }); const surveyPath = joinPath(fullPath, 'Survey'); await ensureDirectory(config, surveyPath); const { data: downloadedFile, error: downloadError } = await admin.storage.from(bucket).download(storagePath); if (downloadError || !downloadedFile) { return json(res, 500, { error: downloadError?.message || 'Failed to download request file from storage.' }); } const fileBuffer = Buffer.from(await downloadedFile.arrayBuffer()); const uploadUrl = `${config.url}/api/resources?source=${FB_SOURCE}&path=${encodeURIComponent(joinPath(surveyPath, fileName))}&override=true`; const contentType = String(downloadedFile.type || '').trim() || 'application/octet-stream'; const response = await fetch(uploadUrl, { method: 'POST', headers: { Authorization: `Bearer ${getToken(config)}`, 'Content-Type': contentType, }, body: fileBuffer, }); if (!response.ok) { const text = await response.text().catch(() => ''); return json(res, response.status, { error: text || `Upload failed (${response.status})` }); } return json(res, 200, { ok: true, configured: true, path: joinPath(surveyPath, fileName) }); } await ensureDirectory(config, fullPath); for (const folderName of REQUEST_DEFAULT_SUBFOLDERS) { await ensureDirectory(config, joinPath(fullPath, folderName)); } return json(res, 200, { ok: true, configured: true, path: fullPath }); } catch (error) { return json(res, error?.status || 500, { error: error?.message || 'Failed to ensure request folder.' }); } }