import { createClient } from '@supabase/supabase-js'; const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000; const directoryUsageCache = new Map(); 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 normalizeBaseUrl(url) { return String(url || '').trim().replace(/\/+$/, ''); } function normalizePath(path) { const raw = String(path || '/').trim(); const parts = raw.split('/').filter(Boolean); const clean = []; for (const part of parts) { if (part === '.' || part === '') continue; if (part === '..') throw new Error('Invalid path'); 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; } function fillTemplate(template, profile) { const name = safeName(profile.name, profile.id); const companyName = safeName(profile.company?.name, name); const email = safeName(profile.email, profile.id); const emailName = safeName(String(profile.email || '').split('@')[0], profile.id); return String(template || '') .replaceAll('{id}', profile.id) .replaceAll('{name}', name) .replaceAll('{companyName}', companyName) .replaceAll('{email}', email) .replaceAll('{emailName}', emailName); } function getConfig() { const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL); const apiToken = process.env.SEAFILE_API_TOKEN; const repoId = process.env.SEAFILE_REPO_ID; return { serverUrl, webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl), apiToken, repoId, teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'), externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'), externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}', clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'), clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}', configured: Boolean(serverUrl && apiToken && repoId), }; } 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 auth env is not configured on Vercel.'); } 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' }; } return { ok: true, callerClient, profile: { ...profile, email: userData.user.email, }, }; } function getUserRoot(config, profile) { if (profile.role === 'team') return config.teamRoot; if (profile.role === 'client') { const templated = fillTemplate(config.clientTemplate, profile); if (templated.startsWith('/')) return normalizePath(templated); return joinPath(config.clientRoot, templated); } const templated = fillTemplate(config.externalTemplate, profile); if (templated.startsWith('/')) return normalizePath(templated); return joinPath(config.externalRoot, templated); } function resolveSeafilePath(config, profile, requestedPath = '/') { const root = getUserRoot(config, profile); const virtualPath = normalizePath(requestedPath); return { root, virtualPath, seafilePath: joinPath(root, virtualPath), }; } async function seafileRequest(config, endpoint, options = {}) { const response = await fetch(`${config.serverUrl}${endpoint}`, { ...options, headers: { Authorization: `Token ${config.apiToken}`, Accept: 'application/json', ...(options.headers || {}), }, }); const text = await response.text(); let body = text; try { body = text ? JSON.parse(text) : null; } catch { body = text; } if (!response.ok) { const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`; const error = new Error(message); error.status = response.status; throw error; } return body; } async function createSeafileFolder(config, path) { const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' }); await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); } async function createSeafileFolderIfMissing(config, path) { try { await createSeafileFolder(config, path); return true; } catch (error) { const message = String(error.message || '').toLowerCase(); if (error.status === 400 || message.includes('already') || message.includes('exist')) return false; throw error; } } function getCachedDirectoryUsage(cacheKey) { const cached = directoryUsageCache.get(cacheKey); if (!cached) return null; if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) { directoryUsageCache.delete(cacheKey); return null; } return cached.bytes; } function setCachedDirectoryUsage(cacheKey, bytes) { directoryUsageCache.set(cacheKey, { bytes: Number(bytes) || 0, timestamp: Date.now(), }); } async function listDirectoryEntries(config, path) { const entries = await seafileRequest( config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}` ); return Array.isArray(entries) ? entries : []; } async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) { const normalizedPath = normalizePath(path); const cacheKey = `${config.repoId}:${normalizedPath}`; const cached = getCachedDirectoryUsage(cacheKey); if (cached != null) return cached; const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath); let total = 0; for (const entry of entries) { if (entry.type === 'file') { total += Number(entry.size || 0); continue; } if (entry.type === 'dir') { total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name)); } } setCachedDirectoryUsage(cacheKey, total); return total; } function clearDirectoryUsageCache(config, path) { const normalizedPath = normalizePath(path); const prefixes = []; let cursor = normalizedPath; while (true) { prefixes.push(`${config.repoId}:${cursor}`); if (cursor === '/') break; cursor = parentDir(cursor); } for (const key of prefixes) { directoryUsageCache.delete(key); } } function entryPath(parent, name) { return joinPath(parent, name); } function parentDir(path) { const normalized = normalizePath(path); const parts = normalized.split('/').filter(Boolean); parts.pop(); return `/${parts.join('/')}`; } function basename(path) { const parts = normalizePath(path).split('/').filter(Boolean); return parts[parts.length - 1] || ''; } async function syncManagedFolders(config, auth) { if (auth.profile.role !== 'team') { const error = new Error('Team only'); error.status = 403; throw error; } const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([ auth.callerClient.from('companies').select('id, name').order('name'), auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'), ]); if (companiesError) throw new Error(companiesError.message); if (externalsError) throw new Error(externalsError.message); const folderPaths = [ config.clientRoot, config.externalRoot, ...(companies || []).map(company => { const profile = { id: company.id, name: company.name, email: '', company }; const templated = fillTemplate(config.clientTemplate, profile); return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated); }), ...(externals || []).map(profile => { const templated = fillTemplate(config.externalTemplate, profile); return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated); }), ]; const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/'); let created = 0; for (const path of uniquePaths) { if (await createSeafileFolderIfMissing(config, path)) created += 1; } return { created, checked: uniquePaths.length, clients: companies?.length || 0, subcontractors: externals?.length || 0, }; } 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) { return json(res, 200, { configured: false, error: 'Seafile is not configured yet.', requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'], }); } const action = req.query.action || (req.method === 'GET' ? 'list' : ''); const requestedPath = req.query.path || req.body?.path; const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/'); const invalidateUsage = req.query.invalidateUsage === '1'; if (req.method === 'POST' && action === 'sync-folders') { const result = await syncManagedFolders(config, auth); return json(res, 200, { success: true, ...result }); } if (req.method === 'GET' && action === 'config') { return json(res, 200, { configured: true, role: auth.profile.role, root: resolved.root, webUrl: config.webUrl, }); } if (req.method === 'GET' && action === 'list') { if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath); let entries; try { entries = await listDirectoryEntries(config, resolved.seafilePath); } catch (error) { if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error; await createSeafileFolder(config, resolved.root); entries = await listDirectoryEntries(config, resolved.seafilePath); } const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => { const itemPath = entryPath(resolved.virtualPath, item.name); return { id: item.id, name: item.name, type: item.type, size: item.type === 'file' ? Number(item.size || 0) : 0, aggregateSize: item.type === 'file' ? Number(item.size || 0) : null, mtime: item.mtime || null, permission: item.permission || null, path: itemPath, }; }).sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; return a.name.localeCompare(b.name); }); return json(res, 200, { configured: true, path: resolved.virtualPath, canGoUp: resolved.virtualPath !== '/', parentPath: parentDir(resolved.virtualPath), entries: normalizedEntries, webUrl: config.webUrl, }); } if (req.method === 'GET' && action === 'download') { const url = await seafileRequest( config, `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1` ); return json(res, 200, { url }); } if (req.method === 'POST' && action === 'mkdir') { const folderName = safeName(req.body?.name, ''); if (!folderName) return json(res, 400, { error: 'Folder name is required.' }); const folderPath = joinPath(resolved.seafilePath, folderName); await createSeafileFolder(config, folderPath); clearDirectoryUsageCache(config, resolved.seafilePath); return json(res, 200, { success: true }); } if (req.method === 'POST' && action === 'upload-link') { const uploadLink = await seafileRequest( config, `/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}` ); return json(res, 200, { uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''), parentDir: resolved.seafilePath, }); } if (req.method === 'POST' && action === 'rename') { const newName = safeName(req.body?.name, ''); if (!newName) return json(res, 400, { error: 'New name is required.' }); const type = req.body?.type || 'file'; const endpoint = type === 'dir' ? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}` : `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`; const body = new URLSearchParams({ operation: 'rename', newname: newName }); await seafileRequest(config, endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); return json(res, 200, { success: true }); } if (req.method === 'POST' && action === 'move') { const srcPath = req.body?.srcPath; const dstDir = req.body?.dstDir; if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' }); const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath); const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir); const itemName = basename(resolvedSrc.seafilePath); const srcDir = parentDir(resolvedSrc.seafilePath); if (!itemName) return json(res, 400, { error: 'Cannot move root.' }); const type = req.body?.type || 'file'; const endpoint = type === 'dir' ? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}` : `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`; const body = new URLSearchParams({ operation: 'move', dst_repo: config.repoId, dst_dir: resolvedDst.seafilePath, }); await seafileRequest(config, endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); clearDirectoryUsageCache(config, srcDir); clearDirectoryUsageCache(config, resolvedDst.seafilePath); return json(res, 200, { success: true }); } if (req.method === 'DELETE' && action === 'delete') { const type = req.query.type || req.body?.type; if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' }); const itemName = basename(resolved.seafilePath); const itemParent = parentDir(resolved.seafilePath); if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' }); const body = new URLSearchParams({ file_names: itemName, }); await seafileRequest( config, `/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, } ); clearDirectoryUsageCache(config, itemParent); 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 Seafile error' }); } }