Files
fourge-portal/api/seafile.js
T
Krao Hasanee eee0885811 Fix file sharing load speed and move error; misc updates
- Remove recursive directory size calculations (single Seafile API call per list)
- Remove 'Used in this location' usage display
- Fix move using v2 per-type endpoints instead of broken batch endpoint
- Send entry type from frontend for correct move routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:20:38 -04:00

497 lines
16 KiB
JavaScript

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 === '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' });
}
}