Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

Fixed bsky.social OAuth issues, removed old SQL cruft

+152 -176
+3
fly-production.toml
··· 16 16 auto_start_machines = true 17 17 min_machines_running = 0 18 18 19 + [http_service.http_options] 20 + h2_backend = false 21 + 19 22 [mounts] 20 23 source = "data" 21 24 destination = "/data"
+3
fly-staging.toml
··· 16 16 auto_start_machines = true 17 17 min_machines_running = 1 18 18 19 + [http_service.http_options] 20 + h2_backend = false 21 + 19 22 [mounts] 20 23 source = "data" 21 24 destination = "/data"
+52 -6
internal/atproto/identity.go
··· 135 135 } 136 136 137 137 // FetchAuthServerMeta fetches OAuth authorization server metadata from a PDS. 138 + // Per the ATProto OAuth spec, the PDS may delegate to a separate authorization server. 139 + // We discover it via the protected resource metadata if the PDS doesn't host its own. 138 140 func FetchAuthServerMeta(pdsURL string) (*AuthServerMeta, error) { 139 - url := strings.TrimRight(pdsURL, "/") + "/.well-known/oauth-authorization-server" 140 - resp, err := httpClient.Get(url) 141 + base := strings.TrimRight(pdsURL, "/") 142 + 143 + // First try the PDS directly (works for self-hosted PDSes). 144 + directURL := base + "/.well-known/oauth-authorization-server" 145 + resp, err := httpClient.Get(directURL) 141 146 if err != nil { 142 147 return nil, fmt.Errorf("fetch auth server meta from %s: %w", pdsURL, err) 143 148 } 144 149 defer resp.Body.Close() 145 - if resp.StatusCode != http.StatusOK { 146 - return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", pdsURL, resp.StatusCode) 150 + 151 + if resp.StatusCode == http.StatusOK { 152 + var meta AuthServerMeta 153 + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { 154 + return nil, fmt.Errorf("decode auth server meta: %w", err) 155 + } 156 + if meta.PushedAuthorizationRequestEndpoint == "" { 157 + return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL) 158 + } 159 + return &meta, nil 160 + } 161 + resp.Body.Close() 162 + 163 + // PDS doesn't host its own auth server — discover it via protected resource metadata. 164 + prURL := base + "/.well-known/oauth-protected-resource" 165 + prResp, err := httpClient.Get(prURL) 166 + if err != nil { 167 + return nil, fmt.Errorf("fetch protected resource meta from %s: %w", pdsURL, err) 168 + } 169 + defer prResp.Body.Close() 170 + if prResp.StatusCode != http.StatusOK { 171 + return nil, fmt.Errorf("fetch protected resource meta from %s: HTTP %d", pdsURL, prResp.StatusCode) 172 + } 173 + 174 + var prMeta struct { 175 + AuthorizationServers []string `json:"authorization_servers"` 176 + } 177 + if err := json.NewDecoder(prResp.Body).Decode(&prMeta); err != nil { 178 + return nil, fmt.Errorf("decode protected resource meta: %w", err) 179 + } 180 + if len(prMeta.AuthorizationServers) == 0 { 181 + return nil, fmt.Errorf("no authorization_servers in protected resource meta for %s", pdsURL) 182 + } 183 + 184 + authServerURL := strings.TrimRight(prMeta.AuthorizationServers[0], "/") 185 + metaURL := authServerURL + "/.well-known/oauth-authorization-server" 186 + metaResp, err := httpClient.Get(metaURL) 187 + if err != nil { 188 + return nil, fmt.Errorf("fetch auth server meta from %s: %w", authServerURL, err) 189 + } 190 + defer metaResp.Body.Close() 191 + if metaResp.StatusCode != http.StatusOK { 192 + return nil, fmt.Errorf("fetch auth server meta from %s: HTTP %d", authServerURL, metaResp.StatusCode) 147 193 } 148 194 149 195 var meta AuthServerMeta 150 - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { 196 + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil { 151 197 return nil, fmt.Errorf("decode auth server meta: %w", err) 152 198 } 153 199 if meta.PushedAuthorizationRequestEndpoint == "" { 154 - return nil, fmt.Errorf("auth server at %s does not support PAR", pdsURL) 200 + return nil, fmt.Errorf("auth server at %s does not support PAR", authServerURL) 155 201 } 156 202 return &meta, nil 157 203 }
+14 -5
internal/atproto/xrpc/client.go
··· 88 88 c.db.UpdateATProtoTokens(c.userID, c.session.AccessToken, c.session.RefreshToken, newNonce, c.session.ExpiresAt) 89 89 } 90 90 91 - // On 401, refresh token and retry once 91 + // On 401, retry once — nonce error just needs the updated nonce, token errors need a refresh. 92 92 if resp.StatusCode == http.StatusUnauthorized { 93 + respBody, _ := io.ReadAll(resp.Body) 93 94 resp.Body.Close() 94 - if err := atproto.RefreshAccessToken(c.db, c.session); err != nil { 95 - return nil, fmt.Errorf("refresh after 401: %w", err) 95 + 96 + var errPayload struct { 97 + Error string `json:"error"` 98 + } 99 + json.Unmarshal(respBody, &errPayload) 100 + 101 + if errPayload.Error != "use_dpop_nonce" { 102 + // Expired/invalid token — refresh and retry. 103 + if err := atproto.RefreshAccessToken(c.db, c.session); err != nil { 104 + return nil, fmt.Errorf("refresh after 401: %w", err) 105 + } 96 106 } 107 + // For use_dpop_nonce the nonce was already updated above; just retry. 97 108 98 - // Re-read the body if it was provided — caller must handle this 99 - // For our use case, body is always a bytes.Reader or nil 100 109 if body != nil { 101 110 if seeker, ok := body.(io.Seeker); ok { 102 111 seeker.Seek(0, io.SeekStart)
+25 -9
internal/handler/handler.go
··· 2 2 3 3 import ( 4 4 "crypto/rand" 5 + "encoding/base64" 5 6 "encoding/hex" 6 7 "encoding/json" 7 8 "fmt" ··· 13 14 "strings" 14 15 "time" 15 16 16 - "github.com/golang-jwt/jwt/v5" 17 17 "github.com/gorilla/websocket" 18 18 19 19 "github.com/limeleaf/diffdown/internal/atproto" ··· 998 998 999 999 did, name, err := h.validateWSToken(accessToken, dpopProof) 1000 1000 if err != nil { 1001 + log.Printf("CollaboratorWebSocket: token validation failed for rkey %s: %v", rKey, err) 1001 1002 http.Error(w, "Invalid tokens", http.StatusUnauthorized) 1002 1003 return 1003 1004 } 1004 1005 1005 1006 user, err := h.DB.GetUserByDID(did) 1006 1007 if err != nil { 1008 + log.Printf("CollaboratorWebSocket: user not found for DID %s: %v", did, err) 1007 1009 http.Error(w, "No user found", http.StatusUnauthorized) 1008 1010 return 1009 1011 } 1010 1012 1011 1013 session, err := h.DB.GetATProtoSession(user.ID) 1012 1014 if err != nil || session == nil { 1015 + log.Printf("CollaboratorWebSocket: no ATProto session for user %s: %v", user.ID, err) 1013 1016 http.Error(w, "No ATProto session", http.StatusUnauthorized) 1014 1017 return 1015 1018 } ··· 1028 1031 } 1029 1032 docClient, err = h.xrpcClient(ownerUser.ID) 1030 1033 if err != nil { 1034 + log.Printf("CollaboratorWebSocket: xrpc client for owner %s: %v", ownerDID, err) 1031 1035 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 1032 1036 return 1033 1037 } ··· 1035 1039 } else { 1036 1040 docClient, err = h.xrpcClient(session.UserID) 1037 1041 if err != nil { 1042 + log.Printf("CollaboratorWebSocket: xrpc client for user %s: %v", session.UserID, err) 1038 1043 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 1039 1044 return 1040 1045 } ··· 1043 1048 1044 1049 value, _, err := docClient.GetRecord(docRepoDID, collectionDocument, rKey) 1045 1050 if err != nil { 1051 + log.Printf("CollaboratorWebSocket: GetRecord %s/%s: %v", docRepoDID, rKey, err) 1046 1052 http.Error(w, "Document not found", http.StatusNotFound) 1047 1053 return 1048 1054 } 1049 1055 doc := &model.Document{} 1050 1056 if err := json.Unmarshal(value, doc); err != nil { 1057 + log.Printf("CollaboratorWebSocket: unmarshal doc %s: %v", rKey, err) 1051 1058 http.Error(w, "Invalid document", http.StatusInternalServerError) 1052 1059 return 1053 1060 } ··· 1061 1068 } 1062 1069 } 1063 1070 if !isCollaborator { 1071 + log.Printf("CollaboratorWebSocket: DID %s not a collaborator on %s/%s", did, docRepoDID, rKey) 1064 1072 http.Error(w, "Not a collaborator", http.StatusForbidden) 1065 1073 return 1066 1074 } ··· 1088 1096 wsClient.ReadPump() 1089 1097 } 1090 1098 1091 - func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 1092 - claims := &jwt.MapClaims{} 1093 - parser := jwt.Parser{} 1094 - _, _, err := parser.ParseUnverified(accessToken, claims) 1099 + func (h *Handler) validateWSToken(accessToken, _ string) (string, string, error) { 1100 + // ATProto JWTs use ES256K which golang-jwt doesn't register by default. 1101 + // We only need the sub claim, so decode the payload directly. 1102 + parts := strings.SplitN(accessToken, ".", 3) 1103 + if len(parts) != 3 { 1104 + return "", "", fmt.Errorf("malformed token") 1105 + } 1106 + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) 1095 1107 if err != nil { 1096 - return "", "", fmt.Errorf("parse token: %w", err) 1108 + return "", "", fmt.Errorf("decode token payload: %w", err) 1109 + } 1110 + var claims map[string]interface{} 1111 + if err := json.Unmarshal(payload, &claims); err != nil { 1112 + return "", "", fmt.Errorf("parse token claims: %w", err) 1097 1113 } 1098 1114 1099 - did, ok := (*claims)["sub"].(string) 1100 - if !ok { 1115 + did, ok := claims["sub"].(string) 1116 + if !ok || did == "" { 1101 1117 return "", "", fmt.Errorf("no sub in token") 1102 1118 } 1103 1119 ··· 1115 1131 return "", "", fmt.Errorf("session expired") 1116 1132 } 1117 1133 1118 - name, _ := (*claims)["name"].(string) 1134 + name, _ := claims["name"].(string) 1119 1135 return did, name, nil 1120 1136 } 1121 1137
+54
migrations/001_baseline.sql
··· 1 + -- 001_baseline.sql 2 + -- Consolidated baseline schema replacing migrations 001–008. 3 + 4 + CREATE TABLE users ( 5 + id TEXT PRIMARY KEY, 6 + did TEXT UNIQUE NOT NULL 7 + ); 8 + 9 + CREATE TABLE atproto_sessions ( 10 + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 11 + did TEXT NOT NULL, 12 + pds_url TEXT NOT NULL, 13 + access_token TEXT NOT NULL, 14 + refresh_token TEXT NOT NULL, 15 + dpop_key_jwk TEXT NOT NULL, 16 + dpop_nonce TEXT DEFAULT '', 17 + token_endpoint TEXT NOT NULL, 18 + expires_at DATETIME NOT NULL, 19 + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 20 + ); 21 + 22 + CREATE TABLE invites ( 23 + id TEXT PRIMARY KEY, 24 + document_rkey TEXT NOT NULL, 25 + token TEXT NOT NULL UNIQUE, 26 + created_by_did TEXT NOT NULL, 27 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 28 + expires_at DATETIME NOT NULL 29 + ); 30 + 31 + CREATE INDEX idx_invites_document ON invites(document_rkey); 32 + CREATE INDEX idx_invites_token ON invites(token); 33 + 34 + CREATE TABLE doc_steps ( 35 + id INTEGER PRIMARY KEY AUTOINCREMENT, 36 + doc_rkey TEXT NOT NULL, 37 + version INTEGER NOT NULL, 38 + step_json TEXT NOT NULL, 39 + client_id TEXT NOT NULL, 40 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 41 + UNIQUE(doc_rkey, version) 42 + ); 43 + 44 + CREATE INDEX idx_doc_steps_rkey_version ON doc_steps(doc_rkey, version); 45 + 46 + CREATE TABLE collaborations ( 47 + collaborator_did TEXT NOT NULL, 48 + owner_did TEXT NOT NULL, 49 + document_rkey TEXT NOT NULL, 50 + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 51 + PRIMARY KEY (collaborator_did, owner_did, document_rkey) 52 + ); 53 + 54 + CREATE INDEX idx_collaborations_did_added ON collaborations(collaborator_did, added_at DESC);
-73
migrations/001_initial.sql
··· 1 - -- 001_initial.sql 2 - 3 - CREATE TABLE IF NOT EXISTS users ( 4 - id TEXT PRIMARY KEY, 5 - name TEXT NOT NULL, 6 - email TEXT UNIQUE NOT NULL, 7 - password_hash TEXT, 8 - avatar_url TEXT DEFAULT '', 9 - oauth_provider TEXT, 10 - oauth_id TEXT, 11 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP 12 - ); 13 - 14 - CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oauth ON users(oauth_provider, oauth_id) 15 - WHERE oauth_provider IS NOT NULL; 16 - 17 - CREATE TABLE IF NOT EXISTS repos ( 18 - id TEXT PRIMARY KEY, 19 - name TEXT NOT NULL, 20 - slug TEXT NOT NULL, 21 - description TEXT DEFAULT '', 22 - owner_id TEXT NOT NULL REFERENCES users(id), 23 - visibility TEXT DEFAULT 'private' CHECK(visibility IN ('private','public','unlisted')), 24 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 25 - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 26 - ); 27 - 28 - CREATE UNIQUE INDEX IF NOT EXISTS idx_repos_owner_slug ON repos(owner_id, slug); 29 - 30 - CREATE TABLE IF NOT EXISTS repo_collaborators ( 31 - repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, 32 - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 33 - role TEXT NOT NULL DEFAULT 'viewer' CHECK(role IN ('owner','editor','commenter','viewer')), 34 - PRIMARY KEY (repo_id, user_id) 35 - ); 36 - 37 - CREATE TABLE IF NOT EXISTS files ( 38 - id TEXT PRIMARY KEY, 39 - repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, 40 - path TEXT NOT NULL, 41 - content TEXT DEFAULT '', 42 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 43 - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 44 - ); 45 - 46 - CREATE UNIQUE INDEX IF NOT EXISTS idx_files_repo_path ON files(repo_id, path); 47 - 48 - CREATE TABLE IF NOT EXISTS versions ( 49 - id TEXT PRIMARY KEY, 50 - file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, 51 - content TEXT NOT NULL, 52 - author_id TEXT NOT NULL REFERENCES users(id), 53 - message TEXT DEFAULT '', 54 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP 55 - ); 56 - 57 - CREATE INDEX IF NOT EXISTS idx_versions_file ON versions(file_id, created_at DESC); 58 - 59 - CREATE TABLE IF NOT EXISTS comments ( 60 - id TEXT PRIMARY KEY, 61 - file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, 62 - version_id TEXT REFERENCES versions(id), 63 - line_start INTEGER NOT NULL, 64 - line_end INTEGER NOT NULL, 65 - content_hash TEXT DEFAULT '', 66 - body TEXT NOT NULL, 67 - author_id TEXT NOT NULL REFERENCES users(id), 68 - parent_id TEXT REFERENCES comments(id), 69 - resolved INTEGER DEFAULT 0, 70 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP 71 - ); 72 - 73 - CREATE INDEX IF NOT EXISTS idx_comments_file ON comments(file_id);
-3
migrations/002_add_did.sql
··· 1 - -- 002_add_did.sql 2 - ALTER TABLE users ADD COLUMN did TEXT; 3 - CREATE UNIQUE INDEX IF NOT EXISTS idx_users_did ON users(did) WHERE did IS NOT NULL;
-14
migrations/003_atproto_sessions.sql
··· 1 - CREATE TABLE IF NOT EXISTS atproto_sessions ( 2 - user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 3 - did TEXT NOT NULL, 4 - pds_url TEXT NOT NULL, 5 - access_token TEXT NOT NULL, 6 - refresh_token TEXT NOT NULL, 7 - dpop_key_jwk TEXT NOT NULL, 8 - dpop_nonce TEXT DEFAULT '', 9 - token_endpoint TEXT NOT NULL, 10 - expires_at DATETIME NOT NULL, 11 - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 12 - ); 13 - 14 - ALTER TABLE users ADD COLUMN pds_url TEXT DEFAULT '';
-5
migrations/004_drop_content_tables.sql
··· 1 - DROP TABLE IF EXISTS comments; 2 - DROP TABLE IF EXISTS versions; 3 - DROP TABLE IF EXISTS files; 4 - DROP TABLE IF EXISTS repo_collaborators; 5 - DROP TABLE IF EXISTS repos;
-13
migrations/005_create_invites.sql
··· 1 - -- 005_create_invites.sql 2 - 3 - CREATE TABLE IF NOT EXISTS invites ( 4 - id TEXT PRIMARY KEY, 5 - document_rkey TEXT NOT NULL, 6 - token TEXT NOT NULL UNIQUE, 7 - created_by_did TEXT NOT NULL, 8 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 9 - expires_at DATETIME NOT NULL 10 - ); 11 - 12 - CREATE INDEX IF NOT EXISTS idx_invites_document ON invites(document_rkey); 13 - CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
-12
migrations/006_doc_steps.sql
··· 1 - -- migrations/006_doc_steps.sql 2 - CREATE TABLE IF NOT EXISTS doc_steps ( 3 - id INTEGER PRIMARY KEY AUTOINCREMENT, 4 - doc_rkey TEXT NOT NULL, 5 - version INTEGER NOT NULL, 6 - step_json TEXT NOT NULL, 7 - client_id TEXT NOT NULL, 8 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 9 - UNIQUE(doc_rkey, version) 10 - ); 11 - 12 - CREATE INDEX IF NOT EXISTS idx_doc_steps_rkey_version ON doc_steps(doc_rkey, version);
-25
migrations/007_stateless_users.sql
··· 1 - -- 007_stateless_users.sql 2 - -- Remove cached profile data, make auth stateless 3 - 4 - -- Recreate users table with minimal data (DID only) 5 - -- We need to preserve existing data, so we'll create new table and migrate 6 - 7 - CREATE TABLE users_new ( 8 - id TEXT PRIMARY KEY, 9 - did TEXT UNIQUE NOT NULL 10 - ); 11 - 12 - -- Migrate existing users - copy only id and did 13 - INSERT INTO users_new (id, did) 14 - SELECT id, COALESCE(did, email) as did 15 - FROM users 16 - WHERE did IS NOT NULL OR email LIKE 'did:%'; 17 - 18 - -- Drop old users table 19 - DROP TABLE users; 20 - 21 - -- Rename new table 22 - ALTER TABLE users_new RENAME TO users; 23 - 24 - -- Note: We keep atproto_sessions as-is since it stores the OAuth tokens 25 - -- needed to authenticate with the user's PDS at runtime
-10
migrations/008_collaborations.sql
··· 1 - CREATE TABLE IF NOT EXISTS collaborations ( 2 - collaborator_did TEXT NOT NULL, 3 - owner_did TEXT NOT NULL, 4 - document_rkey TEXT NOT NULL, 5 - added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 - PRIMARY KEY (collaborator_did, owner_did, document_rkey) 7 - ); 8 - 9 - CREATE INDEX IF NOT EXISTS idx_collaborations_did_added 10 - ON collaborations (collaborator_did, added_at DESC);
+1 -1
templates/about.html
··· 33 33 </div> 34 34 <div class="about-col"> 35 35 <h2>Status</h2> 36 - <p>As I said, I am not an engineer. My goal was to create something simple that worked, and I achieved that. But 95% of Diffdown was written by <a href="https://en.wikipedia.org/wiki/Large_language_model">LLMs</a>. I am not qualified to assess the security or robustness of the code and don't claim that it is production quality. I would <em>like</em> it to be, so <a href="https://tangled.org/diffdown.com/diffdown-app/">contributors are welcome</a>!</p> 36 + <p>As I said, I am not an engineer. My goal was to create something simple that worked, and I achieved that. But 95% of Diffdown was written by <a href="https://en.wikipedia.org/wiki/Large_language_model">LLMs</a>. I am not qualified to assess the security or robustness of the code and don't claim that it is production quality. I would <em>like</em> it to be, so <a href="https://tangled.org/diffdown.com/diffdown-app/">contributions from real engineers are welcome</a>!</p> 37 37 <p>Functionally, the app is alpha quality, at best. Expect bugs, flakiness, breaking changes, and limited features.</p> 38 38 <p>The good news is that any documents you create will be stored on your AT Proto PDS, so even if Diffdown goes away, you will still have your documents and comments.</p> 39 39 <p><strong class="warning">Important:</strong> Because AT Proto does not support private records (<a href="https://atproto.wiki/en/working-groups/private-data">yet</a>), any documents you create will be visible to the world (not on diffdown.com, but with a PDS record viewer, <a href="https://atproto.at/viewer?uri=did:plc:za4vlvbizdstoym7lpymc5q5/com.diffdown.document/3mhg62vlznz24">see this example</a>).</p>