From 1e2a0726646287103aaeb59a7bbe5706ca33c788 Mon Sep 17 00:00:00 2001 From: ylhan Date: Mon, 21 Sep 2026 21:25:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20xinquandashboard:=20?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E4=BF=9D=E6=8A=A4=E7=9A=84=E5=9B=BE=E6=96=87?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2App(Caddy=20basic=5Fauth=20+=20=E9=9B=B6?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=AE=B9=E5=99=A8),=20=E4=B8=BB=E9=A1=B5?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=8F=A3;=20=E5=90=8C=E6=AD=A5note=E5=AD=90?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + Caddyfile | 13 +- docker-compose.yml | 9 + homepage/index.html | 3 +- space/note | 2 +- xinquan/Dockerfile | 8 + xinquan/app.py | 462 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 xinquan/Dockerfile create mode 100644 xinquan/app.py diff --git a/.gitignore b/.gitignore index 2f31945..4225d4b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,13 @@ caddy_config/ # ---- Gitea 运行时数据(git 仓库、数据库、SSH 密钥等,敏感) ---- gitea/ +# ---- Xinquan Dashboard 运行时数据(页面与图片) ---- +xinquan_data/ + +# ---- Python 构建缓存 ---- +__pycache__/ +*.pyc + # ---- SilverBullet 运行时内容与密钥 ---- space/Library/ space/.silverbullet.auth.json diff --git a/Caddyfile b/Caddyfile index c8e7d28..2af9d21 100644 --- a/Caddyfile +++ b/Caddyfile @@ -11,8 +11,17 @@ hyonglin.top { tls { dns cloudflare {env.CF_API_TOKEN} } - root * /srv/homepage - file_server + # Xinquan Dashboard:密码保护的图文页面 + handle_path /xinquandashboard/* { + basic_auth { + xqzk $2a$14$GrTqs4WiYHepvOq/0CnOU.Zq2.cA4B2FcilgGIv/6Jbi9BZX/8rNK + } + reverse_proxy xinquan:8000 + } + handle { + root * /srv/homepage + file_server + } } # frp 管理面板 diff --git a/docker-compose.yml b/docker-compose.yml index 75b7539..95351af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,15 @@ services: - SB_USER=lin:YL@han0824 - SB_INDEX_PAGE=HOME + xinquan: + build: ./xinquan + container_name: xinquan + restart: unless-stopped + volumes: + - ./xinquan_data:/data + expose: + - "8000" + caddy: image: caddy-cloudflare:latest container_name: caddy diff --git a/homepage/index.html b/homepage/index.html index 91786f2..bc31a4c 100644 --- a/homepage/index.html +++ b/homepage/index.html @@ -242,12 +242,13 @@
-

📡 自建服务4

+

📡 自建服务5

