diff --git a/package.json b/package.json index 52096d9c..2bd8153e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.79", + "version": "0.9.80", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index 52c579c4..9dfb5f7b 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -27,7 +27,8 @@ "api_doc": "API Documentation", "version": "Version", "video": "Video", - "notification": "Notifications" + "notification": "Notifications", + "cloudflared": "Cloudflare Tunnel" }, "channel": { "leave": "Leave Channel", @@ -461,5 +462,22 @@ "update_error": "Failed to update channel type", "delete_success": "Channel type deleted successfully", "delete_error": "Failed to delete channel type" + }, + "cloudflared": { + "title": "Cloudflare Tunnel", + "desc": "Expose this server to the public internet via a temporary trycloudflare.com tunnel — no Cloudflare account required.", + "start": "Start Tunnel", + "stop": "Stop Tunnel", + "stopping": "Stopping...", + "copy": "Copy", + "url_copied": "URL copied to clipboard", + "downloading_binary": "Downloading cloudflared binary...", + "status": { + "idle": "Idle", + "downloading": "Downloading", + "starting": "Starting", + "running": "Running", + "error": "Error" + } } -} +} \ No newline at end of file diff --git a/public/locales/zh/setting.json b/public/locales/zh/setting.json index 6d3aa9b6..f369621d 100644 --- a/public/locales/zh/setting.json +++ b/public/locales/zh/setting.json @@ -26,7 +26,8 @@ "version": "版本信息", "feedback": "反馈", "video": "音视频", - "notification": "通知设置" + "notification": "通知设置", + "cloudflared": "Cloudflare 隧道" }, "channel": { "leave": "离开", @@ -58,7 +59,6 @@ "name": "服务器名", "desc": "服务器描述", "upload_desc": "图片最小 128x128,我们推荐上传 512x512 大小的图片,最大不要超过 5M。", - "sign_up": { "title": "注册设置", "desc": "允许什么方式注册", @@ -201,7 +201,6 @@ "renew": "升级证书", "update": "手动更新", "update_placeholder": "请输入新的证书内容", - "renew_select": "请选择价格套餐", "tip": { "title": "一个获取免费升级的机会!", @@ -460,5 +459,22 @@ "update_error": "渠道类型更新失败", "delete_success": "渠道类型删除成功", "delete_error": "渠道类型删除失败" + }, + "cloudflared": { + "title": "Cloudflare 隧道", + "desc": "通过临时 trycloudflare.com 隧道将此服务器暴露到公网,无需 Cloudflare 账号。", + "start": "启动隧道", + "stop": "停止隧道", + "stopping": "停止中...", + "copy": "复制", + "url_copied": "链接已复制到剪贴板", + "downloading_binary": "正在下载 cloudflared 二进制文件...", + "status": { + "idle": "空闲", + "downloading": "下载中", + "starting": "启动中", + "running": "运行中", + "error": "错误" + } } -} +} \ No newline at end of file diff --git a/src/app/services/server.ts b/src/app/services/server.ts index 2d647091..5aacf795 100644 --- a/src/app/services/server.ts +++ b/src/app/services/server.ts @@ -7,6 +7,7 @@ import { AgoraConfig, AgoraTokenResponse, AgoraVoicingListResponse, + CloudflaredStatus, CreateAdminDTO, FirebaseConfig, GithubAuthConfig, @@ -482,6 +483,16 @@ export const serverApi = createApi({ }), invalidatesTags: (result, error, gid) => [{ type: "GroupAnnouncements", id: gid }], }), + getCloudflaredStatus: builder.query({ + query: () => ({ url: `/admin/cloudflared/status` }), + }), + stopCloudflared: builder.mutation({ + query: () => ({ + url: `/admin/cloudflared/stop`, + method: "POST", + responseHandler: "text", + }), + }), }), }); @@ -543,4 +554,6 @@ export const { useGetGroupAnnouncementQuery, useCreateOrUpdateGroupAnnouncementMutation, useDeleteGroupAnnouncementMutation, + useLazyGetCloudflaredStatusQuery, + useStopCloudflaredMutation, } = serverApi; diff --git a/src/routes/setting/Cloudflared/index.tsx b/src/routes/setting/Cloudflared/index.tsx new file mode 100644 index 00000000..84f39956 --- /dev/null +++ b/src/routes/setting/Cloudflared/index.tsx @@ -0,0 +1,245 @@ +import { useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +import { BASE_ORIGIN, tokenHeader } from "@/app/config"; +import { useLazyGetCloudflaredStatusQuery, useStopCloudflaredMutation } from "@/app/services/server"; +import ServerVersionChecker from "@/components/ServerVersionChecker"; +import Button from "@/components/styled/Button"; +import { getLocalAuthData } from "@/utils"; + +type TunnelStatus = "idle" | "downloading" | "starting" | "running" | "error"; + +interface SseEvent { + event: string; + message: string; + url: string | null; + progress: number | null; +} + +export default function Cloudflared() { + const { t } = useTranslation("setting", { keyPrefix: "cloudflared" }); + const [status, setStatus] = useState("idle"); + const [tunnelUrl, setTunnelUrl] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(0); + const [logs, setLogs] = useState([]); + const [streaming, setStreaming] = useState(false); + const abortRef = useRef(null); + + const [getStatus] = useLazyGetCloudflaredStatusQuery(); + const [stopCloudflared, { isLoading: isStopping }] = useStopCloudflaredMutation(); + + useEffect(() => { + getStatus().then((res) => { + if (res.data) { + setStatus(res.data.status); + setTunnelUrl(res.data.url); + setErrorMsg(res.data.error); + } + }); + }, []); + + const addLog = (msg: string) => + setLogs((prev) => [...prev.slice(-29), msg]); + + const handleSseEvent = (evt: SseEvent) => { + addLog(evt.message); + switch (evt.event) { + case "downloading": + setStatus("downloading"); + if (evt.progress != null) setDownloadProgress(evt.progress); + break; + case "progress": + setStatus("starting"); + break; + case "url": + setStatus("running"); + setTunnelUrl(evt.url); + break; + case "already_running": + setStatus("running"); + setTunnelUrl(evt.url); + break; + case "error": + setStatus("error"); + setErrorMsg(evt.message); + break; + case "stopped": + setStatus("idle"); + break; + } + }; + + const startTunnel = async () => { + if (streaming) return; + setStreaming(true); + setStatus("starting"); + setLogs([]); + setTunnelUrl(null); + setErrorMsg(null); + setDownloadProgress(0); + + const ctrl = new AbortController(); + abortRef.current = ctrl; + + try { + const { token } = getLocalAuthData(); + const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/start`, { + method: "POST", + headers: { [tokenHeader]: token }, + signal: ctrl.signal, + }); + + if (!resp.ok || !resp.body) { + setStatus("error"); + setErrorMsg(`HTTP ${resp.status}`); + setStreaming(false); + return; + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + + const parts = buf.split("\n\n"); + buf = parts.pop() ?? ""; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith("data:")) continue; + try { + const evt: SseEvent = JSON.parse(line.slice("data:".length).trim()); + handleSseEvent(evt); + if (["url", "error", "stopped", "already_running"].includes(evt.event)) { + reader.cancel(); + setStreaming(false); + return; + } + } catch { + // ignore malformed lines + } + } + } + } catch (err: any) { + if (err?.name !== "AbortError") { + setStatus("error"); + setErrorMsg(String(err)); + } + } finally { + setStreaming(false); + abortRef.current = null; + } + }; + + const handleStop = async () => { + abortRef.current?.abort(); + await stopCloudflared(); + setStatus("idle"); + setTunnelUrl(null); + setLogs([]); + }; + + const handleCopyUrl = () => { + if (!tunnelUrl) return; + navigator.clipboard.writeText(tunnelUrl); + toast.success(t("url_copied")); + }; + + const statusBadge: Record = { + idle: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300", + downloading: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", + starting: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", + running: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300", + error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", + }; + + const isActive = status === "running" || status === "downloading" || status === "starting"; + + return ( + +
+
+

{t("title")}

+

{t("desc")}

+
+ +
+ + {t(`status.${status}`)} + +
+ + {status === "downloading" && ( +
+
+ {t("downloading_binary")} + {downloadProgress}% +
+
+
+
+
+ )} + + {status === "running" && tunnelUrl && ( +
+ + {tunnelUrl} + + +
+ )} + + {status === "error" && errorMsg && ( +
+ {errorMsg} +
+ )} + + {logs.length > 0 && ( +
+ {logs.map((log, i) => ( +
{log}
+ ))} +
+ )} + +
+ {!isActive && ( + + )} + {isActive && ( + + )} +
+
+ + ); +} diff --git a/src/routes/setting/navs.tsx b/src/routes/setting/navs.tsx index ebbb009e..25838370 100644 --- a/src/routes/setting/navs.tsx +++ b/src/routes/setting/navs.tsx @@ -6,6 +6,7 @@ import Version from "@/components/Version"; import APIConfig from "./APIConfig"; import APIDocument from "./APIDocument"; import BotConfig from "./BotConfig"; +import Cloudflared from "./Cloudflared"; import ConfigAgora from "./config/Agora"; import ConfigFirebase from "./config/Firebase"; import Logins from "./config/Logins"; @@ -56,6 +57,11 @@ const navs = [ component: , admin: true, }, + { + name: "cloudflared", + component: , + admin: true, + }, { name: "notification_channels", component: , diff --git a/src/types/server.ts b/src/types/server.ts index 36905756..7fee90da 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -144,3 +144,9 @@ export interface RenewLicenseResponse { export interface CreateAdminDTO extends Pick { password: string; } + +export interface CloudflaredStatus { + status: "idle" | "downloading" | "starting" | "running" | "error"; + url: string | null; + error: string | null; +}