apps/sipp/server/server.go 11.8 K raw
1
// Package server hosts the sipp web server, API, and admin pages.
2
package server
3
4
import (
5
	"bytes"
6
	"database/sql"
7
	"embed"
8
	"encoding/json"
9
	"html/template"
10
	"io"
11
	"log"
12
	"log/slog"
13
	"net/http"
14
	"net/url"
15
	"os"
16
	"strconv"
17
	"strings"
18
19
	"github.com/alecthomas/chroma/v2"
20
	"github.com/alecthomas/chroma/v2/formatters/html"
21
	"github.com/alecthomas/chroma/v2/lexers"
22
	"github.com/alecthomas/chroma/v2/styles"
23
	"github.com/stevedylandev/andromeda/apps/sipp/internal/store"
24
	"github.com/stevedylandev/andromeda/pkg/auth"
25
	"github.com/stevedylandev/andromeda/pkg/config"
26
	"github.com/stevedylandev/andromeda/pkg/darkmatter"
27
	"github.com/stevedylandev/andromeda/pkg/web"
28
)
29
30
//go:embed templates/*.html static/*
31
var appFS embed.FS
32
33
type Snippet = store.Snippet
34
35
type App struct {
36
	DB             *sql.DB
37
	Log            *slog.Logger
38
	Templates      *template.Template
39
	Sessions       *auth.Store
40
	APIKey         string
41
	BaseURL        string
42
	CookieSecure   bool
43
	AuthEndpoints  map[string]bool
44
	MaxContentSize int
45
}
46
47
var (
48
	createSnippet          = store.Create
49
	getSnippetByShortID    = store.GetByShortID
50
	getAllSnippets         = store.List
51
	deleteSnippetByShortID = store.DeleteByShortID
52
	updateSnippetByShortID = store.UpdateByShortID
53
)
54
55
func highlight(name, content string) string {
56
	ext := ""
57
	if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 {
58
		ext = strings.ToLower(name[i+1:])
59
	}
60
	switch ext {
61
	case "ts", "tsx", "jsx":
62
		ext = "js"
63
	}
64
	var lexer chroma.Lexer
65
	if ext != "" {
66
		lexer = lexers.MatchMimeType("text/" + ext)
67
		if lexer == nil {
68
			lexer = lexers.Get(ext)
69
		}
70
	}
71
	if lexer == nil {
72
		lexer = lexers.Analyse(content)
73
	}
74
	if lexer == nil {
75
		lexer = lexers.Fallback
76
	}
77
	style := styles.Get("darkmatter")
78
	if style == nil {
79
		style = styles.Fallback
80
	}
81
	formatter := html.New(html.Standalone(false), html.WithClasses(false))
82
	iterator, err := lexer.Tokenise(nil, content)
83
	if err != nil {
84
		escaped := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(content)
85
		return "<pre>" + escaped + "</pre>"
86
	}
87
	var buf bytes.Buffer
88
	if err := formatter.Format(&buf, style, iterator); err != nil {
89
		escaped := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(content)
90
		return "<pre>" + escaped + "</pre>"
91
	}
92
	return buf.String()
93
}
94
95
type indexPageData struct{ BaseURL string }
96
type adminPageData struct {
97
	BaseURL  string
98
	Snippets []Snippet
99
}
100
type loginPageData struct {
101
	Error string
102
	Next  string
103
}
104
type snippetPageData struct {
105
	BaseURL            string
106
	Name               string
107
	Content            string
108
	HighlightedContent template.HTML
109
}
110
111
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
112
	web.Render(a.Templates, w, "index.html", indexPageData{BaseURL: a.BaseURL}, a.Log)
113
}
114
115
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
116
	snippets, err := getAllSnippets(a.DB)
117
	if err != nil {
118
		http.Error(w, "Server error", http.StatusInternalServerError)
119
		return
120
	}
121
	web.Render(a.Templates, w, "admin.html", adminPageData{BaseURL: a.BaseURL, Snippets: snippets}, a.Log)
122
}
123
124
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
125
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error"), Next: r.URL.Query().Get("next")}, a.Log)
126
}
127
128
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
129
	next := r.URL.Query().Get("next")
130
	if next == "" {
131
		next = "/admin"
132
	}
133
	if err := r.ParseForm(); err != nil {
134
		http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther)
135
		return
136
	}
137
	if a.APIKey == "" {
138
		http.Redirect(w, r, "/admin/login?error=No+API+key+configured", http.StatusSeeOther)
139
		return
140
	}
141
	if !auth.SecureEqual(r.FormValue("api_key"), a.APIKey) {
142
		http.Redirect(w, r, "/admin/login?error=Invalid+API+key&next="+url.QueryEscape(next), http.StatusSeeOther)
143
		return
144
	}
145
	token, err := a.Sessions.Create()
146
	if err != nil {
147
		http.Redirect(w, r, "/admin/login?error=Server+error", http.StatusSeeOther)
148
		return
149
	}
