diff --git a/README.md b/README.md index 60a9237..ea7bc90 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,15 @@ ``` . ├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里) +│ ├── api/server.py # SQLite-backed recurring jobs API / scheduler +│ ├── data/ # jobs.sqlite 数据目录(不提交) │ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要) │ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all │ └── web/ # 静态前端(rclone rcd 直接服务) │ ├── index.html │ └── assets/ ├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交) -├── docker-compose.yml # 单容器 rclone rcd +├── docker-compose.yml # rclone rcd + jobs-api sidecar ├── DESIGN.md # UI 设计系统规范 ├── CLAUDE.md # Claude Code 协作指引 └── rclone/ # submodule → github.com/rclone/rclone,纯净不改动 @@ -29,20 +31,25 @@ `/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。 - **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / rename / download。 -- **同步任务** — copy / sync / move 异步任务,1.5 秒轮询进度(速度、 - ETA、已传输 / 总量、错误计数),任务元信息(src→dst)持久化到 - localStorage,刷新页面不丢。 +- **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、 + ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC; + 固定循环任务保存到 SQLite,并由 jobs-api sidecar 调度。 > OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, > 引导用户在终端跑 `rclone config` 完成授权。 ## 快速开始 +> 安全提示:默认 Docker 编排使用 `--rc-no-auth`,RC API 可以读写和删除 +> 已配置 remote 上的数据。Compose 文件默认只绑定到 `127.0.0.1`。不要把 +> `5580` / `5581` 直接暴露到 LAN 或公网;需要远程访问时,请先加带认证 +> 和 TLS 的反向代理。 + ```bash # 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要) git clone --recurse-submodules -# 2. 启动堆栈(单容器,无需 build) +# 2. 启动堆栈(无需 build) docker compose up -d # 3. 打开 http://localhost:5580 @@ -50,7 +57,14 @@ docker compose up -d ## 架构 -只用一个 rclone rcd 容器,一个端口(5580),同时承担: +Docker 编排包含两个服务: + +| 服务 | 本机 URL | 职责 | +|---|---|---| +| `rclone` | `http://localhost:5580` | 静态前端、RC API、远端文件下载 | +| `jobs-api` | `http://localhost:5581` | SQLite 循环任务 API 和调度器 | + +`rclone` 容器通过 `rclone rcd` 同时承担: | 职责 | URL | 配置项 | |---|---|---| @@ -58,8 +72,20 @@ docker compose up -d | RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 | | 远端文件下载 | `GET /:` | `--rc-serve` | -浏览器同源访问 → 不需要 CORS、不需要 nginx、不需要双端口。 -要加 TLS 或自定义 header 时,**在前面套你自己的反代**即可。 +浏览器从静态前端调用 `5580` 的 RC API,并调用 `5581` 的 jobs-api 管理 +固定循环任务。jobs-api 默认只允许 `http://localhost:5580` 和 +`http://127.0.0.1:5580` 这两个 Origin。反代或改端口时,同时调整 +`JOBS_API_ALLOWED_ORIGINS`。 + +## 开发检查 + +```bash +find webgui/web/assets/js -name '*.js' -exec node --check {} \; +python3 -m py_compile webgui/api/server.py +python3 -m unittest discover -s webgui/api -p '*_test.py' +curl -sS http://127.0.0.1:5580/ +curl -sS http://127.0.0.1:5581/health +``` ## 自行构建 rclone(可选) diff --git a/docker-compose.yml b/docker-compose.yml index f32f146..97fb622 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,8 @@ # # The jobs-api sidecar stores recurring task definitions in SQLite and starts # scheduled runs through rclone RC. The browser calls it on port 5581. -# For TLS, auth, or custom headers, put a reverse proxy of your choice in front. +# Default port bindings are loopback-only because the RC API runs without auth. +# For LAN/public access, put TLS and authentication in a reverse proxy first. # # rclone config lives in ./config/rclone/rclone.conf (bind-mounted). # If it doesn't exist yet, create your remotes with: @@ -50,7 +51,7 @@ services: - XDG_CONFIG_HOME=/config - RCLONE_CACHE_DIR=/cache ports: - - "5580:8080" + - "127.0.0.1:5580:8080" restart: unless-stopped jobs-api: @@ -67,9 +68,10 @@ services: - JOBS_API_HOST=0.0.0.0 - JOBS_API_PORT=8081 - JOBS_SCHEDULER_INTERVAL=15 + - JOBS_API_ALLOWED_ORIGINS=http://localhost:5580,http://127.0.0.1:5580 - RCLONE_RC_URL=http://rclone:8080 ports: - - "5581:8081" + - "127.0.0.1:5581:8081" restart: unless-stopped volumes: diff --git a/webgui/api/server.py b/webgui/api/server.py index b092114..7181c7a 100644 --- a/webgui/api/server.py +++ b/webgui/api/server.py @@ -27,6 +27,15 @@ RCLONE_RC_URL = os.environ.get("RCLONE_RC_URL", "http://rclone:8080").rstrip("/" HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0") PORT = int(os.environ.get("JOBS_API_PORT", "8081")) SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15")) +MAX_BODY_BYTES = int(os.environ.get("JOBS_API_MAX_BODY_BYTES", str(64 * 1024))) +ALLOWED_ORIGINS = { + origin.strip().rstrip("/") + for origin in os.environ.get( + "JOBS_API_ALLOWED_ORIGINS", + "http://localhost:5580,http://127.0.0.1:5580", + ).split(",") + if origin.strip() +} VALID_ACTIONS = {"copy", "sync", "move"} VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"} @@ -646,11 +655,22 @@ def scheduler_loop() -> None: traceback.print_exc() +def scheduler_initial_tick() -> None: + try: + scheduler_tick() + except Exception: + traceback.print_exc() + + class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_OPTIONS(self) -> None: - self.send_json(204, None) + try: + self.require_allowed_origin() + self.send_json(204, None) + except ApiError as exc: + self.send_json(exc.status, {"error": exc.message}) def do_GET(self) -> None: self.handle_request("GET") @@ -663,6 +683,7 @@ class Handler(BaseHTTPRequestHandler): def handle_request(self, method: str) -> None: try: + self.require_allowed_origin() parsed = urlparse(self.path) path = parsed.path.rstrip("/") or "/" parts = [p for p in path.split("/") if p] @@ -704,8 +725,22 @@ class Handler(BaseHTTPRequestHandler): traceback.print_exc() self.send_json(500, {"error": str(exc)}) + def require_allowed_origin(self) -> None: + origin = self.headers.get("Origin") + if not origin: + return + if origin.rstrip("/") not in ALLOWED_ORIGINS: + raise ApiError(403, "origin not allowed") + def read_json(self) -> dict[str, Any]: - length = int(self.headers.get("Content-Length") or "0") + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + raise ApiError(400, "invalid Content-Length") from None + if length < 0: + raise ApiError(400, "invalid Content-Length") + if length > MAX_BODY_BYTES: + raise ApiError(413, "request body too large") if length == 0: return {} raw = self.rfile.read(length) @@ -719,8 +754,11 @@ class Handler(BaseHTTPRequestHandler): def send_json(self, status: int, body: Any) -> None: raw = b"" if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8") + origin = self.headers.get("Origin") self.send_response(status) - self.send_header("Access-Control-Allow-Origin", "*") + if origin and origin.rstrip("/") in ALLOWED_ORIGINS: + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") self.send_header("Content-Type", "application/json; charset=utf-8") @@ -747,10 +785,8 @@ def main() -> None: init_db() thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True) thread.start() - try: - scheduler_tick() - except Exception: - traceback.print_exc() + initial_thread = threading.Thread(target=scheduler_initial_tick, name="job-scheduler-initial", daemon=True) + initial_thread.start() server = ThreadingHTTPServer((HOST, PORT), Handler) print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}") try: diff --git a/webgui/api/server_test.py b/webgui/api/server_test.py new file mode 100644 index 0000000..a69a44d --- /dev/null +++ b/webgui/api/server_test.py @@ -0,0 +1,102 @@ +import unittest +from io import BytesIO +from datetime import datetime, timezone + +from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at + + +class ComputeNextRunAtTest(unittest.TestCase): + def test_daily_uses_same_day_when_time_is_future(self): + after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("daily", "23:30", 0, after), + "2026-06-20T23:30:00Z", + ) + + def test_daily_moves_to_next_day_when_time_has_passed(self): + after = datetime(2026, 6, 20, 23, 30, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("daily", "23:30", 0, after), + "2026-06-21T23:30:00Z", + ) + + def test_weekly_uses_next_target_weekday(self): + after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) # Saturday + self.assertEqual( + compute_next_run_at("weekly", "09:00", 0, after, weekday=1), + "2026-06-22T09:00:00Z", + ) + + def test_weekly_rolls_forward_a_week_after_same_day_time_passed(self): + after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) # Monday + self.assertEqual( + compute_next_run_at("weekly", "09:00", 0, after, weekday=1), + "2026-06-29T09:00:00Z", + ) + + def test_monthly_uses_next_valid_monthday(self): + after = datetime(2026, 1, 30, 12, 0, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("monthly", "23:00", 0, after, monthday=31), + "2026-01-31T23:00:00Z", + ) + + def test_monthly_skips_short_months_for_day_31(self): + after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("monthly", "23:00", 0, after, monthday=31), + "2026-03-31T23:00:00Z", + ) + + def test_timezone_offset_is_applied_from_local_schedule_time(self): + after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) + self.assertEqual( + compute_next_run_at("daily", "23:00", 8 * 60, after), + "2026-06-20T15:00:00Z", + ) + + def test_invalid_monthday_raises(self): + after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) + with self.assertRaises(ValueError): + compute_next_run_at("monthly", "23:00", 0, after, monthday=32) + + +class HandlerSecurityTest(unittest.TestCase): + def test_allowed_origin_is_accepted(self): + handler = type("DummyHandler", (), {"headers": {"Origin": "http://localhost:5580"}})() + Handler.require_allowed_origin(handler) + + def test_disallowed_origin_is_rejected(self): + handler = type("DummyHandler", (), {"headers": {"Origin": "http://example.invalid"}})() + with self.assertRaises(ApiError) as ctx: + Handler.require_allowed_origin(handler) + self.assertEqual(ctx.exception.status, 403) + + def test_request_body_size_is_limited(self): + handler = type( + "DummyHandler", + (), + { + "headers": {"Content-Length": str(MAX_BODY_BYTES + 1)}, + "rfile": BytesIO(), + }, + )() + with self.assertRaises(ApiError) as ctx: + Handler.read_json(handler) + self.assertEqual(ctx.exception.status, 413) + + def test_read_json_accepts_small_object(self): + body = b'{"ok":true}' + handler = type( + "DummyHandler", + (), + { + "headers": {"Content-Length": str(len(body))}, + "rfile": BytesIO(body), + }, + )() + self.assertEqual(Handler.read_json(handler), {"ok": True}) + + +if __name__ == "__main__": + unittest.main()