package main // HTTP plumbing + JS value-semantics helpers: the Go port of lib/http.js // plus the small coercion helpers server.js relies on (cleanText, // requiredText, optionalIso, nonNegativeNumber, asIds). import ( "bytes" "encoding/json" "errors" "fmt" "io" "math" "net/http" "net/url" "os" "strconv" "strings" "time" "unicode/utf8" ) // ---------- lib/http.js ---------- // SendJSON is sendJson: compact JSON, HTML escaping off (via marshalJSON), // content-type/length set first, caller headers override. func SendJSON(w http.ResponseWriter, status int, v any, headers map[string]string) { body, err := marshalJSON(v) if err != nil { status = 500 body = []byte(`{"error":"internal_error","message":"服务器内部错误"}`) } h := w.Header() h.Set("Content-Type", "application/json; charset=utf-8") h.Set("Content-Length", strconv.Itoa(len(body))) for k, val := range headers { h.Set(k, val) } w.WriteHeader(status) _, _ = w.Write(body) } // SendError is sendError: {"error":code,"message":message}. func SendError(w http.ResponseWriter, status int, message, code string) { SendJSON(w, status, map[string]any{"error": code, "message": message}, nil) } const defaultMaxBody = 1024 * 1024 // ReadJSON is readJson: empty body -> empty map; over maxBytes -> 413; // invalid JSON -> 400. Numbers decode as json.Number (UseNumber). func ReadJSON(r *http.Request, maxBytes int64) (map[string]any, error) { raw, err := io.ReadAll(io.LimitReader(r.Body, maxBytes+1)) if err != nil { return nil, err } if int64(len(raw)) > maxBytes { return nil, httpErr(413, "Request body is too large") } if len(raw) == 0 { return map[string]any{}, nil } var out map[string]any if err := unmarshalJSONUseNumber(raw, &out); err != nil { return nil, httpErr(400, "Invalid JSON body") } if out == nil { // body was `null` out = map[string]any{} } return out, nil } // RoutePattern is lib/http.js route(): ":param" matches a single non-empty // path segment; a single trailing slash on the pathname is optional. // Captured values are URI-decoded like decodeURIComponent (on a malformed // escape the raw segment is kept). func RoutePattern(pattern, pathname string) (map[string]string, bool) { p := pathname if len(p) > 1 && strings.HasSuffix(p, "/") { p = p[:len(p)-1] } patSegs := strings.Split(strings.TrimPrefix(pattern, "/"), "/") pathSegs := strings.Split(strings.TrimPrefix(p, "/"), "/") if len(patSegs) != len(pathSegs) { return nil, false } params := map[string]string{} for i, seg := range patSegs { if strings.HasPrefix(seg, ":") { if pathSegs[i] == "" { return nil, false } v := pathSegs[i] if dec, err := url.PathUnescape(v); err == nil { v = dec } params[seg[1:]] = v continue } if seg != pathSegs[i] { return nil, false } } return params, true } // securityHeaders mirrors server.js securityHeaders(). func securityHeaders(w http.ResponseWriter) { h := w.Header() h.Set("X-Frame-Options", "DENY") h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") } // ---------- dispatch ---------- // trackingWriter records whether a response has been committed, mirroring // the JS `if (!res.headersSent) ... else res.end()` error path. type trackingWriter struct { http.ResponseWriter wrote bool } func (t *trackingWriter) WriteHeader(status int) { t.wrote = true t.ResponseWriter.WriteHeader(status) } func (t *trackingWriter) Write(b []byte) (int, error) { t.wrote = true return t.ResponseWriter.Write(b) } // ServeHTTP is requestHandler: security headers first, then healthz, auth, // agent, subscription, admin, static, SPA fallback, 404 — in that order. func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { tw := &trackingWriter{ResponseWriter: w} securityHeaders(tw) pathname := r.URL.EscapedPath() if err := a.dispatch(tw, r, pathname); err != nil { fmt.Fprintf(os.Stderr, "[%s] %v\n", NowISO(), err) if !tw.wrote { var he *HTTPError if errors.As(err, &he) { SendError(tw, he.Status, he.Message, "request_error") } else { SendError(tw, 500, "服务器内部错误", "internal_error") } } } } func (a *App) dispatch(w http.ResponseWriter, r *http.Request, pathname string) error { if pathname == "/healthz" { SendJSON(w, 200, map[string]any{"ok": true, "version": VERSION, "time": NowISO()}, nil) return nil } if handled, err := a.handleAuth(w, r, pathname); err != nil || handled { return err } if handled, err := a.handleAgent(w, r, pathname); err != nil || handled { return err } if handled, err := a.handleSubscription(w, r, pathname); err != nil || handled { return err } if handled, err := a.handleAdminApi(w, r, pathname); err != nil || handled { return err } if r.Method == http.MethodGet { served, err := ServeStatic(w, pathname) if err != nil { return err } if served { return nil } } if r.Method == http.MethodGet && !strings.HasPrefix(pathname, "/api/") { _, err := ServeStatic(w, "/") return err } SendError(w, 404, "接口不存在", "not_found") return nil } // ---------- JS value semantics ---------- // JSString approximates JS String(value) for the shapes JSON bodies carry: // nil -> "", string as-is, json.Number -> its literal, bool -> "true"/"false". // Objects/arrays yield "" (JS would produce "[object Object]"/joined text; // no panel code path depends on those). func JSString(v any) string { switch t := v.(type) { case nil: return "" case string: return t case json.Number: return t.String() case bool: if t { return "true" } return "false" case float64: return strconv.FormatFloat(t, 'g', -1, 64) default: return "" } } // secretString is String(value) as used by verifySecret/hashSecret inputs: // strings pass through, everything else goes through JSString. func secretString(v any) string { if s, ok := v.(string); ok { return s } return JSString(v) } // jsTruthy is JS Boolean(value) / `value || fallback` truthiness. func jsTruthy(v any) bool { switch t := v.(type) { case nil: return false case bool: return t case string: return t != "" case json.Number: f, err := t.Float64() return err == nil && f != 0 && !math.IsNaN(f) case float64: return t != 0 && !math.IsNaN(t) default: return true // arrays and objects are always truthy in JS } } // jsNumber approximates JS Number(value); ok=false is NaN. func jsNumber(v any) (float64, bool) { switch t := v.(type) { case nil: return 0, false // Number(null) is 0 in JS, but panel paths treat // null like undefined via `value == null` guards before calling // Number; the remaining direct uses (ports) want NaN semantics. case json.Number: f, err := t.Float64() if err != nil { return 0, false } return f, true case float64: return t, true case bool: if t { return 1, true } return 0, true case string: s := strings.TrimSpace(t) if s == "" { return 0, true } f, err := strconv.ParseFloat(s, 64) if err != nil { return 0, false } return f, true case []any: if len(t) == 0 { return 0, true } if len(t) == 1 { return jsNumber(t[0]) } return 0, false default: return 0, false } } // truncateRunes slices by rune count. JS slice() counts UTF-16 code units, // so astral-plane characters count once here but twice in JS. func truncateRunes(s string, max int) string { if max < 0 { max = 0 } if utf8.RuneCountInString(s) <= max { return s } r := []rune(s) return string(r[:max]) } // CleanTextStr is cleanText for an already-string value. func CleanTextStr(s string, max int) string { return truncateRunes(strings.TrimSpace(s), max) } // CleanText is String(value ?? ”).trim().slice(0, max). func CleanText(v any, max int) string { return CleanTextStr(JSString(v), max) } // RequiredText is requiredText: CleanText, or 400 `