From a1b791e6ef07e8412d5ac82ce73788e97e2bd32c Mon Sep 17 00:00:00 2001 From: wuxu Date: Sat, 20 Jun 2026 13:51:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=20GUI=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++- webgui/api/server.py | 66 +++++++++++++++---------- webgui/web/assets/js/app.js | 12 ++--- webgui/web/assets/js/rc.js | 23 ++++++--- webgui/web/assets/js/views/browser.js | 12 +++-- webgui/web/assets/js/views/configure.js | 15 +++--- webgui/web/assets/js/views/jobs.js | 8 +-- webgui/web/assets/js/views/remotes.js | 6 +-- webgui/web/index.html | 4 +- webgui/webgui.go | 26 ++++++++-- 10 files changed, 112 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index c3ea2bb..d538a24 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Docker 编排包含两个服务: ## 开发检查 ```bash -find webgui/web/assets/js -name '*.js' -exec node --check {} \; +find webgui/web/assets/js -name '*.js' -exec sh -c 'for f; do node --input-type=module --check < "$f" || exit 1; done' sh {} + 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/ @@ -117,6 +117,11 @@ Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即 如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui` 能像 `rclone gui` 那样独立运行),可以: +注意:内置 `rclone webgui` 只启动静态 GUI 和 rclone RC,不会启动 +`jobs-api` sidecar。当前 Jobs 页依赖 `jobs-api` 的 SQLite 接口,所以完整 +的单次/循环任务体验请使用 Docker 编排,或自行启动兼容的 jobs-api 并通过 +`?jobsApi=` 指给前端。 + ```bash # 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下 ln -s ../../webgui rclone/cmd/webgui diff --git a/webgui/api/server.py b/webgui/api/server.py index e117e7e..fcb5fb6 100644 --- a/webgui/api/server.py +++ b/webgui/api/server.py @@ -16,6 +16,7 @@ import traceback import urllib.error import urllib.request from calendar import monthrange +from contextlib import contextmanager from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any @@ -178,11 +179,22 @@ def connect() -> sqlite3.Connection: return conn +@contextmanager +def db_connect(): + with db_lock: + conn = connect() + try: + with conn: + yield conn + finally: + conn.close() + + def init_db() -> None: db_dir = os.path.dirname(DB_PATH) if db_dir: os.makedirs(db_dir, exist_ok=True) - with db_lock, connect() as conn: + with db_connect() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS job_schedules ( @@ -322,7 +334,7 @@ def row_to_one_time_job(row: sqlite3.Row) -> dict[str, Any]: def fetch_job(schedule_id: int) -> dict[str, Any]: - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -331,13 +343,13 @@ def fetch_job(schedule_id: int) -> dict[str, Any]: def list_jobs() -> list[dict[str, Any]]: refresh_running_jobs() - with db_lock, connect() as conn: + with db_connect() as conn: rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall() return [row_to_job(row) for row in rows] def fetch_one_time_job(job_id: int) -> dict[str, Any]: - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -346,7 +358,7 @@ def fetch_one_time_job(job_id: int) -> dict[str, Any]: def list_one_time_jobs() -> list[dict[str, Any]]: refresh_one_time_jobs() - with db_lock, connect() as conn: + with db_connect() as conn: rows = conn.execute("SELECT * FROM one_time_jobs ORDER BY updated_at DESC, id DESC").fetchall() return [row_to_one_time_job(row) for row in rows] @@ -383,7 +395,7 @@ def create_job(payload: dict[str, Any]) -> dict[str, Any]: interval_seconds = legacy_interval_seconds(schedule_kind) now = iso(utcnow()) - with db_lock, connect() as conn: + with db_connect() as conn: cur = conn.execute( """ INSERT INTO job_schedules @@ -423,7 +435,7 @@ def create_one_time_job(payload: dict[str, Any]) -> dict[str, Any]: raise ApiError(400, "src and dst are required") now = iso(utcnow()) - with db_lock, connect() as conn: + with db_connect() as conn: cur = conn.execute( """ INSERT INTO one_time_jobs @@ -452,7 +464,7 @@ def legacy_interval_seconds(schedule_kind: str) -> int: def delete_job(schedule_id: int) -> None: - with db_lock, connect() as conn: + with db_connect() as conn: cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,)) if cur.rowcount == 0: raise ApiError(404, "job not found") @@ -461,7 +473,7 @@ def delete_job(schedule_id: int) -> None: def manual_run(schedule_id: int) -> dict[str, Any]: with scheduler_lock: refresh_running_jobs() - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -472,7 +484,7 @@ def manual_run(schedule_id: int) -> dict[str, Any]: def stop_job(schedule_id: int) -> dict[str, Any]: - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -485,7 +497,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]: now = iso(now_dt) next_run_at = compute_row_next_run_at(row, now_dt) snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":")) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE job_schedules @@ -512,7 +524,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]: def manual_run_one_time_job(job_id: int) -> dict[str, Any]: - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -523,7 +535,7 @@ def manual_run_one_time_job(job_id: int) -> dict[str, Any]: def stop_one_time_job(job_id: int) -> dict[str, Any]: - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -534,7 +546,7 @@ def stop_one_time_job(job_id: int) -> dict[str, Any]: rc_post("job/stop", {"jobid": jobid}) now = iso(utcnow()) snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":")) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE one_time_jobs @@ -551,7 +563,7 @@ def stop_one_time_job(job_id: int) -> dict[str, Any]: def delete_one_time_job(job_id: int) -> None: - with db_lock, connect() as conn: + with db_connect() as conn: cur = conn.execute("DELETE FROM one_time_jobs WHERE id=?", (job_id,)) if cur.rowcount == 0: raise ApiError(404, "job not found") @@ -619,7 +631,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None: error = str(exc) next_run_at = compute_row_next_run_at(row, now_dt) snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":")) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ INSERT INTO job_runs @@ -647,7 +659,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None: snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":")) next_run_at = compute_row_next_run_at(row, now_dt) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ INSERT INTO job_runs @@ -675,7 +687,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None: def start_one_time_job(job_id: int) -> None: - with db_lock, connect() as conn: + with db_connect() as conn: row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() if row is None: raise ApiError(404, "job not found") @@ -699,7 +711,7 @@ def start_one_time_job(job_id: int) -> None: except Exception as exc: error = str(exc) snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":")) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE one_time_jobs @@ -717,7 +729,7 @@ def start_one_time_job(job_id: int) -> None: return snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":")) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE one_time_jobs @@ -762,7 +774,7 @@ def status_with_stats(kind: str, row: sqlite3.Row, jobid: int) -> dict[str, Any] def refresh_running_jobs() -> None: - with db_lock, connect() as conn: + with db_connect() as conn: rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall() for row in rows: @@ -776,7 +788,7 @@ def refresh_running_jobs() -> None: def refresh_one_time_jobs() -> None: - with db_lock, connect() as conn: + with db_connect() as conn: rows = conn.execute("SELECT * FROM one_time_jobs WHERE status='running' AND jobid IS NOT NULL").fetchall() for row in rows: @@ -797,7 +809,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None: name = status_name(status) if name == "running": - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE job_schedules @@ -821,7 +833,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None: error = status.get("error") next_run_at = compute_row_next_run_at(row, now_dt) - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE job_schedules @@ -854,7 +866,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None: name = status_name(status) if name == "running": - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE one_time_jobs @@ -869,7 +881,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None: return error = status.get("error") - with db_lock, connect() as conn: + with db_connect() as conn: conn.execute( """ UPDATE one_time_jobs @@ -887,7 +899,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None: def run_due_jobs() -> None: with scheduler_lock: now = iso(utcnow()) - with db_lock, connect() as conn: + with db_connect() as conn: rows = conn.execute( """ SELECT * FROM job_schedules diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index 3904055..77f86e8 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -2,15 +2,15 @@ // the top-nav active state in sync, and re-renders chrome strings when // the locale changes. -import { escapeHtml, onRoute } from "./state.js?v=review-fixes-1"; -import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=review-fixes-1"; -import { renderRemotes } from "./views/remotes.js?v=review-fixes-1"; -import { renderBrowse } from "./views/browser.js?v=review-fixes-1"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=review-fixes-1"; +import { escapeHtml, onRoute } from "./state.js?v=security-fixes-1"; +import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=security-fixes-1"; +import { renderRemotes } from "./views/remotes.js?v=security-fixes-1"; +import { renderBrowse } from "./views/browser.js?v=security-fixes-1"; +import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=security-fixes-1"; import { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js?v=review-fixes-1"; +} from "./views/configure.js?v=security-fixes-1"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/rc.js b/webgui/web/assets/js/rc.js index b309bec..1e0c08a 100644 --- a/webgui/web/assets/js/rc.js +++ b/webgui/web/assets/js/rc.js @@ -15,6 +15,7 @@ const AUTH_PASS = params.get("pass"); let authHeader = null; if (AUTH_USER && AUTH_PASS) { authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`); + scrubAuthQuery(); } export function rcURL() { @@ -30,6 +31,13 @@ export function isNoAuth() { return !authHeader; } +function scrubAuthQuery() { + const clean = new URL(location.href); + clean.searchParams.delete("user"); + clean.searchParams.delete("pass"); + history.replaceState(null, "", `${clean.pathname}${clean.search}${clean.hash}`); +} + // POST JSON to an RC endpoint. Returns the parsed JSON response, or throws. export async function post(path, body = {}, options = {}) { const headers = { "Content-Type": "application/json" }; @@ -72,14 +80,13 @@ export async function uploadFile(fs, remote, files) { export function downloadURL(remoteFs, remotePath, fileName) { const base = RC_BASE.replace(/\/$/, ""); // rc server serves remote files at /: - const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, ""); - const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`; - let url = `${base}/${path}`; - if (authHeader) { - // Embed basic auth into the URL so the browser can fetch it directly. - url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`); - } - return url; + const path = [remoteFs, remotePath, fileName] + .filter(Boolean) + .flatMap((part) => String(part).split("/")) + .filter((part) => part !== "") + .map(encodeURIComponent) + .join("/"); + return `${base}/${path}`; } async function parseResponse(res, path, { allowError = false } = {}) { diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index b48a28e..1e14d3c 100644 --- a/webgui/web/assets/js/views/browser.js +++ b/webgui/web/assets/js/views/browser.js @@ -1,8 +1,8 @@ // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. -import { post, uploadFile, downloadURL } from "../rc.js?v=review-fixes-1"; -import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=review-fixes-1"; -import { t } from "../i18n.js?v=review-fixes-1"; +import { post, uploadFile, downloadURL } from "../rc.js?v=security-fixes-1"; +import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=security-fixes-1"; +import { t } from "../i18n.js?v=security-fixes-1"; export async function renderBrowse({ remote, path }) { const app = document.getElementById("app"); @@ -169,7 +169,7 @@ function row(fs, path, item) { return ` - ${icon("file")} ${escapeHtml(item.Name)} + ${icon("file")} ${escapeHtml(item.Name)} ${escapeHtml(formatBytes(item.Size))} ${escapeHtml(formatTime(item.ModTime))} @@ -296,3 +296,7 @@ export function openModal(title, fields, onSubmit) { if (first) first.focus(); }); } + +function escapeAttr(s) { + return escapeHtml(s); +} diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js index 572feab..5c82d27 100644 --- a/webgui/web/assets/js/views/configure.js +++ b/webgui/web/assets/js/views/configure.js @@ -9,9 +9,9 @@ // OAuth backends (option named "token" with IsPassword) get a banner // and disabled submit — user must run `rclone config` in a terminal. -import { post } from "../rc.js?v=review-fixes-1"; -import { escapeHtml, getState, setState, toast } from "../state.js?v=review-fixes-1"; -import { t } from "../i18n.js?v=review-fixes-1"; +import { post } from "../rc.js?v=security-fixes-1"; +import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1"; +import { t } from "../i18n.js?v=security-fixes-1"; // --- Route entrypoints --- @@ -25,7 +25,7 @@ export async function renderConfigureNew({ provider = "" }) { document.getElementById("app").innerHTML = `

