// SPDX-License-Identifier: AGPL-3.0-only package devserver import ( "bytes" "crypto/sha256" "encoding/base64" "fmt" "io" "mime" "net" "net/http" "net/http/httputil" "net/url" "strconv" "strings" "sync/atomic" ) const ( maxInjectableHTML = 16 << 20 reloadClient = `(function(){var id="__himesan_overlay";function show(e){var d=document.getElementById(id);if(!d){d=document.createElement("dialog");d.id=id;var b=document.createElement("button");b.textContent="Close";b.addEventListener("click",function(){d.close()});var p=document.createElement("pre");d.appendChild(b);d.appendChild(p);document.body.appendChild(d)}var p=d.querySelector("pre"),xs=e.diagnostics||[];p.textContent=(e.phase?e.phase+": ":"")+(e.message||"Hime-san could not reload")+(xs.length?"\n\n"+xs.map(function(x){return (x.path||"")+(x.line?":"+x.line+(x.column?":"+x.column:""):"")+(x.code?" ["+x.code+"]":"")+" "+x.message}).join("\n"):"");if(!d.open)d.showModal()}var s=new EventSource("/__himesan/events");s.addEventListener("reload",function(){location.reload()});s.addEventListener("diagnostic",function(e){try{show(JSON.parse(e.data))}catch(_){show({message:e.data})}})})();` ) var ( reloadClientTag = []byte("") reloadClientHash = makeReloadClientHash() ) func makeReloadClientHash() string { digest := sha256.Sum256([]byte(reloadClient)) return "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'" } type developmentProxy struct { target atomic.Pointer[url.URL] authority atomic.Pointer[localProxyAuthority] hub *eventHub proxy *httputil.ReverseProxy } type localProxyAuthority struct { port int } func newDevelopmentProxy(hub *eventHub) *developmentProxy { d := &developmentProxy{hub: hub} transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = nil d.proxy = &httputil.ReverseProxy{ Transport: transport, Rewrite: func(request *httputil.ProxyRequest) { target := d.target.Load() if target == nil { return } request.SetURL(target) request.SetXForwarded() request.Out.Header.Set("Accept-Encoding", "identity") request.Out.Header.Del("If-Modified-Since") request.Out.Header.Del("If-None-Match") request.Out.Header.Set("Cache-Control", "no-cache") }, ModifyResponse: injectDevelopmentClient, ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { http.Error(w, "Hime-san development upstream is unavailable: "+err.Error(), http.StatusBadGateway) }, } return d } func (d *developmentProxy) closeIdleConnections() { if transport, ok := d.proxy.Transport.(interface{ CloseIdleConnections() }); ok { transport.CloseIdleConnections() } } func (d *developmentProxy) setAuthority(address string) error { if err := ValidateLoopbackAddress(address); err != nil { return fmt.Errorf("development proxy authority %q: %w", address, err) } _, rawPort, err := net.SplitHostPort(address) if err != nil { return fmt.Errorf("split development proxy authority: %w", err) } port, err := strconv.Atoi(rawPort) if err != nil { return fmt.Errorf("parse development proxy port: %w", err) } d.authority.Store(&localProxyAuthority{port: port}) return nil } func (d *developmentProxy) setTarget(address string) error { if err := ValidateLoopbackAddress(address); err != nil { return fmt.Errorf("development upstream %q: %w", address, err) } target, err := url.Parse("http://" + address) if err != nil { return fmt.Errorf("parse development upstream: %w", err) } d.target.Store(target) return nil } func (d *developmentProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if status, message := d.validateRequest(r); status != 0 { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") http.Error(w, message, status) return } if r.URL.Path == eventsPath { d.hub.serveHTTP(w, r) return } if d.target.Load() == nil { d.serveWaitingPage(w) return } d.proxy.ServeHTTP(w, r) } func (d *developmentProxy) validateRequest(r *http.Request) (int, string) { authority := d.authority.Load() if authority == nil { return http.StatusServiceUnavailable, "Hime-san development proxy is not ready" } requestAuthority, ok := canonicalLoopbackAuthority(r.Host, authority.port) if !ok { return http.StatusMisdirectedRequest, "Hime-san development proxy requires a loopback Host authority" } if values := r.Header.Values("Sec-Fetch-Site"); len(values) > 1 { return http.StatusForbidden, "cross-origin development proxy request denied" } else if len(values) == 1 { switch strings.ToLower(strings.TrimSpace(values[0])) { case "", "none", "same-origin": default: return http.StatusForbidden, "cross-origin development proxy request denied" } } origins := r.Header.Values("Origin") if len(origins) > 1 { return http.StatusForbidden, "cross-origin development proxy request denied" } if len(origins) == 1 { originAuthority, ok := canonicalHTTPOrigin(origins[0], authority.port) if !ok || originAuthority != requestAuthority { return http.StatusForbidden, "cross-origin development proxy request denied" } } return 0, "" } func canonicalHTTPOrigin(raw string, port int) (string, bool) { if raw == "" || strings.TrimSpace(raw) != raw { return "", false } origin, err := url.Parse(raw) if err != nil || !strings.EqualFold(origin.Scheme, "http") || origin.Host == "" || origin.User != nil || origin.Opaque != "" || origin.Path != "" || origin.RawPath != "" || origin.RawQuery != "" || origin.Fragment != "" || origin.ForceQuery { return "", false } return canonicalLoopbackAuthority(origin.Host, port) } func canonicalLoopbackAuthority(authority string, requiredPort int) (string, bool) { if authority == "" || strings.TrimSpace(authority) != authority { return "", false } host, rawPort, err := net.SplitHostPort(authority) if err != nil { if requiredPort != 80 { return "", false } host = authority rawPort = "80" if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = host[1 : len(host)-1] } } requestPort, err := strconv.Atoi(rawPort) if err != nil || requestPort != requiredPort { return "", false } normalizedHost := strings.ToLower(host) if normalizedHost == "localhost" || normalizedHost == "localhost." { return "localhost:" + strconv.Itoa(requiredPort), true } ip := net.ParseIP(host) if ip == nil || !ip.IsLoopback() { return "", false } return ip.String() + ":" + strconv.Itoa(requiredPort), true } func (d *developmentProxy) serveWaitingPage(w http.ResponseWriter) { body := append([]byte("

