fix
This commit is contained in:
parent
d2f4e11390
commit
95e62c2d77
17 changed files with 288 additions and 16 deletions
32
cmd/telesrv-admin/addserverlink.go
Normal file
32
cmd/telesrv-admin/addserverlink.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
9
cmd/telesrv-admin/web/dist/assets/index-CP20QCwX.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-CP20QCwX.js
vendored
Normal file
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
1
cmd/telesrv-admin/web/dist/assets/index-Wtwg__bv.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-Wtwg__bv.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
28
cmd/telesrv-admin/web/src/clipboard.ts
Normal file
28
cmd/telesrv-admin/web/src/clipboard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
81
cmd/telesrv-admin/web/src/components/AddServerLinkModal.tsx
Normal file
81
cmd/telesrv-admin/web/src/components/AddServerLinkModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue