Tap is a proof-of-concept editor for screenplays formatted in Fountain markup. It stores all data in AT Protocol records.

Refactored to be more modular

Changed files
+805 -634
server
config
devstore
handlers
docs
pages
render
session
+1
server/TASK.md
··· 1 + - [ ] Modularize server entrypoint: extract routing, handlers, and templates
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }