+1
-6
README.md
+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
+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
+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
+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