Files
Toolbox/src/server/private_clipboard.js
T
yangxiangyuan af5d0f43a5 feat(private_clipboard): 新增私有剪贴板文件访问接口并调整url返回逻辑
不再直接暴露本地文件路径作为文件url,改用api接口代理访问;新增对应路由处理文件的预览和下载请求,完善响应头配置。
2026-08-02 21:00:43 +08:00

700 lines
28 KiB
JavaScript

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 }