This commit is contained in:
onysd 2026-09-01 20:06:58 +03:00
parent d2f4e11390
commit 95e62c2d77
17 changed files with 288 additions and 16 deletions

View file

@ -0,0 +1,32 @@
package main
import (
"net/http"
"net/url"
"strconv"
)
// handleAddServerLinkAPI builds an owpg://addserver link (see the desktop
// and Android clients' handling of that scheme) carrying only this server's
// host and port, so an operator can hand it out as a ready-made "add my
// server" button/QR code.
//
// Deliberately carries nothing else -- no name, description, key, or DC.
// Anyone who can get a link in front of a user (a forum post, a chat
// message, an intercepted share) controls whatever it contains; if it also
// carried the RSA key, a link with a forged key pointed at an attacker's own
// host would be indistinguishable from a real one, and the client would
// trust it outright as "this server's identity" -- a real MITM vector, not
// a hypothetical one. host+port alone can't misrepresent anything: the
// client always fetches name/description/key/DC itself, straight from
// whatever actually answers at that address (ServerInfoPath), the same way
// it already does for a hand-typed address in the "Add Server" form.
func (s *server) handleAddServerLinkAPI(w http.ResponseWriter, r *http.Request) {
q := url.Values{}
q.Set("host", s.cfg.AdvertiseHost)
q.Set("port", strconv.Itoa(s.cfg.ServerPort))
writeJSON(w, http.StatusOK, map[string]any{
"link": "owpg://addserver?" + q.Encode(),
})
}

View file

