feat: 文件快传新增共享剪贴板(跨客户端粘贴、查看与一键复制)

- 后端:新增 /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 同步更新
This commit is contained in:
2026-10-11 22:20:54 +08:00
parent 67caaa428e
commit 5af69d694f
9 changed files with 773 additions and 45 deletions
+146
View File
@@ -16,8 +16,21 @@ CREATE TABLE IF NOT EXISTS files (
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)
@@ -172,3 +185,136 @@ def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None,
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()