package webcontroller import ( "errors" "fmt" "html/template" "net/http" "net/http/httputil" "net/url" "os" "strings" "fornaxian.tech/log" "fornaxian.tech/pixeldrain_api_client/pixelapi" "github.com/julienschmidt/httprouter" ) type Config struct { APIURLExternal string `toml:"api_url_external"` APIURLInternal string `toml:"api_url_internal"` APISocketPath string `toml:"api_socket_path"` ResourceDir string `toml:"resource_dir"` DebugMode bool `toml:"debug_mode"` ProxyAPIRequests bool `toml:"proxy_api_requests"` } // WebController controls how requests are handled and makes sure they have // proper context when running type WebController struct { tpl *template.Template config Config // Server hostname, displayed in the footer of every web page hostname string // API client to use for all requests. If the user is authenticated you // should call Login() on this object. Calling Login will create a copy and // not alter the original PixelAPI, but it will use the same HTTP Transport api pixelapi.PixelAPI } // New initializes a new WebController by registering all the request handlers // and parsing the page template func New(r *httprouter.Router, conf Config) (wc *WebController) { hostname, err := os.Hostname() if err != nil { panic(fmt.Errorf("could not get hostname: %s", err)) } wc = &WebController{ config: conf, hostname: hostname, api: pixelapi.New(conf.APIURLInternal), } if conf.APISocketPath != "" { wc.api = wc.api.UnixSocketPath(conf.APISocketPath) } wc.tpl = wc.parseTemplate() // Serve static files var fs = http.FileServer(http.Dir(conf.ResourceDir + "/static")) var resourceHandler = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { // Cache resources for a year. Except the Svelte entrypoint, it keeps its // name when it changes. The chunks it imports have a hash in their name if p.ByName("filepath") == "/svelte/wrap.js" { w.Header().Set("Cache-Control", "no-cache") } else { w.Header().Set("Cache-Control", "public, max-age=31536000") } r.URL.Path = p.ByName("filepath") fs.ServeHTTP(w, r) } r.HEAD("/res/*filepath", resourceHandler) r.OPTIONS("/res/*filepath", resourceHandler) r.GET("/res/*filepath", resourceHandler) // Browsers and crawlers look for these in the root var rootFile = func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { fs.ServeHTTP(w, r) } r.GET("/favicon.ico", rootFile) r.GET("/robots.txt", rootFile) if conf.ProxyAPIRequests { remoteURL, err := url.Parse(strings.TrimSuffix(conf.APIURLInternal, "/api")) if err != nil { panic(fmt.Errorf("failed to parse reverse proxy URL '%s': %w", conf.APIURLInternal, err)) } log.Info("Starting API proxy to %s", remoteURL) var prox = httputil.NewSingleHostReverseProxy(remoteURL) var proxyHandler = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { log.Info("Proxying request to %s", r.URL) r.Host = remoteURL.Host r.Header.Set("Origin", remoteURL.String()) prox.ServeHTTP(w, r) } for _, method := range []string{"OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"} { r.Handle(method, "/api/*p", proxyHandler) } } // Every other path is a page of the app, which shows its own not found // message for paths it doesn't know var page = middleware(func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { w.Header().Set("X-Frame-Options", "DENY") wc.renderPage(w, r, wc.newTemplateData(r)) }) r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page(w, r, nil) }) // Filesystem pages embed the requested node, for link previews var directory = middleware(wc.serveDirectory) r.GET("/d/*path", directory) r.HEAD("/d/*path", directory) return wc } func middleware(handle httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { // Redirect the user to the correct domain if hostname, found := strings.CutPrefix(r.Host, "www."); found { http.Redirect( w, r, "https://"+hostname+r.URL.String(), http.StatusMovedPermanently, ) return } w.Header().Set("Strict-Transport-Security", "max-age=31536000") w.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett") handle(w, r, p) } } func (wc *WebController) getAPIKey(r *http.Request) (key string, err error) { if cookie, err := r.Cookie("nova_auth_key"); err == nil { if len(cookie.Value) == 36 { return cookie.Value, nil } } return "", errors.New("not a valid Nova authentication cookie") }