const express = require('express') const fs = require('fs') const path = require('path') const crypto = require('crypto') const Database = require('better-sqlite3') const multer = require('multer') const archiver = require('archiver') const DATA_DIR = path.join(process.cwd(), 'data') const DB_PATH = process.env.OSS_FILE_CABINET_DB_PATH || path.join(DATA_DIR, 'oss_file_cabinet.db') const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const OSS_CONFIG = { bucket: process.env.OSS_FILE_CABINET_OSS_BUCKET || process.env.DOC_CLOUD_KEEPER_OSS_BUCKET || 'android-o1-images', endpoint: process.env.OSS_FILE_CABINET_OSS_ENDPOINT || process.env.DOC_CLOUD_KEEPER_OSS_ENDPOINT || 'oss-cn-shanghai.aliyuncs.com', root_prefix: process.env.OSS_FILE_CABINET_OSS_ROOT_PREFIX || '550e8400-e29b-41d4-a716-446655442082/file_cabinet', access_key_id: process.env.OSS_FILE_CABINET_OSS_ACCESS_KEY_ID || process.env.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID || '', access_key_secret: process.env.OSS_FILE_CABINET_OSS_ACCESS_KEY_SECRET || process.env.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET || '' } const AUTH_COOKIE = 'oss_file_cabinet_auth' const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 const AUTH_HMAC_SECRET = process.env.OSS_FILE_CABINET_AUTH_SECRET || 'ofc-auth-secret-change-me' if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) let db = null const getDb = () => { if (db) return db db = new Database(DB_PATH) db.pragma('journal_mode = WAL') db.pragma('synchronous = NORMAL') db.pragma('busy_timeout = 4000') db.exec(` CREATE TABLE IF NOT EXISTS trash_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, oss_key TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, is_folder INTEGER NOT NULL DEFAULT 0, original_path TEXT NOT NULL, size INTEGER DEFAULT 0, deleted_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_trash_items_deleted_at ON trash_items(deleted_at); CREATE TABLE IF NOT EXISTS share_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, oss_key TEXT NOT NULL, display_name TEXT NOT NULL, is_folder INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token); CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at); `) return db } const safeName = name => { let s = String(name || 'untitled') try { const recovered = Buffer.from(s, 'latin1').toString('utf8') if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered } catch {} return s.replace(/[<>\":/\\|?*\u0000-\u001F]/g, '_').trim() || 'untitled' } const normalizeOssKey = key => { let k = String(key || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/') const endsSlash = k.endsWith('/') if (k.startsWith('/')) k = k.slice(1) if (endsSlash && !k.endsWith('/') && k) k += '/' return k } const fullKey = relKey => { const r = normalizeOssKey(relKey) const p = OSS_CONFIG.root_prefix ? `${OSS_CONFIG.root_prefix.replace(/\/+$/, '')}/` : '' return p + r } const displayNameFromKey = key => { const k = normalizeOssKey(key) if (!k) return '' const parts = k.split('/').filter(Boolean) const last = parts[parts.length - 1] || '' if (k.endsWith('/') && last) return last return last } const encodeOssKey = key => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/') const ossObjectResource = key => `/${OSS_CONFIG.bucket}/${String(key || '').replace(/^\/+/, '')}` const readLocalCreds = () => { try { const os = require('os') const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir() const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')] const usersRoot = path.join(path.parse(userProfile).root, 'Users') try { if (fs.existsSync(usersRoot)) { fs.readdirSync(usersRoot).forEach(name => { const dir = path.join(usersRoot, name) if (dir !== userProfile && fs.existsSync(dir)) { candidates.push(path.join(dir, 'Toolbox_local_creds.env.local')) } }) } } catch {} let content = null for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } } if (!content) return {} const creds = {} content.split(/\r?\n/).forEach(line => { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) return const idx = trimmed.indexOf('=') if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim() }) return creds } catch { return {} } } const getExpectedUsername = () => { const fromEnv = String(process.env.OSS_FILE_CABINET_USERNAME || '').trim() if (fromEnv) return fromEnv const creds = readLocalCreds() return String(creds.OSS_FILE_CABINET_USERNAME || '').trim() } const getExpectedPassword = () => { const fromEnv = String(process.env.OSS_FILE_CABINET_PASSWORD || '').trim() if (fromEnv) return fromEnv const creds = readLocalCreds() return String(creds.OSS_FILE_CABINET_PASSWORD || '').trim() } const getExpectedToken = () => { const fromEnv = String(process.env.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim() if (fromEnv) return fromEnv const creds = readLocalCreds() return String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim() } const getLocalCredsToken = (req) => { const headerToken = String((req.headers && req.headers['x-local-creds-token']) || '').trim() if (headerToken) return headerToken if (req.method !== 'GET' && req.body && typeof req.body === 'object') { return String(req.body.token || '').trim() } return '' } const signAuthPayload = (username) => { const payload = { sub: username, iat: Date.now() } const payloadStr = Buffer.from(JSON.stringify(payload)).toString('base64url') const sig = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url') return `${payloadStr}.${sig}` } const verifyAuthPayload = (token) => { if (!token || typeof token !== 'string') return null const [payloadStr, sig] = token.split('.') if (!payloadStr || !sig) return null const expected = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url') if (expected.length !== sig.length) return null if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null try { const payload = JSON.parse(Buffer.from(payloadStr, 'base64url').toString()) if (!payload || typeof payload !== 'object') return null return payload } catch { return null } } const parseCookies = (cookieHeader) => { const cookies = {} if (!cookieHeader) return cookies cookieHeader.split(';').forEach(part => { const idx = part.indexOf('=') if (idx < 0) return const key = part.slice(0, idx).trim() const val = decodeURIComponent(part.slice(idx + 1).trim()) if (key) cookies[key] = val }) return cookies } const hasAuth = (req) => { const cookies = parseCookies(req.headers.cookie || '') const token = cookies[AUTH_COOKIE] if (!token) return false return !!verifyAuthPayload(token) } const mustAuth = (req, res, next) => { if (hasAuth(req)) return next() return res.status(401).json({ ok: false, error: '未登录或会话已过期' }) } const signOssRequest = ({ method, contentType = '', date, key, headers = {} }) => { const canonicalHeaders = Object.keys(headers) .filter(k => k && k.toLowerCase().startsWith('x-oss-')) .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) .map(k => `${k.toLowerCase()}:${String(headers[k] || '').trim()}`) .join('\n') const resource = ossObjectResource(key) const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}` return crypto.createHmac('sha1', OSS_CONFIG.access_key_secret).update(stringToSign).digest('base64') } const httpRequest = (options, body = null, timeoutMs = 30000) => new Promise((resolve, reject) => { const lib = options.protocol === 'https:' ? require('https') : require('http') const req = lib.request(options, (res) => { const chunks = [] res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const buf = Buffer.concat(chunks) resolve({ statusCode: res.statusCode, headers: res.headers, body: buf }) }) }) req.on('error', reject) req.setTimeout(timeoutMs, () => { req.destroy(new Error('request timeout')) }) if (body !== null && body !== undefined) { if (Buffer.isBuffer(body) || typeof body === 'string') req.write(body) } req.end() }) const ossListObjects = async (prefix = '', delimiter = '/') => { const fullPrefix = fullKey(prefix) const date = new Date().toUTCString() const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: '' })}` const query = new URLSearchParams() query.set('prefix', fullPrefix) if (delimiter) query.set('delimiter', delimiter) query.set('max-keys', '1000') const options = { hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, path: `/?${query.toString()}`, method: 'GET', headers: { Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, Date: date, Authorization: auth } } const resp = await httpRequest(options) if (resp.statusCode < 200 || resp.statusCode >= 300) { throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) } return parseOssListXml(resp.body.toString('utf8')) } const parseOssListXml = (xml) => { const extractTag = (tag, src) => { const open = `<${tag}>` const close = `` const result = [] let idx = 0 while (true) { const s = src.indexOf(open, idx) if (s < 0) break const e = src.indexOf(close, s + open.length) if (e < 0) break result.push(src.slice(s + open.length, e)) idx = e + close.length } return result } const extractTagInSection = (tag, sectionOpen, sectionClose, src) => { const result = [] let searchFrom = 0 while (true) { const sOpen = src.indexOf(sectionOpen, searchFrom) if (sOpen < 0) break const sClose = src.indexOf(sectionClose, sOpen) if (sClose < 0) break const section = src.slice(sOpen, sClose) result.push(...extractTag(tag, section)) searchFrom = sClose + sectionClose.length } return result } const folders = extractTagInSection('Prefix', '', '', xml).map(p => ({ key: p, name: p.split('/').filter(Boolean).pop() + '/', isFolder: true, size: 0 })) const fileKeys = extractTag('Key', xml) const fileSizes = extractTag('Size', xml) const fileLms = extractTag('LastModified', xml) const files = fileKeys.map((k, i) => { const size = parseInt(fileSizes[i] || '0', 10) || 0 const lastModified = fileLms[i] ? Date.parse(fileLms[i]) : 0 return { key: k, name: k.split('/').filter(Boolean).pop(), isFolder: false, size, lastModified } }).filter(f => f.key && !f.key.endsWith('/')) return { folders, files } } const ossPutObject = async (key, body, contentType = 'application/octet-stream') => { const full = fullKey(key) const date = new Date().toUTCString() const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'PUT', contentType, date, key: full })}` const options = { hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, path: `/${encodeOssKey(full)}`, method: 'PUT', headers: { Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, Date: date, 'Content-Type': contentType, 'Content-Length': Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || ''), Authorization: auth } } const resp = await httpRequest(options, body) if (resp.statusCode < 200 || resp.statusCode >= 300) { throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) } return { key: full } } const ossDeleteObject = async (key) => { const full = fullKey(key) const date = new Date().toUTCString() const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'DELETE', date, key: full })}` const options = { hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, path: `/${encodeOssKey(full)}`, method: 'DELETE', headers: { Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, Date: date, Authorization: auth } } const resp = await httpRequest(options) if (resp.statusCode < 200 || resp.statusCode >= 300) { throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) } return true } const ossCopyObject = async (srcKey, destKey) => { const srcFull = fullKey(srcKey) const destFull = fullKey(destKey) const date = new Date().toUTCString() const xOssCopySource = `/${OSS_CONFIG.bucket}/${encodeOssKey(srcFull)}` const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'PUT', date, key: destFull, headers: { 'x-oss-copy-source': xOssCopySource } })}` const options = { hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, path: `/${encodeOssKey(destFull)}`, method: 'PUT', headers: { Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, Date: date, 'x-oss-copy-source': xOssCopySource, Authorization: auth } } const resp = await httpRequest(options) if (resp.statusCode < 200 || resp.statusCode >= 300) { throw new Error(`OSS copy failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`) } return true } const ossGetObjectBuffer = async (key) => { const full = fullKey(key) const date = new Date().toUTCString() const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: full })}` const options = { hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, path: `/${encodeOssKey(full)}`, method: 'GET', headers: { Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, Date: date, Authorization: auth } } return await httpRequest(options) } const ossObjectStream = (key) => new Promise((resolve, reject) => { const https = require('https') const full = fullKey(key) const date = new Date().toUTCString() const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: full })}` const options = { hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, path: `/${encodeOssKey(full)}`, method: 'GET', headers: { Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`, Date: date, Authorization: auth } } const req = https.request(options, (res) => { if (res.statusCode < 200 || res.statusCode >= 300) { const chunks = [] res.on('data', c => chunks.push(c)) res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 200)}`))) return } resolve({ stream: res, headers: res.headers }) }) req.on('error', reject) req.setTimeout(30000, () => req.destroy(new Error('request timeout'))) req.end() }) const listRecursive = async (prefix = '') => { const all = [] const stack = [prefix] while (stack.length > 0) { const current = stack.pop() const result = await ossListObjects(current, '/') for (const f of result.folders) stack.push(normalizeOssKey(f.key)) for (const f of result.files) all.push(f) } return all } const ossDeleteFolderRecursive = async (prefix = '') => { const files = await listRecursive(prefix) for (const f of files) { const relKey = normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix ? OSS_CONFIG.root_prefix.length + 1 : 0) await ossDeleteObject(relKey) } const emptyPrefixKey = normalizeOssKey(prefix).replace(/\/+$/, '') + '/' try { await ossDeleteObject(emptyPrefixKey) } catch {} } const humanSize = bytes => { const b = Number(bytes) || 0 if (b < 1024) return `${b} B` if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB` if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB` return `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB` } const getExt = name => { const n = String(name || '') const idx = n.lastIndexOf('.') if (idx < 0 || idx === n.length - 1) return '' return n.slice(idx + 1).toLowerCase() } const isImage = name => { const ext = getExt(name) return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) } const isVideo = name => { const ext = getExt(name) return ['mp4', 'mov', 'avi', 'webm', 'mkv', 'flv', 'm4v'].includes(ext) } const isPdf = name => getExt(name) === 'pdf' const isText = name => { const ext = getExt(name) return ['txt', 'md', 'json', 'js', 'ts', 'html', 'css', 'xml', 'log', 'csv', 'yml', 'yaml', 'sh', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(ext) } const contentTypeByExt = name => { const ext = getExt(name) const map = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8', js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json', xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv', mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska', flv: 'video/x-flv', m4v: 'video/mp4', mp3: 'audio/mpeg', zip: 'application/zip' } return map[ext] || 'application/octet-stream' } const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 } }) const createRouter = () => { const router = express.Router() router.use((req, res, next) => { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') res.set('Pragma', 'no-cache') res.set('Expires', '0') } catch {} next() }) router.post('/auth/login', (req, res) => { try { const username = String((req.body && req.body.username) || '').trim() const password = String((req.body && req.body.password) || '') const expectedUser = getExpectedUsername() const expectedPass = getExpectedPassword() if (!expectedUser || !expectedPass) return res.status(500).json({ ok: false, error: '服务器未配置登录凭据' }) if (username !== expectedUser || password !== expectedPass) return res.status(401).json({ ok: false, error: '用户名或密码错误' }) const token = signAuthPayload(username) res.cookie(AUTH_COOKIE, token, { httpOnly: true, sameSite: 'lax', maxAge: AUTH_MAX_AGE_MS, path: '/' }) res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/auth/logout', (req, res) => { res.clearCookie(AUTH_COOKIE, { path: '/' }) res.json({ ok: true }) }) router.get('/auth/me', (req, res) => { res.json({ ok: true, authorized: hasAuth(req) }) }) router.get('/auth/local_creds', (req, res) => { try { const token = getLocalCredsToken(req) const expectedToken = getExpectedToken() if (!expectedToken || token !== expectedToken) return res.status(401).json({ ok: false, error: '令牌无效' }) const username = getExpectedUsername() const password = getExpectedPassword() if (!username || !password) return res.status(500).json({ ok: false, error: '未配置本地凭据' }) res.json({ ok: true, creds: { username, password } }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/share/access/:token', async (req, res) => { try { const token = String(req.params.token || '').trim() const now = Date.now() const d = getDb() const row = d.prepare('SELECT * FROM share_links WHERE token = ?').get(token) if (!row) return res.status(404).json({ ok: false, error: '链接不存在' }) if (row.expires_at < now) return res.status(410).json({ ok: false, error: '链接已过期' }) const key = normalizeOssKey(row.oss_key) const isFolder = row.is_folder === 1 const displayName = row.display_name if (!isFolder) { const { stream, headers } = await ossObjectStream(key) res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`) if (headers['content-type']) res.set('Content-Type', headers['content-type']) if (headers['content-length']) res.set('Content-Length', headers['content-length']) stream.pipe(res) } else { const files = await listRecursive(key) res.set('Content-Type', 'application/zip') res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName + '.zip')}`) const archive = archiver('zip', { zlib: { level: 6 } }) archive.on('error', err => { try { res.status(500).end() } catch {} }) archive.pipe(res) const prefixClean = normalizeOssKey(key).replace(/\/+$/, '') + '/' for (const f of files) { try { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(prefixClean.length) const { stream } = await ossObjectStream(fullRel) archive.append(stream, { name: relPart }) } catch {} } archive.finalize() } } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.use(mustAuth) router.get('/files/list', async (req, res) => { try { const prefix = normalizeOssKey(String(req.query.prefix || '')) const search = String(req.query.search || '').trim().toLowerCase() const result = await ossListObjects(prefix || '', '/') const trashDb = getDb() const trashKeys = new Set(trashDb.prepare('SELECT oss_key FROM trash_items').all().map(r => r.oss_key)) const isInTrash = (ossKey) => { const full = fullKey(normalizeOssKey(ossKey)) if (trashKeys.has(full)) return true for (const tk of trashKeys) { if (tk.endsWith('/') && full.startsWith(tk)) return true } return false } const folders = result.folders .map(f => { const relKey = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const display = displayNameFromKey(relKey) return { key: relKey, name: display, isFolder: true, size: 0, lastModified: 0, isImage: false, ext: '', inTrash: isInTrash(relKey) } }) .filter(f => !f.inTrash && f.key && f.name) const files = result.files .map(f => { const relKey = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const display = displayNameFromKey(relKey) return { key: relKey, name: display, isFolder: false, size: f.size, lastModified: f.lastModified, isImage: isImage(display), ext: getExt(display), inTrash: isInTrash(relKey) } }) .filter(f => !f.inTrash && f.key && f.name) const seen = new Set() let allItems = [...folders, ...files].filter(item => { if (seen.has(item.key)) return false seen.add(item.key) return true }) if (search) { allItems = allItems.filter(item => item.name && item.name.toLowerCase().includes(search)) } res.json({ ok: true, items: allItems, prefix, total: allItems.length }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/mkdir', async (req, res) => { try { const parent = normalizeOssKey(String((req.body && req.body.parent) || '')) const folderName = safeName(String((req.body && req.body.name) || '新文件夹')) const folderKey = (parent ? parent + '/' : '') + folderName + '/' await ossPutObject(folderKey, Buffer.alloc(0), 'application/x-directory') res.json({ ok: true, key: normalizeOssKey(folderKey) }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/rename', async (req, res) => { try { const oldKey = normalizeOssKey(String((req.body && req.body.key) || '')) const newName = safeName(String((req.body && req.body.name) || '')) if (!oldKey || !newName) return res.status(400).json({ ok: false, error: '参数错误' }) const isFolder = oldKey.endsWith('/') const parts = oldKey.split('/').filter(Boolean) const parent = parts.slice(0, -1).join('/') const newKey = parent ? parent + '/' + newName : newName const finalNewKey = isFolder ? newKey + '/' : newKey if (!isFolder) { await ossCopyObject(oldKey, finalNewKey) await ossDeleteObject(oldKey) } else { const files = await listRecursive(oldKey) const oldPrefix = normalizeOssKey(oldKey) const newPrefix = normalizeOssKey(finalNewKey) for (const f of files) { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1)) const destKey = newPrefix + (newPrefix.endsWith('/') ? '' : '/') + relPart await ossCopyObject(fullRel, destKey) await ossDeleteObject(fullRel) } await ossPutObject(finalNewKey, Buffer.alloc(0), 'application/x-directory') await ossDeleteObject(oldKey) } res.json({ ok: true, newKey: finalNewKey }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/copy', async (req, res) => { try { const sourceKey = normalizeOssKey(String((req.body && req.body.source) || '')) const destDir = normalizeOssKey(String((req.body && req.body.dest) || '')) if (!sourceKey) return res.status(400).json({ ok: false, error: '源文件缺失' }) const isFolder = sourceKey.endsWith('/') const displayName = displayNameFromKey(sourceKey) const buildUniqueKey = (target) => { let candidate = target let counter = 1 const maxTries = 100 while (counter < maxTries) { try { const r = ossListObjects('', '/') const prefixToCheck = candidate.split('/').slice(0, -1).join('/') + '/' const existing = r.folders.concat(r.files.map(x => ({ key: x.key }))) const exists = existing.some(e => normalizeOssKey(e.key) === normalizeOssKey(candidate) || normalizeOssKey(e.key) === normalizeOssKey(candidate + '/')) if (!exists) return candidate const ext = getExt(candidate) const base = candidate.slice(0, candidate.length - (ext ? ext.length + 1 : 0)).replace(/\/+$/, '') candidate = `${base} 副本${counter}${ext ? '.' + ext : ''}${candidate.endsWith('/') ? '/' : ''}` counter++ } catch { return candidate } } return candidate } if (!isFolder) { let destKey = destDir ? destDir + '/' + displayName : displayName const fullCheck = await ossListObjects(destDir || '', '/') const exists = fullCheck.files.some(f => { const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) return rel === normalizeOssKey(destKey) }) if (exists) { const ext = getExt(displayName) const base = displayName.slice(0, displayName.length - (ext ? ext.length + 1 : 0)) destKey = destDir ? destDir + '/' + `${base} 副本${ext ? '.' + ext : ''}` : `${base} 副本${ext ? '.' + ext : ''}` } await ossCopyObject(sourceKey, destKey) res.json({ ok: true, newKey: destKey }) } else { const files = await listRecursive(sourceKey) const newFolderBase = displayName + ' 副本' let counter = 1 let newFolder = destDir ? destDir + '/' + newFolderBase + '/' : newFolderBase + '/' while (true) { try { const r = await ossListObjects(destDir || '', '/') const exists = r.folders.some(f => { const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) return rel === normalizeOssKey(newFolder) }) if (!exists) break newFolder = destDir ? destDir + '/' + newFolderBase + counter + '/' : newFolderBase + counter + '/' counter++ if (counter > 100) break } catch { break } } await ossPutObject(newFolder, Buffer.alloc(0), 'application/x-directory') const oldPrefix = normalizeOssKey(sourceKey) const newPrefix = normalizeOssKey(newFolder) for (const f of files) { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1)) const destKey = newPrefix + '/' + relPart await ossCopyObject(fullRel, destKey) } res.json({ ok: true, newKey: newFolder }) } } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/move', async (req, res) => { try { const sourceKey = normalizeOssKey(String((req.body && req.body.source) || '')) const destDir = normalizeOssKey(String((req.body && req.body.dest) || '')) if (!sourceKey) return res.status(400).json({ ok: false, error: '源文件缺失' }) const isFolder = sourceKey.endsWith('/') const displayName = displayNameFromKey(sourceKey) if (!isFolder) { const destKey = destDir ? destDir + '/' + displayName : displayName await ossCopyObject(sourceKey, destKey) await ossDeleteObject(sourceKey) res.json({ ok: true, newKey: destKey }) } else { const files = await listRecursive(sourceKey) const newFolder = destDir ? destDir + '/' + displayName + '/' : displayName + '/' await ossPutObject(newFolder, Buffer.alloc(0), 'application/x-directory') const oldPrefix = normalizeOssKey(sourceKey) const newPrefix = normalizeOssKey(newFolder) for (const f of files) { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1)) const destKey = newPrefix + '/' + relPart await ossCopyObject(fullRel, destKey) await ossDeleteObject(fullRel) } try { await ossDeleteObject(oldPrefix + '/') } catch {} res.json({ ok: true, newKey: newFolder }) } } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/delete', async (req, res) => { try { const key = normalizeOssKey(String((req.body && req.body.key) || '')) if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) const isFolder = key.endsWith('/') const full = fullKey(key) const displayName = displayNameFromKey(key) let totalSize = 0 if (isFolder) { try { const files = await listRecursive(key) totalSize = files.reduce((s, f) => s + (Number(f.size) || 0), 0) } catch {} } else { try { const r = await ossListObjects(key.split('/').slice(0, -1).join('/') || '', '/') const hit = r.files.find(f => { const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) return rel === key }) totalSize = hit ? hit.size : 0 } catch {} } const trashDb = getDb() const existing = trashDb.prepare('SELECT id FROM trash_items WHERE oss_key = ?').get(full) if (!existing) { trashDb.prepare(`INSERT INTO trash_items(oss_key, display_name, is_folder, original_path, size, deleted_at) VALUES(?,?,?,?,?,?)`).run( full, displayName, isFolder ? 1 : 0, key, totalSize, Date.now() ) } res.json({ ok: true, moved: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/files/info', async (req, res) => { try { const key = normalizeOssKey(String(req.query.key || '')) if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) const isFolder = key.endsWith('/') const displayName = displayNameFromKey(key) const ext = getExt(displayName) let size = 0 let lastModified = 0 if (!isFolder) { try { const parent = key.includes('/') ? key.split('/').slice(0, -1).join('/') : '' const r = await ossListObjects(parent || '', '/') const hit = r.files.find(f => { const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) return rel === key }) if (hit) { size = hit.size; lastModified = hit.lastModified } } catch {} } else { try { const files = await listRecursive(key) size = files.reduce((s, f) => s + (Number(f.size) || 0), 0) } catch {} } res.json({ ok: true, info: { key, name: displayName, isFolder, ext, size, lastModified, fullPath: fullKey(key), isImage: isImage(displayName) } }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/files/raw_url', async (req, res) => { try { const key = normalizeOssKey(String(req.query.key || '')) if (!key || key.endsWith('/')) return res.status(400).json({ ok: false, error: '仅支持文件' }) const full = fullKey(key) const url = `https://${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}/${encodeOssKey(full)}` res.json({ ok: true, url }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/upload', upload.array('files', 20), async (req, res) => { try { const parent = normalizeOssKey(String((req.body && req.body.prefix) || '')) if (!req.files || !Array.isArray(req.files) || req.files.length === 0) return res.status(400).json({ ok: false, error: '未收到文件' }) const results = [] for (const f of req.files) { const name = safeName(f.originalname || 'file') const key = parent ? parent + '/' + name : name await ossPutObject(key, f.buffer, f.mimetype || contentTypeByExt(name)) results.push({ key, name, size: f.size }) } res.json({ ok: true, results }) } catch (e) { if (String(e.message || '').includes('File too large')) return res.status(413).json({ ok: false, error: '文件超过 50MB 上限' }) res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/files/download', async (req, res) => { try { const key = normalizeOssKey(String(req.query.key || '')) if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) const displayName = displayNameFromKey(key) || 'download' const { stream, headers } = await ossObjectStream(key) res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`) if (headers['content-type']) res.set('Content-Type', headers['content-type']) if (headers['content-length']) res.set('Content-Length', headers['content-length']) stream.pipe(res) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/files/preview', async (req, res) => { try { const key = normalizeOssKey(String(req.query.key || '')) if (!key || key.endsWith('/')) return res.status(400).json({ ok: false, error: '参数错误' }) const displayName = displayNameFromKey(key) const previewable = isImage(displayName) || isVideo(displayName) || isPdf(displayName) || isText(displayName) if (!previewable) return res.status(400).json({ ok: false, error: '不支持的预览格式' }) const { stream, headers } = await ossObjectStream(key) res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(displayName)}`) if (headers['content-type']) res.set('Content-Type', contentTypeByExt(displayName)) if (headers['content-length']) res.set('Content-Length', headers['content-length']) stream.pipe(res) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/folder/download', async (req, res) => { try { const prefix = normalizeOssKey(String(req.query.prefix || '')) if (!prefix) return res.status(400).json({ ok: false, error: '参数错误' }) const displayName = displayNameFromKey(prefix) || 'folder' const files = await listRecursive(prefix) res.set('Content-Type', 'application/zip') res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName + '.zip')}`) const archive = archiver('zip', { zlib: { level: 6 } }) archive.on('error', err => { try { res.status(500).end() } catch {} }) archive.pipe(res) const prefixClean = normalizeOssKey(prefix).replace(/\/+$/, '') + '/' for (const f of files) { try { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(prefixClean.length) const { stream } = await ossObjectStream(fullRel) archive.append(stream, { name: relPart }) } catch {} } archive.finalize() } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/files/batch_download', async (req, res) => { try { const keys = Array.isArray(req.body && req.body.keys) ? req.body.keys : [] const mode = String(req.body && req.body.mode === 'compress' ? 'compress' : 'download') if (keys.length === 0) return res.status(400).json({ ok: false, error: '请选择至少一个项目' }) const archiveName = '归档.zip' if (mode === 'compress') { const parent = normalizeOssKey(String((req.body && req.body.prefix) || '')) let destKey = parent ? parent + '/' + archiveName : archiveName let counter = 1 while (counter < 100) { try { const r = await ossListObjects(parent || '', '/') const existing = r.files.some(f => { const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) return rel === normalizeOssKey(destKey) }) if (!existing) break const base = '归档' destKey = parent ? parent + '/' + base + ' ' + counter + '.zip' : base + ' ' + counter + '.zip' counter++ } catch { break } } const archive = archiver('zip', { zlib: { level: 6 } }) const chunks = [] archive.on('data', chunk => chunks.push(chunk)) const archivePromise = new Promise((resolve, reject) => { archive.on('end', resolve) archive.on('error', reject) }) for (const k of keys) { const nk = normalizeOssKey(k) if (nk.endsWith('/')) { const files = await listRecursive(nk) const prefixClean = nk.replace(/\/+$/, '') + '/' for (const f of files) { try { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(prefixClean.length) const { stream } = await ossObjectStream(fullRel) archive.append(stream, { name: relPart }) } catch {} } } else { const name = displayNameFromKey(nk) try { const { stream } = await ossObjectStream(nk) archive.append(stream, { name }) } catch {} } } archive.finalize() await archivePromise const zipBuf = Buffer.concat(chunks) await ossPutObject(destKey, zipBuf, 'application/zip') res.json({ ok: true, key: destKey, name: archiveName }) } else { res.set('Content-Type', 'application/zip') res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(archiveName)}`) const archive = archiver('zip', { zlib: { level: 6 } }) archive.on('error', err => { try { res.status(500).end() } catch {} }) archive.pipe(res) for (const k of keys) { const nk = normalizeOssKey(k) if (nk.endsWith('/')) { const files = await listRecursive(nk) const prefixClean = nk.replace(/\/+$/, '') + '/' for (const f of files) { try { const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key) const relPart = fullRel.slice(prefixClean.length) const { stream } = await ossObjectStream(fullRel) archive.append(stream, { name: relPart }) } catch {} } } else { const name = displayNameFromKey(nk) try { const { stream } = await ossObjectStream(nk) archive.append(stream, { name }) } catch {} } } archive.finalize() } } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/share/create', async (req, res) => { try { const key = normalizeOssKey(String((req.body && req.body.key) || '')) const ttlHours = Math.max(1, Math.min(7 * 24, Number((req.body && req.body.ttl_hours) || 24))) if (!key) return res.status(400).json({ ok: false, error: '参数错误' }) const isFolder = key.endsWith('/') const displayName = displayNameFromKey(key) const token = crypto.randomBytes(16).toString('hex') const now = Date.now() const expiresAt = now + ttlHours * 3600 * 1000 const d = getDb() d.prepare(`INSERT INTO share_links(token, oss_key, display_name, is_folder, created_at, expires_at) VALUES(?,?,?,?,?,?)`).run( token, key, displayName, isFolder ? 1 : 0, now, expiresAt ) res.json({ ok: true, token, expires_at: expiresAt, display_name: displayName }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/trash/list', (req, res) => { try { const d = getDb() const rows = d.prepare('SELECT * FROM trash_items ORDER BY deleted_at DESC').all() const now = Date.now() const items = rows.map(r => ({ id: r.id, key: OSS_CONFIG.root_prefix ? String(r.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : r.oss_key, name: r.display_name, isFolder: r.is_folder === 1, size: r.size, deleted_at: r.deleted_at, original_path: r.original_path })) res.json({ ok: true, items }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/trash/restore', async (req, res) => { try { const id = Number((req.body && req.body.id) || 0) if (!id) return res.status(400).json({ ok: false, error: '参数错误' }) const d = getDb() const row = d.prepare('SELECT * FROM trash_items WHERE id = ?').get(id) if (!row) return res.status(404).json({ ok: false, error: '记录不存在' }) d.prepare('DELETE FROM trash_items WHERE id = ?').run(id) res.json({ ok: true, restored: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/trash/empty', async (req, res) => { try { const ids = Array.isArray(req.body && req.body.ids) ? req.body.ids : null const d = getDb() const rows = ids && ids.length > 0 ? d.prepare(`SELECT * FROM trash_items WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids.map(i => Number(i))) : d.prepare('SELECT * FROM trash_items').all() let deleted = 0 for (const row of rows) { const relKey = OSS_CONFIG.root_prefix ? String(row.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : row.oss_key try { if (row.is_folder === 1) { await ossDeleteFolderRecursive(relKey) } else { await ossDeleteObject(relKey) } d.prepare('DELETE FROM trash_items WHERE id = ?').run(row.id) deleted++ } catch {} } res.json({ ok: true, deleted_count: deleted }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) startTrashAutoCleanup() return router } const startTrashAutoCleanup = () => { const MAX_AGE_MS = 2 * 30 * 24 * 3600 * 1000 const run = async () => { try { const d = getDb() const cutoff = Date.now() - MAX_AGE_MS const rows = d.prepare('SELECT * FROM trash_items WHERE deleted_at < ?').all(cutoff) for (const row of rows) { try { const relKey = OSS_CONFIG.root_prefix ? String(row.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : row.oss_key const normalized = normalizeOssKey(relKey) if (row.is_folder === 1) await ossDeleteFolderRecursive(normalized) else await ossDeleteObject(normalized) d.prepare('DELETE FROM trash_items WHERE id = ?').run(row.id) } catch {} } } catch {} } run() setInterval(run, 60 * 60 * 1000) } const bindRoutes = (app) => { const router = createRouter() app.use('/api/oss_file_cabinet', router) } module.exports = { bindRoutes, getDb, humanSize, getExt, isImage }