- 后端:新增 /api/clips(列表/新建)、/api/clips/<id>(取全文/删除)、/api/clips/<id>/copy(复制计数),列表仅返回 240 字符预览 - 存储:SQLite 新增 clips 表(含 expires_at 索引),启动自动建表,线上老库无需手工迁移 - 前端:剪贴板卡片支持读取本机剪贴板、来源备注、查看全文、一键复制到本机剪贴板、单条删除与复制次数 - 清理:systemd 定时任务与 /api/cleanup 同时清理过期剪贴板 - 配置:新增 max_clip_length / max_clips / clip_default_expires_hours - 部署:deploy_transfer.py 新增 --keep-credentials(不重置登录密码与会话密钥)与 SSH 连接重试 - 文档:README / setup.md / CHANGELOG 同步更新
321 lines
10 KiB
Python
321 lines
10 KiB
Python
"""文件元数据与磁盘缓存的存储层(SQLite + 文件目录,仅标准库)。"""
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
|
|
_DB_LOCK = threading.Lock()
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
stored_path TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
downloads INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS clips (
|
|
id TEXT PRIMARY KEY,
|
|
content TEXT NOT NULL,
|
|
client TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
copies INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_clips_expires_at ON clips (expires_at);
|
|
"""
|
|
|
|
CLIP_PREVIEW_CHARS = 240
|
|
|
|
|
|
def connect(db_path: str) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(db_path, timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=30000")
|
|
return conn
|
|
|
|
|
|
def init_db(db_path: str) -> None:
|
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
conn.executescript(SCHEMA)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def iso(ts: int) -> str:
|
|
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
|
|
|
|
def file_to_dict(row: sqlite3.Row, now: int) -> dict:
|
|
return {
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"size": row["size"],
|
|
"created_at": iso(row["created_at"]),
|
|
"expires_at": iso(row["expires_at"]),
|
|
"remaining_seconds": max(0, row["expires_at"] - now),
|
|
"downloads": row["downloads"],
|
|
}
|
|
|
|
|
|
def insert_file(db_path: str, file_id: str, name: str, stored_path: str,
|
|
size: int, expires_at: int, created_at: int) -> None:
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO files (id, name, stored_path, size, created_at, expires_at, downloads) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, 0)",
|
|
(file_id, name, stored_path, size, created_at, expires_at),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_file(db_path: str, file_id: str):
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
return conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def list_files(db_path: str, now: int) -> list[dict]:
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT * FROM files WHERE expires_at > ? ORDER BY created_at DESC", (now,)
|
|
).fetchall()
|
|
return [file_to_dict(r, now) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def increment_downloads(db_path: str, file_id: str) -> None:
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
conn.execute("UPDATE files SET downloads = downloads + 1 WHERE id = ?", (file_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def delete_file(db_path: str, files_dir: str, file_id: str) -> bool:
|
|
"""删除数据库记录并移除磁盘文件,返回是否删除过数据库记录。"""
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
row = conn.execute("SELECT id, stored_path FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
if row is None:
|
|
return False
|
|
conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
try:
|
|
os.remove(row["stored_path"])
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError:
|
|
pass
|
|
return True
|
|
|
|
|
|
def cleanup_expired(db_path: str, files_dir: str, now: int | None = None) -> int:
|
|
now = now if now is not None else int(__import__("time").time())
|
|
removed = 0
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, stored_path FROM files WHERE expires_at <= ?", (now,)
|
|
).fetchall()
|
|
for row in rows:
|
|
try:
|
|
os.remove(row["stored_path"])
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError:
|
|
continue # 磁盘文件暂时删不掉时保留记录,下次重试
|
|
conn.execute("DELETE FROM files WHERE id = ?", (row["id"],))
|
|
removed += 1
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return removed
|
|
|
|
|
|
def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None,
|
|
min_age_seconds: int = 172800) -> int:
|
|
"""清理磁盘上不在数据库且超过 48 小时未修改的孤儿文件。"""
|
|
import time as _time
|
|
now = now if now is not None else int(_time.time())
|
|
removed = 0
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
ids = {r["id"] for r in conn.execute("SELECT id FROM files").fetchall()}
|
|
finally:
|
|
conn.close()
|
|
if not os.path.isdir(files_dir):
|
|
return 0
|
|
for name in os.listdir(files_dir):
|
|
if name in ids:
|
|
continue
|
|
path = os.path.join(files_dir, name)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
try:
|
|
if now - os.path.getmtime(path) >= min_age_seconds:
|
|
os.remove(path)
|
|
removed += 1
|
|
except OSError:
|
|
pass
|
|
return removed
|
|
|
|
|
|
# ---------- 剪贴板(纯数据库存储,无磁盘文件) ----------
|
|
|
|
def clip_to_dict(row: sqlite3.Row, now: int) -> dict:
|
|
"""把带 preview/size 字段的查询行转换为接口返回结构(不含完整正文)。"""
|
|
size = row["size"] if row["size"] is not None else 0
|
|
return {
|
|
"id": row["id"],
|
|
"preview": row["preview"] or "",
|
|
"size": size,
|
|
"truncated": size > len(row["preview"] or ""),
|
|
"client": row["client"] or "",
|
|
"created_at": iso(row["created_at"]),
|
|
"expires_at": iso(row["expires_at"]),
|
|
"remaining_seconds": max(0, row["expires_at"] - now),
|
|
"copies": row["copies"],
|
|
}
|
|
|
|
|
|
def insert_clip(db_path: str, clip_id: str, content: str, client: str,
|
|
expires_at: int, created_at: int) -> None:
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO clips (id, content, client, created_at, expires_at, copies) "
|
|
"VALUES (?, ?, ?, ?, ?, 0)",
|
|
(clip_id, content, client, created_at, expires_at),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_clip(db_path: str, clip_id: str, preview_chars: int = CLIP_PREVIEW_CHARS):
|
|
"""返回单条剪贴板记录的完整内容(含 content 字段)。"""
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
return conn.execute(
|
|
"SELECT id, content, client, created_at, expires_at, copies, "
|
|
"length(content) AS size FROM clips WHERE id = ?",
|
|
(clip_id,),
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def clip_content_to_dict(row: sqlite3.Row, now: int) -> dict:
|
|
return {
|
|
"id": row["id"],
|
|
"content": row["content"] or "",
|
|
"size": row["size"] if row["size"] is not None else 0,
|
|
"client": row["client"] or "",
|
|
"created_at": iso(row["created_at"]),
|
|
"expires_at": iso(row["expires_at"]),
|
|
"remaining_seconds": max(0, row["expires_at"] - now),
|
|
"copies": row["copies"],
|
|
}
|
|
|
|
|
|
def list_clips(db_path: str, now: int,
|
|
preview_chars: int = CLIP_PREVIEW_CHARS) -> list[dict]:
|
|
"""列出未过期剪贴板记录;只取正文前若干字符作为预览,避免列表响应过大。"""
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, client, created_at, expires_at, copies, "
|
|
"substr(content, 1, ?) AS preview, length(content) AS size "
|
|
"FROM clips WHERE expires_at > ? "
|
|
"ORDER BY created_at DESC, rowid DESC",
|
|
(preview_chars, now),
|
|
).fetchall()
|
|
return [clip_to_dict(r, now) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def increment_clip_copies(db_path: str, clip_id: str) -> bool:
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
cur = conn.execute(
|
|
"UPDATE clips SET copies = copies + 1 WHERE id = ?", (clip_id,)
|
|
)
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def delete_clip(db_path: str, clip_id: str) -> bool:
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
cur = conn.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def cleanup_expired_clips(db_path: str, now: int | None = None) -> int:
|
|
import time as _time
|
|
now = now if now is not None else int(_time.time())
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
cur = conn.execute("DELETE FROM clips WHERE expires_at <= ?", (now,))
|
|
conn.commit()
|
|
return cur.rowcount
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def prune_clips(db_path: str, keep: int) -> int:
|
|
"""只保留最近 keep 条剪贴板记录,返回被删除的条数。"""
|
|
if keep <= 0:
|
|
return 0
|
|
with _DB_LOCK:
|
|
conn = connect(db_path)
|
|
try:
|
|
cur = conn.execute(
|
|
"DELETE FROM clips WHERE id NOT IN ("
|
|
"SELECT id FROM clips ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
|
(keep,),
|
|
)
|
|
conn.commit()
|
|
return cur.rowcount
|
|
finally:
|
|
conn.close()
|