Files
MyGoNavi/internal/webserver/server.go
Syngnat d87e314880 🎨 style(brand): 吉祥物品牌图标可选,并同步 macOS Dock
新增 10 款品牌吉祥物资源与设置页选择器;标题栏/关于页/favicon 跟随切换,
macOS 通过原生 API 更新 Dock 图标(满铺浅蓝 Dock 专用图)。
同步默认 appicon 与 README 品牌展示。
2026-07-15 19:55:20 +08:00

564 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webserver
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"path"
"reflect"
"strings"
"sync"
"time"
aiservice "GoNavi-Wails/internal/ai/service"
appcore "GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/uievents"
)
const (
defaultWebServerAddr = "127.0.0.1:34116"
internalRoutePrefix = "/__gonavi"
)
var errorType = reflect.TypeOf((*error)(nil)).Elem()
var desktopOnlyAppMethods = map[string]struct{}{
"Shutdown": {},
"SetWindowTranslucency": {},
"SetMacNativeWindowControls": {},
"SetApplicationBrandIcon": {},
"ResetWebViewZoom": {},
"SelectDataRootDirectory": {},
"GetDataRootDirectoryInfo": {},
"ApplyDataRootDirectory": {},
"OpenDataRootDirectory": {},
"SelectDriverDownloadDirectory": {},
"SelectDriverPackageFile": {},
"SelectDriverPackageDirectory": {},
"OpenSQLFile": {},
"SelectSQLFileForExecution": {},
"SelectSQLDirectory": {},
"ListSQLDirectory": {},
"ReadSQLFile": {},
"WriteSQLFile": {},
"CreateSQLFile": {},
"CreateSQLDirectory": {},
"DeleteSQLFile": {},
"DeleteSQLDirectory": {},
"RenameSQLFile": {},
"RenameSQLDirectory": {},
"ExecuteSQLFile": {},
"ExportSQLFile": {},
"ImportConfigFile": {},
"ExportConnectionsPackage": {},
"SelectSSHKeyFile": {},
"SelectCertificateFile": {},
"SelectDatabaseFile": {},
"ImportData": {},
"PreviewImportFile": {},
"ImportDataWithProgress": {},
"ExportTable": {},
"ExportTableWithOptions": {},
"ExportTablesSQL": {},
"ExportTablesDataSQL": {},
"ExportTablesSQLWithOptions": {},
"ExportDatabaseSQL": {},
"ExportDatabasesSQLWithOptions": {},
"ExportSchemaSQL": {},
"ExportData": {},
"ExportDataWithOptions": {},
"ExportQuery": {},
"ExportQueryWithOptions": {},
"RedisExportKeys": {},
"ExportSQLAuditFile": {},
}
type Options struct {
Addr string
}
type invokeRequest struct {
Namespace string `json:"namespace"`
Receiver string `json:"receiver"`
Method string `json:"method"`
Args []json.RawMessage `json:"args"`
}
type invokeResponse struct {
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type eventMessage struct {
Name string `json:"name"`
Args []any `json:"args,omitempty"`
}
type eventHub struct {
mu sync.RWMutex
subscribers map[chan eventMessage]struct{}
}
func newEventHub() *eventHub {
return &eventHub{subscribers: make(map[chan eventMessage]struct{})}
}
func (h *eventHub) Emit(name string, args ...any) {
if h == nil || strings.TrimSpace(name) == "" {
return
}
msg := eventMessage{Name: name, Args: args}
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subscribers {
select {
case ch <- msg:
default:
}
}
}
func (h *eventHub) subscribe() chan eventMessage {
ch := make(chan eventMessage, 128)
h.mu.Lock()
h.subscribers[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *eventHub) unsubscribe(ch chan eventMessage) {
if ch == nil {
return
}
h.mu.Lock()
if _, ok := h.subscribers[ch]; ok {
delete(h.subscribers, ch)
close(ch)
}
h.mu.Unlock()
}
type methodInvoker struct {
targets map[string]reflect.Value
}
func newMethodInvoker(app *appcore.App, ai *aiservice.Service) *methodInvoker {
return &methodInvoker{
targets: map[string]reflect.Value{
"app.app": reflect.ValueOf(app),
"app": reflect.ValueOf(app),
"aiservice.service": reflect.ValueOf(ai),
"aiservice": reflect.ValueOf(ai),
},
}
}
func (i *methodInvoker) Invoke(req invokeRequest) (any, error) {
if i == nil {
return nil, fmt.Errorf("web invoker is not initialized")
}
namespace := strings.ToLower(strings.TrimSpace(req.Namespace))
receiver := strings.ToLower(strings.TrimSpace(req.Receiver))
methodName := strings.TrimSpace(req.Method)
if namespace == "" || methodName == "" {
return nil, fmt.Errorf("invalid invoke request")
}
key := namespace
if receiver != "" {
key = namespace + "." + receiver
}
target, ok := i.targets[key]
if !ok {
return nil, fmt.Errorf("unsupported invoke target: %s.%s", namespace, receiver)
}
if (key == "app" || key == "app.app") && isDesktopOnlyAppMethod(methodName) {
return nil, fmt.Errorf("method %s is unavailable in web runtime", methodName)
}
method := target.MethodByName(methodName)
if !method.IsValid() {
return nil, fmt.Errorf("unsupported method: %s.%s.%s", namespace, receiver, methodName)
}
methodType := method.Type()
if methodType.IsVariadic() {
return nil, fmt.Errorf("variadic methods are not supported: %s", methodName)
}
if methodType.NumIn() != len(req.Args) {
return nil, fmt.Errorf("invalid argument count for %s: want %d got %d", methodName, methodType.NumIn(), len(req.Args))
}
callArgs := make([]reflect.Value, 0, len(req.Args))
for index, raw := range req.Args {
argValue, err := decodeArgument(raw, methodType.In(index))
if err != nil {
return nil, fmt.Errorf("decode argument %d for %s failed: %w", index, methodName, err)
}
callArgs = append(callArgs, argValue)
}
results := method.Call(callArgs)
return unpackResults(results)
}
func isDesktopOnlyAppMethod(methodName string) bool {
_, denied := desktopOnlyAppMethods[strings.TrimSpace(methodName)]
return denied
}
func decodeArgument(raw json.RawMessage, targetType reflect.Type) (reflect.Value, error) {
holder := reflect.New(targetType)
if len(raw) == 0 {
return holder.Elem(), nil
}
if err := json.Unmarshal(raw, holder.Interface()); err != nil {
return reflect.Value{}, err
}
return holder.Elem(), nil
}
func unpackResults(results []reflect.Value) (any, error) {
if len(results) == 0 {
return nil, nil
}
last := results[len(results)-1]
if last.IsValid() && last.Type().Implements(errorType) {
if !last.IsNil() {
return nil, last.Interface().(error)
}
results = results[:len(results)-1]
}
switch len(results) {
case 0:
return nil, nil
case 1:
return results[0].Interface(), nil
default:
unpacked := make([]any, len(results))
for index := range results {
unpacked[index] = results[index].Interface()
}
return unpacked, nil
}
}
type Server struct {
options Options
assets fs.FS
app *appcore.App
ai *aiservice.Service
auth *webAuthManager
events *eventHub
invoker *methodInvoker
auditHeavySem chan struct{}
}
func ParseOptions(args []string) (Options, error) {
options := Options{
Addr: defaultWebServerAddr,
}
if envAddr := strings.TrimSpace(os.Getenv("GONAVI_WEB_ADDR")); envAddr != "" {
options.Addr = envAddr
}
fs := flag.NewFlagSet("gonavi web-server", flag.ContinueOnError)
fs.SetOutput(ioDiscard{})
fs.StringVar(&options.Addr, "addr", options.Addr, "web server listen address, for example 127.0.0.1:34116")
if err := fs.Parse(args); err != nil {
return Options{}, err
}
if fs.NArg() > 0 {
return Options{}, fmt.Errorf("unknown web-server arguments: %s", strings.Join(fs.Args(), " "))
}
return options, nil
}
func Run(ctx context.Context, assetFS fs.FS, args []string) error {
options, err := ParseOptions(args)
if err != nil {
return err
}
server, err := New(ctx, assetFS, options)
if err != nil {
return err
}
return server.Run(ctx)
}
func New(ctx context.Context, assetFS fs.FS, options Options) (*Server, error) {
if assetFS == nil {
return nil, fmt.Errorf("web assets are unavailable")
}
frontendFS, err := fs.Sub(assetFS, "frontend/dist")
if err != nil {
return nil, fmt.Errorf("resolve frontend dist assets failed: %w", err)
}
events := newEventHub()
lifecycleCtx := uievents.WithEmitter(ctx, events)
app := appcore.NewWebApp()
appcore.InitializeLifecycle(app, lifecycleCtx)
ai := aiservice.NewService()
aiservice.InitializeLifecycle(ai, lifecycleCtx)
auth, err := newWebAuthManagerFromEnvironment("")
if err != nil {
return nil, fmt.Errorf("initialize web auth failed: %w", err)
}
return &Server{
options: options,
assets: frontendFS,
app: app,
ai: ai,
auth: auth,
events: events,
invoker: newMethodInvoker(app, ai),
auditHeavySem: make(chan struct{}, 1),
}, nil
}
func (s *Server) Run(ctx context.Context) error {
if s == nil {
return fmt.Errorf("web server is not initialized")
}
if ctx == nil {
ctx = context.Background()
}
defer s.ai.Shutdown()
defer s.app.Shutdown()
httpServer := &http.Server{
Addr: s.options.Addr,
Handler: s.routes(),
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := net.Listen("tcp", s.options.Addr)
if err != nil {
return err
}
logger.Infof("GoNavi Web Server 启动addr=%s", listener.Addr().String())
errCh := make(chan error, 1)
go func() {
errCh <- httpServer.Serve(listener)
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := httpServer.Shutdown(shutdownCtx)
serveErr := <-errCh
if shutdownErr != nil && !errors.Is(shutdownErr, http.ErrServerClosed) {
return shutdownErr
}
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
}
return nil
}
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(internalRoutePrefix+"/auth/status", s.handleAuthStatus)
mux.HandleFunc(internalRoutePrefix+"/auth/setup/bootstrap", s.handleSetupBootstrap)
mux.HandleFunc(internalRoutePrefix+"/auth/setup/complete", s.handleSetupComplete)
mux.HandleFunc(internalRoutePrefix+"/auth/login", s.handleLogin)
mux.HandleFunc(internalRoutePrefix+"/auth/logout", s.handleLogout)
mux.Handle(internalRoutePrefix+"/auth/settings", s.requireWebAuth(http.HandlerFunc(s.handleAuthSettings)))
mux.Handle(internalRoutePrefix+"/auth/settings/password", s.requireWebAuth(http.HandlerFunc(s.handleAuthPasswordChange)))
mux.Handle(internalRoutePrefix+"/api/invoke", s.requireWebAuth(http.HandlerFunc(s.handleInvoke)))
mux.Handle(internalRoutePrefix+"/events", s.requireWebAuth(http.HandlerFunc(s.handleEvents)))
mux.Handle(internalRoutePrefix+"/web-runtime.js", s.requireWebAuth(http.HandlerFunc(s.handleRuntimeBridge)))
mux.HandleFunc(internalRoutePrefix+"/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/login", s.handleLoginPage)
mux.HandleFunc("/setup", s.handleSetupPage)
fileServer := http.FileServer(http.FS(s.assets))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, internalRoutePrefix+"/") {
http.NotFound(w, r)
return
}
status := s.auth.Status(func() string {
sessionID, _ := readSessionCookie(r)
return sessionID
}())
if !status.Configured {
http.Redirect(w, r, buildAuthRedirectURL("/setup", r.URL.RequestURI()), http.StatusSeeOther)
return
}
if !status.Authenticated {
clearSessionCookie(w, r)
http.Redirect(w, r, buildAuthRedirectURL("/login", r.URL.RequestURI()), http.StatusSeeOther)
return
}
if s.shouldServeIndex(r.URL.Path) {
s.serveIndex(w, r)
return
}
fileServer.ServeHTTP(w, r)
})
return withSecurityHeaders(mux)
}
func (s *Server) shouldServeIndex(requestPath string) bool {
cleaned := strings.TrimPrefix(path.Clean("/"+requestPath), "/")
if cleaned == "" || cleaned == "." {
return true
}
if strings.Contains(path.Base(cleaned), ".") {
file, err := s.assets.Open(cleaned)
if err != nil {
return false
}
defer file.Close()
info, err := file.Stat()
return err == nil && info.IsDir()
}
return true
}
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
payload, err := fs.ReadFile(s.assets, "index.html")
if err != nil {
http.Error(w, "frontend index is unavailable", http.StatusInternalServerError)
return
}
html := injectRuntimeBridge(string(payload))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, "index.html", time.Time{}, strings.NewReader(html))
}
func injectRuntimeBridge(indexHTML string) string {
if strings.Contains(indexHTML, internalRoutePrefix+"/web-runtime.js") {
return indexHTML
}
scriptTag := fmt.Sprintf(`<script src="%s/web-runtime.js"></script>`, internalRoutePrefix)
if strings.Contains(indexHTML, "</head>") {
return strings.Replace(indexHTML, "</head>", scriptTag+"\n</head>", 1)
}
return scriptTag + "\n" + indexHTML
}
func (s *Server) handleInvoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
var request invokeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
s.writeInvokeResponse(w, http.StatusBadRequest, invokeResponse{Error: err.Error()})
return
}
if isSQLAuditHeavyInvoke(request) && s.auditHeavySem != nil {
select {
case s.auditHeavySem <- struct{}{}:
defer func() { <-s.auditHeavySem }()
default:
s.writeInvokeResponse(w, http.StatusTooManyRequests, invokeResponse{Error: "another SQL audit export or integrity verification is already in progress"})
return
}
}
result, err := s.invoker.Invoke(request)
if err != nil {
s.writeInvokeResponse(w, http.StatusBadRequest, invokeResponse{Error: err.Error()})
return
}
s.writeInvokeResponse(w, http.StatusOK, invokeResponse{Result: result})
}
func isSQLAuditHeavyInvoke(request invokeRequest) bool {
namespace := strings.ToLower(strings.TrimSpace(request.Namespace))
receiver := strings.ToLower(strings.TrimSpace(request.Receiver))
if namespace != "app" || (receiver != "" && receiver != "app") {
return false
}
switch strings.TrimSpace(request.Method) {
case "BuildSQLAuditExport", "VerifySQLAuditIntegrity":
return true
default:
return false
}
}
func (s *Server) writeInvokeResponse(w http.ResponseWriter, status int, response invokeResponse) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := s.events.subscribe()
defer s.events.unsubscribe(ch)
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
_, _ = w.Write([]byte(": connected\n\n"))
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
_, _ = w.Write([]byte(": ping\n\n"))
flusher.Flush()
case msg, ok := <-ch:
if !ok {
return
}
payload, err := json.Marshal(msg)
if err != nil {
continue
}
_, _ = fmt.Fprintf(w, "event: gonavi\ndata: %s\n\n", payload)
flusher.Flush()
}
}
}
func (s *Server) handleRuntimeBridge(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(runtimeBridgeScript()))
}
type ioDiscard struct{}
func (ioDiscard) Write(p []byte) (int, error) {
return len(p), nil
}