150
	a.Sessions.PruneExpired()
151
	http.SetCookie(w, a.Sessions.SessionCookie(token))
152
	target := "/admin"
153
	if strings.HasPrefix(next, "/") {
154
		target = next
155
	}
156
	http.Redirect(w, r, target, http.StatusSeeOther)
157
}
158
159
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
160
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
161
		a.Sessions.Delete(c.Value)
162
	}
163
	http.SetCookie(w, a.Sessions.ClearCookie())
164
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
165
}
166
167
func (a *App) adminDeleteSnippet(w http.ResponseWriter, r *http.Request) {
168
	_, _ = deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
169
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
170
}
171
172
func isCLIUserAgent(r *http.Request) bool {
173
	ua := strings.ToLower(r.Header.Get("User-Agent"))
174
	return strings.HasPrefix(ua, "curl/") || strings.HasPrefix(ua, "wget/") || strings.HasPrefix(ua, "httpie/")
175
}
176
177
func (a *App) viewSnippet(w http.ResponseWriter, r *http.Request) {
178
	snippet, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
179
	if err != nil {
180
		http.Error(w, "Internal server error", http.StatusInternalServerError)
181
		return
182
	}
183
	if snippet == nil {
184
		http.Error(w, "Snippet not found", http.StatusNotFound)
185
		return
186
	}
187
	if isCLIUserAgent(r) {
188
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
189
		_, _ = w.Write([]byte(snippet.Content))
190
		return
191
	}
192
	highlighted := highlight(snippet.Name, snippet.Content)
193
	web.Render(a.Templates, w, "snippet.html", snippetPageData{
194
		BaseURL:            a.BaseURL,
195
		Name:               snippet.Name,
196
		Content:            snippet.Content,
197
		HighlightedContent: template.HTML(highlighted),
198
	}, a.Log)
199
}
200
201
func (a *App) createSnippetForm(w http.ResponseWriter, r *http.Request) {
202
	if err := r.ParseForm(); err != nil {
203
		http.Error(w, "Bad request", http.StatusBadRequest)
204
		return
205
	}
206
	content := r.FormValue("content")
207
	if len(content) > a.MaxContentSize {
208
		http.Error(w, "Content too large", http.StatusRequestEntityTooLarge)
209
		return
210
	}
211
	sn, err := createSnippet(a.DB, r.FormValue("name"), content)
212
	if err != nil {
213
		http.Error(w, "Server error", http.StatusInternalServerError)
214
		return
215
	}
216
	http.Redirect(w, r, "/s/"+sn.ShortID, http.StatusSeeOther)
217
}
218
219
func (a *App) requireAPIKey(next http.HandlerFunc) http.HandlerFunc {
220
	return func(w http.ResponseWriter, r *http.Request) {
221
		if a.APIKey == "" {
222
			web.WriteError(w, http.StatusForbidden, "No API key configured on server")
223
			return
224
		}
225
		if auth.SecureEqual(r.Header.Get("x-api-key"), a.APIKey) {
226
			next(w, r)
227
			return
228
		}
229
		if a.Sessions.HasValid(r) {
230
			next(w, r)
231
			return
232
		}
233
		web.WriteError(w, http.StatusUnauthorized, "Invalid or missing API key")
234
	}
235
}
236
237
func (a *App) apiList(w http.ResponseWriter, r *http.Request) {
238
	snippets, err := getAllSnippets(a.DB)
239
	if err != nil {
240
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
241
		return
242
	}
243
	web.WriteJSON(w, http.StatusOK, snippets)
244
}
245
246
func (a *App) apiGet(w http.ResponseWriter, r *http.Request) {
247
	s, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
248
	if err != nil {
249
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
250
		return
251
	}
252
	if s == nil {
253
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
254
		return
255
	}
256
	web.WriteJSON(w, http.StatusOK, s)
257
}
258
259
type apiCreateBody struct {
260
	Name    string `json:"name"`
261
	Content string `json:"content"`
262
}
263
264
func (a *App) apiCreate(w http.ResponseWriter, r *http.Request) {
265
	var body apiCreateBody
266
	if !web.DecodeJSON(w, r, &body) {
267
		return
268
	}
269
	if len(body.Content) > a.MaxContentSize {
270
		web.WriteError(w, http.StatusRequestEntityTooLarge, "Content too large. Maximum size is "+strconv.Itoa(a.MaxContentSize)+" bytes")
271
		return
272
	}
273
	s, err := createSnippet(a.DB, body.Name, body.Content)
274
	if err != nil {
275
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
276
		return
277
	}
278
	web.WriteJSON(w, http.StatusCreated, s)
279
}
280
281
func (a *App) apiUpdate(w http.ResponseWriter, r *http.Request) {
282
	var body apiCreateBody
283
	if !web.DecodeJSON(w, r, &body) {
284
		return
285
	}
286
	if len(body.Content) > a.MaxContentSize {
287
		web.WriteError(w, http.StatusRequestEntityTooLarge, "Content too large")
288
		return
289
	}
290
	s, err := updateSnippetByShortID(a.DB, r.PathValue("short_id"), body.Name, body.Content)
291
	if err != nil {
292
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
293
		return
294
	}
295
	if s == nil {
296
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
297
		return
298
	}
299
	web.WriteJSON(w, http.StatusOK, s)
300
}
301
302
func (a *App) apiDelete(w http.ResponseWriter, r *http.Request) {
303
	ok, err := deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
304
	if err != nil {
305
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
306
		return
307
	}
308
	if !ok {
309
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
310
		return
311
	}
312
	web.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true})
