feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,668 @@
|
||||
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 filePath = row.file_path ? String(row.file_path) : ''
|
||||
const url = filePath ? '/' + filePath.replace(/^\/+/, '') : ''
|
||||
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 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 = String((req.query && req.query.token) || '').trim()
|
||||
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('/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 }
|
||||
Reference in New Issue
Block a user