1096 lines
46 KiB
JavaScript
1096 lines
46 KiB
JavaScript
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 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 = `</${tag}>`
|
|
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', '<CommonPrefixes>', '</CommonPrefixes>', 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 = String((req.query && req.query.token) || '').trim()
|
|
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 } |