diff --git a/space/note b/space/note index f5b825b..19afe00 160000 --- a/space/note +++ b/space/note @@ -1 +1 @@ -Subproject commit f5b825bd4a3a605278fef1793089d9f8e9f3ed3a +Subproject commit 19afe00a8636c6eb01179487c0f00aa01080f4a7 diff --git a/xinquan/Dockerfile b/xinquan/Dockerfile new file mode 100644 index 0000000..4c62b82 --- /dev/null +++ b/xinquan/Dockerfile @@ -0,0 +1,8 @@ +# xinquandashboard —— 零依赖图片文字 dashboard +FROM python:3.12-slim +WORKDIR /app +COPY app.py /app/app.py +ENV DATA_DIR=/data \ + PORT=8000 +EXPOSE 8000 +CMD ["python3", "/app/app.py"] \ No newline at end of file diff --git a/xinquan/app.py b/xinquan/app.py new file mode 100644 index 0000000..f3c246f --- /dev/null +++ b/xinquan/app.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""xinquandashboard —— 密码保护的图文页面应用(零依赖,标准库 only)。 + +路由(Caddy 通过 handle_path 剥掉 /xinquandashboard 前缀后转发至此): + GET / 单页 UI(HTML+JS 内联) + GET /api/pages 页面列表 + POST /api/pages 新建页面 body: {"title","content"} + GET /api/pages/ 单页详情 + PUT /api/pages/ 编辑页面 body: {"title","content"} + DELETE /api/pages/ 删除页面 + POST /api/upload 上传图片 body: {"data":"","name":"a.png","mime":"image/png"} + GET /media/ 图片静态读取 + +持久化:/data/pages.json(页面元数据)+ /data/uploads/(图片文件)。 +鉴权:由 Caddy basic_auth 在网关卡负责,本应用不鉴权。 +""" +import base64 +import json +import os +import re +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +DATA_DIR = os.environ.get("DATA_DIR", "/data") +PAGES_FILE = os.path.join(DATA_DIR, "pages.json") +UPLOADS_DIR = os.path.join(DATA_DIR, "uploads") +PORT = int(os.environ.get("PORT", "8000")) + +_lock = threading.Lock() + +ALLOWED_IMG_EXT = {".png": "image/png", ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", ".gif": "image/gif", + ".webp": "image/webp", ".svg": "image/svg+xml"} + + +def _ensure_dirs(): + os.makedirs(DATA_DIR, exist_ok=True) + os.makedirs(UPLOADS_DIR, exist_ok=True) + if not os.path.exists(PAGES_FILE): + _write_pages({}) + + +def _read_pages(): + if not os.path.exists(PAGES_FILE): + return {} + try: + with open(PAGES_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _write_pages(pages): + tmp = PAGES_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(pages, f, ensure_ascii=False, indent=2) + os.replace(tmp, PAGES_FILE) + + +def _summary(content, n=160): + text = re.sub(r"[\n\r]+", " ", content or "") + text = re.sub(r"[\s ]+", " ", text).strip() + return text[:n] + ("…" if len(text) > n else "") + + +# ------------------------- 页面 与 UI ------------------------- +BOOTSTRAP = "https://cdn.jsdelivr.net/npm/marked@12/marked.min.js" + +INDEX_HTML = r""" + + + + +Xinquan Dashboard + + + + +
+ +

Xinquan Dashboard

