+1
server/TASK.md
+1
server/TASK.md
···
1
+
- [ ] Modularize server entrypoint: extract routing, handlers, and templates
+34
server/config/config.go
+34
server/config/config.go
···
1
+
package config
2
+
3
+
import "os"
4
+
5
+
// Config aggregates server configuration derived from environment variables.
6
+
type Config struct {
7
+
Port string
8
+
ClientURI string
9
+
CookieSecret string
10
+
DevOffline bool
11
+
}
12
+
13
+
// FromEnv reads process environment variables and returns a Config populated
14
+
// with defaults that mirror the previous hard-coded values in main.
15
+
func FromEnv() Config {
16
+
port := getEnv("PORT", "80")
17
+
clientURI := getEnv("CLIENT_URI", "http://localhost:"+port)
18
+
cookieSecret := getEnv("COOKIE_SECRET", "your-secret-key")
19
+
devOffline := getEnv("DEV_OFFLINE", "") == "1"
20
+
21
+
return Config{
22
+
Port: port,
23
+
ClientURI: clientURI,
24
+
CookieSecret: cookieSecret,
25
+
DevOffline: devOffline,
26
+
}
27
+
}
28
+
29
+
func getEnv(key, def string) string {
30
+
if v := os.Getenv(key); v != "" {
31
+
return v
32
+
}
33
+
return def
34
+
}
+41
server/devstore/store.go
+41
server/devstore/store.go
···
1
+
package devstore
2
+
3
+
import "sync"
4
+
5
+
// Doc represents a document stored in the in-memory development store.
6
+
type Doc struct {
7
+
ID string
8
+
Name string
9
+
Text string
10
+
UpdatedAt string
11
+
}
12
+
13
+
// Store keeps per-session document maps for DEV_OFFLINE mode.
14
+
type Store struct {
15
+
mu sync.Mutex
16
+
store map[string]map[string]*Doc
17
+
}
18
+
19
+
// New creates an empty Store.
20
+
func New() *Store {
21
+
return &Store{store: make(map[string]map[string]*Doc)}
22
+
}
23
+
24
+
// GetSession returns the document map for the given session ID, creating it on demand.
25
+
func (s *Store) GetSession(sessionID string) map[string]*Doc {
26
+
s.mu.Lock()
27
+
defer s.mu.Unlock()
28
+
docs, ok := s.store[sessionID]
29
+
if !ok {
30
+
docs = make(map[string]*Doc)
31
+
s.store[sessionID] = docs
32
+
}
33
+
return docs
34
+
}
35
+
36
+
// DeleteSession removes the document map for a session.
37
+
func (s *Store) DeleteSession(sessionID string) {
38
+
s.mu.Lock()
39
+
delete(s.store, sessionID)
40
+
s.mu.Unlock()
41
+
}
+588
server/handlers/docs/handler.go
+588
server/handlers/docs/handler.go
···
1
+
package docs
2
+
3
+
import (
4
+
"context"
5
+
"crypto/rand"
6
+
"encoding/hex"
7
+
"encoding/json"
8
+
"fmt"
9
+
"net/http"
10
+
"strings"
11
+
"time"
12
+
13
+
"github.com/johnluther/tap-editor/server/devstore"
14
+
"github.com/johnluther/tap-editor/server/session"
15
+
fountain "github.com/johnluther/tap-editor/server/tap-editor"
16
+
)
17
+
18
+
// FetchDocFunc retrieves the name and text for a document by rkey.
19
+
type FetchDocFunc func(http.ResponseWriter, *http.Request, context.Context, session.Session, string) (string, string, int, error)
20
+
21
+
// RenderPDFFunc renders fountain blocks into PDF bytes.
22
+
type RenderPDFFunc func([]fountain.Block, string) ([]byte, error)
23
+
24
+
// UploadBlobFunc uploads a blob to the user's PDS.
25
+
type UploadBlobFunc func(http.ResponseWriter, *http.Request, []byte) (*http.Response, error)
26
+
27
+
// PDSRequestFunc issues an authenticated request to the user's PDS.
28
+
type PDSRequestFunc func(http.ResponseWriter, *http.Request, string, string, string, []byte) (*http.Response, error)
29
+
30
+
// PDSBaseFunc returns the base URL for the user's PDS.
31
+
type PDSBaseFunc func(*http.Request) string
32
+
33
+
// GetSessionFunc returns the user's DID and handle.
34
+
type GetSessionFunc func(*http.Request) (string, string, bool)
35
+
36
+
// SessionIDFunc returns the session ID for the request, creating one if needed.
37
+
type SessionIDFunc func(http.ResponseWriter, *http.Request) string
38
+
39
+
// SanitizeFilenameFunc cleans a string for safe filesystem usage.
40
+
type SanitizeFilenameFunc func(string) string
41
+
42
+
// Dependencies aggregates collaborators required by the docs handler.
43
+
type Dependencies struct {
44
+
DevStore *devstore.Store
45
+
DevOffline func() bool
46
+
GetSessionID SessionIDFunc
47
+
GetDIDAndHandle GetSessionFunc
48
+
UploadBlobWithRetry UploadBlobFunc
49
+
PDSRequest PDSRequestFunc
50
+
PDSBase PDSBaseFunc
51
+
RenderPDF RenderPDFFunc
52
+
FetchDoc FetchDocFunc
53
+
SanitizeFilename SanitizeFilenameFunc
54
+
MaxJSONBody int64
55
+
MaxTextBytes int
56
+
}
57
+
58
+
// Handler serves document endpoints backed by ATProto or an in-memory dev store.
59
+
type Handler struct {
60
+
deps Dependencies
61
+
}
62
+
63
+
// New constructs a Handler with the given dependencies.
64
+
func New(deps Dependencies) *Handler {
65
+
return &Handler{deps: deps}
66
+
}
67
+
68
+
// Register attaches document routes to the mux.
69
+
func (h *Handler) Register(mux *http.ServeMux) {
70
+
mux.HandleFunc("/docs", h.handleDocs)
71
+
mux.HandleFunc("/docs/", h.handleDocByID)
72
+
}
73
+
74
+
// DocsHandler exposes the main docs collection handler.
75
+
func (h *Handler) DocsHandler() http.HandlerFunc {
76
+
return h.handleDocs
77
+
}
78
+
79
+
// DocByIDHandler exposes the per-document handler.
80
+
func (h *Handler) DocByIDHandler() http.HandlerFunc {
81
+
return h.handleDocByID
82
+
}
83
+
84
+
func (h *Handler) handleDocs(w http.ResponseWriter, r *http.Request) {
85
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
86
+
if h.deps.DevOffline() {
87
+
sid := h.deps.GetSessionID(w, r)
88
+
store := h.deps.DevStore.GetSession(sid)
89
+
switch r.Method {
90
+
case http.MethodGet:
91
+
type item struct {
92
+
ID string `json:"id"`
93
+
Name string `json:"name"`
94
+
UpdatedAt string `json:"updatedAt"`
95
+
}
96
+
out := make([]item, 0, len(store))
97
+
for _, d := range store {
98
+
out = append(out, item{ID: d.ID, Name: d.Name, UpdatedAt: d.UpdatedAt})
99
+
}
100
+
_ = json.NewEncoder(w).Encode(out)
101
+
return
102
+
case http.MethodPost:
103
+
r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody)
104
+
var body struct{ Name, Text string }
105
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
106
+
http.Error(w, "invalid json", http.StatusBadRequest)
107
+
return
108
+
}
109
+
if body.Name == "" {
110
+
body.Name = "Untitled"
111
+
}
112
+
rb := make([]byte, 8)
113
+
_, _ = rand.Read(rb)
114
+
id := "d-" + hex.EncodeToString(rb)
115
+
now := time.Now().UTC().Format(time.RFC3339)
116
+
store[id] = &devstore.Doc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now}
117
+
w.WriteHeader(http.StatusCreated)
118
+
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
119
+
return
120
+
default:
121
+
w.WriteHeader(http.StatusMethodNotAllowed)
122
+
return
123
+
}
124
+
}
125
+
126
+
did, _, ok := h.deps.GetDIDAndHandle(r)
127
+
if !ok {
128
+
w.WriteHeader(http.StatusNoContent)
129
+
return
130
+
}
131
+
132
+
switch r.Method {
133
+
case http.MethodGet:
134
+
url := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.listRecords?repo=" + did + "&collection=lol.tapapp.tap.doc&limit=100"
135
+
resp, err := h.deps.PDSRequest(w, r, http.MethodGet, url, "", nil)
136
+
if err != nil {
137
+
http.Error(w, "list failed", http.StatusBadGateway)
138
+
return
139
+
}
140
+
defer resp.Body.Close()
141
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
142
+
w.WriteHeader(resp.StatusCode)
143
+
return
144
+
}
145
+
var lr struct {
146
+
Records []map[string]any `json:"records"`
147
+
}
148
+
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
149
+
http.Error(w, "decode list", http.StatusBadGateway)
150
+
return
151
+
}
152
+
type item struct {
153
+
ID string `json:"id"`
154
+
Name string `json:"name"`
155
+
UpdatedAt string `json:"updatedAt"`
156
+
}
157
+
out := make([]item, 0, len(lr.Records))
158
+
for _, rec := range lr.Records {
159
+
val, _ := rec["value"].(map[string]any)
160
+
name := firstNonEmpty(val["name"], val["title"], "Untitled")
161
+
updatedAt := normalizeTime(firstNonEmpty(val["updatedAt"], val["updated"], rec["indexedAt"]))
162
+
id := extractID(rec)
163
+
out = append(out, item{ID: id, Name: name, UpdatedAt: updatedAt})
164
+
}
165
+
_ = json.NewEncoder(w).Encode(out)
166
+
case http.MethodPost:
167
+
r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody)
168
+
var body struct{ Name, Text string }
169
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
170
+
http.Error(w, "invalid json", http.StatusBadRequest)
171
+
return
172
+
}
173
+
if len(body.Text) > h.deps.MaxTextBytes {
174
+
http.Error(w, "text too large", http.StatusRequestEntityTooLarge)
175
+
return
176
+
}
177
+
if body.Name == "" {
178
+
body.Name = "Untitled"
179
+
}
180
+
bRes, err := h.deps.UploadBlobWithRetry(w, r, []byte(body.Text))
181
+
if err != nil {
182
+
http.Error(w, "blob upload failed", http.StatusBadGateway)
183
+
return
184
+
}
185
+
defer bRes.Body.Close()
186
+
var bOut struct {
187
+
Blob map[string]any `json:"blob"`
188
+
}
189
+
if err := json.NewDecoder(bRes.Body).Decode(&bOut); err != nil {
190
+
http.Error(w, "blob decode failed", http.StatusBadGateway)
191
+
return
192
+
}
193
+
rb := make([]byte, 8)
194
+
_, _ = rand.Read(rb)
195
+
id := "d-" + hex.EncodeToString(rb)
196
+
record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": body.Name, "contentBlob": bOut.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)}
197
+
payload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record}
198
+
buf, _ := json.Marshal(payload)
199
+
createURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.createRecord"
200
+
cr, err := h.deps.PDSRequest(w, r, http.MethodPost, createURL, "application/json", buf)
201
+
if err != nil {
202
+
http.Error(w, "create failed", http.StatusBadGateway)
203
+
return
204
+
}
205
+
defer cr.Body.Close()
206
+
if cr.StatusCode < 200 || cr.StatusCode >= 300 {
207
+
w.WriteHeader(cr.StatusCode)
208
+
return
209
+
}
210
+
w.WriteHeader(http.StatusCreated)
211
+
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
212
+
default:
213
+
w.WriteHeader(http.StatusMethodNotAllowed)
214
+
}
215
+
}
216
+
217
+
func (h *Handler) handleDocByID(w http.ResponseWriter, r *http.Request) {
218
+
if h.deps.DevOffline() {
219
+
h.handleDevDoc(w, r)
220
+
return
221
+
}
222
+
223
+
did, handle, ok := h.deps.GetDIDAndHandle(r)
224
+
if !ok {
225
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
226
+
w.WriteHeader(http.StatusNoContent)
227
+
return
228
+
}
229
+
230
+
id := strings.TrimPrefix(r.URL.Path, "/docs/")
231
+
if id == "" {
232
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
233
+
w.WriteHeader(http.StatusBadRequest)
234
+
return
235
+
}
236
+
237
+
// PDF export
238
+
if r.Method == http.MethodGet && strings.HasSuffix(id, ".pdf") {
239
+
h.handlePDFExport(w, r, did, handle, strings.TrimSuffix(id, ".pdf"))
240
+
return
241
+
}
242
+
243
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
244
+
245
+
switch r.Method {
246
+
case http.MethodGet:
247
+
h.handleGetDoc(w, r, did, handle, id)
248
+
case http.MethodPut:
249
+
h.handleUpdateDoc(w, r, did, id)
250
+
case http.MethodDelete:
251
+
h.handleDeleteDoc(w, r, did, id)
252
+
default:
253
+
w.WriteHeader(http.StatusMethodNotAllowed)
254
+
}
255
+
}
256
+
257
+
func (h *Handler) handleDevDoc(w http.ResponseWriter, r *http.Request) {
258
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
259
+
id := strings.TrimPrefix(r.URL.Path, "/docs/")
260
+
if id == "" {
261
+
w.WriteHeader(http.StatusBadRequest)
262
+
return
263
+
}
264
+
sid := h.deps.GetSessionID(w, r)
265
+
store := h.deps.DevStore.GetSession(sid)
266
+
switch r.Method {
267
+
case http.MethodGet:
268
+
if strings.HasSuffix(id, ".pdf") {
269
+
baseID := strings.TrimSuffix(id, ".pdf")
270
+
d, ok := store[baseID]
271
+
if !ok {
272
+
http.Error(w, "not found", http.StatusNotFound)
273
+
return
274
+
}
275
+
name := fallback(d.Name, "Untitled")
276
+
blocks := fountain.Parse(d.Text)
277
+
pdfBytes, err := h.deps.RenderPDF(blocks, name)
278
+
if err != nil {
279
+
http.Error(w, "PDF render failed", http.StatusInternalServerError)
280
+
return
281
+
}
282
+
safeName := h.deps.SanitizeFilename(name)
283
+
w.Header().Del("Content-Type")
284
+
w.Header().Set("Content-Type", "application/pdf")
285
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName))
286
+
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
287
+
w.Header().Set("Pragma", "no-cache")
288
+
w.Header().Set("Expires", "0")
289
+
_, _ = w.Write(pdfBytes)
290
+
return
291
+
}
292
+
if strings.HasSuffix(id, ".fountain") {
293
+
baseID := strings.TrimSuffix(id, ".fountain")
294
+
d, ok := store[baseID]
295
+
if !ok {
296
+
http.Error(w, "not found", http.StatusNotFound)
297
+
return
298
+
}
299
+
name := fallback(d.Name, "screenplay")
300
+
w.Header().Del("Content-Type")
301
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
302
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", h.deps.SanitizeFilename(name)))
303
+
_, _ = w.Write([]byte(d.Text))
304
+
return
305
+
}
306
+
if r.URL.Query().Get("action") == "delete" {
307
+
if _, ok := store[id]; !ok {
308
+
http.Error(w, "not found", http.StatusNotFound)
309
+
return
310
+
}
311
+
delete(store, id)
312
+
http.Redirect(w, r, "/library", http.StatusSeeOther)
313
+
return
314
+
}
315
+
d, ok := store[id]
316
+
if !ok {
317
+
http.Error(w, "not found", http.StatusNotFound)
318
+
return
319
+
}
320
+
_ = json.NewEncoder(w).Encode(map[string]any{"id": d.ID, "name": d.Name, "text": d.Text, "updatedAt": d.UpdatedAt})
321
+
case http.MethodPut:
322
+
var body struct {
323
+
Name *string `json:"name"`
324
+
Text *string `json:"text"`
325
+
}
326
+
r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody)
327
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
328
+
http.Error(w, "invalid json", http.StatusBadRequest)
329
+
return
330
+
}
331
+
d, ok := store[id]
332
+
if !ok {
333
+
http.Error(w, "not found", http.StatusNotFound)
334
+
return
335
+
}
336
+
if body.Name != nil {
337
+
n := strings.TrimSpace(*body.Name)
338
+
if n == "" {
339
+
n = "Untitled"
340
+
}
341
+
d.Name = n
342
+
}
343
+
if body.Text != nil {
344
+
d.Text = *body.Text
345
+
}
346
+
d.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
347
+
w.WriteHeader(http.StatusNoContent)
348
+
case http.MethodDelete:
349
+
if _, ok := store[id]; !ok {
350
+
http.Error(w, "not found", http.StatusNotFound)
351
+
return
352
+
}
353
+
delete(store, id)
354
+
w.WriteHeader(http.StatusNoContent)
355
+
default:
356
+
w.WriteHeader(http.StatusMethodNotAllowed)
357
+
}
358
+
}
359
+
360
+
func (h *Handler) handlePDFExport(w http.ResponseWriter, r *http.Request, did, handle, id string) {
361
+
s2 := session.Session{DID: did, Handle: handle}
362
+
name, text, status, err := h.deps.FetchDoc(w, r, r.Context(), s2, id)
363
+
if err != nil {
364
+
w.WriteHeader(status)
365
+
return
366
+
}
367
+
if name == "" {
368
+
name = "Untitled"
369
+
}
370
+
blocks := fountain.Parse(text)
371
+
pdfBytes, err := h.deps.RenderPDF(blocks, name)
372
+
if err != nil {
373
+
http.Error(w, "PDF render failed", http.StatusInternalServerError)
374
+
return
375
+
}
376
+
safeName := h.deps.SanitizeFilename(name)
377
+
w.Header().Set("Content-Type", "application/pdf")
378
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName))
379
+
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
380
+
w.Header().Set("Pragma", "no-cache")
381
+
w.Header().Set("Expires", "0")
382
+
_, _ = w.Write(pdfBytes)
383
+
}
384
+
385
+
func (h *Handler) handleGetDoc(w http.ResponseWriter, r *http.Request, did, handle, id string) {
386
+
s2 := session.Session{DID: did, Handle: handle}
387
+
if strings.HasSuffix(id, ".fountain") {
388
+
baseID := strings.TrimSuffix(id, ".fountain")
389
+
name, text, status, err := h.deps.FetchDoc(w, r, r.Context(), s2, baseID)
390
+
if err != nil {
391
+
w.WriteHeader(status)
392
+
return
393
+
}
394
+
w.Header().Del("Content-Type")
395
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
396
+
if name == "" {
397
+
name = "screenplay"
398
+
}
399
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", h.deps.SanitizeFilename(name)))
400
+
_, _ = w.Write([]byte(text))
401
+
return
402
+
}
403
+
404
+
if r.URL.Query().Get("action") == "delete" {
405
+
delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id}
406
+
dbuf, _ := json.Marshal(delPayload)
407
+
delURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.deleteRecord"
408
+
dRes, err := h.deps.PDSRequest(w, r, http.MethodPost, delURL, "application/json", dbuf)
409
+
if err != nil {
410
+
http.Error(w, "delete failed", http.StatusBadGateway)
411
+
return
412
+
}
413
+
defer dRes.Body.Close()
414
+
if dRes.StatusCode < 200 || dRes.StatusCode >= 300 {
415
+
w.WriteHeader(dRes.StatusCode)
416
+
return
417
+
}
418
+
http.Redirect(w, r, "/library", http.StatusSeeOther)
419
+
return
420
+
}
421
+
422
+
name, text, status, err := h.deps.FetchDoc(w, r, r.Context(), s2, id)
423
+
if err != nil {
424
+
w.WriteHeader(status)
425
+
return
426
+
}
427
+
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name, "text": text, "updatedAt": ""})
428
+
}
429
+
430
+
func (h *Handler) handleUpdateDoc(w http.ResponseWriter, r *http.Request, did, id string) {
431
+
r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody)
432
+
var body struct {
433
+
Name *string `json:"name"`
434
+
Text *string `json:"text"`
435
+
}
436
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
437
+
http.Error(w, "invalid json", http.StatusBadRequest)
438
+
return
439
+
}
440
+
441
+
getURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=" + id
442
+
gRes, err := h.deps.PDSRequest(w, r, http.MethodGet, getURL, "", nil)
443
+
if err != nil {
444
+
http.Error(w, "get failed", http.StatusBadGateway)
445
+
return
446
+
}
447
+
defer gRes.Body.Close()
448
+
if gRes.StatusCode == http.StatusNotFound {
449
+
http.Error(w, "not found", http.StatusNotFound)
450
+
return
451
+
}
452
+
if gRes.StatusCode < 200 || gRes.StatusCode >= 300 {
453
+
w.WriteHeader(gRes.StatusCode)
454
+
return
455
+
}
456
+
var cur struct {
457
+
Value map[string]any `json:"value"`
458
+
}
459
+
if err := json.NewDecoder(gRes.Body).Decode(&cur); err != nil {
460
+
http.Error(w, "decode current", http.StatusBadGateway)
461
+
return
462
+
}
463
+
name := fallbackString(cur.Value["name"], "Untitled")
464
+
var blob map[string]any
465
+
if v, ok := cur.Value["contentBlob"].(map[string]any); ok {
466
+
blob = v
467
+
}
468
+
if body.Name != nil {
469
+
name = fallback(strings.TrimSpace(*body.Name), "Untitled")
470
+
}
471
+
if body.Text != nil {
472
+
if len(*body.Text) > h.deps.MaxTextBytes {
473
+
http.Error(w, "text too large", http.StatusRequestEntityTooLarge)
474
+
return
475
+
}
476
+
ubRes, err := h.deps.UploadBlobWithRetry(w, r, []byte(*body.Text))
477
+
if err != nil {
478
+
http.Error(w, "blob upload failed", http.StatusBadGateway)
479
+
return
480
+
}
481
+
defer ubRes.Body.Close()
482
+
var ub struct {
483
+
Blob map[string]any `json:"blob"`
484
+
}
485
+
if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil {
486
+
http.Error(w, "blob decode failed", http.StatusBadGateway)
487
+
return
488
+
}
489
+
blob = ub.Blob
490
+
} else if blob == nil {
491
+
ubRes, err := h.deps.UploadBlobWithRetry(w, r, []byte(""))
492
+
if err != nil {
493
+
http.Error(w, "blob upload failed", http.StatusBadGateway)
494
+
return
495
+
}
496
+
defer ubRes.Body.Close()
497
+
var ub struct {
498
+
Blob map[string]any `json:"blob"`
499
+
}
500
+
if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil {
501
+
http.Error(w, "blob decode failed", http.StatusBadGateway)
502
+
return
503
+
}
504
+
blob = ub.Blob
505
+
}
506
+
507
+
record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": name, "contentBlob": blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)}
508
+
putPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record}
509
+
pbuf, _ := json.Marshal(putPayload)
510
+
putURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.putRecord"
511
+
pRes, err := h.deps.PDSRequest(w, r, http.MethodPost, putURL, "application/json", pbuf)
512
+
if err != nil {
513
+
http.Error(w, "put failed", http.StatusBadGateway)
514
+
return
515
+
}
516
+
defer pRes.Body.Close()
517
+
if pRes.StatusCode < 200 || pRes.StatusCode >= 300 {
518
+
w.WriteHeader(pRes.StatusCode)
519
+
return
520
+
}
521
+
w.WriteHeader(http.StatusNoContent)
522
+
}
523
+
524
+
func (h *Handler) handleDeleteDoc(w http.ResponseWriter, r *http.Request, did, id string) {
525
+
delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id}
526
+
dbuf, _ := json.Marshal(delPayload)
527
+
delURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.deleteRecord"
528
+
dRes, err := h.deps.PDSRequest(w, r, http.MethodPost, delURL, "application/json", dbuf)
529
+
if err != nil {
530
+
http.Error(w, "delete failed", http.StatusBadGateway)
531
+
return
532
+
}
533
+
defer dRes.Body.Close()
534
+
if dRes.StatusCode < 200 || dRes.StatusCode >= 300 {
535
+
w.WriteHeader(dRes.StatusCode)
536
+
return
537
+
}
538
+
w.WriteHeader(http.StatusNoContent)
539
+
}
540
+
541
+
func firstNonEmpty(values ...any) string {
542
+
for _, v := range values {
543
+
if s, ok := v.(string); ok && s != "" {
544
+
return s
545
+
}
546
+
}
547
+
return ""
548
+
}
549
+
550
+
func normalizeTime(ts string) string {
551
+
if ts == "" {
552
+
return ""
553
+
}
554
+
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
555
+
return t.UTC().Format(time.RFC3339)
556
+
}
557
+
if t, err := time.Parse(time.RFC3339, ts); err == nil {
558
+
return t.UTC().Format(time.RFC3339)
559
+
}
560
+
return ts
561
+
}
562
+
563
+
func extractID(rec map[string]any) string {
564
+
if v, ok := rec["rkey"].(string); ok && v != "" {
565
+
return v
566
+
}
567
+
if v, ok := rec["uri"].(string); ok {
568
+
parts := strings.Split(v, "/")
569
+
if len(parts) > 0 {
570
+
return parts[len(parts)-1]
571
+
}
572
+
}
573
+
return "current"
574
+
}
575
+
576
+
func fallback(value, fallbackVal string) string {
577
+
if strings.TrimSpace(value) == "" {
578
+
return fallbackVal
579
+
}
580
+
return value
581
+
}
582
+
583
+
func fallbackString(value any, fallbackVal string) string {
584
+
if s, ok := value.(string); ok && strings.TrimSpace(s) != "" {
585
+
return s
586
+
}
587
+
return fallbackVal
588
+
}
+52
server/handlers/pages/pages.go
+52
server/handlers/pages/pages.go
···
1
+
package pages
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/johnluther/tap-editor/server/render"
7
+
)
8
+
9
+
// Handler serves simple static HTML pages rendered from templates.
10
+
type Handler struct {
11
+
renderer *render.Renderer
12
+
}
13
+
14
+
// New creates a Handler with the provided renderer.
15
+
func New(renderer *render.Renderer) *Handler {
16
+
return &Handler{renderer: renderer}
17
+
}
18
+
19
+
// Register attaches the page handlers to the mux.
20
+
func (h *Handler) Register(mux *http.ServeMux) {
21
+
mux.HandleFunc("/", h.handleIndex)
22
+
mux.HandleFunc("/about", h.handleAbout)
23
+
mux.HandleFunc("/privacy", h.handlePrivacy)
24
+
mux.HandleFunc("/terms", h.handleTerms)
25
+
mux.HandleFunc("/library", h.handleLibrary)
26
+
}
27
+
28
+
func (h *Handler) handleIndex(w http.ResponseWriter, r *http.Request) {
29
+
data := struct{ Title string }{Title: "Tap - A Minimal Fountain Editor"}
30
+
h.renderer.Execute(w, "index.html", data)
31
+
}
32
+
33
+
func (h *Handler) handleAbout(w http.ResponseWriter, r *http.Request) {
34
+
data := struct{ Title string }{Title: "About Tap"}
35
+
h.renderer.Execute(w, "about.html", data)
36
+
}
37
+
38
+
func (h *Handler) handlePrivacy(w http.ResponseWriter, r *http.Request) {
39
+
data := struct{ Title string }{Title: "Privacy Policy"}
40
+
h.renderer.Execute(w, "privacy.html", data)
41
+
}
42
+
43
+
func (h *Handler) handleTerms(w http.ResponseWriter, r *http.Request) {
44
+
data := struct{ Title string }{Title: "Terms of Service"}
45
+
h.renderer.Execute(w, "terms.html", data)
46
+
}
47
+
48
+
func (h *Handler) handleLibrary(w http.ResponseWriter, r *http.Request) {
49
+
w.Header().Set("Cache-Control", "no-cache")
50
+
data := struct{ Title string }{Title: "Library - Tap"}
51
+
h.renderer.Execute(w, "library.html", data)
52
+
}
-634
server/oauth.go
-634
server/oauth.go
···
1
-
package main
2
-
3
-
import (
4
-
"crypto/ecdsa"
5
-
"crypto/elliptic"
6
-
"crypto/rand"
7
-
"crypto/sha256"
8
-
"crypto/x509"
9
-
"encoding/base64"
10
-
"encoding/json"
11
-
"encoding/pem"
12
-
"fmt"
13
-
"log"
14
-
"net/http"
15
-
"os"
16
-
"strings"
17
-
"sync"
18
-
"time"
19
-
20
-
"github.com/gorilla/sessions"
21
-
"github.com/lestrrat-go/jwx/v2/jwk"
22
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
23
-
)
24
-
25
-
const (
26
-
// Use a distinct cookie name for Gorilla sessions to avoid colliding with
27
-
// the legacy 'tap_session' cookie used by non-OAuth flows.
28
-
SessionName = "tap_oauth"
29
-
oauthScope = "atproto transition:generic"
30
-
)
31
-
32
-
type OAuthRequest struct {
33
-
State string
34
-
Handle string
35
-
Did string
36
-
PdsUrl string
37
-
PkceVerifier string
38
-
PkceChallenge string
39
-
DpopAuthserverNonce string
40
-
DpopPrivateJwk string
41
-
AuthserverIss string
42
-
ReturnUrl string
43
-
}
44
-
45
-
// handleOAuthResume allows the client to repopulate the server-side OAuth session
46
-
// after restarts by POSTing current token state. Body JSON:
47
-
// {
48
-
// "did": "...",
49
-
// "handle": "...",
50
-
// "pdsUrl": "https://...",
51
-
// "tokenType": "DPoP",
52
-
// "scope": "atproto transition:generic",
53
-
// "accessJwt": "...",
54
-
// "refreshJwt": "...",
55
-
// "expiry": "RFC3339 timestamp" // optional
56
-
// }
57
-
func handleOAuthResume(w http.ResponseWriter, r *http.Request) {
58
-
if r.Method != http.MethodPost {
59
-
w.WriteHeader(http.StatusMethodNotAllowed)
60
-
return
61
-
}
62
-
if oauthManager == nil {
63
-
http.Error(w, "oauth not initialized", http.StatusInternalServerError)
64
-
return
65
-
}
66
-
r.Body = http.MaxBytesReader(w, r.Body, 32<<10) // 32 KiB
67
-
var in struct {
68
-
Did string `json:"did"`
69
-
Handle string `json:"handle"`
70
-
PdsUrl string `json:"pdsUrl"`
71
-
TokenType string `json:"tokenType"`
72
-
Scope string `json:"scope"`
73
-
AccessJwt string `json:"accessJwt"`
74
-
RefreshJwt string `json:"refreshJwt"`
75
-
Expiry string `json:"expiry"`
76
-
}
77
-
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
78
-
http.Error(w, "invalid json", http.StatusBadRequest)
79
-
return
80
-
}
81
-
if in.Did == "" || in.AccessJwt == "" {
82
-
http.Error(w, "missing did/accessJwt", http.StatusBadRequest)
83
-
return
84
-
}
85
-
var exp time.Time
86
-
if strings.TrimSpace(in.Expiry) != "" {
87
-
if t, err := time.Parse(time.RFC3339, in.Expiry); err == nil {
88
-
exp = t
89
-
}
90
-
}
91
-
sess := OAuthSession{
92
-
Did: in.Did,
93
-
Handle: in.Handle,
94
-
PdsUrl: in.PdsUrl,
95
-
TokenType: in.TokenType,
96
-
Scope: in.Scope,
97
-
AccessJwt: in.AccessJwt,
98
-
RefreshJwt: in.RefreshJwt,
99
-
Expiry: exp,
100
-
}
101
-
// Save to memory and cookie
102
-
oauthManager.SaveSession(sess.Did, sess)
103
-
if err := oauthManager.SaveSessionToCookie(r, w, sess); err != nil {
104
-
http.Error(w, "failed to persist session", http.StatusInternalServerError)
105
-
return
106
-
}
107
-
w.WriteHeader(http.StatusNoContent)
108
-
}
109
-
110
-
// GetSessionFromCookie reconstructs an OAuthSession from the Gorilla cookie, if present.
111
-
func (o *OAuthManager) GetSessionFromCookie(r *http.Request) (OAuthSession, bool) {
112
-
session, err := o.store.Get(r, SessionName)
113
-
if err != nil || session.IsNew {
114
-
return OAuthSession{}, false
115
-
}
116
-
did, _ := session.Values["did"].(string)
117
-
handle, _ := session.Values["handle"].(string)
118
-
pds, _ := session.Values["pds"].(string)
119
-
ttype, _ := session.Values["token_type"].(string)
120
-
scope, _ := session.Values["scope"].(string)
121
-
access, _ := session.Values["access_jwt"].(string)
122
-
refresh, _ := session.Values["refresh_jwt"].(string)
123
-
expStr, _ := session.Values["expiry"].(string)
124
-
var exp time.Time
125
-
if expStr != "" {
126
-
if t, err := time.Parse(time.RFC3339, expStr); err == nil {
127
-
exp = t
128
-
}
129
-
}
130
-
if did == "" || access == "" {
131
-
return OAuthSession{}, false
132
-
}
133
-
return OAuthSession{
134
-
Did: did,
135
-
Handle: handle,
136
-
PdsUrl: pds,
137
-
TokenType: ttype,
138
-
Scope: scope,
139
-
AccessJwt: access,
140
-
RefreshJwt: refresh,
141
-
Expiry: exp,
142
-
}, true
143
-
}
144
-
145
-
// SaveSessionToCookie writes the OAuthSession fields into the Gorilla cookie for persistence.
146
-
func (o *OAuthManager) SaveSessionToCookie(r *http.Request, w http.ResponseWriter, sess OAuthSession) error {
147
-
session, err := o.store.Get(r, SessionName)
148
-
if err != nil {
149
-
return err
150
-
}
151
-
session.Values["did"] = sess.Did
152
-
session.Values["handle"] = sess.Handle
153
-
session.Values["pds"] = sess.PdsUrl
154
-
session.Values["token_type"] = sess.TokenType
155
-
session.Values["scope"] = sess.Scope
156
-
session.Values["access_jwt"] = sess.AccessJwt
157
-
session.Values["refresh_jwt"] = sess.RefreshJwt
158
-
if !sess.Expiry.IsZero() {
159
-
session.Values["expiry"] = sess.Expiry.UTC().Format(time.RFC3339)
160
-
}
161
-
return session.Save(r, w)
162
-
}
163
-
164
-
// generateClientAssertion builds a private_key_jwt for token endpoint auth using ES256.
165
-
// Claims:
166
-
//
167
-
// iss = client_id
168
-
// sub = client_id
169
-
// aud = token endpoint URL
170
-
// iat = now, exp = now + 5 minutes
171
-
// jti = random
172
-
func (o *OAuthManager) generateClientAssertion(clientID, tokenURL string) (string, error) {
173
-
now := time.Now().Unix()
174
-
175
-
// Random jti
176
-
jtiBytes := make([]byte, 16)
177
-
if _, err := rand.Read(jtiBytes); err != nil {
178
-
return "", fmt.Errorf("failed to generate jti: %w", err)
179
-
}
180
-
jti := base64.RawURLEncoding.EncodeToString(jtiBytes)
181
-
182
-
claims := map[string]any{
183
-
"iss": clientID,
184
-
"sub": clientID,
185
-
// Bluesky accepts either token endpoint or issuer; include both
186
-
"aud": []string{tokenURL, "https://bsky.social"},
187
-
"iat": now,
188
-
"exp": now + 300,
189
-
"jti": jti,
190
-
}
191
-
192
-
header := map[string]any{
193
-
"alg": "ES256",
194
-
"typ": "JWT",
195
-
"kid": o.jwksKid,
196
-
}
197
-
198
-
headerJSON, _ := json.Marshal(header)
199
-
payloadJSON, _ := json.Marshal(claims)
200
-
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(payloadJSON)
201
-
202
-
// Sign with ES256
203
-
hash := sha256.Sum256([]byte(signingInput))
204
-
r, s, err := ecdsa.Sign(rand.Reader, o.privateKey, hash[:])
205
-
if err != nil {
206
-
return "", fmt.Errorf("failed to sign client assertion: %w", err)
207
-
}
208
-
209
-
rBytes := make([]byte, 32)
210
-
sBytes := make([]byte, 32)
211
-
r.FillBytes(rBytes)
212
-
s.FillBytes(sBytes)
213
-
signature := append(rBytes, sBytes...)
214
-
sigB64 := base64.RawURLEncoding.EncodeToString(signature)
215
-
216
-
return signingInput + "." + sigB64, nil
217
-
}
218
-
219
-
type OAuthSession struct {
220
-
Did string
221
-
Handle string
222
-
PdsUrl string
223
-
DpopAuthserverNonce string
224
-
AuthServerIss string
225
-
DpopPrivateJwk string
226
-
TokenType string
227
-
Scope string
228
-
AccessJwt string
229
-
RefreshJwt string
230
-
Expiry time.Time
231
-
}
232
-
233
-
type OAuthManager struct {
234
-
store *sessions.CookieStore
235
-
oauthRequests map[string]OAuthRequest
236
-
oauthSessions map[string]OAuthSession
237
-
mu sync.RWMutex
238
-
jwks string
239
-
clientURI string
240
-
privateKey *ecdsa.PrivateKey
241
-
jwksKid string
242
-
}
243
-
244
-
var oauthManager *OAuthManager
245
-
246
-
func initOAuth(clientURI string, cookieSecret string) {
247
-
jwks, privKey, kid := loadOrGenerateKey()
248
-
store := sessions.NewCookieStore([]byte(cookieSecret))
249
-
store.Options = &sessions.Options{
250
-
Path: "/",
251
-
HttpOnly: true,
252
-
Secure: strings.HasPrefix(clientURI, "https://"),
253
-
SameSite: http.SameSiteLaxMode,
254
-
MaxAge: 30 * 24 * 3600, // 30 days
255
-
}
256
-
oauthManager = &OAuthManager{
257
-
store: store,
258
-
oauthRequests: make(map[string]OAuthRequest),
259
-
oauthSessions: make(map[string]OAuthSession),
260
-
jwks: jwks,
261
-
clientURI: clientURI,
262
-
privateKey: privKey,
263
-
jwksKid: kid,
264
-
}
265
-
}
266
-
267
-
// loadOrGenerateKey loads an ES256 private key from env (PEM) or generates a new one.
268
-
// It returns a JWKS (with alg/use/kid), the private key, and a stable kid based on the JWK thumbprint.
269
-
func loadOrGenerateKey() (string, *ecdsa.PrivateKey, string) {
270
-
// Prefer a persistent PEM key from env
271
-
if pemStr := os.Getenv("OAUTH_ES256_PRIVATE_KEY_PEM"); pemStr != "" {
272
-
data := []byte(pemStr)
273
-
// Allow base64-encoded PEM content if it doesn't include BEGIN header
274
-
if !strings.Contains(pemStr, "-----BEGIN") {
275
-
if dec, err := base64.StdEncoding.DecodeString(pemStr); err == nil {
276
-
data = dec
277
-
}
278
-
}
279
-
blk, _ := pem.Decode(data)
280
-
if blk == nil {
281
-
log.Fatal("failed to decode OAUTH_ES256_PRIVATE_KEY_PEM: invalid PEM block")
282
-
}
283
-
// Try SEC1 EC private key
284
-
if pk, err := x509.ParseECPrivateKey(blk.Bytes); err == nil {
285
-
jwks, kid := jwksFromPrivateKey(pk)
286
-
return jwks, pk, kid
287
-
}
288
-
// Try PKCS#8 private key and cast to ECDSA
289
-
if pkAny, err := x509.ParsePKCS8PrivateKey(blk.Bytes); err == nil {
290
-
if ecdsaKey, ok := pkAny.(*ecdsa.PrivateKey); ok {
291
-
jwks, kid := jwksFromPrivateKey(ecdsaKey)
292
-
return jwks, ecdsaKey, kid
293
-
}
294
-
log.Fatal("OAUTH_ES256_PRIVATE_KEY_PEM is PKCS#8 but not an ECDSA key")
295
-
}
296
-
log.Fatal("failed to parse OAUTH_ES256_PRIVATE_KEY_PEM as EC or PKCS#8 ECDSA key")
297
-
}
298
-
// Fallback: generate ephemeral key
299
-
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
300
-
if err != nil {
301
-
log.Fatal("failed to generate key:", err)
302
-
}
303
-
jwks, kid := jwksFromPrivateKey(privKey)
304
-
return jwks, privKey, kid
305
-
}
306
-
307
-
// jwksFromPrivateKey creates a JWKS JSON string and stable kid from the given key.
308
-
func jwksFromPrivateKey(privKey *ecdsa.PrivateKey) (string, string) {
309
-
pubKey := &privKey.PublicKey
310
-
key, err := jwk.FromRaw(pubKey)
311
-
if err != nil {
312
-
log.Fatal("failed to create jwk from public key:", err)
313
-
}
314
-
// Compute a stable kid from the uncompressed EC public key bytes
315
-
// Uncompressed form per SEC1: 0x04 || X(32) || Y(32)
316
-
xb := pubKey.X.Bytes()
317
-
yb := pubKey.Y.Bytes()
318
-
// left-pad to 32 bytes
319
-
if len(xb) < 32 {
320
-
px := make([]byte, 32)
321
-
copy(px[32-len(xb):], xb)
322
-
xb = px
323
-
}
324
-
if len(yb) < 32 {
325
-
py := make([]byte, 32)
326
-
copy(py[32-len(yb):], yb)
327
-
yb = py
328
-
}
329
-
raw := make([]byte, 1+32+32)
330
-
raw[0] = 0x04
331
-
copy(raw[1:33], xb)
332
-
copy(raw[33:], yb)
333
-
sum := sha256.Sum256(raw)
334
-
kid := base64.RawURLEncoding.EncodeToString(sum[:])
335
-
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
336
-
log.Fatal("failed to set kid:", err)
337
-
}
338
-
if err := key.Set("use", "sig"); err != nil {
339
-
log.Fatal("failed to set use:", err)
340
-
}
341
-
if err := key.Set("alg", "ES256"); err != nil {
342
-
log.Fatal("failed to set alg:", err)
343
-
}
344
-
jwks := map[string]interface{}{
345
-
"keys": []interface{}{key},
346
-
}
347
-
b, err := json.Marshal(jwks)
348
-
if err != nil {
349
-
log.Fatal("failed to marshal jwks:", err)
350
-
}
351
-
return string(b), kid
352
-
}
353
-
354
-
// generatePKCE generates a PKCE code verifier and challenge
355
-
func generatePKCE() (verifier, challenge string, err error) {
356
-
// Generate random verifier (43-128 characters)
357
-
verifierBytes := make([]byte, 32)
358
-
if _, err := rand.Read(verifierBytes); err != nil {
359
-
return "", "", err
360
-
}
361
-
362
-
// Base64url encode the verifier
363
-
verifier = base64.RawURLEncoding.EncodeToString(verifierBytes)
364
-
365
-
// Create challenge by SHA256 hashing the verifier
366
-
hash := sha256.Sum256([]byte(verifier))
367
-
challenge = base64.RawURLEncoding.EncodeToString(hash[:])
368
-
369
-
return verifier, challenge, nil
370
-
}
371
-
372
-
// generateDPoPProof generates a DPoP proof for the given HTTP method and URL
373
-
func (o *OAuthManager) generateDPoPProof(httpMethod, httpUri string, nonce ...string) (string, error) {
374
-
// Back-compat wrapper: no access-token hash (ath)
375
-
return o.generateDPoPProofWithToken(httpMethod, httpUri, "", nonce...)
376
-
}
377
-
378
-
// generateDPoPProofWithToken generates a DPoP proof and optionally includes 'ath' (SHA-256 of access token)
379
-
func (o *OAuthManager) generateDPoPProofWithToken(httpMethod, httpUri, accessToken string, nonce ...string) (string, error) {
380
-
log.Printf("DPoP: Generating DPoP proof using standard JWT approach")
381
-
382
-
now := time.Now().Unix()
383
-
384
-
// Create DPoP claims as a simple map
385
-
// Generate a unique JWT ID (jti) to prevent replay attacks
386
-
jtiBytes := make([]byte, 16)
387
-
if _, err := rand.Read(jtiBytes); err != nil {
388
-
log.Printf("DPoP: ERROR - failed to generate jti: %v", err)
389
-
return "", fmt.Errorf("failed to generate jti: %w", err)
390
-
}
391
-
jti := base64.RawURLEncoding.EncodeToString(jtiBytes)
392
-
393
-
claims := map[string]interface{}{
394
-
"iat": now,
395
-
"htu": httpUri,
396
-
"htm": httpMethod,
397
-
"jti": jti,
398
-
}
399
-
// Optionally include 'ath' = base64url(SHA-256(access_token)) to bind token to proof
400
-
if accessToken != "" {
401
-
athHash := sha256.Sum256([]byte(accessToken))
402
-
claims["ath"] = base64.RawURLEncoding.EncodeToString(athHash[:])
403
-
}
404
-
405
-
// Add nonce if provided
406
-
if len(nonce) > 0 && nonce[0] != "" {
407
-
claims["nonce"] = nonce[0]
408
-
log.Printf("DPoP: Added nonce to claims: %s", nonce[0])
409
-
}
410
-
411
-
// Create JWK from our public key
412
-
pubKey := &o.privateKey.PublicKey
413
-
jwkKey, err := jwk.FromRaw(pubKey)
414
-
if err != nil {
415
-
log.Printf("DPoP: ERROR - failed to create JWK: %v", err)
416
-
return "", fmt.Errorf("failed to create JWK: %w", err)
417
-
}
418
-
419
-
// Set key ID
420
-
kid := fmt.Sprintf("%d", time.Now().Unix())
421
-
if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil {
422
-
log.Printf("DPoP: ERROR - failed to set kid: %w", err)
423
-
return "", fmt.Errorf("failed to set kid: %w", err)
424
-
}
425
-
426
-
// Convert JWK to JSON for header
427
-
jwkJSON, err := json.Marshal(jwkKey)
428
-
if err != nil {
429
-
log.Printf("DPoP: ERROR - failed to marshal JWK: %v", err)
430
-
return "", fmt.Errorf("failed to marshal JWK: %w", err)
431
-
}
432
-
433
-
log.Printf("DPoP: JWK JSON: %s", string(jwkJSON))
434
-
435
-
// Create JWT header with JWK
436
-
header := map[string]interface{}{
437
-
"typ": "dpop+jwt",
438
-
"alg": "ES256",
439
-
"jwk": json.RawMessage(jwkJSON),
440
-
}
441
-
442
-
// Encode header and payload
443
-
headerJSON, _ := json.Marshal(header)
444
-
payloadJSON, _ := json.Marshal(claims)
445
-
446
-
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
447
-
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
448
-
449
-
// Create signing input
450
-
signingInput := headerB64 + "." + payloadB64
451
-
log.Printf("DPoP: Signing input: %s", signingInput)
452
-
453
-
// Use ECDSA signing with proper hash
454
-
hash := sha256.Sum256([]byte(signingInput))
455
-
r, s, err := ecdsa.Sign(rand.Reader, o.privateKey, hash[:])
456
-
if err != nil {
457
-
log.Printf("DPoP: ERROR - failed to sign: %v", err)
458
-
return "", fmt.Errorf("failed to sign: %w", err)
459
-
}
460
-
461
-
// Convert to JWT ES256 format: r and s as 32-byte big-endian
462
-
rBytes := make([]byte, 32)
463
-
sBytes := make([]byte, 32)
464
-
r.FillBytes(rBytes)
465
-
s.FillBytes(sBytes)
466
-
467
-
signature := append(rBytes, sBytes...)
468
-
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
469
-
470
-
// Combine into final JWT
471
-
jwtToken := signingInput + "." + signatureB64
472
-
473
-
log.Printf("DPoP: JWT created, length: %d", len(jwtToken))
474
-
log.Printf("DPoP: Full JWT: %s", jwtToken)
475
-
476
-
return jwtToken, nil
477
-
}
478
-
479
-
func (o *OAuthManager) ClientMetadata() map[string]interface{} {
480
-
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", o.clientURI)
481
-
redirectURIs := []string{fmt.Sprintf("%s/oauth/callback", o.clientURI)}
482
-
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", o.clientURI)
483
-
484
-
return map[string]interface{}{
485
-
"client_id": clientID,
486
-
"client_name": "Tap App",
487
-
"subject_type": "public",
488
-
"client_uri": o.clientURI,
489
-
"redirect_uris": redirectURIs,
490
-
"grant_types": []string{"authorization_code", "refresh_token"},
491
-
"response_types": []string{"code"},
492
-
"application_type": "web",
493
-
"dpop_bound_access_tokens": true,
494
-
"jwks_uri": jwksURI,
495
-
"scope": oauthScope,
496
-
"token_endpoint_auth_method": "private_key_jwt",
497
-
"token_endpoint_auth_signing_alg": "ES256",
498
-
}
499
-
}
500
-
501
-
func (o *OAuthManager) SaveRequest(state string, req OAuthRequest) {
502
-
o.mu.Lock()
503
-
defer o.mu.Unlock()
504
-
o.oauthRequests[state] = req
505
-
}
506
-
507
-
func (o *OAuthManager) GetRequest(state string) (OAuthRequest, bool) {
508
-
o.mu.RLock()
509
-
defer o.mu.RUnlock()
510
-
req, ok := o.oauthRequests[state]
511
-
return req, ok
512
-
}
513
-
514
-
func (o *OAuthManager) DeleteRequest(state string) {
515
-
o.mu.Lock()
516
-
defer o.mu.Unlock()
517
-
delete(o.oauthRequests, state)
518
-
}
519
-
520
-
func (o *OAuthManager) SaveSession(did string, sess OAuthSession) {
521
-
o.mu.Lock()
522
-
defer o.mu.Unlock()
523
-
o.oauthSessions[did] = sess
524
-
}
525
-
526
-
func (o *OAuthManager) GetSession(did string) (OAuthSession, bool) {
527
-
o.mu.RLock()
528
-
defer o.mu.RUnlock()
529
-
sess, ok := o.oauthSessions[did]
530
-
return sess, ok
531
-
}
532
-
533
-
func (o *OAuthManager) DeleteSession(did string) {
534
-
o.mu.Lock()
535
-
defer o.mu.Unlock()
536
-
delete(o.oauthSessions, did)
537
-
}
538
-
539
-
func (o *OAuthManager) GetUser(r *http.Request) *User {
540
-
session, err := o.store.Get(r, SessionName)
541
-
if err != nil || session.IsNew {
542
-
return nil
543
-
}
544
-
545
-
did, ok := session.Values["did"].(string)
546
-
if !ok || did == "" {
547
-
return nil
548
-
}
549
-
550
-
// Prefer in-memory session; otherwise, attempt to rehydrate from cookie
551
-
if sess, ok := o.GetSession(did); ok {
552
-
return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl}
553
-
}
554
-
if sess, ok := o.GetSessionFromCookie(r); ok {
555
-
// Cache it in memory for future lookups
556
-
o.SaveSession(sess.Did, sess)
557
-
return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl}
558
-
}
559
-
return &User{Did: did}
560
-
}
561
-
562
-
type User struct {
563
-
Handle string
564
-
Did string
565
-
Pds string
566
-
}
567
-
568
-
func (o *OAuthManager) AuthorizedClient(r *http.Request) (*oauth.XrpcClient, error) {
569
-
user := o.GetUser(r)
570
-
if user == nil {
571
-
return nil, fmt.Errorf("not authorized")
572
-
}
573
-
574
-
sess, ok := o.GetSession(user.Did)
575
-
if !ok {
576
-
return nil, fmt.Errorf("session not found")
577
-
}
578
-
579
-
// Check if token needs refresh
580
-
if time.Until(sess.Expiry) <= 5*time.Minute {
581
-
if err := o.refreshSession(user.Did); err != nil {
582
-
return nil, fmt.Errorf("failed to refresh session: %w", err)
583
-
}
584
-
sess, _ = o.GetSession(user.Did)
585
-
}
586
-
587
-
client := &oauth.XrpcClient{
588
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
589
-
o.mu.Lock()
590
-
if s, ok := o.oauthSessions[did]; ok {
591
-
s.DpopAuthserverNonce = newNonce
592
-
o.oauthSessions[did] = s
593
-
}
594
-
o.mu.Unlock()
595
-
},
596
-
}
597
-
598
-
// For simplicity, return the client
599
-
// In full implementation, wrap with xrpc client
600
-
return client, nil
601
-
}
602
-
603
-
func (o *OAuthManager) refreshSession(did string) error {
604
-
sess, ok := o.GetSession(did)
605
-
if !ok {
606
-
return fmt.Errorf("session not found")
607
-
}
608
-
609
-
// For simplicity, assume we have the client
610
-
// In real implementation, need to create oauth client
611
-
// This is simplified
612
-
613
-
// Placeholder: in full implementation, use oauthClient.RefreshTokenRequest
614
-
// For now, just extend expiry
615
-
sess.Expiry = time.Now().Add(30 * time.Minute)
616
-
o.SaveSession(did, sess)
617
-
618
-
return nil
619
-
}
620
-
621
-
func (o *OAuthManager) ClearSession(r *http.Request, w http.ResponseWriter) error {
622
-
session, err := o.store.Get(r, SessionName)
623
-
if err != nil {
624
-
return err
625
-
}
626
-
627
-
did, ok := session.Values["did"].(string)
628
-
if ok {
629
-
o.DeleteSession(did)
630
-
}
631
-
632
-
session.Options.MaxAge = -1
633
-
return session.Save(r, w)
634
-
}
+32
server/render/render.go
+32
server/render/render.go
···
1
+
package render
2
+
3
+
import (
4
+
"html/template"
5
+
"log"
6
+
"net/http"
7
+
)
8
+
9
+
// Renderer wraps parsed templates and provides helper methods for rendering
10
+
// server-side views.
11
+
type Renderer struct {
12
+
templates *template.Template
13
+
}
14
+
15
+
// New creates a Renderer from a glob pattern (e.g. "templates/*.html").
16
+
func New(pattern string) (*Renderer, error) {
17
+
tmpl, err := template.ParseGlob(pattern)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
return &Renderer{templates: tmpl}, nil
22
+
}
23
+
24
+
// Execute renders the named template with the provided data to the ResponseWriter.
25
+
func (r *Renderer) Execute(w http.ResponseWriter, name string, data any) {
26
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
27
+
if err := r.templates.ExecuteTemplate(w, name, data); err != nil {
28
+
log.Printf("render %s: %v", name, err)
29
+
w.WriteHeader(http.StatusInternalServerError)
30
+
_, _ = w.Write([]byte("Template error"))
31
+
}
32
+
}
+57
server/session/session.go
+57
server/session/session.go
···
1
+
package session
2
+
3
+
import "sync"
4
+
5
+
// Session represents a minimal legacy session persisted via cookie for
6
+
// non-OAuth flows and for compatibility with older endpoints.
7
+
type Session struct {
8
+
DID string `json:"did"`
9
+
Handle string `json:"handle"`
10
+
AccessJWT string `json:"accessJwt,omitempty"`
11
+
RefreshJWT string `json:"refreshJwt,omitempty"`
12
+
}
13
+
14
+
// Store wraps the legacy session map with a mutex to provide safe concurrent
15
+
// access. It retains the in-memory behaviour used previously in main.go.
16
+
type Store struct {
17
+
mu sync.RWMutex
18
+
data map[string]Session
19
+
}
20
+
21
+
// NewStore returns an initialised Store ready for use.
22
+
func NewStore() *Store {
23
+
return &Store{data: make(map[string]Session)}
24
+
}
25
+
26
+
// Get returns the session associated with id, if present.
27
+
func (s *Store) Get(id string) (Session, bool) {
28
+
s.mu.RLock()
29
+
defer s.mu.RUnlock()
30
+
val, ok := s.data[id]
31
+
return val, ok
32
+
}
33
+
34
+
// Set stores the session for the given id, replacing any previous entry.
35
+
func (s *Store) Set(id string, sess Session) {
36
+
s.mu.Lock()
37
+
s.data[id] = sess
38
+
s.mu.Unlock()
39
+
}
40
+
41
+
// Delete removes any session associated with id.
42
+
func (s *Store) Delete(id string) {
43
+
s.mu.Lock()
44
+
delete(s.data, id)
45
+
s.mu.Unlock()
46
+
}
47
+
48
+
// Keys exposes a snapshot of the current session IDs for debugging/tests.
49
+
func (s *Store) Keys() []string {
50
+
s.mu.RLock()
51
+
defer s.mu.RUnlock()
52
+
keys := make([]string, 0, len(s.data))
53
+
for k := range s.data {
54
+
keys = append(keys, k)
55
+
}
56
+
return keys
57
+
}