fix: 收紧 GUI 安全边界
This commit is contained in:
@@ -104,7 +104,7 @@ Docker 编排包含两个服务:
|
|||||||
## 开发检查
|
## 开发检查
|
||||||
|
|
||||||
```bash
|
```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 py_compile webgui/api/server.py
|
||||||
python3 -m unittest discover -s webgui/api -p '*_test.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:5580/
|
||||||
@@ -117,6 +117,11 @@ Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即
|
|||||||
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
|
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
|
||||||
能像 `rclone gui` 那样独立运行),可以:
|
能像 `rclone gui` 那样独立运行),可以:
|
||||||
|
|
||||||
|
注意:内置 `rclone webgui` 只启动静态 GUI 和 rclone RC,不会启动
|
||||||
|
`jobs-api` sidecar。当前 Jobs 页依赖 `jobs-api` 的 SQLite 接口,所以完整
|
||||||
|
的单次/循环任务体验请使用 Docker 编排,或自行启动兼容的 jobs-api 并通过
|
||||||
|
`?jobsApi=<url>` 指给前端。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
|
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
|
||||||
ln -s ../../webgui rclone/cmd/webgui
|
ln -s ../../webgui rclone/cmd/webgui
|
||||||
|
|||||||
+39
-27
@@ -16,6 +16,7 @@ import traceback
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -178,11 +179,22 @@ def connect() -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def db_connect():
|
||||||
|
with db_lock:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
db_dir = os.path.dirname(DB_PATH)
|
db_dir = os.path.dirname(DB_PATH)
|
||||||
if db_dir:
|
if db_dir:
|
||||||
os.makedirs(db_dir, exist_ok=True)
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS job_schedules (
|
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]:
|
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()
|
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
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]]:
|
def list_jobs() -> list[dict[str, Any]]:
|
||||||
refresh_running_jobs()
|
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()
|
rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall()
|
||||||
return [row_to_job(row) for row in rows]
|
return [row_to_job(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def fetch_one_time_job(job_id: int) -> dict[str, Any]:
|
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()
|
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
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]]:
|
def list_one_time_jobs() -> list[dict[str, Any]]:
|
||||||
refresh_one_time_jobs()
|
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()
|
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]
|
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)
|
interval_seconds = legacy_interval_seconds(schedule_kind)
|
||||||
|
|
||||||
now = iso(utcnow())
|
now = iso(utcnow())
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO job_schedules
|
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")
|
raise ApiError(400, "src and dst are required")
|
||||||
|
|
||||||
now = iso(utcnow())
|
now = iso(utcnow())
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO one_time_jobs
|
INSERT INTO one_time_jobs
|
||||||
@@ -452,7 +464,7 @@ def legacy_interval_seconds(schedule_kind: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def delete_job(schedule_id: int) -> None:
|
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,))
|
cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,))
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise ApiError(404, "job not found")
|
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]:
|
def manual_run(schedule_id: int) -> dict[str, Any]:
|
||||||
with scheduler_lock:
|
with scheduler_lock:
|
||||||
refresh_running_jobs()
|
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()
|
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
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]:
|
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()
|
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
raise ApiError(404, "job not found")
|
||||||
@@ -485,7 +497,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
|
|||||||
now = iso(now_dt)
|
now = iso(now_dt)
|
||||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||||
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
|
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE job_schedules
|
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]:
|
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()
|
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
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]:
|
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()
|
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
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})
|
rc_post("job/stop", {"jobid": jobid})
|
||||||
now = iso(utcnow())
|
now = iso(utcnow())
|
||||||
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
|
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE one_time_jobs
|
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:
|
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,))
|
cur = conn.execute("DELETE FROM one_time_jobs WHERE id=?", (job_id,))
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise ApiError(404, "job not found")
|
raise ApiError(404, "job not found")
|
||||||
@@ -619,7 +631,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
|
|||||||
error = str(exc)
|
error = str(exc)
|
||||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||||
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
|
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO job_runs
|
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=(",", ":"))
|
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
|
||||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO job_runs
|
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:
|
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()
|
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "job not found")
|
raise ApiError(404, "job not found")
|
||||||
@@ -699,7 +711,7 @@ def start_one_time_job(job_id: int) -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
|
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE one_time_jobs
|
UPDATE one_time_jobs
|
||||||
@@ -717,7 +729,7 @@ def start_one_time_job(job_id: int) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
|
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE one_time_jobs
|
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:
|
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()
|
rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall()
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -776,7 +788,7 @@ def refresh_running_jobs() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def refresh_one_time_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()
|
rows = conn.execute("SELECT * FROM one_time_jobs WHERE status='running' AND jobid IS NOT NULL").fetchall()
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -797,7 +809,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
|||||||
name = status_name(status)
|
name = status_name(status)
|
||||||
|
|
||||||
if name == "running":
|
if name == "running":
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE job_schedules
|
UPDATE job_schedules
|
||||||
@@ -821,7 +833,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
error = status.get("error")
|
error = status.get("error")
|
||||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE job_schedules
|
UPDATE job_schedules
|
||||||
@@ -854,7 +866,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
|||||||
name = status_name(status)
|
name = status_name(status)
|
||||||
|
|
||||||
if name == "running":
|
if name == "running":
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE one_time_jobs
|
UPDATE one_time_jobs
|
||||||
@@ -869,7 +881,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
error = status.get("error")
|
error = status.get("error")
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE one_time_jobs
|
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:
|
def run_due_jobs() -> None:
|
||||||
with scheduler_lock:
|
with scheduler_lock:
|
||||||
now = iso(utcnow())
|
now = iso(utcnow())
|
||||||
with db_lock, connect() as conn:
|
with db_connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM job_schedules
|
SELECT * FROM job_schedules
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
// the top-nav active state in sync, and re-renders chrome strings when
|
// the top-nav active state in sync, and re-renders chrome strings when
|
||||||
// the locale changes.
|
// the locale changes.
|
||||||
|
|
||||||
import { escapeHtml, onRoute } from "./state.js?v=review-fixes-1";
|
import { escapeHtml, onRoute } from "./state.js?v=security-fixes-1";
|
||||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=review-fixes-1";
|
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=security-fixes-1";
|
||||||
import { renderRemotes } from "./views/remotes.js?v=review-fixes-1";
|
import { renderRemotes } from "./views/remotes.js?v=security-fixes-1";
|
||||||
import { renderBrowse } from "./views/browser.js?v=review-fixes-1";
|
import { renderBrowse } from "./views/browser.js?v=security-fixes-1";
|
||||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=review-fixes-1";
|
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=security-fixes-1";
|
||||||
import {
|
import {
|
||||||
renderConfigureNew,
|
renderConfigureNew,
|
||||||
renderConfigureEdit,
|
renderConfigureEdit,
|
||||||
} from "./views/configure.js?v=review-fixes-1";
|
} from "./views/configure.js?v=security-fixes-1";
|
||||||
|
|
||||||
const views = {
|
const views = {
|
||||||
remotes: renderRemotes,
|
remotes: renderRemotes,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const AUTH_PASS = params.get("pass");
|
|||||||
let authHeader = null;
|
let authHeader = null;
|
||||||
if (AUTH_USER && AUTH_PASS) {
|
if (AUTH_USER && AUTH_PASS) {
|
||||||
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
|
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
|
||||||
|
scrubAuthQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rcURL() {
|
export function rcURL() {
|
||||||
@@ -30,6 +31,13 @@ export function isNoAuth() {
|
|||||||
return !authHeader;
|
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.
|
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
|
||||||
export async function post(path, body = {}, options = {}) {
|
export async function post(path, body = {}, options = {}) {
|
||||||
const headers = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json" };
|
||||||
@@ -72,14 +80,13 @@ export async function uploadFile(fs, remote, files) {
|
|||||||
export function downloadURL(remoteFs, remotePath, fileName) {
|
export function downloadURL(remoteFs, remotePath, fileName) {
|
||||||
const base = RC_BASE.replace(/\/$/, "");
|
const base = RC_BASE.replace(/\/$/, "");
|
||||||
// rc server serves remote files at /<remote>:<path>
|
// rc server serves remote files at /<remote>:<path>
|
||||||
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, "");
|
const path = [remoteFs, remotePath, fileName]
|
||||||
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`;
|
.filter(Boolean)
|
||||||
let url = `${base}/${path}`;
|
.flatMap((part) => String(part).split("/"))
|
||||||
if (authHeader) {
|
.filter((part) => part !== "")
|
||||||
// Embed basic auth into the URL so the browser can fetch it directly.
|
.map(encodeURIComponent)
|
||||||
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`);
|
.join("/");
|
||||||
}
|
return `${base}/${path}`;
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseResponse(res, path, { allowError = false } = {}) {
|
async function parseResponse(res, path, { allowError = false } = {}) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
|
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
|
||||||
|
|
||||||
import { post, uploadFile, downloadURL } from "../rc.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=review-fixes-1";
|
import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=security-fixes-1";
|
||||||
import { t } from "../i18n.js?v=review-fixes-1";
|
import { t } from "../i18n.js?v=security-fixes-1";
|
||||||
|
|
||||||
export async function renderBrowse({ remote, path }) {
|
export async function renderBrowse({ remote, path }) {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -169,7 +169,7 @@ function row(fs, path, item) {
|
|||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="col-name">
|
<td class="col-name">
|
||||||
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
|
<a href="${escapeAttr(downloadURL(fs, path, item.Name))}" download="${escapeAttr(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
|
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
|
||||||
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
|
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
|
||||||
@@ -296,3 +296,7 @@ export function openModal(title, fields, onSubmit) {
|
|||||||
if (first) first.focus();
|
if (first) first.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return escapeHtml(s);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
// OAuth backends (option named "token" with IsPassword) get a banner
|
// OAuth backends (option named "token" with IsPassword) get a banner
|
||||||
// and disabled submit — user must run `rclone config` in a terminal.
|
// and disabled submit — user must run `rclone config` in a terminal.
|
||||||
|
|
||||||
import { post } from "../rc.js?v=review-fixes-1";
|
import { post } from "../rc.js?v=security-fixes-1";
|
||||||
import { escapeHtml, getState, setState, toast } from "../state.js?v=review-fixes-1";
|
import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
|
||||||
import { t } from "../i18n.js?v=review-fixes-1";
|
import { t } from "../i18n.js?v=security-fixes-1";
|
||||||
|
|
||||||
// --- Route entrypoints ---
|
// --- Route entrypoints ---
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export async function renderConfigureNew({ provider = "" }) {
|
|||||||
document.getElementById("app").innerHTML = `
|
document.getElementById("app").innerHTML = `
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>${t("error.unknown_remote")}</h3>
|
<h3>${t("error.unknown_remote")}</h3>
|
||||||
<p>${t("error.no_such_provider", provider)}</p>
|
<p>${t("error.no_such_provider", escapeHtml(provider))}</p>
|
||||||
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
|
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -53,7 +53,7 @@ export async function renderConfigureEdit({ remote }) {
|
|||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>${t("error.unknown_remote")}</h3>
|
<h3>${t("error.unknown_remote")}</h3>
|
||||||
<p>${t("error.no_such_remote", remote)}</p>
|
<p>${t("error.no_such_remote", escapeHtml(remote))}</p>
|
||||||
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
|
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -155,6 +155,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
|||||||
const requiresOAuth = provider.Options.some(
|
const requiresOAuth = provider.Options.some(
|
||||||
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
|
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
|
||||||
);
|
);
|
||||||
|
const blocksCreate = requiresOAuth && !isEdit;
|
||||||
|
|
||||||
// Partition options into required, optional-basic, optional-advanced.
|
// Partition options into required, optional-basic, optional-advanced.
|
||||||
const required = provider.Options.filter((o) => o.Required && !o.Hide);
|
const required = provider.Options.filter((o) => o.Required && !o.Hide);
|
||||||
@@ -206,7 +207,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}>
|
<button type="submit" class="btn btn-primary" ${blocksCreate ? "disabled" : ""}>
|
||||||
${isEdit ? t("configure.save") : t("configure.create")}
|
${isEdit ? t("configure.save") : t("configure.create")}
|
||||||
</button>
|
</button>
|
||||||
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
|
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
|
||||||
@@ -255,7 +256,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
|||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (requiresOAuth) return;
|
if (blocksCreate) return;
|
||||||
|
|
||||||
const name = form.elements._remote_name.value.trim();
|
const name = form.elements._remote_name.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
// 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 {
|
import {
|
||||||
toast,
|
toast,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatSpeed,
|
formatSpeed,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
} from "../state.js?v=review-fixes-1";
|
} from "../state.js?v=security-fixes-1";
|
||||||
import { t } from "../i18n.js?v=review-fixes-1";
|
import { t } from "../i18n.js?v=security-fixes-1";
|
||||||
import {
|
import {
|
||||||
createOneTimeJob,
|
createOneTimeJob,
|
||||||
createRecurringJob,
|
createRecurringJob,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
runRecurringJobNow,
|
runRecurringJobNow,
|
||||||
stopOneTimeJob,
|
stopOneTimeJob,
|
||||||
stopRecurringJob,
|
stopRecurringJob,
|
||||||
} from "../jobs_api.js?v=review-fixes-1";
|
} from "../jobs_api.js?v=security-fixes-1";
|
||||||
|
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
const LOCAL_FS_VALUE = "__local__";
|
const LOCAL_FS_VALUE = "__local__";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
||||||
|
|
||||||
import { post } from "../rc.js?v=review-fixes-1";
|
import { post } from "../rc.js?v=security-fixes-1";
|
||||||
import { escapeHtml, getState, setState, toast } from "../state.js?v=review-fixes-1";
|
import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
|
||||||
import { t } from "../i18n.js?v=review-fixes-1";
|
import { t } from "../i18n.js?v=security-fixes-1";
|
||||||
|
|
||||||
export async function renderRemotes() {
|
export async function renderRemotes() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles/tokens.css">
|
<link rel="stylesheet" href="/assets/styles/tokens.css">
|
||||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||||
<link rel="stylesheet" href="/assets/styles/components.css?v=review-fixes-1">
|
<link rel="stylesheet" href="/assets/styles/components.css?v=security-fixes-1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
@@ -80,6 +80,6 @@
|
|||||||
<div id="toast-stack" class="toast-stack"></div>
|
<div id="toast-stack" class="toast-stack"></div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
|
|
||||||
<script type="module" src="/assets/js/app.js?v=review-fixes-1"></script>
|
<script type="module" src="/assets/js/app.js?v=security-fixes-1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+21
-5
@@ -171,7 +171,7 @@ network.
|
|||||||
return fmt.Errorf("failed to make password: %w", err)
|
return fmt.Errorf("failed to make password: %w", err)
|
||||||
}
|
}
|
||||||
opt.Auth.BasicPass = randomPass
|
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)
|
fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL)
|
||||||
|
|
||||||
// Build the launch URL: always pass ?url=<rcURL> so the SPA can
|
// Build the launch URL: always pass ?url=<rcURL> 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)
|
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 !noOpenBrowser {
|
||||||
if err := open.Start(loginURL); err != nil {
|
if err := open.Start(loginURL); err != nil {
|
||||||
fs.Errorf(nil, "failed to open GUI in browser: %v", err)
|
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
|
// buildLoginURL constructs the URL the browser should open. The query
|
||||||
// string always carries the RC API base URL so the SPA can find it.
|
// 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
|
// When auth is enabled, user/pass and a /login hash are added so the SPA can
|
||||||
// SPA can present credentials to the cross-port RC server.
|
// 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 {
|
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
|
||||||
u, err := url.Parse(guiBaseURL)
|
u, err := url.Parse(guiBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -308,3 +312,15 @@ func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
|
|||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
return u.String()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user