+
+
+
+
+ + + + +""" + + +# ------------------------- 服务器 ------------------------- +_PREFIXES = ("/xinquandashboard",) # 即使未剥前缀也能自适应 + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + # ---- 基础 ---- + def _send(self, code, body: bytes, ct="application/json; charset=utf-8", + extra=None): + self.send_response(code) + self.send_header("Content-Type", ct) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + if extra: + for k, v in extra.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _json(self, obj, code=200): + self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8")) + + def _read_body(self): + n = int(self.headers.get("Content-Length") or 0) + return self.rfile.read(n) if n else b"" + + def _read_json(self): + raw = self._read_body() + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return None + + # ---- 路由 ---- + def _split(self): + path = self.path.split("?", 1)[0] + for pre in _PREFIXES: + if path.startswith(pre): + path = path[len(pre):] or "/" + break + if not path.startswith("/"): + path = "/" + path + return path + + def do_GET(self): + path = self._split() + if path == "/media/health": + return self._json({"ok": True}) + if path.startswith("/media/"): + return self._media(path[len("/media/"):]) + if path.startswith("/api/pages/"): + pid = path[len("/api/pages/"):] + if pid: + return self._get_page(pid) + return self._json({"error": "missing id"}, 400) + if path == "/api/pages": + return self._list_pages() + return self._send(200, self._index_html(), "text/html; charset=utf-8") + + def _index_html(self): + return INDEX_HTML.replace("__MARKED__", BOOTSTRAP).encode("utf-8") + + def do_POST(self): + path = self._split() + if path == "/api/pages": + return self._create_page() + if path == "/api/upload": + return self._upload() + self._json({"error": "not found"}, 404) + + def do_PUT(self): + path = self._split() + if path.startswith("/api/pages/"): + pid = path[len("/api/pages/"):] + if pid: + return self._update_page(pid) + self._json({"error": "not found"}, 404) + + def do_DELETE(self): + path = self._split() + if path.startswith("/api/pages/"): + pid = path[len("/api/pages/"):] + if pid: + return self._delete_page(pid) + self._json({"error": "not found"}, 404) + + def log_message(self, *a): # 静音默认日志 + pass + + # ---- 页面 API ---- + def _list_pages(self): + with _lock: + pages = _read_pages() + out = [{"id": pid, "title": p.get("title", ""), + "summary": _summary(p.get("content", "")), + "updated": p.get("updated")} + for pid, p in pages.items()] + out.sort(key=lambda x: x["updated"] or "", reverse=True) + self._json(out) + + def _get_page(self, pid): + with _lock: + pages = _read_pages() + p = pages.get(pid) + if p is None: + return self._json({"error": "page not found"}, 404) + self._json({"id": pid, **p}) + + def _create_page(self): + data = self._read_json() + if not data or not data.get("title", "").strip(): + return self._json({"error": "title required"}, 400) + with _lock: + pages = _read_pages() + pid = uuid.uuid4().hex[:12] + now = time.strftime("%Y-%m-%dT%H:%M:%S") + pages[pid] = {"title": data["title"].strip(), + "content": data.get("content", ""), + "created": now, "updated": now} + _write_pages(pages) + self._json({"id": pid}, 201) + + def _update_page(self, pid): + data = self._read_json() + if not data: + return self._json({"error": "bad body"}, 400) + with _lock: + pages = _read_pages() + if pid not in pages: + return self._json({"error": "page not found"}, 404) + if data.get("title", "").strip(): + pages[pid]["title"] = data["title"].strip() + if "content" in data: + pages[pid]["content"] = data["content"] or "" + pages[pid]["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") + _write_pages(pages) + self._json({"id": pid}) + + def _delete_page(self, pid): + with _lock: + pages = _read_pages() + if pid not in pages: + return self._json({"error": "page not found"}, 404) + del pages[pid] + _write_pages(pages) + self._json({"ok": True}) + + # ---- 图片 ---- + def _upload(self): + data = self._read_json() + if not data or not data.get("data"): + return self._json({"error": "no image data"}, 400) + name = (data.get("name") or "img").lower() + ext = os.path.splitext(name)[1].replace(".", "") + if ext: + full = "." + ext + if full not in ALLOWED_IMG_EXT: + # 依据 mime 兜底 + mime = data.get("mime", "") + full = next((e for e, m in ALLOWED_IMG_EXT.items() if m == mime), ".png") + else: + mime = data.get("mime", "") + full = next((e for e, m in ALLOWED_IMG_EXT.items() if m == mime), ".png") + try: + raw = base64.b64decode(data["data"]) + except Exception: + return self._json({"error": "bad base64"}, 400) + if not raw or len(raw) > 20 * 1024 * 1024: + return self._json({"error": "image empty or too large (>20MB)"}, 413) + fname = uuid.uuid4().hex + full + with open(os.path.join(UPLOADS_DIR, fname), "wb") as f: + f.write(raw) + self._json({"url": f"media/{fname}", "name": name}) + + def _media(self, name): + if "/" in name or "\\" in name or not name: + return self._json({"error": "bad media"}, 400) + p = os.path.join(UPLOADS_DIR, name) + if not os.path.isfile(p): + return self._send(404, b"not found", "text/plain") + ext = os.path.splitext(name)[1].lower() + ct = ALLOWED_IMG_EXT.get(ext, "application/octet-stream") + with open(p, "rb") as f: + body = f.read() + self._send(200, body, ct, {"Cache-Control": "public, max-age=86400"}) + + +def main(): + _ensure_dirs() + srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"xinquandashboard running on 0.0.0.0:{PORT}", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() \ No newline at end of file