@ -7,9 +7,11 @@ import (
"encoding/hex"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@ -139,6 +141,14 @@ type uiConfig struct {
// from (or by something that cd'd into) the repo root -- true both for a
// manual run and for how the TUI itself launches it.
RepoRoot string
// AdvertiseHost/ServerPort mirror config.AdvertiseIP and the port half
// of config.ListenAddr -- what the "Add Server" sidebar button needs to
// build an owpg://addserver link for this exact server (host+port only,
// see handleAddServerLinkAPI's doc comment for why nothing else belongs
// in that link), assuming both binaries share the same .env (same
// convention as the WelcomeMessage/LoginCode defaults above).
AdvertiseHost string
ServerPort int
}
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
@ -170,6 +180,15 @@ func loadConfig() (uiConfig, error) {
return uiConfig{}, fmt.Errorf("resolve repo root: %w", err)
}
_, serverPortStr, err := net.SplitHostPort(appCfg.ListenAddr)
if err != nil {
return uiConfig{}, fmt.Errorf("parse TELESRV_LISTEN %q: %w", appCfg.ListenAddr, err)
}
serverPort, err := strconv.Atoi(serverPortStr)
if err != nil {
return uiConfig{}, fmt.Errorf("parse TELESRV_LISTEN port %q: %w", serverPortStr, err)
}
return uiConfig{
Addr: appCfg.AdminUIAddr,
PostgresDSN: appCfg.PostgresDSN,
@ -186,6 +205,8 @@ func loadConfig() (uiConfig, error) {
WelcomeMessageEmailDefault: appCfg.WelcomeMessageEmailTemplate,
LoginCodeMessageDefault: appCfg.LoginCodeMessageTemplate,
RepoRoot: repoRoot,
AdvertiseHost: appCfg.AdvertiseIP,
ServerPort: serverPort,
}, nil
}

View file

@ -177,6 +177,7 @@ func (s *server) routes() http.Handler {
// in-process admin API), so it works even for actions (Restart/Update)
// that owpengram-server could never safely perform on itself.
mux.Handle("GET /api/server/identity", s.serverManage(s.handleServerIdentityAPI))
mux.Handle("GET /api/server/add-server-link", s.serverManage(s.handleAddServerLinkAPI))
mux.Handle("GET /api/server/icon", s.serverManage(s.handleServerIconAPI))
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
mux.Handle("POST /api/actions/set-welcome-message-templates", s.serverManage(s.handleSetWelcomeMessageTemplatesAPI))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-D9dyWksN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUxLVnzO.css">
<script type="module" crossorigin src="/assets/index-CP20QCwX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Wtwg__bv.css">
</head>
<body>
<div id="root"></div>

View file

@ -237,6 +237,7 @@ export const api = {
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
addServerLink: () => request<{ link: string }>("/api/server/add-server-link"),
uploadServerIcon: (form: FormData) => request<CommandResult>("/api/actions/upload-server-icon", { method: "POST", body: form }),
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
serverEnv: () => request<EnvGroup[]>("/api/server/env"),

View file

@ -0,0 +1,28 @@
// navigator.clipboard only exists in a secure context (HTTPS, or localhost).
// This admin panel is frequently reached over a plain http:// LAN address
// (e.g. a self-hosted server's own IP), where navigator.clipboard is simply
// undefined -- calling .writeText on it throws "Cannot read properties of
// undefined". Fall back to the old execCommand('copy') path via a hidden,
// off-screen textarea, which still works in that case.
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const ok = document.execCommand("copy");
if (!ok) {
throw new Error("Copy command was not successful");
}
} finally {
document.body.removeChild(textarea);
}
}

View file

@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
@ -94,7 +95,7 @@ export function ActionButton({
: result?.details;
async function copySecret() {
await navigator.clipboard.writeText(secretValue);
await copyToClipboard(secretValue);
setSecretCopied(true);
}

View file

@ -0,0 +1,81 @@
import { Check, Copy, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { Alert, LoadingSurface } from "./ui";
// AddServerLinkModal shows a ready-made owpg://addserver link for this
// exact server (host+port only -- see the Go handler's doc comment for why
// name/description/key/DC are deliberately never embedded in it) -- an
// operator hands this out (a website button, a QR code, a message
// elsewhere) and the desktop/Android client's "Add Server" form opens
// pre-filled from it, fetching the rest straight from this server itself.
export function AddServerLinkModal({ onClose }: { onClose: () => void }) {
const [link, setLink] = useState<string | null>(null);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
api.addServerLink()
.then((result) => {
if (!cancelled) setLink(result.link);
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, []);
async function copy() {
if (!link) return;
try {
await copyToClipboard(link);
setCopied(true);
} catch (err) {
setError(errorMessage(err));
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal add-server-link-modal" role="dialog" aria-modal="true" aria-label={"Share server"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Server"}</div>
<h2>{"Share server"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"Share this link (a button, a QR code, a message) so anyone with the OwpenGram client can add this server in one tap. It only carries the address and port -- the client fetches the name, description, and key directly from the server itself, so the link can never be tampered with to point someone at a fake identity for this address."}</p>
{error && <Alert>{error}</Alert>}
{!link && !error && <LoadingSurface label={"Building link..."} />}
{link && (
<div className="add-server-link-field">
<label className="form-field">
<span>{"owpg:// link"}</span>
<textarea value={link} readOnly rows={2} onFocus={(event) => event.currentTarget.select()} />
</label>
<button className="btn primary icon-text" type="button" onClick={() => void copy()}>
<Copy size={15} /> {copied ? "Copy again" : "Copy link"}
</button>
{copied && (
<div className="secret-reveal">
<div className="secret-reveal-label"><Check size={14} /> {"Copied to clipboard."}</div>
</div>
)}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -2,6 +2,7 @@ import { Check, Copy, X } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { Alert } from "./ui";
// CopyBotTokenModal writes a non-system bot's token straight to the
@ -34,7 +35,7 @@ export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose:
setError(result.error || "No token returned.");
return;
}
await navigator.clipboard.writeText(token);
await copyToClipboard(token);
setCopied(true);
} catch (err) {
setError(errorMessage(err));

View file

@ -10,18 +10,21 @@ import {
Megaphone,
MessageSquareText,
Settings,
Share2,
ShieldAlert,
ShieldCheck,
Smile,
Stamp,
Users,
Zap,
Sticker
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { api, errorMessage } from "../api";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AddServerLinkModal } from "./AddServerLinkModal";
import { AppLink } from "./AppLink";
// Compresses a sorted (or unsorted) list of layer numbers into run-length
@ -85,6 +88,29 @@ export function Shell({
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const canManageServer = useCan(permissionServerManage);
const [addServerLinkOpen, setAddServerLinkOpen] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState("");
// "Connect" is the short-cut version of Share: instead of showing a link to
// copy elsewhere, it opens the owpg://addserver link (host+port only --
// see the Go handler's doc comment for why nothing else ever goes in it)
// right here in this browser, so if an OwpenGram client is registered for
// that scheme on this machine, it launches straight into "Add Server"
// pre-filled for the server this very admin panel manages, fetching the
// rest (name/description/key) straight from it.
async function connectThisServer() {
setConnecting(true);
setConnectError("");
try {
const result = await api.addServerLink();
window.location.href = result.link;
} catch (err) {
setConnectError(errorMessage(err));
} finally {
setConnecting(false);
}
}
// Server identity (name/icon) is admin-editable per Server Settings ->
// Server identity, and takes over the sidebar branding when set -- the
// operator's own server should look like their server, not like the
@ -256,6 +282,29 @@ export function Shell({
</span>
)}
</div>
{canManageServer && (
<div className="sidebar-server-actions">
<button
className="btn ghost sidebar-server-action"
type="button"
title={"Connect this browser's client to this server"}
disabled={connecting}
onClick={() => void connectThisServer()}
>
<Zap size={15} /> {"Connect"}
</button>
<button
className="btn ghost sidebar-server-action"
type="button"
title={"Share server (get an add-server link)"}
onClick={() => setAddServerLinkOpen(true)}
>
<Share2 size={15} /> {"Share"}
</button>
</div>
)}
{connectError && <div className="sidebar-server-action-error">{connectError}</div>}
{addServerLinkOpen && <AddServerLinkModal onClose={() => setAddServerLinkOpen(false)} />}
</aside>
<div className="workspace">
<header className="topbar">

View file

@ -1,6 +1,7 @@
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import type { EmojiListResponse, EmojiRow } from "../types";
@ -43,7 +44,7 @@ function EmojiCard({ row }: { row: EmojiRow }) {
async function copy() {
try {
await navigator.clipboard.writeText(row.DocumentID);
await copyToClipboard(row.DocumentID);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {

View file

@ -256,6 +256,35 @@ a {
font-size: 11px;
}
.sidebar-server-actions {
display: flex;
gap: 8px;
}
.sidebar-server-action {
flex: 1 1 0;
min-width: 0;
font-size: 12.5px;
color: var(--sidebar-text);
background: transparent;
border-color: var(--sidebar-line);
}
.sidebar-server-action:hover:not(:disabled) {
background: var(--sidebar-line);
}
.sidebar-server-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sidebar-server-action-error {
padding: 0 4px;
color: var(--danger, #e5484d);
font-size: 11.5px;
}
.sidebar-label {
padding: 0 8px;
color: var(--sidebar-faint);

View file

@ -63,6 +63,33 @@
gap: 8px;
}
.add-server-link-modal {
width: min(920px, 100%);
max-height: min(880px, calc(100vh - 48px));
}
.add-server-link-field {
display: grid;
gap: 8px;
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.add-server-link-field textarea {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
word-break: break-all;
resize: vertical;
}
.add-server-link-hint {
margin: 0;
color: var(--muted-2);
font-size: 12.5px;
}
.command-body {
display: grid;
min-height: 0;