Files
Felis/internal/panel/panel.go
T
flyemoji a63f49dcb3 feat(panel): steer WeChat/QQ in-app browsers to the system browser for passkey
WebAuthn is unusable inside the WeChat/QQ in-app WebViews, so a document navigation carrying those UAs is served a bilingual 'open in your system browser' interstitial instead of the passkey-centric SPA. API/config/health/asset requests pass through, and an ack cookie (ua_ack) lets a determined user or false-positive continue. Backend-only; the SPA is untouched.
2026-07-02 19:38:38 +09:00

98 lines
2.4 KiB
Go

// Package panel serves the embedded Felis control panel next to the external API.
package panel
import (
"bytes"
"embed"
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed static
var static embed.FS
type runtimeConfig struct {
APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"`
}
// Handler wraps the external API handler with the panel SPA.
func Handler(api http.Handler, rootDomain string) http.Handler {
files, err := fs.Sub(static, "static")
if err != nil {
panic(err)
}
return &handler{
api: api,
rootDomain: rootDomain,
files: files,
fileServer: http.FileServer(http.FS(files)),
}
}
type handler struct {
api http.Handler
rootDomain string
files fs.FS
fileServer http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// WeChat/QQ in-app browsers cannot run WebAuthn, so steer their document
// navigations to a "open in your system browser" interstitial before the SPA
// (which is built around passkey enrollment) ever loads. See webview.go.
if guardInAppWebView(w, r) {
return
}
switch {
case r.URL.Path == "/healthz" || r.URL.Path == "/readyz" || strings.HasPrefix(r.URL.Path, "/api/"):
h.api.ServeHTTP(w, r)
case r.URL.Path == "/config.json":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(runtimeConfig{APIBase: "/api/v1", RootDomain: h.rootDomain})
case h.hasStaticFile(r.URL.Path):
h.fileServer.ServeHTTP(w, r)
default:
h.serveIndex(w, r)
}
}
func (h *handler) hasStaticFile(urlPath string) bool {
name := strings.TrimPrefix(path.Clean("/"+urlPath), "/")
if name == "" {
name = "index.html"
}
info, err := fs.Stat(h.files, name)
return err == nil && !info.IsDir()
}
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
f, err := h.files.Open("index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.Error(w, "panel assets missing", http.StatusServiceUnavailable)
return
}
http.Error(w, "open panel index", http.StatusInternalServerError)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.Error(w, "stat panel index", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(f)
if err != nil {
http.Error(w, "read panel index", http.StatusInternalServerError)
return
}
http.ServeContent(w, r, "index.html", info.ModTime(), bytes.NewReader(body))
}