313
}
314
315
func (a *App) requiresAuth(name string) bool {
316
	return a.AuthEndpoints["all"] || a.AuthEndpoints[name]
317
}
318
319
func (a *App) wrapIfAuth(name string, h http.HandlerFunc) http.HandlerFunc {
320
	if a.requiresAuth(name) {
321
		return a.requireAPIKey(h)
322
	}
323
	return h
324
}
325
326
func parseAuthEndpoints(raw string) map[string]bool {
327
	out := map[string]bool{}
328
	if strings.EqualFold(strings.TrimSpace(raw), "none") {
329
		return out
330
	}
331
	if raw == "" {
332
		out["api_delete"] = true
333
		out["api_list"] = true
334
		out["api_update"] = true
335
		return out
336
	}
337
	for _, p := range strings.Split(raw, ",") {
338
		if v := strings.ToLower(strings.TrimSpace(p)); v != "" {
339
			out[v] = true
340
		}
341
	}
342
	return out
343
}
344
345
// Run starts the sipp web server.
346
func Run(host string, port int) error {
347
	config.LoadDotEnv(".env")
348
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
349
350
	dbPath := config.Getenv("SIPP_DB_PATH", "sipp.sqlite")
351
	db, err := store.Open(dbPath)
352
	if err != nil {
353
		return err
354
	}
355
356
	apiKey := os.Getenv("SIPP_API_KEY")
357
	authEndpoints := parseAuthEndpoints(os.Getenv("SIPP_AUTH_ENDPOINTS"))
358
	maxSize := config.GetenvInt("SIPP_MAX_CONTENT_SIZE", 512000)
359
	baseURL := strings.TrimRight(config.Getenv("BASE_URL", "http://localhost:3000"), "/")
360
	cookieSecure := config.GetenvBool("SIPP_COOKIE_SECURE", false)
361
362
	if len(authEndpoints) > 0 && apiKey == "" {
363
		logger.Warn("SIPP_AUTH_ENDPOINTS set but SIPP_API_KEY not configured")
364
	}
365
366
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: cookieSecure}
367
	if err := sessions.EnsureSchema(); err != nil {
368
		return err
369
	}
370
	sessions.PruneExpired()
371
372
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
373
374
	app := &App{
375
		DB: db, Log: logger, Templates: tmpl, Sessions: sessions,
376
		APIKey: apiKey, BaseURL: baseURL, CookieSecure: cookieSecure,
377
		AuthEndpoints: authEndpoints, MaxContentSize: maxSize,
378
	}
379
380
	mux := http.NewServeMux()
381
	mux.HandleFunc("GET /", app.indexHandler)
382
	mux.HandleFunc("GET /admin", app.Sessions.RequireSession("/admin/login", app.adminHandler))
383
	mux.HandleFunc("GET /admin/login", app.loginGet)
384
	mux.HandleFunc("POST /admin/login", app.loginPost)
385
	mux.HandleFunc("POST /admin/logout", app.logout)
386
	mux.HandleFunc("POST /admin/snippets/{short_id}/delete", app.Sessions.RequireSession("/admin/login", app.adminDeleteSnippet))
387
	mux.HandleFunc("GET /s/{short_id}", app.viewSnippet)
388
	mux.HandleFunc("POST /snippets", app.createSnippetForm)
389
390
	mux.HandleFunc("GET /api/snippets", app.wrapIfAuth("api_list", app.apiList))
391
	mux.HandleFunc("POST /api/snippets", app.wrapIfAuth("api_create", app.apiCreate))
392
	mux.HandleFunc("GET /api/snippets/{short_id}", app.wrapIfAuth("api_get", app.apiGet))
393
	mux.HandleFunc("PUT /api/snippets/{short_id}", app.wrapIfAuth("api_update", app.apiUpdate))
394
	mux.HandleFunc("DELETE /api/snippets/{short_id}", app.wrapIfAuth("api_delete", app.apiDelete))
395
396
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
397
	darkmatter.Mount(mux, "/assets")
398
399
	addr := host + ":" + strconv.Itoa(port)
400
	logger.Info("sipp server running", "addr", addr)
401
	return http.ListenAndServe(addr, mux)
402
}
403
404
// Silence unused import warnings; keep these for forward use.
405
var _ = json.Marshal
406
var _ = bytes.NewReader
407
var _ io.Reader = (*bytes.Reader)(nil)
408
var _ = log.Fatal