af5d0f43a5
不再直接暴露本地文件路径作为文件url,改用api接口代理访问;新增对应路由处理文件的预览和下载请求,完善响应头配置。
700 lines
28 KiB
JavaScript
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 }
|