Personal finance tracker
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

backend: login/logout with atproto oauth

+348 -218
+1 -1
backend/config.json
··· 2 2 "APP_ENV": "development", 3 3 "APP_ADDR": "localhost:50837", 4 4 "CLIENT_NAME": "Subete", 5 - "CLIENT_URL": "http://localhost:50837", 5 + "CLIENT_URL": "http://127.0.0.1:5173", 6 6 "CLIENT_SECRET": "z42twLj2gZeJSeRgZ4yPyEb6Yg6nawhU2W8y2ETDDFFyvwym", 7 7 "DB_NAME": "subete.db", 8 8 "SESSION_SECRET": "c727506476c23ea840e45fb32edf51fc6c34020ca2b373c3c88038f031bbead5"
+3
backend/go.mod
··· 19 19 github.com/dustin/go-humanize v1.0.1 // indirect 20 20 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 21 21 github.com/fatih/color v1.18.0 // indirect 22 + github.com/go-jet/jet/v2 v2.14.1 // indirect 22 23 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 23 24 github.com/google/go-querystring v1.1.0 // indirect 24 25 github.com/google/uuid v1.6.0 // indirect 26 + github.com/gorilla/securecookie v1.1.2 // indirect 27 + github.com/gorilla/sessions v1.4.0 // indirect 25 28 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 26 29 github.com/inconshreveable/mousetrap v1.1.0 // indirect 27 30 github.com/jinzhu/inflection v1.0.0 // indirect
+6
backend/go.sum
··· 17 17 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 18 18 github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= 19 19 github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 20 + github.com/go-jet/jet/v2 v2.14.1 h1:wsfD9e7CGP9h46+IFNlftfncBcmVnKddikbTtapQM3M= 21 + github.com/go-jet/jet/v2 v2.14.1/go.mod h1:dqTAECV2Mo3S2NFjbm4vJ1aDruZjhaJ1RAAR8rGUkkc= 20 22 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 21 23 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 22 24 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 28 30 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 29 31 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 30 32 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 34 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 35 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 36 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 31 37 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 32 38 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 33 39 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+14 -3
backend/internal/db/migrations/20260215122556_init_db.go
··· 5 5 "fmt" 6 6 7 7 "github.com/uptrace/bun" 8 - "tangled.org/jeffydc.xyz/subete/backend/user" 8 + "tangled.org/jeffydc.xyz/subete/backend/oauth" 9 9 "tangled.org/jeffydc.xyz/subete/backend/wallet" 10 10 ) 11 11 ··· 23 23 24 24 func initDB(ctx context.Context, db *bun.DB) { 25 25 err := db.ResetModel(ctx, 26 - (*user.UserModel)(nil), 26 + (*oauth.SessionModel)(nil), 27 + (*oauth.AuthRequestModel)(nil), 28 + (*oauth.UserModel)(nil), 27 29 (*wallet.WalletModel)(nil), 28 30 ) 29 - 30 31 if err != nil { 31 32 panic(err) 33 + } 34 + 35 + for _, query := range []string{ 36 + "CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (created_at)", 37 + "CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions (updated_at)", 38 + "CREATE INDEX IF NOT EXISTS idx_auth_reqs_created_at ON auth_reqs (created_at)", 39 + } { 40 + if _, err := db.ExecContext(ctx, query); err != nil { 41 + panic(err) 42 + } 32 43 } 33 44 }
+4 -5
backend/main.go
··· 33 33 r := chi.NewRouter() 34 34 r.Use(middleware.Logger) 35 35 36 + // OAuth 37 + oauth.New(bundb).Register(r) 38 + 36 39 // API routers 37 40 r.Route("/api", func(r chi.Router) { 38 41 humaconfig := huma.DefaultConfig("Subete API", "v1") ··· 41 44 } 42 45 api := humachi.New(r, humaconfig) 43 46 44 - // Unauthed 45 - oauth.New(bundb).Register(api) 46 - 47 47 // Authed 48 - // user.New(bundb).Register(api) 49 48 wallet.New(bundb).Register(api) 50 49 }) 51 50 ··· 55 54 Handler: r, 56 55 } 57 56 hooks.OnStart(func() { 58 - fmt.Println(config.Loaded.APP_ENV, "server listening at:", config.Loaded.CLIENT_URL) 57 + fmt.Printf("%s server at: http://%s\n", config.Loaded.APP_ENV, config.Loaded.APP_ADDR) 59 58 server.ListenAndServe() 60 59 }) 61 60 hooks.OnStop(func() {
+210 -33
backend/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 - "context" 4 + "encoding/json" 5 + "errors" 5 6 "fmt" 7 + "net/http" 8 + "strings" 6 9 7 10 "github.com/bluesky-social/indigo/atproto/atcrypto" 8 11 atoauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 - "github.com/danielgtaylor/huma/v2" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/go-chi/chi/v5" 14 + "github.com/gorilla/sessions" 10 15 "github.com/uptrace/bun" 11 16 "tangled.org/jeffydc.xyz/subete/backend/internal/config" 12 17 ) 13 18 19 + const ( 20 + // Session key 21 + sessionKey = "appview-session" 22 + // Session values' keys 23 + keySessionID = "id" 24 + keySessionDID = "did" 25 + keySessionHandle = "handle" 26 + keySessionPds = "pds" 27 + ) 28 + 14 29 type OAuth struct { 15 - clientApp *atoauth.ClientApp 16 - jwksURL string 30 + db *bun.DB 31 + clientApp *atoauth.ClientApp 32 + cookieStore *sessions.CookieStore 33 + jwksURL string 17 34 } 18 35 19 36 func New(db *bun.DB) *OAuth { ··· 24 41 if config.Loaded.DEV { 25 42 clientConfig = atoauth.NewLocalhostConfig(callbackURL, scopes) 26 43 } else { 27 - clientID := fmt.Sprintf("%s/oauth-client-metadata.json", config.Loaded.CLIENT_URL) 44 + clientID := fmt.Sprintf("%s/oauth/client-metadata.json", config.Loaded.CLIENT_URL) 28 45 clientConfig = atoauth.NewPublicConfig(clientID, callbackURL, scopes) 29 46 } 30 47 ··· 36 53 panic(err) 37 54 } 38 55 39 - store := NewDBStore(db) 56 + store := newDBStore(db) 40 57 clientApp := atoauth.NewClientApp(&clientConfig, store) 41 58 42 59 return &OAuth{ 43 - clientApp: clientApp, 44 - jwksURL: fmt.Sprintf("%s/oauth/jwks.json", config.Loaded.CLIENT_URL), 60 + db: db, 61 + clientApp: clientApp, 62 + jwksURL: fmt.Sprintf("%s/oauth/jwks.json", config.Loaded.CLIENT_URL), 63 + cookieStore: sessions.NewCookieStore([]byte(config.Loaded.SESSION_SECRET)), 64 + } 65 + } 66 + 67 + func (oa *OAuth) Register(r *chi.Mux) { 68 + r.Get("/oauth/client-metadata.json", oa.clientMetadata) 69 + r.Get("/oauth/jwks.json", oa.jwks) 70 + r.Get("/oauth/callback", oa.callback) 71 + r.Post("/oauth/login", oa.login) 72 + r.Get("/oauth/logout", oa.logout) 73 + // r.Post("/oauth/signup", oauth.signup) 74 + } 75 + 76 + func (oa *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 77 + meta := oa.clientApp.Config.ClientMetadata() 78 + meta.JWKSURI = &oa.jwksURL 79 + meta.ClientName = &config.Loaded.CLIENT_NAME 80 + meta.ClientURI = &config.Loaded.CLIENT_URL 81 + 82 + if err := meta.Validate(oa.clientApp.Config.ClientID); err != nil { 83 + http.Error(w, err.Error(), http.StatusInternalServerError) 84 + return 85 + } 86 + w.Header().Set("Content-Type", "application/json") 87 + if err := json.NewEncoder(w).Encode(meta); err != nil { 88 + http.Error(w, err.Error(), http.StatusInternalServerError) 89 + return 90 + } 91 + } 92 + 93 + func (oa *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 94 + w.Header().Set("Content-Type", "application/json") 95 + jwks := oa.clientApp.Config.PublicJWKS() 96 + if err := json.NewEncoder(w).Encode(jwks); err != nil { 97 + http.Error(w, err.Error(), http.StatusInternalServerError) 98 + } 99 + } 100 + 101 + func (oa *OAuth) callback(w http.ResponseWriter, r *http.Request) { 102 + ctx := r.Context() 103 + params := r.URL.Query() 104 + 105 + sessData, err := oa.clientApp.ProcessCallback(ctx, params) 106 + if err != nil { 107 + http.Error(w, err.Error(), http.StatusInternalServerError) 108 + return 109 + } 110 + 111 + if err := oa.saveSession(w, r, sessData); err != nil { 112 + http.Error(w, err.Error(), http.StatusInternalServerError) 113 + return 114 + } 115 + http.Redirect(w, r, "/", http.StatusFound) 116 + } 117 + 118 + func (oa *OAuth) login(w http.ResponseWriter, r *http.Request) { 119 + ctx := r.Context() 120 + 121 + handle := r.PostFormValue("handle") 122 + handle = strings.TrimSpace(handle) 123 + handle = strings.TrimPrefix(handle, "@") 124 + 125 + // Basic handle validation 126 + if !strings.Contains(handle, ".") { 127 + http.Error(w, "invalid handle", http.StatusUnprocessableEntity) 128 + return 129 + } 130 + 131 + redirectURL, err := oa.clientApp.StartAuthFlow(ctx, handle) 132 + if err != nil { 133 + http.Error(w, err.Error(), http.StatusInternalServerError) 134 + return 135 + } 136 + http.Redirect(w, r, redirectURL, http.StatusFound) 137 + } 138 + 139 + func (oa *OAuth) logout(w http.ResponseWriter, r *http.Request) { 140 + if err := oa.deleteSession(w, r); err != nil { 141 + http.Error(w, err.Error(), http.StatusInternalServerError) 142 + return 143 + } 144 + http.Redirect(w, r, "/", http.StatusFound) 145 + } 146 + 147 + func (oa *OAuth) saveSession( 148 + w http.ResponseWriter, 149 + r *http.Request, 150 + sessData *atoauth.ClientSessionData, 151 + ) error { 152 + _, sessionValue, err := oa.setSessionValue(w, r, sessData) 153 + if err != nil { 154 + return err 155 + } 156 + return oa.upsertUser(r.Context(), sessionValue.did, sessionValue.handle) 157 + } 158 + 159 + func (oa *OAuth) resumeSession(r *http.Request) error { 160 + session, sessionValue, err := oa.getSessionValue(r) 161 + if err != nil { 162 + return err 163 + } 164 + if session.IsNew { 165 + return fmt.Errorf("no session available for user") 166 + } 167 + 168 + _, err = oa.clientApp.ResumeSession(r.Context(), sessionValue.did, sessionValue.id) 169 + if err != nil { 170 + return err 171 + } 172 + 173 + return nil 174 + } 175 + 176 + func (oa *OAuth) deleteSession(w http.ResponseWriter, r *http.Request) error { 177 + session, sessionValue, err := oa.getSessionValue(r) 178 + if err != nil { 179 + return err 180 + } 181 + 182 + err1 := oa.clientApp.Logout(r.Context(), sessionValue.did, sessionValue.id) 183 + if err1 != nil { 184 + err1 = fmt.Errorf("failed to logout: %w", err1) 185 + } 186 + 187 + session.Options.MaxAge = -1 188 + err2 := oa.cookieStore.Save(r, w, session) 189 + if err2 != nil { 190 + err2 = fmt.Errorf("failed to remove session: %w", err2) 191 + } 192 + 193 + return errors.Join(err1, err2) 194 + } 195 + 196 + type sessionData struct { 197 + id string 198 + did syntax.DID 199 + handle string 200 + pds string 201 + } 202 + 203 + func (oa *OAuth) getSessionValue(r *http.Request) (*sessions.Session, *sessionData, error) { 204 + session, err := oa.cookieStore.Get(r, sessionKey) 205 + if err != nil { 206 + return nil, nil, err 207 + } 208 + 209 + did, err := syntax.ParseDID(session.Values[keySessionDID].(string)) 210 + if err != nil { 211 + return nil, nil, err 212 + } 213 + 214 + sessionValue := sessionData{ 215 + did: did, 216 + id: session.Values[keySessionID].(string), 217 + handle: session.Values[keySessionHandle].(string), 218 + pds: session.Values[keySessionPds].(string), 45 219 } 220 + 221 + return session, &sessionValue, nil 46 222 } 47 223 48 - func (oauth *OAuth) Register(api huma.API) { 49 - type clientMetadataOutput struct { 50 - Body *atoauth.ClientMetadata 224 + func (oa *OAuth) setSessionValue( 225 + w http.ResponseWriter, 226 + r *http.Request, 227 + sessData *atoauth.ClientSessionData, 228 + ) (*sessions.Session, *sessionData, error) { 229 + session, err := oa.cookieStore.Get(r, sessionKey) 230 + if err != nil { 231 + return nil, nil, err 51 232 } 52 - huma.Get(api, "/oauth-client-metadata.json", func(ctx context.Context, input *struct{}) (*clientMetadataOutput, error) { 53 - meta := oauth.clientApp.Config.ClientMetadata() 54 - meta.JWKSURI = &oauth.jwksURL 55 - meta.ClientName = &config.Loaded.CLIENT_NAME 56 - meta.ClientURI = &config.Loaded.CLIENT_URL 57 233 58 - err := meta.Validate(oauth.clientApp.Config.ClientID) 59 - if config.Loaded.PROD && err != nil { 60 - return nil, err 61 - } 62 - resp := &clientMetadataOutput{Body: &meta} 63 - return resp, nil 64 - }) 234 + accountDid := sessData.AccountDID.String() 235 + atid, err := syntax.ParseAtIdentifier(accountDid) 236 + if err != nil { 237 + return nil, nil, err 238 + } 65 239 66 - type jwksOutput struct { 67 - Body *atoauth.JWKS 240 + id, err := oa.clientApp.Dir.Lookup(r.Context(), atid) 241 + if err != nil { 242 + return nil, nil, err 68 243 } 69 - huma.Get(api, "/oauth/jwks.json", func(ctx context.Context, input *struct{}) (*jwksOutput, error) { 70 - jwks := oauth.clientApp.Config.PublicJWKS() 71 - resp := &jwksOutput{Body: &jwks} 72 - return resp, nil 73 - }) 244 + 245 + session.Values[keySessionID] = sessData.SessionID 246 + session.Values[keySessionDID] = accountDid 247 + session.Values[keySessionHandle] = id.Handle.String() 248 + session.Values[keySessionPds] = sessData.HostURL 249 + 250 + if err := session.Save(r, w); err != nil { 251 + return nil, nil, err 252 + } 74 253 75 - huma.Get(api, "/oauth/callback", func(ctx context.Context, i *struct{}) (*struct{}, error) { 76 - return nil, nil 77 - }) 254 + return oa.getSessionValue(r) 78 255 }
+52 -32
backend/oauth/store.go
··· 12 12 type SessionModel struct { 13 13 bun.BaseModel `bun:"table:sessions"` 14 14 15 - accountDid syntax.DID `bun:"account_did,pk"` 16 - sessionId string `bun:"session_id,pk"` 17 - data atoauth.ClientSessionData `bun:"data,json"` 18 - createdAt time.Time `bun:"created_at,index"` 19 - updatedAt time.Time `bun:"updated_at,index"` 15 + AccountDid syntax.DID `bun:"account_did,pk"` 16 + SessionId string `bun:"session_id,pk"` 17 + Data atoauth.ClientSessionData `bun:"data,type:jsonb"` 18 + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 19 + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 20 20 } 21 21 22 22 type AuthRequestModel struct { 23 23 bun.BaseModel `bun:"table:auth_reqs"` 24 24 25 - state string `bun:"state,pk"` 26 - data atoauth.AuthRequestData `bun:"data,json"` 27 - created_at time.Time `bun:"created_at,index"` 25 + State string `bun:"state,pk"` 26 + Data atoauth.AuthRequestData `bun:"data,type:jsonb"` 27 + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 28 28 } 29 29 30 30 type storeConfig struct { ··· 40 40 config *storeConfig 41 41 } 42 42 43 - var _ atoauth.ClientAuthStore = &dbStore{} 43 + var _ atoauth.ClientAuthStore = (*dbStore)(nil) 44 44 45 - func NewDBStore(db *bun.DB) *dbStore { 45 + func newDBStore(db *bun.DB) *dbStore { 46 46 return &dbStore{ 47 47 db: db, 48 48 config: &storeConfig{ ··· 53 53 } 54 54 } 55 55 56 - func (store *dbStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*atoauth.ClientSessionData, error) { 57 - expiry_threshold := time.Now().Add(-store.config.SessionExpiryDuration) 58 - inactive_threshold := time.Now().Add(-store.config.SessionInactivityDuration) 59 - _, err := store.db.NewDelete().Model(&AuthRequestModel{}).Where("created_at < ? OR updated_at < ?", expiry_threshold, inactive_threshold).Exec(ctx) 56 + func (s *dbStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*atoauth.ClientSessionData, error) { 57 + expiry_threshold := time.Now().Add(-s.config.SessionExpiryDuration) 58 + inactive_threshold := time.Now().Add(-s.config.SessionInactivityDuration) 59 + _, err := s.db. 60 + NewDelete(). 61 + Model(&SessionModel{}). 62 + Where("created_at < ? OR updated_at < ?", expiry_threshold, inactive_threshold). 63 + Exec(ctx) 60 64 if err != nil { 61 65 return nil, err 62 66 } 63 67 64 - model := &SessionModel{accountDid: did, sessionId: sessionID} 65 - err = store.db.NewSelect().Model(model).Scan(ctx) 68 + model := &SessionModel{} 69 + err = s.db.NewSelect().Model(model).Where("account_did = ? AND session_id = ?", did, sessionID).Scan(ctx) 66 70 if err != nil { 67 71 return nil, err 68 72 } 69 - return &model.data, err 73 + return &model.Data, err 70 74 } 71 75 72 - func (store *dbStore) SaveSession(ctx context.Context, sess atoauth.ClientSessionData) error { 73 - session := &SessionModel{accountDid: sess.AccountDID, sessionId: sess.SessionID} 74 - _, err := store.db.NewInsert().Model(session).Exec(ctx) 76 + func (s *dbStore) SaveSession(ctx context.Context, sess atoauth.ClientSessionData) error { 77 + session := &SessionModel{AccountDid: sess.AccountDID, SessionId: sess.SessionID, Data: sess} 78 + _, err := s.db. 79 + NewInsert(). 80 + Model(session). 81 + On("CONFLICT (account_did, session_id) DO UPDATE"). 82 + Set("data = EXCLUDED.data"). 83 + Set("updated_at = current_timestamp"). 84 + Exec(ctx) 75 85 return err 76 86 } 77 87 78 - func (store *dbStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 79 - session := &SessionModel{accountDid: did, sessionId: sessionID} 80 - _, err := store.db.NewDelete().Model(session).Exec(ctx) 88 + func (s *dbStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 89 + _, err := s.db.NewDelete().Model((*SessionModel)(nil)). 90 + Where("account_did = ? AND session_id = ?", did, sessionID). 91 + Exec(ctx) 81 92 return err 82 93 } 83 94 84 - func (store *dbStore) GetAuthRequestInfo(ctx context.Context, state string) (*atoauth.AuthRequestData, error) { 85 - threshold := time.Now().Add(-store.config.AuthRequestExpiryDuration) 86 - if _, err := store.db.NewDelete().Model(&AuthRequestModel{}).Where("created_at < ?", threshold).Exec(ctx); err != nil { 95 + func (s *dbStore) GetAuthRequestInfo(ctx context.Context, state string) (*atoauth.AuthRequestData, error) { 96 + threshold := time.Now().Add(-s.config.AuthRequestExpiryDuration) 97 + _, err := s.db. 98 + NewDelete(). 99 + Model((*AuthRequestModel)(nil)). 100 + Where("created_at < ?", threshold). 101 + Exec(ctx) 102 + if err != nil { 87 103 return nil, err 88 104 } 89 105 90 106 model := &AuthRequestModel{} 91 - err := store.db.NewSelect().Model(model).Where("state = ?", state).Scan(ctx) 107 + err = s.db.NewSelect().Model(model).Where("state = ?", state).Scan(ctx) 92 108 if err != nil { 93 109 return nil, err 94 110 } 95 - return &model.data, err 111 + return &model.Data, err 96 112 } 97 113 98 - func (store *dbStore) SaveAuthRequestInfo(ctx context.Context, info atoauth.AuthRequestData) error { 99 - _, err := store.db.NewInsert().Model(&AuthRequestModel{state: info.State, data: info}).Exec(ctx) 114 + func (s *dbStore) SaveAuthRequestInfo(ctx context.Context, info atoauth.AuthRequestData) error { 115 + model := &AuthRequestModel{State: info.State, Data: info} 116 + _, err := s.db.NewInsert().Model(model).Exec(ctx) 100 117 return err 101 118 } 102 119 103 - func (store *dbStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 104 - _, err := store.db.NewDelete().Model(&AuthRequestModel{state: state}).Exec(ctx) 120 + func (s *dbStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 121 + _, err := s.db. 122 + NewDelete(). 123 + Model((*AuthRequestModel)(nil)). 124 + Where("state = ?", state).Exec(ctx) 105 125 return err 106 126 }
+30
backend/oauth/user.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/uptrace/bun" 9 + ) 10 + 11 + type UserModel struct { 12 + bun.BaseModel `bun:"table:users"` 13 + 14 + AccountDID syntax.DID `bun:"account_did,pk" json:"account_did"` 15 + Handle string `bun:"handle,unique,notnull" json:"handle"` 16 + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 17 + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 18 + } 19 + 20 + func (oa *OAuth) upsertUser(ctx context.Context, accountDID syntax.DID, handle string) error { 21 + user := &UserModel{AccountDID: accountDID, Handle: handle} 22 + _, err := oa.db. 23 + NewInsert(). 24 + Model(user). 25 + On("CONFLICT (account_did) DO UPDATE"). 26 + Set("handle = EXCLUDED.handle"). 27 + Set("updated_at = current_timestamp"). 28 + Exec(ctx) 29 + return err 30 + }
-62
backend/user/user_model.go
··· 1 - package user 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "strings" 7 - "time" 8 - 9 - "github.com/uptrace/bun" 10 - ) 11 - 12 - type UserModel struct { 13 - bun.BaseModel `bun:"table:users"` 14 - 15 - ID string `bun:"id,pk,autoincrement"` 16 - Username string `bun:"username,unique,notnull"` 17 - Password string `bun:"password,unique,notnull"` 18 - 19 - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 20 - UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 21 - DeletedAt time.Time `bun:"deleted_at,soft_delete,nullzero"` 22 - } 23 - 24 - func createUser(ctx context.Context, db *bun.DB, username string, password string) (*UserModel, error) { 25 - user := &UserModel{Username: username, Password: password} 26 - _, err := db.NewInsert().Model(&user).Exec(ctx) 27 - if err != nil { 28 - if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "UNIQUE constraint") { 29 - return nil, fmt.Errorf("username %s is taken.", username) 30 - } 31 - return nil, err 32 - } 33 - 34 - return user, nil 35 - } 36 - 37 - func getUserByID(ctx context.Context, db *bun.DB, id string) (*UserModel, error) { 38 - user := new(UserModel) 39 - err := db.NewSelect().Model(user).Where("id = ?", id).Scan(ctx) 40 - if err != nil { 41 - return nil, fmt.Errorf("user not found") 42 - } 43 - return user, nil 44 - } 45 - 46 - func updateUser(ctx context.Context, db *bun.DB, id string, username string, password string) (*UserModel, error) { 47 - user := &UserModel{ID: id, Username: username, Password: password} 48 - _, err := db.NewUpdate().Model(user).Column("username", "password", "updated_at").Where("id = ?", id).Exec(ctx) 49 - if err != nil { 50 - if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "UNIQUE constraint") { 51 - return nil, fmt.Errorf("username %s is taken.", username) 52 - } 53 - return nil, err 54 - } 55 - return user, nil 56 - } 57 - 58 - func deleteUser(ctx context.Context, db *bun.DB, id string) error { 59 - user := new(UserModel) 60 - _, err := db.NewDelete().Model(user).Where("id = ?", id).Exec(ctx) 61 - return err 62 - }
-79
backend/user/user_router.go
··· 1 - package user 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "net/http" 7 - 8 - "github.com/danielgtaylor/huma/v2" 9 - "github.com/go-chi/chi/v5" 10 - "github.com/uptrace/bun" 11 - ) 12 - 13 - type UserDep struct { 14 - db *bun.DB 15 - } 16 - 17 - func New(db *bun.DB) *UserDep { 18 - return &UserDep{ 19 - db: db, 20 - } 21 - } 22 - 23 - func (self *UserDep) Register(api huma.API) { 24 - // huma.Get(api, "/me", self.getUser) 25 - r := chi.NewRouter() 26 - // r.Get("/me", self.getUser) 27 - r.Post("/me", self.createUser) 28 - r.Put("/me", self.updateUser) 29 - r.Delete("/me", self.deleteUser) 30 - 31 - // p.Mount("/users", r) 32 - } 33 - 34 - func (self *UserDep) getUser(ctx context.Context) { 35 - // userID := r.Context().Value("userID").(string) 36 - // user, err := getUserByID(r.Context(), self.db, userID) 37 - // if err != nil { 38 - // http.Error(w, err.Error(), http.StatusNotFound) 39 - // return 40 - // } 41 - // w.Header().Set("Content-Type", "application/json") 42 - // json.NewEncoder(w).Encode(user) 43 - } 44 - 45 - func (self *UserDep) createUser(w http.ResponseWriter, r *http.Request) { 46 - username := r.FormValue("username") 47 - password := r.FormValue("password") 48 - user, err := createUser(r.Context(), self.db, username, password) 49 - if err != nil { 50 - http.Error(w, err.Error(), http.StatusBadRequest) 51 - return 52 - } 53 - w.Header().Set("Content-Type", "application/json") 54 - w.WriteHeader(http.StatusCreated) 55 - json.NewEncoder(w).Encode(user) 56 - } 57 - 58 - func (self *UserDep) updateUser(w http.ResponseWriter, r *http.Request) { 59 - userID := r.Context().Value("userID").(string) 60 - username := r.FormValue("username") 61 - password := r.FormValue("password") 62 - user, err := updateUser(r.Context(), self.db, userID, username, password) 63 - if err != nil { 64 - http.Error(w, err.Error(), http.StatusBadRequest) 65 - return 66 - } 67 - w.Header().Set("Content-Type", "application/json") 68 - json.NewEncoder(w).Encode(user) 69 - } 70 - 71 - func (self *UserDep) deleteUser(w http.ResponseWriter, r *http.Request) { 72 - userID := r.Context().Value("userID").(string) 73 - err := deleteUser(r.Context(), self.db, userID) 74 - if err != nil { 75 - http.Error(w, err.Error(), http.StatusBadRequest) 76 - return 77 - } 78 - w.WriteHeader(http.StatusNoContent) 79 - }
+2 -1
frontend/src/main.ts
··· 10 10 11 11 export type Router = typeof router; 12 12 13 - app.use(router).use(VueQueryPlugin, { queryClient: router.context.qc }).mount('#app'); 13 + app.use(router).use(VueQueryPlugin, { queryClient: router.context.qc }); 14 + router.navigate().then(() => app.mount('#app'));
+3 -1
frontend/src/routes/+root-layout.vue
··· 1 1 <script setup lang="ts"></script> 2 2 3 - <template></template> 3 + <template> 4 + <slot></slot> 5 + </template>
+7 -1
frontend/src/routes/+root-page.vue
··· 1 1 <script setup lang="ts"></script> 2 2 3 - <template></template> 3 + <template> 4 + <form action="/oauth/login" method="post"> 5 + <label for="handle">Handle</label> 6 + <input type="text" name="handle" id="handle" /> 7 + <button type="submit">Login</button> 8 + </form> 9 + </template>
+16
frontend/vite.config.ts
··· 3 3 import { defineConfig } from 'vite'; 4 4 import vueDevTools from 'vite-plugin-vue-devtools'; 5 5 6 + const SERVER_HOST = '127.0.0.1' 7 + const BACKEND_TARGET = `http://${SERVER_HOST}:50837` 8 + 6 9 // https://vite.dev/config/ 7 10 export default defineConfig({ 8 11 plugins: [vue(), vueDevTools(), ruta({ routerModule: './src/main.ts' })], 12 + server: { 13 + host: SERVER_HOST, 14 + proxy: { 15 + '/api': { 16 + target: BACKEND_TARGET, 17 + changeOrigin: true, 18 + }, 19 + '/oauth': { 20 + target: BACKEND_TARGET, 21 + changeOrigin: true, 22 + }, 23 + }, 24 + }, 9 25 });