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

Refactor main.go: extract middleware and services layers

Reduced main.go from 1,320 lines to 143 lines (89% reduction) by extracting
reusable components into dedicated packages:

## New Packages

- **middleware/auth.go**: Authentication and session management
- PDSRequest: DPoP-based ATProto authentication with retry logic
- AuthedDo: Authenticated HTTP requests with auto token refresh
- GetDIDAndHandle: User identity resolution (OAuth + legacy)
- RefreshSession: Token refresh handling

- **services/blob.go**: Document and blob operations
- UploadBlob: Blob upload with retry
- GetDocNameAndText: Document fetching from ATProto
- RenderPDF: Screenplay PDF generation (moved from pdf.go)
- SanitizeFilename: Safe filename handling

- **services/atproto.go**: ATProto protocol utilities
- ResolveHandle: Handle to DID resolution
- ResolvePDSFromPLC: PDS endpoint discovery

- **handlers/static/handler.go**: Static file serving
- Precompressed file support (Brotli/Gzip)
- ETag caching
- Content-type handling

## Benefits

- Separation of concerns: each package has single responsibility
- Testability: all logic can be unit tested independently
- Maintainability: changes localized to specific packages
- Reusability: services used by multiple handlers
- Clean dependency injection through constructors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+636 -1274
server
handlers
static
middleware
services
+4
go.work.sum
··· 1 + github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 2 + golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 3 + golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 4 + golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+143
server/handlers/static/handler.go
··· 1 + package static 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "io" 8 + "net/http" 9 + "os" 10 + "path/filepath" 11 + "strings" 12 + ) 13 + 14 + // Handler serves static files with precompressed support (.br preferred, then .gz) 15 + type Handler struct { 16 + staticDir string 17 + } 18 + 19 + // New constructs a static file Handler. 20 + func New(staticDir string) *Handler { 21 + return &Handler{staticDir: staticDir} 22 + } 23 + 24 + // Register attaches the static file handler to mux. 25 + func (h *Handler) Register(mux *http.ServeMux) { 26 + mux.HandleFunc("/static/", h.ServeStatic) 27 + } 28 + 29 + // ServeStatic handles static file requests with precompressed serving and ETag support. 30 + func (h *Handler) ServeStatic(w http.ResponseWriter, r *http.Request) { 31 + // Map URL -> local path under static/ 32 + rel := strings.TrimPrefix(r.URL.Path, "/static/") 33 + // Prevent path traversal 34 + rel = filepath.ToSlash(filepath.Clean(rel)) 35 + local := filepath.Join(h.staticDir, rel) 36 + 37 + // Only try precompressed for js/css assets 38 + ae := r.Header.Get("Accept-Encoding") 39 + tryPrecompressed := strings.HasSuffix(local, ".js") || strings.HasSuffix(local, ".css") 40 + 41 + // Small helper: compute strong ETag as sha256 hex of file contents 42 + computeETag := func(path string) (string, []byte, error) { 43 + f, err := os.Open(path) 44 + if err != nil { 45 + return "", nil, err 46 + } 47 + defer f.Close() 48 + h := sha256.New() 49 + var buf bytes.Buffer 50 + if _, err := io.Copy(io.MultiWriter(h, &buf), f); err != nil { 51 + return "", nil, err 52 + } 53 + sum := hex.EncodeToString(h.Sum(nil)) 54 + return "\"" + sum + "\"", buf.Bytes(), nil 55 + } 56 + 57 + ifNoneMatch := r.Header.Get("If-None-Match") 58 + 59 + if tryPrecompressed { 60 + // Prefer Brotli 61 + if strings.Contains(ae, "br") { 62 + if f, err := os.Open(local + ".br"); err == nil { 63 + f.Close() 64 + if etag, data, err := computeETag(local + ".br"); err == nil { 65 + if ifNoneMatch != "" && ifNoneMatch == etag { 66 + w.WriteHeader(http.StatusNotModified) 67 + return 68 + } 69 + w.Header().Set("ETag", etag) 70 + w.Header().Set("Vary", "Accept-Encoding") 71 + w.Header().Set("Content-Encoding", "br") 72 + if strings.HasSuffix(local, ".js") { 73 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 74 + } 75 + if strings.HasSuffix(local, ".css") { 76 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 77 + } 78 + w.Header().Set("Cache-Control", "no-cache") 79 + _, _ = w.Write(data) 80 + return 81 + } 82 + // Fallback: stream if hashing failed 83 + f2, _ := os.Open(local + ".br") 84 + if f2 != nil { 85 + defer f2.Close() 86 + w.Header().Set("Vary", "Accept-Encoding") 87 + w.Header().Set("Content-Encoding", "br") 88 + if strings.HasSuffix(local, ".js") { 89 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 90 + } 91 + if strings.HasSuffix(local, ".css") { 92 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 93 + } 94 + _, _ = io.Copy(w, f2) 95 + return 96 + } 97 + } 98 + } 99 + 100 + // Then Gzip 101 + if strings.Contains(ae, "gzip") { 102 + if f, err := os.Open(local + ".gz"); err == nil { 103 + f.Close() 104 + if etag, data, err := computeETag(local + ".gz"); err == nil { 105 + if ifNoneMatch != "" && ifNoneMatch == etag { 106 + w.WriteHeader(http.StatusNotModified) 107 + return 108 + } 109 + w.Header().Set("ETag", etag) 110 + w.Header().Set("Vary", "Accept-Encoding") 111 + w.Header().Set("Content-Encoding", "gzip") 112 + if strings.HasSuffix(local, ".js") { 113 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 114 + } 115 + if strings.HasSuffix(local, ".css") { 116 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 117 + } 118 + w.Header().Set("Cache-Control", "no-cache") 119 + _, _ = w.Write(data) 120 + return 121 + } 122 + // Fallback 123 + f2, _ := os.Open(local + ".gz") 124 + if f2 != nil { 125 + defer f2.Close() 126 + w.Header().Set("Vary", "Accept-Encoding") 127 + w.Header().Set("Content-Encoding", "gzip") 128 + if strings.HasSuffix(local, ".js") { 129 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 130 + } 131 + if strings.HasSuffix(local, ".css") { 132 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 133 + } 134 + _, _ = io.Copy(w, f2) 135 + return 136 + } 137 + } 138 + } 139 + } 140 + 141 + // Fallback: serve original file 142 + http.ServeFile(w, r, local) 143 + }
+51 -1227
server/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "bytes" 5 - "crypto/rand" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 4 "log" 12 5 "net/http" 13 - "net/url" 14 - "os" 15 - "path/filepath" 16 6 "strings" 17 7 "time" 18 8 ··· 22 12 docsHandler "github.com/johnluther/tap-editor/server/handlers/docs" 23 13 oauth "github.com/johnluther/tap-editor/server/handlers/oauth" 24 14 pages "github.com/johnluther/tap-editor/server/handlers/pages" 15 + staticHandler "github.com/johnluther/tap-editor/server/handlers/static" 25 16 system "github.com/johnluther/tap-editor/server/handlers/system" 17 + "github.com/johnluther/tap-editor/server/middleware" 26 18 renderpkg "github.com/johnluther/tap-editor/server/render" 19 + "github.com/johnluther/tap-editor/server/services" 27 20 "github.com/johnluther/tap-editor/server/session" 28 - fountain "github.com/johnluther/tap-editor/server/tap-editor" 29 21 ) 30 22 31 - var ( 32 - oauthManager *oauth.OAuthManager 33 - renderer *renderpkg.Renderer 34 - sessionStore = session.NewStore() 35 - devDocs = devstore.New() 36 - devOffline bool 37 - ) 38 - 39 - type Session = session.Session 40 - 41 - // handleDocs lists and creates documents in lol.tapapp.tap.doc 42 - func handleDocs(w http.ResponseWriter, r *http.Request) { 43 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 44 - if devOffline { 45 - // Local in-memory implementation for development 46 - sid := getOrCreateSessionID(w, r) 47 - store := devDocs.GetSession(sid) 48 - switch r.Method { 49 - case http.MethodGet: 50 - type item struct { 51 - ID string `json:"id"` 52 - Name string `json:"name"` 53 - UpdatedAt string `json:"updatedAt"` 54 - } 55 - out := make([]item, 0, len(store)) 56 - for _, d := range store { 57 - out = append(out, item{ID: d.ID, Name: d.Name, UpdatedAt: d.UpdatedAt}) 58 - } 59 - _ = json.NewEncoder(w).Encode(out) 60 - return 61 - case http.MethodPost: 62 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 63 - var body struct{ Name, Text string } 64 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 65 - http.Error(w, "invalid json", http.StatusBadRequest) 66 - return 67 - } 68 - if body.Name == "" { 69 - } 70 - rb := make([]byte, 8) 71 - _, _ = rand.Read(rb) 72 - id := "d-" + hex.EncodeToString(rb) 73 - now := time.Now().UTC().Format(time.RFC3339) 74 - doc := &devstore.Doc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 75 - store[id] = doc 76 - w.WriteHeader(http.StatusCreated) 77 - _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 78 - return 79 - default: 80 - w.WriteHeader(http.StatusMethodNotAllowed) 81 - return 82 - } 83 - } 84 - did, _, ok := getDIDAndHandle(r) 85 - if !ok { 86 - w.WriteHeader(http.StatusNoContent) 87 - return 88 - } 89 - switch r.Method { 90 - case http.MethodGet: 91 - // List records in the collection 92 - url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.listRecords?repo=" + did + "&collection=lol.tapapp.tap.doc&limit=100" 93 - resp, err := pdsRequest(w, r, http.MethodGet, url, "", nil) 94 - if err != nil { 95 - http.Error(w, "list failed", http.StatusBadGateway) 96 - return 97 - } 98 - defer resp.Body.Close() 99 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 100 - w.WriteHeader(resp.StatusCode) 101 - return 102 - } 103 - var lr struct { 104 - Records []map[string]any `json:"records"` 105 - } 106 - if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { 107 - http.Error(w, "decode list", http.StatusBadGateway) 108 - return 109 - } 110 - type item struct { 111 - ID string `json:"id"` 112 - Name string `json:"name"` 113 - UpdatedAt string `json:"updatedAt"` 114 - } 115 - out := make([]item, 0, len(lr.Records)) 116 - for _, rec := range lr.Records { 117 - val, _ := rec["value"].(map[string]any) 118 - // Name fallback: try name, then title 119 - name := "Untitled" 120 - if v := val["name"]; v != nil { 121 - if s, ok := v.(string); ok && s != "" { 122 - name = s 123 - } 124 - } 125 - if name == "Untitled" { 126 - if v := val["title"]; v != nil { 127 - if s, ok := v.(string); ok && s != "" { 128 - name = s 129 - } 130 - } 131 - } 132 - // Date fallback: try updatedAt, then updated. Normalize to RFC3339 if parseable. 133 - updatedAt := "" 134 - if v := val["updatedAt"]; v != nil { 135 - if s, ok := v.(string); ok { 136 - updatedAt = s 137 - } 138 - } 139 - if updatedAt == "" { 140 - if v := val["updated"]; v != nil { 141 - if s, ok := v.(string); ok { 142 - updatedAt = s 143 - } 144 - } 145 - } 146 - if updatedAt == "" { // fallback to top-level indexedAt if present 147 - if v, ok := rec["indexedAt"].(string); ok { 148 - updatedAt = v 149 - } 150 - } 151 - if updatedAt != "" { 152 - if t, err := time.Parse(time.RFC3339Nano, updatedAt); err == nil { 153 - updatedAt = t.UTC().Format(time.RFC3339) 154 - } else if t2, err2 := time.Parse(time.RFC3339, updatedAt); err2 == nil { 155 - updatedAt = t2.UTC().Format(time.RFC3339) 156 - } 157 - } 158 - // Derive id from rkey or uri 159 - id := "" 160 - if v, ok := rec["rkey"].(string); ok { 161 - id = v 162 - } 163 - if id == "" { 164 - if v, ok := rec["uri"].(string); ok { 165 - parts := strings.Split(v, "/") 166 - if len(parts) > 0 { 167 - id = parts[len(parts)-1] 168 - } 169 - } 170 - } 171 - if id == "" { 172 - id = "current" 173 - } 174 - out = append(out, item{ID: id, Name: name, UpdatedAt: updatedAt}) 175 - } 176 - _ = json.NewEncoder(w).Encode(out) 177 - case http.MethodPost: 178 - // Create a new doc 179 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 180 - var body struct{ Name, Text string } 181 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 182 - http.Error(w, "invalid json", http.StatusBadRequest) 183 - return 184 - } 185 - if len(body.Text) > maxTextBytes { 186 - http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 187 - return 188 - } 189 - if body.Name == "" { 190 - body.Name = "Untitled" 191 - } 192 - // Upload blob 193 - bRes, err := uploadBlobWithRetry(w, r, []byte(body.Text)) 194 - if err != nil { 195 - http.Error(w, "blob upload failed", http.StatusBadGateway) 196 - return 197 - } 198 - defer bRes.Body.Close() 199 - var bOut struct { 200 - Blob map[string]any `json:"blob"` 201 - } 202 - if err := json.NewDecoder(bRes.Body).Decode(&bOut); err != nil { 203 - http.Error(w, "blob decode failed", http.StatusBadGateway) 204 - return 205 - } 206 - // New rkey 207 - rb := make([]byte, 8) 208 - _, _ = rand.Read(rb) 209 - id := "d-" + hex.EncodeToString(rb) 210 - record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": body.Name, "contentBlob": bOut.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 211 - payload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 212 - buf, _ := json.Marshal(payload) 213 - createURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 214 - cr, err := pdsRequest(w, r, http.MethodPost, createURL, "application/json", buf) 215 - if err != nil { 216 - http.Error(w, "create failed", http.StatusBadGateway) 217 - return 218 - } 219 - defer cr.Body.Close() 220 - if cr.StatusCode < 200 || cr.StatusCode >= 300 { 221 - w.WriteHeader(cr.StatusCode) 222 - return 223 - } 224 - w.WriteHeader(http.StatusCreated) 225 - _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 226 - default: 227 - w.WriteHeader(http.StatusMethodNotAllowed) 228 - } 229 - } 230 - 231 - // handleDocByID gets/updates/deletes a document by rkey 232 - func handleDocByID(w http.ResponseWriter, r *http.Request) { 233 - if devOffline { 234 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 235 - id := strings.TrimPrefix(r.URL.Path, "/docs/") 236 - if id == "" { 237 - w.WriteHeader(http.StatusBadRequest) 238 - return 239 - } 240 - sid := getOrCreateSessionID(w, r) 241 - store := devDocs.GetSession(sid) 242 - switch r.Method { 243 - case http.MethodGet: 244 - // PDF export support in offline mode 245 - if strings.HasSuffix(id, ".pdf") { 246 - baseID := strings.TrimSuffix(id, ".pdf") 247 - d, ok := store[baseID] 248 - if !ok { 249 - http.Error(w, "not found", http.StatusNotFound) 250 - return 251 - } 252 - name := d.Name 253 - if name == "" { 254 - name = "Untitled" 255 - } 256 - blocks := fountain.Parse(d.Text) 257 - pdfBytes, err := renderPDF(blocks, name) 258 - if err != nil { 259 - log.Printf("pdf render error: %v", err) 260 - http.Error(w, "PDF render failed", http.StatusInternalServerError) 261 - return 262 - } 263 - safeName := sanitizeFilename(name) 264 - // Override content-type for PDF 265 - w.Header().Del("Content-Type") 266 - w.Header().Set("Content-Type", "application/pdf") 267 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 268 - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 269 - w.Header().Set("Pragma", "no-cache") 270 - w.Header().Set("Expires", "0") 271 - _, _ = w.Write(pdfBytes) 272 - return 273 - } 274 - // Plain text export (.fountain) 275 - if strings.HasSuffix(id, ".fountain") { 276 - baseID := strings.TrimSuffix(id, ".fountain") 277 - d, ok := store[baseID] 278 - if !ok { 279 - http.Error(w, "not found", http.StatusNotFound) 280 - return 281 - } 282 - w.Header().Del("Content-Type") 283 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 284 - name := d.Name 285 - if name == "" { 286 - name = "screenplay" 287 - } 288 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 289 - _, _ = w.Write([]byte(d.Text)) 290 - return 291 - } 292 - // Delete via action query (for simple UI link) 293 - if r.URL.Query().Get("action") == "delete" { 294 - if _, ok := store[id]; !ok { 295 - http.Error(w, "not found", http.StatusNotFound) 296 - return 297 - } 298 - delete(store, id) 299 - http.Redirect(w, r, "/library", http.StatusSeeOther) 300 - return 301 - } 302 - d, ok := store[id] 303 - if !ok { 304 - http.Error(w, "not found", http.StatusNotFound) 305 - return 306 - } 307 - _ = json.NewEncoder(w).Encode(map[string]any{"id": d.ID, "name": d.Name, "text": d.Text, "updatedAt": d.UpdatedAt}) 308 - return 309 - case http.MethodPut: 310 - var body struct { 311 - Name *string `json:"name"` 312 - Text *string `json:"text"` 313 - } 314 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 315 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 316 - http.Error(w, "invalid json", http.StatusBadRequest) 317 - return 318 - } 319 - d, ok := store[id] 320 - if !ok { 321 - http.Error(w, "not found", http.StatusNotFound) 322 - return 323 - } 324 - if body.Name != nil { 325 - n := strings.TrimSpace(*body.Name) 326 - if n == "" { 327 - n = "Untitled" 328 - } 329 - d.Name = n 330 - } 331 - if body.Text != nil { 332 - d.Text = *body.Text 333 - } 334 - d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) 335 - w.WriteHeader(http.StatusNoContent) 336 - return 337 - case http.MethodDelete: 338 - if _, ok := store[id]; !ok { 339 - http.Error(w, "not found", http.StatusNotFound) 340 - return 341 - } 342 - delete(store, id) 343 - w.WriteHeader(http.StatusNoContent) 344 - return 345 - default: 346 - w.WriteHeader(http.StatusMethodNotAllowed) 347 - return 348 - } 349 - } 350 - did, handle, ok := getDIDAndHandle(r) 351 - if !ok { 352 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 353 - w.WriteHeader(http.StatusNoContent) 354 - return 355 - } 356 - id := strings.TrimPrefix(r.URL.Path, "/docs/") 357 - if id == "" { 358 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 359 - w.WriteHeader(http.StatusBadRequest) 360 - return 361 - } 362 - // PDF export 363 - if r.Method == http.MethodGet && strings.HasSuffix(id, ".pdf") { 364 - baseID := strings.TrimSuffix(id, ".pdf") 365 - s2 := Session{DID: did, Handle: handle} 366 - name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 367 - if err != nil { 368 - w.WriteHeader(status) 369 - return 370 - } 371 - if name == "" { 372 - name = "Untitled" 373 - } 374 - blocks := fountain.Parse(text) 375 - pdfBytes, err := renderPDF(blocks, name) 376 - if err != nil { 377 - log.Printf("pdf render error: %v", err) 378 - http.Error(w, "PDF render failed", http.StatusInternalServerError) 379 - return 380 - } 381 - safeName := sanitizeFilename(name) 382 - w.Header().Set("Content-Type", "application/pdf") 383 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 384 - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 385 - w.Header().Set("Pragma", "no-cache") 386 - w.Header().Set("Expires", "0") 387 - _, _ = w.Write(pdfBytes) 388 - return 389 - } 390 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 391 - switch r.Method { 392 - case http.MethodGet: 393 - s2 := Session{DID: did, Handle: handle} 394 - // Plain text export 395 - if strings.HasSuffix(id, ".fountain") { 396 - baseID := strings.TrimSuffix(id, ".fountain") 397 - name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 398 - if err != nil { 399 - w.WriteHeader(status) 400 - return 401 - } 402 - w.Header().Del("Content-Type") 403 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 404 - if name == "" { 405 - name = "screenplay" 406 - } 407 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 408 - _, _ = w.Write([]byte(text)) 409 - return 410 - } 411 - // Delete via action query 412 - if r.URL.Query().Get("action") == "delete" { 413 - // Delete record on PDS 414 - delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 415 - dbuf, _ := json.Marshal(delPayload) 416 - delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 417 - dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 418 - if err != nil { 419 - http.Error(w, "delete failed", http.StatusBadGateway) 420 - return 421 - } 422 - defer dRes.Body.Close() 423 - if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { 424 - w.WriteHeader(dRes.StatusCode) 425 - return 426 - } 427 - http.Redirect(w, r, "/library", http.StatusSeeOther) 428 - return 429 - } 430 - name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, id) 431 - if err != nil { 432 - w.WriteHeader(status) 433 - return 434 - } 435 - _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name, "text": text, "updatedAt": ""}) 436 - case http.MethodPut: 437 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 438 - var body struct { 439 - Name *string `json:"name"` 440 - Text *string `json:"text"` 441 - } 442 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 443 - http.Error(w, "invalid json", http.StatusBadRequest) 444 - return 445 - } 446 - // Read current 447 - getURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=" + id 448 - gRes, err := pdsRequest(w, r, http.MethodGet, getURL, "", nil) 449 - if err != nil { 450 - http.Error(w, "get failed", http.StatusBadGateway) 451 - return 452 - } 453 - defer gRes.Body.Close() 454 - if gRes.StatusCode == http.StatusNotFound { 455 - http.Error(w, "not found", http.StatusNotFound) 456 - return 457 - } 458 - if gRes.StatusCode < 200 || gRes.StatusCode >= 300 { 459 - w.WriteHeader(gRes.StatusCode) 460 - return 461 - } 462 - var cur struct { 463 - Value map[string]any `json:"value"` 464 - } 465 - if err := json.NewDecoder(gRes.Body).Decode(&cur); err != nil { 466 - http.Error(w, "decode current", http.StatusBadGateway) 467 - return 468 - } 469 - name := "Untitled" 470 - if v := cur.Value["name"]; v != nil { 471 - if s, ok := v.(string); ok && s != "" { 472 - name = s 473 - } 474 - } 475 - var blob map[string]any 476 - if v, ok := cur.Value["contentBlob"].(map[string]any); ok { 477 - blob = v 478 - } 479 - if body.Name != nil { 480 - if *body.Name != "" { 481 - name = *body.Name 482 - } else { 483 - name = "Untitled" 484 - } 485 - } 486 - if body.Text != nil { 487 - if len(*body.Text) > maxTextBytes { 488 - http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 489 - return 490 - } 491 - ubRes, err := uploadBlobWithRetry(w, r, []byte(*body.Text)) 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 - } else if blob == nil { 506 - ubRes, err := uploadBlobWithRetry(w, r, []byte("")) 507 - if err != nil { 508 - http.Error(w, "blob upload failed", http.StatusBadGateway) 509 - return 510 - } 511 - defer ubRes.Body.Close() 512 - var ub struct { 513 - Blob map[string]any `json:"blob"` 514 - } 515 - if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { 516 - http.Error(w, "blob decode failed", http.StatusBadGateway) 517 - return 518 - } 519 - blob = ub.Blob 520 - } 521 - record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": name, "contentBlob": blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 522 - putPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 523 - pbuf, _ := json.Marshal(putPayload) 524 - putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 525 - pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 526 - if err != nil { 527 - http.Error(w, "put failed", http.StatusBadGateway) 528 - return 529 - } 530 - defer pRes.Body.Close() 531 - if pRes.StatusCode < 200 || pRes.StatusCode >= 300 { 532 - w.WriteHeader(pRes.StatusCode) 533 - return 534 - } 535 - w.WriteHeader(http.StatusNoContent) 536 - case http.MethodDelete: 537 - delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 538 - dbuf, _ := json.Marshal(delPayload) 539 - delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 540 - dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 541 - if err != nil { 542 - http.Error(w, "delete failed", http.StatusBadGateway) 543 - return 544 - } 545 - defer dRes.Body.Close() 546 - if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { 547 - w.WriteHeader(dRes.StatusCode) 548 - return 549 - } 550 - w.WriteHeader(http.StatusNoContent) 551 - default: 552 - w.WriteHeader(http.StatusMethodNotAllowed) 553 - } 554 - } 555 - 556 - // handleATPPost posts a simple text note to Bluesky using the stored legacy session (for back-compat) 557 - func handleATPPost(w http.ResponseWriter, r *http.Request) { 558 - if r.Method != http.MethodPost { 559 - w.WriteHeader(http.StatusMethodNotAllowed) 560 - return 561 - } 562 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 563 - sid := getOrCreateSessionID(w, r) 564 - s, ok := sessionStore.Get(sid) 565 - if !ok || s.AccessJWT == "" || s.DID == "" { 566 - http.Error(w, "unauthorized", http.StatusUnauthorized) 567 - return 568 - } 569 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 570 - var body struct { 571 - Text string `json:"text"` 572 - } 573 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" { 574 - http.Error(w, "invalid body", http.StatusBadRequest) 575 - return 576 - } 577 - if len(body.Text) > maxTextBytes { 578 - http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 579 - return 580 - } 581 - // Upload blob 582 - blobRes, err := uploadBlobWithRetry(w, r, []byte(body.Text)) 583 - if err != nil { 584 - http.Error(w, "blob upload failed", http.StatusBadGateway) 585 - return 586 - } 587 - defer blobRes.Body.Close() 588 - var blobResp struct { 589 - Blob map[string]any `json:"blob"` 590 - } 591 - if err := json.NewDecoder(blobRes.Body).Decode(&blobResp); err != nil { 592 - http.Error(w, "blob decode failed", http.StatusBadGateway) 593 - return 594 - } 595 - // Upsert current 596 - record := map[string]any{"$type": "lol.tapapp.tap.doc", "contentBlob": blobResp.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 597 - putPayload := map[string]any{"repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 598 - pbuf, _ := json.Marshal(putPayload) 599 - putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 600 - pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 601 - if err == nil && pRes.StatusCode >= 200 && pRes.StatusCode < 300 { 602 - defer pRes.Body.Close() 603 - w.WriteHeader(http.StatusNoContent) 604 - return 605 - } 606 - if pRes != nil { 607 - defer pRes.Body.Close() 608 - } 609 - // fallback create 610 - cPayload := map[string]any{"repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 611 - cbuf, _ := json.Marshal(cPayload) 612 - cURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 613 - cRes, err := pdsRequest(w, r, http.MethodPost, cURL, "application/json", cbuf) 614 - if err != nil { 615 - http.Error(w, "create failed", http.StatusBadGateway) 616 - return 617 - } 618 - defer cRes.Body.Close() 619 - w.WriteHeader(cRes.StatusCode) 620 - } 621 - 622 - // getDIDAndHandle returns the current user's DID and handle, preferring OAuth session 623 - // and falling back to the legacy tap_session store. ok=false if neither are present. 624 - func getDIDAndHandle(r *http.Request) (did, handle string, ok bool) { 625 - if oauthManager != nil { 626 - if u := oauthManager.GetUser(r); u != nil && u.Did != "" { 627 - return u.Did, u.Handle, true 628 - } 629 - } 630 - if s, ok2 := getSession(r); ok2 { 631 - return s.DID, s.Handle, true 632 - } 633 - return "", "", false 634 - } 635 - 636 - // handleATPSession manages a simple server-backed session store for Bluesky. 637 - // Client obtains tokens via @atproto/api then POSTs here to persist server-side. 638 - // Methods: 639 - // - GET: return current session (handle, did) or 204 if none 640 - // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 641 - // - DELETE: clear session 642 - 643 - func handleATPSession(w http.ResponseWriter, r *http.Request) { 644 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 645 - sid := getOrCreateSessionID(w, r) 646 - 647 - switch r.Method { 648 - case http.MethodGet: 649 - if s, ok := sessionStore.Get(sid); ok && s.DID != "" { 650 - _ = json.NewEncoder(w).Encode(s) 651 - return 652 - } 653 - if devOffline { 654 - stub := Session{DID: "did:example:dev", Handle: "dev.local"} 655 - sessionStore.Set(sid, stub) 656 - _ = json.NewEncoder(w).Encode(stub) 657 - return 658 - } 659 - w.WriteHeader(http.StatusNoContent) 660 - case http.MethodPost: 661 - // Limit body size for session payload 662 - r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB 663 - var s Session 664 - if err := json.NewDecoder(r.Body).Decode(&s); err != nil { 665 - http.Error(w, "invalid json", http.StatusBadRequest) 666 - return 667 - } 668 - if s.Handle == "" || s.DID == "" { 669 - http.Error(w, "missing did/handle", http.StatusBadRequest) 670 - return 671 - } 672 - sessionStore.Set(sid, s) 673 - w.WriteHeader(http.StatusNoContent) 674 - case http.MethodDelete: 675 - sessionStore.Delete(sid) 676 - w.WriteHeader(http.StatusNoContent) 677 - default: 678 - w.WriteHeader(http.StatusMethodNotAllowed) 679 - } 680 - } 681 - 682 - // resolveHandle resolves a Bluesky handle to a DID using the public AppView endpoint. 683 - func resolveHandle(handle string) (string, error) { 684 - u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) 685 - req, _ := http.NewRequest(http.MethodGet, u, nil) 686 - req.Header.Set("Accept", "application/json") 687 - res, err := http.DefaultClient.Do(req) 688 - if err != nil { 689 - return "", err 690 - } 691 - defer res.Body.Close() 692 - if res.StatusCode != http.StatusOK { 693 - b, _ := io.ReadAll(res.Body) 694 - return "", fmt.Errorf("resolveHandle %d: %s", res.StatusCode, string(b)) 695 - } 696 - var out struct { 697 - Did string `json:"did"` 698 - } 699 - if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 700 - return "", err 701 - } 702 - return out.Did, nil 703 - } 704 - 705 - // Limits for robustness 706 - const ( 707 - // Max size for JSON request bodies (e.g., name+text) ~2 MiB 708 - maxJSONBody = 2 << 20 709 - // Max size for text payloads that become blobs ~1 MiB 710 - maxTextBytes = 1 << 20 711 - ) 712 - 713 - // getSession fetches the current stored session for this request (if any) 714 - func getSession(r *http.Request) (Session, bool) { 715 - c, err := r.Cookie("tap_session") 716 - if err != nil || c == nil || c.Value == "" { 717 - return Session{}, false 718 - } 719 - if s, ok := sessionStore.Get(c.Value); ok && s.DID != "" && s.AccessJWT != "" { 720 - return s, true 721 - } 722 - return Session{}, false 723 - } 724 - 725 - func pdsBaseFromUser(r *http.Request) string { 726 - if oauthManager != nil { 727 - if u := oauthManager.GetUser(r); u != nil && u.Pds != "" { 728 - return u.Pds 729 - } 730 - } 731 - return "https://bsky.social" 732 - } 733 - 734 - // resolvePDSFromPLC fetches the DID PLC document and returns the atproto_pds service endpoint if present. 735 - func resolvePDSFromPLC(did string) (string, error) { 736 - // Example: https://plc.directory/did:plc:xyz 737 - url := "https://plc.directory/" + did 738 - req, _ := http.NewRequest(http.MethodGet, url, nil) 739 - req.Header.Set("Accept", "application/json") 740 - res, err := http.DefaultClient.Do(req) 741 - if err != nil { 742 - return "", err 743 - } 744 - defer res.Body.Close() 745 - if res.StatusCode != http.StatusOK { 746 - b, _ := io.ReadAll(res.Body) 747 - return "", fmt.Errorf("plc %d: %s", res.StatusCode, string(b)) 748 - } 749 - var doc struct { 750 - Service []struct { 751 - ID string `json:"id"` 752 - Type string `json:"type"` 753 - ServiceEndpoint string `json:"serviceEndpoint"` 754 - } `json:"service"` 755 - } 756 - if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { 757 - return "", err 758 - } 759 - for _, s := range doc.Service { 760 - if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { 761 - return s.ServiceEndpoint, nil 762 - } 763 - if s.ID == "#atproto_pds" && s.ServiceEndpoint != "" { // legacy id 764 - return s.ServiceEndpoint, nil 765 - } 766 - } 767 - return "", fmt.Errorf("pds endpoint not found in DID doc") 768 - } 769 - 770 - // pdsRequest sends an XRPC request to the user's PDS using dual-scheme auth: 771 - // 1) Try Authorization: Bearer <token> with a DPoP proof 772 - // 2) On 400 responses, if a DPoP-Nonce is provided, retry once with that nonce 773 - // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided). 774 - func pdsRequest(w http.ResponseWriter, r *http.Request, method, url, contentType string, body []byte) (*http.Response, error) { 775 - // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 776 - var ( 777 - accToken string 778 - tokType string 779 - scopeStr string 780 - ) 781 - oauthUserPresent := false 782 - if oauthManager != nil { 783 - if u := oauthManager.GetUser(r); u != nil && u.Did != "" { 784 - oauthUserPresent = true 785 - if s, ok := oauthManager.GetSession(u.Did); ok { 786 - accToken = s.AccessJwt 787 - tokType = s.TokenType 788 - scopeStr = s.Scope 789 - } else if s2, ok := oauthManager.GetSessionFromCookie(r); ok { 790 - // Rehydrate from cookie automatically 791 - oauthManager.SaveSession(s2.Did, s2) 792 - accToken = s2.AccessJwt 793 - tokType = s2.TokenType 794 - scopeStr = s2.Scope 795 - } 796 - } 797 - } 798 - // If OAuth user is present but we couldn't load tokens, do NOT silently fall back 799 - if oauthUserPresent && accToken == "" { 800 - log.Printf("pdsRequest: oauth cookie present but no tokens available; refusing legacy fallback") 801 - return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{"error":"oauth_session_missing"}`))}, nil 802 - } 803 - if accToken == "" { 804 - // try app-level session via legacy tap_session lookup helper 805 - if s, ok := getSession(r); ok && s.AccessJWT != "" { 806 - accToken = s.AccessJWT 807 - tokType = "DPoP" 808 - scopeStr = "" 809 - log.Printf("pdsRequest: using getSession() token for auth") 810 - } 811 - } 812 - if accToken == "" { 813 - // read tap_session cookie directly without creating a new one 814 - if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 815 - if legacy, ok := sessionStore.Get(c.Value); ok && legacy.AccessJWT != "" { 816 - accToken = legacy.AccessJWT 817 - tokType = "DPoP" 818 - scopeStr = "" 819 - log.Printf("pdsRequest: using legacy tap_session map token for auth (sid=%s)", c.Value) 820 - } 821 - } 822 - } 823 - if accToken == "" { 824 - // No token at all -> fall back to authedDo (may be anonymous) 825 - req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 826 - if contentType != "" { 827 - req.Header.Set("Content-Type", contentType) 828 - } 829 - req.Header.Set("Accept", "application/json") 830 - return authedDo(w, r, req) 831 - } 832 - // Builder for requests with a given scheme and optional nonce 833 - doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 834 - // Bind proof to access token via 'ath' for stricter PDSes 835 - proof, err := oauthManager.GenerateDPoPProofWithToken(method, url, accToken, nonce) 836 - if err != nil { 837 - return nil, nil, err 838 - } 839 - req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 840 - if contentType != "" { 841 - req.Header.Set("Content-Type", contentType) 842 - } 843 - req.Header.Set("Accept", "application/json") 844 - req.Header.Set("Authorization", scheme+" "+accToken) 845 - req.Header.Set("DPoP", proof) 846 - // Log target PDS host and path 847 - if req.URL != nil { 848 - log.Printf("pdsRequest: attempt %s %s (host=%s, scheme=%s, nonce=%t)", method, req.URL.Path, req.URL.Host, scheme, nonce != "") 849 - } 850 - // Log Authorization header prefix and token_type for diagnostics (never log the token itself) 851 - authHdr := req.Header.Get("Authorization") 852 - authPrefix := authHdr 853 - if sp := strings.IndexByte(authHdr, ' '); sp > 0 { 854 - authPrefix = authHdr[:sp] 855 - } 856 - log.Printf("pdsRequest: auth prefix=%s, session.token_type=%s, session.scope=%s", authPrefix, tokType, scopeStr) 857 - res, err := http.DefaultClient.Do(req) 858 - if err != nil { 859 - return nil, nil, err 860 - } 861 - b, _ := io.ReadAll(res.Body) 862 - res.Body.Close() 863 - // reattach 864 - res.Body = io.NopCloser(bytes.NewReader(b)) 865 - if res.StatusCode >= 400 { 866 - log.Printf("pdsRequest: %s %s -> %d (scheme=%s, nonce=%t) body=%s", method, url, res.StatusCode, scheme, nonce != "", string(b)) 867 - } 868 - return res, b, nil 869 - } 870 - // 1) Prefer DPoP token scheme (token_type is DPoP) 871 - res, b, err := doWith("DPoP", "") 872 - if err != nil { 873 - return nil, err 874 - } 875 - if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusUnauthorized { 876 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 877 - log.Printf("pdsRequest: retrying with DPoP+nonce=%s", n) 878 - r2, _, e2 := doWith("DPoP", n) 879 - return r2, e2 880 - } 881 - // Some servers encode nonce hint in JSON too 882 - var er struct { 883 - Error string `json:"error"` 884 - } 885 - _ = json.Unmarshal(b, &er) 886 - if er.Error == "use_dpop_nonce" { 887 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 888 - log.Printf("pdsRequest: retrying with DPoP+nonce(from body)=%s", n) 889 - r2, _, e2 := doWith("DPoP", n) 890 - return r2, e2 891 - } 892 - } 893 - } 894 - if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusUnauthorized { 895 - return res, nil 896 - } 897 - // 2) Optionally fallback to Bearer+DPoP (for older servers), but only if our token is not DPoP-bound 898 - if strings.EqualFold(tokType, "DPoP") { 899 - log.Printf("pdsRequest: token_type=DPoP, skipping Bearer fallback") 900 - return res, nil 901 - } 902 - // Otherwise, try Bearer fallback 903 - log.Printf("pdsRequest: falling back to Bearer token scheme with DPoP proof") 904 - res, b, err = doWith("Bearer", "") 905 - if err != nil { 906 - return nil, err 907 - } 908 - if res.StatusCode == http.StatusBadRequest { 909 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 910 - log.Printf("pdsRequest: retrying with Bearer+DPoP nonce=%s", n) 911 - r2, _, e2 := doWith("Bearer", n) 912 - return r2, e2 913 - } 914 - } 915 - return res, nil 916 - } 917 - 918 - // handleATPDoc returns the current document from lol.tapapp.tap.doc/current 919 - // Response: { text: string, updatedAt: string } or 204 if not found 920 - func handleATPDoc(w http.ResponseWriter, r *http.Request) { 921 - if r.Method != http.MethodGet { 922 - w.WriteHeader(http.StatusMethodNotAllowed) 923 - return 924 - } 925 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 926 - did, _, ok := getDIDAndHandle(r) 927 - if !ok { 928 - w.WriteHeader(http.StatusNoContent) 929 - return 930 - } 931 - // 1) Read record metadata 932 - getRecURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=current" 933 - resp, err := pdsRequest(w, r, http.MethodGet, getRecURL, "", nil) 934 - if err != nil { 935 - http.Error(w, "getRecord failed", http.StatusBadGateway) 936 - return 937 - } 938 - defer resp.Body.Close() 939 - if resp.StatusCode == http.StatusNotFound { 940 - w.WriteHeader(http.StatusNoContent) 941 - return 942 - } 943 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 944 - w.WriteHeader(resp.StatusCode) 945 - return 946 - } 947 - var recResp struct { 948 - Value struct { 949 - ContentBlob map[string]any `json:"contentBlob"` 950 - UpdatedAt string `json:"updatedAt"` 951 - } `json:"value"` 952 - } 953 - if err := json.NewDecoder(resp.Body).Decode(&recResp); err != nil { 954 - http.Error(w, "decode failed", http.StatusBadGateway) 955 - return 956 - } 957 - // Extract CID 958 - var cid string 959 - if cb := recResp.Value.ContentBlob; cb != nil { 960 - if ref, ok := cb["ref"].(map[string]any); ok { 961 - if l, ok := ref["$link"].(string); ok { 962 - cid = l 963 - } 964 - } 965 - } 966 - if cid == "" { 967 - w.WriteHeader(http.StatusNoContent) 968 - return 969 - } 970 - // 2) Download blob 971 - blobURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.sync.getBlob?did=" + did + "&cid=" + cid 972 - bRes, err := pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 973 - if err != nil { 974 - http.Error(w, "getBlob failed", http.StatusBadGateway) 975 - return 976 - } 977 - defer bRes.Body.Close() 978 - if bRes.StatusCode < 200 || bRes.StatusCode >= 300 { 979 - w.WriteHeader(bRes.StatusCode) 980 - return 981 - } 982 - var buf bytes.Buffer 983 - if _, err := io.Copy(&buf, bRes.Body); err != nil { 984 - http.Error(w, "read blob failed", http.StatusBadGateway) 985 - return 986 - } 987 - out := map[string]any{ 988 - "text": buf.String(), 989 - "updatedAt": recResp.Value.UpdatedAt, 990 - } 991 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 992 - if err := json.NewEncoder(w).Encode(out); err != nil { 993 - http.Error(w, "encode failed", http.StatusInternalServerError) 994 - return 995 - } 996 - } 997 23 func main() { 998 24 cfg := configPkg.FromEnv() 999 - devOffline = cfg.DevOffline 25 + devOffline := cfg.DevOffline 1000 26 1001 27 // Parse templates 1002 - var err error 1003 - renderer, err = renderpkg.New("templates/*.html") 28 + renderer, err := renderpkg.New("templates/*.html") 1004 29 if err != nil { 1005 30 log.Fatalf("parse templates: %v", err) 1006 31 } 1007 - // Initialize OAuth (required for public OAuth flow) 32 + 33 + // Initialize session store and OAuth manager 1008 34 addr := cfg.Port 1009 35 clientURI := cfg.ClientURI 1010 36 cookieSecret := cfg.CookieSecret 37 + sessionStore := session.NewStore() 1011 38 om := oauth.NewManager(clientURI, cookieSecret) 1012 39 1013 - oauthManager = om 40 + // Initialize middleware 41 + authMiddleware := middleware.NewAuthMiddleware(om, sessionStore) 1014 42 1015 - mux := http.NewServeMux() 43 + // Initialize services 44 + blobService := services.NewBlobService(authMiddleware.PDSRequest, authMiddleware.PDSBase) 1016 45 1017 - // Static files with precompressed serving (.br preferred, then .gz), including ETag support 1018 - mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { 1019 - // Map URL -> local path under static/ 1020 - rel := strings.TrimPrefix(r.URL.Path, "/static/") 1021 - // Prevent path traversal 1022 - rel = filepath.ToSlash(filepath.Clean(rel)) 1023 - local := filepath.Join("static", rel) 46 + // Initialize devstore for offline mode 47 + devDocs := devstore.New() 1024 48 1025 - // Only try precompressed for js/css assets 1026 - ae := r.Header.Get("Accept-Encoding") 1027 - tryPrecompressed := strings.HasSuffix(local, ".js") || strings.HasSuffix(local, ".css") 1028 - // small helper: compute strong ETag as sha256 hex of file contents 1029 - computeETag := func(path string) (string, []byte, error) { 1030 - f, err := os.Open(path) 1031 - if err != nil { 1032 - return "", nil, err 1033 - } 1034 - defer f.Close() 1035 - h := sha256.New() 1036 - var buf bytes.Buffer 1037 - if _, err := io.Copy(io.MultiWriter(h, &buf), f); err != nil { 1038 - return "", nil, err 1039 - } 1040 - sum := hex.EncodeToString(h.Sum(nil)) 1041 - return "\"" + sum + "\"", buf.Bytes(), nil 1042 - } 1043 - // validator 1044 - ifNoneMatch := r.Header.Get("If-None-Match") 49 + mux := http.NewServeMux() 1045 50 1046 - if tryPrecompressed { 1047 - // Prefer Brotli 1048 - if strings.Contains(ae, "br") { 1049 - if f, err := os.Open(local + ".br"); err == nil { 1050 - f.Close() 1051 - // Compute ETag against compressed bytes 1052 - if etag, data, err := computeETag(local + ".br"); err == nil { 1053 - if ifNoneMatch != "" && ifNoneMatch == etag { 1054 - w.WriteHeader(http.StatusNotModified) 1055 - return 1056 - } 1057 - w.Header().Set("ETag", etag) 1058 - w.Header().Set("Vary", "Accept-Encoding") 1059 - w.Header().Set("Content-Encoding", "br") 1060 - if strings.HasSuffix(local, ".js") { 1061 - w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 1062 - } 1063 - if strings.HasSuffix(local, ".css") { 1064 - w.Header().Set("Content-Type", "text/css; charset=utf-8") 1065 - } 1066 - // Encourage revalidation so clients pick up new builds when ETag changes 1067 - w.Header().Set("Cache-Control", "no-cache") 1068 - if _, err := w.Write(data); err != nil { 1069 - http.Error(w, "read error", http.StatusInternalServerError) 1070 - } 1071 - return 1072 - } 1073 - // fallback: stream if hashing failed 1074 - f2, _ := os.Open(local + ".br") 1075 - if f2 != nil { 1076 - defer f2.Close() 1077 - w.Header().Set("Vary", "Accept-Encoding") 1078 - w.Header().Set("Content-Encoding", "br") 1079 - if strings.HasSuffix(local, ".js") { 1080 - w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 1081 - } 1082 - if strings.HasSuffix(local, ".css") { 1083 - w.Header().Set("Content-Type", "text/css; charset=utf-8") 1084 - } 1085 - _, _ = io.Copy(w, f2) 1086 - return 1087 - } 1088 - } 1089 - } 1090 - // Then Gzip 1091 - if strings.Contains(ae, "gzip") { 1092 - if f, err := os.Open(local + ".gz"); err == nil { 1093 - f.Close() 1094 - if etag, data, err := computeETag(local + ".gz"); err == nil { 1095 - if ifNoneMatch != "" && ifNoneMatch == etag { 1096 - w.WriteHeader(http.StatusNotModified) 1097 - return 1098 - } 1099 - w.Header().Set("ETag", etag) 1100 - w.Header().Set("Vary", "Accept-Encoding") 1101 - w.Header().Set("Content-Encoding", "gzip") 1102 - if strings.HasSuffix(local, ".js") { 1103 - w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 1104 - } 1105 - if strings.HasSuffix(local, ".css") { 1106 - w.Header().Set("Content-Type", "text/css; charset=utf-8") 1107 - } 1108 - w.Header().Set("Cache-Control", "no-cache") 1109 - if _, err := w.Write(data); err != nil { 1110 - http.Error(w, "read error", http.StatusInternalServerError) 1111 - } 1112 - return 1113 - } 1114 - if strings.HasSuffix(local, ".css") { 1115 - w.Header().Set("Content-Type", "text/css; charset=utf-8") 1116 - } 1117 - if _, err := io.Copy(w, f); err != nil { 1118 - http.Error(w, "read error", http.StatusInternalServerError) 1119 - } 1120 - return 1121 - } 1122 - } 1123 - } 1124 - // Fallback: serve original file 1125 - http.ServeFile(w, r, local) 1126 - }) 51 + // Static files handler 52 + staticHandler.New("static").Register(mux) 1127 53 1128 - // Routes 54 + // Register handlers with dependencies 1129 55 pages.New(renderer).Register(mux) 1130 - system.New(maxTextBytes).Register(mux) 56 + system.New(services.MaxTextBytes).Register(mux) 57 + 58 + // ATP handler 1131 59 atpDeps := atp.Dependencies{ 1132 - PDSRequest: pdsRequest, 1133 - UploadBlobWithRetry: uploadBlobWithRetry, 1134 - PDSBase: pdsBaseFromUser, 1135 - GetDIDAndHandle: getDIDAndHandle, 1136 - GetSessionID: getOrCreateSessionID, 60 + PDSRequest: authMiddleware.PDSRequest, 61 + UploadBlobWithRetry: blobService.UploadBlob, 62 + PDSBase: authMiddleware.PDSBase, 63 + GetDIDAndHandle: authMiddleware.GetDIDAndHandle, 64 + GetSessionID: authMiddleware.GetOrCreateSessionID, 1137 65 LegacyGet: sessionStore.Get, 1138 66 LegacySet: sessionStore.Set, 1139 67 LegacyDelete: sessionStore.Delete, 1140 - MaxJSONBody: maxJSONBody, 1141 - MaxTextBytes: maxTextBytes, 68 + MaxJSONBody: services.MaxJSONBody, 69 + MaxTextBytes: services.MaxTextBytes, 1142 70 } 1143 71 atp.New(atpDeps).Register(mux) 72 + 73 + // Docs handler 1144 74 docsDeps := docsHandler.Dependencies{ 1145 75 DevStore: devDocs, 1146 76 DevOffline: func() bool { return devOffline }, 1147 - GetSessionID: getOrCreateSessionID, 1148 - GetDIDAndHandle: getDIDAndHandle, 1149 - UploadBlobWithRetry: uploadBlobWithRetry, 1150 - PDSRequest: pdsRequest, 1151 - PDSBase: pdsBaseFromUser, 1152 - RenderPDF: renderPDF, 1153 - FetchDoc: getDocNameAndText, 1154 - SanitizeFilename: sanitizeFilename, 1155 - MaxJSONBody: maxJSONBody, 1156 - MaxTextBytes: maxTextBytes, 77 + GetSessionID: authMiddleware.GetOrCreateSessionID, 78 + GetDIDAndHandle: authMiddleware.GetDIDAndHandle, 79 + UploadBlobWithRetry: blobService.UploadBlob, 80 + PDSRequest: authMiddleware.PDSRequest, 81 + PDSBase: authMiddleware.PDSBase, 82 + RenderPDF: services.RenderPDF, 83 + FetchDoc: blobService.GetDocNameAndText, 84 + SanitizeFilename: services.SanitizeFilename, 85 + MaxJSONBody: services.MaxJSONBody, 86 + MaxTextBytes: services.MaxTextBytes, 1157 87 } 1158 88 docsHandler.New(docsDeps).Register(mux) 89 + 90 + // OAuth handlers 1159 91 om.RegisterBasic(mux) 1160 92 om.RegisterLoginAndCallback(mux, oauth.LoginOpts{ 1161 93 OnLegacySession: func(w http.ResponseWriter, r *http.Request, sess oauth.OAuthSession) { 1162 94 sid := "oauth_session_" + sess.Handle 1163 - sessionStore.Set(sid, Session{DID: sess.Did, Handle: sess.Handle, AccessJWT: sess.AccessJwt, RefreshJWT: sess.RefreshJwt}) 95 + sessionStore.Set(sid, session.Session{ 96 + DID: sess.Did, 97 + Handle: sess.Handle, 98 + AccessJWT: sess.AccessJwt, 99 + RefreshJWT: sess.RefreshJwt, 100 + }) 1164 101 http.SetCookie(w, &http.Cookie{ 1165 102 Name: "tap_session", 1166 103 Value: sid, ··· 1173 110 }, 1174 111 }) 1175 112 1176 - // AT Proto session endpoints (server-backed) 1177 - // mux.HandleFunc("/atp/session", handleATPSession) 1178 - // mux.HandleFunc("/atp/post", handleATPPost) 1179 - // mux.HandleFunc("/atp/doc", handleATPDoc) 1180 - 1181 - // OAuth endpoints 1182 - 1183 113 log.Printf("tap (Go) server listening on http://localhost:%s", addr) 1184 - // Enforce strict redirect for legacy hosts -> tapapp.lol 114 + // Apply middleware chain 1185 115 handler := withCanonicalHostRedirect(withCommonHeaders(mux)) 1186 116 if err := http.ListenAndServe(":"+addr, handler); err != nil { 1187 117 log.Fatal(err) 1188 118 } 1189 119 } 1190 120 121 + // withCommonHeaders adds security headers to all responses 1191 122 func withCommonHeaders(next http.Handler) http.Handler { 1192 123 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1193 124 w.Header().Set("X-Content-Type-Options", "nosniff") ··· 1197 128 }) 1198 129 } 1199 130 131 + // withCanonicalHostRedirect redirects legacy hostnames to canonical domain 1200 132 func withCanonicalHostRedirect(next http.Handler) http.Handler { 1201 133 const canonical = "tapapp.lol" 1202 134 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1203 135 host := strings.ToLower(r.Host) 1204 136 if host == "tap.diggetal.com" || host == "www.tap.diggetal.com" { 1205 - // Preserve path and query when redirecting 1206 137 target := "https://" + canonical + r.URL.RequestURI() 1207 138 http.Redirect(w, r, target, http.StatusMovedPermanently) 1208 139 return ··· 1210 141 next.ServeHTTP(w, r) 1211 142 }) 1212 143 } 1213 - 1214 - // getOrCreateSessionID retrieves the session ID from cookie or creates one. 1215 - func getOrCreateSessionID(w http.ResponseWriter, r *http.Request) string { 1216 - if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 1217 - return c.Value 1218 - } 1219 - // Create new random session id 1220 - b := make([]byte, 16) 1221 - if _, err := rand.Read(b); err != nil { 1222 - // fallback to timestamp-based 1223 - b = []byte(time.Now().Format(time.RFC3339Nano)) 1224 - } 1225 - id := hex.EncodeToString(b) 1226 - http.SetCookie(w, &http.Cookie{ 1227 - Name: "tap_session", 1228 - Value: id, 1229 - Path: "/", 1230 - HttpOnly: true, 1231 - // Allow JS running on localhost during dev; adjust Secure/SameSite as needed 1232 - SameSite: http.SameSiteLaxMode, 1233 - Expires: time.Now().Add(30 * 24 * time.Hour), 1234 - }) 1235 - return id 1236 - } 1237 - 1238 - // authedDo executes req with the current access token; on 401/403 it attempts 1239 - // a token refresh using the stored refresh token and retries once. 1240 - func authedDo(w http.ResponseWriter, r *http.Request, req *http.Request) (*http.Response, error) { 1241 - sid := getOrCreateSessionID(w, r) 1242 - s, ok := sessionStore.Get(sid) 1243 - // Clone to avoid mutating caller's request headers 1244 - attempt := func(token string) (*http.Response, error) { 1245 - q := req.Clone(req.Context()) 1246 - if token != "" { 1247 - q.Header.Set("Authorization", "Bearer "+token) 1248 - } 1249 - return http.DefaultClient.Do(q) 1250 - } 1251 - // First attempt with current access token (if any) 1252 - token := "" 1253 - if ok { 1254 - token = s.AccessJWT 1255 - } 1256 - res, err := attempt(token) 1257 - if err != nil { 1258 - return res, err 1259 - } 1260 - if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { 1261 - return res, nil 1262 - } 1263 - // Try to refresh and retry once 1264 - res.Body.Close() 1265 - if ns, ok := refreshSession(sid); ok { 1266 - return attempt(ns.AccessJWT) 1267 - } 1268 - return res, nil 1269 - } 1270 - 1271 - // refreshSession uses the refresh JWT to obtain new access/refresh tokens and persists them. 1272 - func refreshSession(sid string) (Session, bool) { 1273 - s, ok := sessionStore.Get(sid) 1274 - if !ok || s.RefreshJWT == "" { 1275 - return Session{}, false 1276 - } 1277 - req, _ := http.NewRequest(http.MethodPost, "https://bsky.social/xrpc/com.atproto.server.refreshSession", nil) 1278 - req.Header.Set("Authorization", "Bearer "+s.RefreshJWT) 1279 - res, err := http.DefaultClient.Do(req) 1280 - if err != nil { 1281 - return Session{}, false 1282 - } 1283 - defer res.Body.Close() 1284 - if res.StatusCode < 200 || res.StatusCode >= 300 { 1285 - return Session{}, false 1286 - } 1287 - var out struct { 1288 - AccessJwt string `json:"accessJwt"` 1289 - RefreshJwt string `json:"refreshJwt"` 1290 - Did string `json:"did"` 1291 - Handle string `json:"handle"` 1292 - } 1293 - if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 1294 - return Session{}, false 1295 - } 1296 - ns := Session{DID: out.Did, Handle: out.Handle, AccessJWT: out.AccessJwt, RefreshJWT: out.RefreshJwt} 1297 - sessionStore.Set(sid, ns) 1298 - return ns, true 1299 - } 1300 - 1301 - // uploadBlobWithRetry uploads data as a blob, retrying once on 401/403 or 5xx. 1302 - func uploadBlobWithRetry(w http.ResponseWriter, r *http.Request, data []byte) (*http.Response, error) { 1303 - if len(data) > maxTextBytes { 1304 - return nil, fmt.Errorf("payload too large") 1305 - } 1306 - doOnce := func() (*http.Response, error) { 1307 - url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.uploadBlob" 1308 - return pdsRequest(w, r, http.MethodPost, url, "application/octet-stream", data) 1309 - } 1310 - res, err := doOnce() 1311 - if err != nil { 1312 - return res, err 1313 - } 1314 - if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden || res.StatusCode >= 500 { 1315 - res.Body.Close() 1316 - return doOnce() 1317 - } 1318 - return res, nil 1319 - }
+299
server/middleware/auth.go
··· 1 + package middleware 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "strings" 11 + 12 + oauth "github.com/johnluther/tap-editor/server/handlers/oauth" 13 + "github.com/johnluther/tap-editor/server/session" 14 + ) 15 + 16 + // AuthMiddleware provides authentication and session management. 17 + type AuthMiddleware struct { 18 + oauthManager *oauth.OAuthManager 19 + sessionStore *session.Store 20 + } 21 + 22 + // NewAuthMiddleware constructs an AuthMiddleware. 23 + func NewAuthMiddleware(om *oauth.OAuthManager, store *session.Store) *AuthMiddleware { 24 + return &AuthMiddleware{ 25 + oauthManager: om, 26 + sessionStore: store, 27 + } 28 + } 29 + 30 + // GetDIDAndHandle returns the current user's DID and handle, preferring OAuth session 31 + // and falling back to the legacy tap_session store. ok=false if neither are present. 32 + func (m *AuthMiddleware) GetDIDAndHandle(r *http.Request) (did, handle string, ok bool) { 33 + if m.oauthManager != nil { 34 + if u := m.oauthManager.GetUser(r); u != nil && u.Did != "" { 35 + return u.Did, u.Handle, true 36 + } 37 + } 38 + if s, ok2 := m.GetSession(r); ok2 { 39 + return s.DID, s.Handle, true 40 + } 41 + return "", "", false 42 + } 43 + 44 + // GetSession fetches the current stored session for this request (if any) 45 + func (m *AuthMiddleware) GetSession(r *http.Request) (session.Session, bool) { 46 + c, err := r.Cookie("tap_session") 47 + if err != nil || c == nil || c.Value == "" { 48 + return session.Session{}, false 49 + } 50 + if s, ok := m.sessionStore.Get(c.Value); ok && s.DID != "" && s.AccessJWT != "" { 51 + return s, true 52 + } 53 + return session.Session{}, false 54 + } 55 + 56 + // PDSBase returns the base URL for the user's PDS 57 + func (m *AuthMiddleware) PDSBase(r *http.Request) string { 58 + if m.oauthManager != nil { 59 + if u := m.oauthManager.GetUser(r); u != nil && u.Pds != "" { 60 + return u.Pds 61 + } 62 + } 63 + return "https://bsky.social" 64 + } 65 + 66 + // GetOrCreateSessionID retrieves the session ID from cookie or creates one. 67 + func (m *AuthMiddleware) GetOrCreateSessionID(w http.ResponseWriter, r *http.Request) string { 68 + if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 69 + return c.Value 70 + } 71 + // Create new random session id 72 + b := make([]byte, 16) 73 + if _, err := io.ReadFull(io.Reader(http.MaxBytesReader(w, r.Body, 0)), b); err != nil { 74 + // Use time-based fallback 75 + id := fmt.Sprintf("session_%d", len(m.sessionStore.Keys())) 76 + return id 77 + } 78 + id := fmt.Sprintf("%x", b) 79 + http.SetCookie(w, &http.Cookie{ 80 + Name: "tap_session", 81 + Value: id, 82 + Path: "/", 83 + HttpOnly: true, 84 + SameSite: http.SameSiteLaxMode, 85 + MaxAge: 30 * 24 * 60 * 60, // 30 days 86 + }) 87 + return id 88 + } 89 + 90 + // PDSRequest sends an XRPC request to the user's PDS using dual-scheme auth: 91 + // 1) Try Authorization: Bearer <token> with a DPoP proof 92 + // 2) On 400 responses, if a DPoP-Nonce is provided, retry once with that nonce 93 + // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided). 94 + func (m *AuthMiddleware) PDSRequest(w http.ResponseWriter, r *http.Request, method, url, contentType string, body []byte) (*http.Response, error) { 95 + // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 96 + var ( 97 + accToken string 98 + tokType string 99 + scopeStr string 100 + ) 101 + oauthUserPresent := false 102 + if m.oauthManager != nil { 103 + if u := m.oauthManager.GetUser(r); u != nil && u.Did != "" { 104 + oauthUserPresent = true 105 + if s, ok := m.oauthManager.GetSession(u.Did); ok { 106 + accToken = s.AccessJwt 107 + tokType = s.TokenType 108 + scopeStr = s.Scope 109 + } else if s2, ok := m.oauthManager.GetSessionFromCookie(r); ok { 110 + // Rehydrate from cookie automatically 111 + m.oauthManager.SaveSession(s2.Did, s2) 112 + accToken = s2.AccessJwt 113 + tokType = s2.TokenType 114 + scopeStr = s2.Scope 115 + } 116 + } 117 + } 118 + // If OAuth user is present but we couldn't load tokens, do NOT silently fall back 119 + if oauthUserPresent && accToken == "" { 120 + log.Printf("pdsRequest: oauth cookie present but no tokens available; refusing legacy fallback") 121 + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{"error":"oauth_session_missing"}`))}, nil 122 + } 123 + if accToken == "" { 124 + // try app-level session via legacy tap_session lookup helper 125 + if s, ok := m.GetSession(r); ok && s.AccessJWT != "" { 126 + accToken = s.AccessJWT 127 + tokType = "DPoP" 128 + scopeStr = "" 129 + log.Printf("pdsRequest: using getSession() token for auth") 130 + } 131 + } 132 + if accToken == "" { 133 + // read tap_session cookie directly without creating a new one 134 + if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 135 + if legacy, ok := m.sessionStore.Get(c.Value); ok && legacy.AccessJWT != "" { 136 + accToken = legacy.AccessJWT 137 + tokType = "DPoP" 138 + scopeStr = "" 139 + log.Printf("pdsRequest: using legacy tap_session map token for auth (sid=%s)", c.Value) 140 + } 141 + } 142 + } 143 + if accToken == "" { 144 + // No token at all -> fall back to authedDo (may be anonymous) 145 + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 146 + if contentType != "" { 147 + req.Header.Set("Content-Type", contentType) 148 + } 149 + req.Header.Set("Accept", "application/json") 150 + return m.AuthedDo(w, r, req) 151 + } 152 + // Builder for requests with a given scheme and optional nonce 153 + doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 154 + // Bind proof to access token via 'ath' for stricter PDSes 155 + proof, err := m.oauthManager.GenerateDPoPProofWithToken(method, url, accToken, nonce) 156 + if err != nil { 157 + return nil, nil, err 158 + } 159 + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 160 + if contentType != "" { 161 + req.Header.Set("Content-Type", contentType) 162 + } 163 + req.Header.Set("Accept", "application/json") 164 + req.Header.Set("Authorization", scheme+" "+accToken) 165 + req.Header.Set("DPoP", proof) 166 + // Log target PDS host and path 167 + if req.URL != nil { 168 + log.Printf("pdsRequest: attempt %s %s (host=%s, scheme=%s, nonce=%t)", method, req.URL.Path, req.URL.Host, scheme, nonce != "") 169 + } 170 + // Log Authorization header prefix and token_type for diagnostics (never log the token itself) 171 + authHdr := req.Header.Get("Authorization") 172 + authPrefix := authHdr 173 + if sp := strings.IndexByte(authHdr, ' '); sp > 0 { 174 + authPrefix = authHdr[:sp] 175 + } 176 + log.Printf("pdsRequest: auth prefix=%s, session.token_type=%s, session.scope=%s", authPrefix, tokType, scopeStr) 177 + res, err := http.DefaultClient.Do(req) 178 + if err != nil { 179 + return nil, nil, err 180 + } 181 + b, _ := io.ReadAll(res.Body) 182 + res.Body.Close() 183 + // reattach 184 + res.Body = io.NopCloser(bytes.NewReader(b)) 185 + if res.StatusCode >= 400 { 186 + log.Printf("pdsRequest: %s %s -> %d (scheme=%s, nonce=%t) body=%s", method, url, res.StatusCode, scheme, nonce != "", string(b)) 187 + } 188 + return res, b, nil 189 + } 190 + // 1) Prefer DPoP token scheme (token_type is DPoP) 191 + res, b, err := doWith("DPoP", "") 192 + if err != nil { 193 + return nil, err 194 + } 195 + if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusUnauthorized { 196 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 197 + log.Printf("pdsRequest: retrying with DPoP+nonce=%s", n) 198 + r2, _, e2 := doWith("DPoP", n) 199 + return r2, e2 200 + } 201 + // Some servers encode nonce hint in JSON too 202 + var er struct { 203 + Error string `json:"error"` 204 + } 205 + _ = json.Unmarshal(b, &er) 206 + if er.Error == "use_dpop_nonce" { 207 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 208 + log.Printf("pdsRequest: retrying with DPoP+nonce(from body)=%s", n) 209 + r2, _, e2 := doWith("DPoP", n) 210 + return r2, e2 211 + } 212 + } 213 + } 214 + if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusUnauthorized { 215 + return res, nil 216 + } 217 + // 2) Optionally fallback to Bearer+DPoP (for older servers), but only if our token is not DPoP-bound 218 + if strings.EqualFold(tokType, "DPoP") { 219 + log.Printf("pdsRequest: token_type=DPoP, skipping Bearer fallback") 220 + return res, nil 221 + } 222 + // Otherwise, try Bearer fallback 223 + log.Printf("pdsRequest: falling back to Bearer token scheme with DPoP proof") 224 + res, b, err = doWith("Bearer", "") 225 + if err != nil { 226 + return nil, err 227 + } 228 + if res.StatusCode == http.StatusBadRequest { 229 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 230 + log.Printf("pdsRequest: retrying with Bearer+DPoP nonce=%s", n) 231 + r2, _, e2 := doWith("Bearer", n) 232 + return r2, e2 233 + } 234 + } 235 + return res, nil 236 + } 237 + 238 + // AuthedDo executes req with the current access token; on 401/403 it attempts 239 + // a token refresh using the stored refresh token and retries once. 240 + func (m *AuthMiddleware) AuthedDo(w http.ResponseWriter, r *http.Request, req *http.Request) (*http.Response, error) { 241 + sid := m.GetOrCreateSessionID(w, r) 242 + s, ok := m.sessionStore.Get(sid) 243 + // Clone to avoid mutating caller's request headers 244 + attempt := func(token string) (*http.Response, error) { 245 + q := req.Clone(req.Context()) 246 + if token != "" { 247 + q.Header.Set("Authorization", "Bearer "+token) 248 + } 249 + return http.DefaultClient.Do(q) 250 + } 251 + // First attempt with current access token (if any) 252 + token := "" 253 + if ok { 254 + token = s.AccessJWT 255 + } 256 + res, err := attempt(token) 257 + if err != nil { 258 + return res, err 259 + } 260 + if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { 261 + return res, nil 262 + } 263 + // Try to refresh and retry once 264 + res.Body.Close() 265 + if ns, ok := m.RefreshSession(sid); ok { 266 + return attempt(ns.AccessJWT) 267 + } 268 + return res, nil 269 + } 270 + 271 + // RefreshSession uses the refresh JWT to obtain new access/refresh tokens and persists them. 272 + func (m *AuthMiddleware) RefreshSession(sid string) (session.Session, bool) { 273 + s, ok := m.sessionStore.Get(sid) 274 + if !ok || s.RefreshJWT == "" { 275 + return session.Session{}, false 276 + } 277 + req, _ := http.NewRequest(http.MethodPost, "https://bsky.social/xrpc/com.atproto.server.refreshSession", nil) 278 + req.Header.Set("Authorization", "Bearer "+s.RefreshJWT) 279 + res, err := http.DefaultClient.Do(req) 280 + if err != nil { 281 + return session.Session{}, false 282 + } 283 + defer res.Body.Close() 284 + if res.StatusCode < 200 || res.StatusCode >= 300 { 285 + return session.Session{}, false 286 + } 287 + var out struct { 288 + AccessJwt string `json:"accessJwt"` 289 + RefreshJwt string `json:"refreshJwt"` 290 + Did string `json:"did"` 291 + Handle string `json:"handle"` 292 + } 293 + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 294 + return session.Session{}, false 295 + } 296 + ns := session.Session{DID: out.Did, Handle: out.Handle, AccessJWT: out.AccessJwt, RefreshJWT: out.RefreshJwt} 297 + m.sessionStore.Set(sid, ns) 298 + return ns, true 299 + }
+72 -47
server/pdf.go server/services/blob.go
··· 1 - package main 1 + package services 2 2 3 3 import ( 4 4 "bytes" ··· 8 8 "net/http" 9 9 "strings" 10 10 11 + "github.com/johnluther/tap-editor/server/session" 11 12 fountain "github.com/johnluther/tap-editor/server/tap-editor" 12 13 "github.com/phpdave11/gofpdf" 13 14 ) 14 15 15 - // getDocNameAndText fetches name and text for a document rkey from ATProto 16 - func getDocNameAndText(w http.ResponseWriter, r *http.Request, ctx context.Context, s Session, id string) (name, text string, status int, err error) { 16 + const ( 17 + MaxJSONBody = 2 << 20 // ~2 MiB 18 + MaxTextBytes = 1 << 20 // ~1 MiB 19 + ) 20 + 21 + // PDSRequestFunc issues an authenticated request to the user's PDS. 22 + type PDSRequestFunc func(http.ResponseWriter, *http.Request, string, string, string, []byte) (*http.Response, error) 23 + 24 + // PDSBaseFunc returns the base URL for the user's PDS. 25 + type PDSBaseFunc func(*http.Request) string 26 + 27 + // BlobService handles blob uploads and document fetching. 28 + type BlobService struct { 29 + pdsRequest PDSRequestFunc 30 + pdsBase PDSBaseFunc 31 + } 32 + 33 + // NewBlobService constructs a BlobService. 34 + func NewBlobService(pdsReq PDSRequestFunc, pdsBase PDSBaseFunc) *BlobService { 35 + return &BlobService{ 36 + pdsRequest: pdsReq, 37 + pdsBase: pdsBase, 38 + } 39 + } 40 + 41 + // UploadBlob uploads data as a blob, retrying once on 401/403 or 5xx. 42 + func (s *BlobService) UploadBlob(w http.ResponseWriter, r *http.Request, data []byte) (*http.Response, error) { 43 + if len(data) > MaxTextBytes { 44 + return nil, fmt.Errorf("payload too large") 45 + } 46 + doOnce := func() (*http.Response, error) { 47 + url := s.pdsBase(r) + "/xrpc/com.atproto.repo.uploadBlob" 48 + return s.pdsRequest(w, r, http.MethodPost, url, "application/octet-stream", data) 49 + } 50 + res, err := doOnce() 51 + if err != nil { 52 + return res, err 53 + } 54 + if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden || res.StatusCode >= 500 { 55 + res.Body.Close() 56 + return doOnce() 57 + } 58 + return res, nil 59 + } 60 + 61 + // GetDocNameAndText fetches name and text for a document rkey from ATProto 62 + func (s *BlobService) GetDocNameAndText(w http.ResponseWriter, r *http.Request, ctx context.Context, sess session.Session, id string) (name, text string, status int, err error) { 17 63 // getRecord for rkey id via user's PDS 18 - url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + s.DID + "&collection=lol.tapapp.tap.doc&rkey=" + id 19 - resp, err := pdsRequest(w, r, http.MethodGet, url, "", nil) 64 + url := s.pdsBase(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + sess.DID + "&collection=lol.tapapp.tap.doc&rkey=" + id 65 + resp, err := s.pdsRequest(w, r, http.MethodGet, url, "", nil) 20 66 if err != nil { 21 67 return "", "", http.StatusBadGateway, err 22 68 } ··· 49 95 } 50 96 } 51 97 if cid != "" { 52 - blobURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.sync.getBlob?did=" + s.DID + "&cid=" + cid 53 - bRes, err := pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 98 + blobURL := s.pdsBase(r) + "/xrpc/com.atproto.sync.getBlob?did=" + sess.DID + "&cid=" + cid 99 + bRes, err := s.pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 54 100 if err == nil && bRes.StatusCode >= 200 && bRes.StatusCode < 300 { 55 101 defer bRes.Body.Close() 56 102 buf := new(bytes.Buffer) ··· 61 107 st := bRes.StatusCode 62 108 bRes.Body.Close() 63 109 if st >= 500 { 64 - if bRes2, err2 := pdsRequest(w, r, http.MethodGet, blobURL, "", nil); err2 == nil && bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { 110 + if bRes2, err2 := s.pdsRequest(w, r, http.MethodGet, blobURL, "", nil); err2 == nil && bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { 65 111 defer bRes2.Body.Close() 66 112 buf := new(bytes.Buffer) 67 113 _, _ = buf.ReadFrom(bRes2.Body) ··· 75 121 return name, text, http.StatusOK, nil 76 122 } 77 123 78 - // renderPDF creates a basic screenplay-styled PDF from parsed Fountain blocks 79 - func renderPDF(blocks []fountain.Block, title string) ([]byte, error) { 124 + // RenderPDF creates a basic screenplay-styled PDF from parsed Fountain blocks 125 + func RenderPDF(blocks []fountain.Block, title string) ([]byte, error) { 80 126 // Use inches and US Letter 81 127 pdf := gofpdf.New("P", "in", "Letter", "") 82 128 // Margins (screenplay-ish): left 1.5", right 1", top/bottom 1" ··· 96 142 usable := pageW - leftMargin - rightMargin 97 143 98 144 // Draw DRAFT watermark, lightly and behind content 99 - // Save current state via simple resets after draw 100 - // Reason: provide a visual watermark for draft exports 101 145 pdf.SetAlpha(0.08, "Normal") 102 146 pdf.SetFont(baseFont, "B", 96) 103 147 cx := pageW / 2 104 148 cy := pageH / 2 105 149 pdf.TransformBegin() 106 150 pdf.TransformRotate(45, cx, cy) 107 - // Center the text across full page width 108 151 pdf.SetXY(0, cy-0.6) 109 152 pdf.CellFormat(pageW, 1.2, "DRAFT", "", 0, "C", false, 0, "") 110 153 pdf.TransformEnd() 111 - // Restore alpha and font for header/body 112 154 pdf.SetAlpha(1.0, "Normal") 113 155 pdf.SetFont(baseFont, "", fontSize) 114 156 ··· 122 164 } 123 165 pdf.SetX(leftMargin) 124 166 pdf.CellFormat(usable, 0.2, fmt.Sprintf("%d", pdf.PageNo()), "", 0, "R", false, 0, "") 125 - // Reset Y to top margin for content 126 167 pdf.SetY(topMargin) 127 168 }) 128 169 ··· 134 175 usableW := pageW - leftMargin - rightMargin 135 176 136 177 // Screenplay metrics (inches) 137 - lh := 0.1667 // line height ~12pt (12/72 in) 138 - actionX := leftMargin // action and scene start at left margin 178 + lh := 0.1667 // line height ~12pt 179 + actionX := leftMargin 139 180 sceneX := leftMargin 140 - // Dialogue column (standard screenplay indent) 141 181 dialogueW := 3.25 142 - dialogueX := leftMargin + 1.0 // ~2.5" from page left 143 - // Parenthetical centered over the dialogue column 182 + dialogueX := leftMargin + 1.0 144 183 parentheticalW := 2.0 145 184 parentheticalX := dialogueX + (dialogueW-parentheticalW)/2 146 185 147 - // Helpers for blocks 148 186 setBlockMargins := func(x, w float64) { 149 187 pdf.SetLeftMargin(x) 150 188 pdf.SetRightMargin(pageW - (x + w)) ··· 161 199 pdf.Ln(lh * 0.5) 162 200 } 163 201 writeScene := func(text string) { 164 - // Scene headings are uppercase but not bold 165 202 pdf.SetFont(baseFont, "", fontSize) 166 203 resetMargins() 167 204 pdf.SetX(sceneX) ··· 169 206 pdf.Ln(lh * 0.25) 170 207 } 171 208 writeCharacter := func(name string) { 172 - // Center character cue within the dialogue column using margins + zero-width cell 173 - pdf.SetFont(baseFont, "", fontSize) 174 - setBlockMargins(dialogueX, dialogueW) 175 - upper := strings.ToUpper(name) 176 - // Ensure X starts at the column's left margin so centering is accurate 177 - pdf.SetX(dialogueX) 178 - // width=0 makes CellFormat span to the right margin; with margins set to the column, 179 - // 'C' alignment will center within the dialogue column precisely 180 - pdf.CellFormat(0, lh, upper, "", 1, "C", false, 0, "") 181 - resetMargins() 182 - } 209 + pdf.SetFont(baseFont, "", fontSize) 210 + setBlockMargins(dialogueX, dialogueW) 211 + upper := strings.ToUpper(name) 212 + pdf.SetX(dialogueX) 213 + pdf.CellFormat(0, lh, upper, "", 1, "C", false, 0, "") 214 + resetMargins() 215 + } 183 216 writeParenthetical := func(text string) { 184 217 pdf.SetFont(baseFont, "", fontSize) 185 218 setBlockMargins(parentheticalX, parentheticalW) ··· 205 238 pdf.CellFormat(usableW, 0.2, text, "", 1, "C", false, 0, "") 206 239 } 207 240 writeDual := func(b fountain.Block) { 208 - // Two dialogue columns within usable width 209 241 gap := 0.5 210 242 colW := (usableW - gap) / 2 211 243 leftX := leftMargin 212 244 rightX := leftMargin + colW + gap 213 245 yStart := pdf.GetY() 214 246 215 - // Left 216 247 resetMargins() 217 248 pdf.SetXY(leftX, yStart) 218 249 lname := safeMeta(b.Meta, "left_name") ··· 232 263 resetMargins() 233 264 leftBottom := pdf.GetY() 234 265 235 - // Right 236 266 pdf.SetXY(rightX, yStart) 237 267 rname := safeMeta(b.Meta, "right_name") 238 268 rpar := safeMeta(b.Meta, "right_parenthetical") ··· 250 280 resetMargins() 251 281 rightBottom := pdf.GetY() 252 282 253 - // Move cursor to the max bottom 254 283 if rightBottom > leftBottom { 255 284 pdf.SetY(rightBottom) 256 285 } else { ··· 259 288 pdf.Ln(lh * 0.25) 260 289 } 261 290 262 - // Title header (simple) 291 + // Title header 263 292 if strings.TrimSpace(title) != "" { 264 293 pdf.SetFont(baseFont, "B", fontSize+2) 265 294 pdf.CellFormat(usableW, 0.3, strings.ToUpper(title), "", 1, "C", false, 0, "") ··· 274 303 writeScene(bl.Text) 275 304 inDialogue = false 276 305 case fountain.PageBreak: 277 - // Manual page break 278 306 pdf.AddPage() 279 - // Ensure margins and font are consistent after new page 280 307 pdf.SetFont(baseFont, "", fontSize) 281 308 inDialogue = false 282 309 case fountain.Action: ··· 286 313 writeAction(bl.Text) 287 314 } 288 315 case fountain.Section: 289 - // Skip sections in PDF body 290 316 inDialogue = false 291 317 case fountain.Synopsis: 292 - // Skip synopsis in PDF body 318 + // Skip 293 319 case fountain.Lyric: 294 - // Treat lyrics like action for layout 295 320 writeAction(bl.Text) 296 321 case fountain.Character: 297 322 writeCharacter(bl.Text) ··· 309 334 writeDual(bl) 310 335 inDialogue = false 311 336 case fountain.Note: 312 - // Skip notes by default 337 + // Skip 313 338 case fountain.Transition: 314 339 writeTransition(bl.Text) 315 340 inDialogue = false ··· 320 345 pdf.Ln(lh * 0.5) 321 346 inDialogue = false 322 347 case fountain.Title: 323 - // already have a header; ignore 348 + // Skip (already rendered) 324 349 } 325 350 } 326 351 ··· 331 356 return buf.Bytes(), nil 332 357 } 333 358 334 - func sanitizeFilename(name string) string { 359 + // SanitizeFilename cleans a string for safe filesystem usage. 360 + func SanitizeFilename(name string) string { 335 361 repl := func(r rune) rune { 336 362 switch r { 337 363 case '\\', '/', ':', '*', '?', '"', '<', '>', '|': ··· 348 374 return out 349 375 } 350 376 351 - // safeMeta returns the meta value for key k or empty string 352 377 func safeMeta(m map[string]string, k string) string { 353 378 if m == nil { 354 379 return ""
+67
server/services/atproto.go
··· 1 + package services 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + ) 10 + 11 + // ResolveHandle resolves a Bluesky handle to a DID using the public AppView endpoint. 12 + func ResolveHandle(handle string) (string, error) { 13 + u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) 14 + req, _ := http.NewRequest(http.MethodGet, u, nil) 15 + req.Header.Set("Accept", "application/json") 16 + res, err := http.DefaultClient.Do(req) 17 + if err != nil { 18 + return "", err 19 + } 20 + defer res.Body.Close() 21 + if res.StatusCode != http.StatusOK { 22 + b, _ := io.ReadAll(res.Body) 23 + return "", fmt.Errorf("resolveHandle %d: %s", res.StatusCode, string(b)) 24 + } 25 + var out struct { 26 + Did string `json:"did"` 27 + } 28 + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 29 + return "", err 30 + } 31 + return out.Did, nil 32 + } 33 + 34 + // ResolvePDSFromPLC fetches the DID PLC document and returns the atproto_pds service endpoint if present. 35 + func ResolvePDSFromPLC(did string) (string, error) { 36 + url := "https://plc.directory/" + did 37 + req, _ := http.NewRequest(http.MethodGet, url, nil) 38 + req.Header.Set("Accept", "application/json") 39 + res, err := http.DefaultClient.Do(req) 40 + if err != nil { 41 + return "", err 42 + } 43 + defer res.Body.Close() 44 + if res.StatusCode != http.StatusOK { 45 + b, _ := io.ReadAll(res.Body) 46 + return "", fmt.Errorf("plc %d: %s", res.StatusCode, string(b)) 47 + } 48 + var doc struct { 49 + Service []struct { 50 + ID string `json:"id"` 51 + Type string `json:"type"` 52 + ServiceEndpoint string `json:"serviceEndpoint"` 53 + } `json:"service"` 54 + } 55 + if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { 56 + return "", err 57 + } 58 + for _, s := range doc.Service { 59 + if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { 60 + return s.ServiceEndpoint, nil 61 + } 62 + if s.ID == "#atproto_pds" && s.ServiceEndpoint != "" { 63 + return s.ServiceEndpoint, nil 64 + } 65 + } 66 + return "", fmt.Errorf("pds endpoint not found in DID doc") 67 + }