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:
+104
-5
@@ -24,6 +24,10 @@ USERS = cfg["users"]
|
||||
MIN_EXPIRES_SECONDS = 3600 # 最短 1 小时
|
||||
MAX_EXPIRES_SECONDS = 30 * 24 * 3600 # 最长 30 天
|
||||
DEFAULT_EXPIRES_HOURS = 168 # 默认 7 天
|
||||
MAX_CLIP_LENGTH = cfg["max_clip_length"] # 单条剪贴板文本上限(字符)
|
||||
MAX_CLIPS = cfg["max_clips"] # 剪贴板保留条数上限
|
||||
DEFAULT_CLIP_EXPIRES_HOURS = cfg["clip_default_expires_hours"] # 剪贴板默认有效期
|
||||
MAX_CLIP_PREVIEW_CHARS = 240 # 列表接口返回的正文预览长度
|
||||
|
||||
os.makedirs(FILES_DIR, exist_ok=True)
|
||||
storage.init_db(DB_PATH)
|
||||
@@ -98,16 +102,19 @@ def not_found(err):
|
||||
return err
|
||||
|
||||
|
||||
def _parse_expires_hours(value) -> int:
|
||||
def _parse_expires_hours(value, default_hours: int = DEFAULT_EXPIRES_HOURS,
|
||||
label: str = "有效期") -> int:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
value = default_hours
|
||||
try:
|
||||
hours = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("有效期格式不正确")
|
||||
raise ValueError("%s格式不正确" % label)
|
||||
if not (1 <= hours <= 720):
|
||||
raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)")
|
||||
raise ValueError("%s最短 1 小时,最长 30 天(720 小时)" % label)
|
||||
seconds = int(round(hours * 3600))
|
||||
if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS):
|
||||
raise ValueError("有效期超出允许范围")
|
||||
raise ValueError("%s超出允许范围" % label)
|
||||
return seconds
|
||||
|
||||
|
||||
@@ -118,6 +125,17 @@ def _clean_name(raw_name) -> str:
|
||||
return (name[:200] or "未命名文件")
|
||||
|
||||
|
||||
def _clean_clip_text(raw_text) -> str:
|
||||
"""规范化剪贴板正文:统一换行、去掉 NUL,保留换行与制表符。"""
|
||||
text = raw_text.replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
|
||||
return text
|
||||
|
||||
|
||||
def _clean_client(raw_client) -> str:
|
||||
name = re.sub(r"[\x00-\x1f\x7f]", "", str(raw_client or "").strip())
|
||||
return name[:40]
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
@@ -261,7 +279,88 @@ def api_cleanup():
|
||||
now = int(time.time())
|
||||
removed = storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
|
||||
orphans = storage.cleanup_orphans(DB_PATH, FILES_DIR, now=now)
|
||||
return jsonify(ok=True, removed=removed, orphans=orphans)
|
||||
clips = storage.cleanup_expired_clips(DB_PATH, now=now)
|
||||
return jsonify(ok=True, removed=removed, orphans=orphans, clips=clips)
|
||||
|
||||
|
||||
# ---------- 剪贴板 ----------
|
||||
# 登录用户共享同一块剪贴板:任一客户端粘贴文本上传,其他客户端可查看并一键复制。
|
||||
|
||||
@app.get("/api/clips")
|
||||
@login_required
|
||||
def api_clips():
|
||||
now = int(time.time())
|
||||
storage.cleanup_expired_clips(DB_PATH, now=now)
|
||||
return jsonify(clips=storage.list_clips(DB_PATH, now))
|
||||
|
||||
|
||||
@app.post("/api/clips")
|
||||
@login_required
|
||||
def api_create_clip():
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify(ok=False, error="请求格式不正确"), 400
|
||||
raw_text = data.get("text")
|
||||
if not isinstance(raw_text, str):
|
||||
return jsonify(ok=False, error="缺少剪贴板文本"), 400
|
||||
text = _clean_clip_text(raw_text)
|
||||
if not text.strip():
|
||||
return jsonify(ok=False, error="剪贴板内容不能为空"), 400
|
||||
if len(text) > MAX_CLIP_LENGTH:
|
||||
return jsonify(ok=False, error="文本过长:最多 %d 个字符" % MAX_CLIP_LENGTH), 400
|
||||
try:
|
||||
expires_seconds = _parse_expires_hours(
|
||||
data.get("expires_hours"), DEFAULT_CLIP_EXPIRES_HOURS, "剪贴板有效期")
|
||||
except ValueError as exc:
|
||||
return jsonify(ok=False, error=str(exc)), 400
|
||||
|
||||
now = int(time.time())
|
||||
clip_id = secrets.token_hex(8)
|
||||
storage.insert_clip(DB_PATH, clip_id, text, _clean_client(data.get("client")),
|
||||
now + expires_seconds, now)
|
||||
storage.prune_clips(DB_PATH, MAX_CLIPS)
|
||||
row = storage.get_clip(DB_PATH, clip_id)
|
||||
if row is None: # 理论上不会发生,被 prune 挤掉时也返回成功但不回传内容
|
||||
return jsonify(ok=True, clip=None)
|
||||
return jsonify(ok=True, clip=storage.clip_content_to_dict(row, now))
|
||||
|
||||
|
||||
@app.get("/api/clips/<clip_id>")
|
||||
@login_required
|
||||
def api_clip_detail(clip_id):
|
||||
now = int(time.time())
|
||||
row = storage.get_clip(DB_PATH, clip_id)
|
||||
if row is None:
|
||||
return jsonify(ok=False, error="剪贴板内容不存在或已删除"), 404
|
||||
if row["expires_at"] <= now:
|
||||
storage.cleanup_expired_clips(DB_PATH, now=now)
|
||||
return jsonify(ok=False, error="剪贴板内容已过期"), 404
|
||||
return jsonify(ok=True, clip=storage.clip_content_to_dict(row, now))
|
||||
|
||||
|
||||
@app.post("/api/clips/<clip_id>/copy")
|
||||
@login_required
|
||||
def api_clip_copied(clip_id):
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
if not storage.increment_clip_copies(DB_PATH, clip_id):
|
||||
return jsonify(ok=False, error="剪贴板内容不存在"), 404
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.delete("/api/clips/<clip_id>")
|
||||
@login_required
|
||||
def api_delete_clip(clip_id):
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
if not storage.delete_clip(DB_PATH, clip_id):
|
||||
return jsonify(ok=False, error="剪贴板内容不存在"), 404
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user