package web
import (
"net/http"
"regexp"
"strconv"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// RequireHTTPS keeps a forge whose public origin is https from being
// used over plain http through its proxy.
//
// The tunnel in front of the forge accepts http:// as readily as
// https://, and says which one the reader used in X-Forwarded-Proto.
// Before this, a typed http:// URL signed people in and cloned private
// repositories in the clear between the reader and the proxy.
//
// A page load is redirected, because nothing secret has been sent yet.
// Anything else (a form post, the API, a sync round) is refused rather
// than redirected: its credential and body have already crossed the
// network by the time the forge sees it, and a client that followed a
// redirect would send them a second time.
//
// Only a request the trusted proxy vouches for and marks as http is
// touched, by the same rule the rest of the web believes forwarded
// headers by; a scheme header from anyone else is ignored. A request
// with no forwarded scheme came straight to the listener — the runner
// and the probes on the forge's own box, over loopback — and is served
// as before. A forge with no https origin is left alone entirely.
func RequireHTTPS(publicOrigin string, proxy TrustedProxy, next http.Handler) http.Handler {
if !strings.HasPrefix(publicOrigin, "https://") {
return next
}
base := strings.TrimSuffix(publicOrigin, "/")
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch proxy.forwardedProto(req) {
case "http":
if req.Method == http.MethodGet || req.Method == http.MethodHead {
http.Redirect(w, req, base+req.URL.RequestURI(), http.StatusPermanentRedirect)
return
}
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "This forge only accepts "+req.Method+" over https. Use "+
base+req.URL.Path+" instead; if you cloned with an http:// URL, "+
"change the remote to https:// and change your password or sync token, "+
"which has been sent unencrypted.", http.StatusForbidden)
return
case "https":
// Browsers that have seen this once will not try http again.
// No includeSubDomains: other names under the domain are not
// this forge's to promise for.
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, req)
})
}
// cacheFor is the Cache-Control for a response whose bytes are the same
// for everyone who may read them, like an image by hash.
//
// It says public only when the reader is anonymous. A request with no
// identity that got this far proves anyone may read these bytes, so a
// shared cache holding them gives nothing away. A signed-in reader may be
// reading a private repository, and the edge cache in front of the forge
// would otherwise keep that reader's bytes and hand them to the next
// stranger to ask for the same URL. private keeps the reader's own
// browser cache and nobody else's.
func cacheFor(req *http.Request, maxAge int, immutable bool) string {
scope := "public"
// Any credential counts, not only a resolved one: a feed reader or
// a client may authenticate in the handler rather than the
// middleware, and a credential nobody resolved is still a reader
// who may be reading something private.
if ops.User(req.Context()) != "" || req.Header.Get("Authorization") != "" || cookieToken(req) != "" {
scope = "private"
}
v := scope + ", max-age=" + strconv.Itoa(maxAge)
if immutable {
v += ", immutable"
}
return v
}
// PrivateByDefault gives a response that says nothing about caching
// "private, no-cache": keep it in the reader's own browser, ask before
// reusing it, and never in a shared cache.
//
// The edge in front of the forge decides by the URL's extension when
// the origin is silent or says only no-cache. A file page for
// "logo.gif" is HTML about one reader's view of one repository, but the
// edge stored it and told the browser to keep it for four hours (#223).
// private is the word it honours. A handler that sets its own policy —
// an immutable asset, cacheFor's public image, a no-store stream —
// keeps it.
//
// The one exception is edgeCacheable: a page that names a check-in by
// its full hash, asked for with no cookie and no credential, is the same
// bytes for every stranger and cannot change, so the edge may keep it.
func PrivateByDefault(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(&privateDefaultWriter{ResponseWriter: w, shared: edgePolicy(req)}, req)
})
}
// edgeShared is what an edgeCacheable page says: the edge keeps it an
// hour, a browser asks again each time. The hour bounds what a cached
// copy can get wrong: the nav's counts, and a repository made private
// since, which the edge would otherwise go on serving.
const edgeShared = "public, max-age=0, s-maxage=3600"
// immutablePage matches the pages addressed by a check-in's full hash
// that show nothing but that check-in's content: a file and a tree at
// it, and its diff. The check-in page itself is not one: it carries CI
// status and who landed it, which change after the hash exists, so it
// gets edgeFresh like any other page. The owner segment is optional
// because both spellings of a repository's path are served.
var immutablePage = regexp.MustCompile(`^/r/(?:[^/]+/)?[^/]+/(?:(?:file|tree)/[0-9a-f]{40}(?:[0-9a-f]{24})?(?:/.*)?|checkin/[0-9a-f]{40}(?:[0-9a-f]{24})?/diff)$`)
// edgeCacheable says whether a response to req may be kept by the edge
// for the next stranger: an immutable page, asked for by somebody the
// forge cannot tell apart from anybody else. No cookie at all, not only
// no session: a theme cookie changes the page too. Crawlers send none,
// and they are who asks for the same page hundreds of times a minute.
func edgeCacheable(req *http.Request) bool {
return edgePolicy(req) != ""
}
// edgeFresh is what an anonymous page that can change says: the edge
// may answer the next stranger from it for 15 seconds. A signed-out
// reader in Texas otherwise paid ~0.45 s of edge-to-origin round trip
// on every page the server rendered in 50-150 ms. Fifteen seconds bounds
// how stale a stranger's view of a public page can be, and how long a
// repository made private stays visible to them.
const edgeFresh = "public, max-age=0, s-maxage=15"
// edgeCostly is edgeFresh for a page that reads a repository's whole
// window of history to draw: the edge keeps it two minutes. A crawler
// swarm sends each request from a different country, so a short life
// left every edge cold; with the tiered cache in front, two minutes
// makes a burst one render at the origin (2026-11-10: three swarm
// requests for one activity page took the forge down). Two minutes is
// how stale a stranger's activity chart can be.
const edgeCostly = "public, max-age=0, s-maxage=120"
// costlyPage matches the pages edgeCostly is for, either spelling of
// the repository's path.
var costlyPage = regexp.MustCompile(`^/r/(?:[^/]+/)?[^/]+/activity$`)
// edgePolicy is the shared-cache policy for req, or "" for private:
// edgeShared for a page at a full check-in hash, edgeFresh for any
// other public page, both only for somebody the forge cannot tell apart
// from anybody else (no cookie, no credential, no user). Live streams
// stay private.
func edgePolicy(req *http.Request) string {
// Identity is resolved inside the handler this wraps, so the request
// headers are what decide: every way the forge knows a reader is a
// cookie (sessions) or an Authorization header (tokens, Basic).
// ops.User is checked for a caller that does resolve it first.
if (req.Method != http.MethodGet && req.Method != http.MethodHead) ||
req.Header.Get("Cookie") != "" || req.Header.Get("Authorization") != "" || ops.User(req.Context()) != "" {
return ""
}
path := req.URL.Path
switch {
case immutablePage.MatchString(path):
return edgeShared
case strings.HasSuffix(path, ".sse"), strings.HasSuffix(path, ".frag"),
strings.Contains(path, ".sse/"), strings.Contains(path, ".frag/"), strings.Contains(path, "-frag/"),
strings.HasSuffix(path, "/patch"), strings.Contains(path, "/archive/"):
// Live streams and fragments are one moment of one page;
// patches and archives are downloads, not pages.
return ""
case costlyPage.MatchString(path):
return edgeCostly
case path == "/" || strings.HasPrefix(path, "/r/"):
return edgeFresh
}
return ""
}
// privateDefaultWriter fills in Cache-Control at the moment the header
// goes out. Flush and Unwrap are forwarded for the same reason as
// cspWriter's: streams type-assert for Flusher, and
// http.ResponseController looks through the wrapper.
type privateDefaultWriter struct {
http.ResponseWriter
wrote bool
// shared is edgePolicy's answer for the request, "" for private.
shared string
}
func (p *privateDefaultWriter) WriteHeader(status int) {
if !p.wrote {
p.wrote = true
if cc := p.Header().Get("Cache-Control"); cc == "" || cc == "no-cache" {
// Only a whole page that sets no cookie: a refusal, a
// redirect or a page that hands out state is one reader's.
if p.shared != "" && status == http.StatusOK && p.Header().Get("Set-Cookie") == "" {
p.Header().Set("Cache-Control", p.shared)
} else {
p.Header().Set("Cache-Control", "private, no-cache")
}
}
}
p.ResponseWriter.WriteHeader(status)
}
func (p *privateDefaultWriter) Write(b []byte) (int, error) {
if !p.wrote {
p.WriteHeader(http.StatusOK)
}
return p.ResponseWriter.Write(b)
}
func (p *privateDefaultWriter) Flush() {
if !p.wrote {
p.WriteHeader(http.StatusOK)
}
if f, ok := p.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (p *privateDefaultWriter) Unwrap() http.ResponseWriter { return p.ResponseWriter }