Hime-san is waiting for a healthy application build.

"), reloadClientTag...) body = append(body, []byte("")...) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src "+reloadClientHash+"; connect-src 'self'") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Length", strconv.Itoa(len(body))) w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write(body) } func injectDevelopmentClient(response *http.Response) error { disableDevelopmentCaching(response) if !eligibleHTMLResponse(response) { return nil } prefix, err := io.ReadAll(io.LimitReader(response.Body, maxInjectableHTML+1)) if err != nil { return fmt.Errorf("read HTML for development reload injection: %w", err) } if len(prefix) > maxInjectableHTML { response.Body = &prefixedReadCloser{ Reader: io.MultiReader(bytes.NewReader(prefix), response.Body), Closer: response.Body, } return nil } if err := response.Body.Close(); err != nil { return fmt.Errorf("close upstream HTML response: %w", err) } if !isFullHTMLDocument(prefix) { response.Body = io.NopCloser(bytes.NewReader(prefix)) response.ContentLength = int64(len(prefix)) response.Header.Set("Content-Length", strconv.Itoa(len(prefix))) return nil } contents := insertReloadClient(prefix) response.Body = io.NopCloser(bytes.NewReader(contents)) response.ContentLength = int64(len(contents)) response.Header.Set("Content-Length", strconv.Itoa(len(contents))) response.Header.Del("ETag") response.Header.Del("Last-Modified") adjustCSP(response.Header, "Content-Security-Policy") adjustCSP(response.Header, "Content-Security-Policy-Report-Only") return nil } func disableDevelopmentCaching(response *http.Response) { response.Header.Set("Cache-Control", "no-store") response.Header.Set("Pragma", "no-cache") response.Header.Set("Expires", "0") response.Header.Del("ETag") response.Header.Del("Last-Modified") } func eligibleHTMLResponse(response *http.Response) bool { if response.StatusCode != http.StatusOK || response.Request == nil || response.Body == nil || response.Request.Method == http.MethodHead { return false } mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil || !strings.EqualFold(mediaType, "text/html") { return false } if encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")); encoding != "" && !strings.EqualFold(encoding, "identity") { return false } rawDisposition := strings.TrimSpace(response.Header.Get("Content-Disposition")) disposition, _, dispositionErr := mime.ParseMediaType(rawDisposition) if strings.EqualFold(disposition, "attachment") || (dispositionErr != nil && strings.HasPrefix(strings.ToLower(rawDisposition), "attachment")) { return false } request := response.Request for _, header := range []string{"HX-Request", "Turbo-Frame", "X-PJAX", "X-Requested-With"} { if strings.TrimSpace(request.Header.Get(header)) != "" { return false } } if strings.EqualFold(response.Header.Get("X-Himesan-Fragment"), "true") { return false } destination := strings.TrimSpace(request.Header.Get("Sec-Fetch-Dest")) return destination == "" || strings.EqualFold(destination, "document") } func isFullHTMLDocument(contents []byte) bool { remaining := bytes.TrimSpace(contents) remaining = bytes.TrimSpace(bytes.TrimPrefix(remaining, []byte{0xef, 0xbb, 0xbf})) for bytes.HasPrefix(remaining, []byte("")) if end < 0 { return false } remaining = bytes.TrimSpace(remaining[4+end+3:]) } lower := bytes.ToLower(remaining) return hasHTMLTokenPrefix(lower, "' || next == '/' || next == ' ' || next == '\t' || next == '\r' || next == '\n' || next == '\f' } func insertReloadClient(contents []byte) []byte { lower := bytes.ToLower(contents) position := bytes.LastIndex(lower, []byte("")) if position < 0 { position = bytes.LastIndex(lower, []byte("")) } if position < 0 { position = len(contents) } result := make([]byte, 0, len(contents)+len(reloadClientTag)) result = append(result, contents[:position]...) result = append(result, reloadClientTag...) result = append(result, contents[position:]...) return result } func adjustCSP(header http.Header, name string) { policies := header.Values(name) if len(policies) == 0 { return } header.Del(name) for _, policy := range policies { policy = addCSPSource(policy, "script-src", reloadClientHash, "default-src") // CSP3 gives script-src-elem precedence over script-src for an inline //