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

checkpooint

Changed files
+117 -90
server
+109 -79
server/main.go
··· 28 28 userSessions = map[string]Session{} 29 29 ) 30 30 31 + // Session represents a minimal legacy session persisted via cookie for 32 + // non-OAuth flows and for compatibility with older endpoints. 33 + type Session struct { 34 + DID string `json:"did"` 35 + Handle string `json:"handle"` 36 + AccessJWT string `json:"accessJwt,omitempty"` 37 + RefreshJWT string `json:"refreshJwt,omitempty"` 38 + } 39 + 40 + // handleATPSession manages a simple server-backed session store for Bluesky. 41 + // Client obtains tokens via @atproto/api then POSTs here to persist server-side. 42 + // Methods: 43 + // - GET: return current session (handle, did) or 204 if none 44 + // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 45 + // - DELETE: clear session 46 + func handleATPSession(w http.ResponseWriter, r *http.Request) { 47 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 48 + sid := getOrCreateSessionID(w, r) 49 + 50 + switch r.Method { 51 + case http.MethodGet: 52 + sessionsMu.Lock() 53 + s, ok := userSessions[sid] 54 + sessionsMu.Unlock() 55 + if !ok || s.DID == "" { 56 + w.WriteHeader(http.StatusNoContent) 57 + return 58 + } 59 + _ = json.NewEncoder(w).Encode(s) 60 + case http.MethodPost: 61 + // Limit body size for session payload 62 + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB 63 + var s Session 64 + if err := json.NewDecoder(r.Body).Decode(&s); err != nil { 65 + http.Error(w, "invalid json", http.StatusBadRequest) 66 + return 67 + } 68 + if s.Handle == "" || s.DID == "" { 69 + http.Error(w, "missing did/handle", http.StatusBadRequest) 70 + return 71 + } 72 + sessionsMu.Lock() 73 + userSessions[sid] = s 74 + sessionsMu.Unlock() 75 + w.WriteHeader(http.StatusNoContent) 76 + case http.MethodDelete: 77 + sessionsMu.Lock() 78 + delete(userSessions, sid) 79 + sessionsMu.Unlock() 80 + w.WriteHeader(http.StatusNoContent) 81 + default: 82 + w.WriteHeader(http.StatusMethodNotAllowed) 83 + } 84 + } 85 + 31 86 // resolveHandle resolves a Bluesky handle to a DID using the public AppView endpoint. 32 87 func resolveHandle(handle string) (string, error) { 33 88 u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) ··· 964 1019 if len(pb) > 0 { _, _ = w.Write(pb) } 965 1020 return 966 1021 } 967 - w.WriteHeader(http.StatusNoContent) 1022 + // Verify by re-reading the record and (best-effort) the blob 1023 + verifyURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + s.DID + "&collection=lol.tapapp.tap.doc&rkey=" + id 1024 + vRes, vErr := pdsRequest(w, r, http.MethodGet, verifyURL, "", nil) 1025 + if vErr != nil { 1026 + http.Error(w, "verify failed", http.StatusBadGateway) 1027 + return 1028 + } 1029 + defer vRes.Body.Close() 1030 + if vRes.StatusCode < 200 || vRes.StatusCode >= 300 { 1031 + w.WriteHeader(http.StatusBadGateway) 1032 + return 1033 + } 1034 + var vRec struct { Value map[string]any `json:"value"` } 1035 + if e := json.NewDecoder(vRes.Body).Decode(&vRec); e != nil { 1036 + http.Error(w, "verify decode failed", http.StatusBadGateway) 1037 + return 1038 + } 1039 + var txt string 1040 + if cb, ok := vRec.Value["contentBlob"].(map[string]any); ok { 1041 + if ref, ok := cb["ref"].(map[string]any); ok { 1042 + if l, ok := ref["$link"].(string); ok && l != "" { 1043 + blobURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.sync.getBlob?did=" + s.DID + "&cid=" + l 1044 + br, be := pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 1045 + if be == nil && br.StatusCode >= 200 && br.StatusCode < 300 { 1046 + defer br.Body.Close() 1047 + bbuf := new(bytes.Buffer) 1048 + _, _ = bbuf.ReadFrom(br.Body) 1049 + txt = bbuf.String() 1050 + } 1051 + } 1052 + } 1053 + } 1054 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 1055 + _ = json.NewEncoder(w).Encode(map[string]any{ 1056 + "id": id, 1057 + "name": vRec.Value["name"], 1058 + "text": txt, 1059 + "updatedAt": vRec.Value["updatedAt"], 1060 + }) 1061 + return 968 1062 case http.MethodDelete: 969 1063 // Delete the record for this rkey 970 1064 delPayload := map[string]any{ ··· 990 1084 } 991 1085 } 992 1086 993 - // handleATPSession manages a simple server-backed session store for Bluesky. 994 - // Client obtains tokens via @atproto/api then POSTs here to persist server-side. 995 - // Methods: 996 - // - GET: return current session (handle, did) or 204 if none 997 - // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 998 - // - DELETE: clear session 999 - func handleATPSession(w http.ResponseWriter, r *http.Request) { 1000 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 1001 - sid := getOrCreateSessionID(w, r) 1002 - 1003 - switch r.Method { 1004 - case http.MethodGet: 1005 - sessionsMu.Lock() 1006 - s, ok := userSessions[sid] 1007 - sessionsMu.Unlock() 1008 - if !ok || s.DID == "" { 1009 - w.WriteHeader(http.StatusNoContent) 1010 - return 1011 - } 1012 - _ = json.NewEncoder(w).Encode(s) 1013 - case http.MethodPost: 1014 - // Limit body size for session payload 1015 - r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB 1016 - var s Session 1017 - if err := json.NewDecoder(r.Body).Decode(&s); err != nil { 1018 - http.Error(w, "invalid json", http.StatusBadRequest) 1019 - return 1020 - } 1021 - if s.Handle == "" || s.DID == "" { 1022 - http.Error(w, "missing did/handle", http.StatusBadRequest) 1023 - return 1024 - } 1025 - sessionsMu.Lock() 1026 - userSessions[sid] = s 1027 - sessionsMu.Unlock() 1028 - w.WriteHeader(http.StatusNoContent) 1029 - case http.MethodDelete: 1030 - sessionsMu.Lock() 1031 - delete(userSessions, sid) 1032 - sessionsMu.Unlock() 1033 - w.WriteHeader(http.StatusNoContent) 1034 - default: 1035 - w.WriteHeader(http.StatusMethodNotAllowed) 1036 - } 1037 - } 1038 - 1039 1087 // handleATPPost posts a simple text note to Bluesky using the stored session. 1040 1088 // Body: { "text": "..." } 1041 1089 func handleATPPost(w http.ResponseWriter, r *http.Request) { ··· 1075 1123 return 1076 1124 } 1077 1125 defer blobRes.Body.Close() 1078 - var blobResp struct { 1079 - Blob map[string]any `json:"blob"` 1080 - } 1126 + var blobResp struct { Blob map[string]any `json:"blob"` } 1081 1127 if err := json.NewDecoder(blobRes.Body).Decode(&blobResp); err != nil { 1082 1128 http.Error(w, "blob decode failed", http.StatusBadGateway) 1083 1129 return 1084 1130 } 1085 1131 1086 - // 2) Build record referencing the blob and upsert at fixed rkey "current" 1132 + // 2) Upsert at fixed rkey "current" 1087 1133 record := map[string]any{ 1088 1134 "$type": "lol.tapapp.tap.doc", 1089 1135 "contentBlob": blobResp.Blob, 1090 1136 "updatedAt": time.Now().UTC().Format(time.RFC3339Nano), 1091 1137 } 1092 1138 putPayload := map[string]any{ 1093 - "repo": s.DID, 1094 - "collection": "lol.tapapp.tap.doc", 1095 - "rkey": "current", 1096 - "record": record, 1139 + "repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record, 1097 1140 } 1098 - putBuf, _ := json.Marshal(putPayload) 1141 + pbuf, _ := json.Marshal(putPayload) 1099 1142 putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 1100 - putRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", putBuf) 1101 - if err == nil && putRes.StatusCode >= 200 && putRes.StatusCode < 300 { 1102 - defer putRes.Body.Close() 1143 + pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 1144 + if err == nil && pRes.StatusCode >= 200 && pRes.StatusCode < 300 { 1145 + defer pRes.Body.Close() 1103 1146 w.WriteHeader(http.StatusNoContent) 1104 1147 return 1105 1148 } 1106 - if putRes != nil { 1107 - defer putRes.Body.Close() 1108 - } 1149 + if pRes != nil { defer pRes.Body.Close() } 1109 1150 1110 - // 3) If put failed (e.g., not found), try create with fixed rkey 1151 + // 3) Fallback to create 1111 1152 createPayload := map[string]any{ 1112 - "repo": s.DID, 1113 - "collection": "lol.tapapp.tap.doc", 1114 - "rkey": "current", 1115 - "record": record, 1153 + "repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record, 1116 1154 } 1117 - createBuf, _ := json.Marshal(createPayload) 1155 + cbuf, _ := json.Marshal(createPayload) 1118 1156 createURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 1119 - createRes, err := pdsRequest(w, r, http.MethodPost, createURL, "application/json", createBuf) 1157 + cRes, err := pdsRequest(w, r, http.MethodPost, createURL, "application/json", cbuf) 1120 1158 if err != nil { 1121 1159 http.Error(w, "create failed", http.StatusBadGateway) 1122 1160 return 1123 1161 } 1124 - defer createRes.Body.Close() 1125 - w.WriteHeader(createRes.StatusCode) 1126 - _, _ = w.Write([]byte("{}")) 1127 - } 1128 - 1129 - type Session struct { 1130 - DID string `json:"did"` 1131 - Handle string `json:"handle"` 1132 - AccessJWT string `json:"accessJwt,omitempty"` 1133 - RefreshJWT string `json:"refreshJwt,omitempty"` 1162 + defer cRes.Body.Close() 1163 + w.WriteHeader(cRes.StatusCode) 1134 1164 } 1135 1165 1136 1166 func main() {
+8 -11
server/pdf.go
··· 14 14 15 15 // getDocNameAndText fetches name and text for a document rkey from ATProto 16 16 func getDocNameAndText(w http.ResponseWriter, r *http.Request, ctx context.Context, s Session, id string) (name, text string, status int, err error) { 17 - // getRecord for rkey id 18 - url := "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=" + s.DID + "&collection=lol.tapapp.tap.doc&rkey=" + id 19 - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 20 - resp, err := authedDo(w, r, req) 17 + // 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) 21 20 if err != nil { 22 21 return "", "", http.StatusBadGateway, err 23 22 } ··· 50 49 } 51 50 } 52 51 if cid != "" { 53 - blobURL := "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=" + s.DID + "&cid=" + cid 54 - bReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, blobURL, nil) 55 - bRes, err := authedDo(w, r, bReq) 52 + blobURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.sync.getBlob?did=" + s.DID + "&cid=" + cid 53 + bRes, err := pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 56 54 if err == nil && bRes.StatusCode >= 200 && bRes.StatusCode < 300 { 57 55 defer bRes.Body.Close() 58 56 buf := new(bytes.Buffer) ··· 60 58 text = buf.String() 61 59 } else if bRes != nil { 62 60 // retry once for 5xx 63 - status := bRes.StatusCode 61 + st := bRes.StatusCode 64 62 bRes.Body.Close() 65 - if status >= 500 { 66 - bReq2, _ := http.NewRequestWithContext(ctx, http.MethodGet, blobURL, nil) 67 - if bRes2, err2 := authedDo(w, r, bReq2); err2 == nil && bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { 63 + if st >= 500 { 64 + if bRes2, err2 := pdsRequest(w, r, http.MethodGet, blobURL, "", nil); err2 == nil && bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { 68 65 defer bRes2.Body.Close() 69 66 buf := new(bytes.Buffer) 70 67 _, _ = buf.ReadFrom(bRes2.Body)
server/server

This is a binary file and will not be displayed.