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

Remove app password references

Changed files
+381 -112
server
+1 -6
README.md
··· 8 8 9 9 ### Authentication 10 10 11 - Tap uses Bluesky App Passwords (not your main account password). 12 - 13 - - Enter your Bluesky handle and an App Password on the home page to sign in. 14 - - The server stores your access and refresh tokens in memory for the duration of your session. 15 - - Tokens are refreshed automatically via `com.atproto.server.refreshSession`. 16 - - You can revoke the App Password any time in your Bluesky account settings. 11 + Tap uses [Bluesky OAuth authentication](https://aaronparecki.com/2023/03/09/5/bluesky-and-oauth). Enter your Bluesky handle on the home page to sign in. You will be redirected to Bluesky to authorize the app. 17 12 18 13 ### Export features 19 14
+377 -101
server/main.go
··· 39 39 Text string 40 40 UpdatedAt string 41 41 } 42 + 42 43 var devDocsMu sync.Mutex 43 44 var devDocs = map[string]map[string]*devDoc{} 45 + 44 46 func devGetStore(sid string) map[string]*devDoc { 45 47 devDocsMu.Lock() 46 48 defer devDocsMu.Unlock() 47 49 m, ok := devDocs[sid] 48 - if !ok { m = map[string]*devDoc{}; devDocs[sid] = m } 50 + if !ok { 51 + m = map[string]*devDoc{} 52 + devDocs[sid] = m 53 + } 49 54 return m 50 55 } 51 56 ··· 72 77 case http.MethodPost: 73 78 r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 74 79 var body struct{ Name, Text string } 75 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 76 - if body.Name == "" { body.Name = "Untitled" } 77 - rb := make([]byte, 8); _, _ = rand.Read(rb); id := "d-" + hex.EncodeToString(rb) 80 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 81 + http.Error(w, "invalid json", http.StatusBadRequest) 82 + return 83 + } 84 + if body.Name == "" { 85 + body.Name = "Untitled" 86 + } 87 + rb := make([]byte, 8) 88 + _, _ = rand.Read(rb) 89 + id := "d-" + hex.EncodeToString(rb) 78 90 now := time.Now().UTC().Format(time.RFC3339) 79 91 store[id] = &devDoc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 80 92 w.WriteHeader(http.StatusCreated) 81 93 _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 82 94 return 83 95 default: 84 - w.WriteHeader(http.StatusMethodNotAllowed); return 96 + w.WriteHeader(http.StatusMethodNotAllowed) 97 + return 85 98 } 86 99 } 87 100 did, _, ok := getDIDAndHandle(r) ··· 94 107 // List records in the collection 95 108 url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.listRecords?repo=" + did + "&collection=lol.tapapp.tap.doc&limit=100" 96 109 resp, err := pdsRequest(w, r, http.MethodGet, url, "", nil) 97 - if err != nil { http.Error(w, "list failed", http.StatusBadGateway); return } 110 + if err != nil { 111 + http.Error(w, "list failed", http.StatusBadGateway) 112 + return 113 + } 98 114 defer resp.Body.Close() 99 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { w.WriteHeader(resp.StatusCode); return } 100 - var lr struct{ Records []map[string]any `json:"records"` } 101 - if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { http.Error(w, "decode list", http.StatusBadGateway); return } 102 - type item struct{ 115 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 116 + w.WriteHeader(resp.StatusCode) 117 + return 118 + } 119 + var lr struct { 120 + Records []map[string]any `json:"records"` 121 + } 122 + if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { 123 + http.Error(w, "decode list", http.StatusBadGateway) 124 + return 125 + } 126 + type item struct { 103 127 ID string `json:"id"` 104 128 Name string `json:"name"` 105 129 UpdatedAt string `json:"updatedAt"` ··· 109 133 val, _ := rec["value"].(map[string]any) 110 134 // Name fallback: try name, then title 111 135 name := "Untitled" 112 - if v := val["name"]; v != nil { if s, ok := v.(string); ok && s != "" { name = s } } 113 - if name == "Untitled" { if v := val["title"]; v != nil { if s, ok := v.(string); ok && s != "" { name = s } } } 136 + if v := val["name"]; v != nil { 137 + if s, ok := v.(string); ok && s != "" { 138 + name = s 139 + } 140 + } 141 + if name == "Untitled" { 142 + if v := val["title"]; v != nil { 143 + if s, ok := v.(string); ok && s != "" { 144 + name = s 145 + } 146 + } 147 + } 114 148 // Date fallback: try updatedAt, then updated. Normalize to RFC3339 if parseable. 115 149 updatedAt := "" 116 - if v := val["updatedAt"]; v != nil { if s, ok := v.(string); ok { updatedAt = s } } 117 - if updatedAt == "" { if v := val["updated"]; v != nil { if s, ok := v.(string); ok { updatedAt = s } } } 150 + if v := val["updatedAt"]; v != nil { 151 + if s, ok := v.(string); ok { 152 + updatedAt = s 153 + } 154 + } 155 + if updatedAt == "" { 156 + if v := val["updated"]; v != nil { 157 + if s, ok := v.(string); ok { 158 + updatedAt = s 159 + } 160 + } 161 + } 118 162 if updatedAt == "" { // fallback to top-level indexedAt if present 119 - if v, ok := rec["indexedAt"].(string); ok { updatedAt = v } 163 + if v, ok := rec["indexedAt"].(string); ok { 164 + updatedAt = v 165 + } 120 166 } 121 167 if updatedAt != "" { 122 168 if t, err := time.Parse(time.RFC3339Nano, updatedAt); err == nil { ··· 127 173 } 128 174 // Derive id from rkey or uri 129 175 id := "" 130 - if v, ok := rec["rkey"].(string); ok { id = v } 131 - if id == "" { if v, ok := rec["uri"].(string); ok { parts := strings.Split(v, "/"); if len(parts) > 0 { id = parts[len(parts)-1] } } } 132 - if id == "" { id = "current" } 176 + if v, ok := rec["rkey"].(string); ok { 177 + id = v 178 + } 179 + if id == "" { 180 + if v, ok := rec["uri"].(string); ok { 181 + parts := strings.Split(v, "/") 182 + if len(parts) > 0 { 183 + id = parts[len(parts)-1] 184 + } 185 + } 186 + } 187 + if id == "" { 188 + id = "current" 189 + } 133 190 out = append(out, item{ID: id, Name: name, UpdatedAt: updatedAt}) 134 191 } 135 192 _ = json.NewEncoder(w).Encode(out) ··· 137 194 // Create a new doc 138 195 r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 139 196 var body struct{ Name, Text string } 140 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 141 - if len(body.Text) > maxTextBytes { http.Error(w, "text too large", http.StatusRequestEntityTooLarge); return } 142 - if body.Name == "" { body.Name = "Untitled" } 197 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 198 + http.Error(w, "invalid json", http.StatusBadRequest) 199 + return 200 + } 201 + if len(body.Text) > maxTextBytes { 202 + http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 203 + return 204 + } 205 + if body.Name == "" { 206 + body.Name = "Untitled" 207 + } 143 208 // Upload blob 144 209 bRes, err := uploadBlobWithRetry(w, r, []byte(body.Text)) 145 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 210 + if err != nil { 211 + http.Error(w, "blob upload failed", http.StatusBadGateway) 212 + return 213 + } 146 214 defer bRes.Body.Close() 147 - var bOut struct{ Blob map[string]any `json:"blob"` } 148 - if err := json.NewDecoder(bRes.Body).Decode(&bOut); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 215 + var bOut struct { 216 + Blob map[string]any `json:"blob"` 217 + } 218 + if err := json.NewDecoder(bRes.Body).Decode(&bOut); err != nil { 219 + http.Error(w, "blob decode failed", http.StatusBadGateway) 220 + return 221 + } 149 222 // New rkey 150 - rb := make([]byte, 8); _, _ = rand.Read(rb); id := "d-" + hex.EncodeToString(rb) 223 + rb := make([]byte, 8) 224 + _, _ = rand.Read(rb) 225 + id := "d-" + hex.EncodeToString(rb) 151 226 record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": body.Name, "contentBlob": bOut.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 152 227 payload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 153 228 buf, _ := json.Marshal(payload) 154 229 createURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 155 230 cr, err := pdsRequest(w, r, http.MethodPost, createURL, "application/json", buf) 156 - if err != nil { http.Error(w, "create failed", http.StatusBadGateway); return } 231 + if err != nil { 232 + http.Error(w, "create failed", http.StatusBadGateway) 233 + return 234 + } 157 235 defer cr.Body.Close() 158 236 if cr.StatusCode < 200 || cr.StatusCode >= 300 { 159 237 w.WriteHeader(cr.StatusCode) ··· 171 249 if devOffline { 172 250 w.Header().Set("Content-Type", "application/json; charset=utf-8") 173 251 id := strings.TrimPrefix(r.URL.Path, "/docs/") 174 - if id == "" { w.WriteHeader(http.StatusBadRequest); return } 252 + if id == "" { 253 + w.WriteHeader(http.StatusBadRequest) 254 + return 255 + } 175 256 sid := getOrCreateSessionID(w, r) 176 257 store := devGetStore(sid) 177 258 switch r.Method { ··· 179 260 // PDF export support in offline mode 180 261 if strings.HasSuffix(id, ".pdf") { 181 262 baseID := strings.TrimSuffix(id, ".pdf") 182 - d, ok := store[baseID]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 183 - name := d.Name; if name == "" { name = "Untitled" } 263 + d, ok := store[baseID] 264 + if !ok { 265 + http.Error(w, "not found", http.StatusNotFound) 266 + return 267 + } 268 + name := d.Name 269 + if name == "" { 270 + name = "Untitled" 271 + } 184 272 blocks := fountain.Parse(d.Text) 185 273 pdfBytes, err := renderPDF(blocks, name) 186 - if err != nil { log.Printf("pdf render error: %v", err); http.Error(w, "PDF render failed", http.StatusInternalServerError); return } 274 + if err != nil { 275 + log.Printf("pdf render error: %v", err) 276 + http.Error(w, "PDF render failed", http.StatusInternalServerError) 277 + return 278 + } 187 279 safeName := sanitizeFilename(name) 188 280 // Override content-type for PDF 189 281 w.Header().Del("Content-Type") 190 282 w.Header().Set("Content-Type", "application/pdf") 191 283 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 192 284 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 193 - w.Header().Set("Pragma", "no-cache"); w.Header().Set("Expires", "0") 285 + w.Header().Set("Pragma", "no-cache") 286 + w.Header().Set("Expires", "0") 194 287 _, _ = w.Write(pdfBytes) 195 288 return 196 289 } 197 290 // Plain text export (.fountain) 198 291 if strings.HasSuffix(id, ".fountain") { 199 292 baseID := strings.TrimSuffix(id, ".fountain") 200 - d, ok := store[baseID]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 293 + d, ok := store[baseID] 294 + if !ok { 295 + http.Error(w, "not found", http.StatusNotFound) 296 + return 297 + } 201 298 w.Header().Del("Content-Type") 202 299 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 203 - name := d.Name; if name == "" { name = "screenplay" } 300 + name := d.Name 301 + if name == "" { 302 + name = "screenplay" 303 + } 204 304 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 205 305 _, _ = w.Write([]byte(d.Text)) 206 306 return 207 307 } 208 308 // Delete via action query (for simple UI link) 209 309 if r.URL.Query().Get("action") == "delete" { 210 - if _, ok := store[id]; !ok { http.Error(w, "not found", http.StatusNotFound); return } 310 + if _, ok := store[id]; !ok { 311 + http.Error(w, "not found", http.StatusNotFound) 312 + return 313 + } 211 314 delete(store, id) 212 315 http.Redirect(w, r, "/library", http.StatusSeeOther) 213 316 return 214 317 } 215 - d, ok := store[id]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 318 + d, ok := store[id] 319 + if !ok { 320 + http.Error(w, "not found", http.StatusNotFound) 321 + return 322 + } 216 323 _ = json.NewEncoder(w).Encode(map[string]any{"id": d.ID, "name": d.Name, "text": d.Text, "updatedAt": d.UpdatedAt}) 217 324 return 218 325 case http.MethodPut: 219 - var body struct{ Name *string `json:"name"`; Text *string `json:"text"` } 326 + var body struct { 327 + Name *string `json:"name"` 328 + Text *string `json:"text"` 329 + } 220 330 r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 221 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 222 - d, ok := store[id]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 223 - if body.Name != nil { n := strings.TrimSpace(*body.Name); if n == "" { n = "Untitled" }; d.Name = n } 224 - if body.Text != nil { d.Text = *body.Text } 331 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 332 + http.Error(w, "invalid json", http.StatusBadRequest) 333 + return 334 + } 335 + d, ok := store[id] 336 + if !ok { 337 + http.Error(w, "not found", http.StatusNotFound) 338 + return 339 + } 340 + if body.Name != nil { 341 + n := strings.TrimSpace(*body.Name) 342 + if n == "" { 343 + n = "Untitled" 344 + } 345 + d.Name = n 346 + } 347 + if body.Text != nil { 348 + d.Text = *body.Text 349 + } 225 350 d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) 226 351 w.WriteHeader(http.StatusNoContent) 227 352 return 228 353 case http.MethodDelete: 229 - if _, ok := store[id]; !ok { http.Error(w, "not found", http.StatusNotFound); return } 354 + if _, ok := store[id]; !ok { 355 + http.Error(w, "not found", http.StatusNotFound) 356 + return 357 + } 230 358 delete(store, id) 231 359 w.WriteHeader(http.StatusNoContent) 232 360 return 233 361 default: 234 - w.WriteHeader(http.StatusMethodNotAllowed); return 362 + w.WriteHeader(http.StatusMethodNotAllowed) 363 + return 235 364 } 236 365 } 237 366 did, handle, ok := getDIDAndHandle(r) ··· 241 370 return 242 371 } 243 372 id := strings.TrimPrefix(r.URL.Path, "/docs/") 244 - if id == "" { w.Header().Set("Content-Type", "application/json; charset=utf-8"); w.WriteHeader(http.StatusBadRequest); return } 373 + if id == "" { 374 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 375 + w.WriteHeader(http.StatusBadRequest) 376 + return 377 + } 245 378 // PDF export 246 379 if r.Method == http.MethodGet && strings.HasSuffix(id, ".pdf") { 247 380 baseID := strings.TrimSuffix(id, ".pdf") 248 381 s2 := Session{DID: did, Handle: handle} 249 382 name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 250 - if err != nil { w.WriteHeader(status); return } 251 - if name == "" { name = "Untitled" } 383 + if err != nil { 384 + w.WriteHeader(status) 385 + return 386 + } 387 + if name == "" { 388 + name = "Untitled" 389 + } 252 390 blocks := fountain.Parse(text) 253 391 pdfBytes, err := renderPDF(blocks, name) 254 - if err != nil { log.Printf("pdf render error: %v", err); http.Error(w, "PDF render failed", http.StatusInternalServerError); return } 392 + if err != nil { 393 + log.Printf("pdf render error: %v", err) 394 + http.Error(w, "PDF render failed", http.StatusInternalServerError) 395 + return 396 + } 255 397 safeName := sanitizeFilename(name) 256 398 w.Header().Set("Content-Type", "application/pdf") 257 399 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 258 400 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 259 - w.Header().Set("Pragma", "no-cache"); w.Header().Set("Expires", "0") 260 - _, _ = w.Write(pdfBytes); return 401 + w.Header().Set("Pragma", "no-cache") 402 + w.Header().Set("Expires", "0") 403 + _, _ = w.Write(pdfBytes) 404 + return 261 405 } 262 406 w.Header().Set("Content-Type", "application/json; charset=utf-8") 263 407 switch r.Method { ··· 267 411 if strings.HasSuffix(id, ".fountain") { 268 412 baseID := strings.TrimSuffix(id, ".fountain") 269 413 name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 270 - if err != nil { w.WriteHeader(status); return } 414 + if err != nil { 415 + w.WriteHeader(status) 416 + return 417 + } 271 418 w.Header().Del("Content-Type") 272 419 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 273 - if name == "" { name = "screenplay" } 420 + if name == "" { 421 + name = "screenplay" 422 + } 274 423 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 275 424 _, _ = w.Write([]byte(text)) 276 425 return ··· 282 431 dbuf, _ := json.Marshal(delPayload) 283 432 delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 284 433 dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 285 - if err != nil { http.Error(w, "delete failed", http.StatusBadGateway); return } 434 + if err != nil { 435 + http.Error(w, "delete failed", http.StatusBadGateway) 436 + return 437 + } 286 438 defer dRes.Body.Close() 287 - if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { w.WriteHeader(dRes.StatusCode); return } 439 + if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { 440 + w.WriteHeader(dRes.StatusCode) 441 + return 442 + } 288 443 http.Redirect(w, r, "/library", http.StatusSeeOther) 289 444 return 290 445 } 291 446 name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, id) 292 - if err != nil { w.WriteHeader(status); return } 447 + if err != nil { 448 + w.WriteHeader(status) 449 + return 450 + } 293 451 _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name, "text": text, "updatedAt": ""}) 294 452 case http.MethodPut: 295 453 r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 296 - var body struct{ Name *string `json:"name"`; Text *string `json:"text"` } 297 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 454 + var body struct { 455 + Name *string `json:"name"` 456 + Text *string `json:"text"` 457 + } 458 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 459 + http.Error(w, "invalid json", http.StatusBadRequest) 460 + return 461 + } 298 462 // Read current 299 463 getURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=" + id 300 464 gRes, err := pdsRequest(w, r, http.MethodGet, getURL, "", nil) 301 - if err != nil { http.Error(w, "get failed", http.StatusBadGateway); return } 465 + if err != nil { 466 + http.Error(w, "get failed", http.StatusBadGateway) 467 + return 468 + } 302 469 defer gRes.Body.Close() 303 - if gRes.StatusCode == http.StatusNotFound { http.Error(w, "not found", http.StatusNotFound); return } 304 - if gRes.StatusCode < 200 || gRes.StatusCode >= 300 { w.WriteHeader(gRes.StatusCode); return } 305 - var cur struct{ Value map[string]any `json:"value"` } 306 - if err := json.NewDecoder(gRes.Body).Decode(&cur); err != nil { http.Error(w, "decode current", http.StatusBadGateway); return } 307 - name := "Untitled"; if v := cur.Value["name"]; v != nil { if s, ok := v.(string); ok && s != "" { name = s } } 308 - var blob map[string]any; if v, ok := cur.Value["contentBlob"].(map[string]any); ok { blob = v } 309 - if body.Name != nil { if *body.Name != "" { name = *body.Name } else { name = "Untitled" } } 470 + if gRes.StatusCode == http.StatusNotFound { 471 + http.Error(w, "not found", http.StatusNotFound) 472 + return 473 + } 474 + if gRes.StatusCode < 200 || gRes.StatusCode >= 300 { 475 + w.WriteHeader(gRes.StatusCode) 476 + return 477 + } 478 + var cur struct { 479 + Value map[string]any `json:"value"` 480 + } 481 + if err := json.NewDecoder(gRes.Body).Decode(&cur); err != nil { 482 + http.Error(w, "decode current", http.StatusBadGateway) 483 + return 484 + } 485 + name := "Untitled" 486 + if v := cur.Value["name"]; v != nil { 487 + if s, ok := v.(string); ok && s != "" { 488 + name = s 489 + } 490 + } 491 + var blob map[string]any 492 + if v, ok := cur.Value["contentBlob"].(map[string]any); ok { 493 + blob = v 494 + } 495 + if body.Name != nil { 496 + if *body.Name != "" { 497 + name = *body.Name 498 + } else { 499 + name = "Untitled" 500 + } 501 + } 310 502 if body.Text != nil { 311 - if len(*body.Text) > maxTextBytes { http.Error(w, "text too large", http.StatusRequestEntityTooLarge); return } 503 + if len(*body.Text) > maxTextBytes { 504 + http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 505 + return 506 + } 312 507 ubRes, err := uploadBlobWithRetry(w, r, []byte(*body.Text)) 313 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 508 + if err != nil { 509 + http.Error(w, "blob upload failed", http.StatusBadGateway) 510 + return 511 + } 314 512 defer ubRes.Body.Close() 315 - var ub struct{ Blob map[string]any `json:"blob"` } 316 - if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 513 + var ub struct { 514 + Blob map[string]any `json:"blob"` 515 + } 516 + if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { 517 + http.Error(w, "blob decode failed", http.StatusBadGateway) 518 + return 519 + } 317 520 blob = ub.Blob 318 521 } else if blob == nil { 319 522 ubRes, err := uploadBlobWithRetry(w, r, []byte("")) 320 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 523 + if err != nil { 524 + http.Error(w, "blob upload failed", http.StatusBadGateway) 525 + return 526 + } 321 527 defer ubRes.Body.Close() 322 - var ub struct{ Blob map[string]any `json:"blob"` } 323 - if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 528 + var ub struct { 529 + Blob map[string]any `json:"blob"` 530 + } 531 + if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { 532 + http.Error(w, "blob decode failed", http.StatusBadGateway) 533 + return 534 + } 324 535 blob = ub.Blob 325 536 } 326 537 record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": name, "contentBlob": blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} ··· 328 539 pbuf, _ := json.Marshal(putPayload) 329 540 putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 330 541 pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 331 - if err != nil { http.Error(w, "put failed", http.StatusBadGateway); return } 542 + if err != nil { 543 + http.Error(w, "put failed", http.StatusBadGateway) 544 + return 545 + } 332 546 defer pRes.Body.Close() 333 - if pRes.StatusCode < 200 || pRes.StatusCode >= 300 { w.WriteHeader(pRes.StatusCode); return } 547 + if pRes.StatusCode < 200 || pRes.StatusCode >= 300 { 548 + w.WriteHeader(pRes.StatusCode) 549 + return 550 + } 334 551 w.WriteHeader(http.StatusNoContent) 335 552 case http.MethodDelete: 336 553 delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 337 554 dbuf, _ := json.Marshal(delPayload) 338 555 delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 339 556 dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 340 - if err != nil { http.Error(w, "delete failed", http.StatusBadGateway); return } 557 + if err != nil { 558 + http.Error(w, "delete failed", http.StatusBadGateway) 559 + return 560 + } 341 561 defer dRes.Body.Close() 342 - if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { w.WriteHeader(dRes.StatusCode); return } 562 + if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { 563 + w.WriteHeader(dRes.StatusCode) 564 + return 565 + } 343 566 w.WriteHeader(http.StatusNoContent) 344 567 default: 345 568 w.WriteHeader(http.StatusMethodNotAllowed) ··· 348 571 349 572 // handleATPPost posts a simple text note to Bluesky using the stored legacy session (for back-compat) 350 573 func handleATPPost(w http.ResponseWriter, r *http.Request) { 351 - if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed); return } 574 + if r.Method != http.MethodPost { 575 + w.WriteHeader(http.StatusMethodNotAllowed) 576 + return 577 + } 352 578 w.Header().Set("Content-Type", "application/json; charset=utf-8") 353 579 sid := getOrCreateSessionID(w, r) 354 - sessionsMu.Lock(); s, ok := userSessions[sid]; sessionsMu.Unlock() 355 - if !ok || s.AccessJWT == "" || s.DID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized); return } 580 + sessionsMu.Lock() 581 + s, ok := userSessions[sid] 582 + sessionsMu.Unlock() 583 + if !ok || s.AccessJWT == "" || s.DID == "" { 584 + http.Error(w, "unauthorized", http.StatusUnauthorized) 585 + return 586 + } 356 587 r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 357 - var body struct{ Text string `json:"text"` } 358 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" { http.Error(w, "invalid body", http.StatusBadRequest); return } 359 - if len(body.Text) > maxTextBytes { http.Error(w, "text too large", http.StatusRequestEntityTooLarge); return } 588 + var body struct { 589 + Text string `json:"text"` 590 + } 591 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" { 592 + http.Error(w, "invalid body", http.StatusBadRequest) 593 + return 594 + } 595 + if len(body.Text) > maxTextBytes { 596 + http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 597 + return 598 + } 360 599 // Upload blob 361 600 blobRes, err := uploadBlobWithRetry(w, r, []byte(body.Text)) 362 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 601 + if err != nil { 602 + http.Error(w, "blob upload failed", http.StatusBadGateway) 603 + return 604 + } 363 605 defer blobRes.Body.Close() 364 - var blobResp struct{ Blob map[string]any `json:"blob"` } 365 - if err := json.NewDecoder(blobRes.Body).Decode(&blobResp); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 606 + var blobResp struct { 607 + Blob map[string]any `json:"blob"` 608 + } 609 + if err := json.NewDecoder(blobRes.Body).Decode(&blobResp); err != nil { 610 + http.Error(w, "blob decode failed", http.StatusBadGateway) 611 + return 612 + } 366 613 // Upsert current 367 614 record := map[string]any{"$type": "lol.tapapp.tap.doc", "contentBlob": blobResp.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 368 615 putPayload := map[string]any{"repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 369 616 pbuf, _ := json.Marshal(putPayload) 370 617 putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 371 618 pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 372 - if err == nil && pRes.StatusCode >= 200 && pRes.StatusCode < 300 { defer pRes.Body.Close(); w.WriteHeader(http.StatusNoContent); return } 373 - if pRes != nil { defer pRes.Body.Close() } 619 + if err == nil && pRes.StatusCode >= 200 && pRes.StatusCode < 300 { 620 + defer pRes.Body.Close() 621 + w.WriteHeader(http.StatusNoContent) 622 + return 623 + } 624 + if pRes != nil { 625 + defer pRes.Body.Close() 626 + } 374 627 // fallback create 375 628 cPayload := map[string]any{"repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 376 629 cbuf, _ := json.Marshal(cPayload) 377 630 cURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 378 631 cRes, err := pdsRequest(w, r, http.MethodPost, cURL, "application/json", cbuf) 379 - if err != nil { http.Error(w, "create failed", http.StatusBadGateway); return } 380 - defer cRes.Body.Close(); w.WriteHeader(cRes.StatusCode) 632 + if err != nil { 633 + http.Error(w, "create failed", http.StatusBadGateway) 634 + return 635 + } 636 + defer cRes.Body.Close() 637 + w.WriteHeader(cRes.StatusCode) 381 638 } 382 639 383 640 // getDIDAndHandle returns the current user's DID and handle, preferring OAuth session ··· 422 679 if devOffline { 423 680 // Provide a stub session in offline mode so UI treats user as logged in 424 681 stub := Session{DID: "did:example:dev", Handle: "dev.local"} 425 - sessionsMu.Lock(); userSessions[sid] = stub; sessionsMu.Unlock() 682 + sessionsMu.Lock() 683 + userSessions[sid] = stub 684 + sessionsMu.Unlock() 426 685 _ = json.NewEncoder(w).Encode(stub) 427 686 return 428 687 } ··· 879 1138 // pdsRequest sends an XRPC request to the user's PDS using dual-scheme auth: 880 1139 // 1) Try Authorization: Bearer <token> with a DPoP proof 881 1140 // 2) On 400 responses, if a DPoP-Nonce is provided, retry once with that nonce 882 - // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided) 883 - // If no OAuth session is present, falls back to authedDo (legacy app-password flow). 1141 + // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided). 884 1142 func pdsRequest(w http.ResponseWriter, r *http.Request, method, url, contentType string, body []byte) (*http.Response, error) { 885 1143 // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 886 1144 var ( ··· 1071 1329 var cid string 1072 1330 if cb := recResp.Value.ContentBlob; cb != nil { 1073 1331 if ref, ok := cb["ref"].(map[string]any); ok { 1074 - if l, ok := ref["$link"].(string); ok { cid = l } 1332 + if l, ok := ref["$link"].(string); ok { 1333 + cid = l 1334 + } 1075 1335 } 1076 1336 } 1077 1337 if cid == "" { ··· 1132 1392 // small helper: compute strong ETag as sha256 hex of file contents 1133 1393 computeETag := func(path string) (string, []byte, error) { 1134 1394 f, err := os.Open(path) 1135 - if err != nil { return "", nil, err } 1395 + if err != nil { 1396 + return "", nil, err 1397 + } 1136 1398 defer f.Close() 1137 1399 h := sha256.New() 1138 1400 var buf bytes.Buffer 1139 - if _, err := io.Copy(io.MultiWriter(h, &buf), f); err != nil { return "", nil, err } 1401 + if _, err := io.Copy(io.MultiWriter(h, &buf), f); err != nil { 1402 + return "", nil, err 1403 + } 1140 1404 sum := hex.EncodeToString(h.Sum(nil)) 1141 1405 return "\"" + sum + "\"", buf.Bytes(), nil 1142 1406 } ··· 1149 1413 if f, err := os.Open(local + ".br"); err == nil { 1150 1414 f.Close() 1151 1415 // Compute ETag against compressed bytes 1152 - if etag, data, err := computeETag(local+".br"); err == nil { 1416 + if etag, data, err := computeETag(local + ".br"); err == nil { 1153 1417 if ifNoneMatch != "" && ifNoneMatch == etag { 1154 1418 w.WriteHeader(http.StatusNotModified) 1155 1419 return ··· 1165 1429 } 1166 1430 // Encourage revalidation so clients pick up new builds when ETag changes 1167 1431 w.Header().Set("Cache-Control", "no-cache") 1168 - if _, err := w.Write(data); err != nil { http.Error(w, "read error", http.StatusInternalServerError) } 1432 + if _, err := w.Write(data); err != nil { 1433 + http.Error(w, "read error", http.StatusInternalServerError) 1434 + } 1169 1435 return 1170 1436 } 1171 1437 // fallback: stream if hashing failed ··· 1174 1440 defer f2.Close() 1175 1441 w.Header().Set("Vary", "Accept-Encoding") 1176 1442 w.Header().Set("Content-Encoding", "br") 1177 - if strings.HasSuffix(local, ".js") { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } 1178 - if strings.HasSuffix(local, ".css") { w.Header().Set("Content-Type", "text/css; charset=utf-8") } 1443 + if strings.HasSuffix(local, ".js") { 1444 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 1445 + } 1446 + if strings.HasSuffix(local, ".css") { 1447 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 1448 + } 1179 1449 _, _ = io.Copy(w, f2) 1180 1450 return 1181 1451 } ··· 1185 1455 if strings.Contains(ae, "gzip") { 1186 1456 if f, err := os.Open(local + ".gz"); err == nil { 1187 1457 f.Close() 1188 - if etag, data, err := computeETag(local+".gz"); err == nil { 1458 + if etag, data, err := computeETag(local + ".gz"); err == nil { 1189 1459 if ifNoneMatch != "" && ifNoneMatch == etag { 1190 1460 w.WriteHeader(http.StatusNotModified) 1191 1461 return ··· 1193 1463 w.Header().Set("ETag", etag) 1194 1464 w.Header().Set("Vary", "Accept-Encoding") 1195 1465 w.Header().Set("Content-Encoding", "gzip") 1196 - if strings.HasSuffix(local, ".js") { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } 1197 - if strings.HasSuffix(local, ".css") { w.Header().Set("Content-Type", "text/css; charset=utf-8") } 1466 + if strings.HasSuffix(local, ".js") { 1467 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 1468 + } 1469 + if strings.HasSuffix(local, ".css") { 1470 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 1471 + } 1198 1472 w.Header().Set("Cache-Control", "no-cache") 1199 - if _, err := w.Write(data); err != nil { http.Error(w, "read error", http.StatusInternalServerError) } 1473 + if _, err := w.Write(data); err != nil { 1474 + http.Error(w, "read error", http.StatusInternalServerError) 1475 + } 1200 1476 return 1201 1477 } 1202 1478 if strings.HasSuffix(local, ".css") {
+2 -4
server/templates/index.html
··· 53 53 <footer class="container footer"> 54 54 <p> 55 55 <span class="footer-left"> 56 - <a href="/about">About</a><span id="footer-apppw-wrap"> • <a href="https://www.dailykos.com/stories/2025/1/24/2298963/-Bluesky-Tips-and-Tricks-Third-party-Apps-App-Passwords" target="_blank" rel="noreferrer">What is an App Password?</a></span> 57 - <span id="footer-sample-wrap"> • 56 + <a href="/about">About</a><span id="footer-apppw-wrap"> • 58 57 <a id="footer-sample" href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">Sample Fountain script</a> 59 58 </span> 60 59 </span> ··· 78 77 userEl.style.display = handle ? 'inline' : 'none'; 79 78 btn.style.display = handle ? 'inline-block' : 'none'; 80 79 if (sampleWrap) sampleWrap.style.display = handle ? 'inline' : 'none'; 81 - // Show the App Password link only on the login page (unauthenticated) 82 - if (appPwWrap) appPwWrap.style.display = handle ? 'none' : 'inline'; 80 + 83 81 }; 84 82 const hide = () => show(''); 85 83
+1 -1
server/templates/privacy.html
··· 62 62 <ul> 63 63 <li>Access the service without providing personal information</li> 64 64 <li>Use the service without creating an account</li> 65 - <li>Revoke your Bluesky App Password at any time</li> 65 + <li>Revoke access to your Bluesky account at any time</li> 66 66 <li>Clear your browser data to remove any local session information</li> 67 67 </ul> 68 68