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

Migrate to ATProto PDS storage using Standard lexicon

Documents are now stored as site.standard.document records on each
user's PDS via com.atproto.repo XRPC endpoints. Local SQLite retains
only user accounts and ATProto token sessions.

- Add atproto_sessions table to persist DPoP keys and OAuth tokens
- Build XRPC client with DPoP auth, ath claim (RFC 9449), and token refresh
- Replace repo/file model with Document/MarkdownContent (at.markpub.markdown)
- Replace all repo/file handlers and routes with document handlers
- Add site.standard.publication auto-creation on first document
- Request transition:generic scope for write access to custom collections
- Drop repos/files/versions/comments tables (migration 004)
- Remove go-diff dependency and internal/version package
- Add Cache-Control: no-store to client-metadata.json
- Configure custom domain diffdown.jluther.net

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

+866 -924
+12 -15
cmd/server/main.go
··· 42 42 } 43 43 44 44 pages := []string{ 45 - "atproto_login", "dashboard", "diff", "editor", "file_view", "history", 46 - "landing", "login", "new_repo", "register", "repo", 45 + "atproto_login", "documents", "document_view", "document_edit", 46 + "landing", "login", "new_document", "register", 47 47 } 48 48 tmpls := make(map[string]*template.Template, len(pages)) 49 49 for _, page := range pages { ··· 59 59 mux := http.NewServeMux() 60 60 61 61 // Static files are handled outside the mux to avoid conflicts with 62 - // wildcard patterns like /{owner}/{repo}. 62 + // wildcard patterns. 63 63 staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static"))) 64 64 65 65 // Auth routes ··· 76 76 // Dashboard 77 77 mux.HandleFunc("GET /", h.Dashboard) 78 78 79 - // Repo routes 80 - mux.HandleFunc("GET /new", h.NewRepoPage) 81 - mux.HandleFunc("POST /new", h.NewRepoSubmit) 82 - mux.HandleFunc("GET /{owner}/{repo}", h.RepoView) 83 - mux.HandleFunc("GET /{owner}/{repo}/{path...}", h.FileDispatch) 84 - 85 - // Diff 86 - mux.HandleFunc("GET /diff/{v1}/{v2}", h.DiffView) 79 + // Document routes 80 + mux.HandleFunc("GET /docs/new", h.NewDocumentPage) 81 + mux.HandleFunc("POST /docs/new", h.NewDocumentSubmit) 82 + mux.HandleFunc("GET /docs/{rkey}", h.DocumentView) 83 + mux.HandleFunc("GET /docs/{rkey}/edit", h.DocumentEdit) 87 84 88 85 // API 89 86 mux.HandleFunc("POST /api/render", h.APIRender) 90 - mux.HandleFunc("POST /api/files", h.APICreateFile) 91 - mux.HandleFunc("POST /api/files/{fileID}/save", h.APISaveFile) 92 - mux.HandleFunc("PUT /api/files/{fileID}/autosave", h.APIAutoSave) 87 + mux.HandleFunc("POST /api/docs/{rkey}/save", h.APIDocumentSave) 88 + mux.HandleFunc("PUT /api/docs/{rkey}/autosave", h.APIDocumentAutoSave) 89 + mux.HandleFunc("DELETE /api/docs/{rkey}", h.APIDocumentDelete) 93 90 94 - // Middleware stack — static files are intercepted before the mux. 91 + // Middleware stack 95 92 stack := middleware.Logger( 96 93 middleware.InjectUser( 97 94 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
fly.toml
··· 6 6 [env] 7 7 PORT = "8080" 8 8 DB_PATH = "/data/markdownhub.db" 9 - DIFFDOWN_BASE_URL = "https://markdownhub.fly.dev" 9 + DIFFDOWN_BASE_URL = "https://diffdown.jluther.net" 10 10 11 11 [http_service] 12 12 internal_port = 8080
-1
go.mod
··· 7 7 github.com/gorilla/sessions v1.2.2 8 8 github.com/mattn/go-sqlite3 v1.14.22 9 9 github.com/oklog/ulid/v2 v2.1.0 10 - github.com/sergi/go-diff v1.3.1 11 10 github.com/yuin/goldmark v1.7.1 12 11 golang.org/x/crypto v0.22.0 13 12 )
-18
go.sum
··· 1 - github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 - github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 - github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 1 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 5 2 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 6 3 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= ··· 9 6 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 10 7 github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 11 8 github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 12 - github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 13 - github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 - github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 15 9 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 16 10 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 17 11 github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= 18 12 github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 19 13 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 20 - github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 - github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 - github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 23 - github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 24 - github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 - github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 26 - github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 27 14 github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= 28 15 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 29 16 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 30 17 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 31 - gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 - gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 34 - gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 35 - gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+9 -2
internal/atproto/dpop/dpop.go
··· 4 4 "crypto/ecdsa" 5 5 "crypto/elliptic" 6 6 "crypto/rand" 7 + "crypto/sha256" 7 8 "encoding/base64" 8 9 "encoding/json" 9 10 "fmt" ··· 98 99 } 99 100 100 101 // Proof builds a DPoP proof JWT for the given HTTP method and URL. 101 - // nonce is optional (include when the server has issued one). 102 - func (kp *KeyPair) Proof(method, htu, nonce string) (string, error) { 102 + // nonce is optional. accessToken, when non-empty, causes an "ath" claim to be 103 + // included (base64url SHA-256 of the token) as required by RFC 9449 §4.2 when 104 + // presenting a DPoP-bound access token. 105 + func (kp *KeyPair) Proof(method, htu, nonce, accessToken string) (string, error) { 103 106 claims := jwt.MapClaims{ 104 107 "jti": randJTI(), 105 108 "htm": method, ··· 108 111 } 109 112 if nonce != "" { 110 113 claims["nonce"] = nonce 114 + } 115 + if accessToken != "" { 116 + h := sha256.Sum256([]byte(accessToken)) 117 + claims["ath"] = base64.RawURLEncoding.EncodeToString(h[:]) 111 118 } 112 119 113 120 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+103
internal/atproto/token.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "os" 11 + "strings" 12 + "time" 13 + 14 + "github.com/limeleaf/diffdown/internal/atproto/dpop" 15 + "github.com/limeleaf/diffdown/internal/db" 16 + "github.com/limeleaf/diffdown/internal/model" 17 + ) 18 + 19 + // RefreshAccessToken refreshes the access token for the given ATProto session. 20 + func RefreshAccessToken(database *db.DB, session *model.ATProtoSession) error { 21 + kp, err := dpop.UnmarshalPrivate([]byte(session.DPoPKeyJWK)) 22 + if err != nil { 23 + return fmt.Errorf("unmarshal DPoP key: %w", err) 24 + } 25 + 26 + base := os.Getenv("DIFFDOWN_BASE_URL") 27 + if base == "" { 28 + base = "http://127.0.0.1:8080" 29 + } 30 + clientID := strings.TrimRight(base, "/") + "/client-metadata.json" 31 + 32 + params := url.Values{ 33 + "grant_type": {"refresh_token"}, 34 + "refresh_token": {session.RefreshToken}, 35 + "client_id": {clientID}, 36 + } 37 + 38 + proof, err := kp.Proof("POST", session.TokenEndpoint, session.DPoPNonce, "") 39 + if err != nil { 40 + return fmt.Errorf("build DPoP proof: %w", err) 41 + } 42 + 43 + req, _ := http.NewRequest("POST", session.TokenEndpoint, strings.NewReader(params.Encode())) 44 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 45 + req.Header.Set("DPoP", proof) 46 + 47 + resp, err := http.DefaultClient.Do(req) 48 + if err != nil { 49 + return fmt.Errorf("refresh request: %w", err) 50 + } 51 + defer resp.Body.Close() 52 + 53 + // Handle nonce retry 54 + newNonce := resp.Header.Get("DPoP-Nonce") 55 + if resp.StatusCode == http.StatusBadRequest && newNonce != "" { 56 + proof, err = kp.Proof("POST", session.TokenEndpoint, newNonce, "") 57 + if err != nil { 58 + return fmt.Errorf("build DPoP proof (retry): %w", err) 59 + } 60 + req2, _ := http.NewRequest("POST", session.TokenEndpoint, strings.NewReader(params.Encode())) 61 + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") 62 + req2.Header.Set("DPoP", proof) 63 + resp.Body.Close() 64 + resp, err = http.DefaultClient.Do(req2) 65 + if err != nil { 66 + return fmt.Errorf("refresh retry: %w", err) 67 + } 68 + defer resp.Body.Close() 69 + newNonce = resp.Header.Get("DPoP-Nonce") 70 + } 71 + 72 + if resp.StatusCode != http.StatusOK { 73 + body, _ := io.ReadAll(resp.Body) 74 + return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, body) 75 + } 76 + 77 + var tokenBody struct { 78 + AccessToken string `json:"access_token"` 79 + RefreshToken string `json:"refresh_token"` 80 + ExpiresIn int `json:"expires_in"` 81 + } 82 + if err := json.NewDecoder(resp.Body).Decode(&tokenBody); err != nil { 83 + return fmt.Errorf("decode refresh response: %w", err) 84 + } 85 + 86 + if newNonce == "" { 87 + newNonce = session.DPoPNonce 88 + } 89 + 90 + expiresAt := time.Now().Add(time.Duration(tokenBody.ExpiresIn) * time.Second) 91 + if err := database.UpdateATProtoTokens(session.UserID, tokenBody.AccessToken, tokenBody.RefreshToken, newNonce, expiresAt); err != nil { 92 + return fmt.Errorf("update tokens in DB: %w", err) 93 + } 94 + 95 + // Update in-memory session 96 + session.AccessToken = tokenBody.AccessToken 97 + session.RefreshToken = tokenBody.RefreshToken 98 + session.DPoPNonce = newNonce 99 + session.ExpiresAt = expiresAt 100 + 101 + log.Printf("ATProto: refreshed token for user %s", session.UserID) 102 + return nil 103 + }
+293
internal/atproto/xrpc/client.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "github.com/limeleaf/diffdown/internal/atproto" 14 + "github.com/limeleaf/diffdown/internal/atproto/dpop" 15 + "github.com/limeleaf/diffdown/internal/db" 16 + "github.com/limeleaf/diffdown/internal/model" 17 + ) 18 + 19 + type Client struct { 20 + db *db.DB 21 + userID string 22 + session *model.ATProtoSession 23 + kp *dpop.KeyPair 24 + } 25 + 26 + // NewClient creates an XRPC client for the given user. 27 + func NewClient(database *db.DB, userID string) (*Client, error) { 28 + session, err := database.GetATProtoSession(userID) 29 + if err != nil { 30 + return nil, fmt.Errorf("get ATProto session: %w", err) 31 + } 32 + 33 + kp, err := dpop.UnmarshalPrivate([]byte(session.DPoPKeyJWK)) 34 + if err != nil { 35 + return nil, fmt.Errorf("unmarshal DPoP key: %w", err) 36 + } 37 + 38 + return &Client{ 39 + db: database, 40 + userID: userID, 41 + session: session, 42 + kp: kp, 43 + }, nil 44 + } 45 + 46 + // DID returns the user's DID. 47 + func (c *Client) DID() string { 48 + return c.session.DID 49 + } 50 + 51 + // PDSURL returns the user's PDS URL. 52 + func (c *Client) PDSURL() string { 53 + return c.session.PDSURL 54 + } 55 + 56 + // do performs an authenticated XRPC request with DPoP and auto-refresh. 57 + func (c *Client) do(method, url string, body io.Reader) (*http.Response, error) { 58 + // Auto-refresh if token expires within 30 seconds 59 + if time.Until(c.session.ExpiresAt) < 30*time.Second { 60 + if err := atproto.RefreshAccessToken(c.db, c.session); err != nil { 61 + return nil, fmt.Errorf("refresh token: %w", err) 62 + } 63 + } 64 + 65 + proof, err := c.kp.Proof(method, url, c.session.DPoPNonce, c.session.AccessToken) 66 + if err != nil { 67 + return nil, fmt.Errorf("build DPoP proof: %w", err) 68 + } 69 + 70 + req, err := http.NewRequest(method, url, body) 71 + if err != nil { 72 + return nil, err 73 + } 74 + req.Header.Set("Authorization", "DPoP "+c.session.AccessToken) 75 + req.Header.Set("DPoP", proof) 76 + if body != nil { 77 + req.Header.Set("Content-Type", "application/json") 78 + } 79 + 80 + resp, err := http.DefaultClient.Do(req) 81 + if err != nil { 82 + return nil, err 83 + } 84 + 85 + // Update nonce if server sends a new one 86 + if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" && newNonce != c.session.DPoPNonce { 87 + c.session.DPoPNonce = newNonce 88 + c.db.UpdateATProtoTokens(c.userID, c.session.AccessToken, c.session.RefreshToken, newNonce, c.session.ExpiresAt) 89 + } 90 + 91 + // On 401, refresh token and retry once 92 + if resp.StatusCode == http.StatusUnauthorized { 93 + resp.Body.Close() 94 + if err := atproto.RefreshAccessToken(c.db, c.session); err != nil { 95 + return nil, fmt.Errorf("refresh after 401: %w", err) 96 + } 97 + 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 + if body != nil { 101 + if seeker, ok := body.(io.Seeker); ok { 102 + seeker.Seek(0, io.SeekStart) 103 + } 104 + } 105 + 106 + proof, err = c.kp.Proof(method, url, c.session.DPoPNonce, c.session.AccessToken) 107 + if err != nil { 108 + return nil, fmt.Errorf("build DPoP proof (retry): %w", err) 109 + } 110 + 111 + req2, err := http.NewRequest(method, url, body) 112 + if err != nil { 113 + return nil, err 114 + } 115 + req2.Header.Set("Authorization", "DPoP "+c.session.AccessToken) 116 + req2.Header.Set("DPoP", proof) 117 + if body != nil { 118 + req2.Header.Set("Content-Type", "application/json") 119 + } 120 + 121 + resp, err = http.DefaultClient.Do(req2) 122 + if err != nil { 123 + return nil, err 124 + } 125 + 126 + if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" { 127 + c.session.DPoPNonce = newNonce 128 + c.db.UpdateATProtoTokens(c.userID, c.session.AccessToken, c.session.RefreshToken, newNonce, c.session.ExpiresAt) 129 + } 130 + } 131 + 132 + return resp, nil 133 + } 134 + 135 + // Record represents a record returned from listRecords. 136 + type Record struct { 137 + URI string `json:"uri"` 138 + CID string `json:"cid"` 139 + Value json.RawMessage `json:"value"` 140 + } 141 + 142 + // CreateRecord creates a new record in the given collection. 143 + func (c *Client) CreateRecord(collection string, record interface{}) (uri, cid string, err error) { 144 + payload := map[string]interface{}{ 145 + "repo": c.session.DID, 146 + "collection": collection, 147 + "record": record, 148 + } 149 + body, err := json.Marshal(payload) 150 + if err != nil { 151 + return "", "", err 152 + } 153 + 154 + url := strings.TrimRight(c.session.PDSURL, "/") + "/xrpc/com.atproto.repo.createRecord" 155 + resp, err := c.do("POST", url, bytes.NewReader(body)) 156 + if err != nil { 157 + return "", "", err 158 + } 159 + defer resp.Body.Close() 160 + 161 + if resp.StatusCode != http.StatusOK { 162 + respBody, _ := io.ReadAll(resp.Body) 163 + log.Printf("XRPC createRecord %s: HTTP %d: %s", collection, resp.StatusCode, respBody) 164 + return "", "", fmt.Errorf("createRecord failed (HTTP %d): %s", resp.StatusCode, respBody) 165 + } 166 + 167 + var result struct { 168 + URI string `json:"uri"` 169 + CID string `json:"cid"` 170 + } 171 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 172 + return "", "", err 173 + } 174 + return result.URI, result.CID, nil 175 + } 176 + 177 + // PutRecord creates or updates a record with the given rkey. 178 + func (c *Client) PutRecord(collection, rkey string, record interface{}) (uri, cid string, err error) { 179 + payload := map[string]interface{}{ 180 + "repo": c.session.DID, 181 + "collection": collection, 182 + "rkey": rkey, 183 + "record": record, 184 + } 185 + body, err := json.Marshal(payload) 186 + if err != nil { 187 + return "", "", err 188 + } 189 + 190 + url := strings.TrimRight(c.session.PDSURL, "/") + "/xrpc/com.atproto.repo.putRecord" 191 + resp, err := c.do("POST", url, bytes.NewReader(body)) 192 + if err != nil { 193 + return "", "", err 194 + } 195 + defer resp.Body.Close() 196 + 197 + if resp.StatusCode != http.StatusOK { 198 + respBody, _ := io.ReadAll(resp.Body) 199 + log.Printf("XRPC putRecord %s/%s: HTTP %d: %s", collection, rkey, resp.StatusCode, respBody) 200 + return "", "", fmt.Errorf("putRecord failed (HTTP %d): %s", resp.StatusCode, respBody) 201 + } 202 + 203 + var result struct { 204 + URI string `json:"uri"` 205 + CID string `json:"cid"` 206 + } 207 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 208 + return "", "", err 209 + } 210 + return result.URI, result.CID, nil 211 + } 212 + 213 + // GetRecord fetches a single record. 214 + func (c *Client) GetRecord(did, collection, rkey string) (json.RawMessage, string, error) { 215 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 216 + strings.TrimRight(c.session.PDSURL, "/"), did, collection, rkey) 217 + 218 + resp, err := c.do("GET", url, nil) 219 + if err != nil { 220 + return nil, "", err 221 + } 222 + defer resp.Body.Close() 223 + 224 + if resp.StatusCode != http.StatusOK { 225 + respBody, _ := io.ReadAll(resp.Body) 226 + return nil, "", fmt.Errorf("getRecord failed (HTTP %d): %s", resp.StatusCode, respBody) 227 + } 228 + 229 + var result struct { 230 + URI string `json:"uri"` 231 + CID string `json:"cid"` 232 + Value json.RawMessage `json:"value"` 233 + } 234 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 235 + return nil, "", err 236 + } 237 + return result.Value, result.CID, nil 238 + } 239 + 240 + // ListRecords lists records in a collection. 241 + func (c *Client) ListRecords(did, collection string, limit int, cursor string) ([]Record, string, error) { 242 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", 243 + strings.TrimRight(c.session.PDSURL, "/"), did, collection, limit) 244 + if cursor != "" { 245 + url += "&cursor=" + cursor 246 + } 247 + 248 + resp, err := c.do("GET", url, nil) 249 + if err != nil { 250 + return nil, "", err 251 + } 252 + defer resp.Body.Close() 253 + 254 + if resp.StatusCode != http.StatusOK { 255 + respBody, _ := io.ReadAll(resp.Body) 256 + return nil, "", fmt.Errorf("listRecords failed (HTTP %d): %s", resp.StatusCode, respBody) 257 + } 258 + 259 + var result struct { 260 + Records []Record `json:"records"` 261 + Cursor string `json:"cursor"` 262 + } 263 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 264 + return nil, "", err 265 + } 266 + return result.Records, result.Cursor, nil 267 + } 268 + 269 + // DeleteRecord deletes a record. 270 + func (c *Client) DeleteRecord(collection, rkey string) error { 271 + payload := map[string]interface{}{ 272 + "repo": c.session.DID, 273 + "collection": collection, 274 + "rkey": rkey, 275 + } 276 + body, err := json.Marshal(payload) 277 + if err != nil { 278 + return err 279 + } 280 + 281 + url := strings.TrimRight(c.session.PDSURL, "/") + "/xrpc/com.atproto.repo.deleteRecord" 282 + resp, err := c.do("POST", url, bytes.NewReader(body)) 283 + if err != nil { 284 + return err 285 + } 286 + defer resp.Body.Close() 287 + 288 + if resp.StatusCode != http.StatusOK { 289 + respBody, _ := io.ReadAll(resp.Body) 290 + return fmt.Errorf("deleteRecord failed (HTTP %d): %s", resp.StatusCode, respBody) 291 + } 292 + return nil 293 + }
+38 -233
internal/db/db.go
··· 76 76 u.ID = NewID() 77 77 u.CreatedAt = time.Now() 78 78 _, err := db.Exec( 79 - `INSERT INTO users (id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, created_at) 80 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 81 - u.ID, u.Name, u.Email, u.PasswordHash, u.AvatarURL, u.OAuthProvider, u.OAuthID, u.DID, u.CreatedAt, 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, 82 82 ) 83 83 return err 84 84 } 85 85 86 - func (db *DB) GetUserByID(id string) (*model.User, error) { 86 + func (db *DB) scanUser(row interface{ Scan(...interface{}) error }) (*model.User, error) { 87 87 u := &model.User{} 88 - err := db.QueryRow( 89 - `SELECT id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, created_at 90 - FROM users WHERE id = ?`, id, 91 - ).Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash, &u.AvatarURL, &u.OAuthProvider, &u.OAuthID, &u.DID, &u.CreatedAt) 88 + err := row.Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash, &u.AvatarURL, &u.OAuthProvider, &u.OAuthID, &u.DID, &u.PDSURL, &u.CreatedAt) 92 89 if err != nil { 93 90 return nil, err 94 91 } 95 92 return u, nil 96 93 } 97 94 95 + const userColumns = `id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, pds_url, created_at` 96 + 97 + func (db *DB) GetUserByID(id string) (*model.User, error) { 98 + return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE id = ?`, id)) 99 + } 100 + 98 101 func (db *DB) GetUserByEmail(email string) (*model.User, error) { 99 - u := &model.User{} 100 - err := db.QueryRow( 101 - `SELECT id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, created_at 102 - FROM users WHERE email = ?`, email, 103 - ).Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash, &u.AvatarURL, &u.OAuthProvider, &u.OAuthID, &u.DID, &u.CreatedAt) 104 - if err != nil { 105 - return nil, err 106 - } 107 - return u, nil 102 + return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE email = ?`, email)) 108 103 } 109 104 110 105 func (db *DB) GetUserByOAuth(provider, oauthID string) (*model.User, error) { 111 - u := &model.User{} 112 - err := db.QueryRow( 113 - `SELECT id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, created_at 114 - FROM users WHERE oauth_provider = ? AND oauth_id = ?`, provider, oauthID, 115 - ).Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash, &u.AvatarURL, &u.OAuthProvider, &u.OAuthID, &u.DID, &u.CreatedAt) 116 - if err != nil { 117 - return nil, err 118 - } 119 - return u, nil 106 + return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE oauth_provider = ? AND oauth_id = ?`, provider, oauthID)) 120 107 } 121 108 122 109 func (db *DB) GetUserByDID(did string) (*model.User, error) { 123 - u := &model.User{} 124 - err := db.QueryRow( 125 - `SELECT id, name, email, password_hash, avatar_url, oauth_provider, oauth_id, did, created_at 126 - FROM users WHERE did = ?`, did, 127 - ).Scan(&u.ID, &u.Name, &u.Email, &u.PasswordHash, &u.AvatarURL, &u.OAuthProvider, &u.OAuthID, &u.DID, &u.CreatedAt) 128 - if err != nil { 129 - return nil, err 130 - } 131 - return u, nil 110 + return db.scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE did = ?`, did)) 132 111 } 133 112 134 113 func (db *DB) UpdateUserPassword(userID, hash string) error { ··· 136 115 return err 137 116 } 138 117 139 - // --- Repos --- 140 - 141 - func (db *DB) CreateRepo(r *model.Repo) error { 142 - r.ID = NewID() 143 - r.CreatedAt = time.Now() 144 - r.UpdatedAt = r.CreatedAt 145 - tx, err := db.Begin() 146 - if err != nil { 147 - return err 148 - } 149 - defer tx.Rollback() 150 - 151 - _, err = tx.Exec( 152 - `INSERT INTO repos (id, name, slug, description, owner_id, visibility, created_at, updated_at) 153 - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 154 - r.ID, r.Name, r.Slug, r.Description, r.OwnerID, r.Visibility, r.CreatedAt, r.UpdatedAt, 155 - ) 156 - if err != nil { 157 - return err 158 - } 159 - 160 - // Add owner as collaborator 161 - _, err = tx.Exec( 162 - `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES (?, ?, 'owner')`, 163 - r.ID, r.OwnerID, 164 - ) 165 - if err != nil { 166 - return err 167 - } 168 - 169 - return tx.Commit() 170 - } 171 - 172 - func (db *DB) GetRepo(ownerID, slug string) (*model.Repo, error) { 173 - r := &model.Repo{} 174 - err := db.QueryRow( 175 - `SELECT id, name, slug, description, owner_id, visibility, created_at, updated_at 176 - FROM repos WHERE owner_id = ? AND slug = ?`, ownerID, slug, 177 - ).Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.OwnerID, &r.Visibility, &r.CreatedAt, &r.UpdatedAt) 178 - if err != nil { 179 - return nil, err 180 - } 181 - return r, nil 182 - } 183 - 184 - func (db *DB) GetRepoByID(id string) (*model.Repo, error) { 185 - r := &model.Repo{} 186 - err := db.QueryRow( 187 - `SELECT id, name, slug, description, owner_id, visibility, created_at, updated_at 188 - FROM repos WHERE id = ?`, id, 189 - ).Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.OwnerID, &r.Visibility, &r.CreatedAt, &r.UpdatedAt) 190 - if err != nil { 191 - return nil, err 192 - } 193 - return r, nil 194 - } 195 - 196 - func (db *DB) ListUserRepos(userID string) ([]*model.Repo, error) { 197 - rows, err := db.Query( 198 - `SELECT r.id, r.name, r.slug, r.description, r.owner_id, r.visibility, r.created_at, r.updated_at 199 - FROM repos r 200 - JOIN repo_collaborators rc ON r.id = rc.repo_id 201 - WHERE rc.user_id = ? 202 - ORDER BY r.updated_at DESC`, userID, 203 - ) 204 - if err != nil { 205 - return nil, err 206 - } 207 - defer rows.Close() 208 - 209 - var repos []*model.Repo 210 - for rows.Next() { 211 - r := &model.Repo{} 212 - if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.OwnerID, &r.Visibility, &r.CreatedAt, &r.UpdatedAt); err != nil { 213 - return nil, err 214 - } 215 - repos = append(repos, r) 216 - } 217 - return repos, nil 218 - } 118 + // --- ATProto Sessions --- 219 119 220 - func (db *DB) GetUserRole(repoID, userID string) (string, error) { 221 - var role string 222 - err := db.QueryRow( 223 - `SELECT role FROM repo_collaborators WHERE repo_id = ? AND user_id = ?`, 224 - repoID, userID, 225 - ).Scan(&role) 226 - if err != nil { 227 - return "", err 228 - } 229 - return role, nil 230 - } 231 - 232 - // --- Files --- 233 - 234 - func (db *DB) CreateFile(f *model.File) error { 235 - f.ID = NewID() 236 - f.CreatedAt = time.Now() 237 - f.UpdatedAt = f.CreatedAt 120 + func (db *DB) UpsertATProtoSession(s *model.ATProtoSession) error { 238 121 _, err := db.Exec( 239 - `INSERT INTO files (id, repo_id, path, content, created_at, updated_at) 240 - VALUES (?, ?, ?, ?, ?, ?)`, 241 - f.ID, f.RepoID, f.Path, f.Content, f.CreatedAt, f.UpdatedAt, 122 + `INSERT INTO atproto_sessions (user_id, did, pds_url, access_token, refresh_token, dpop_key_jwk, dpop_nonce, token_endpoint, expires_at, updated_at) 123 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 124 + ON CONFLICT(user_id) DO UPDATE SET 125 + did = excluded.did, 126 + pds_url = excluded.pds_url, 127 + access_token = excluded.access_token, 128 + refresh_token = excluded.refresh_token, 129 + dpop_key_jwk = excluded.dpop_key_jwk, 130 + dpop_nonce = excluded.dpop_nonce, 131 + token_endpoint = excluded.token_endpoint, 132 + expires_at = excluded.expires_at, 133 + updated_at = excluded.updated_at`, 134 + s.UserID, s.DID, s.PDSURL, s.AccessToken, s.RefreshToken, s.DPoPKeyJWK, s.DPoPNonce, s.TokenEndpoint, s.ExpiresAt, time.Now(), 242 135 ) 243 136 return err 244 137 } 245 138 246 - func (db *DB) GetFile(repoID, path string) (*model.File, error) { 247 - f := &model.File{} 248 - err := db.QueryRow( 249 - `SELECT id, repo_id, path, content, created_at, updated_at 250 - FROM files WHERE repo_id = ? AND path = ?`, repoID, path, 251 - ).Scan(&f.ID, &f.RepoID, &f.Path, &f.Content, &f.CreatedAt, &f.UpdatedAt) 252 - if err != nil { 253 - return nil, err 254 - } 255 - return f, nil 256 - } 257 - 258 - func (db *DB) GetFileByID(id string) (*model.File, error) { 259 - f := &model.File{} 139 + func (db *DB) GetATProtoSession(userID string) (*model.ATProtoSession, error) { 140 + s := &model.ATProtoSession{} 260 141 err := db.QueryRow( 261 - `SELECT id, repo_id, path, content, created_at, updated_at 262 - FROM files WHERE id = ?`, id, 263 - ).Scan(&f.ID, &f.RepoID, &f.Path, &f.Content, &f.CreatedAt, &f.UpdatedAt) 142 + `SELECT user_id, did, pds_url, access_token, refresh_token, dpop_key_jwk, dpop_nonce, token_endpoint, expires_at, updated_at 143 + FROM atproto_sessions WHERE user_id = ?`, userID, 144 + ).Scan(&s.UserID, &s.DID, &s.PDSURL, &s.AccessToken, &s.RefreshToken, &s.DPoPKeyJWK, &s.DPoPNonce, &s.TokenEndpoint, &s.ExpiresAt, &s.UpdatedAt) 264 145 if err != nil { 265 146 return nil, err 266 147 } 267 - return f, nil 148 + return s, nil 268 149 } 269 150 270 - func (db *DB) UpdateFileContent(id, content string) error { 151 + func (db *DB) UpdateATProtoTokens(userID, accessToken, refreshToken, nonce string, expiresAt time.Time) error { 271 152 _, err := db.Exec( 272 - `UPDATE files SET content = ?, updated_at = ? WHERE id = ?`, 273 - content, time.Now(), id, 153 + `UPDATE atproto_sessions SET access_token = ?, refresh_token = ?, dpop_nonce = ?, expires_at = ?, updated_at = ? WHERE user_id = ?`, 154 + accessToken, refreshToken, nonce, expiresAt, time.Now(), userID, 274 155 ) 275 156 return err 276 157 } 277 158 278 - func (db *DB) ListFiles(repoID string) ([]*model.File, error) { 279 - rows, err := db.Query( 280 - `SELECT id, repo_id, path, '', created_at, updated_at 281 - FROM files WHERE repo_id = ? ORDER BY path`, repoID, 282 - ) 283 - if err != nil { 284 - return nil, err 285 - } 286 - defer rows.Close() 287 - 288 - var files []*model.File 289 - for rows.Next() { 290 - f := &model.File{} 291 - if err := rows.Scan(&f.ID, &f.RepoID, &f.Path, &f.Content, &f.CreatedAt, &f.UpdatedAt); err != nil { 292 - return nil, err 293 - } 294 - files = append(files, f) 295 - } 296 - return files, nil 297 - } 298 - 299 - func (db *DB) DeleteFile(id string) error { 300 - _, err := db.Exec(`DELETE FROM files WHERE id = ?`, id) 301 - return err 302 - } 303 - 304 - // --- Versions --- 305 - 306 - func (db *DB) CreateVersion(v *model.Version) error { 307 - v.ID = NewID() 308 - v.CreatedAt = time.Now() 309 - _, err := db.Exec( 310 - `INSERT INTO versions (id, file_id, content, author_id, message, created_at) 311 - VALUES (?, ?, ?, ?, ?, ?)`, 312 - v.ID, v.FileID, v.Content, v.AuthorID, v.Message, v.CreatedAt, 313 - ) 314 - return err 315 - } 316 - 317 - func (db *DB) ListVersions(fileID string) ([]*model.Version, error) { 318 - rows, err := db.Query( 319 - `SELECT v.id, v.file_id, '', v.author_id, v.message, v.created_at, u.name 320 - FROM versions v 321 - JOIN users u ON v.author_id = u.id 322 - WHERE v.file_id = ? 323 - ORDER BY v.created_at DESC`, fileID, 324 - ) 325 - if err != nil { 326 - return nil, err 327 - } 328 - defer rows.Close() 329 - 330 - var versions []*model.Version 331 - for rows.Next() { 332 - v := &model.Version{} 333 - if err := rows.Scan(&v.ID, &v.FileID, &v.Content, &v.AuthorID, &v.Message, &v.CreatedAt, &v.AuthorName); err != nil { 334 - return nil, err 335 - } 336 - versions = append(versions, v) 337 - } 338 - return versions, nil 339 - } 340 - 341 - func (db *DB) GetVersion(id string) (*model.Version, error) { 342 - v := &model.Version{} 343 - err := db.QueryRow( 344 - `SELECT v.id, v.file_id, v.content, v.author_id, v.message, v.created_at, u.name 345 - FROM versions v 346 - JOIN users u ON v.author_id = u.id 347 - WHERE v.id = ?`, id, 348 - ).Scan(&v.ID, &v.FileID, &v.Content, &v.AuthorID, &v.Message, &v.CreatedAt, &v.AuthorName) 349 - if err != nil { 350 - return nil, err 351 - } 352 - return v, nil 353 - }
+48 -23
internal/handler/atproto.go
··· 10 10 "net/url" 11 11 "os" 12 12 "strings" 13 + "time" 13 14 14 15 "github.com/limeleaf/diffdown/internal/atproto" 15 16 "github.com/limeleaf/diffdown/internal/atproto/dpop" ··· 25 26 } 26 27 27 28 // ClientMetadata serves the OAuth client metadata document. 28 - // The client_id in ATProto OAuth must be a URL pointing to this document. 29 29 func (h *Handler) ClientMetadata(w http.ResponseWriter, r *http.Request) { 30 30 base := baseURL() 31 31 meta := map[string]interface{}{ ··· 35 35 "redirect_uris": []string{base + "/auth/atproto/callback"}, 36 36 "grant_types": []string{"authorization_code", "refresh_token"}, 37 37 "response_types": []string{"code"}, 38 - "scope": "atproto", 38 + "scope": "atproto transition:generic", 39 39 "token_endpoint_auth_method": "none", 40 40 "dpop_bound_access_tokens": true, 41 41 "application_type": "web", 42 42 } 43 43 w.Header().Set("Content-Type", "application/json") 44 + w.Header().Set("Cache-Control", "no-store") 44 45 json.NewEncoder(w).Encode(meta) 45 46 } 46 47 ··· 49 50 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky"}) 50 51 } 51 52 52 - // ATProtoLoginSubmit starts the ATProto OAuth flow: 53 - // 1. Resolve handle → DID → PDS → auth server metadata 54 - // 2. Generate DPoP key + PKCE 55 - // 3. POST PAR request 56 - // 4. Store state in session, redirect user to authorization endpoint 53 + // ATProtoLoginSubmit starts the ATProto OAuth flow. 57 54 func (h *Handler) ATProtoLoginSubmit(w http.ResponseWriter, r *http.Request) { 58 55 handle := strings.TrimSpace(r.FormValue("handle")) 59 56 if handle == "" { ··· 104 101 clientID := baseURL() + "/client-metadata.json" 105 102 106 103 // 3. POST PAR request 107 - parProof, err := kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, "") 104 + parProof, err := kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, "", "") 108 105 if err != nil { 109 106 log.Printf("ATProto: build PAR DPoP proof: %v", err) 110 107 http.Error(w, "Internal error", 500) ··· 115 112 "response_type": {"code"}, 116 113 "client_id": {clientID}, 117 114 "redirect_uri": {redirectURI}, 118 - "scope": {"atproto"}, 115 + "scope": {"atproto transition:generic"}, 119 116 "state": {state}, 120 117 "code_challenge": {challenge}, 121 118 "code_challenge_method": {"S256"}, ··· 134 131 } 135 132 defer resp.Body.Close() 136 133 137 - // Handle DPoP nonce requirement (server may return 400 with nonce on first try) 134 + // Handle DPoP nonce requirement 138 135 nonce := resp.Header.Get("DPoP-Nonce") 139 136 if resp.StatusCode == http.StatusBadRequest && nonce != "" { 140 - parProof, err = kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, nonce) 137 + parProof, err = kp.Proof("POST", meta.PushedAuthorizationRequestEndpoint, nonce, "") 141 138 if err != nil { 142 139 http.Error(w, "Internal error", 500) 143 140 return ··· 181 178 sess.Values["atproto_token_endpoint"] = meta.TokenEndpoint 182 179 sess.Values["atproto_did"] = did 183 180 sess.Values["atproto_handle"] = handle 181 + sess.Values["atproto_pds_url"] = pds 184 182 sess.Save(r, w) 185 183 186 - // Redirect user to authorization endpoint 187 184 authURL := meta.AuthorizationEndpoint + "?client_id=" + url.QueryEscape(clientID) + "&request_uri=" + url.QueryEscape(parResp.RequestURI) 188 185 http.Redirect(w, r, authURL, http.StatusFound) 189 186 } 190 187 191 - // ATProtoCallback handles the redirect back from the PDS after user approval: 192 - // 1. Validate state 193 - // 2. Exchange code for tokens (with DPoP) 194 - // 3. Verify sub (DID) 195 - // 4. Find or create user, set session 188 + // ATProtoCallback handles the redirect back from the PDS after user approval. 196 189 func (h *Handler) ATProtoCallback(w http.ResponseWriter, r *http.Request) { 197 190 sess := auth.GetSession(r) 198 191 ··· 220 213 tokenEndpoint, _ := sess.Values["atproto_token_endpoint"].(string) 221 214 expectedDID, _ := sess.Values["atproto_did"].(string) 222 215 handle, _ := sess.Values["atproto_handle"].(string) 216 + pdsURL, _ := sess.Values["atproto_pds_url"].(string) 223 217 224 218 // Clean up session state 225 219 delete(sess.Values, "atproto_state") ··· 229 223 delete(sess.Values, "atproto_token_endpoint") 230 224 delete(sess.Values, "atproto_did") 231 225 delete(sess.Values, "atproto_handle") 226 + delete(sess.Values, "atproto_pds_url") 232 227 233 228 // 2. Exchange code for tokens 234 229 kp, err := dpop.UnmarshalPrivate([]byte(keyJSON)) ··· 249 244 "code_verifier": {verifier}, 250 245 } 251 246 252 - tokenProof, err := kp.Proof("POST", tokenEndpoint, nonce) 247 + tokenProof, err := kp.Proof("POST", tokenEndpoint, nonce, "") 253 248 if err != nil { 254 249 log.Printf("ATProto callback: build token DPoP proof: %v", err) 255 250 http.Error(w, "Internal error", 500) ··· 271 266 // Handle nonce refresh on token endpoint 272 267 newNonce := tokenResp.Header.Get("DPoP-Nonce") 273 268 if tokenResp.StatusCode == http.StatusBadRequest && newNonce != "" { 274 - tokenProof, err = kp.Proof("POST", tokenEndpoint, newNonce) 269 + tokenProof, err = kp.Proof("POST", tokenEndpoint, newNonce, "") 275 270 if err != nil { 276 271 http.Error(w, "Internal error", 500) 277 272 return ··· 286 281 return 287 282 } 288 283 defer tokenResp.Body.Close() 284 + newNonce = tokenResp.Header.Get("DPoP-Nonce") 289 285 } 290 286 291 287 if tokenResp.StatusCode != http.StatusOK { 292 - log.Printf("ATProto callback: token response %d", tokenResp.StatusCode) 288 + body, _ := io.ReadAll(tokenResp.Body) 289 + log.Printf("ATProto callback: token response %d: %s", tokenResp.StatusCode, body) 293 290 h.render(w, "atproto_login.html", PageData{Title: "Sign in with Bluesky", Error: "Token exchange failed"}) 294 291 return 295 292 } 296 293 297 294 var tokenBody struct { 298 - Sub string `json:"sub"` 295 + Sub string `json:"sub"` 296 + AccessToken string `json:"access_token"` 297 + RefreshToken string `json:"refresh_token"` 298 + ExpiresIn int `json:"expires_in"` 299 299 } 300 300 if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil { 301 301 log.Printf("ATProto callback: decode token response: %v", err) ··· 303 303 return 304 304 } 305 305 306 + // Capture final nonce from response 307 + if newNonce == "" { 308 + newNonce = nonce 309 + } 310 + 306 311 // 3. Verify sub matches the DID we resolved at the start 307 312 if tokenBody.Sub == "" { 308 313 http.Error(w, "Token missing sub claim", http.StatusBadRequest) ··· 322 327 } 323 328 did := tokenBody.Sub 324 329 user = &model.User{ 325 - Name: handle, 326 - DID: &did, 330 + Name: handle, 331 + DID: &did, 332 + PDSURL: pdsURL, 327 333 } 328 334 if err := h.DB.CreateUser(user); err != nil { 329 335 log.Printf("ATProto callback: create user: %v", err) ··· 332 338 } 333 339 } else if err != nil { 334 340 log.Printf("ATProto callback: get user by DID: %v", err) 341 + http.Error(w, "Internal error", 500) 342 + return 343 + } 344 + 345 + // 5. Store ATProto session with tokens 346 + expiresAt := time.Now().Add(time.Duration(tokenBody.ExpiresIn) * time.Second) 347 + atSession := &model.ATProtoSession{ 348 + UserID: user.ID, 349 + DID: tokenBody.Sub, 350 + PDSURL: pdsURL, 351 + AccessToken: tokenBody.AccessToken, 352 + RefreshToken: tokenBody.RefreshToken, 353 + DPoPKeyJWK: keyJSON, 354 + DPoPNonce: newNonce, 355 + TokenEndpoint: tokenEndpoint, 356 + ExpiresAt: expiresAt, 357 + } 358 + if err := h.DB.UpsertATProtoSession(atSession); err != nil { 359 + log.Printf("ATProto callback: upsert session: %v", err) 335 360 http.Error(w, "Internal error", 500) 336 361 return 337 362 }
+207 -244
internal/handler/handler.go
··· 1 1 package handler 2 2 3 3 import ( 4 - "database/sql" 5 4 "encoding/json" 6 5 "fmt" 7 6 "html/template" ··· 9 8 "net/http" 10 9 "regexp" 11 10 "strings" 11 + "time" 12 12 13 + "github.com/limeleaf/diffdown/internal/atproto/xrpc" 13 14 "github.com/limeleaf/diffdown/internal/auth" 14 15 "github.com/limeleaf/diffdown/internal/db" 15 16 "github.com/limeleaf/diffdown/internal/model" 16 17 "github.com/limeleaf/diffdown/internal/render" 17 - "github.com/limeleaf/diffdown/internal/version" 18 18 ) 19 19 20 20 type Handler struct { ··· 64 64 func (h *Handler) jsonResponse(w http.ResponseWriter, data interface{}) { 65 65 w.Header().Set("Content-Type", "application/json") 66 66 json.NewEncoder(w).Encode(data) 67 + } 68 + 69 + func (h *Handler) xrpcClient(userID string) (*xrpc.Client, error) { 70 + return xrpc.NewClient(h.DB, userID) 67 71 } 68 72 69 73 // --- Auth handlers --- ··· 134 138 return 135 139 } 136 140 137 - repos, _ := h.DB.ListUserRepos(user.ID) 138 - h.render(w, "dashboard.html", PageData{ 139 - Title: "Dashboard", 140 - User: user, 141 - Content: repos, 142 - }) 143 - } 144 - 145 - // --- Repo handlers --- 146 - 147 - var slugRe = regexp.MustCompile(`[^a-z0-9-]+`) 148 - 149 - func slugify(s string) string { 150 - s = strings.ToLower(strings.TrimSpace(s)) 151 - s = slugRe.ReplaceAllString(s, "-") 152 - return strings.Trim(s, "-") 153 - } 154 - 155 - func (h *Handler) NewRepoPage(w http.ResponseWriter, r *http.Request) { 156 - h.render(w, "new_repo.html", PageData{Title: "New Repository", User: h.currentUser(r)}) 157 - } 158 - 159 - func (h *Handler) NewRepoSubmit(w http.ResponseWriter, r *http.Request) { 160 - user := h.currentUser(r) 161 - name := strings.TrimSpace(r.FormValue("name")) 162 - desc := strings.TrimSpace(r.FormValue("description")) 163 - vis := r.FormValue("visibility") 164 - if vis == "" { 165 - vis = "private" 166 - } 167 - 168 - if name == "" { 169 - h.render(w, "new_repo.html", PageData{Title: "New Repository", User: user, Error: "Name is required"}) 141 + client, err := h.xrpcClient(user.ID) 142 + if err != nil { 143 + log.Printf("Dashboard: xrpc client: %v", err) 144 + h.render(w, "documents.html", PageData{ 145 + Title: "Documents", 146 + User: user, 147 + Content: []*model.Document{}, 148 + }) 170 149 return 171 150 } 172 151 173 - repo := &model.Repo{ 174 - Name: name, 175 - Slug: slugify(name), 176 - Description: desc, 177 - OwnerID: user.ID, 178 - Visibility: vis, 179 - } 180 - if err := h.DB.CreateRepo(repo); err != nil { 181 - h.render(w, "new_repo.html", PageData{Title: "New Repository", User: user, Error: "Could not create repo"}) 152 + records, _, err := client.ListRecords(client.DID(), "site.standard.document", 100, "") 153 + if err != nil { 154 + log.Printf("Dashboard: list records: %v", err) 155 + h.render(w, "documents.html", PageData{ 156 + Title: "Documents", 157 + User: user, 158 + Content: []*model.Document{}, 159 + }) 182 160 return 183 161 } 184 162 185 - // Create default README.md 186 - f := &model.File{ 187 - RepoID: repo.ID, 188 - Path: "README.md", 189 - Content: fmt.Sprintf("# %s\n\n%s\n", name, desc), 163 + var docs []*model.Document 164 + for _, rec := range records { 165 + doc := &model.Document{} 166 + if err := json.Unmarshal(rec.Value, doc); err != nil { 167 + continue 168 + } 169 + doc.URI = rec.URI 170 + doc.CID = rec.CID 171 + doc.RKey = model.RKeyFromURI(rec.URI) 172 + docs = append(docs, doc) 190 173 } 191 - h.DB.CreateFile(f) 192 174 193 - http.Redirect(w, r, fmt.Sprintf("/%s/%s", user.Name, repo.Slug), http.StatusSeeOther) 175 + h.render(w, "documents.html", PageData{ 176 + Title: "Documents", 177 + User: user, 178 + Content: docs, 179 + }) 194 180 } 195 181 196 - // --- Repo view (file browser) --- 182 + // --- Document handlers --- 197 183 198 - func (h *Handler) RepoView(w http.ResponseWriter, r *http.Request) { 184 + func (h *Handler) NewDocumentPage(w http.ResponseWriter, r *http.Request) { 199 185 user := h.currentUser(r) 200 - ownerName := r.PathValue("owner") 201 - repoSlug := r.PathValue("repo") 202 - 203 - owner, err := h.DB.GetUserByEmail("") // We need GetUserByName 204 - _ = owner 205 - // For now, use a simpler approach — look up repos by iterating 206 - // TODO: add GetUserByName to DB 207 - _ = ownerName 208 - 209 - // Simplified: get repo by current user for now 210 186 if user == nil { 211 - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 212 - return 213 - } 214 - 215 - repo, err := h.DB.GetRepo(user.ID, repoSlug) 216 - if err != nil { 217 - http.NotFound(w, r) 187 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 218 188 return 219 189 } 190 + h.render(w, "new_document.html", PageData{Title: "New Document", User: user}) 191 + } 220 192 221 - files, _ := h.DB.ListFiles(repo.ID) 193 + // stripMarkdown removes basic markdown syntax to produce plain text for textContent. 194 + var mdSyntaxRe = regexp.MustCompile(`(?m)^#{1,6}\s+|[*_~` + "`" + `\[\]()>]`) 222 195 223 - type RepoViewData struct { 224 - Repo *model.Repo 225 - Files []*model.File 226 - } 227 - h.render(w, "repo.html", PageData{ 228 - Title: repo.Name, 229 - User: user, 230 - Content: RepoViewData{Repo: repo, Files: files}, 231 - }) 196 + func stripMarkdown(md string) string { 197 + return strings.TrimSpace(mdSyntaxRe.ReplaceAllString(md, "")) 232 198 } 233 199 234 - // --- File dispatcher --- 200 + // ensurePublication creates the site.standard.publication record if it doesn't exist. 201 + func (h *Handler) ensurePublication(client *xrpc.Client, user *model.User) (string, error) { 202 + pubURI := fmt.Sprintf("at://%s/site.standard.publication/self", client.DID()) 235 203 236 - // FileDispatch routes /{owner}/{repo}/{path...} to the correct handler based 237 - // on whether the path ends with "/edit" or "/history". 238 - func (h *Handler) FileDispatch(w http.ResponseWriter, r *http.Request) { 239 - path := r.PathValue("path") 240 - switch { 241 - case strings.HasSuffix(path, "/edit"): 242 - h.editor(w, r, strings.TrimSuffix(path, "/edit")) 243 - case strings.HasSuffix(path, "/history"): 244 - h.versionHistory(w, r, strings.TrimSuffix(path, "/history")) 245 - default: 246 - h.fileView(w, r, path) 204 + // Check if it exists 205 + _, _, err := client.GetRecord(client.DID(), "site.standard.publication", "self") 206 + if err == nil { 207 + return pubURI, nil 208 + } 209 + 210 + // Create it 211 + base := baseURL() 212 + pub := map[string]interface{}{ 213 + "$type": "site.standard.publication", 214 + "url": base, 215 + "name": user.Name, 247 216 } 217 + _, _, err = client.PutRecord("site.standard.publication", "self", pub) 218 + if err != nil { 219 + return "", fmt.Errorf("create publication: %w", err) 220 + } 221 + log.Printf("Created publication record for %s", user.Name) 222 + return pubURI, nil 248 223 } 249 224 250 - // --- File view (rendered) --- 251 - 252 - func (h *Handler) fileView(w http.ResponseWriter, r *http.Request, filePath string) { 225 + func (h *Handler) NewDocumentSubmit(w http.ResponseWriter, r *http.Request) { 253 226 user := h.currentUser(r) 254 - repoSlug := r.PathValue("repo") 227 + if user == nil { 228 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 229 + return 230 + } 255 231 256 - if user == nil { 257 - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 232 + title := strings.TrimSpace(r.FormValue("title")) 233 + if title == "" { 234 + h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Title is required"}) 258 235 return 259 236 } 260 237 261 - repo, err := h.DB.GetRepo(user.ID, repoSlug) 238 + client, err := h.xrpcClient(user.ID) 262 239 if err != nil { 263 - http.NotFound(w, r) 240 + log.Printf("NewDocumentSubmit: xrpc client: %v", err) 241 + h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Could not connect to your PDS"}) 264 242 return 265 243 } 266 244 267 - file, err := h.DB.GetFile(repo.ID, filePath) 245 + siteURI, err := h.ensurePublication(client, user) 268 246 if err != nil { 269 - http.NotFound(w, r) 247 + log.Printf("NewDocumentSubmit: ensure publication: %v", err) 248 + h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Could not set up publication"}) 270 249 return 271 250 } 272 251 273 - rendered, _ := render.Markdown([]byte(file.Content)) 252 + now := time.Now().UTC().Format(time.RFC3339) 253 + initialMD := fmt.Sprintf("# %s\n", title) 254 + doc := map[string]interface{}{ 255 + "$type": "site.standard.document", 256 + "site": siteURI, 257 + "title": title, 258 + "content": map[string]interface{}{ 259 + "$type": "at.markpub.markdown", 260 + "flavor": "gfm", 261 + "text": map[string]interface{}{ 262 + "rawMarkdown": initialMD, 263 + }, 264 + }, 265 + "textContent": stripMarkdown(initialMD), 266 + "publishedAt": now, 267 + "updatedAt": now, 268 + } 274 269 275 - type FileViewData struct { 276 - Repo *model.Repo 277 - File *model.File 278 - Rendered template.HTML 270 + uri, _, err := client.CreateRecord("site.standard.document", doc) 271 + if err != nil { 272 + log.Printf("NewDocumentSubmit: create record: %v", err) 273 + h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Could not create document"}) 274 + return 279 275 } 280 - h.render(w, "file_view.html", PageData{ 281 - Title: file.Path + " — " + repo.Name, 282 - User: user, 283 - Content: FileViewData{Repo: repo, File: file, Rendered: template.HTML(rendered)}, 284 - }) 285 - } 286 276 287 - // --- Editor --- 277 + rkey := model.RKeyFromURI(uri) 278 + http.Redirect(w, r, fmt.Sprintf("/docs/%s/edit", rkey), http.StatusSeeOther) 279 + } 288 280 289 - func (h *Handler) editor(w http.ResponseWriter, r *http.Request, filePath string) { 281 + // DocumentView renders a document as HTML. 282 + func (h *Handler) DocumentView(w http.ResponseWriter, r *http.Request) { 290 283 user := h.currentUser(r) 291 - repoSlug := r.PathValue("repo") 292 - 293 284 if user == nil { 294 - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 285 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 295 286 return 296 287 } 297 288 298 - repo, err := h.DB.GetRepo(user.ID, repoSlug) 289 + rkey := r.PathValue("rkey") 290 + client, err := h.xrpcClient(user.ID) 299 291 if err != nil { 300 - http.NotFound(w, r) 292 + http.Error(w, "Could not connect to PDS", 500) 301 293 return 302 294 } 303 295 304 - file, err := h.DB.GetFile(repo.ID, filePath) 296 + value, _, err := client.GetRecord(client.DID(), "site.standard.document", rkey) 305 297 if err != nil { 306 298 http.NotFound(w, r) 307 299 return 308 300 } 309 301 310 - type EditorData struct { 311 - Repo *model.Repo 312 - File *model.File 302 + doc := &model.Document{} 303 + if err := json.Unmarshal(value, doc); err != nil { 304 + http.Error(w, "Invalid document", 500) 305 + return 306 + } 307 + doc.RKey = rkey 308 + 309 + var rendered string 310 + if doc.Content != nil { 311 + rendered, _ = render.Markdown([]byte(doc.Content.Text.RawMarkdown)) 313 312 } 314 - h.render(w, "editor.html", PageData{ 315 - Title: "Edit " + file.Path, 313 + 314 + type DocumentViewData struct { 315 + Doc *model.Document 316 + Rendered template.HTML 317 + } 318 + h.render(w, "document_view.html", PageData{ 319 + Title: doc.Title, 316 320 User: user, 317 - Content: EditorData{Repo: repo, File: file}, 321 + Content: DocumentViewData{Doc: doc, Rendered: template.HTML(rendered)}, 318 322 }) 319 323 } 320 324 321 - // --- API: Save file --- 322 - 323 - func (h *Handler) APISaveFile(w http.ResponseWriter, r *http.Request) { 325 + // DocumentEdit renders the editor for a document. 326 + func (h *Handler) DocumentEdit(w http.ResponseWriter, r *http.Request) { 324 327 user := h.currentUser(r) 325 328 if user == nil { 326 - http.Error(w, "Unauthorized", 401) 329 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 327 330 return 328 331 } 329 332 330 - var req struct { 331 - Content string `json:"content"` 332 - Message string `json:"message"` 333 - } 334 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 335 - http.Error(w, "Bad request", 400) 333 + rkey := r.PathValue("rkey") 334 + client, err := h.xrpcClient(user.ID) 335 + if err != nil { 336 + http.Error(w, "Could not connect to PDS", 500) 336 337 return 337 338 } 338 339 339 - fileID := r.PathValue("fileID") 340 - file, err := h.DB.GetFileByID(fileID) 340 + value, _, err := client.GetRecord(client.DID(), "site.standard.document", rkey) 341 341 if err != nil { 342 342 http.NotFound(w, r) 343 343 return 344 344 } 345 345 346 - // Update file content 347 - h.DB.UpdateFileContent(file.ID, req.Content) 348 - 349 - // Create version snapshot 350 - v := &model.Version{ 351 - FileID: file.ID, 352 - Content: req.Content, 353 - AuthorID: user.ID, 354 - Message: req.Message, 346 + doc := &model.Document{} 347 + if err := json.Unmarshal(value, doc); err != nil { 348 + http.Error(w, "Invalid document", 500) 349 + return 355 350 } 356 - h.DB.CreateVersion(v) 351 + doc.RKey = rkey 357 352 358 - h.jsonResponse(w, map[string]string{"status": "ok", "version_id": v.ID}) 353 + h.render(w, "document_edit.html", PageData{ 354 + Title: "Edit " + doc.Title, 355 + User: user, 356 + Content: doc, 357 + }) 359 358 } 360 359 361 - // --- API: Auto-save (no version) --- 362 - 363 - func (h *Handler) APIAutoSave(w http.ResponseWriter, r *http.Request) { 360 + // APIDocumentSave saves a document to the PDS. 361 + func (h *Handler) APIDocumentSave(w http.ResponseWriter, r *http.Request) { 364 362 user := h.currentUser(r) 365 363 if user == nil { 366 364 http.Error(w, "Unauthorized", 401) 367 365 return 368 366 } 369 367 368 + rkey := r.PathValue("rkey") 370 369 var req struct { 371 370 Content string `json:"content"` 371 + Title string `json:"title"` 372 372 } 373 373 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 374 374 http.Error(w, "Bad request", 400) 375 375 return 376 376 } 377 377 378 - fileID := r.PathValue("fileID") 379 - h.DB.UpdateFileContent(fileID, req.Content) 380 - h.jsonResponse(w, map[string]string{"status": "ok"}) 381 - } 382 - 383 - // --- API: Render markdown --- 384 - 385 - func (h *Handler) APIRender(w http.ResponseWriter, r *http.Request) { 386 - var req struct { 387 - Content string `json:"content"` 388 - } 389 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 390 - http.Error(w, "Bad request", 400) 378 + client, err := h.xrpcClient(user.ID) 379 + if err != nil { 380 + http.Error(w, "Could not connect to PDS", 500) 391 381 return 392 382 } 393 383 394 - rendered, err := render.Markdown([]byte(req.Content)) 384 + // Fetch existing record to preserve fields 385 + value, _, err := client.GetRecord(client.DID(), "site.standard.document", rkey) 395 386 if err != nil { 396 - http.Error(w, "Render error", 500) 387 + http.Error(w, "Document not found", 404) 397 388 return 398 389 } 399 390 400 - h.jsonResponse(w, map[string]string{"html": rendered}) 401 - } 391 + var existing map[string]interface{} 392 + json.Unmarshal(value, &existing) 402 393 403 - // --- Version history --- 394 + // Update content 395 + title := req.Title 396 + if title == "" { 397 + if t, ok := existing["title"].(string); ok { 398 + title = t 399 + } 400 + } 404 401 405 - func (h *Handler) versionHistory(w http.ResponseWriter, r *http.Request, filePath string) { 406 - user := h.currentUser(r) 407 - repoSlug := r.PathValue("repo") 408 - 409 - repo, err := h.DB.GetRepo(user.ID, repoSlug) 410 - if err != nil { 411 - http.NotFound(w, r) 412 - return 402 + now := time.Now().UTC().Format(time.RFC3339) 403 + existing["title"] = title 404 + existing["content"] = map[string]interface{}{ 405 + "$type": "at.markpub.markdown", 406 + "flavor": "gfm", 407 + "text": map[string]interface{}{ 408 + "rawMarkdown": req.Content, 409 + }, 413 410 } 411 + existing["textContent"] = stripMarkdown(req.Content) 412 + existing["updatedAt"] = now 414 413 415 - file, err := h.DB.GetFile(repo.ID, filePath) 414 + _, _, err = client.PutRecord("site.standard.document", rkey, existing) 416 415 if err != nil { 417 - http.NotFound(w, r) 416 + log.Printf("APIDocumentSave: put record: %v", err) 417 + http.Error(w, "Save failed", 500) 418 418 return 419 419 } 420 420 421 - versions, _ := h.DB.ListVersions(file.ID) 422 - 423 - type HistoryData struct { 424 - Repo *model.Repo 425 - File *model.File 426 - Versions []*model.Version 427 - } 428 - h.render(w, "history.html", PageData{ 429 - Title: "History — " + file.Path, 430 - User: user, 431 - Content: HistoryData{Repo: repo, File: file, Versions: versions}, 432 - }) 421 + h.jsonResponse(w, map[string]string{"status": "ok"}) 433 422 } 434 423 435 - // --- Diff view --- 424 + // APIDocumentAutoSave is the same as save, called on debounce from editor. 425 + func (h *Handler) APIDocumentAutoSave(w http.ResponseWriter, r *http.Request) { 426 + h.APIDocumentSave(w, r) 427 + } 436 428 437 - func (h *Handler) DiffView(w http.ResponseWriter, r *http.Request) { 429 + // APIDocumentDelete deletes a document from the PDS. 430 + func (h *Handler) APIDocumentDelete(w http.ResponseWriter, r *http.Request) { 438 431 user := h.currentUser(r) 439 - v1ID := r.PathValue("v1") 440 - v2ID := r.PathValue("v2") 441 - 442 - v1, err := h.DB.GetVersion(v1ID) 443 - if err != nil { 444 - http.NotFound(w, r) 432 + if user == nil { 433 + http.Error(w, "Unauthorized", 401) 445 434 return 446 435 } 447 - v2, err := h.DB.GetVersion(v2ID) 436 + 437 + rkey := r.PathValue("rkey") 438 + client, err := h.xrpcClient(user.ID) 448 439 if err != nil { 449 - http.NotFound(w, r) 440 + http.Error(w, "Could not connect to PDS", 500) 450 441 return 451 442 } 452 443 453 - file, _ := h.DB.GetFileByID(v1.FileID) 454 - repo, _ := h.DB.GetRepoByID(file.RepoID) 455 - 456 - diffLines := version.Diff(v1.Content, v2.Content) 457 - 458 - type DiffData struct { 459 - Repo *model.Repo 460 - File *model.File 461 - V1 *model.Version 462 - V2 *model.Version 463 - Lines []version.DiffLine 444 + if err := client.DeleteRecord("site.standard.document", rkey); err != nil { 445 + log.Printf("APIDocumentDelete: %v", err) 446 + http.Error(w, "Delete failed", 500) 447 + return 464 448 } 465 - h.render(w, "diff.html", PageData{ 466 - Title: "Diff", 467 - User: user, 468 - Content: DiffData{Repo: repo, File: file, V1: v1, V2: v2, Lines: diffLines}, 469 - }) 449 + 450 + h.jsonResponse(w, map[string]string{"status": "ok"}) 470 451 } 471 452 472 - // --- API: Create file --- 473 - 474 - func (h *Handler) APICreateFile(w http.ResponseWriter, r *http.Request) { 475 - user := h.currentUser(r) 476 - if user == nil { 477 - http.Error(w, "Unauthorized", 401) 478 - return 479 - } 453 + // --- API: Render markdown --- 480 454 455 + func (h *Handler) APIRender(w http.ResponseWriter, r *http.Request) { 481 456 var req struct { 482 - RepoID string `json:"repo_id"` 483 - Path string `json:"path"` 457 + Content string `json:"content"` 484 458 } 485 459 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 486 460 http.Error(w, "Bad request", 400) 487 461 return 488 462 } 489 463 490 - // Verify user has access 491 - _, err := h.DB.GetUserRole(req.RepoID, user.ID) 492 - if err == sql.ErrNoRows { 493 - http.Error(w, "Forbidden", 403) 494 - return 495 - } 496 - 497 - f := &model.File{ 498 - RepoID: req.RepoID, 499 - Path: req.Path, 500 - Content: "", 501 - } 502 - if err := h.DB.CreateFile(f); err != nil { 503 - http.Error(w, "Could not create file", 500) 464 + rendered, err := render.Markdown([]byte(req.Content)) 465 + if err != nil { 466 + http.Error(w, "Render error", 500) 504 467 return 505 468 } 506 469 507 - h.jsonResponse(w, f) 470 + h.jsonResponse(w, map[string]string{"html": rendered}) 508 471 }
+44 -47
internal/model/models.go
··· 1 1 package model 2 2 3 - import "time" 3 + import ( 4 + "strings" 5 + "time" 6 + ) 4 7 5 8 type User struct { 6 9 ID string `json:"id"` ··· 11 14 OAuthProvider *string `json:"oauth_provider,omitempty"` 12 15 OAuthID *string `json:"-"` 13 16 DID *string `json:"did,omitempty"` 17 + PDSURL string `json:"pds_url,omitempty"` 14 18 CreatedAt time.Time `json:"created_at"` 15 19 } 16 20 17 - type Repo struct { 18 - ID string `json:"id"` 19 - Name string `json:"name"` 20 - Slug string `json:"slug"` 21 - Description string `json:"description"` 22 - OwnerID string `json:"owner_id"` 23 - Visibility string `json:"visibility"` // private, public, unlisted 24 - CreatedAt time.Time `json:"created_at"` 25 - UpdatedAt time.Time `json:"updated_at"` 21 + type ATProtoSession struct { 22 + UserID string `json:"user_id"` 23 + DID string `json:"did"` 24 + PDSURL string `json:"pds_url"` 25 + AccessToken string `json:"access_token"` 26 + RefreshToken string `json:"refresh_token"` 27 + DPoPKeyJWK string `json:"dpop_key_jwk"` 28 + DPoPNonce string `json:"dpop_nonce"` 29 + TokenEndpoint string `json:"token_endpoint"` 30 + ExpiresAt time.Time `json:"expires_at"` 31 + UpdatedAt time.Time `json:"updated_at"` 26 32 } 27 33 28 - type RepoCollaborator struct { 29 - RepoID string `json:"repo_id"` 30 - UserID string `json:"user_id"` 31 - Role string `json:"role"` // owner, editor, commenter, viewer 34 + // --- Document model (Standard lexicon) --- 35 + 36 + type Document struct { 37 + // ATProto metadata 38 + URI string `json:"uri,omitempty"` 39 + CID string `json:"cid,omitempty"` 40 + RKey string `json:"rkey,omitempty"` 41 + // Standard fields 42 + Title string `json:"title"` 43 + Content *MarkdownContent `json:"content,omitempty"` 44 + TextContent string `json:"textContent,omitempty"` 45 + PublishedAt string `json:"publishedAt"` 46 + UpdatedAt string `json:"updatedAt,omitempty"` 47 + Site string `json:"site"` 32 48 } 33 49 34 - type File struct { 35 - ID string `json:"id"` 36 - RepoID string `json:"repo_id"` 37 - Path string `json:"path"` 38 - Content string `json:"content"` 39 - CreatedAt time.Time `json:"created_at"` 40 - UpdatedAt time.Time `json:"updated_at"` 50 + type MarkdownContent struct { 51 + Type string `json:"$type"` 52 + Flavor string `json:"flavor"` 53 + Text MarkdownText `json:"text"` 41 54 } 42 55 43 - type Version struct { 44 - ID string `json:"id"` 45 - FileID string `json:"file_id"` 46 - Content string `json:"content"` 47 - AuthorID string `json:"author_id"` 48 - Message string `json:"message"` 49 - CreatedAt time.Time `json:"created_at"` 50 - // Computed fields for display 51 - AuthorName string `json:"author_name,omitempty"` 52 - LinesAdded int `json:"lines_added,omitempty"` 53 - LinesRemoved int `json:"lines_removed,omitempty"` 56 + type MarkdownText struct { 57 + RawMarkdown string `json:"rawMarkdown"` 54 58 } 55 59 56 - type Comment struct { 57 - ID string `json:"id"` 58 - FileID string `json:"file_id"` 59 - VersionID *string `json:"version_id,omitempty"` 60 - LineStart int `json:"line_start"` 61 - LineEnd int `json:"line_end"` 62 - ContentHash string `json:"content_hash"` 63 - Body string `json:"body"` 64 - AuthorID string `json:"author_id"` 65 - ParentID *string `json:"parent_id,omitempty"` 66 - Resolved bool `json:"resolved"` 67 - CreatedAt time.Time `json:"created_at"` 68 - // Computed 69 - AuthorName string `json:"author_name,omitempty"` 70 - Replies []*Comment `json:"replies,omitempty"` 60 + // RKeyFromURI extracts the rkey (last path segment) from an at:// URI. 61 + func RKeyFromURI(uri string) string { 62 + parts := strings.Split(uri, "/") 63 + if len(parts) > 0 { 64 + return parts[len(parts)-1] 65 + } 66 + return "" 71 67 } 68 +
-77
internal/version/diff.go
··· 1 - package version 2 - 3 - import ( 4 - "strings" 5 - 6 - difflib "github.com/sergi/go-diff/diffmatchpatch" 7 - ) 8 - 9 - type DiffLine struct { 10 - Type string `json:"type"` // equal, insert, delete 11 - Content string `json:"content"` 12 - OldNum int `json:"old_num,omitempty"` 13 - NewNum int `json:"new_num,omitempty"` 14 - } 15 - 16 - func Diff(oldText, newText string) []DiffLine { 17 - dmp := difflib.New() 18 - diffs := dmp.DiffMain(oldText, newText, true) 19 - diffs = dmp.DiffCleanupSemantic(diffs) 20 - 21 - var lines []DiffLine 22 - oldNum := 1 23 - newNum := 1 24 - 25 - for _, d := range diffs { 26 - parts := strings.Split(d.Text, "\n") 27 - for i, part := range parts { 28 - if i == len(parts)-1 && part == "" { 29 - continue 30 - } 31 - switch d.Type { 32 - case difflib.DiffEqual: 33 - lines = append(lines, DiffLine{ 34 - Type: "equal", 35 - Content: part, 36 - OldNum: oldNum, 37 - NewNum: newNum, 38 - }) 39 - oldNum++ 40 - newNum++ 41 - case difflib.DiffInsert: 42 - lines = append(lines, DiffLine{ 43 - Type: "insert", 44 - Content: part, 45 - NewNum: newNum, 46 - }) 47 - newNum++ 48 - case difflib.DiffDelete: 49 - lines = append(lines, DiffLine{ 50 - Type: "delete", 51 - Content: part, 52 - OldNum: oldNum, 53 - }) 54 - oldNum++ 55 - } 56 - } 57 - } 58 - return lines 59 - } 60 - 61 - func Stats(oldText, newText string) (added, removed int) { 62 - dmp := difflib.New() 63 - diffs := dmp.DiffMain(oldText, newText, true) 64 - for _, d := range diffs { 65 - lineCount := strings.Count(d.Text, "\n") 66 - if d.Text != "" && !strings.HasSuffix(d.Text, "\n") { 67 - lineCount++ 68 - } 69 - switch d.Type { 70 - case difflib.DiffInsert: 71 - added += lineCount 72 - case difflib.DiffDelete: 73 - removed += lineCount 74 - } 75 - } 76 - return 77 - }
+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;
+1 -1
templates/base.html
··· 19 19 <a href="/" class="logo">Diffdown</a> 20 20 <div class="nav-right"> 21 21 {{if .User}} 22 - <a href="/new">New Repo</a> 22 + <a href="/docs/new">New Document</a> 23 23 <span class="nav-user">{{.User.Name}}</span> 24 24 <form method="post" action="/auth/logout" style="display:inline"> 25 25 <button type="submit" class="btn-link">Log out</button>
-27
templates/dashboard.html
··· 1 - {{template "base" .}} 2 - {{define "content"}} 3 - <div class="dashboard"> 4 - <div class="dashboard-header"> 5 - <h2>Your Repositories</h2> 6 - <a href="/new" class="btn">New Repo</a> 7 - </div> 8 - {{$user := .User}} 9 - {{with .Content}} 10 - <div class="repo-list"> 11 - {{range .}} 12 - <a href="/{{$user.Name}}/{{.Slug}}" class="repo-card"> 13 - <h3>{{.Name}}</h3> 14 - <p>{{.Description}}</p> 15 - <span class="repo-vis">{{.Visibility}}</span> 16 - <time>Updated {{.UpdatedAt.Format "Jan 2, 2006"}}</time> 17 - </a> 18 - {{else}} 19 - <div class="empty-state"> 20 - <p>No repositories yet.</p> 21 - <a href="/new" class="btn">Create your first repo</a> 22 - </div> 23 - {{end}} 24 - </div> 25 - {{end}} 26 - </div> 27 - {{end}}
-42
templates/diff.html
··· 1 - {{template "base" .}} 2 - {{define "head"}} 3 - <link rel="stylesheet" href="/static/css/diff.css"> 4 - {{end}} 5 - 6 - {{define "content"}} 7 - {{with .Content}} 8 - <div class="diff-page"> 9 - <div class="file-header"> 10 - <div class="breadcrumb"> 11 - <a href="/">{{.Repo.Name}}</a> 12 - <span>/</span> 13 - <span>{{.File.Path}}</span> 14 - <span>/</span> 15 - <span>Diff</span> 16 - </div> 17 - </div> 18 - <div class="diff-meta"> 19 - <div class="diff-version"> 20 - <strong>From:</strong> {{.V1.AuthorName}} — {{.V1.CreatedAt.Format "Jan 2, 2006 3:04 PM"}} 21 - {{if .V1.Message}}<em>({{.V1.Message}})</em>{{end}} 22 - </div> 23 - <div class="diff-version"> 24 - <strong>To:</strong> {{.V2.AuthorName}} — {{.V2.CreatedAt.Format "Jan 2, 2006 3:04 PM"}} 25 - {{if .V2.Message}}<em>({{.V2.Message}})</em>{{end}} 26 - </div> 27 - </div> 28 - <div class="diff-view"> 29 - <table class="diff-table"> 30 - {{range .Lines}} 31 - <tr class="diff-line diff-{{.Type}}"> 32 - <td class="diff-num">{{if .OldNum}}{{.OldNum}}{{end}}</td> 33 - <td class="diff-num">{{if .NewNum}}{{.NewNum}}{{end}}</td> 34 - <td class="diff-sign">{{if eq .Type "insert"}}+{{else if eq .Type "delete"}}-{{else}} {{end}}</td> 35 - <td class="diff-content"><pre>{{.Content}}</pre></td> 36 - </tr> 37 - {{end}} 38 - </table> 39 - </div> 40 - </div> 41 - {{end}} 42 - {{end}}
+39
templates/document_view.html
··· 1 + {{template "base" .}} 2 + {{define "head"}} 3 + <link rel="stylesheet" href="/static/css/markdown.css"> 4 + {{end}} 5 + 6 + {{define "content"}} 7 + {{with .Content}} 8 + <div class="file-view"> 9 + <div class="file-header"> 10 + <h2>{{.Doc.Title}}</h2> 11 + <div class="file-actions"> 12 + <a href="/docs/{{.Doc.RKey}}/edit" class="btn btn-sm">Edit</a> 13 + <button class="btn btn-sm btn-outline" onclick="deleteDocument('{{.Doc.RKey}}')">Delete</button> 14 + </div> 15 + </div> 16 + <div class="markdown-body"> 17 + {{.Rendered}} 18 + </div> 19 + </div> 20 + {{end}} 21 + {{end}} 22 + 23 + {{define "scripts"}} 24 + <script> 25 + async function deleteDocument(rkey) { 26 + if (!confirm('Delete this document? This cannot be undone.')) return; 27 + try { 28 + const resp = await fetch('/api/docs/' + rkey, {method: 'DELETE'}); 29 + if (resp.ok) { 30 + window.location.href = '/'; 31 + } else { 32 + alert('Delete failed'); 33 + } 34 + } catch (e) { 35 + alert('Delete failed: ' + e.message); 36 + } 37 + } 38 + </script> 39 + {{end}}
+28
templates/documents.html
··· 1 + {{template "base" .}} 2 + {{define "content"}} 3 + <div class="dashboard"> 4 + <div class="dashboard-header"> 5 + <h2>Your Documents</h2> 6 + <a href="/docs/new" class="btn">New Document</a> 7 + </div> 8 + {{with .Content}} 9 + <div class="repo-list"> 10 + {{range .}} 11 + <a href="/docs/{{.RKey}}" class="repo-card"> 12 + <h3>{{.Title}}</h3> 13 + {{if .UpdatedAt}} 14 + <time>Updated {{.UpdatedAt}}</time> 15 + {{else}} 16 + <time>Created {{.PublishedAt}}</time> 17 + {{end}} 18 + </a> 19 + {{else}} 20 + <div class="empty-state"> 21 + <p>No documents yet.</p> 22 + <a href="/docs/new" class="btn">Create your first document</a> 23 + </div> 24 + {{end}} 25 + </div> 26 + {{end}} 27 + </div> 28 + {{end}}
+11 -13
templates/editor.html templates/document_edit.html
··· 9 9 <div class="editor-page"> 10 10 <div class="editor-toolbar"> 11 11 <div class="breadcrumb"> 12 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}">{{.Repo.Name}}</a> 12 + <a href="/">Documents</a> 13 13 <span>/</span> 14 - <span>{{.File.Path}}</span> 14 + <span>{{.Title}}</span> 15 15 </div> 16 16 <div class="toolbar-actions"> 17 17 <span id="save-status"></span> 18 - <button class="btn btn-sm" id="btn-save" onclick="saveFile()">Save Version</button> 19 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}/{{.File.Path}}" class="btn btn-sm btn-outline">View</a> 20 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}/{{.File.Path}}/history" class="btn btn-sm btn-outline">History</a> 18 + <button class="btn btn-sm" id="btn-save" onclick="saveDocument()">Save</button> 19 + <a href="/docs/{{.RKey}}" class="btn btn-sm btn-outline">View</a> 21 20 </div> 22 21 </div> 23 22 <div class="editor-split"> 24 23 <div class="editor-pane"> 25 - <textarea id="editor-textarea" style="display:none">{{.File.Content}}</textarea> 24 + <textarea id="editor-textarea" style="display:none">{{if .Content}}{{.Content.Text.RawMarkdown}}{{end}}</textarea> 26 25 <div id="editor"></div> 27 26 </div> 28 27 <div class="preview-pane"> ··· 40 39 const textarea = document.getElementById('editor-textarea'); 41 40 const previewEl = document.getElementById('preview'); 42 41 const saveStatus = document.getElementById('save-status'); 43 - const fileID = '{{.Content.File.ID}}'; 42 + const rkey = '{{.Content.RKey}}'; 44 43 45 44 let autoSaveTimer = null; 46 45 ··· 104 103 saveStatus.className = 'status-unsaved'; 105 104 autoSaveTimer = setTimeout(async () => { 106 105 try { 107 - await fetch(`/api/files/${fileID}/autosave`, { 106 + await fetch(`/api/docs/${rkey}/autosave`, { 108 107 method: 'PUT', 109 108 headers: {'Content-Type': 'application/json'}, 110 109 body: JSON.stringify({content}), ··· 118 117 }, 2000); 119 118 } 120 119 121 - window.saveFile = async function() { 120 + window.saveDocument = async function() { 122 121 const content = view.state.doc.toString(); 123 - const message = prompt('Version message (optional):') || ''; 124 122 try { 125 - const resp = await fetch(`/api/files/${fileID}/save`, { 123 + const resp = await fetch(`/api/docs/${rkey}/save`, { 126 124 method: 'POST', 127 125 headers: {'Content-Type': 'application/json'}, 128 - body: JSON.stringify({content, message}), 126 + body: JSON.stringify({content}), 129 127 }); 130 128 if (resp.ok) { 131 - saveStatus.textContent = 'Version saved!'; 129 + saveStatus.textContent = 'Saved!'; 132 130 saveStatus.className = 'status-saved'; 133 131 } 134 132 } catch (e) {
-25
templates/file_view.html
··· 1 - {{template "base" .}} 2 - {{define "head"}} 3 - <link rel="stylesheet" href="/static/css/markdown.css"> 4 - {{end}} 5 - 6 - {{define "content"}} 7 - {{with .Content}} 8 - <div class="file-page"> 9 - <div class="file-header"> 10 - <div class="breadcrumb"> 11 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}">{{.Repo.Name}}</a> 12 - <span>/</span> 13 - <span>{{.File.Path}}</span> 14 - </div> 15 - <div class="file-actions"> 16 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}/{{.File.Path}}/edit" class="btn btn-sm">Edit</a> 17 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}/{{.File.Path}}/history" class="btn btn-sm btn-outline">History</a> 18 - </div> 19 - </div> 20 - <article class="markdown-body"> 21 - {{.Rendered}} 22 - </article> 23 - </div> 24 - {{end}} 25 - {{end}}
-65
templates/history.html
··· 1 - {{template "base" .}} 2 - {{define "content"}} 3 - {{with .Content}} 4 - <div class="history-page"> 5 - <div class="file-header"> 6 - <div class="breadcrumb"> 7 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}">{{.Repo.Name}}</a> 8 - <span>/</span> 9 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}/{{.File.Path}}">{{.File.Path}}</a> 10 - <span>/</span> 11 - <span>History</span> 12 - </div> 13 - <a href="/{{$.User.Name}}/{{.Repo.Slug}}/{{.File.Path}}/edit" class="btn btn-sm">Edit</a> 14 - </div> 15 - 16 - <div class="version-list"> 17 - <form id="diff-form" class="diff-controls"> 18 - <button type="submit" class="btn btn-sm" disabled id="diff-btn">Compare Selected</button> 19 - </form> 20 - <table class="version-table"> 21 - <thead> 22 - <tr> 23 - <th></th> 24 - <th></th> 25 - <th>Author</th> 26 - <th>Message</th> 27 - <th>Date</th> 28 - </tr> 29 - </thead> 30 - <tbody> 31 - {{range .Versions}} 32 - <tr> 33 - <td><input type="radio" name="v1" value="{{.ID}}" onchange="checkDiff()"></td> 34 - <td><input type="radio" name="v2" value="{{.ID}}" onchange="checkDiff()"></td> 35 - <td>{{.AuthorName}}</td> 36 - <td>{{if .Message}}{{.Message}}{{else}}<em>No message</em>{{end}}</td> 37 - <td>{{.CreatedAt.Format "Jan 2, 2006 3:04 PM"}}</td> 38 - </tr> 39 - {{else}} 40 - <tr><td colspan="5">No versions saved yet.</td></tr> 41 - {{end}} 42 - </tbody> 43 - </table> 44 - </div> 45 - </div> 46 - {{end}} 47 - {{end}} 48 - 49 - {{define "scripts"}} 50 - <script> 51 - function checkDiff() { 52 - const v1 = document.querySelector('input[name=v1]:checked'); 53 - const v2 = document.querySelector('input[name=v2]:checked'); 54 - const btn = document.getElementById('diff-btn'); 55 - btn.disabled = !(v1 && v2 && v1.value !== v2.value); 56 - } 57 - 58 - document.getElementById('diff-form').addEventListener('submit', function(e) { 59 - e.preventDefault(); 60 - const v1 = document.querySelector('input[name=v1]:checked').value; 61 - const v2 = document.querySelector('input[name=v2]:checked').value; 62 - location.href = `/diff/${v1}/${v2}`; 63 - }); 64 - </script> 65 - {{end}}
+13
templates/new_document.html
··· 1 + {{template "base" .}} 2 + {{define "content"}} 3 + <div class="form-page"> 4 + <h2>New Document</h2> 5 + <form method="post" action="/docs/new"> 6 + <div class="form-group"> 7 + <label for="title">Title</label> 8 + <input type="text" id="title" name="title" required autofocus placeholder="My Document"> 9 + </div> 10 + <button type="submit" class="btn">Create Document</button> 11 + </form> 12 + </div> 13 + {{end}}
-22
templates/new_repo.html
··· 1 - {{template "base" .}} 2 - {{define "content"}} 3 - <div class="auth-page"> 4 - <h2>New Repository</h2> 5 - <form method="post" action="/new" class="auth-form"> 6 - <label>Name 7 - <input type="text" name="name" required autofocus placeholder="my-docs"> 8 - </label> 9 - <label>Description 10 - <textarea name="description" rows="3" placeholder="What are these docs for?"></textarea> 11 - </label> 12 - <label>Visibility 13 - <select name="visibility"> 14 - <option value="private">Private</option> 15 - <option value="public">Public</option> 16 - <option value="unlisted">Unlisted</option> 17 - </select> 18 - </label> 19 - <button type="submit" class="btn">Create Repository</button> 20 - </form> 21 - </div> 22 - {{end}}
-68
templates/repo.html
··· 1 - {{template "base" .}} 2 - {{define "content"}} 3 - {{with .Content}} 4 - <div class="repo-page"> 5 - <div class="repo-header"> 6 - <h2>{{.Repo.Name}}</h2> 7 - <p>{{.Repo.Description}}</p> 8 - </div> 9 - <div class="file-browser"> 10 - <div class="file-browser-header"> 11 - <span>Files</span> 12 - <button class="btn btn-sm" onclick="newFileDialog()">New File</button> 13 - </div> 14 - <ul class="file-list"> 15 - {{$repo := .Repo}} 16 - {{range .Files}} 17 - <li> 18 - <a href="/{{$.User.Name}}/{{$repo.Slug}}/{{.Path}}">{{.Path}}</a> 19 - <div class="file-actions"> 20 - <a href="/{{$.User.Name}}/{{$repo.Slug}}/{{.Path}}/edit" class="btn btn-sm">Edit</a> 21 - <a href="/{{$.User.Name}}/{{$repo.Slug}}/{{.Path}}/history" class="btn btn-sm btn-outline">History</a> 22 - </div> 23 - </li> 24 - {{else}} 25 - <li class="empty-state">No files yet</li> 26 - {{end}} 27 - </ul> 28 - </div> 29 - </div> 30 - 31 - <dialog id="new-file-dialog"> 32 - <form method="dialog"> 33 - <h3>New File</h3> 34 - <label>File path 35 - <input type="text" id="new-file-path" placeholder="docs/guide.md" required> 36 - </label> 37 - <div class="dialog-actions"> 38 - <button type="button" class="btn btn-outline" onclick="this.closest('dialog').close()">Cancel</button> 39 - <button type="button" class="btn" onclick="createFile()">Create</button> 40 - </div> 41 - </form> 42 - </dialog> 43 - {{end}} 44 - {{end}} 45 - 46 - {{define "scripts"}} 47 - <script> 48 - function newFileDialog() { 49 - document.getElementById('new-file-dialog').showModal(); 50 - } 51 - 52 - async function createFile() { 53 - const path = document.getElementById('new-file-path').value; 54 - if (!path) return; 55 - 56 - const repoID = '{{.Content.Repo.ID}}'; 57 - const resp = await fetch('/api/files', { 58 - method: 'POST', 59 - headers: {'Content-Type': 'application/json'}, 60 - body: JSON.stringify({repo_id: repoID, path: path}) 61 - }); 62 - 63 - if (resp.ok) { 64 - location.reload(); 65 - } 66 - } 67 - </script> 68 - {{end}}