package zoraxy_plugin import ( "embed" "io/fs" "net/http" "net/url" "os" "strings" "time" ) // PluginUiRouter serves an embedded web UI and provides CSRF token injection. type PluginUiRouter struct { PluginID string TargetFs *embed.FS TargetFsPrefix string HandlerPrefix string EnableDebug bool terminateHandler func() } // NewPluginEmbedUIRouter creates a router backed by an embed.FS. // targetFsPrefix is the root folder within the embed.FS (e.g. "/web"). // handlerPrefix is the HTTP path prefix (e.g. "/ui"). func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { if !strings.HasPrefix(targetFsPrefix, "/") { targetFsPrefix = "/" + targetFsPrefix } targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") if !strings.HasPrefix(handlerPrefix, "/") { handlerPrefix = "/" + handlerPrefix } handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") return &PluginUiRouter{ PluginID: pluginID, TargetFs: targetFs, TargetFsPrefix: targetFsPrefix, HandlerPrefix: handlerPrefix, } } // csrfMiddleware intercepts HTML responses and injects the CSRF token. func (p *PluginUiRouter) csrfMiddleware(r *http.Request, fsHandler http.Handler) http.Handler { csrfToken := r.Header.Get("X-Zoraxy-Csrf") if csrfToken == "" { csrfToken = "missing-csrf-token" } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, ".html") { targetPath := p.TargetFsPrefix + "/" + strings.TrimPrefix(r.URL.Path, "/") targetPath = strings.TrimPrefix(targetPath, "/") content, err := fs.ReadFile(*p.TargetFs, targetPath) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } body := strings.ReplaceAll(string(content), "{{.csrfToken}}", csrfToken) w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) w.Write([]byte(body)) return } if strings.HasSuffix(r.URL.Path, "/") { indexPath := p.TargetFsPrefix + "/" + strings.TrimPrefix(r.URL.Path, "/") + "index.html" indexPath = strings.TrimPrefix(indexPath, "/") content, err := fs.ReadFile(*p.TargetFs, indexPath) if err == nil { body := strings.ReplaceAll(string(content), "{{.csrfToken}}", csrfToken) w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) w.Write([]byte(body)) return } } fsHandler.ServeHTTP(w, r) }) } // Handler returns an http.Handler for the embedded UI. func (p *PluginUiRouter) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rewrittenURL := strings.TrimPrefix(r.RequestURI, p.HandlerPrefix) rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") r.URL, _ = url.Parse(rewrittenURL) r.RequestURI = rewrittenURL subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } p.csrfMiddleware(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) }) } // RegisterTerminateHandler registers a graceful shutdown endpoint at {prefix}/term. func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { p.terminateHandler = termFunc if mux == nil { mux = http.DefaultServeMux } mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { p.terminateHandler() w.WriteHeader(http.StatusOK) go func() { time.Sleep(100 * time.Millisecond) os.Exit(0) }() }) } // HandleFunc registers a handler under the UI path prefix. func (p *PluginUiRouter) HandleFunc(pattern string, handler http.HandlerFunc, mux *http.ServeMux) { if mux == nil { mux = http.DefaultServeMux } if !strings.HasPrefix(pattern, p.HandlerPrefix) { pattern = p.HandlerPrefix + pattern } mux.HandleFunc(pattern, handler) } // AttachHandlerToMux attaches the UI file handler to the mux. func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { if mux == nil { mux = http.DefaultServeMux } p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") mux.Handle(p.HandlerPrefix+"/", p.Handler()) }