app.go 4.1 K raw
1
package main
2
3
import (
4
	"embed"
5
	"encoding/json"
6
	"html/template"
7
	"log/slog"
8
	"mime"
9
	"net/http"
10
	"os"
11
	"path/filepath"
12
	"strings"
13
)
14
15
//go:embed templates/*.html static/*
16
var appFS embed.FS
17
18
type App struct {
19
	Log       *slog.Logger
20
	Templates *template.Template
21
	BaseURL   string
22
	Cache     *feedCache
23
	Images    *imageCache
24
	renderSem chan struct{}
25
}
26
27
type templateItem struct {
28
	Title         string
29
	Link          string
30
	Author        string
31
	FormattedDate string
32
}
33
34
type feedRef struct {
35
	Name string
36
	URL  string
37
}
38
39
type indexPageData struct {
40
	BaseURL         string
41
	Items           []templateItem
42
	FeedURLs        []feedRef
43
	Error           string
44
	MetaTitle       string
45
	MetaDescription string
46
	CanonicalURL    string
47
	OGImage         string
48
}
49
50
func (a *App) routes() *http.ServeMux {
51
	mux := http.NewServeMux()
52
	mux.HandleFunc("GET /", a.indexHandler)
53
	mux.HandleFunc("GET /privacy", a.privacyHandler)
54
	mux.HandleFunc("GET /og.png", a.ogImageHandler)
55
	mux.HandleFunc("GET /api/resolve", a.resolveHandler)
56
	mux.HandleFunc("GET /static/", embeddedHandler(appFS, "static"))
57
	return mux
58
}
59
60
// limitInFlight caps concurrent in-flight requests. Under a traffic spike,
61
// excess requests get an immediate 503 rather than piling up goroutines and
62
// outbound connections that would exhaust file descriptors. Static asset
63
// requests are cheap (served from memory) and bypass the limiter.
64
func limitInFlight(next http.Handler, max int) http.Handler {
65
	sem := make(chan struct{}, max)
66
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67
		if strings.HasPrefix(r.URL.Path, "/static/") {
68
			next.ServeHTTP(w, r)
69
			return
70
		}
71
		select {
72
		case sem <- struct{}{}:
73
			defer func() { <-sem }()
74
			next.ServeHTTP(w, r)
75
		default:
76
			http.Error(w, "server busy", http.StatusServiceUnavailable)
77
		}
78
	})
79
}
80
81
// embeddedHandler serves files from an embed.FS under the given URL prefix.
82
func embeddedHandler(fs embed.FS, prefix string) http.HandlerFunc {
83
	return func(w http.ResponseWriter, r *http.Request) {
84
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
85
		path := filepath.ToSlash(filepath.Join(prefix, name))
86
		data, err := fs.ReadFile(path)
87
		if err != nil {
88
			http.NotFound(w, r)
89
			return
90
		}
91
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
92
			w.Header().Set("Content-Type", ct)
93
		}
94
		_, _ = w.Write(data)
95
	}
96
}
97
98
// render executes a named template into w. Errors are logged and surfaced as HTTP 500.
99
func render(t *template.Template, w http.ResponseWriter, name string, data any, log *slog.Logger) {
100
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
101
	if err := t.ExecuteTemplate(w, name, data); err != nil {
102
		if log != nil {
103
			log.Error("template render failed", "name", name, "err", err)
104
		}
105
		http.Error(w, "template error", http.StatusInternalServerError)
106
	}
107
}
108
109
// writeJSON writes data as JSON with the given status code.
110
func writeJSON(w http.ResponseWriter, status int, data any) {
111
	w.Header().Set("Content-Type", "application/json")
112
	w.WriteHeader(status)
113
	_ = json.NewEncoder(w).Encode(data)
114
}
115
116
// writeError writes a JSON error response of the form {"error": msg}.
117
func writeError(w http.ResponseWriter, status int, msg string) {
118
	writeJSON(w, status, map[string]any{"error": msg})
119
}
120
121
// getenv returns the trimmed value of key or fallback when unset/blank.
122
func getenv(key, fallback string) string {
123
	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
124
		return v
125
	}
126
	return fallback
127
}
128
129
// loadDotEnv reads KEY=VALUE pairs from a .env file in the working directory
130
// and sets them as environment variables, without overriding vars already set.
131
func loadDotEnv(path string) {
132
	data, err := os.ReadFile(path)
133
	if err != nil {
134
		return
135
	}
136
	for line := range strings.SplitSeq(string(data), "\n") {
137
		line = strings.TrimSpace(line)
138
		if line == "" || strings.HasPrefix(line, "#") {
139
			continue
140
		}
141
		key, value, ok := strings.Cut(line, "=")
142
		if !ok {
143
			continue
144
		}
145
		key = strings.TrimSpace(key)
146
		value = strings.TrimSpace(value)
147
		value = strings.Trim(value, `"'`)
148
		if key == "" {
149
			continue
150
		}
151
		if _, exists := os.LookupEnv(key); !exists {
152
			os.Setenv(key, value)
153
		}
154
	}
155
}