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 @@
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
+
+
+
+
+
+
XQ
+
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