254 lines
12 KiB
Go
254 lines
12 KiB
Go
package adminweb
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||
)
|
||
|
||
type Snapshot struct {
|
||
SystemState, ClientIDMasked string
|
||
SecretConfigured bool
|
||
Components []Component
|
||
Administrators []AdministratorView
|
||
}
|
||
type AdministratorView struct {
|
||
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
|
||
CanDisable bool
|
||
}
|
||
type Component struct {
|
||
Name, State, Code, Detail string
|
||
LastSuccess, CheckedAt time.Time
|
||
}
|
||
type Verification struct {
|
||
OK bool
|
||
Code string
|
||
CheckedAt time.Time
|
||
}
|
||
type Service interface {
|
||
Snapshot(context.Context) (Snapshot, error)
|
||
SaveCredentials(context.Context, string, string) error
|
||
VerifyCredentials(context.Context, string, string) Verification
|
||
}
|
||
type BootstrapService interface {
|
||
CreateBootstrap(context.Context, string) error
|
||
ConfirmBootstrap(context.Context) error
|
||
}
|
||
type CandidateService interface {
|
||
ActivateCandidate(context.Context) error
|
||
}
|
||
type AdministratorService interface {
|
||
DisableAdministrator(context.Context, string) error
|
||
}
|
||
type server struct {
|
||
service Service
|
||
token string
|
||
csrfToken string
|
||
mux *http.ServeMux
|
||
page *template.Template
|
||
}
|
||
type pageData struct {
|
||
Snapshot
|
||
CSRFToken string
|
||
}
|
||
|
||
const settingsPage = `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||
<title>DobotAI 设置</title>
|
||
<style>
|
||
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
|
||
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
|
||
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
|
||
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
|
||
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>DobotAI 配置中心</h1>
|
||
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
|
||
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
|
||
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
|
||
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
|
||
</body></html>`
|
||
|
||
func New(service Service, token string) http.Handler {
|
||
functions := template.FuncMap{"timeText": func(value time.Time) string {
|
||
if value.IsZero() {
|
||
return "—"
|
||
}
|
||
return value.UTC().Format("2006-01-02 15:04:05")
|
||
}}
|
||
csrfBytes := make([]byte, 32)
|
||
if _, err := rand.Read(csrfBytes); err != nil {
|
||
panic("admin CSRF token unavailable")
|
||
}
|
||
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
|
||
s.mux.HandleFunc("GET /admin/settings", s.settings)
|
||
s.mux.HandleFunc("GET /admin/setup", s.settings)
|
||
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
|
||
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
|
||
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
|
||
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
|
||
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
|
||
return s
|
||
}
|
||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
if !s.authorized(r) {
|
||
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
|
||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
s.mux.ServeHTTP(w, r)
|
||
}
|
||
func (s *server) authorized(r *http.Request) bool {
|
||
if s.token == "" {
|
||
return false
|
||
}
|
||
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
|
||
return true
|
||
}
|
||
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
|
||
}
|
||
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||
if err := r.ParseForm(); err != nil {
|
||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||
return false
|
||
}
|
||
provided := strings.TrimSpace(r.FormValue("csrf_token"))
|
||
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
|
||
http.Error(w, "forbidden", http.StatusForbidden)
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
|
||
snap, err := s.service.Snapshot(r.Context())
|
||
if err != nil {
|
||
http.Error(w, "settings unavailable", 500)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
|
||
}
|
||
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRFForm(w, r, 16<<10) {
|
||
return
|
||
}
|
||
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
|
||
result := s.service.VerifyCredentials(r.Context(), id, secret)
|
||
if !result.OK {
|
||
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
|
||
return
|
||
}
|
||
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
|
||
http.Error(w, "credential save failed", 500)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||
}
|
||
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||
return
|
||
}
|
||
service, ok := s.service.(CandidateService)
|
||
if !ok {
|
||
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
if err := service.ActivateCandidate(r.Context()); err != nil {
|
||
http.Error(w, "candidate activation failed", http.StatusConflict)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
|
||
}
|
||
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||
return
|
||
}
|
||
service, ok := s.service.(BootstrapService)
|
||
if !ok {
|
||
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
code := strings.TrimSpace(r.FormValue("code"))
|
||
if !sixDigits(code) {
|
||
http.Error(w, "binding code must be 6 digits", 400)
|
||
return
|
||
}
|
||
if err := service.CreateBootstrap(r.Context(), code); err != nil {
|
||
http.Error(w, "create binding code failed", 500)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
|
||
}
|
||
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||
return
|
||
}
|
||
service, ok := s.service.(BootstrapService)
|
||
if !ok {
|
||
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
if err := service.ConfirmBootstrap(r.Context()); err != nil {
|
||
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||
}
|
||
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
|
||
if !s.requireCSRFForm(w, r, 4<<10) {
|
||
return
|
||
}
|
||
service, ok := s.service.(AdministratorService)
|
||
if !ok {
|
||
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
staffID := strings.TrimSpace(r.FormValue("staff_id"))
|
||
if staffID == "" {
|
||
http.Error(w, "administrator not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
err := service.DisableAdministrator(r.Context(), staffID)
|
||
switch {
|
||
case err == nil:
|
||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||
case errors.Is(err, settings.ErrLastAdministrator):
|
||
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
|
||
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
|
||
http.Error(w, "管理员已经停用。", http.StatusConflict)
|
||
case errors.Is(err, settings.ErrAdministratorNotFound):
|
||
http.Error(w, "administrator not found", http.StatusNotFound)
|
||
default:
|
||
http.Error(w, "administrator update failed", http.StatusInternalServerError)
|
||
}
|
||
}
|
||
func sixDigits(value string) bool {
|
||
if len(value) != 6 {
|
||
return false
|
||
}
|
||
for _, ch := range value {
|
||
if ch < '0' || ch > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|