From c9656c3e1e85df796e111054f3cfc32ff7dca13f Mon Sep 17 00:00:00 2001 From: cdlus <47757796+cdlus@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:53:44 +0100 Subject: [PATCH] Refactor proxy into modular packages --- Dockerfile | 6 +- README.md | 4 +- cmd/giscus-proxy/main.go | 45 ++++ go.mod | 2 +- internal/cache/cache.go | 64 +++++ internal/config/env.go | 69 +++++ internal/proxy/cache.go | 30 +++ internal/proxy/helpers.go | 132 +++++++++ internal/proxy/passthrough.go | 108 ++++++++ internal/proxy/proxy.go | 86 ++++++ internal/proxy/widget.go | 91 +++++++ main.go | 489 ---------------------------------- 12 files changed, 631 insertions(+), 495 deletions(-) create mode 100644 cmd/giscus-proxy/main.go create mode 100644 internal/cache/cache.go create mode 100644 internal/config/env.go create mode 100644 internal/proxy/cache.go create mode 100644 internal/proxy/helpers.go create mode 100644 internal/proxy/passthrough.go create mode 100644 internal/proxy/proxy.go create mode 100644 internal/proxy/widget.go delete mode 100644 main.go diff --git a/Dockerfile b/Dockerfile index 714d20a..08b386e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ COPY . . ARG TARGETOS ARG TARGETARCH ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} -RUN go build -ldflags='-s -w' -o /out/giscus-wrapper ./ +RUN go build -ldflags='-s -w' -o /out/giscus-proxy ./cmd/giscus-proxy # -------- Runtime stage -------- @@ -28,7 +28,7 @@ WORKDIR / # Copy CA certs and binary COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt -COPY --from=builder /out/giscus-wrapper /giscus-wrapper +COPY --from=builder /out/giscus-proxy /giscus-proxy # Run as non-root for security USER nonroot:nonroot @@ -37,4 +37,4 @@ USER nonroot:nonroot EXPOSE 8080 # Start the binary -ENTRYPOINT ["/giscus-wrapper"] +ENTRYPOINT ["/giscus-proxy"] diff --git a/README.md b/README.md index a30d7cc..cecf632 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ Minimal proxy for the public giscus widget so you can embed it from your own ori ## Run locally ```bash -go run . +go run ./cmd/giscus-proxy # or with custom port -PORT=9000 go run . +PORT=9000 go run ./cmd/giscus-proxy ``` --- diff --git a/cmd/giscus-proxy/main.go b/cmd/giscus-proxy/main.go new file mode 100644 index 0000000..4340630 --- /dev/null +++ b/cmd/giscus-proxy/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "log" + "net/http" + "os" + "strings" + "time" + + "giscus-proxy/internal/cache" + "giscus-proxy/internal/config" + "giscus-proxy/internal/proxy" +) + +func main() { + client := &http.Client{Timeout: 25 * time.Second} + p := proxy.New(proxy.Config{ + Client: client, + Cache: cache.NewMemoryCache(512), + }) + + mux := http.NewServeMux() + p.Register(mux) + + addr := strings.TrimSpace(os.Getenv("ADDR")) + if addr == "" { + host := config.GetEnv("HOST", "0.0.0.0") + port := config.GetEnv("PORT", "8080") + port = strings.TrimPrefix(port, ":") + addr = host + ":" + port + } + + log.SetOutput(os.Stdout) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ErrorLog: log.New(os.Stdout, "", 0), + } + + publicURL := config.DerivePublicURL(addr, config.GetEnv("HOST", ""), config.GetEnv("PORT", "")) + log.Printf("giscus proxy listening: bind=%s url=%s", addr, publicURL) + log.Fatal(srv.ListenAndServe()) +} diff --git a/go.mod b/go.mod index 7528afc..1f9cc8c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module giscus-wrapper +module giscus-proxy go 1.25.0 diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..3831488 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,64 @@ +package cache + +import ( + "net/http" + "sync" + "time" +) + +// Entry represents a cached HTTP response. +type Entry struct { + Status int + Headers http.Header + Body []byte + Expires time.Time +} + +// Cache defines the behaviour required for storing HTTP responses. +type Cache interface { + Get(key string) (Entry, bool) + Set(key string, entry Entry) +} + +// MemoryCache is a simple in-memory implementation of Cache. +type MemoryCache struct { + mu sync.RWMutex + data map[string]Entry + maxEntries int +} + +// NewMemoryCache constructs a MemoryCache limited to the provided number of entries. +func NewMemoryCache(maxEntries int) *MemoryCache { + return &MemoryCache{data: make(map[string]Entry), maxEntries: maxEntries} +} + +// Get retrieves a cache entry if present and not expired. +func (c *MemoryCache) Get(key string) (Entry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, ok := c.data[key] + if !ok { + return Entry{}, false + } + if time.Now().After(entry.Expires) { + return Entry{}, false + } + return entry, true +} + +// Set stores a cache entry, evicting an arbitrary entry when capacity is reached. +func (c *MemoryCache) Set(key string, entry Entry) { + c.mu.Lock() + defer c.mu.Unlock() + + if len(c.data) >= c.maxEntries { + for k := range c.data { + delete(c.data, k) + break + } + } + c.data[key] = entry +} + +var _ Cache = (*MemoryCache)(nil) diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..2a49937 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,69 @@ +package config + +import ( + "os" + "strings" +) + +// GetEnv returns the trimmed value of an environment variable or a default when unset. +func GetEnv(key, def string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + return v +} + +// EnsureURL normalises an input into a URL, applying a default scheme when necessary. +func EnsureURL(v, defaultScheme string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") { + return v + } + if defaultScheme == "" { + defaultScheme = "https" + } + return defaultScheme + "://" + v +} + +// DerivePublicURL attempts to build a public URL for the service based on environment hints. +func DerivePublicURL(bindAddr, host, port string) string { + if u := EnsureURL(os.Getenv("PUBLIC_URL"), ""); u != "" { + return u + } + if u := EnsureURL(os.Getenv("RAILWAY_PUBLIC_DOMAIN"), "https"); u != "" { + return u + } + if u := EnsureURL(os.Getenv("RAILWAY_URL"), ""); u != "" { + return u + } + + p := strings.TrimSpace(port) + h := strings.TrimSpace(host) + if p == "" { + b := bindAddr + if strings.HasPrefix(b, ":") { + p = strings.TrimPrefix(b, ":") + } else if i := strings.LastIndex(b, ":"); i != -1 { + p = b[i+1:] + } + } + if h == "" { + b := bindAddr + if strings.HasPrefix(b, ":") || b == "" { + h = "localhost" + } else if i := strings.LastIndex(b, ":"); i != -1 { + h = b[:i] + } + } + if h == "0.0.0.0" || h == "::" || h == "[::]" || h == "" { + h = "localhost" + } + if p == "" { + p = "8080" + } + return "http://" + h + ":" + p +} diff --git a/internal/proxy/cache.go b/internal/proxy/cache.go new file mode 100644 index 0000000..147181f --- /dev/null +++ b/internal/proxy/cache.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +func (p *Proxy) cacheKey(r *http.Request) string { + return r.Method + " " + r.URL.RequestURI() + " ae=" + strings.TrimSpace(r.Header.Get("Accept-Encoding")) +} + +func parseMaxAge(h http.Header) (time.Duration, bool) { + cc := h.Get("Cache-Control") + if cc == "" { + return 0, false + } + parts := strings.Split(cc, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if strings.HasPrefix(strings.ToLower(p), "max-age=") { + v := strings.TrimSpace(p[len("max-age="):]) + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + return time.Duration(secs) * time.Second, true + } + } + } + return 0, false +} diff --git a/internal/proxy/helpers.go b/internal/proxy/helpers.go new file mode 100644 index 0000000..503af9e --- /dev/null +++ b/internal/proxy/helpers.go @@ -0,0 +1,132 @@ +package proxy + +import ( + "compress/gzip" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +type statusWriter struct { + http.ResponseWriter + status int + written int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(p []byte) (int, error) { + n, err := w.ResponseWriter.Write(p) + w.written += n + return n, err +} + +func fmtDur(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%4dms", d.Milliseconds()) + } + sec := float64(d) / float64(time.Second) + return fmt.Sprintf("%6.2fs", sec) +} + +func (p *Proxy) logLine(kind, method, path string, status, bytes int, dur time.Duration, cacheState, target string) { + if cacheState == "" { + cacheState = "-" + } + p.logf("%-6s method=%-4s status=%3d bytes=%8d dur=%9s cache=%-10s path=%s target=%s", + kind, method, status, bytes, fmtDur(dur), cacheState, path, target) +} + +func writeCORS(h http.ResponseWriter) { + h.Header().Set("Access-Control-Allow-Origin", "*") + h.Header().Set("Vary", "Origin") + h.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS") + h.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,Accept") +} + +func copyIf(dst, src http.Header, keys ...string) { + for _, k := range keys { + if v := src.Get(k); v != "" { + dst.Set(k, v) + } + } +} + +func decompressIfNeeded(h http.Header, body io.ReadCloser) (io.ReadCloser, func(), error) { + enc := strings.ToLower(strings.TrimSpace(h.Get("Content-Encoding"))) + switch enc { + case "", "identity": + return body, func() {}, nil + case "gzip": + zr, err := gzip.NewReader(body) + if err != nil { + return nil, func() {}, err + } + return zr, func() { _ = zr.Close(); _ = body.Close() }, nil + default: + return nil, func() {}, fmt.Errorf("unsupported content-encoding: %s", enc) + } +} + +type replacer struct { + useRegex bool + from string + fromRE *regexp.Regexp + to string +} + +func parseReplacers(q url.Values) ([]replacer, error) { + vals := q["rep"] + if len(vals) == 0 { + return nil, nil + } + var out []replacer + for _, raw := range vals { + parts := strings.SplitN(raw, "=>", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("bad rep value %q (use LEFT=>RIGHT)", raw) + } + left, right := parts[0], parts[1] + if strings.HasPrefix(left, "re:") { + pat := left[len("re:"):] + re, err := regexp.Compile(pat) + if err != nil { + return nil, fmt.Errorf("regex compile failed for %q: %w", pat, err) + } + out = append(out, replacer{useRegex: true, fromRE: re, to: right}) + } else { + out = append(out, replacer{from: left, to: right}) + } + } + return out, nil +} + +func applyReplacements(b []byte, reps []replacer) []byte { + if len(reps) == 0 { + return b + } + s := string(b) + for _, r := range reps { + if r.useRegex { + s = r.fromRE.ReplaceAllString(s, r.to) + } else { + s = strings.ReplaceAll(s, r.from, r.to) + } + } + return []byte(s) +} + +func widgetFooterSwap(b []byte) []byte { + s := string(b) + s = strings.ReplaceAll(s, "– powered by \\u003ca\\u003egiscus\\u003c/a\\u003e", "") + s = strings.ReplaceAll(s, "– powered by giscus", "") + s = strings.ReplaceAll(s, "- powered by giscus", "") + return []byte(s) +} diff --git a/internal/proxy/passthrough.go b/internal/proxy/passthrough.go new file mode 100644 index 0000000..10f0eee --- /dev/null +++ b/internal/proxy/passthrough.go @@ -0,0 +1,108 @@ +package proxy + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "giscus-proxy/internal/cache" +) + +func (p *Proxy) handlePassthrough(w http.ResponseWriter, r *http.Request) { + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + var target string + cacheState := "BYPASS" + defer func() { + p.logLine("pass", r.Method, r.URL.RequestURI(), sw.status, sw.written, time.Since(start), cacheState, target) + }() + w = sw + + if r.Method == http.MethodOptions { + writeCORS(w) + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + target = p.upstreamOrigin + r.URL.Path + if raw := r.URL.RawQuery; raw != "" { + target += "?" + raw + } + + if p.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + if ent, ok := p.cache.Get(p.cacheKey(r)); ok { + for _, k := range p.cacheHeaders { + if v := ent.Headers.Get(k); v != "" { + w.Header().Set(k, v) + } + } + w.WriteHeader(ent.Status) + if r.Method == http.MethodGet { + _, _ = w.Write(ent.Body) + } + cacheState = "HIT" + return + } + } + + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + http.Error(w, "failed to build upstream request", http.StatusInternalServerError) + return + } + if ae := r.Header.Get("Accept-Encoding"); ae != "" { + req.Header.Set("Accept-Encoding", ae) + } + req.Header.Set("Accept", "*/*") + req.Header.Set("User-Agent", "giscus-proxy/clean-1.0") + + resp, err := p.client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("upstream error: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + writeCORS(w) + + enc := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) + if p.cache != nil && r.Method == http.MethodGet && (enc == "" || enc == "identity") && resp.StatusCode == http.StatusOK { + bin, err := io.ReadAll(resp.Body) + if err == nil { + copyIf(w.Header(), resp.Header, p.cacheHeaders...) + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(bin) + + if ttl, ok := parseMaxAge(resp.Header); ok { + h := http.Header{} + for _, k := range p.cacheHeaders { + if v := resp.Header.Get(k); v != "" { + h.Set(k, v) + } + } + p.cache.Set(p.cacheKey(r), cache.Entry{Status: resp.StatusCode, Headers: h, Body: bin, Expires: time.Now().Add(ttl)}) + cacheState = "MISS:cached" + return + } + } + copyIf(w.Header(), resp.Header, p.cacheHeaders...) + w.WriteHeader(resp.StatusCode) + if err == nil { + _, _ = w.Write(bin) + } + cacheState = "MISS" + return + } + + copyIf(w.Header(), resp.Header, p.cacheHeaders...) + w.WriteHeader(resp.StatusCode) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, resp.Body) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..60946e7 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,86 @@ +package proxy + +import ( + "log" + "net/http" + "time" + + "giscus-proxy/internal/cache" +) + +// HTTPClient represents the subset of *http.Client used by the proxy. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Config provides all the dependencies required to build a Proxy. +type Config struct { + UpstreamOrigin string + WidgetSourcePath string + WidgetPaths []string + CacheHeaders []string + Client HTTPClient + Cache cache.Cache + Logger *log.Logger +} + +// Proxy coordinates the handlers that proxy traffic to giscus. +type Proxy struct { + upstreamOrigin string + widgetSourcePath string + widgetPaths []string + cacheHeaders []string + client HTTPClient + cache cache.Cache + logger *log.Logger +} + +// New constructs a Proxy from the provided configuration, applying sensible defaults. +func New(cfg Config) *Proxy { + p := &Proxy{ + upstreamOrigin: cfg.UpstreamOrigin, + widgetSourcePath: cfg.WidgetSourcePath, + widgetPaths: append([]string(nil), cfg.WidgetPaths...), + cacheHeaders: append([]string(nil), cfg.CacheHeaders...), + client: cfg.Client, + cache: cfg.Cache, + logger: cfg.Logger, + } + + if p.upstreamOrigin == "" { + p.upstreamOrigin = "https://giscus.app" + } + if p.widgetSourcePath == "" { + p.widgetSourcePath = "/en/widget" + } + if len(p.widgetPaths) == 0 { + p.widgetPaths = []string{"/widget", "/en/widget"} + } + if len(p.cacheHeaders) == 0 { + p.cacheHeaders = []string{"Content-Type", "Content-Encoding", "Cache-Control", "ETag", "Last-Modified", "Vary"} + } + if p.client == nil { + p.client = &http.Client{Timeout: 25 * time.Second} + } + if p.logger == nil { + p.logger = log.Default() + } + + return p +} + +// Register attaches the proxy handlers to the provided mux. +func (p *Proxy) Register(mux *http.ServeMux) { + for _, path := range p.widgetPaths { + mux.HandleFunc(path, p.handleWidget) + } + mux.HandleFunc("/", p.handlePassthrough) +} + +func (p *Proxy) logf(format string, args ...any) { + if p.logger == nil { + log.Printf(format, args...) + return + } + p.logger.Printf(format, args...) +} diff --git a/internal/proxy/widget.go b/internal/proxy/widget.go new file mode 100644 index 0000000..a0cf1bf --- /dev/null +++ b/internal/proxy/widget.go @@ -0,0 +1,91 @@ +package proxy + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +func (p *Proxy) handleWidget(w http.ResponseWriter, r *http.Request) { + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + var target string + defer func() { + p.logLine("widget", r.Method, r.URL.RequestURI(), sw.status, sw.written, time.Since(start), "", target) + }() + w = sw + + if r.Method == http.MethodOptions { + writeCORS(w) + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query() + reps, err := parseReplacers(q) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tq := url.Values{} + for k, vs := range q { + if k == "rep" { + continue + } + for _, v := range vs { + tq.Add(k, v) + } + } + target = p.upstreamOrigin + p.widgetSourcePath + if enc := tq.Encode(); enc != "" { + target += "?" + enc + } + + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + http.Error(w, "failed to build upstream request", http.StatusInternalServerError) + return + } + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("User-Agent", "giscus-proxy/clean-1.0") + + resp, err := p.client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("upstream error: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + writeCORS(w) + copyIf(w.Header(), resp.Header, "Content-Type") + + body, clean, decErr := decompressIfNeeded(resp.Header, resp.Body) + if decErr != nil { + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + return + } + defer clean() + + bin, err := io.ReadAll(body) + if err != nil { + w.WriteHeader(resp.StatusCode) + _, _ = w.Write([]byte(fmt.Sprintf("", err))) + return + } + + bin = applyReplacements(bin, reps) + bin = widgetFooterSwap(bin) + + w.WriteHeader(resp.StatusCode) + if r.Method != http.MethodHead { + _, _ = w.Write(bin) + } +} diff --git a/main.go b/main.go deleted file mode 100644 index b54844e..0000000 --- a/main.go +++ /dev/null @@ -1,489 +0,0 @@ -package main - -import ( - "compress/gzip" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "regexp" - "strconv" - "strings" - "sync" - "time" -) - -const ( - upstreamOrigin = "https://giscus.app" - widgetPath = "/en/widget" -) - -var httpClient = &http.Client{Timeout: 25 * time.Second} - -// ---------- logging helpers ---------- - -type statusWriter struct { - http.ResponseWriter - status int - written int -} - -func (w *statusWriter) WriteHeader(code int) { - w.status = code - w.ResponseWriter.WriteHeader(code) -} - -func (w *statusWriter) Write(p []byte) (int, error) { - n, err := w.ResponseWriter.Write(p) - w.written += n - return n, err -} - -// ---------- simple response cache ---------- - -type cacheEntry struct { - status int - headers http.Header - body []byte - expires time.Time -} - -type memoryCache struct { - mu sync.RWMutex - data map[string]cacheEntry - maxEntries int -} - -func newMemoryCache(maxEntries int) *memoryCache { - return &memoryCache{data: make(map[string]cacheEntry), maxEntries: maxEntries} -} - -func (c *memoryCache) Get(key string) (cacheEntry, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - v, ok := c.data[key] - if !ok { - return cacheEntry{}, false - } - if time.Now().After(v.expires) { - return cacheEntry{}, false - } - return v, true -} - -func (c *memoryCache) Set(key string, val cacheEntry) { - c.mu.Lock() - defer c.mu.Unlock() - if len(c.data) >= c.maxEntries { - for k := range c.data { // naive eviction - delete(c.data, k) - break - } - } - c.data[key] = val -} - -var respCache = newMemoryCache(512) - -var cacheHeaderKeys = []string{"Content-Type", "Content-Encoding", "Cache-Control", "ETag", "Last-Modified", "Vary"} - -func cacheKey(r *http.Request) string { - return r.Method + " " + r.URL.RequestURI() + " ae=" + strings.TrimSpace(r.Header.Get("Accept-Encoding")) -} - -func parseMaxAge(h http.Header) (time.Duration, bool) { - cc := h.Get("Cache-Control") - if cc == "" { - return 0, false - } - parts := strings.Split(cc, ",") - for _, p := range parts { - p = strings.TrimSpace(p) - if strings.HasPrefix(strings.ToLower(p), "max-age=") { - v := strings.TrimSpace(p[len("max-age="):]) - if secs, err := strconv.Atoi(v); err == nil && secs > 0 { - return time.Duration(secs) * time.Second, true - } - } - } - return 0, false -} - -// pretty logging helpers - -func fmtDur(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%4dms", d.Milliseconds()) - } - sec := float64(d) / float64(time.Second) - return fmt.Sprintf("%6.2fs", sec) -} - -func logLine(kind, method, path string, status, bytes int, dur time.Duration, cacheState, target string) { - if cacheState == "" { - cacheState = "-" - } - log.Printf("%-6s method=%-4s status=%3d bytes=%8d dur=%9s cache=%-10s path=%s target=%s", - kind, method, status, bytes, fmtDur(dur), cacheState, path, target) -} - -func writeCORS(h http.ResponseWriter) { - h.Header().Set("Access-Control-Allow-Origin", "*") - h.Header().Set("Vary", "Origin") - h.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS") - h.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,Accept") -} - -func copyIf(dst, src http.Header, keys ...string) { - for _, k := range keys { - if v := src.Get(k); v != "" { - dst.Set(k, v) - } - } -} - -func getEnv(key, def string) string { - v := strings.TrimSpace(os.Getenv(key)) - if v == "" { - return def - } - return v -} - -func ensureURL(v string, defaultScheme string) string { - v = strings.TrimSpace(v) - if v == "" { - return "" - } - if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") { - return v - } - if defaultScheme == "" { - defaultScheme = "https" - } - return defaultScheme + "://" + v -} - -func derivePublicURL(bindAddr, host, port string) string { - if u := ensureURL(os.Getenv("PUBLIC_URL"), ""); u != "" { - return u - } - if u := ensureURL(os.Getenv("RAILWAY_PUBLIC_DOMAIN"), "https"); u != "" { - return u - } - if u := ensureURL(os.Getenv("RAILWAY_URL"), ""); u != "" { - return u - } - - // Fallback to local composition - p := strings.TrimSpace(port) - h := strings.TrimSpace(host) - if p == "" { - b := bindAddr - if strings.HasPrefix(b, ":") { - p = strings.TrimPrefix(b, ":") - } else if i := strings.LastIndex(b, ":"); i != -1 { - p = b[i+1:] - } - } - if h == "" { - b := bindAddr - if strings.HasPrefix(b, ":") || b == "" { - h = "localhost" - } else if i := strings.LastIndex(b, ":"); i != -1 { - h = b[:i] - } - } - if h == "0.0.0.0" || h == "::" || h == "[::]" || h == "" { - h = "localhost" - } - if p == "" { - p = "8080" - } - return "http://" + h + ":" + p -} - -func decompressIfNeeded(h http.Header, body io.ReadCloser) (io.ReadCloser, func(), error) { - enc := strings.ToLower(strings.TrimSpace(h.Get("Content-Encoding"))) - switch enc { - case "", "identity": - return body, func() {}, nil - case "gzip": - zr, err := gzip.NewReader(body) - if err != nil { - return nil, func() {}, err - } - return zr, func() { _ = zr.Close(); _ = body.Close() }, nil - default: - return nil, func() {}, fmt.Errorf("unsupported content-encoding: %s", enc) - } -} - -type replacer struct { - useRegex bool - from string - fromRE *regexp.Regexp - to string -} - -func parseReplacers(q url.Values) ([]replacer, error) { - vals := q["rep"] - if len(vals) == 0 { - return nil, nil - } - var out []replacer - for _, raw := range vals { - parts := strings.SplitN(raw, "=>", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("bad rep value %q (use LEFT=>RIGHT)", raw) - } - left, right := parts[0], parts[1] - if strings.HasPrefix(left, "re:") { - pat := left[len("re:"):] - re, err := regexp.Compile(pat) - if err != nil { - return nil, fmt.Errorf("regex compile failed for %q: %w", pat, err) - } - out = append(out, replacer{useRegex: true, fromRE: re, to: right}) - } else { - out = append(out, replacer{from: left, to: right}) - } - } - return out, nil -} - -func applyReplacements(b []byte, reps []replacer) []byte { - if len(reps) == 0 { - return b - } - s := string(b) - for _, r := range reps { - if r.useRegex { - s = r.fromRE.ReplaceAllString(s, r.to) - } else { - s = strings.ReplaceAll(s, r.from, r.to) - } - } - return []byte(s) -} - -func widgetFooterSwap(b []byte) []byte { - s := string(b) - s = strings.ReplaceAll(s, "– powered by \\u003ca\\u003egiscus\\u003c/a\\u003e", "") - s = strings.ReplaceAll(s, "– powered by giscus", "") - s = strings.ReplaceAll(s, "- powered by giscus", "") - return []byte(s) -} - -func handleWidget(w http.ResponseWriter, r *http.Request) { - sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} - start := time.Now() - var target string - defer func() { - logLine("widget", r.Method, r.URL.RequestURI(), sw.status, sw.written, time.Since(start), "", target) - }() - w = sw - if r.Method == http.MethodOptions { - writeCORS(w) - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - q := r.URL.Query() - reps, err := parseReplacers(q) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - tq := url.Values{} - for k, vs := range q { - if k == "rep" { - continue - } - for _, v := range vs { - tq.Add(k, v) - } - } - target = upstreamOrigin + widgetPath - if enc := tq.Encode(); enc != "" { - target += "?" + enc - } - - req, err := http.NewRequest(http.MethodGet, target, nil) - if err != nil { - http.Error(w, "failed to build upstream request", http.StatusInternalServerError) - return - } - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("User-Agent", "giscus-wrap/clean-1.0") - - resp, err := httpClient.Do(req) - if err != nil { - http.Error(w, fmt.Sprintf("upstream error: %v", err), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - writeCORS(w) - copyIf(w.Header(), resp.Header, "Content-Type") - - body, clean, decErr := decompressIfNeeded(resp.Header, resp.Body) - if decErr != nil { - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) - return - } - defer clean() - - bin, err := io.ReadAll(body) - if err != nil { - w.WriteHeader(resp.StatusCode) - _, _ = w.Write([]byte(fmt.Sprintf("", err))) - return - } - - bin = applyReplacements(bin, reps) - bin = widgetFooterSwap(bin) - - w.WriteHeader(resp.StatusCode) - if r.Method != http.MethodHead { - _, _ = w.Write(bin) - } -} - -// /api/* -> upstream passthrough (NO replacements) -func handlePassthrough(w http.ResponseWriter, r *http.Request) { - sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} - start := time.Now() - var target string - cacheState := "BYPASS" - defer func() { - logLine("pass", r.Method, r.URL.RequestURI(), sw.status, sw.written, time.Since(start), cacheState, target) - }() - w = sw - - if r.Method == http.MethodOptions { - writeCORS(w) - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Build upstream URL, forwarding path and query as-is - target = upstreamOrigin + r.URL.Path - if raw := r.URL.RawQuery; raw != "" { - target += "?" + raw - } - - // Simple in-memory cache for GET/HEAD of uncompressed responses - if r.Method == http.MethodGet || r.Method == http.MethodHead { - if ent, ok := respCache.Get(cacheKey(r)); ok { - for _, k := range cacheHeaderKeys { - if v := ent.headers.Get(k); v != "" { - w.Header().Set(k, v) - } - } - w.WriteHeader(ent.status) - if r.Method == http.MethodGet { - _, _ = w.Write(ent.body) - } - cacheState = "HIT" - return - } - } - - req, err := http.NewRequest(http.MethodGet, target, nil) - if err != nil { - http.Error(w, "failed to build upstream request", http.StatusInternalServerError) - return - } - if ae := r.Header.Get("Accept-Encoding"); ae != "" { - req.Header.Set("Accept-Encoding", ae) - } - req.Header.Set("Accept", "*/*") - req.Header.Set("User-Agent", "giscus-wrap/clean-1.0") - - resp, err := httpClient.Do(req) - if err != nil { - http.Error(w, fmt.Sprintf("upstream error: %v", err), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - writeCORS(w) - - // Attempt cacheable path for GET when body is not compressed - enc := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) - if r.Method == http.MethodGet && (enc == "" || enc == "identity") && resp.StatusCode == http.StatusOK { - bin, err := io.ReadAll(resp.Body) - if err == nil { - copyIf(w.Header(), resp.Header, cacheHeaderKeys...) - w.WriteHeader(resp.StatusCode) - _, _ = w.Write(bin) - - if ttl, ok := parseMaxAge(resp.Header); ok { - h := http.Header{} - for _, k := range cacheHeaderKeys { - if v := resp.Header.Get(k); v != "" { - h.Set(k, v) - } - } - respCache.Set(cacheKey(r), cacheEntry{status: resp.StatusCode, headers: h, body: bin, expires: time.Now().Add(ttl)}) - cacheState = "MISS:cached" - return - } - } - copyIf(w.Header(), resp.Header, cacheHeaderKeys...) - w.WriteHeader(resp.StatusCode) - _, _ = w.Write(bin) - cacheState = "MISS" - return - } - - // Non-cacheable path or HEAD/other methods: stream - copyIf(w.Header(), resp.Header, cacheHeaderKeys...) - w.WriteHeader(resp.StatusCode) - if r.Method != http.MethodHead { - _, _ = io.Copy(w, resp.Body) - } -} - -func main() { - mux := http.NewServeMux() - mux.HandleFunc("/widget", handleWidget) - mux.HandleFunc("/en/widget", handleWidget) - mux.HandleFunc("/", handlePassthrough) - - addr := "" - if v := strings.TrimSpace(os.Getenv("ADDR")); v != "" { - addr = v - } else { - host := getEnv("HOST", "0.0.0.0") - port := getEnv("PORT", "8080") - port = strings.TrimPrefix(port, ":") - addr = host + ":" + port - } - // ensure logs go to stdout so PaaS platforms don't mark them as errors - log.SetOutput(os.Stdout) - - srv := &http.Server{ - Addr: addr, - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - ErrorLog: log.New(os.Stdout, "", 0), - } - - publicURL := derivePublicURL(addr, getEnv("HOST", ""), getEnv("PORT", "")) - log.Printf("giscus wrapper listening: bind=%s url=%s", addr, publicURL) - log.Fatal(srv.ListenAndServe()) -}