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())
-}