const express = require('express') const fs = require('fs') const path = require('path') const crypto = require('crypto') const Database = require('better-sqlite3') const DATA_DIR = path.join(process.cwd(), 'data') const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'private_clipboard') const DB_PATH = process.env.PRIVATE_CLIPBOARD_DB_PATH || path.join(DATA_DIR, 'private_clipboard.db') if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }) let db = null const buildFolderCode = () => `fd_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}` const toPositiveInt = (value, fallback) => { const n = Number.parseInt(String(value == null ? '' : value), 10) if (!Number.isFinite(n) || n <= 0) return fallback return n } const escapeLikeValue = (value) => String(value == null ? '' : value).replace(/[\\%_]/g, ch => `\\${ch}`) const ensureSchema = () => { if (!db) return db.exec(` CREATE TABLE IF NOT EXISTS clipboard_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, text_content TEXT, file_name TEXT, file_mime TEXT, file_size INTEGER, file_path TEXT, group_id INTEGER, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS clipboard_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at); CREATE INDEX IF NOT EXISTS idx_clipboard_folders_sort_order ON clipboard_folders(sort_order, id); `) const cols = db.prepare('PRAGMA table_info(clipboard_items)').all() const hasGroup = cols.some(c => c.name === 'group_id') if (!hasGroup) db.exec('ALTER TABLE clipboard_items ADD COLUMN group_id INTEGER') const hasFavoriteFolderCode = cols.some(c => c.name === 'favorite_folder_code') if (!hasFavoriteFolderCode) db.exec('ALTER TABLE clipboard_items ADD COLUMN favorite_folder_code TEXT') const hasFavoriteNote = cols.some(c => c.name === 'favorite_note') if (!hasFavoriteNote) db.exec(`ALTER TABLE clipboard_items ADD COLUMN favorite_note TEXT`) const hasFavoritedAt = cols.some(c => c.name === 'favorited_at') if (!hasFavoritedAt) db.exec('ALTER TABLE clipboard_items ADD COLUMN favorited_at INTEGER') db.exec('CREATE INDEX IF NOT EXISTS idx_clipboard_items_favorite_folder_code ON clipboard_items(favorite_folder_code)') db.exec('CREATE INDEX IF NOT EXISTS idx_clipboard_items_favorited_at ON clipboard_items(favorited_at)') } const getDb = () => { if (db) { ensureSchema() return db } db = new Database(DB_PATH) db.pragma('journal_mode = WAL') db.pragma('synchronous = NORMAL') db.pragma('busy_timeout = 4000') ensureSchema() return db } const normalizeName = (name) => { const base = path.basename(String(name || '').replace(/\0/g, '')) return base || 'file' } const extFromMime = (mime) => { const m = String(mime || '').toLowerCase() if (m === 'image/png') return '.png' if (m === 'image/jpeg') return '.jpg' if (m === 'image/jpg') return '.jpg' if (m === 'image/webp') return '.webp' if (m === 'image/gif') return '.gif' if (m === 'image/bmp') return '.bmp' if (m === 'image/svg+xml') return '.svg' return '' } const saveFile = (raw, name, mime) => { const safe = normalizeName(name) let ext = path.extname(safe) if (!ext) ext = extFromMime(mime) const stamp = Date.now() const rand = crypto.randomBytes(6).toString('hex') const fileName = `${stamp}-${rand}${ext}` const rel = path.join('uploads', 'private_clipboard', fileName) const abs = path.join(process.cwd(), rel) fs.writeFileSync(abs, raw) const size = raw.length return { rel, fileName: safe, size } } const formatRow = (row) => { const url = row && row.id && row.file_path ? `/api/private_clipboard/file/${row.id}` : '' return { id: row.id, type: row.type, text_content: row.text_content || '', file_name: row.file_name || '', file_mime: row.file_mime || '', file_size: row.file_size || 0, file_url: url, favorite_folder_code: row.favorite_folder_code || null, favorite_folder_name: row.favorite_folder_name || '', favorite_note: row.favorite_note || '', favorited_at: row.favorited_at || 0, created_at: row.created_at } } const buildListQueryParts = (query) => { const filters = [] const params = [] const view = String((query && query.view) || 'all').trim() === 'favorites' ? 'favorites' : 'all' const type = String((query && query.type) || 'all').trim() const keyword = String((query && query.keyword) || '').trim() const folderCode = String((query && query.folder_code) || 'all').trim() if (view === 'favorites') filters.push('i.favorite_folder_code IS NOT NULL') if (view === 'favorites' && folderCode && folderCode !== 'all') { filters.push('i.favorite_folder_code = ?') params.push(folderCode) } if (type && type !== 'all' && ['text', 'image', 'file'].includes(type)) { filters.push('i.type = ?') params.push(type) } if (keyword) { const like = `%${escapeLikeValue(keyword)}%` filters.push(`( COALESCE(i.text_content, '') LIKE ? ESCAPE '\\' OR COALESCE(i.file_name, '') LIKE ? ESCAPE '\\' OR COALESCE(i.favorite_note, '') LIKE ? ESCAPE '\\' OR COALESCE(f.name, '') LIKE ? ESCAPE '\\' )`) params.push(like, like, like, like) } const whereSql = filters.length ? `WHERE ${filters.join(' AND ')}` : '' const filteredCte = ` WITH filtered AS ( SELECT i.*, f.name AS favorite_folder_name, CASE WHEN i.group_id IS NOT NULL THEN 'g:' || CAST(i.group_id AS TEXT) ELSE 'r:' || CAST(i.id AS TEXT) END AS entity_key, CASE WHEN i.group_id IS NOT NULL THEN i.group_id ELSE i.id END AS entity_id FROM clipboard_items i LEFT JOIN clipboard_folders f ON f.code = i.favorite_folder_code ${whereSql} ) ` return { filteredCte, params, view, type, keyword, folderCode } } const groupItemsFromRows = (rows) => { const groups = new Map() rows.forEach(row => { const entityKey = String(row.entity_key || '') const gid = row.group_id ? Number(row.group_id) : null const key = entityKey || (gid ? `g:${gid}` : `r:${row.id}`) let group = groups.get(key) if (!group) { group = { id: gid || row.id, type: row.type, text_content: row.text_content || '', created_at: Number(row.group_created_at || row.created_at || Date.now()), items: [], group_types: [], favorite_folder_code: row.group_favorite_folder_code || row.favorite_folder_code || null, favorite_folder_name: row.group_favorite_folder_name || row.favorite_folder_name || '', favorite_note: row.group_favorite_note || row.favorite_note || '', favorited_at: Number(row.group_favorited_at || row.favorited_at || 0), is_favorite: !!(row.group_favorite_folder_code || row.favorite_folder_code) } groups.set(key, group) } group.group_types.push(row.type) if (row.type === 'text') { if (!group.text_content) group.text_content = row.text_content || '' } else { group.items.push(formatRow(row)) } }) return Array.from(groups.values()).map(group => { const unique = Array.from(new Set(group.group_types.filter(Boolean))) if (unique.length === 0 && group.text_content) unique.push('text') let type = group.type if (group.items.length) { type = unique.length === 1 ? unique[0] : 'mixed' } else { type = 'text' } return { id: group.id, type, text_content: group.text_content, created_at: group.created_at, items: group.items, group_types: unique, favorite_folder_code: group.favorite_folder_code || null, favorite_folder_name: group.favorite_folder_name || '', favorite_note: group.favorite_note || '', favorited_at: Number(group.favorited_at || 0), is_favorite: !!group.favorite_folder_code } }) } // ===== 登录鉴权 ===== const AUTH_COOKIE = 'private_clipboard_auth' const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 // 1天 const AUTH_HMAC_SECRET = process.env.PRIVATE_CLIPBOARD_AUTH_SECRET || 'pc-auth-secret-change-me' 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 const payload = verifyAuthPayload(token) return !!payload } const mustAuth = (req, res, next) => { if (hasAuth(req)) return next() return res.status(401).json({ ok: false, error: 'unauthorized' }) } 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.PRIVATE_CLIPBOARD_USERNAME || '').trim() if (fromEnv) return fromEnv const creds = readLocalCreds() return String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim() } const getExpectedPassword = () => { const fromEnv = String(process.env.PRIVATE_CLIPBOARD_PASSWORD || '').trim() if (fromEnv) return fromEnv const creds = readLocalCreds() return String(creds.PRIVATE_CLIPBOARD_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 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') res.set('Surrogate-Control', 'no-store') } catch {} next() }) // ===== Auth 路由(白名单,不需要登录)===== 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: 'server_not_configured' }) } if (username !== expectedUser || password !== expectedPass) { return res.status(401).json({ ok: false, error: 'invalid_credentials' }) } 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) => { if (hasAuth(req)) { return res.json({ ok: true, authorized: true }) } res.json({ ok: false, authorized: false }) }) 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: 'invalid_token' }) } const creds = readLocalCreds() const username = String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim() const password = String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim() if (!username || !password) { return res.status(500).json({ ok: false, error: 'no_local_creds' }) } res.json({ ok: true, creds: { username, password } }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) // ===== 以下业务路由需要登录 ===== router.use(mustAuth) router.get('/file/:id', (req, res) => { try { const id = Number.parseInt(String(req.params.id || ''), 10) if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' }) const row = getDb().prepare('SELECT id, type, file_name, file_mime, file_path FROM clipboard_items WHERE id = ?').get(id) if (!row || !row.file_path) return res.status(404).json({ ok: false, error: 'file_not_found' }) const rel = String(row.file_path || '').replace(/^\/+/, '') const abs = path.join(process.cwd(), rel) if (!fs.existsSync(abs)) return res.status(404).json({ ok: false, error: 'file_not_found' }) const mime = String(row.file_mime || '').trim() || 'application/octet-stream' res.setHeader('Content-Type', mime) res.setHeader('Cache-Control', 'private, no-store, max-age=0') const safeName = normalizeName(row.file_name || 'file') const isImage = String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/') const dispositionType = isImage ? 'inline' : 'attachment' const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_') const utf8Name = encodeURIComponent(safeName) res.setHeader('Content-Disposition', `${dispositionType}; filename="${fallbackName}"; filename*=UTF-8''${utf8Name}`) return res.sendFile(abs) } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/list', (req, res) => { try { const pageSizeRaw = String((req.query && req.query.page_size) || '50').trim().toLowerCase() const allMode = pageSizeRaw === 'all' const pageSize = allMode ? 0 : toPositiveInt(pageSizeRaw, 50) const page = allMode ? 1 : toPositiveInt(req.query && req.query.page, 1) const offset = allMode ? 0 : (page - 1) * pageSize const { filteredCte, params, view, type, keyword, folderCode } = buildListQueryParts(req.query || {}) const totalRow = getDb().prepare(` ${filteredCte} SELECT COUNT(1) AS cnt FROM ( SELECT entity_key FROM filtered GROUP BY entity_key ) `).get(...params) const total = Number(totalRow && totalRow.cnt ? totalRow.cnt : 0) const totalPages = allMode ? 1 : Math.max(1, Math.ceil(total / Math.max(1, pageSize))) const safePage = allMode ? 1 : Math.min(page, totalPages) const safeOffset = allMode ? 0 : (safePage - 1) * pageSize const rows = getDb().prepare(` ${filteredCte}, paged AS ( SELECT entity_key, entity_id, MAX(created_at) AS group_created_at, MAX(COALESCE(favorite_folder_code, '')) AS group_favorite_folder_code, MAX(COALESCE(favorite_folder_name, '')) AS group_favorite_folder_name, MAX(COALESCE(favorite_note, '')) AS group_favorite_note, MAX(COALESCE(favorited_at, 0)) AS group_favorited_at FROM filtered GROUP BY entity_key, entity_id ORDER BY group_created_at DESC, entity_id DESC ${allMode ? '' : 'LIMIT ? OFFSET ?'} ) SELECT filtered.*, paged.group_created_at, paged.group_favorite_folder_code, paged.group_favorite_folder_name, paged.group_favorite_note, paged.group_favorited_at FROM filtered JOIN paged ON paged.entity_key = filtered.entity_key ORDER BY paged.group_created_at DESC, paged.entity_id DESC, filtered.created_at DESC, filtered.id DESC `).all(...params, ...(allMode ? [] : [pageSize, safeOffset])) const items = groupItemsFromRows(rows) res.json({ ok: true, items, pagination: { page: safePage, page_size: allMode ? 'all' : pageSize, total, total_pages: totalPages, is_all: allMode, has_prev: !allMode && safePage > 1, has_next: !allMode && safePage < totalPages }, filters: { view, type, keyword, folder_code: folderCode || 'all' } }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.get('/folders', (req, res) => { try { const folders = getDb().prepare(` SELECT code, name, sort_order, created_at FROM clipboard_folders ORDER BY sort_order ASC, id ASC `).all() const rawCounts = getDb().prepare(` SELECT favorite_folder_code AS code, COUNT(DISTINCT CASE WHEN group_id IS NOT NULL THEN 'g:' || group_id ELSE 'r:' || id END) AS cnt FROM clipboard_items WHERE favorite_folder_code IS NOT NULL GROUP BY favorite_folder_code `).all() const countMap = new Map(rawCounts.map(r => [String(r.code || ''), Number(r.cnt || 0)])) const list = folders.map(folder => ({ code: folder.code, name: folder.name, sort_order: Number(folder.sort_order || 1), created_at: Number(folder.created_at || Date.now()), favorite_count: countMap.get(String(folder.code || '')) || 0 })) res.json({ ok: true, folders: list }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/folders', (req, res) => { try { const name = String((req.body && req.body.name) || '').trim() if (!name) return res.status(400).json({ ok: false, error: 'invalid_folder_name' }) const maxRow = getDb().prepare('SELECT MAX(sort_order) AS max_sort FROM clipboard_folders').get() const defaultSort = Number(maxRow && maxRow.max_sort ? maxRow.max_sort : 0) + 1 const sortOrder = toPositiveInt(req.body && req.body.sort_order, defaultSort) const now = Date.now() const code = buildFolderCode() getDb().prepare('INSERT INTO clipboard_folders (code, name, sort_order, created_at) VALUES (?, ?, ?, ?)').run(code, name, sortOrder, now) res.json({ ok: true, folder: { code, name, sort_order: sortOrder, created_at: now, favorite_count: 0 } }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.patch('/folders/:code', (req, res) => { try { const code = String(req.params.code || '').trim() if (!code) return res.status(400).json({ ok: false, error: 'invalid_folder_code' }) const folder = getDb().prepare('SELECT code, name, sort_order, created_at FROM clipboard_folders WHERE code = ?').get(code) if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' }) const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, 'name') const hasSort = Object.prototype.hasOwnProperty.call(req.body || {}, 'sort_order') if (!hasName && !hasSort) return res.status(400).json({ ok: false, error: 'nothing_to_update' }) const nextName = hasName ? String(req.body.name || '').trim() : String(folder.name || '') if (!nextName) return res.status(400).json({ ok: false, error: 'invalid_folder_name' }) const nextSort = hasSort ? toPositiveInt(req.body.sort_order, -1) : Number(folder.sort_order || 1) if (!Number.isFinite(nextSort) || nextSort <= 0) return res.status(400).json({ ok: false, error: 'invalid_sort_order' }) getDb().prepare('UPDATE clipboard_folders SET name = ?, sort_order = ? WHERE code = ?').run(nextName, nextSort, code) res.json({ ok: true, folder: { code, name: nextName, sort_order: nextSort, created_at: Number(folder.created_at || Date.now()) } }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.delete('/folders/:code', (req, res) => { try { const code = String(req.params.code || '').trim() if (!code) return res.status(400).json({ ok: false, error: 'invalid_folder_code' }) const folder = getDb().prepare('SELECT code FROM clipboard_folders WHERE code = ?').get(code) if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' }) const used = getDb().prepare('SELECT COUNT(1) AS cnt FROM clipboard_items WHERE favorite_folder_code = ?').get(code) if (Number(used && used.cnt ? used.cnt : 0) > 0) { return res.status(409).json({ ok: false, error: 'folder_not_empty' }) } getDb().prepare('DELETE FROM clipboard_folders WHERE code = ?').run(code) res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/favorite', (req, res) => { try { const id = Number.parseInt(String(req.body && req.body.id), 10) const folderCode = String((req.body && req.body.folder_code) || '').trim() const favoriteNote = String((req.body && req.body.favorite_note) || '').trim() const favoritedAt = Date.now() if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' }) if (!folderCode) return res.status(400).json({ ok: false, error: 'invalid_folder_code' }) const folder = getDb().prepare('SELECT code FROM clipboard_folders WHERE code = ?').get(folderCode) if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' }) let changed = 0 if (id < 0) { const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = ?, favorite_note = ?, favorited_at = ? WHERE group_id = ?').run(folderCode, favoriteNote, favoritedAt, id) changed = Number(info.changes || 0) } else { const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = ?, favorite_note = ?, favorited_at = ? WHERE id = ?').run(folderCode, favoriteNote, favoritedAt, id) changed = Number(info.changes || 0) } if (!changed) return res.status(404).json({ ok: false, error: 'item_not_found' }) res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/unfavorite', (req, res) => { try { const id = Number.parseInt(String(req.body && req.body.id), 10) if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' }) let changed = 0 if (id < 0) { const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = NULL, favorite_note = NULL, favorited_at = NULL WHERE group_id = ?').run(id) changed = Number(info.changes || 0) } else { const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = NULL, favorite_note = NULL, favorited_at = NULL WHERE id = ?').run(id) changed = Number(info.changes || 0) } if (!changed) return res.status(404).json({ ok: false, error: 'item_not_found' }) res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/text', (req, res) => { try { const text = String((req.body && req.body.text) || '').trim() if (!text) return res.status(400).json({ ok: false, error: 'empty_text' }) const now = Date.now() const info = getDb().prepare('INSERT INTO clipboard_items (type, text_content, created_at) VALUES (?, ?, ?)').run('text', text, now) res.json({ ok: true, id: info.lastInsertRowid }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.post('/upload', express.raw({ type: '*/*', limit: '200mb' }), (req, res) => { try { const raw = req.body if (!raw || !raw.length) return res.status(400).json({ ok: false, error: 'empty_file' }) const name = String(req.query.filename || req.headers['x-file-name'] || '') const mime = String(req.query.mime || req.headers['x-file-type'] || req.headers['content-type'] || '') const gid = parseInt(String(req.query.group_id || ''), 10) const groupId = Number.isFinite(gid) ? gid : null const { rel, fileName, size } = saveFile(raw, name, mime) const now = Date.now() const type = String(mime || '').toLowerCase().startsWith('image/') ? 'image' : 'file' const info = getDb().prepare('INSERT INTO clipboard_items (type, file_name, file_mime, file_size, file_path, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run( type, fileName, mime, size, rel, groupId, now ) res.json({ ok: true, id: info.lastInsertRowid, path: '/' + rel.replace(/^\/+/, '') }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) router.delete('/:id', (req, res) => { try { const id = parseInt(req.params.id, 10) if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' }) if (id < 0) { const rows = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id) if (!rows.length) return res.status(404).json({ ok: false, error: 'item_not_found' }) const hasFavorite = rows.some(row => row && row.favorite_folder_code) if (hasFavorite) return res.status(409).json({ ok: false, error: 'item_favorited' }) getDb().prepare('DELETE FROM clipboard_items WHERE group_id = ?').run(id) rows.forEach(row => { if (row && row.file_path) { const rel = String(row.file_path || '').replace(/^\/+/, '') const abs = path.join(process.cwd(), rel) if (fs.existsSync(abs)) fs.unlinkSync(abs) } }) } else { const row = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE id = ?').get(id) if (!row) return res.status(404).json({ ok: false, error: 'item_not_found' }) if (row.favorite_folder_code) return res.status(409).json({ ok: false, error: 'item_favorited' }) getDb().prepare('DELETE FROM clipboard_items WHERE id = ?').run(id) if (row && row.file_path) { const rel = String(row.file_path || '').replace(/^\/+/, '') const abs = path.join(process.cwd(), rel) if (fs.existsSync(abs)) fs.unlinkSync(abs) } } res.json({ ok: true }) } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) } }) return router } module.exports = { createRouter }