Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------
Expand All @@ -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
Expand All @@ -37,4 +37,4 @@ USER nonroot:nonroot
EXPOSE 8080

# Start the binary
ENTRYPOINT ["/giscus-wrapper"]
ENTRYPOINT ["/giscus-proxy"]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down
45 changes: 45 additions & 0 deletions cmd/giscus-proxy/main.go
Original file line number Diff line number Diff line change
@@ -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())
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module giscus-wrapper
module giscus-proxy

go 1.25.0
64 changes: 64 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -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)
69 changes: 69 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions internal/proxy/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions internal/proxy/helpers.go
Original file line number Diff line number Diff line change
@@ -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 <a>giscus</a>", "")
s = strings.ReplaceAll(s, "- powered by <a>giscus</a>", "")
return []byte(s)
}
Loading
Loading