${t("error.unknown_remote")}

-

${t("error.no_such_provider", provider)}

+

${t("error.no_such_provider", escapeHtml(provider))}

← ${t("configure.new_title")}

`; return; @@ -53,7 +53,7 @@ export async function renderConfigureEdit({ remote }) { app.innerHTML = `

${t("error.unknown_remote")}

-

${t("error.no_such_remote", remote)}

+

${t("error.no_such_remote", escapeHtml(remote))}

← ${t("nav.remotes")}

`; return; @@ -155,6 +155,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {} const requiresOAuth = provider.Options.some( (o) => o.Name === "token" && (o.IsPassword || o.Sensitive), ); + const blocksCreate = requiresOAuth && !isEdit; // Partition options into required, optional-basic, optional-advanced. const required = provider.Options.filter((o) => o.Required && !o.Hide); @@ -206,7 +207,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
- ${t("configure.cancel")} @@ -255,7 +256,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {} form.addEventListener("submit", async (e) => { e.preventDefault(); - if (requiresOAuth) return; + if (blocksCreate) return; const name = form.elements._remote_name.value.trim(); if (!name) return; diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index afd6c28..69698dc 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -1,14 +1,14 @@ // views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. -import { post } from "../rc.js?v=review-fixes-1"; +import { post } from "../rc.js?v=security-fixes-1"; import { toast, formatBytes, formatSpeed, formatDuration, escapeHtml, -} from "../state.js?v=review-fixes-1"; -import { t } from "../i18n.js?v=review-fixes-1"; +} from "../state.js?v=security-fixes-1"; +import { t } from "../i18n.js?v=security-fixes-1"; import { createOneTimeJob, createRecurringJob, @@ -21,7 +21,7 @@ import { runRecurringJobNow, stopOneTimeJob, stopRecurringJob, -} from "../jobs_api.js?v=review-fixes-1"; +} from "../jobs_api.js?v=security-fixes-1"; let pollTimer = null; const LOCAL_FS_VALUE = "__local__"; diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js index 5473aa1..226b495 100644 --- a/webgui/web/assets/js/views/remotes.js +++ b/webgui/web/assets/js/views/remotes.js @@ -1,8 +1,8 @@ // views/remotes.js — connector-tile grid of configured remotes with CRUD. -import { post } from "../rc.js?v=review-fixes-1"; -import { escapeHtml, getState, setState, toast } from "../state.js?v=review-fixes-1"; -import { t } from "../i18n.js?v=review-fixes-1"; +import { post } from "../rc.js?v=security-fixes-1"; +import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1"; +import { t } from "../i18n.js?v=security-fixes-1"; export async function renderRemotes() { const app = document.getElementById("app"); diff --git a/webgui/web/index.html b/webgui/web/index.html index 61954eb..3b62f3e 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- + diff --git a/webgui/webgui.go b/webgui/webgui.go index 9b5e860..ba3802c 100644 --- a/webgui/webgui.go +++ b/webgui/webgui.go @@ -171,7 +171,7 @@ network. return fmt.Errorf("failed to make password: %w", err) } opt.Auth.BasicPass = randomPass - fs.Infof(nil, "No password specified. Using random password: %s", randomPass) + fs.Infof(nil, "No password specified. Using random password for this session") } } @@ -200,10 +200,13 @@ network. fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) // Build the launch URL: always pass ?url= so the SPA can - // discover the RC base; embed user/pass only when auth is on. + // discover the RC base. loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) - fs.Logf(nil, "GUI available at %s", loginURL) + fs.Logf(nil, "GUI available at %s", safeLoginURL(loginURL)) + if !opt.NoAuth { + fs.Logf(nil, "GUI authentication user: %s", opt.Auth.BasicUser) + } if !noOpenBrowser { if err := open.Start(loginURL); err != nil { fs.Errorf(nil, "failed to open GUI in browser: %v", err) @@ -287,8 +290,9 @@ func guiHandler(srcFS iofs.FS) http.Handler { // buildLoginURL constructs the URL the browser should open. The query // string always carries the RC API base URL so the SPA can find it. -// When auth is enabled, user/pass and a /login hash are added so the -// SPA can present credentials to the cross-port RC server. +// When auth is enabled, user/pass and a /login hash are added so the SPA can +// present credentials to the cross-port RC server, then remove them from the +// visible address bar on load. func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { u, err := url.Parse(guiBaseURL) if err != nil { @@ -308,3 +312,15 @@ func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { u.RawQuery = q.Encode() return u.String() } + +func safeLoginURL(loginURL string) string { + u, err := url.Parse(loginURL) + if err != nil { + return loginURL + } + q := u.Query() + q.Del("user") + q.Del("pass") + u.RawQuery = q.Encode() + return u.String() +}