eb32fd5e22
外层仓库管理 webgui 源码、Docker 编排与项目文档;rclone 作为
git submodule 锁定在上游 master HEAD(59c86b01b),不携带任何
我们的改动。
- webgui/: webgui 源码(原本位于 rclone/cmd/webgui/)
- web/: 原生 HTML/CSS/JS 静态前端(Anthropic 设计语言)
- webgui.go: Go 子命令源码,仅当自行构建 rclone 二进制时需要
- rclone-cmd-all-add-webgui-import.patch: 把 webgui 注册进
rclone 的 cmd/all/all.go 的补丁,留作 fork 时使用
- rclone/: submodule → github.com/rclone/rclone,纯净不改动
- Dockerfile.webgui: 基于 nginx:1.27-alpine,从 ./webgui/web/
COPY 静态资源
- docker/nginx.conf: SPA 静态托管 + 反向代理 RC API
(/config/、/operations/、/sync/、/job/ 等) 与文件下载
(/<remote>:<path>) 到 rclone rcd 容器,前端同源访问无 CORS
- docker-compose.yml: rclone (官方镜像 + rcd --rc-no-auth
--rc-serve) + gui (nginx) 双服务编排,config 走 bind mount
持久化
- DESIGN.md / CLAUDE.md / README.md: 文档
- .gitignore / .dockerignore: 排除 rclone.conf 等敏感文件,
Docker 构建上下文只剩 webgui/web/ + nginx 配置(几十 KB)
106 lines
3.3 KiB
JavaScript
106 lines
3.3 KiB
JavaScript
// rc.js — minimal client for rclone's RC HTTP API.
|
|
// The RC base URL is discovered from the ?url= query param that the
|
|
// webgui command appends to the GUI URL on launch. If absent (e.g. when
|
|
// developing the SPA from another server), fall back to same-origin.
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const QUERY_URL = params.get("url");
|
|
const RC_BASE = QUERY_URL
|
|
? QUERY_URL.replace(/\/$/, "")
|
|
: location.origin;
|
|
|
|
const AUTH_USER = params.get("user");
|
|
const AUTH_PASS = params.get("pass");
|
|
|
|
let authHeader = null;
|
|
if (AUTH_USER && AUTH_PASS) {
|
|
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
|
|
}
|
|
|
|
export function rcURL() {
|
|
return RC_BASE;
|
|
}
|
|
|
|
// Are we running with basic auth configured from the launch URL?
|
|
export function hasAuth() {
|
|
return authHeader !== null;
|
|
}
|
|
|
|
export function isNoAuth() {
|
|
return !authHeader;
|
|
}
|
|
|
|
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
|
|
export async function post(path, body = {}) {
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (authHeader) headers["Authorization"] = authHeader;
|
|
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
return parseResponse(res, path);
|
|
}
|
|
|
|
// POST JSON and request an async job. Returns { jobid, executeId }.
|
|
export async function postAsync(path, body = {}) {
|
|
return post(path, { ...body, _async: true });
|
|
}
|
|
|
|
// Upload one or more files via multipart form-data.
|
|
// Matches operations/uploadfile: form fields `fs`, `remote`, and one
|
|
// file part per uploaded file. The server uses the part's filename.
|
|
export async function uploadFile(fs, remote, files) {
|
|
const form = new FormData();
|
|
form.set("fs", fs);
|
|
form.set("remote", remote);
|
|
for (const file of files) {
|
|
form.append("file", file, file.name);
|
|
}
|
|
const headers = {};
|
|
if (authHeader) headers["Authorization"] = authHeader;
|
|
// Do NOT set Content-Type — the browser sets multipart boundary.
|
|
const res = await fetch(RC_BASE + "/operations/uploadfile", {
|
|
method: "POST",
|
|
headers,
|
|
body: form,
|
|
});
|
|
return parseResponse(res, "/operations/uploadfile");
|
|
}
|
|
|
|
// Build a download URL for a file. Requires opt.Serve = true on the rc server.
|
|
export function downloadURL(remoteFs, remotePath, fileName) {
|
|
const base = RC_BASE.replace(/\/$/, "");
|
|
// rc server serves remote files at /<remote>:<path>
|
|
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;
|
|
}
|
|
|
|
async function parseResponse(res, path) {
|
|
let body = null;
|
|
const ct = res.headers.get("Content-Type") || "";
|
|
if (ct.includes("application/json")) {
|
|
body = await res.json();
|
|
} else {
|
|
const text = await res.text();
|
|
body = text ? { raw: text } : {};
|
|
}
|
|
if (!res.ok) {
|
|
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
|
|
const err = new Error(`${path}: ${msg}`);
|
|
err.status = res.status;
|
|
err.body = body;
|
|
throw err;
|
|
}
|
|
if (body && body.error) {
|
|
throw new Error(`${path}: ${body.error}`);
|
|
}
|
|
return body;
|
|
}
|