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

Remove email/password auth, simplify User model to ATProto-only

- Drop email, password, name, avatar_url, oauth columns from User;
users table now has only id and did
- Remove email/password login and registration routes and handlers
- Remove GetUserByEmail, GetUserByOAuth, UpdateUserPassword from db
- Simplify CreateUser / scanUser to match the new schema
- Update ATProto callback to create minimal User{DID: sub}
- Fix nav to use UserHandle (resolved from DID doc) instead of User.Name
- Point login buttons to /auth/atproto instead of /auth/login
- Add migration 007 for the simplified users table
- About page content and style tweaks
- Landing page gap tweak

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

+51 -58
+1 -4
cmd/server/main.go
··· 83 83 faviconHandler := http.FileServer(http.Dir("static")) 84 84 85 85 // Auth routes 86 - mux.HandleFunc("GET /auth/login", h.LoginPage) 87 - mux.HandleFunc("POST /auth/login", h.LoginSubmit) 88 - mux.HandleFunc("GET /auth/register", h.RegisterPage) 89 - mux.HandleFunc("POST /auth/register", h.RegisterSubmit) 86 + 90 87 mux.HandleFunc("POST /auth/logout", h.Logout) 91 88 mux.HandleFunc("GET /client-metadata.json", h.ClientMetadata) 92 89 mux.HandleFunc("GET /auth/atproto", h.ATProtoLoginPage)
+7 -20
internal/db/db.go
··· 73 73 // --- Users --- 74 74 75 75 func (db *DB) CreateUser(u *model.User) error { 76 - u.ID = NewID() 77 - u.CreatedAt = time.Now() 76 + if u.ID == "" { 77 + u.ID = NewID() 78 + } 78 79 _, err := db.Exec( 79 - `INSERT INTO users (id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, pds_url, created_at) 80 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 81 - u.ID, u.Name, u.Email, u.PasswordHash, u.AvatarURL, u.OAuthProvider, u.OAuthID, u.DID, u.PDSURL, u.CreatedAt, 80 + `INSERT INTO users (id, did) VALUES (?, ?)`, 81 + u.ID, u.DID, 82 82 ) 83 83 return err 84 84 } 85 85 86 86 func (db *DB) scanUser(row interface{ Scan(...interface{}) error }) (*model.User, error) { 87 87 u := &model.User{} 88 - err := row.Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash, &u.AvatarURL, &u.OAuthProvider, &u.OAuthID, &u.DID, &u.PDSURL, &u.CreatedAt) 88 + err := row.Scan(&u.ID, &u.DID) 89 89 if err != nil { 90 90 return nil, err 91 91 } 92 92 return u, nil 93 93 } 94 94 95 - const userColumns = `id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, pds_url, created_at` 95 + const userColumns = `id, did` 96 96 97 97 func (db *DB) GetUserByID(id string) (*model.User, error) { 98 98 return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE id = ?`, id)) 99 99 } 100 100 101 - func (db *DB) GetUserByEmail(email string) (*model.User, error) { 102 - return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE email = ?`, email)) 103 - } 104 - 105 - func (db *DB) GetUserByOAuth(provider, oauthID string) (*model.User, error) { 106 - return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE oauth_provider = ? AND oauth_id = ?`, provider, oauthID)) 107 - } 108 - 109 101 func (db *DB) GetUserByDID(did string) (*model.User, error) { 110 102 return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE did = ?`, did)) 111 - } 112 - 113 - func (db *DB) UpdateUserPassword(userID, hash string) error { 114 - _, err := db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, userID) 115 - return err 116 103 } 117 104 118 105 // --- ATProto Sessions ---
+1 -12
internal/handler/atproto.go
··· 212 212 nonce, _ := sess.Values["atproto_dpop_nonce"].(string) 213 213 tokenEndpoint, _ := sess.Values["atproto_token_endpoint"].(string) 214 214 expectedDID, _ := sess.Values["atproto_did"].(string) 215 - handle, _ := sess.Values["atproto_handle"].(string) 216 215 pdsURL, _ := sess.Values["atproto_pds_url"].(string) 217 216 218 217 // Clean up session state ··· 222 221 delete(sess.Values, "atproto_dpop_nonce") 223 222 delete(sess.Values, "atproto_token_endpoint") 224 223 delete(sess.Values, "atproto_did") 225 - delete(sess.Values, "atproto_handle") 226 224 delete(sess.Values, "atproto_pds_url") 227 225 228 226 // 2. Exchange code for tokens ··· 322 320 // 4. Find or create user 323 321 user, err := h.DB.GetUserByDID(tokenBody.Sub) 324 322 if err == sql.ErrNoRows { 325 - if handle == "" { 326 - handle = tokenBody.Sub 327 - } 328 - did := tokenBody.Sub 329 - // Use DID as synthetic email — ATProto users have no email, 330 - // but the users table requires a unique non-empty value. 331 323 user = &model.User{ 332 - Name: handle, 333 - Email: did, 334 - DID: &did, 335 - PDSURL: pdsURL, 324 + DID: tokenBody.Sub, 336 325 } 337 326 if err := h.DB.CreateUser(user); err != nil { 338 327 log.Printf("ATProto callback: create user: %v", err)
+1 -2
internal/handler/steps_test.go
··· 43 43 func createTestUser(t *testing.T, d *db.DB) *model.User { 44 44 t.Helper() 45 45 user := &model.User{ 46 - Name: "Test User", 47 - Email: "test@example.com", 46 + DID: "did:plc:testuser123", 48 47 } 49 48 if err := d.CreateUser(user); err != nil { 50 49 t.Fatalf("create user: %v", err)
+2 -10
internal/model/models.go
··· 6 6 ) 7 7 8 8 type User struct { 9 - ID string `json:"id"` 10 - Name string `json:"name"` 11 - Email string `json:"email"` 12 - PasswordHash *string `json:"-"` 13 - AvatarURL string `json:"avatar_url"` 14 - OAuthProvider *string `json:"oauth_provider,omitempty"` 15 - OAuthID *string `json:"-"` 16 - DID *string `json:"did,omitempty"` 17 - PDSURL string `json:"pds_url,omitempty"` 18 - CreatedAt time.Time `json:"created_at"` 9 + ID string `json:"id"` 10 + DID string `json:"did"` 19 11 } 20 12 21 13 type ATProtoSession struct {
+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
+1 -1
static/css/style.css
··· 263 263 color: var(--text-secondary); 264 264 display: grid; 265 265 grid-template-columns: 1fr 1fr; 266 - gap: 2rem; 266 + gap: 4rem; 267 267 text-align: left; 268 268 margin-bottom: 2rem; 269 269 }
+10 -6
templates/about.html
··· 7 7 <section class="about-content"> 8 8 <div class="about-col"> 9 9 <h2>What is This?</h2> 10 - <p>Diffdown is a real-time collaborative <a href="https://www.markdownguide.org/basic-syntax/">Markdown</a> editor/previewer built on <a href="https://atproto.brussels/about-the-atmosphere">AT Protocol</a> (the tech that powers <a href="https://bsky.app">Bluesky</a> and <a href="https://atproto.brussels/atproto-apps">other cool apps</a>). 11 - <p>Diffdown is decentralized; it stores documents as AT Protocol records on the document creator's <a href="https://atproto.wiki/en/wiki/reference/core-architecture/pds">PDS</a>, not on the Diffdown server or a cloud provider.</p> 10 + <p>Diffdown is a real-time collaborative <a href="https://www.markdownguide.org/basic-syntax/">Markdown</a> editor/previewer built on <a href="https://atproto.brussels/about-the-atmosphere">AT Protocol</a> (the tech that powers <a href="https://bsky.app">Bluesky</a> and <a href="https://atproto.brussels/atproto-apps">many other cool apps</a>). 11 + <p>Diffdown is decentralized; it stores documents as <a href="https://atproto.wiki/en/wiki/reference/data/records">records</a> on the document creator's <a href="https://atproto.wiki/en/wiki/reference/core-architecture/pds">PDS</a>, not on the Diffdown server or a cloud provider. Your data is yours, literally.</p> 12 12 <h2>About Me</h2> 13 13 <p>I'm a tech tinkerer, co-founder of <a href="https://limeleaf.coop">Limeleaf Worker Collective</a>, and an advisor to a few startups. Read about my journey building Diffdown <a href="https://leaflet.jluther.net">on my Leaflet</a>.</p> 14 14 <h2>Contact</h2> 15 15 <p>Feedback is welcome! Create an issue in the <a href="https://tangled.org/diffdown.com/diffdown-app/issues">Diffdown Tangled repository</a>.</p> 16 16 </div> 17 17 <div class="about-col"> 18 - 19 18 <h2>Status</h2> 20 - <p>This is a prototype. Use at your own risk.</p> 21 - <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>), <strong>any documents you create will be visible to anyone with the URL</strong> (<a href="https://atproto.at/viewer?uri=did:plc:za4vlvbizdstoym7lpymc5q5/com.diffdown.document/3mgzsp6m5hs24">for example</a>).</p> 22 - <p>Expect bugs, breaking changes, and limited features. However, any documents you create will be stored in your AT Proto account, so even if Diffdown goes away, you will still have your documents.</p> 19 + <p>This app is alpha quality. Use at your own risk. Expect bugs, breaking changes, and limited features. However, any documents you create will be stored in your AT Proto account, so even if Diffdown goes away, you will still have your documents.</p> 20 + <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 anyone with the URL to the record (<a href="at://did:plc:za4vlvbizdstoym7lpymc5q5/com.diffdown.document/3mgncllbr7424">see this example</a>).</p> 21 + <h2>Roadmap</h2> 22 + <ul> 23 + <li>Comments</li> 24 + <li>Document versioning</li> 25 + <li>Export to .md, HTML, PDF</li> 26 + </ul> 23 27 <h2>Technology</h2> 24 28 <ul> 25 29 <li><strong>Backend:</strong> Go, SQLite</li>
+2 -2
templates/base.html
··· 39 39 {{if .User}} 40 40 <a href="/">Documents</a> 41 41 <a href="/about">About</a> 42 - <span class="nav-user">{{.User.Name}}</span> 42 + <span class="nav-user">{{.UserHandle}}</span> 43 43 <form method="post" action="/auth/logout" style="display:inline"> 44 44 <button type="submit" class="btn-link">Log out</button> 45 45 </form> 46 46 {{else}} 47 - <a href="/auth/login" class="btn btn-sm">Log in</a> 47 + <a href="/auth/atproto" class="btn btn-sm">Log in</a> 48 48 <a href="/about">About</a> 49 49 {{end}} 50 50 <button id="theme-toggle" class="btn-link" aria-label="Toggle dark mode" onclick="toggleTheme()" style="font-size:1.1rem;padding:0.25rem">☀</button>
+1 -1
templates/landing.html
··· 8 8 </p> 9 9 </section> 10 10 <div class="landing-actions"> 11 - <a href="/auth/login" class="btn btn-lg">Log In</a> 11 + <a href="/auth/atproto" class="btn btn-lg">Log In</a> 12 12 </div> 13 13 <hr class="landing-hr"> 14 14 <section>