eee0885811
- 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>
497 lines
16 KiB
JavaScript
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' });
|
|
}
|
|
}
|