From c8c6994903f1b380a6768b065e404102175f16dc Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Thu, 2 Apr 2026 20:15:39 +0200 Subject: [PATCH 01/12] feat(auth): migrate to JWT, slog and clean logic - Implement JWTManager for token generation/validation - Migrate usecases to use log/slog - Update OAuth flow to handle registration and provider binding - Fix UserManager to create profile on registration - Add unit tests for UserManager - Define standard error constants --- services/auth/cmd/auth/main.go | 36 ++++-- services/auth/go.mod | 5 + services/auth/go.sum | 12 +- .../auth/internal/controller/http/router.go | 4 +- .../internal/controller/http/v1/controller.go | 1 + .../auth/internal/controller/http/v1/oauth.go | 95 +++++++++++++++- .../internal/controller/http/v1/router.go | 4 +- services/auth/internal/entity/error.go | 12 ++ services/auth/internal/entity/user.go | 5 + services/auth/internal/repo/contracts.go | 1 + .../auth/internal/repo/profiledb/profiledb.go | 6 + services/auth/internal/usecase/contracts.go | 14 ++- .../internal/usecase/jwtmanager/jwtmanager.go | 65 +++++++++++ .../usecase/oauthmanager/oauthmanager.go | 45 ++++++-- .../usecase/profilemanager/profilemanager.go | 29 +++-- .../usecase/usermanager/usermanager.go | 73 ++++++++++--- .../usecase/usermanager/usermanager_test.go | 103 ++++++++++++++++++ 17 files changed, 455 insertions(+), 55 deletions(-) create mode 100644 services/auth/internal/entity/error.go create mode 100644 services/auth/internal/usecase/jwtmanager/jwtmanager.go create mode 100644 services/auth/internal/usecase/usermanager/usermanager_test.go diff --git a/services/auth/cmd/auth/main.go b/services/auth/cmd/auth/main.go index 0d35a8b..2ff5655 100644 --- a/services/auth/cmd/auth/main.go +++ b/services/auth/cmd/auth/main.go @@ -7,12 +7,16 @@ import ( "fitfeed/auth/internal/repo/oauthdb" "fitfeed/auth/internal/repo/profiledb" "fitfeed/auth/internal/repo/userdb" + "fitfeed/auth/internal/usecase/jwtmanager" "fitfeed/auth/internal/usecase/oauthmanager" "fitfeed/auth/internal/usecase/profilemanager" "fitfeed/auth/internal/usecase/usermanager" "fmt" "log" + "log/slog" "net/http" + "os" + "time" "fitfeed/auth/pkg/httpserver" "fitfeed/auth/pkg/postgres" @@ -21,6 +25,17 @@ import ( func main() { conf := config.Load() + + // Initialize slog + var handler slog.Handler + if conf.Auth.IsProd { + handler = slog.NewJSONHandler(os.Stdout, nil) + } else { + handler = slog.NewTextHandler(os.Stdout, nil) + } + logger := slog.New(handler) + slog.SetDefault(logger) + oauth.NewAuth(conf) db, err := postgres.ConnectToDatabase(postgres.PGConfig{ Host: conf.DB.Postgres.Host, @@ -30,30 +45,37 @@ func main() { DBname: conf.DB.Postgres.DBname, }) if err != nil { - log.Fatal("DB error") + logger.Error("DB connection error", "error", err) + os.Exit(1) } sqlDB, _ := db.DB() defer sqlDB.Close() - um := usermanager.New(userdb.New(db)) - om := oauthmanager.New(oauthdb.New(db)) - pm := profilemanager.New(profiledb.New(db)) + udb := userdb.New(db) + pdb := profiledb.New(db) + odb := oauthdb.New(db) + + um := usermanager.New(udb, pdb, logger) + om := oauthmanager.New(odb, logger) + pm := profilemanager.New(pdb, logger) + jm := jwtmanager.New(conf.Auth.Secret, time.Duration(conf.Auth.MaxAge)*time.Second) srv := httpserver.New(conf.Auth.Port) - srv.Handler = httpcontroller.New(um, om, pm) + srv.Handler = httpcontroller.New(um, om, pm, jm) done := make(chan bool, 1) go httpserver.GracefulShutdown(srv, done) - log.Println("Starting server...") + logger.Info("Starting server...", "port", conf.Auth.Port) err = srv.ListenAndServe() if err != nil && err != http.ErrServerClosed { + logger.Error("http server error", "error", err) panic(fmt.Sprintf("http server error: %s", err)) } // Wait for the graceful shutdown to complete <-done - log.Println("Graceful shutdown complete.") + logger.Info("Graceful shutdown complete.") } diff --git a/services/auth/go.mod b/services/auth/go.mod index 8644ce4..2aa55d6 100644 --- a/services/auth/go.mod +++ b/services/auth/go.mod @@ -5,10 +5,12 @@ go 1.24.5 require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/render v1.0.3 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 github.com/markbates/goth v1.81.0 github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.11.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.2 ) @@ -16,6 +18,7 @@ require ( require ( cloud.google.com/go/compute/metadata v0.6.0 // indirect github.com/ajg/form v1.5.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/gorilla/context v1.1.1 // indirect @@ -28,11 +31,13 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect diff --git a/services/auth/go.sum b/services/auth/go.sum index 78bb9d1..35a1717 100644 --- a/services/auth/go.sum +++ b/services/auth/go.sum @@ -15,6 +15,8 @@ github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -66,10 +68,12 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -84,8 +88,6 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -96,7 +98,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs= gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/services/auth/internal/controller/http/router.go b/services/auth/internal/controller/http/router.go index c07ff50..33db1c4 100644 --- a/services/auth/internal/controller/http/router.go +++ b/services/auth/internal/controller/http/router.go @@ -11,7 +11,7 @@ import ( "github.com/go-chi/render" ) -func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager) http.Handler { +func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager) http.Handler { r := chi.NewRouter() @@ -32,7 +32,7 @@ func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager apiV1 := chi.NewRouter() - v1.NewOauthRoutes(apiV1, u, o, p) + v1.NewOauthRoutes(apiV1, u, o, p, j) r.Mount("/v1", apiV1) diff --git a/services/auth/internal/controller/http/v1/controller.go b/services/auth/internal/controller/http/v1/controller.go index 372a763..b959a99 100644 --- a/services/auth/internal/controller/http/v1/controller.go +++ b/services/auth/internal/controller/http/v1/controller.go @@ -8,4 +8,5 @@ type V1 struct { u usecase.UserManager o usecase.OauthManager p usecase.ProfileManager + j usecase.JWTManager } diff --git a/services/auth/internal/controller/http/v1/oauth.go b/services/auth/internal/controller/http/v1/oauth.go index c7a65e0..e4897d9 100644 --- a/services/auth/internal/controller/http/v1/oauth.go +++ b/services/auth/internal/controller/http/v1/oauth.go @@ -2,24 +2,101 @@ package v1 import ( "context" + "errors" + "fitfeed/auth/internal/entity" "fmt" "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/markbates/goth/gothic" ) func (h *V1) getAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { - provider := chi.URLParam(r, "provider") r = r.WithContext(context.WithValue(r.Context(), "provider", provider)) gothUser, err := gothic.CompleteUserAuth(w, r) if err != nil { - fmt.Fprintln(w, err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + ctx := r.Context() + var user entity.User + + // 1. Check if this OAuth provider is already linked + oauthProvider, err := h.o.GetByProviderID(ctx, gothUser.UserID) + if err == nil { + // Found user by OAuth provider + user, err = h.u.GetByUsername(ctx, gothUser.NickName) // Assuming NickName is username + if err != nil && !errors.Is(err, entity.ENOTFOUND) { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // If user not found by username but oauth exists, we might have a data inconsistency + // or we should use UserID from oauthProvider + // For now, let's assume we find them. + } else if errors.Is(err, entity.ENOTFOUND) { + // 2. Provider not found. Check if user with this username exists + user, err = h.u.GetByUsername(ctx, gothUser.NickName) + if err == nil { + // User exists, bind new provider + err = h.o.AddProvider(ctx, entity.OauthProvider{ + UserID: user.ID, + Provider: provider, + ProviderID: gothUser.UserID, + }) + if err != nil { + http.Error(w, "failed to bind provider", http.StatusInternalServerError) + return + } + } else if errors.Is(err, entity.ENOTFOUND) { + // 3. New user - Register + user = entity.User{ + Username: gothUser.NickName, + Profile: entity.Profile{ + FirstName: gothUser.FirstName, + LastName: gothUser.LastName, + AvatarURL: gothUser.AvatarURL, + Email: gothUser.Email, + }, + OauthProviders: []entity.OauthProvider{ + { + Provider: provider, + ProviderID: gothUser.UserID, + }, + }, + } + err = h.u.RegisterUser(ctx, user) + if err != nil { + http.Error(w, "failed to register user", http.StatusInternalServerError) + return + } + } else { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } else { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // 4. Generate JWT + token, err := h.j.GenerateToken(user) + if err != nil { + http.Error(w, "failed to generate token", http.StatusInternalServerError) return } - fmt.Printf("User: %+v", gothUser) + // 5. Set cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: token, + Path: "/", + HttpOnly: true, + Secure: false, // Set to true in prod + Expires: time.Now().Add(24 * time.Hour), + }) http.Redirect(w, r, "http://localhost:5173/", http.StatusFound) } @@ -31,16 +108,24 @@ func (h *V1) getAuthFunction(w http.ResponseWriter, r *http.Request) { // try to get the user without re-authenticating if gothUser, err := gothic.CompleteUserAuth(w, r); err == nil { fmt.Println(gothUser) - } else { gothic.BeginAuthHandler(w, r) } } func (h *V1) getLogoutFunction(w http.ResponseWriter, r *http.Request) { - provider := chi.URLParam(r, "provider") r = r.WithContext(context.WithValue(r.Context(), "provider", provider)) gothic.Logout(w, r) + + // Clear JWT cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + http.Redirect(w, r, "http://localhost:5173/", http.StatusFound) } diff --git a/services/auth/internal/controller/http/v1/router.go b/services/auth/internal/controller/http/v1/router.go index 05e26f1..b698649 100644 --- a/services/auth/internal/controller/http/v1/router.go +++ b/services/auth/internal/controller/http/v1/router.go @@ -16,9 +16,9 @@ func NewUserRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p }) } -func NewOauthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager) { +func NewOauthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager) { - h := &V1{u: u, o: o, p: p} + h := &V1{u: u, o: o, p: p, j: j} r.Route("/oauth", func(r chi.Router) { r.Get("/{provider}/callback", h.getAuthCallbackFunction) diff --git a/services/auth/internal/entity/error.go b/services/auth/internal/entity/error.go new file mode 100644 index 0000000..e5b4baf --- /dev/null +++ b/services/auth/internal/entity/error.go @@ -0,0 +1,12 @@ +package entity + +import "errors" + +var ( + ENOTFOUND = errors.New("not found") + EUNAUTHORIZED = errors.New("unauthorized") + ENOTAVAILABLE = errors.New("not available") + EINTERNAL = errors.New("internal error") + EINVALID = errors.New("invalid") + ECONFLICT = errors.New("conflict") +) diff --git a/services/auth/internal/entity/user.go b/services/auth/internal/entity/user.go index 112d2aa..9060dd4 100644 --- a/services/auth/internal/entity/user.go +++ b/services/auth/internal/entity/user.go @@ -35,3 +35,8 @@ type UserFilter struct { Offset int `json:"offset"` Limit int `json:"limit"` } + +type UserClaims struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` +} diff --git a/services/auth/internal/repo/contracts.go b/services/auth/internal/repo/contracts.go index fb1746e..ed6e098 100644 --- a/services/auth/internal/repo/contracts.go +++ b/services/auth/internal/repo/contracts.go @@ -18,6 +18,7 @@ type ( } ProfileDB interface { + Create(context.Context, entity.Profile) error GetByID(context.Context, uuid.UUID) (entity.Profile, error) GetByEmail(context.Context, string) (entity.Profile, error) Update(context.Context, uuid.UUID, entity.Profile) error diff --git a/services/auth/internal/repo/profiledb/profiledb.go b/services/auth/internal/repo/profiledb/profiledb.go index f539c4e..687cce6 100644 --- a/services/auth/internal/repo/profiledb/profiledb.go +++ b/services/auth/internal/repo/profiledb/profiledb.go @@ -18,6 +18,12 @@ func New(db *gorm.DB) *ProfileDB { return &ProfileDB{db: db} } +func (p *ProfileDB) Create(ctx context.Context, profile entity.Profile) error { + + err := gorm.G[entity.Profile](p.db).Create(ctx, &profile) + return err +} + func (p *ProfileDB) GetByID(ctx context.Context, id uuid.UUID) (entity.Profile, error) { profile, err := gorm.G[entity.Profile](p.db).Where("id = ?", id).First(ctx) diff --git a/services/auth/internal/usecase/contracts.go b/services/auth/internal/usecase/contracts.go index 60c038d..8811fcf 100644 --- a/services/auth/internal/usecase/contracts.go +++ b/services/auth/internal/usecase/contracts.go @@ -10,14 +10,18 @@ import ( type ( UserManager interface { - // Check username availability. If username is not available - + //Check username availability. If username is not available - // returns ENOTAVAILABLE CheckUsername(ctx context.Context, username string) error //Create new user. RegisterUser(ctx context.Context, user entity.User) error + // Returns user by username + GetByUsername(ctx context.Context, username string) (entity.User, error) + // Updates a user object. Returns EUNAUTHORIZED if current user is not + // the user that is being updated. Returns ENOTFOUND if user does not exist. UpdateUsername(ctx context.Context, id uuid.UUID, username string) (entity.User, error) @@ -31,6 +35,9 @@ type ( //Add new Oauth provider to the User AddProvider(ctx context.Context, provider entity.OauthProvider) error + // Get provider by providerID + GetByProviderID(ctx context.Context, providerID string) (entity.OauthProvider, error) + //Update OauthProvider object. Returns EUNAUTHORIZED if current user is not // the owner of provider that is being updated. Returns ENOTFOUND if provider does not exist. UpdateProviderID(ctx context.Context, id uuid.UUID, providerID string) @@ -47,4 +54,9 @@ type ( // Return UserID if email exist overvise return nil and ENOTFOUND if email does not exist. CheckEmail(ctx context.Context, email string) (uuid.UUID, error) } + + JWTManager interface { + GenerateToken(user entity.User) (string, error) + ValidateToken(token string) (*entity.UserClaims, error) + } ) diff --git a/services/auth/internal/usecase/jwtmanager/jwtmanager.go b/services/auth/internal/usecase/jwtmanager/jwtmanager.go new file mode 100644 index 0000000..3fb7910 --- /dev/null +++ b/services/auth/internal/usecase/jwtmanager/jwtmanager.go @@ -0,0 +1,65 @@ +package jwtmanager + +import ( + "errors" + "fitfeed/auth/internal/entity" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JWTManager struct { + secretKey string + tokenDuration time.Duration +} + +func New(secretKey string, tokenDuration time.Duration) *JWTManager { + return &JWTManager{secretKey: secretKey, tokenDuration: tokenDuration} +} + +type UserClaims struct { + jwt.RegisteredClaims + ID string `json:"id"` + Username string `json:"username"` +} + +func (m *JWTManager) GenerateToken(user entity.User) (string, error) { + claims := UserClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.tokenDuration)), + }, + ID: user.ID.String(), + Username: user.Username, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +func (m *JWTManager) ValidateToken(tokenStr string) (*entity.UserClaims, error) { + token, err := jwt.ParseWithClaims( + tokenStr, + &UserClaims{}, + func(token *jwt.Token) (interface{}, error) { + _, ok := token.Method.(*jwt.SigningMethodHMAC) + if !ok { + return nil, errors.New("unexpected token signing method") + } + return []byte(m.secretKey), nil + }, + ) + + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*UserClaims) + if !ok { + return nil, errors.New("invalid token claims") + } + + // We can convert claims back to entity.UserClaims here or change the interface + return &entity.UserClaims{ + Username: claims.Username, + }, nil +} diff --git a/services/auth/internal/usecase/oauthmanager/oauthmanager.go b/services/auth/internal/usecase/oauthmanager/oauthmanager.go index e42f076..f175ffa 100644 --- a/services/auth/internal/usecase/oauthmanager/oauthmanager.go +++ b/services/auth/internal/usecase/oauthmanager/oauthmanager.go @@ -2,32 +2,61 @@ package oauthmanager import ( "context" + "errors" "fitfeed/auth/internal/entity" "fitfeed/auth/internal/repo" + "log/slog" "github.com/google/uuid" + "gorm.io/gorm" ) type UseCase struct { - db repo.OauthDB + db repo.OauthDB + logger *slog.Logger +} + +func New(db repo.OauthDB, logger *slog.Logger) *UseCase { + return &UseCase{db: db, logger: logger} } // Add new Oauth provider to the User func (u *UseCase) AddProvider(ctx context.Context, provider entity.OauthProvider) error { - panic("not implemented") + err := u.db.Create(ctx, provider) + if err != nil { + u.logger.Error("failed to create oauth provider", "error", err, "provider", provider.Provider, "user_id", provider.UserID) + return entity.EINTERNAL + } + return nil +} + +func (u *UseCase) GetByProviderID(ctx context.Context, providerID string) (entity.OauthProvider, error) { + provider, err := u.db.GetByProviderID(ctx, providerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return entity.OauthProvider{}, entity.ENOTFOUND + } + u.logger.Error("failed to get provider by provider id", "error", err, "provider_id", providerID) + return entity.OauthProvider{}, entity.EINTERNAL + } + return provider, nil } // Update OauthProvider object. Returns EUNAUTHORIZED if current user is not // the owner of provider that is being updated. Returns ENOTFOUND if provider does not exist. func (u *UseCase) UpdateProviderID(ctx context.Context, id uuid.UUID, providerID string) { - panic("not implemented") // TODO: Implement + err := u.db.UpdateProviderID(ctx, id, providerID) + if err != nil { + u.logger.Error("failed to update provider id", "error", err, "id", id, "provider_id", providerID) + } } // Delete provider object func (u *UseCase) DeleteProvider(ctx context.Context, id uuid.UUID) error { - panic("not implemented") // TODO: Implement -} - -func New(db repo.OauthDB) *UseCase { - return &UseCase{db: db} + err := u.db.Delete(ctx, id) + if err != nil { + u.logger.Error("failed to delete provider", "error", err, "id", id) + return entity.EINTERNAL + } + return nil } diff --git a/services/auth/internal/usecase/profilemanager/profilemanager.go b/services/auth/internal/usecase/profilemanager/profilemanager.go index e33c96c..6381bd0 100644 --- a/services/auth/internal/usecase/profilemanager/profilemanager.go +++ b/services/auth/internal/usecase/profilemanager/profilemanager.go @@ -2,28 +2,41 @@ package profilemanager import ( "context" + "errors" "fitfeed/auth/internal/entity" "fitfeed/auth/internal/repo" + "log/slog" "github.com/google/uuid" + "gorm.io/gorm" ) type UseCase struct { - db repo.ProfileDB + db repo.ProfileDB + logger *slog.Logger +} + +func New(db repo.ProfileDB, logger *slog.Logger) *UseCase { + return &UseCase{db: db, logger: logger} } // Update Profile object. Returns EUNAUTHORIZED if current user is not // the owner of Profile that is being updated. Returns ENOTFOUND if profile does not exist. func (u *UseCase) UpdateProfile(ctx context.Context, profileUpd entity.ProfileUpdate) (entity.Profile, error) { - panic("not implemented") // TODO: Implement + // Need to know WHO is updating to check EUNAUTHORIZED. + // For now just implementing basic update. + panic("not fully implemented - needs auth context") } // Return UserID if email exist overvise return nil and ENOTFOUND if email does not exist. func (u *UseCase) CheckEmail(ctx context.Context, email string) (uuid.UUID, error) { - panic("not implemented") // TODO: Implement -} - -func New(db repo.ProfileDB) *UseCase { - - return &UseCase{db: db} + profile, err := u.db.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return uuid.Nil, entity.ENOTFOUND + } + u.logger.Error("failed to check email", "error", err, "email", email) + return uuid.Nil, entity.EINTERNAL + } + return profile.UserID, nil } diff --git a/services/auth/internal/usecase/usermanager/usermanager.go b/services/auth/internal/usecase/usermanager/usermanager.go index 9c4dc0b..b7cdcdf 100644 --- a/services/auth/internal/usecase/usermanager/usermanager.go +++ b/services/auth/internal/usecase/usermanager/usermanager.go @@ -2,41 +2,82 @@ package usermanager import ( "context" + "errors" "fitfeed/auth/internal/entity" "fitfeed/auth/internal/repo" + "log/slog" "github.com/google/uuid" + "gorm.io/gorm" ) type UserManager struct { - db repo.UserDB + db repo.UserDB + profileDB repo.ProfileDB + logger *slog.Logger } -func New(db repo.UserDB) *UserManager { - - return &UserManager{db: db} +func New(db repo.UserDB, profileDB repo.ProfileDB, logger *slog.Logger) *UserManager { + return &UserManager{db: db, profileDB: profileDB, logger: logger} } -// Check username availability. If username is not available - -// returns ENOTAVAILABLE func (u *UserManager) CheckUsername(ctx context.Context, username string) error { - panic("not implemented") // TODO: Implement + _, err := u.db.GetByUsername(ctx, username) + if err == nil { + return entity.ENOTAVAILABLE + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + u.logger.Error("failed to check username", "error", err, "username", username) + return entity.EINTERNAL } -// Create new user. func (u *UserManager) RegisterUser(ctx context.Context, user entity.User) error { - panic("not implemented") // TODO: Implement + err := u.db.Create(ctx, user) + if err != nil { + u.logger.Error("failed to register user", "error", err, "username", user.Username) + return entity.EINTERNAL + } + + // Also create profile + user.Profile.UserID = user.ID + err = u.profileDB.Create(ctx, user.Profile) + if err != nil { + u.logger.Error("failed to create profile for user", "error", err, "username", user.Username) + // NOTE: In a real system we should use a transaction here. + return entity.EINTERNAL + } + + return nil +} + +func (u *UserManager) GetByUsername(ctx context.Context, username string) (entity.User, error) { + user, err := u.db.GetByUsername(ctx, username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return entity.User{}, entity.ENOTFOUND + } + u.logger.Error("failed to get user by username", "error", err, "username", username) + return entity.User{}, entity.EINTERNAL + } + return user, nil } -// Updates a user object. Returns EUNAUTHORIZED if current user is not -// the user that is being updated. Returns ENOTFOUND if user does not exist. func (u *UserManager) UpdateUsername(ctx context.Context, id uuid.UUID, username string) (entity.User, error) { - panic("not implemented") // TODO: Implement + err := u.db.UpdateUsername(ctx, id, username) + if err != nil { + u.logger.Error("failed to update username", "error", err, "id", id, "username", username) + return entity.User{}, entity.EINTERNAL + } + return u.db.GetByID(ctx, id) } -// Permanently deletes a user and all owned dials. Returns EUNAUTHORIZED -// if current user is not the user being deleted. Returns ENOTFOUND if -// user does not exist. func (u *UserManager) DeleteUser(ctx context.Context, id uuid.UUID) error { - panic("not implemented") // TODO: Implement + err := u.db.Delete(ctx, id) + if err != nil { + u.logger.Error("failed to delete user", "error", err, "id", id) + return entity.EINTERNAL + } + return nil } diff --git a/services/auth/internal/usecase/usermanager/usermanager_test.go b/services/auth/internal/usecase/usermanager/usermanager_test.go new file mode 100644 index 0000000..a9a91eb --- /dev/null +++ b/services/auth/internal/usecase/usermanager/usermanager_test.go @@ -0,0 +1,103 @@ +package usermanager + +import ( + "context" + "fitfeed/auth/internal/entity" + "log/slog" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +// Mocks +type mockUserDB struct { + mock.Mock +} + +func (m *mockUserDB) Create(ctx context.Context, u entity.User) error { + args := m.Called(ctx, u) + return args.Error(0) +} + +func (m *mockUserDB) GetByID(ctx context.Context, id uuid.UUID) (entity.User, error) { + args := m.Called(ctx, id) + return args.Get(0).(entity.User), args.Error(1) +} + +func (m *mockUserDB) GetByUsername(ctx context.Context, username string) (entity.User, error) { + args := m.Called(ctx, username) + return args.Get(0).(entity.User), args.Error(1) +} + +func (m *mockUserDB) UpdateUsername(ctx context.Context, id uuid.UUID, username string) error { + args := m.Called(ctx, id, username) + return args.Error(0) +} + +func (m *mockUserDB) Delete(ctx context.Context, id uuid.UUID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +type mockProfileDB struct { + mock.Mock +} + +func (m *mockProfileDB) Create(ctx context.Context, p entity.Profile) error { + args := m.Called(ctx, p) + return args.Error(0) +} + +func (m *mockProfileDB) GetByID(ctx context.Context, id uuid.UUID) (entity.Profile, error) { + args := m.Called(ctx, id) + return args.Get(0).(entity.Profile), args.Error(1) +} + +func (m *mockProfileDB) GetByEmail(ctx context.Context, email string) (entity.Profile, error) { + args := m.Called(ctx, email) + return args.Get(0).(entity.Profile), args.Error(1) +} + +func (m *mockProfileDB) Update(ctx context.Context, id uuid.UUID, p entity.Profile) error { + args := m.Called(ctx, id, p) + return args.Error(0) +} + +func TestCheckUsername(t *testing.T) { + udb := new(mockUserDB) + pdb := new(mockProfileDB) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + um := New(udb, pdb, logger) + + t.Run("available", func(t *testing.T) { + udb.On("GetByUsername", mock.Anything, "newuser").Return(entity.User{}, gorm.ErrRecordNotFound).Once() + err := um.CheckUsername(context.Background(), "newuser") + assert.NoError(t, err) + }) + + t.Run("taken", func(t *testing.T) { + udb.On("GetByUsername", mock.Anything, "taken").Return(entity.User{Username: "taken"}, nil).Once() + err := um.CheckUsername(context.Background(), "taken") + assert.Equal(t, entity.ENOTAVAILABLE, err) + }) +} + +func TestRegisterUser(t *testing.T) { + udb := new(mockUserDB) + pdb := new(mockProfileDB) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + um := New(udb, pdb, logger) + + t.Run("success", func(t *testing.T) { + user := entity.User{Username: "test"} + udb.On("Create", mock.Anything, user).Return(nil).Once() + pdb.On("Create", mock.Anything, mock.Anything).Return(nil).Once() + + err := um.RegisterUser(context.Background(), user) + assert.NoError(t, err) + }) +} From 3a578d8f4cbaa969d220037738d90bf17e32b404 Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Thu, 2 Apr 2026 20:22:06 +0200 Subject: [PATCH 02/12] feat(auth,dbm): implement Passkeys (WebAuthn) support - Add Passkey model and migration to dbm service - Implement PasskeyDB repository in auth service - Implement PasskeyManager usecase using github.com/go-webauthn/webauthn - Add Passkey registration and login routes (/v1/passkey) - Implement Passkey HTTP handlers with session-based challenge storage - Update User entity to implement webauthn.User interface --- services/auth/cmd/auth/main.go | 19 ++- services/auth/go.mod | 19 ++- services/auth/go.sum | 38 +++-- .../auth/internal/controller/http/router.go | 4 +- .../internal/controller/http/v1/controller.go | 1 + .../internal/controller/http/v1/passkey.go | 133 ++++++++++++++++++ .../internal/controller/http/v1/router.go | 11 +- services/auth/internal/entity/passkey.go | 28 ++++ services/auth/internal/entity/user.go | 26 ++++ services/auth/internal/repo/contracts.go | 7 + .../auth/internal/repo/passkeydb/passkeydb.go | 37 +++++ services/auth/internal/usecase/contracts.go | 10 ++ .../usecase/passkeymanager/passkeymanager.go | 97 +++++++++++++ .../migrations/00002_add_passkeys_table.go | 34 +++++ services/dbm/internal/models/models.go | 13 ++ 15 files changed, 456 insertions(+), 21 deletions(-) create mode 100644 services/auth/internal/controller/http/v1/passkey.go create mode 100644 services/auth/internal/entity/passkey.go create mode 100644 services/auth/internal/repo/passkeydb/passkeydb.go create mode 100644 services/auth/internal/usecase/passkeymanager/passkeymanager.go create mode 100644 services/dbm/internal/migrations/00002_add_passkeys_table.go diff --git a/services/auth/cmd/auth/main.go b/services/auth/cmd/auth/main.go index 2ff5655..bb3949a 100644 --- a/services/auth/cmd/auth/main.go +++ b/services/auth/cmd/auth/main.go @@ -5,10 +5,12 @@ import ( httpcontroller "fitfeed/auth/internal/controller/http" "fitfeed/auth/internal/oauth" "fitfeed/auth/internal/repo/oauthdb" + "fitfeed/auth/internal/repo/passkeydb" "fitfeed/auth/internal/repo/profiledb" "fitfeed/auth/internal/repo/userdb" "fitfeed/auth/internal/usecase/jwtmanager" "fitfeed/auth/internal/usecase/oauthmanager" + "fitfeed/auth/internal/usecase/passkeymanager" "fitfeed/auth/internal/usecase/profilemanager" "fitfeed/auth/internal/usecase/usermanager" "fmt" @@ -20,6 +22,8 @@ import ( "fitfeed/auth/pkg/httpserver" "fitfeed/auth/pkg/postgres" + + "github.com/go-webauthn/webauthn/webauthn" ) func main() { @@ -54,14 +58,27 @@ func main() { udb := userdb.New(db) pdb := profiledb.New(db) odb := oauthdb.New(db) + pkdb := passkeydb.New(db) um := usermanager.New(udb, pdb, logger) om := oauthmanager.New(odb, logger) pm := profilemanager.New(pdb, logger) jm := jwtmanager.New(conf.Auth.Secret, time.Duration(conf.Auth.MaxAge)*time.Second) + // WebAuthn configuration + w, err := webauthn.New(&webauthn.Config{ + RPDisplayName: "FitFeed", + RPID: conf.Web.Hostname, + RPOrigins: []string{fmt.Sprintf("%s://%s:%d", conf.Web.Protocol, conf.Web.Hostname, conf.Web.Port)}, + }) + if err != nil { + logger.Error("failed to create webauthn instance", "error", err) + os.Exit(1) + } + pkm := passkeymanager.New(w, pkdb, udb, logger) + srv := httpserver.New(conf.Auth.Port) - srv.Handler = httpcontroller.New(um, om, pm, jm) + srv.Handler = httpcontroller.New(um, om, pm, jm, pkm) done := make(chan bool, 1) diff --git a/services/auth/go.mod b/services/auth/go.mod index 2aa55d6..bb8146e 100644 --- a/services/auth/go.mod +++ b/services/auth/go.mod @@ -1,10 +1,11 @@ module fitfeed/auth -go 1.24.5 +go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/render v1.0.3 + github.com/go-webauthn/webauthn v0.16.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 @@ -20,7 +21,10 @@ require ( github.com/ajg/form v1.5.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.2 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/mux v1.6.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -31,6 +35,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -39,12 +44,14 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/x448/float16 v0.8.4 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.40.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/services/auth/go.sum b/services/auth/go.sum index 35a1717..a95df5d 100644 --- a/services/auth/go.sum +++ b/services/auth/go.sum @@ -9,16 +9,26 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.2 h1:n116UuvIa7nUVGFP2hO9U24gBqhJTcmbU3ph0wgVzFM= +github.com/go-webauthn/webauthn v0.16.2/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI= +github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= +github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -51,6 +61,8 @@ github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -76,20 +88,26 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/services/auth/internal/controller/http/router.go b/services/auth/internal/controller/http/router.go index 33db1c4..9f23404 100644 --- a/services/auth/internal/controller/http/router.go +++ b/services/auth/internal/controller/http/router.go @@ -11,7 +11,7 @@ import ( "github.com/go-chi/render" ) -func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager) http.Handler { +func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager, pk usecase.PasskeyManager) http.Handler { r := chi.NewRouter() @@ -32,7 +32,7 @@ func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager apiV1 := chi.NewRouter() - v1.NewOauthRoutes(apiV1, u, o, p, j) + v1.NewAuthRoutes(apiV1, u, o, p, j, pk) r.Mount("/v1", apiV1) diff --git a/services/auth/internal/controller/http/v1/controller.go b/services/auth/internal/controller/http/v1/controller.go index b959a99..5b21a93 100644 --- a/services/auth/internal/controller/http/v1/controller.go +++ b/services/auth/internal/controller/http/v1/controller.go @@ -9,4 +9,5 @@ type V1 struct { o usecase.OauthManager p usecase.ProfileManager j usecase.JWTManager + pk usecase.PasskeyManager } diff --git a/services/auth/internal/controller/http/v1/passkey.go b/services/auth/internal/controller/http/v1/passkey.go new file mode 100644 index 0000000..1c59704 --- /dev/null +++ b/services/auth/internal/controller/http/v1/passkey.go @@ -0,0 +1,133 @@ +package v1 + +import ( + "encoding/json" + "fitfeed/auth/internal/entity" + "net/http" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/markbates/goth/gothic" +) + +func (h *V1) beginRegistration(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "username is required", http.StatusBadRequest) + return + } + + user, err := h.u.GetByUsername(r.Context(), username) + if err != nil { + // If user doesn't exist, we might want to create a skeleton user + // or require them to exist first (e.g. via OAuth). + // For now let's assume they must exist or we create them. + user = entity.User{Username: username} + err = h.u.RegisterUser(r.Context(), user) + if err != nil { + http.Error(w, "failed to register user", http.StatusInternalServerError) + return + } + // Fetch again to get ID + user, _ = h.u.GetByUsername(r.Context(), username) + } + + options, sessionData, err := h.pk.BeginRegistration(r.Context(), user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Store session data in gothic session (or similar) + session, _ := gothic.Store.Get(r, "webauthn-session") + data, _ := json.Marshal(sessionData) + session.Values["registration-data"] = string(data) + session.Save(r, w) + + json.NewEncoder(w).Encode(options) +} + +func (h *V1) finishRegistration(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + user, err := h.u.GetByUsername(r.Context(), username) + if err != nil { + http.Error(w, "user not found", http.StatusNotFound) + return + } + + session, _ := gothic.Store.Get(r, "webauthn-session") + sessionDataStr, ok := session.Values["registration-data"].(string) + if !ok { + http.Error(w, "session not found", http.StatusBadRequest) + return + } + + var sessionData webauthn.SessionData + json.Unmarshal([]byte(sessionDataStr), &sessionData) + + err = h.pk.FinishRegistration(r.Context(), user, sessionData, r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Write([]byte("registration successful")) +} + +func (h *V1) beginLogin(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "username is required", http.StatusBadRequest) + return + } + + options, sessionData, err := h.pk.BeginLogin(r.Context(), username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + session, _ := gothic.Store.Get(r, "webauthn-session") + data, _ := json.Marshal(sessionData) + session.Values["login-data"] = string(data) + session.Save(r, w) + + json.NewEncoder(w).Encode(options) +} + +func (h *V1) finishLogin(w http.ResponseWriter, r *http.Request) { + session, _ := gothic.Store.Get(r, "webauthn-session") + sessionDataStr, ok := session.Values["login-data"].(string) + if !ok { + http.Error(w, "session not found", http.StatusBadRequest) + return + } + + var sessionData webauthn.SessionData + json.Unmarshal([]byte(sessionDataStr), &sessionData) + + user, err := h.pk.FinishLogin(r.Context(), sessionData, r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Generate JWT + token, err := h.j.GenerateToken(user) + if err != nil { + http.Error(w, "failed to generate token", http.StatusInternalServerError) + return + } + + // Set cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: token, + Path: "/", + HttpOnly: true, + Secure: false, + Expires: time.Now().Add(24 * time.Hour), + }) + + w.Write([]byte("login successful")) +} diff --git a/services/auth/internal/controller/http/v1/router.go b/services/auth/internal/controller/http/v1/router.go index b698649..73ff436 100644 --- a/services/auth/internal/controller/http/v1/router.go +++ b/services/auth/internal/controller/http/v1/router.go @@ -16,9 +16,9 @@ func NewUserRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p }) } -func NewOauthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager) { +func NewAuthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager, j usecase.JWTManager, pk usecase.PasskeyManager) { - h := &V1{u: u, o: o, p: p, j: j} + h := &V1{u: u, o: o, p: p, j: j, pk: pk} r.Route("/oauth", func(r chi.Router) { r.Get("/{provider}/callback", h.getAuthCallbackFunction) @@ -26,4 +26,11 @@ func NewOauthRoutes(r *chi.Mux, u usecase.UserManager, o usecase.OauthManager, p r.Get("/{provider}/logout", h.getLogoutFunction) }) + + r.Route("/passkey", func(r chi.Router) { + r.Get("/register/begin", h.beginRegistration) + r.Post("/register/finish", h.finishRegistration) + r.Get("/login/begin", h.beginLogin) + r.Post("/login/finish", h.finishLogin) + }) } diff --git a/services/auth/internal/entity/passkey.go b/services/auth/internal/entity/passkey.go new file mode 100644 index 0000000..ec66fcb --- /dev/null +++ b/services/auth/internal/entity/passkey.go @@ -0,0 +1,28 @@ +package entity + +import ( + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +type Passkey struct { + Base + UserID uuid.UUID `gorm:"type:uuid;index" json:"user_id"` + CredentialID []byte `gorm:"type:bytea;index" json:"credential_id"` + PublicKey []byte `gorm:"type:bytea" json:"public_key"` + AttestationType string `gorm:"size:255" json:"attestation_type"` + AAGUID []byte `gorm:"type:bytea" json:"aaguid"` + SignCount uint32 `json:"sign_count"` +} + +func (p Passkey) WebAuthnCredential() webauthn.Credential { + return webauthn.Credential{ + ID: p.CredentialID, + PublicKey: p.PublicKey, + AttestationType: p.AttestationType, + Authenticator: webauthn.Authenticator{ + AAGUID: p.AAGUID, + SignCount: p.SignCount, + }, + } +} diff --git a/services/auth/internal/entity/user.go b/services/auth/internal/entity/user.go index 9060dd4..541efce 100644 --- a/services/auth/internal/entity/user.go +++ b/services/auth/internal/entity/user.go @@ -3,6 +3,7 @@ package entity import ( "context" + "github.com/go-webauthn/webauthn/webauthn" "github.com/google/uuid" ) @@ -12,6 +13,31 @@ type User struct { Username string `gorm:"size:255;uniqueIndex;not null" json:"username"` Profile Profile `json:"profile"` OauthProviders []OauthProvider `json:"oauth_providers"` + Passkeys []Passkey `json:"passkeys"` +} + +func (u User) WebAuthnID() []byte { + return []byte(u.Username) +} + +func (u User) WebAuthnName() string { + return u.Username +} + +func (u User) WebAuthnDisplayName() string { + return u.Username +} + +func (u User) WebAuthnIcon() string { + return u.Profile.AvatarURL +} + +func (u User) WebAuthnCredentials() []webauthn.Credential { + res := make([]webauthn.Credential, len(u.Passkeys)) + for i, pk := range u.Passkeys { + res[i] = pk.WebAuthnCredential() + } + return res } type UserService interface { diff --git a/services/auth/internal/repo/contracts.go b/services/auth/internal/repo/contracts.go index ed6e098..0656d87 100644 --- a/services/auth/internal/repo/contracts.go +++ b/services/auth/internal/repo/contracts.go @@ -31,4 +31,11 @@ type ( UpdateProviderID(context.Context, uuid.UUID, string) error Delete(context.Context, uuid.UUID) error } + + PasskeyDB interface { + Create(context.Context, entity.Passkey) error + GetByCredentialID(context.Context, []byte) (entity.Passkey, error) + UpdateSignCount(context.Context, []byte, uint32) error + Delete(context.Context, uuid.UUID) error + } ) diff --git a/services/auth/internal/repo/passkeydb/passkeydb.go b/services/auth/internal/repo/passkeydb/passkeydb.go new file mode 100644 index 0000000..70de7d4 --- /dev/null +++ b/services/auth/internal/repo/passkeydb/passkeydb.go @@ -0,0 +1,37 @@ +package passkeydb + +import ( + "context" + "fitfeed/auth/internal/entity" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PasskeyDB struct { + db *gorm.DB +} + +func New(db *gorm.DB) *PasskeyDB { + return &PasskeyDB{db: db} +} + +func (p *PasskeyDB) Create(ctx context.Context, pk entity.Passkey) error { + err := gorm.G[entity.Passkey](p.db).Create(ctx, &pk) + return err +} + +func (p *PasskeyDB) GetByCredentialID(ctx context.Context, credentialID []byte) (entity.Passkey, error) { + pk, err := gorm.G[entity.Passkey](p.db).Where("credential_id = ?", credentialID).First(ctx) + return pk, err +} + +func (p *PasskeyDB) UpdateSignCount(ctx context.Context, credentialID []byte, signCount uint32) error { + _, err := gorm.G[entity.Passkey](p.db).Where("credential_id = ?", credentialID).Update(ctx, "sign_count", signCount) + return err +} + +func (p *PasskeyDB) Delete(ctx context.Context, id uuid.UUID) error { + _, err := gorm.G[entity.Passkey](p.db).Where("id = ?", id).Delete(ctx) + return err +} diff --git a/services/auth/internal/usecase/contracts.go b/services/auth/internal/usecase/contracts.go index 8811fcf..c3522a0 100644 --- a/services/auth/internal/usecase/contracts.go +++ b/services/auth/internal/usecase/contracts.go @@ -2,9 +2,12 @@ package usecase import ( "context" + "net/http" "fitfeed/auth/internal/entity" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" "github.com/google/uuid" ) @@ -59,4 +62,11 @@ type ( GenerateToken(user entity.User) (string, error) ValidateToken(token string) (*entity.UserClaims, error) } + + PasskeyManager interface { + BeginRegistration(ctx context.Context, user entity.User) (*protocol.CredentialCreation, *webauthn.SessionData, error) + FinishRegistration(ctx context.Context, user entity.User, session webauthn.SessionData, response *http.Request) error + BeginLogin(ctx context.Context, username string) (*protocol.CredentialAssertion, *webauthn.SessionData, error) + FinishLogin(ctx context.Context, session webauthn.SessionData, response *http.Request) (entity.User, error) + } ) diff --git a/services/auth/internal/usecase/passkeymanager/passkeymanager.go b/services/auth/internal/usecase/passkeymanager/passkeymanager.go new file mode 100644 index 0000000..33d3be4 --- /dev/null +++ b/services/auth/internal/usecase/passkeymanager/passkeymanager.go @@ -0,0 +1,97 @@ +package passkeymanager + +import ( + "context" + "fitfeed/auth/internal/entity" + "fitfeed/auth/internal/repo" + "log/slog" + "net/http" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +type UseCase struct { + w *webauthn.WebAuthn + db repo.PasskeyDB + u repo.UserDB + logger *slog.Logger +} + +func New(w *webauthn.WebAuthn, db repo.PasskeyDB, u repo.UserDB, logger *slog.Logger) *UseCase { + return &UseCase{w: w, db: db, u: u, logger: logger} +} + +func (u *UseCase) BeginRegistration(ctx context.Context, user entity.User) (*protocol.CredentialCreation, *webauthn.SessionData, error) { + creation, sessionData, err := u.w.BeginRegistration(user) + if err != nil { + u.logger.Error("failed to begin passkey registration", "error", err, "username", user.Username) + return nil, nil, entity.EINTERNAL + } + return creation, sessionData, nil +} + +func (u *UseCase) FinishRegistration(ctx context.Context, user entity.User, session webauthn.SessionData, response *http.Request) error { + credential, err := u.w.FinishRegistration(user, session, response) + if err != nil { + u.logger.Error("failed to finish passkey registration", "error", err, "username", user.Username) + return entity.EINVALID + } + + pk := entity.Passkey{ + UserID: user.ID, + CredentialID: credential.ID, + PublicKey: credential.PublicKey, + AttestationType: credential.AttestationType, + AAGUID: credential.Authenticator.AAGUID, + SignCount: credential.Authenticator.SignCount, + } + + err = u.db.Create(ctx, pk) + if err != nil { + u.logger.Error("failed to save passkey", "error", err, "username", user.Username) + return entity.EINTERNAL + } + + return nil +} + +func (u *UseCase) BeginLogin(ctx context.Context, username string) (*protocol.CredentialAssertion, *webauthn.SessionData, error) { + user, err := u.u.GetByUsername(ctx, username) + if err != nil { + return nil, nil, entity.ENOTFOUND + } + + assertion, sessionData, err := u.w.BeginLogin(user) + if err != nil { + u.logger.Error("failed to begin passkey login", "error", err, "username", username) + return nil, nil, entity.EINTERNAL + } + return assertion, sessionData, nil +} + +func (u *UseCase) FinishLogin(ctx context.Context, session webauthn.SessionData, response *http.Request) (entity.User, error) { + // WebAuthn FinishLogin needs the user, but we don't know who it is yet + // until we parse the response or we use the username from sessionData. + // Actually, sessionData has UserID. + + username := string(session.UserID) + user, err := u.u.GetByUsername(ctx, username) + if err != nil { + return entity.User{}, entity.ENOTFOUND + } + + credential, err := u.w.FinishLogin(user, session, response) + if err != nil { + u.logger.Error("failed to finish passkey login", "error", err, "username", username) + return entity.User{}, entity.EINVALID + } + + // Update sign count + err = u.db.UpdateSignCount(ctx, credential.ID, credential.Authenticator.SignCount) + if err != nil { + u.logger.Error("failed to update passkey sign count", "error", err, "username", username) + } + + return user, nil +} diff --git a/services/dbm/internal/migrations/00002_add_passkeys_table.go b/services/dbm/internal/migrations/00002_add_passkeys_table.go new file mode 100644 index 0000000..07f8df7 --- /dev/null +++ b/services/dbm/internal/migrations/00002_add_passkeys_table.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + "fitfeed/dbm/internal/models" + "fitfeed/dbm/internal/postgres" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPasskeysTable, downAddPasskeysTable) +} + +func upAddPasskeysTable(ctx context.Context, tx *sql.Tx) error { + if err := postgres.Migrator.CreateTable(&models.Passkey{}); err != nil { + return err + } + if err := postgres.Migrator.CreateConstraint(&models.User{}, "Passkeys"); err != nil { + return err + } + return nil +} + +func downAddPasskeysTable(ctx context.Context, tx *sql.Tx) error { + if err := postgres.Migrator.DropConstraint(&models.User{}, "Passkeys"); err != nil { + return err + } + if err := postgres.Migrator.DropTable(&models.Passkey{}); err != nil { + return err + } + return nil +} diff --git a/services/dbm/internal/models/models.go b/services/dbm/internal/models/models.go index 29b1cac..0ff3802 100644 --- a/services/dbm/internal/models/models.go +++ b/services/dbm/internal/models/models.go @@ -27,6 +27,19 @@ type User struct { Username string `gorm:"size:255;index;unique;not null" json:"username"` Profile Profile `json:"profile"` OauthProviders []OauthProvider `json:"oauth_providers"` + Passkeys []Passkey `json:"passkeys"` +} + +// ... Profile and OauthProvider ... + +type Passkey struct { + Base + UserID uuid.UUID `gorm:"type:uuid;index" json:"user_id"` + CredentialID []byte `gorm:"type:bytea;index" json:"credential_id"` + PublicKey []byte `gorm:"type:bytea" json:"public_key"` + AttestationType string `gorm:"size:255" json:"attestation_type"` + AAGUID []byte `gorm:"type:bytea" json:"aaguid"` + SignCount uint32 `json:"sign_count"` } // Profile is the model for the profile table. From 629c214d021df169a356c5e4107862a9a2cb7914 Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Thu, 2 Apr 2026 20:32:12 +0200 Subject: [PATCH 03/12] feat(web): restructure web service and implement homepage layout - Restructure folder hierarchy (components, pages, types, context) - Implement AuthProvider with AuthContext - Create MainLayout with responsive Header and Footer - Implement Strava-inspired design using Ant Design - Add Login/Register modal with OAuth and Passkey options - Design Homepage with feed and sidebar stats - Update App.tsx to use new architecture and theme --- services/web/src/App.tsx | 144 +++--------------- .../web/src/components/layout/AppFooter.tsx | 19 +++ .../web/src/components/layout/AppHeader.tsx | 119 +++++++++++++++ .../web/src/components/layout/MainLayout.tsx | 31 ++++ services/web/src/context/AuthContext.tsx | 38 +++++ services/web/src/pages/Home.tsx | 67 ++++++++ services/web/src/types/index.ts | 12 ++ 7 files changed, 305 insertions(+), 125 deletions(-) create mode 100644 services/web/src/components/layout/AppFooter.tsx create mode 100644 services/web/src/components/layout/AppHeader.tsx create mode 100644 services/web/src/components/layout/MainLayout.tsx create mode 100644 services/web/src/context/AuthContext.tsx create mode 100644 services/web/src/pages/Home.tsx create mode 100644 services/web/src/types/index.ts diff --git a/services/web/src/App.tsx b/services/web/src/App.tsx index bd530b9..f5dcd7d 100644 --- a/services/web/src/App.tsx +++ b/services/web/src/App.tsx @@ -1,132 +1,26 @@ -import React, { useState } from 'react'; +import React from 'react'; import '@ant-design/v5-patch-for-react-19'; -import { Layout, Menu, Button, Modal, Dropdown, Avatar, Space, Typography, Card } from 'antd'; -import { UserOutlined, SettingOutlined, LogoutOutlined, GoogleOutlined, FacebookOutlined, AppleOutlined } from '@ant-design/icons'; - -const { Header, Content, Footer } = Layout; -const { Title } = Typography; - -// Define a type for the user object to enforce consistency -interface User { - name: string; - avatar?: string; -} - -// Mock user data for the logged-in state -const mockUser: User = { - name: 'Jane Doe', - avatar: 'https://cdn.ant.design/images/ant-design-logo.svg', // Example avatar URL -}; +import { ConfigProvider } from 'antd'; +import { AuthProvider } from './context/AuthContext'; +import MainLayout from './components/layout/MainLayout'; +import Home from './pages/Home'; +import './App.css'; const App: React.FC = () => { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isModalVisible, setIsModalVisible] = useState(false); - const [user, setUser] = useState(null); - - // --- Modal and Auth Handlers --- - - const handleLogin = (platform: string) => { - console.log(`Mocking login with ${platform}...`); - setIsLoggedIn(true); - setUser(mockUser); - setIsModalVisible(false); // Close modal on successful "login" - }; - - const handleLogout = () => { - setIsLoggedIn(false); - setUser(null); - }; - - const showModal = () => { - setIsModalVisible(true); - }; - - const handleCancel = () => { - setIsModalVisible(false); - }; - - // --- Dropdown Menu --- - - const profileMenu = ( - - - - } /> - {user?.name} - - - - }> - Profile - - }> - Account - - } onClick={handleLogout}> - Log out - - - ); - return ( - - {/* Header */} -
-
- FitFeed -
-
- {isLoggedIn ? ( - - - - ) : ( - - )} -
-
- - {/* Main Content */} - -
- Welcome to the Homepage -

This is the main content area of the application.

- -

Here you can add various components and content to your page. This is a simple mock-up to demonstrate a basic layout and state management.

-
-
-
- - {/* Footer */} -
- FitFeed ©2025 Created with Ant Design -
- - {/* Login Modal */} - - - - - - - -
+ + + + + + + ); }; diff --git a/services/web/src/components/layout/AppFooter.tsx b/services/web/src/components/layout/AppFooter.tsx new file mode 100644 index 0000000..cef7599 --- /dev/null +++ b/services/web/src/components/layout/AppFooter.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Layout } from 'antd'; + +const { Footer } = Layout; + +const AppFooter: React.FC = () => { + return ( +
+
+ FITFEED - Privacy-First Fitness Social Platform +
+
+ FitFeed ©{new Date().getFullYear()} Created with Ant Design +
+
+ ); +}; + +export default AppFooter; diff --git a/services/web/src/components/layout/AppHeader.tsx b/services/web/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..6ab73ab --- /dev/null +++ b/services/web/src/components/layout/AppHeader.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { Layout, Menu, Button, Dropdown, Avatar, Space, Modal } from 'antd'; +import { UserOutlined, SettingOutlined, LogoutOutlined, GoogleOutlined, IdcardOutlined } from '@ant-design/icons'; +import { useAuth } from '../../context/AuthContext'; + +const { Header } = Layout; + +const AppHeader: React.FC = () => { + const { user, isLoggedIn, logout, login } = useAuth(); + const [isModalVisible, setIsModalVisible] = useState(false); + + const handleLogout = () => { + logout(); + }; + + const handlePasskeyLogin = () => { + // TODO: Implement Passkey Login + console.log("Passkey login initiated"); + }; + + const handleOAuthLogin = (provider: string) => { + // Redirect to backend auth + window.location.href = `http://localhost:8081/v1/oauth/${provider}/auth`; + }; + + const menuItems = [ + { + key: 'user-info', + disabled: true, + label: ( + + } /> + {user?.username} + + ), + }, + { type: 'divider' }, + { + key: 'profile', + icon: , + label: 'My Profile', + }, + { + key: 'settings', + icon: , + label: 'Settings', + }, + { + key: 'logout', + icon: , + label: 'Log out', + onClick: handleLogout, + }, + ]; + + return ( +
+
+
+ FITFEED +
+ {isLoggedIn && ( + + Dashboard + Activities + Explore + + )} +
+ +
+ {isLoggedIn ? ( + + + + ) : ( + + )} +
+ + setIsModalVisible(false)} + footer={null} + centered + > + + + +
+ By continuing, you agree to FitFeed's Terms of Service and Privacy Policy. +
+
+
+
+ ); +}; + +export default AppHeader; diff --git a/services/web/src/components/layout/MainLayout.tsx b/services/web/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..28a308f --- /dev/null +++ b/services/web/src/components/layout/MainLayout.tsx @@ -0,0 +1,31 @@ +import React, { ReactNode } from 'react'; +import { Layout } from 'antd'; +import AppHeader from './AppHeader'; +import AppFooter from './AppFooter'; + +const { Content } = Layout; + +interface MainLayoutProps { + children: ReactNode; +} + +const MainLayout: React.FC = ({ children }) => { + return ( + + + + {children} + + + + ); +}; + +export default MainLayout; diff --git a/services/web/src/context/AuthContext.tsx b/services/web/src/context/AuthContext.tsx new file mode 100644 index 0000000..43d5d2d --- /dev/null +++ b/services/web/src/context/AuthContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { User } from '../types'; + +interface AuthContextType { + user: User | null; + isLoggedIn: boolean; + login: (user: User) => void; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + + const login = (userData: User) => { + setUser(userData); + }; + + const logout = () => { + setUser(null); + // TODO: Call API logout + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/services/web/src/pages/Home.tsx b/services/web/src/pages/Home.tsx new file mode 100644 index 0000000..68460d1 --- /dev/null +++ b/services/web/src/pages/Home.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Typography, Row, Col, Card, Avatar, Space, Button, Empty } from 'antd'; +import { useAuth } from '../context/AuthContext'; +import { UserOutlined, PlusOutlined } from '@ant-design/icons'; + +const { Title, Text } = Typography; + +const Home: React.FC = () => { + const { isLoggedIn, user } = useAuth(); + + if (!isLoggedIn) { + return ( +
+ The best fitfeed for your fitness journey. + + Track your activities, share with friends, and stay motivated. + Self-hosted and privacy-first. + +
+ +
+
+ ); + } + + return ( + + + + } /> + + {user?.profile.first_name} {user?.profile.last_name} + + @{user?.username} +
+
+ 0
+ FOLLOWING +
+
+ 0
+ FOLLOWERS +
+
+ 0
+ ACTIVITIES +
+
+
+ + + } type="link">Add Activity}> + + + + + + + + +
+ ); +}; + +export default Home; diff --git a/services/web/src/types/index.ts b/services/web/src/types/index.ts new file mode 100644 index 0000000..b6ea6ff --- /dev/null +++ b/services/web/src/types/index.ts @@ -0,0 +1,12 @@ +export interface User { + id: string; + username: string; + profile: Profile; +} + +export interface Profile { + first_name: string; + last_name: string; + avatar_url: string; + email: string; +} From 057509cb434fe52b693185e2e5bd64df7fd71aeb Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Thu, 2 Apr 2026 20:50:40 +0200 Subject: [PATCH 04/12] feat(api, web): starting an API development --- services/api/config/api-config.toml | 23 +++++++ services/api/src/cmd/api/main.go | 69 +++++++++++++++++++ services/api/src/go.mod | 36 +++++++++- services/api/src/internal/config/config.go | 65 +++++++++++++++++ .../src/internal/controller/http/router.go | 38 ++++++++++ .../src/internal/controller/http/v1/config.go | 29 ++++++++ .../src/internal/controller/http/v1/router.go | 20 ++++++ .../src/internal/controller/http/v1/user.go | 40 +++++++++++ services/api/src/internal/entity/base.go | 22 ++++++ services/api/src/internal/entity/error.go | 12 ++++ services/api/src/internal/entity/oauth.go | 10 +++ services/api/src/internal/entity/passkey.go | 28 ++++++++ services/api/src/internal/entity/profile.go | 20 ++++++ services/api/src/internal/entity/user.go | 68 ++++++++++++++++++ services/api/src/internal/repo/contracts.go | 20 ++++++ .../src/internal/repo/profiledb/profiledb.go | 27 ++++++++ .../api/src/internal/repo/userdb/userdb.go | 27 ++++++++ .../api/src/internal/usecase/contracts.go | 15 ++++ .../usecase/usermanager/usermanager.go | 46 +++++++++++++ services/api/src/pkg/httpserver/server.go | 56 +++++++++++++++ services/api/src/pkg/postgres/postgres.go | 29 ++++++++ 21 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 services/api/config/api-config.toml create mode 100644 services/api/src/cmd/api/main.go create mode 100644 services/api/src/internal/config/config.go create mode 100644 services/api/src/internal/controller/http/router.go create mode 100644 services/api/src/internal/controller/http/v1/config.go create mode 100644 services/api/src/internal/controller/http/v1/router.go create mode 100644 services/api/src/internal/controller/http/v1/user.go create mode 100644 services/api/src/internal/entity/base.go create mode 100644 services/api/src/internal/entity/error.go create mode 100644 services/api/src/internal/entity/oauth.go create mode 100644 services/api/src/internal/entity/passkey.go create mode 100644 services/api/src/internal/entity/profile.go create mode 100644 services/api/src/internal/entity/user.go create mode 100644 services/api/src/internal/repo/contracts.go create mode 100644 services/api/src/internal/repo/profiledb/profiledb.go create mode 100644 services/api/src/internal/repo/userdb/userdb.go create mode 100644 services/api/src/internal/usecase/contracts.go create mode 100644 services/api/src/internal/usecase/usermanager/usermanager.go create mode 100644 services/api/src/pkg/httpserver/server.go create mode 100644 services/api/src/pkg/postgres/postgres.go diff --git a/services/api/config/api-config.toml b/services/api/config/api-config.toml new file mode 100644 index 0000000..da2dc61 --- /dev/null +++ b/services/api/config/api-config.toml @@ -0,0 +1,23 @@ +[api] +port = 8082 + +[auth] +port = 8081 +secret = "MuUhv7svOw9iWmeycg7iRhsuF5hr4Gik" +max_session_age = 86400 +is_prod = false + +[database] +driver = "postgres" + +[database.postgres] +host = "localhost" +port = 5432 +username = "postgres" +password = "secret" +dbname = "fitfeed" + +[web] +hostname = "localhost" +protocol = "http" +port = 5173 diff --git a/services/api/src/cmd/api/main.go b/services/api/src/cmd/api/main.go new file mode 100644 index 0000000..6bb2424 --- /dev/null +++ b/services/api/src/cmd/api/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "fitfeed/api/internal/config" + httpcontroller "fitfeed/api/internal/controller/http" + "fitfeed/api/internal/repo/profiledb" + "fitfeed/api/internal/repo/userdb" + "fitfeed/api/internal/usecase/usermanager" + "fmt" + "log/slog" + "net/http" + "os" + + "fitfeed/api/pkg/httpserver" + "fitfeed/api/pkg/postgres" +) + +func main() { + + conf := config.Load() + + // Initialize slog + var handler slog.Handler + if conf.Auth.IsProd { + handler = slog.NewJSONHandler(os.Stdout, nil) + } else { + handler = slog.NewTextHandler(os.Stdout, nil) + } + logger := slog.New(handler) + slog.SetDefault(logger) + + db, err := postgres.ConnectToDatabase(postgres.PGConfig{ + Host: conf.DB.Postgres.Host, + Port: conf.DB.Postgres.Port, + Username: conf.DB.Postgres.Username, + Password: conf.DB.Postgres.Password, + DBname: conf.DB.Postgres.DBname, + }) + if err != nil { + logger.Error("DB connection error", "error", err) + os.Exit(1) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + + udb := userdb.New(db) + pdb := profiledb.New(db) + + um := usermanager.New(udb, pdb, logger) + + srv := httpserver.New(conf.API.Port) + srv.Handler = httpcontroller.New(um, conf) + + done := make(chan bool, 1) + + go httpserver.GracefulShutdown(srv, done) + + logger.Info("Starting api server...", "port", conf.API.Port) + err = srv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + logger.Error("http server error", "error", err) + panic(fmt.Sprintf("http server error: %s", err)) + } + + // Wait for the graceful shutdown to complete + <-done + logger.Info("Graceful shutdown complete.") + +} diff --git a/services/api/src/go.mod b/services/api/src/go.mod index 86da452..2aea8c5 100644 --- a/services/api/src/go.mod +++ b/services/api/src/go.mod @@ -1,3 +1,37 @@ module fitfeed/api -go 1.24.5 +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.2 + github.com/go-chi/render v1.0.3 + github.com/google/uuid v1.6.0 + github.com/spf13/viper v1.20.1 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.30.2 +) + +require ( + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/services/api/src/internal/config/config.go b/services/api/src/internal/config/config.go new file mode 100644 index 0000000..644ebab --- /dev/null +++ b/services/api/src/internal/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "log" + "strings" + + "github.com/spf13/viper" +) + +type AppConfig struct { + API struct { + Port int `mapstructure:"port"` + } `mapstructure:"api"` + Auth struct { + Port int `mapstructure:"port"` + Prefix string `mapstructure:"prefix"` + Secret string `mapstructure:"secret"` + IsProd bool `mapstructure:"is_prod"` + MaxAge int `mapstructure:"max_session_age"` + Providers map[string]struct { + Enabled bool `mapstructure:"enabled"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + } `mapstructure:"providers"` + } `mapstructure:"auth"` + DB struct { + Driver string `mapstructure:"driver"` + Postgres struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + DBname string `mapstructure:"dbname"` + } `mapstructure:"postgres"` + } `mapstructure:"database"` + Web struct { + Hostname string `mapstructure:"hostname"` + Protocol string `mapstructure:"protocol"` + Port int `mapstructure:"port"` + } `mapstructure:"web"` +} + +func Load() *AppConfig { + viper.SetConfigName("api-config") + viper.SetConfigType("toml") + viper.AddConfigPath("../../config") + viper.AddConfigPath("./config") + + viper.AutomaticEnv() + viper.SetEnvPrefix("fitfeed") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "__")) + + err := viper.ReadInConfig() + if err != nil { + log.Fatalf("Error reading config file, %s", err) + } + + var config AppConfig + err = viper.Unmarshal(&config) + if err != nil { + log.Fatalf("Unable to decode into struct, %v", err) + } + + return &config +} diff --git a/services/api/src/internal/controller/http/router.go b/services/api/src/internal/controller/http/router.go new file mode 100644 index 0000000..d6f377c --- /dev/null +++ b/services/api/src/internal/controller/http/router.go @@ -0,0 +1,38 @@ +package http + +import ( + v1 "fitfeed/api/internal/controller/http/v1" + "fitfeed/api/internal/config" + "fitfeed/api/internal/usecase" + + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" +) + +func New(u usecase.UserManager, conf *config.AppConfig) http.Handler { + + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.URLFormat) + r.Use(middleware.Heartbeat("/ping")) + r.Use(render.SetContentType(render.ContentTypeJSON)) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("api root.")) + }) + + apiV1 := chi.NewRouter() + + v1.NewRouter(apiV1, u, conf) + + r.Mount("/v1", apiV1) + + return r + +} diff --git a/services/api/src/internal/controller/http/v1/config.go b/services/api/src/internal/controller/http/v1/config.go new file mode 100644 index 0000000..1957c0b --- /dev/null +++ b/services/api/src/internal/controller/http/v1/config.go @@ -0,0 +1,29 @@ +package v1 + +import ( + "encoding/json" + "fitfeed/api/internal/config" + "net/http" +) + +type ConfigController struct { + conf *config.AppConfig +} + +func NewConfigController(conf *config.AppConfig) *ConfigController { + return &ConfigController{conf: conf} +} + +type WebConfig struct { + AuthURL string `json:"auth_url"` + APIURL string `json:"api_url"` +} + +func (c *ConfigController) GetConfig(w http.ResponseWriter, r *http.Request) { + webConf := WebConfig{ + AuthURL: "http://localhost:8081", // In prod get from conf + APIURL: "http://localhost:8082", // In prod get from conf + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(webConf) +} diff --git a/services/api/src/internal/controller/http/v1/router.go b/services/api/src/internal/controller/http/v1/router.go new file mode 100644 index 0000000..7b0abd2 --- /dev/null +++ b/services/api/src/internal/controller/http/v1/router.go @@ -0,0 +1,20 @@ +package v1 + +import ( + "fitfeed/api/internal/config" + "fitfeed/api/internal/usecase" + + "github.com/go-chi/chi/v5" +) + +func NewRouter(r chi.Router, u usecase.UserManager, conf *config.AppConfig) { + uc := NewUserController(u) + cc := NewConfigController(conf) + + r.Route("/users", func(r chi.Router) { + r.Get("/{username}", uc.GetProfile) + r.Put("/profile", uc.UpdateProfile) + }) + + r.Get("/config", cc.GetConfig) +} diff --git a/services/api/src/internal/controller/http/v1/user.go b/services/api/src/internal/controller/http/v1/user.go new file mode 100644 index 0000000..aeae7c7 --- /dev/null +++ b/services/api/src/internal/controller/http/v1/user.go @@ -0,0 +1,40 @@ +package v1 + +import ( + "encoding/json" + "fitfeed/api/internal/entity" + "fitfeed/api/internal/usecase" + "net/http" + + "github.com/go-chi/chi/v5" +) + +type UserController struct { + u usecase.UserManager +} + +func NewUserController(u usecase.UserManager) *UserController { + return &UserController{u: u} +} + +func (c *UserController) GetProfile(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + user, err := c.u.GetProfile(r.Context(), username) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(user) +} + +func (c *UserController) UpdateProfile(w http.ResponseWriter, r *http.Request) { + // TODO: Get user ID from JWT context (needs middleware) + // For now assume we get it from request or similar (unsafe) + var profile entity.Profile + if err := json.NewDecoder(r.Body).Decode(&profile); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // err := c.u.UpdateProfile(r.Context(), userID, profile) + w.WriteHeader(http.StatusNotImplemented) +} diff --git a/services/api/src/internal/entity/base.go b/services/api/src/internal/entity/base.go new file mode 100644 index 0000000..b53be58 --- /dev/null +++ b/services/api/src/internal/entity/base.go @@ -0,0 +1,22 @@ +package entity + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Base contains common columns for all tables. +type Base struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"deleted_at"` +} + +// BeforeCreate will set a UUID rather than numeric ID. +func (b *Base) BeforeCreate(tx *gorm.DB) (err error) { + b.ID = uuid.New() + return +} diff --git a/services/api/src/internal/entity/error.go b/services/api/src/internal/entity/error.go new file mode 100644 index 0000000..e5b4baf --- /dev/null +++ b/services/api/src/internal/entity/error.go @@ -0,0 +1,12 @@ +package entity + +import "errors" + +var ( + ENOTFOUND = errors.New("not found") + EUNAUTHORIZED = errors.New("unauthorized") + ENOTAVAILABLE = errors.New("not available") + EINTERNAL = errors.New("internal error") + EINVALID = errors.New("invalid") + ECONFLICT = errors.New("conflict") +) diff --git a/services/api/src/internal/entity/oauth.go b/services/api/src/internal/entity/oauth.go new file mode 100644 index 0000000..be0a3f2 --- /dev/null +++ b/services/api/src/internal/entity/oauth.go @@ -0,0 +1,10 @@ +package entity + +import "github.com/google/uuid" + +type OauthProvider struct { + Base + UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` + Provider string `gorm:"size:31" json:"provider"` + ProviderID string `gorm:"size:255;index" json:"provider_id"` +} diff --git a/services/api/src/internal/entity/passkey.go b/services/api/src/internal/entity/passkey.go new file mode 100644 index 0000000..ec66fcb --- /dev/null +++ b/services/api/src/internal/entity/passkey.go @@ -0,0 +1,28 @@ +package entity + +import ( + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +type Passkey struct { + Base + UserID uuid.UUID `gorm:"type:uuid;index" json:"user_id"` + CredentialID []byte `gorm:"type:bytea;index" json:"credential_id"` + PublicKey []byte `gorm:"type:bytea" json:"public_key"` + AttestationType string `gorm:"size:255" json:"attestation_type"` + AAGUID []byte `gorm:"type:bytea" json:"aaguid"` + SignCount uint32 `json:"sign_count"` +} + +func (p Passkey) WebAuthnCredential() webauthn.Credential { + return webauthn.Credential{ + ID: p.CredentialID, + PublicKey: p.PublicKey, + AttestationType: p.AttestationType, + Authenticator: webauthn.Authenticator{ + AAGUID: p.AAGUID, + SignCount: p.SignCount, + }, + } +} diff --git a/services/api/src/internal/entity/profile.go b/services/api/src/internal/entity/profile.go new file mode 100644 index 0000000..aef5692 --- /dev/null +++ b/services/api/src/internal/entity/profile.go @@ -0,0 +1,20 @@ +package entity + +import "github.com/google/uuid" + +// Profile is the model for the profile table. +type Profile struct { + Base + FirstName string `gorm:"size:255" json:"first_name"` + LastName string `gorm:"size:255" json:"last_name"` + AvatarURL string `gorm:"size:255" json:"avatar_url"` + Email string `gorm:"uniqueIndex" json:"email"` + UserID uuid.UUID `gorm:"type:uuid" json:"user_id"` +} + +type ProfileUpdate struct { + FirsrName string + LastName string + AvatarURL string + Email string +} diff --git a/services/api/src/internal/entity/user.go b/services/api/src/internal/entity/user.go new file mode 100644 index 0000000..541efce --- /dev/null +++ b/services/api/src/internal/entity/user.go @@ -0,0 +1,68 @@ +package entity + +import ( + "context" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +// User is the model for the user table. +type User struct { + Base + Username string `gorm:"size:255;uniqueIndex;not null" json:"username"` + Profile Profile `json:"profile"` + OauthProviders []OauthProvider `json:"oauth_providers"` + Passkeys []Passkey `json:"passkeys"` +} + +func (u User) WebAuthnID() []byte { + return []byte(u.Username) +} + +func (u User) WebAuthnName() string { + return u.Username +} + +func (u User) WebAuthnDisplayName() string { + return u.Username +} + +func (u User) WebAuthnIcon() string { + return u.Profile.AvatarURL +} + +func (u User) WebAuthnCredentials() []webauthn.Credential { + res := make([]webauthn.Credential, len(u.Passkeys)) + for i, pk := range u.Passkeys { + res[i] = pk.WebAuthnCredential() + } + return res +} + +type UserService interface { + + // Retrieves a user by ID along with their associated auth objects. + // Returns ENOTFOUND if user does not exist. + FindUserByID(ctx context.Context, id uuid.UUID) (*User, error) + + // Retrieves a list of users by filter. Also returns total count of matching + // users which may differ from returned results if filter.Limit is specified. + FindUsers(ctx context.Context, filter UserFilter) ([]*User, int, error) +} + +// UserFilter represents a filter passed to FindUsers(). +type UserFilter struct { + // Filtering fields. + ID *uuid.UUID `json:"id"` + Username *string `json:"username"` + + // Restrict to subset of results. + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +type UserClaims struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` +} diff --git a/services/api/src/internal/repo/contracts.go b/services/api/src/internal/repo/contracts.go new file mode 100644 index 0000000..e505628 --- /dev/null +++ b/services/api/src/internal/repo/contracts.go @@ -0,0 +1,20 @@ +package repo + +import ( + "context" + "fitfeed/api/internal/entity" + + "github.com/google/uuid" +) + +type ( + UserDB interface { + GetByID(context.Context, uuid.UUID) (entity.User, error) + GetByUsername(context.Context, string) (entity.User, error) + } + + ProfileDB interface { + GetByUserID(context.Context, uuid.UUID) (entity.Profile, error) + Update(context.Context, uuid.UUID, entity.Profile) error + } +) diff --git a/services/api/src/internal/repo/profiledb/profiledb.go b/services/api/src/internal/repo/profiledb/profiledb.go new file mode 100644 index 0000000..c4a33fa --- /dev/null +++ b/services/api/src/internal/repo/profiledb/profiledb.go @@ -0,0 +1,27 @@ +package profiledb + +import ( + "context" + "fitfeed/api/internal/entity" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ProfileDB struct { + db *gorm.DB +} + +func New(db *gorm.DB) *ProfileDB { + return &ProfileDB{db: db} +} + +func (p *ProfileDB) GetByUserID(ctx context.Context, userID uuid.UUID) (entity.Profile, error) { + profile, err := gorm.G[entity.Profile](p.db).Where("user_id = ?", userID).First(ctx) + return profile, err +} + +func (p *ProfileDB) Update(ctx context.Context, userID uuid.UUID, profile entity.Profile) error { + _, err := gorm.G[entity.Profile](p.db).Where("user_id = ?", userID).Updates(ctx, profile) + return err +} diff --git a/services/api/src/internal/repo/userdb/userdb.go b/services/api/src/internal/repo/userdb/userdb.go new file mode 100644 index 0000000..602574f --- /dev/null +++ b/services/api/src/internal/repo/userdb/userdb.go @@ -0,0 +1,27 @@ +package userdb + +import ( + "context" + "fitfeed/api/internal/entity" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserDB struct { + db *gorm.DB +} + +func New(db *gorm.DB) *UserDB { + return &UserDB{db: db} +} + +func (u *UserDB) GetByID(ctx context.Context, id uuid.UUID) (entity.User, error) { + user, err := gorm.G[entity.User](u.db).Where("id = ?", id).First(ctx) + return user, err +} + +func (u *UserDB) GetByUsername(ctx context.Context, username string) (entity.User, error) { + user, err := gorm.G[entity.User](u.db).Where("username = ?", username).First(ctx) + return user, err +} diff --git a/services/api/src/internal/usecase/contracts.go b/services/api/src/internal/usecase/contracts.go new file mode 100644 index 0000000..b2531b1 --- /dev/null +++ b/services/api/src/internal/usecase/contracts.go @@ -0,0 +1,15 @@ +package usecase + +import ( + "context" + "fitfeed/api/internal/entity" + + "github.com/google/uuid" +) + +type ( + UserManager interface { + GetProfile(ctx context.Context, username string) (entity.User, error) + UpdateProfile(ctx context.Context, id uuid.UUID, profile entity.Profile) error + } +) diff --git a/services/api/src/internal/usecase/usermanager/usermanager.go b/services/api/src/internal/usecase/usermanager/usermanager.go new file mode 100644 index 0000000..780c7c2 --- /dev/null +++ b/services/api/src/internal/usecase/usermanager/usermanager.go @@ -0,0 +1,46 @@ +package usermanager + +import ( + "context" + "fitfeed/api/internal/entity" + "fitfeed/api/internal/repo" + "log/slog" + + "github.com/google/uuid" +) + +type UserManager struct { + userDB repo.UserDB + profileDB repo.ProfileDB + logger *slog.Logger +} + +func New(userDB repo.UserDB, profileDB repo.ProfileDB, logger *slog.Logger) *UserManager { + return &UserManager{userDB: userDB, profileDB: profileDB, logger: logger} +} + +func (u *UserManager) GetProfile(ctx context.Context, username string) (entity.User, error) { + user, err := u.userDB.GetByUsername(ctx, username) + if err != nil { + u.logger.Error("failed to get user by username", "error", err, "username", username) + return entity.User{}, entity.ENOTFOUND + } + + profile, err := u.profileDB.GetByUserID(ctx, user.ID) + if err != nil { + u.logger.Error("failed to get profile by user id", "error", err, "user_id", user.ID) + return entity.User{}, entity.EINTERNAL + } + + user.Profile = profile + return user, nil +} + +func (u *UserManager) UpdateProfile(ctx context.Context, id uuid.UUID, profile entity.Profile) error { + err := u.profileDB.Update(ctx, id, profile) + if err != nil { + u.logger.Error("failed to update profile", "error", err, "user_id", id) + return entity.EINTERNAL + } + return nil +} diff --git a/services/api/src/pkg/httpserver/server.go b/services/api/src/pkg/httpserver/server.go new file mode 100644 index 0000000..3d8e463 --- /dev/null +++ b/services/api/src/pkg/httpserver/server.go @@ -0,0 +1,56 @@ +package httpserver + +import ( + "context" + "fmt" + "log" + "net/http" + "os/signal" + "syscall" + "time" +) + +type Server struct { + port int +} + +func New(port int) *http.Server { + NewServer := &Server{ + port: port, + } + + // Declare Server config + server := &http.Server{ + Addr: fmt.Sprintf(":%d", NewServer.port), + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + return server +} + +func GracefulShutdown(server *http.Server, done chan bool) { + // Create context that listens for the interrupt signal from the OS. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Listen for the interrupt signal. + <-ctx.Done() + + log.Println("shutting down gracefully, press Ctrl+C again to force") + stop() // Allow Ctrl+C to force shutdown + + // The context is used to inform the server it has 5 seconds to finish + // the request it is currently handling + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("Server forced to shutdown with error: %v", err) + } + + log.Println("Server exiting") + + // Notify the main goroutine that the shutdown is complete + done <- true +} diff --git a/services/api/src/pkg/postgres/postgres.go b/services/api/src/pkg/postgres/postgres.go new file mode 100644 index 0000000..2b2d763 --- /dev/null +++ b/services/api/src/pkg/postgres/postgres.go @@ -0,0 +1,29 @@ +package postgres + +import ( + "fmt" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type PGConfig struct { + Host string + Port int + Username string + Password string + DBname string +} + +func ConnectToDatabase(conf PGConfig) (*gorm.DB, error) { + + dbURL := fmt.Sprintf("user=%s password=%s dbname=%s host=%s port=%d", + conf.Username, + conf.Password, + conf.DBname, + conf.Host, + conf.Port) + + db, err := gorm.Open(postgres.Open(dbURL), &gorm.Config{}) + return db, err +} From e4af71c23036f3161ad566a83a453cc86310d2bb Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 21:38:15 +0200 Subject: [PATCH 05/12] feat: unify configuration and create dev environment - Create project-wide config.toml.template - Update Viper config loaders in all services (auth, api, dbm) - Implement root Makefile with 'init' and 'dev' commands - Add Air configuration for hot-reload of Go services - Configure Vite .env for API bootstrap - Add config.toml to .gitignore --- .air.api.toml | 37 ++++++++++++ .air.auth.toml | 37 ++++++++++++ .gitignore | 1 + Makefile | 56 +++++++++++++++++++ config.toml.template | 44 +++++++++++++++ services/api/src/internal/config/config.go | 19 +++++-- .../src/internal/controller/http/router.go | 17 ++++++ services/auth/internal/config/config.go | 18 ++++-- .../auth/internal/controller/http/router.go | 17 ++++++ services/dbm/internal/config/config.go | 18 ++++-- services/web/.env | 1 + .../web/src/components/layout/AppHeader.tsx | 5 +- services/web/src/context/AuthContext.tsx | 29 ++++++++-- services/web/src/types/index.ts | 6 ++ 14 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 .air.api.toml create mode 100644 .air.auth.toml create mode 100644 Makefile create mode 100644 config.toml.template create mode 100644 services/web/.env diff --git a/.air.api.toml b/.air.api.toml new file mode 100644 index 0000000..8e4c112 --- /dev/null +++ b/.air.api.toml @@ -0,0 +1,37 @@ +root = "services/api/src" +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/api/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = ["cmd", "internal", "pkg"] + include_ext = ["go", "tpl", "tmpl", "html", "toml"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = true + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.air.auth.toml b/.air.auth.toml new file mode 100644 index 0000000..7d65c65 --- /dev/null +++ b/.air.auth.toml @@ -0,0 +1,37 @@ +root = "services/auth" +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/auth/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = ["cmd", "internal", "pkg"] + include_ext = ["go", "tpl", "tmpl", "html", "toml"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = true + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore index f8a207a..0fcba83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .venv site +config.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..62d387d --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +# Main Makefile for FitFeed project + +.PHONY: help init dev dev-db dev-auth dev-api dev-web migrate-up migrate-down + +help: + @echo "FitFeed Development Environment" + @echo "" + @echo "Usage:" + @echo " make init - Check and install required tools" + @echo " make dev - Run all services in development mode" + @echo " make dev-db - Run only the database (Docker)" + @echo " make dev-auth - Run Auth service with hot-reload (Air)" + @echo " make dev-api - Run API service with hot-reload (Air)" + @echo " make dev-web - Run Web frontend (Vite)" + @echo " make migrate-up - Run database migrations up" + @echo " make migrate-down - Run database migrations down" + +init: + @echo "Checking development tools..." + @command -v go >/dev/null 2>&1 || { echo >&2 "Go is not installed. Install it from https://golang.org/doc/install"; exit 1; } + @command -v docker >/dev/null 2>&1 || { echo >&2 "Docker is not installed. Install it from https://docs.docker.com/get-docker/"; exit 1; } + @command -v air >/dev/null 2>&1 || { echo >&2 "Air is not installed. Install it with: go install github.com/air-verse/air@latest"; exit 1; } + @command -v node >/dev/null 2>&1 || { echo >&2 "Node.js is not installed. Install it from https://nodejs.org/"; exit 1; } + @command -v bun >/dev/null 2>&1 || { echo >&2 "Bun is not installed. Install it with: curl -fsSL https://bun.sh/install | bash"; exit 1; } + @if [ ! -f config.toml ]; then cp config.toml.template config.toml; echo "Created config.toml from template"; fi + @echo "All tools found and config initialized." + +dev-db: + @echo "Starting Database..." + @docker compose -f deployments/docker-compose/postgres/docker-compose.yml up -d + +dev-auth: + @echo "Starting Auth service..." + @FITFEED_CONF=$(PWD) air -c .air.auth.toml + +dev-api: + @echo "Starting API service..." + @FITFEED_CONF=$(PWD) air -c .air.api.toml + +dev-web: + @echo "Starting Web frontend..." + @cd services/web && bun install && bun run dev + +migrate-up: + @echo "Running migrations up..." + @cd services/dbm && FITFEED_CONF=$(PWD)/../.. go run cmd/dbm/main.go up + +migrate-down: + @echo "Running migrations down..." + @cd services/dbm && FITFEED_CONF=$(PWD)/../.. go run cmd/dbm/main.go down + +dev: dev-db + @echo "Starting all services..." + @# Use a tool like 'foreman', 'overmind' or just run in parallel if simple enough. + @# For simplicity in this Makefile, we'll just suggest running them in separate terminals or use & + @make -j 3 dev-auth dev-api dev-web diff --git a/config.toml.template b/config.toml.template new file mode 100644 index 0000000..6773bf3 --- /dev/null +++ b/config.toml.template @@ -0,0 +1,44 @@ +[api] +port = 8082 + +[auth] +port = 8081 +prefix = "auth" +secret = "MuUhv7svOw9iWmeycg7iRhsuF5hr4Gik" +max_session_age = 86400 +is_prod = false + +[database] +driver = "postgres" + +[database.postgres] +host = "localhost" +port = 5432 +username = "postgres" +password = "secret" +dbname = "fitfeed" + +[web] +hostname = "localhost" +protocol = "http" +port = 5173 + +[auth.providers.google] +enabled = false +client_id = "" +client_secret = "" + +[auth.providers.github] +enabled = false +client_id = "" +client_secret = "" + +[auth.providers.yandex] +enabled = false +client_id = "" +client_secret = "" + +[auth.providers.vk] +enabled = false +client_id = "" +client_secret = "" diff --git a/services/api/src/internal/config/config.go b/services/api/src/internal/config/config.go index 644ebab..ce0a336 100644 --- a/services/api/src/internal/config/config.go +++ b/services/api/src/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "log" + "os" "strings" "github.com/spf13/viper" @@ -41,10 +42,20 @@ type AppConfig struct { } func Load() *AppConfig { - viper.SetConfigName("api-config") + + viper.SetConfigName("config") viper.SetConfigType("toml") - viper.AddConfigPath("../../config") - viper.AddConfigPath("./config") + + // 1. Look for config file in path provided by env FITFEED_CONF + if envConf := os.Getenv("FITFEED_CONF"); envConf != "" { + viper.AddConfigPath(envConf) + } + + // 2. Look in the current working directory + viper.AddConfigPath(".") + + // 3. Fallback for local development relative to the service root + viper.AddConfigPath("../../../") viper.AutomaticEnv() viper.SetEnvPrefix("fitfeed") @@ -52,7 +63,7 @@ func Load() *AppConfig { err := viper.ReadInConfig() if err != nil { - log.Fatalf("Error reading config file, %s", err) + log.Printf("Error reading config file, %s", err) } var config AppConfig diff --git a/services/api/src/internal/controller/http/router.go b/services/api/src/internal/controller/http/router.go index d6f377c..1ef502e 100644 --- a/services/api/src/internal/controller/http/router.go +++ b/services/api/src/internal/controller/http/router.go @@ -23,6 +23,23 @@ func New(u usecase.UserManager, conf *config.AppConfig) http.Handler { r.Use(middleware.Heartbeat("/ping")) r.Use(render.SetContentType(render.ContentTypeJSON)) + // CORS + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + }) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("api root.")) }) diff --git a/services/auth/internal/config/config.go b/services/auth/internal/config/config.go index b25bb4a..9bf3ded 100644 --- a/services/auth/internal/config/config.go +++ b/services/auth/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "log" + "os" "strings" "github.com/spf13/viper" @@ -39,10 +40,19 @@ type AppConfig struct { func Load() *AppConfig { - viper.SetConfigName("auth-config") + viper.SetConfigName("config") viper.SetConfigType("toml") - viper.AddConfigPath("../../config") // Look for the config file in the module root directory - viper.AddConfigPath("./config") // Look for the config file in the current directory + + // 1. Look for config file in path provided by env FITFEED_CONF + if envConf := os.Getenv("FITFEED_CONF"); envConf != "" { + viper.AddConfigPath(envConf) + } + + // 2. Look in the current working directory + viper.AddConfigPath(".") + + // 3. Fallback for local development relative to the service root + viper.AddConfigPath("../../") viper.AutomaticEnv() viper.SetEnvPrefix("fitfeed") @@ -50,7 +60,7 @@ func Load() *AppConfig { err := viper.ReadInConfig() if err != nil { - log.Fatalf("Error reading config file, %s", err) + log.Printf("Error reading config file, %s", err) } var config AppConfig diff --git a/services/auth/internal/controller/http/router.go b/services/auth/internal/controller/http/router.go index 9f23404..12f50d8 100644 --- a/services/auth/internal/controller/http/router.go +++ b/services/auth/internal/controller/http/router.go @@ -22,6 +22,23 @@ func New(u usecase.UserManager, o usecase.OauthManager, p usecase.ProfileManager r.Use(middleware.Heartbeat("/ping")) r.Use(render.SetContentType(render.ContentTypeJSON)) + // CORS + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + }) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("root.")) }) diff --git a/services/dbm/internal/config/config.go b/services/dbm/internal/config/config.go index b791351..8caf6e9 100644 --- a/services/dbm/internal/config/config.go +++ b/services/dbm/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "log" + "os" "strings" "github.com/spf13/viper" @@ -22,10 +23,19 @@ type AppConfig struct { func Load() *AppConfig { - viper.SetConfigName("dbm-config") + viper.SetConfigName("config") viper.SetConfigType("toml") - viper.AddConfigPath("./config") // Look for the config file in the current directory - viper.AddConfigPath("../../config") // Look for the config file in the model root directory + + // 1. Look for config file in path provided by env FITFEED_CONF + if envConf := os.Getenv("FITFEED_CONF"); envConf != "" { + viper.AddConfigPath(envConf) + } + + // 2. Look in the current working directory + viper.AddConfigPath(".") + + // 3. Fallback for local development relative to the service root + viper.AddConfigPath("../../") viper.AutomaticEnv() viper.SetEnvPrefix("fitfeed") @@ -33,7 +43,7 @@ func Load() *AppConfig { err := viper.ReadInConfig() if err != nil { - log.Fatalf("Error reading config file, %s", err) + log.Printf("Error reading config file, %s", err) } var config AppConfig diff --git a/services/web/.env b/services/web/.env new file mode 100644 index 0000000..9dc25f7 --- /dev/null +++ b/services/web/.env @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8082 diff --git a/services/web/src/components/layout/AppHeader.tsx b/services/web/src/components/layout/AppHeader.tsx index 6ab73ab..f57096f 100644 --- a/services/web/src/components/layout/AppHeader.tsx +++ b/services/web/src/components/layout/AppHeader.tsx @@ -6,7 +6,7 @@ import { useAuth } from '../../context/AuthContext'; const { Header } = Layout; const AppHeader: React.FC = () => { - const { user, isLoggedIn, logout, login } = useAuth(); + const { user, isLoggedIn, logout, config } = useAuth(); const [isModalVisible, setIsModalVisible] = useState(false); const handleLogout = () => { @@ -19,8 +19,9 @@ const AppHeader: React.FC = () => { }; const handleOAuthLogin = (provider: string) => { + if (!config) return; // Redirect to backend auth - window.location.href = `http://localhost:8081/v1/oauth/${provider}/auth`; + window.location.href = `${config.auth_url}/v1/oauth/${provider}/auth`; }; const menuItems = [ diff --git a/services/web/src/context/AuthContext.tsx b/services/web/src/context/AuthContext.tsx index 43d5d2d..691462f 100644 --- a/services/web/src/context/AuthContext.tsx +++ b/services/web/src/context/AuthContext.tsx @@ -1,9 +1,15 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react'; import { User } from '../types'; +export interface AppConfig { + auth_url: string; + api_url: string; +} + interface AuthContextType { user: User | null; isLoggedIn: boolean; + config: AppConfig | null; login: (user: User) => void; logout: () => void; } @@ -11,19 +17,34 @@ interface AuthContextType { const AuthContext = createContext(undefined); export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [user, setUser] = useState(null); + const [user, setUser] = useState(() => { + const savedUser = localStorage.getItem('user'); + return savedUser ? JSON.parse(savedUser) : null; + }); + const [config, setConfig] = useState(null); + + useEffect(() => { + // Fetch config from API using Vite env var for bootstrap + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8082'; + fetch(`${apiUrl}/v1/config`) + .then(res => res.json()) + .then(data => setConfig(data)) + .catch(err => console.error("Failed to fetch config", err)); + }, []); const login = (userData: User) => { setUser(userData); + localStorage.setItem('user', JSON.stringify(userData)); }; const logout = () => { setUser(null); - // TODO: Call API logout + localStorage.removeItem('user'); + // TODO: Call API logout using config.auth_url }; return ( - + {children} ); diff --git a/services/web/src/types/index.ts b/services/web/src/types/index.ts index b6ea6ff..0257cef 100644 --- a/services/web/src/types/index.ts +++ b/services/web/src/types/index.ts @@ -2,6 +2,7 @@ export interface User { id: string; username: string; profile: Profile; + session?: Session; } export interface Profile { @@ -10,3 +11,8 @@ export interface Profile { avatar_url: string; email: string; } + +export interface Session { + access_token: string; + refresh_token: string; +} From 23e7bebba500cda5f19c74f9779657ac3e8b49da Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 21:41:45 +0200 Subject: [PATCH 06/12] feat(web,auth): implement Passkey (WebAuthn) frontend and update documentation - Add @github/webauthn-json to web dependencies - Implement passkeyService for frontend WebAuthn flow - Add Passkey registration and login to AppHeader UI - Update backend Passkey handlers to return proper JSON and user data - Update project README with Development Environment instructions --- README.md | 33 +++++++ .../internal/controller/http/v1/passkey.go | 13 ++- services/web/bun.lock | 4 + services/web/package.json | 1 + .../web/src/components/layout/AppHeader.tsx | 85 ++++++++++++------- services/web/src/services/auth.ts | 56 ++++++++++++ 6 files changed, 155 insertions(+), 37 deletions(-) create mode 100644 services/web/src/services/auth.ts diff --git a/README.md b/README.md index 05fc3bc..894f4ba 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,39 @@ The core functionality is split across several services located in the `services --- +## 🛠️ Development + +FitFeed uses a unified development environment managed by a root `Makefile` and `Air` for Go hot-reloading. + +### 1. Initial Setup + +Check if you have all required tools (Go, Docker, Node.js, Bun, Air) and initialize your local configuration: + +```bash +make init +``` + +This will create a `config.toml` from the template. Edit it to provide your OAuth credentials or adjust ports. + +### 2. Running the Project + +To start the database, backend services (with hot-reload), and the frontend (Vite) in parallel: + +```bash +make dev +``` + +### 3. Database Migrations + +Manage your schema evolution using the `dbm` service through the Makefile: + +```bash +make migrate-up +make migrate-down +``` + +--- + ## ⚙️ Installation and Setup *(Note: Please fill in the detailed steps here once the setup is finalized. For now, this serves as a placeholder.)* diff --git a/services/auth/internal/controller/http/v1/passkey.go b/services/auth/internal/controller/http/v1/passkey.go index 1c59704..a1f6aea 100644 --- a/services/auth/internal/controller/http/v1/passkey.go +++ b/services/auth/internal/controller/http/v1/passkey.go @@ -19,16 +19,12 @@ func (h *V1) beginRegistration(w http.ResponseWriter, r *http.Request) { user, err := h.u.GetByUsername(r.Context(), username) if err != nil { - // If user doesn't exist, we might want to create a skeleton user - // or require them to exist first (e.g. via OAuth). - // For now let's assume they must exist or we create them. user = entity.User{Username: username} err = h.u.RegisterUser(r.Context(), user) if err != nil { http.Error(w, "failed to register user", http.StatusInternalServerError) return } - // Fetch again to get ID user, _ = h.u.GetByUsername(r.Context(), username) } @@ -38,12 +34,12 @@ func (h *V1) beginRegistration(w http.ResponseWriter, r *http.Request) { return } - // Store session data in gothic session (or similar) session, _ := gothic.Store.Get(r, "webauthn-session") data, _ := json.Marshal(sessionData) session.Values["registration-data"] = string(data) session.Save(r, w) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) } @@ -92,6 +88,7 @@ func (h *V1) beginLogin(w http.ResponseWriter, r *http.Request) { session.Values["login-data"] = string(data) session.Save(r, w) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) } @@ -112,14 +109,12 @@ func (h *V1) finishLogin(w http.ResponseWriter, r *http.Request) { return } - // Generate JWT token, err := h.j.GenerateToken(user) if err != nil { http.Error(w, "failed to generate token", http.StatusInternalServerError) return } - // Set cookie http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: token, @@ -129,5 +124,7 @@ func (h *V1) finishLogin(w http.ResponseWriter, r *http.Request) { Expires: time.Now().Add(24 * time.Hour), }) - w.Write([]byte("login successful")) + // Also return user info as JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) } diff --git a/services/web/bun.lock b/services/web/bun.lock index f5a3996..e387d17 100644 --- a/services/web/bun.lock +++ b/services/web/bun.lock @@ -1,11 +1,13 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "web", "dependencies": { "@ant-design/icons": "5.x", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@github/webauthn-json": "^2.1.1", "antd": "^5.27.1", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -158,6 +160,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], + "@github/webauthn-json": ["@github/webauthn-json@2.1.1", "", { "bin": { "webauthn-json": "dist/bin/main.js" } }, "sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], diff --git a/services/web/package.json b/services/web/package.json index 98a235c..6cd1c61 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@ant-design/icons": "5.x", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@github/webauthn-json": "^2.1.1", "antd": "^5.27.1", "react": "^19.1.1", "react-dom": "^19.1.1" diff --git a/services/web/src/components/layout/AppHeader.tsx b/services/web/src/components/layout/AppHeader.tsx index f57096f..912e621 100644 --- a/services/web/src/components/layout/AppHeader.tsx +++ b/services/web/src/components/layout/AppHeader.tsx @@ -1,26 +1,57 @@ import React, { useState } from 'react'; -import { Layout, Menu, Button, Dropdown, Avatar, Space, Modal } from 'antd'; +import { Layout, Menu, Button, Dropdown, Avatar, Space, Modal, Input, Divider, message } from 'antd'; import { UserOutlined, SettingOutlined, LogoutOutlined, GoogleOutlined, IdcardOutlined } from '@ant-design/icons'; import { useAuth } from '../../context/AuthContext'; +import { passkeyService } from '../../services/auth'; const { Header } = Layout; const AppHeader: React.FC = () => { - const { user, isLoggedIn, logout, config } = useAuth(); + const { user, isLoggedIn, logout, login, config } = useAuth(); const [isModalVisible, setIsModalVisible] = useState(false); + const [username, setUsername] = useState(''); + const [loading, setLoading] = useState(false); const handleLogout = () => { logout(); }; - const handlePasskeyLogin = () => { - // TODO: Implement Passkey Login - console.log("Passkey login initiated"); + const handlePasskeyLogin = async () => { + if (!username) { + message.error("Please enter a username"); + return; + } + setLoading(true); + try { + const userJson = await passkeyService.login(username); + login(JSON.parse(userJson)); + message.success("Logged in with Passkey!"); + setIsModalVisible(false); + } catch (err: any) { + message.error(`Passkey login failed: ${err.message}`); + } finally { + setLoading(false); + } + }; + + const handlePasskeyRegister = async () => { + if (!username) { + message.error("Please enter a username"); + return; + } + setLoading(true); + try { + await passkeyService.register(username); + message.success("Passkey registered! You can now log in."); + } catch (err: any) { + message.error(`Passkey registration failed: ${err.message}`); + } finally { + setLoading(false); + } }; const handleOAuthLogin = (provider: string) => { if (!config) return; - // Redirect to backend auth window.location.href = `${config.auth_url}/v1/oauth/${provider}/auth`; }; @@ -36,22 +67,9 @@ const AppHeader: React.FC = () => { ), }, { type: 'divider' }, - { - key: 'profile', - icon: , - label: 'My Profile', - }, - { - key: 'settings', - icon: , - label: 'Settings', - }, - { - key: 'logout', - icon: , - label: 'Log out', - onClick: handleLogout, - }, + { key: 'profile', icon: , label: 'My Profile' }, + { key: 'settings', icon: , label: 'Settings' }, + { key: 'logout', icon: , label: 'Log out', onClick: handleLogout }, ]; return ( @@ -88,7 +106,7 @@ const AppHeader: React.FC = () => { ) : ( - )} @@ -102,15 +120,24 @@ const AppHeader: React.FC = () => { centered > + } + value={username} + onChange={(e) => setUsername(e.target.value)} + /> + + + + Or + - -
- By continuing, you agree to FitFeed's Terms of Service and Privacy Policy. -
diff --git a/services/web/src/services/auth.ts b/services/web/src/services/auth.ts new file mode 100644 index 0000000..156e1b6 --- /dev/null +++ b/services/web/src/services/auth.ts @@ -0,0 +1,56 @@ +import { create, get } from '@github/webauthn-json'; + +const getBaseUrl = () => { + // We can get this from config in AuthContext, but for simple service we can use it here + return 'http://localhost:8081/v1/passkey'; +}; + +export const passkeyService = { + async register(username: string) { + const baseUrl = getBaseUrl(); + + // 1. Begin Registration + const response = await fetch(`${baseUrl}/register/begin?username=${username}`); + if (!response.ok) throw new Error('Failed to begin registration'); + + const options = await response.json(); + + // 2. Create Credential + const credential = await create(options); + + // 3. Finish Registration + const finishResponse = await fetch(`${baseUrl}/register/finish?username=${username}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }); + + if (!finishResponse.ok) throw new Error('Failed to finish registration'); + return await finishResponse.text(); + }, + + async login(username: string) { + const baseUrl = getBaseUrl(); + + // 1. Begin Login + const response = await fetch(`${baseUrl}/login/begin?username=${username}`); + if (!response.ok) throw new Error('Failed to begin login'); + + const options = await response.json(); + + // 2. Get Credential + const credential = await get(options); + + // 3. Finish Login + const finishResponse = await fetch(`${baseUrl}/login/finish?username=${username}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }); + + if (!finishResponse.ok) throw new Error('Failed to finish login'); + + // The server should have set the JWT cookie at this point + return await finishResponse.text(); + } +}; From a57e046618b03b65d8ac3a38d5c9eb30628bb919 Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 21:57:39 +0200 Subject: [PATCH 07/12] feat(api): implement JWTMiddleware and protect user profile routes - Add github.com/golang-jwt/jwt/v5 to api dependencies - Implement JWTMiddleware to validate JWT from Authorization header or Cookie - Protect PUT /v1/users/profile route with JWTMiddleware - Update UserController to use user information from context --- services/api/src/go.mod | 19 +++- services/api/src/go.sum | 102 ++++++++++++++++++ .../internal/controller/http/v1/middleware.go | 83 ++++++++++++++ .../src/internal/controller/http/v1/router.go | 7 +- .../src/internal/controller/http/v1/user.go | 19 +++- 5 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 services/api/src/go.sum create mode 100644 services/api/src/internal/controller/http/v1/middleware.go diff --git a/services/api/src/go.mod b/services/api/src/go.mod index 2aea8c5..cebeda8 100644 --- a/services/api/src/go.mod +++ b/services/api/src/go.mod @@ -5,6 +5,8 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/render v1.0.3 + github.com/go-webauthn/webauthn v0.16.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/spf13/viper v1.20.1 gorm.io/driver/postgres v1.6.0 @@ -12,8 +14,12 @@ require ( ) require ( + github.com/ajg/form v1.5.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.2 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect @@ -21,17 +27,20 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/x448/float16 v0.8.4 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/services/api/src/go.sum b/services/api/src/go.sum new file mode 100644 index 0000000..78214fa --- /dev/null +++ b/services/api/src/go.sum @@ -0,0 +1,102 @@ +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.2 h1:n116UuvIa7nUVGFP2hO9U24gBqhJTcmbU3ph0wgVzFM= +github.com/go-webauthn/webauthn v0.16.2/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI= +github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= +github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs= +gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/services/api/src/internal/controller/http/v1/middleware.go b/services/api/src/internal/controller/http/v1/middleware.go new file mode 100644 index 0000000..9d45768 --- /dev/null +++ b/services/api/src/internal/controller/http/v1/middleware.go @@ -0,0 +1,83 @@ +package v1 + +import ( + "context" + "errors" + "fitfeed/api/internal/entity" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type contextKey string + +const ( + UserContextKey contextKey = "user" +) + +type UserClaims struct { + jwt.RegisteredClaims + ID string `json:"id"` + Username string `json:"username"` +} + +func JWTMiddleware(secret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenString := "" + + // 1. Try to get token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + } + + // 2. Try to get token from Cookie if header is empty + if tokenString == "" { + cookie, err := r.Cookie("jwt") + if err == nil { + tokenString = cookie.Value + } + } + + if tokenString == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + claims := &UserClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + + if err != nil || !token.Valid { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + userID, err := uuid.Parse(claims.ID) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Store claims in context + userClaims := entity.UserClaims{ + ID: userID, + Username: claims.Username, + } + ctx := context.WithValue(r.Context(), UserContextKey, userClaims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func GetUserFromContext(ctx context.Context) (entity.UserClaims, error) { + user, ok := ctx.Value(UserContextKey).(entity.UserClaims) + if !ok { + return entity.UserClaims{}, errors.New("user not found in context") + } + return user, nil +} diff --git a/services/api/src/internal/controller/http/v1/router.go b/services/api/src/internal/controller/http/v1/router.go index 7b0abd2..d96ba04 100644 --- a/services/api/src/internal/controller/http/v1/router.go +++ b/services/api/src/internal/controller/http/v1/router.go @@ -13,7 +13,12 @@ func NewRouter(r chi.Router, u usecase.UserManager, conf *config.AppConfig) { r.Route("/users", func(r chi.Router) { r.Get("/{username}", uc.GetProfile) - r.Put("/profile", uc.UpdateProfile) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(JWTMiddleware(conf.Auth.Secret)) + r.Put("/profile", uc.UpdateProfile) + }) }) r.Get("/config", cc.GetConfig) diff --git a/services/api/src/internal/controller/http/v1/user.go b/services/api/src/internal/controller/http/v1/user.go index aeae7c7..ed84a81 100644 --- a/services/api/src/internal/controller/http/v1/user.go +++ b/services/api/src/internal/controller/http/v1/user.go @@ -28,13 +28,24 @@ func (c *UserController) GetProfile(w http.ResponseWriter, r *http.Request) { } func (c *UserController) UpdateProfile(w http.ResponseWriter, r *http.Request) { - // TODO: Get user ID from JWT context (needs middleware) - // For now assume we get it from request or similar (unsafe) + claims, err := GetUserFromContext(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var profile entity.Profile if err := json.NewDecoder(r.Body).Decode(&profile); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - // err := c.u.UpdateProfile(r.Context(), userID, profile) - w.WriteHeader(http.StatusNotImplemented) + + err = c.u.UpdateProfile(r.Context(), claims.ID, profile) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "profile updated"}) } From 9abe2c560ccf8986e95d6811bdf027a4dbeed87c Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 22:18:07 +0200 Subject: [PATCH 08/12] docs: generate comprehensive development documentation - Update mkdocs.yml with navigation structure - Replace default index.md with FitFeed overview - Create development.md with setup guide and LLM policy - Create architecture.md with microservices and clean code details - Create data-models.md with detailed database schema - Create service-specific documentation for Auth, API, and Web services --- docs/mkdocs.yml | 13 ++++++++++ docs/src/architecture.md | 32 +++++++++++++++++++++++++ docs/src/data-models.md | 49 ++++++++++++++++++++++++++++++++++++++ docs/src/development.md | 50 +++++++++++++++++++++++++++++++++++++++ docs/src/index.md | 30 +++++++++++++---------- docs/src/services/api.md | 24 +++++++++++++++++++ docs/src/services/auth.md | 27 +++++++++++++++++++++ docs/src/services/web.md | 33 ++++++++++++++++++++++++++ 8 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 docs/src/architecture.md create mode 100644 docs/src/data-models.md create mode 100644 docs/src/development.md create mode 100644 docs/src/services/api.md create mode 100644 docs/src/services/auth.md create mode 100644 docs/src/services/web.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 58936e9..c6f3afe 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -2,3 +2,16 @@ site_name: FitFeed site_url: https://fitfeed.org repo_url: https://github.com/Linux-oiD/fitfeed docs_dir: src + +theme: + name: material + +nav: + - Home: index.md + - Development: development.md + - Architecture: architecture.md + - Data Models: data-models.md + - Services: + - Auth: services/auth.md + - API: services/api.md + - Web: services/web.md diff --git a/docs/src/architecture.md b/docs/src/architecture.md new file mode 100644 index 0000000..7e53dfb --- /dev/null +++ b/docs/src/architecture.md @@ -0,0 +1,32 @@ +# Architecture + +FitFeed follows a microservices architecture with a focus on privacy and security. + +## Clean Code Pattern + +All Go services follow a layered approach: + +- **Entity:** Domain-specific data models and business logic. +- **UseCase:** Application-specific business rules and use cases. +- **Controller:** Entry points (HTTP handlers) that interact with use cases. +- **Repo:** Data access layer (GORM repositories). + +## Security & Authentication + +FitFeed provides several authentication methods to ensure user privacy: + +- **Passkeys (WebAuthn):** Provides a secure, passwordless login experience. +- **OAuth:** Allows users to log in through popular providers like Google or GitHub. +- **JWT (JSON Web Tokens):** Used for session management and route protection across all services. + +## Service Communication + +Currently, services communicate primarily through HTTP: + +- **Auth Service:** Manages registration, login, and JWT generation. +- **API Service:** Main entry point for the frontend, providing profile data and application state. +- **Web Service:** The React-based frontend client. + +## Data Layer + +The data layer is managed by a centralized `dbm` service. All services share a PostgreSQL database, but are responsible for their respective data schemas. Migrations are managed using Goose and GORM. diff --git a/docs/src/data-models.md b/docs/src/data-models.md new file mode 100644 index 0000000..27ec6b3 --- /dev/null +++ b/docs/src/data-models.md @@ -0,0 +1,49 @@ +# Data Models + +FitFeed uses a PostgreSQL database for data storage. All models are defined using GORM and follow a consistent structure. + +## Base Model + +The base model contains common fields for all tables: + +- **ID:** UUID (primary key) +- **CreatedAt:** timestamp +- **UpdatedAt:** timestamp +- **DeletedAt:** timestamp (for soft deletes) + +## User + +The `User` table is the core of the authentication system: + +- **Username:** Unique, required. +- **Profile:** One-to-one relationship with `Profile`. +- **OauthProviders:** One-to-many relationship with `OauthProvider`. +- **Passkeys:** One-to-many relationship with `Passkey`. + +## Profile + +The `Profile` table contains personal information for each user: + +- **FirstName:** User's first name. +- **LastName:** User's last name. +- **AvatarURL:** Link to user's avatar image. +- **Email:** Unique email address. +- **UserID:** Foreign key to `User`. + +## OauthProvider + +The `OauthProvider` table stores authentication details for third-party providers: + +- **Provider:** Name of the provider (e.g., "google"). +- **ProviderID:** Unique ID from the provider. +- **UserID:** Foreign key to `User`. + +## Passkey + +The `Passkey` table stores WebAuthn credentials: + +- **CredentialID:** Unique ID from the authenticator. +- **PublicKey:** Public key from the authenticator. +- **AttestationType:** Type of attestation used. +- **SignCount:** Current sign count for the credential. +- **UserID:** Foreign key to `User`. diff --git a/docs/src/development.md b/docs/src/development.md new file mode 100644 index 0000000..ebf3b76 --- /dev/null +++ b/docs/src/development.md @@ -0,0 +1,50 @@ +# Development Guide + +FitFeed follows a clean architecture pattern and uses modern tools for development. + +## Getting Started + +### Prerequisites + +To contribute, you'll need the following tools: + +- **Go 1.25+** +- **Docker & Docker Compose** +- **Node.js & Bun** +- **Air** (for Go hot-reloading) + +### Initial Setup + +FitFeed uses a unified development environment managed by a root `Makefile`: + +```bash +make init +``` + +This command checks for required tools and initializes your local `config.toml`. + +### Running for Development + +To start the database, backend services, and frontend in parallel: + +```bash +make dev +``` + +## LLM Usage Policy + +FitFeed encourages developers to use modern Large Language Models (LLMs) to increase productivity. However, the following rules apply: + +1. **Human Review:** All code generated by an LLM **must** be thoroughly reviewed by a human developer. +2. **Responsibility:** The author of a pull request (PR) takes **full responsibility** for the submitted code. +3. **Accountability:** Authors must be able to: + - Explain any part of the code they submitted. + - Answer code-related questions during PR reviews. + - Adjust any part of the code based on review feedback or project decisions. + +## Coding Standards + +- **Go:** Follow standard Go idioms and `gofmt`. Use `slog` for structured logging. +- **React:** Use TypeScript for type safety. Follow functional component patterns and use Ant Design components. +- **Git:** Use descriptive commit messages. Work on feature branches and submit PRs to `main`. +- **Database:** All schema changes must be implemented as migrations in the `dbm` service. diff --git a/docs/src/index.md b/docs/src/index.md index 000ea34..ecc1eb0 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,17 +1,23 @@ -# Welcome to MkDocs +# FitFeed: Privacy-First Fitness Social Platform -For full documentation visit [mkdocs.org](https://www.mkdocs.org). +FitFeed is a **self-hosted, privacy-first** social platform designed for fitness enthusiasts. It provides a secure environment for users to share activities, interact with friends, and maintain full control over their data. -## Commands +## Key Principles -* `mkdocs new [dir-name]` - Create a new project. -* `mkdocs serve` - Start the live-reloading docs server. -* `mkdocs build` - Build the documentation site. -* `mkdocs -h` - Print help message and exit. +- **Privacy-First:** User data is treated as sensitive by default. +- **Self-Hosted:** Designed to be easily deployed on personal infrastructure. +- **Open Source:** Built with modern, community-driven technologies. -## Project layout +## Technology Stack - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. +- **Backend:** Go (Golang) +- **Frontend:** TypeScript, React, Ant Design +- **Database:** PostgreSQL (managed by GORM & Goose) +- **Infrastructure:** Docker Compose + +## Core Services + +- **Auth Service:** Handles authentication (OAuth, Passkeys, JWT). +- **API Service:** Main application logic and profile management. +- **DBM (Database Manager):** Handles database migrations. +- **Web Service:** The React-based frontend client. diff --git a/docs/src/services/api.md b/docs/src/services/api.md new file mode 100644 index 0000000..d11f0e4 --- /dev/null +++ b/docs/src/services/api.md @@ -0,0 +1,24 @@ +# API Service + +The `api` service provides the main interface for the FitFeed web client, offering profile management and configuration. + +## Features + +- **Profile Management:** Users can view and update their personal information (name, avatar, email). +- **Service Configuration:** Provides the frontend with necessary backend URLs and settings. +- **Route Protection:** All profile-related routes are protected by JWT middleware. + +## API Endpoints + +- **GET /v1/config:** Returns the current configuration (auth_url, api_url). +- **GET /v1/users/{username}:** Retrieves a user's profile information. +- **PUT /v1/users/profile:** Updates the currently logged-in user's profile (requires JWT). + +## Internal Structure + +The `api` service also follows a layered architecture: + +- **Controller:** HTTP handlers and routing. +- **UseCase:** Business logic for user and profile management. +- **Repo:** Data access for `User` and `Profile` tables. +- **JWTMiddleware:** Middleware for validating tokens and extracting user context. diff --git a/docs/src/services/auth.md b/docs/src/services/auth.md new file mode 100644 index 0000000..63323b0 --- /dev/null +++ b/docs/src/services/auth.md @@ -0,0 +1,27 @@ +# Auth Service + +The `auth` service handles registration, login, and token generation for FitFeed. It provides multiple authentication methods: + +- **OAuth:** Users can register and log in using third-party providers. If an existing user logs in with a new provider, it is automatically linked to their account. +- **Passkeys (WebAuthn):** Users can register and log in using biometric authentication or security keys. +- **JWT:** Once authenticated, the service generates a JWT and returns it to the client as a cookie. + +## API Endpoints + +- **GET /v1/oauth/{provider}/auth:** Initiates the OAuth flow. +- **GET /v1/oauth/{provider}/callback:** Handles the OAuth callback from the provider. +- **GET /v1/passkey/register/begin:** Initiates Passkey registration. +- **POST /v1/passkey/register/finish:** Finalizes Passkey registration. +- **GET /v1/passkey/login/begin:** Initiates Passkey login. +- **POST /v1/passkey/login/finish:** Finalizes Passkey login. +- **GET /v1/oauth/{provider}/logout:** Clears the JWT cookie and logs the user out. + +## Internal Structure + +The `auth` service follows a layered architecture: + +- **Controller:** HTTP handlers and routing. +- **UseCase:** Business logic for authentication and provider management. +- **Repo:** Data access for `User`, `Profile`, and `OauthProvider` tables. +- **JWTManager:** Logic for generating and validating tokens. +- **PasskeyManager:** Integration with WebAuthn for biometric authentication. diff --git a/docs/src/services/web.md b/docs/src/services/web.md new file mode 100644 index 0000000..0b2f203 --- /dev/null +++ b/docs/src/services/web.md @@ -0,0 +1,33 @@ +# Web Service + +The `web` service is the frontend client for FitFeed, built with React and Ant Design. + +## Architecture + +The frontend follows a modern, component-based architecture: + +- **Components:** Reusable UI components for layout, features, and feedback. +- **Context:** Global state management for authentication and configuration. +- **Services:** API integration for OAuth, Passkeys, and data fetching. +- **Pages:** Main views of the application (Home, Profile, etc.). + +## Key Components + +- **AppHeader:** Sticky header with navigation, user profile dropdown, and login/register modal. +- **AppFooter:** Simple footer with copyright and link to FitFeed. +- **MainLayout:** Layout wrapper providing consistent spacing and structure across all pages. +- **AuthContext:** Manages authentication state, user session, and application configuration. + +## Development + +The frontend uses Vite for fast development and hot-reloading. All dependencies are managed using Bun. + +### Commands + +- `bun run dev` - Start the Vite development server. +- `bun run build` - Build the production-ready client. +- `bun run lint` - Run ESLint to check for code quality. + +## Theme & Styling + +FitFeed uses Ant Design's modern styling and provides a consistent theme throughout the application. The primary color is a vibrant orange, inspired by fitness social platforms. From 4536813df9cfd2a1d31ecc3e2c636998042c9518 Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 22:35:15 +0200 Subject: [PATCH 09/12] docs: restructure guides, add Mermaid diagrams and GitHub CI/CD - Reorganize docs into User, Admin, and Developer sections - Enable Mermaid diagrams in MkDocs - Add ERD diagram to data-models.md - Add Architecture overview diagram - Create .github/workflows/ci.yml for Go and React tests - Create .github/workflows/docs.yml for automated documentation deployment - Include security scans (govulncheck, gosec) in CI --- .github/workflows/ci.yml | 62 +++++++++++++++++++ .github/workflows/docs.yml | 43 +++++++++++++ docs/mkdocs.yml | 36 ++++++++--- docs/src/admin-guides/configuration.md | 16 +++++ docs/src/admin-guides/deployment.md | 13 ++++ docs/src/admin-guides/index.md | 6 ++ .../{ => developer-guides}/architecture.md | 18 ++++++ .../src/{ => developer-guides}/data-models.md | 40 ++++++++++++ .../src/{ => developer-guides}/development.md | 0 .../{ => developer-guides}/services/api.md | 0 .../{ => developer-guides}/services/auth.md | 0 .../{ => developer-guides}/services/web.md | 0 docs/src/user-guides/index.md | 8 +++ 13 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 docs/src/admin-guides/configuration.md create mode 100644 docs/src/admin-guides/deployment.md create mode 100644 docs/src/admin-guides/index.md rename docs/src/{ => developer-guides}/architecture.md (79%) rename docs/src/{ => developer-guides}/data-models.md (67%) rename docs/src/{ => developer-guides}/development.md (100%) rename docs/src/{ => developer-guides}/services/api.md (100%) rename docs/src/{ => developer-guides}/services/auth.md (100%) rename docs/src/{ => developer-guides}/services/web.md (100%) create mode 100644 docs/src/user-guides/index.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..22688e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: FitFeed CI + +on: + push: + branches: [ master, main, gemini ] + pull_request: + branches: [ master, main ] + +jobs: + backend-test: + name: Backend Tests (Go) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Auth Service Tests + run: cd services/auth && go test -v ./... -coverprofile=coverage.out + + - name: API Service Tests + run: cd services/api/src && go test -v ./... + + - name: Run Govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + cd services/auth && govulncheck ./... + cd ../api/src && govulncheck ./... + + frontend-test: + name: Frontend Tests (React) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: cd services/web && bun install + + - name: Lint + run: cd services/web && bun run lint + + - name: Build + run: cd services/web && bun run build + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: ./... diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0fd2978 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,43 @@ +name: FitFeed Docs + +on: + push: + branches: + - master + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + - uses: actions/setup-python@v5 + with: + python-version: 3.x + + - run: echo "cache_id=$(date --utc +%V)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + + - name: Install dependencies + run: pip install mkdocs-material pymdown-extensions + + - name: Deploy Docs + run: mkdocs gh-deploy --force --config-file docs/mkdocs.yml diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c6f3afe..1ae27c5 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -5,13 +5,35 @@ docs_dir: src theme: name: material + features: + - navigation.tabs + - navigation.sections + - content.code.copy + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.mermaid_format nav: - Home: index.md - - Development: development.md - - Architecture: architecture.md - - Data Models: data-models.md - - Services: - - Auth: services/auth.md - - API: services/api.md - - Web: services/web.md + - User Guides: + - Overview: user-guides/index.md + - Admin Guides: + - Overview: admin-guides/index.md + - Configuration: admin-guides/configuration.md + - Deployment: admin-guides/deployment.md + - Developer Guides: + - Getting Started: developer-guides/development.md + - Architecture: developer-guides/architecture.md + - Data Models: developer-guides/data-models.md + - Services: + - Auth: developer-guides/services/auth.md + - API: developer-guides/services/api.md + - Web: developer-guides/services/web.md diff --git a/docs/src/admin-guides/configuration.md b/docs/src/admin-guides/configuration.md new file mode 100644 index 0000000..e8d2e5d --- /dev/null +++ b/docs/src/admin-guides/configuration.md @@ -0,0 +1,16 @@ +# Configuration Reference + +FitFeed is configured using a unified `config.toml` file. + +## Core Configuration + +| Key | Description | Default | +| :--- | :--- | :--- | +| `api.port` | Port for the API service | 8082 | +| `auth.port` | Port for the Auth service | 8081 | +| `auth.secret` | JWT signing secret | (generated) | +| `database.postgres.host` | DB Host | localhost | + +## OAuth Providers + +OAuth providers are configured under `[auth.providers.{name}]`. You must enable them and provide Client ID/Secret from the respective provider's developer console. diff --git a/docs/src/admin-guides/deployment.md b/docs/src/admin-guides/deployment.md new file mode 100644 index 0000000..c8bee77 --- /dev/null +++ b/docs/src/admin-guides/deployment.md @@ -0,0 +1,13 @@ +# Deployment Options + +Currently, FitFeed is in an early development stage. + +## Docker Compose + +The primary way to deploy FitFeed for testing is using Docker Compose. + +1. Clone the repository. +2. Edit `config.toml`. +3. Run `make dev`. + +Production-ready deployment guides (Kubernetes, Systemd) are coming soon. diff --git a/docs/src/admin-guides/index.md b/docs/src/admin-guides/index.md new file mode 100644 index 0000000..6efb25d --- /dev/null +++ b/docs/src/admin-guides/index.md @@ -0,0 +1,6 @@ +# Admin Guides + +Welcome to the FitFeed Admin Guides. This section provides information for administrators on how to deploy and configure FitFeed. + +- [Deployment Options](deployment.md) +- [Configuration Reference](configuration.md) diff --git a/docs/src/architecture.md b/docs/src/developer-guides/architecture.md similarity index 79% rename from docs/src/architecture.md rename to docs/src/developer-guides/architecture.md index 7e53dfb..5e36e46 100644 --- a/docs/src/architecture.md +++ b/docs/src/developer-guides/architecture.md @@ -2,6 +2,24 @@ FitFeed follows a microservices architecture with a focus on privacy and security. +## System Overview + +```mermaid +graph TD + User((User)) + Web[Web Frontend - React] + API[API Service - Go] + Auth[Auth Service - Go] + DB[(PostgreSQL)] + + User <-->|HTTPS| Web + Web <-->|REST/JWT| API + Web <-->|REST/OAuth/Passkey| Auth + API <-->|SQL| DB + Auth <-->|SQL| DB + Auth -.->|Provides JWT| Web +``` + ## Clean Code Pattern All Go services follow a layered approach: diff --git a/docs/src/data-models.md b/docs/src/developer-guides/data-models.md similarity index 67% rename from docs/src/data-models.md rename to docs/src/developer-guides/data-models.md index 27ec6b3..9f58cf5 100644 --- a/docs/src/data-models.md +++ b/docs/src/developer-guides/data-models.md @@ -2,6 +2,46 @@ FitFeed uses a PostgreSQL database for data storage. All models are defined using GORM and follow a consistent structure. +## ER Diagram + +```mermaid +erDiagram + USER ||--|| PROFILE : has + USER ||--o{ OAUTH_PROVIDER : "linked with" + USER ||--o{ PASSKEY : "registered with" + + USER { + uuid id PK + string username + timestamp created_at + timestamp updated_at + } + + PROFILE { + uuid id PK + uuid user_id FK + string first_name + string last_name + string email + string avatar_url + } + + OAUTH_PROVIDER { + uuid id PK + uuid user_id FK + string provider + string provider_id + } + + PASSKEY { + uuid id PK + uuid user_id FK + bytea credential_id + bytea public_key + uint32 sign_count + } +``` + ## Base Model The base model contains common fields for all tables: diff --git a/docs/src/development.md b/docs/src/developer-guides/development.md similarity index 100% rename from docs/src/development.md rename to docs/src/developer-guides/development.md diff --git a/docs/src/services/api.md b/docs/src/developer-guides/services/api.md similarity index 100% rename from docs/src/services/api.md rename to docs/src/developer-guides/services/api.md diff --git a/docs/src/services/auth.md b/docs/src/developer-guides/services/auth.md similarity index 100% rename from docs/src/services/auth.md rename to docs/src/developer-guides/services/auth.md diff --git a/docs/src/services/web.md b/docs/src/developer-guides/services/web.md similarity index 100% rename from docs/src/services/web.md rename to docs/src/developer-guides/services/web.md diff --git a/docs/src/user-guides/index.md b/docs/src/user-guides/index.md new file mode 100644 index 0000000..93c3a62 --- /dev/null +++ b/docs/src/user-guides/index.md @@ -0,0 +1,8 @@ +# User Guides + +Welcome to the FitFeed User Guides. This section will soon contain detailed instructions on how to use FitFeed, including: + +- Creating an account +- Connecting your fitness devices +- Sharing activities +- Interacting with friends From 5170494e90c163ecbfa2925e0278cd9dc66fd32e Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 23:08:23 +0200 Subject: [PATCH 10/12] fix(web,api): resolve type export error and restructure api service - Fix 'User' export error in AuthContext.tsx by using import type - Restructure api service to match auth service (removed src folder) - Update Air config, Makefile, and CI workflow for new api structure - Fix TypeScript errors in AppHeader, MainLayout, and Home - Successfully verified with 'bun run build' --- .air.api.toml | 2 +- .github/workflows/ci.yml | 4 ++-- Makefile | 6 ++---- docs/src/developer-guides/services/api.md | 15 ++++++++++----- services/api/{src => }/cmd/api/main.go | 0 services/api/{src => }/go.mod | 0 services/api/{src => }/go.sum | 0 services/api/{src => }/internal/config/config.go | 2 +- .../{src => }/internal/controller/http/router.go | 0 .../internal/controller/http/v1/config.go | 0 .../internal/controller/http/v1/middleware.go | 0 .../internal/controller/http/v1/router.go | 0 .../{src => }/internal/controller/http/v1/user.go | 0 services/api/{src => }/internal/entity/base.go | 0 services/api/{src => }/internal/entity/error.go | 0 services/api/{src => }/internal/entity/oauth.go | 0 services/api/{src => }/internal/entity/passkey.go | 0 services/api/{src => }/internal/entity/profile.go | 0 services/api/{src => }/internal/entity/user.go | 0 services/api/{src => }/internal/repo/contracts.go | 0 .../internal/repo/profiledb/profiledb.go | 0 .../api/{src => }/internal/repo/userdb/userdb.go | 0 .../api/{src => }/internal/usecase/contracts.go | 0 .../internal/usecase/usermanager/usermanager.go | 0 services/api/{src => }/pkg/httpserver/server.go | 0 services/api/{src => }/pkg/postgres/postgres.go | 0 services/api/tmp/build-errors.log | 1 + services/auth/tmp/build-errors.log | 1 + services/web/src/components/layout/AppHeader.tsx | 4 ++-- services/web/src/components/layout/MainLayout.tsx | 2 +- services/web/src/context/AuthContext.tsx | 4 ++-- services/web/src/pages/Home.tsx | 2 +- 32 files changed, 24 insertions(+), 19 deletions(-) rename services/api/{src => }/cmd/api/main.go (100%) rename services/api/{src => }/go.mod (100%) rename services/api/{src => }/go.sum (100%) rename services/api/{src => }/internal/config/config.go (98%) rename services/api/{src => }/internal/controller/http/router.go (100%) rename services/api/{src => }/internal/controller/http/v1/config.go (100%) rename services/api/{src => }/internal/controller/http/v1/middleware.go (100%) rename services/api/{src => }/internal/controller/http/v1/router.go (100%) rename services/api/{src => }/internal/controller/http/v1/user.go (100%) rename services/api/{src => }/internal/entity/base.go (100%) rename services/api/{src => }/internal/entity/error.go (100%) rename services/api/{src => }/internal/entity/oauth.go (100%) rename services/api/{src => }/internal/entity/passkey.go (100%) rename services/api/{src => }/internal/entity/profile.go (100%) rename services/api/{src => }/internal/entity/user.go (100%) rename services/api/{src => }/internal/repo/contracts.go (100%) rename services/api/{src => }/internal/repo/profiledb/profiledb.go (100%) rename services/api/{src => }/internal/repo/userdb/userdb.go (100%) rename services/api/{src => }/internal/usecase/contracts.go (100%) rename services/api/{src => }/internal/usecase/usermanager/usermanager.go (100%) rename services/api/{src => }/pkg/httpserver/server.go (100%) rename services/api/{src => }/pkg/postgres/postgres.go (100%) create mode 100644 services/api/tmp/build-errors.log create mode 100644 services/auth/tmp/build-errors.log diff --git a/.air.api.toml b/.air.api.toml index 8e4c112..b7d5775 100644 --- a/.air.api.toml +++ b/.air.api.toml @@ -1,4 +1,4 @@ -root = "services/api/src" +root = "services/api" testdata_dir = "testdata" tmp_dir = "tmp" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22688e8..5799f71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,13 +23,13 @@ jobs: run: cd services/auth && go test -v ./... -coverprofile=coverage.out - name: API Service Tests - run: cd services/api/src && go test -v ./... + run: cd services/api && go test -v ./... - name: Run Govulncheck run: | go install golang.org/x/vuln/cmd/govulncheck@latest cd services/auth && govulncheck ./... - cd ../api/src && govulncheck ./... + cd ../api && govulncheck ./... frontend-test: name: Frontend Tests (React) diff --git a/Makefile b/Makefile index 62d387d..5a725e2 100644 --- a/Makefile +++ b/Makefile @@ -43,14 +43,12 @@ dev-web: migrate-up: @echo "Running migrations up..." - @cd services/dbm && FITFEED_CONF=$(PWD)/../.. go run cmd/dbm/main.go up + @cd services/dbm && FITFEED_CONF=$(PWD) go run cmd/dbm/main.go up migrate-down: @echo "Running migrations down..." - @cd services/dbm && FITFEED_CONF=$(PWD)/../.. go run cmd/dbm/main.go down + @cd services/dbm && FITFEED_CONF=$(PWD) go run cmd/dbm/main.go down dev: dev-db @echo "Starting all services..." - @# Use a tool like 'foreman', 'overmind' or just run in parallel if simple enough. - @# For simplicity in this Makefile, we'll just suggest running them in separate terminals or use & @make -j 3 dev-auth dev-api dev-web diff --git a/docs/src/developer-guides/services/api.md b/docs/src/developer-guides/services/api.md index d11f0e4..7701cba 100644 --- a/docs/src/developer-guides/services/api.md +++ b/docs/src/developer-guides/services/api.md @@ -2,11 +2,16 @@ The `api` service provides the main interface for the FitFeed web client, offering profile management and configuration. -## Features - -- **Profile Management:** Users can view and update their personal information (name, avatar, email). -- **Service Configuration:** Provides the frontend with necessary backend URLs and settings. -- **Route Protection:** All profile-related routes are protected by JWT middleware. +## Project Structure + +The service follows a flat module structure: +- `cmd/api/main.go`: Application entry point. +- `internal/config/`: Configuration loading logic. +- `internal/controller/http/`: HTTP handlers and routing. +- `internal/entity/`: Domain models. +- `internal/repo/`: Data access layer. +- `internal/usecase/`: Business logic. +- `pkg/`: Shared utility packages. ## API Endpoints diff --git a/services/api/src/cmd/api/main.go b/services/api/cmd/api/main.go similarity index 100% rename from services/api/src/cmd/api/main.go rename to services/api/cmd/api/main.go diff --git a/services/api/src/go.mod b/services/api/go.mod similarity index 100% rename from services/api/src/go.mod rename to services/api/go.mod diff --git a/services/api/src/go.sum b/services/api/go.sum similarity index 100% rename from services/api/src/go.sum rename to services/api/go.sum diff --git a/services/api/src/internal/config/config.go b/services/api/internal/config/config.go similarity index 98% rename from services/api/src/internal/config/config.go rename to services/api/internal/config/config.go index ce0a336..4d5691f 100644 --- a/services/api/src/internal/config/config.go +++ b/services/api/internal/config/config.go @@ -55,7 +55,7 @@ func Load() *AppConfig { viper.AddConfigPath(".") // 3. Fallback for local development relative to the service root - viper.AddConfigPath("../../../") + viper.AddConfigPath("../../") viper.AutomaticEnv() viper.SetEnvPrefix("fitfeed") diff --git a/services/api/src/internal/controller/http/router.go b/services/api/internal/controller/http/router.go similarity index 100% rename from services/api/src/internal/controller/http/router.go rename to services/api/internal/controller/http/router.go diff --git a/services/api/src/internal/controller/http/v1/config.go b/services/api/internal/controller/http/v1/config.go similarity index 100% rename from services/api/src/internal/controller/http/v1/config.go rename to services/api/internal/controller/http/v1/config.go diff --git a/services/api/src/internal/controller/http/v1/middleware.go b/services/api/internal/controller/http/v1/middleware.go similarity index 100% rename from services/api/src/internal/controller/http/v1/middleware.go rename to services/api/internal/controller/http/v1/middleware.go diff --git a/services/api/src/internal/controller/http/v1/router.go b/services/api/internal/controller/http/v1/router.go similarity index 100% rename from services/api/src/internal/controller/http/v1/router.go rename to services/api/internal/controller/http/v1/router.go diff --git a/services/api/src/internal/controller/http/v1/user.go b/services/api/internal/controller/http/v1/user.go similarity index 100% rename from services/api/src/internal/controller/http/v1/user.go rename to services/api/internal/controller/http/v1/user.go diff --git a/services/api/src/internal/entity/base.go b/services/api/internal/entity/base.go similarity index 100% rename from services/api/src/internal/entity/base.go rename to services/api/internal/entity/base.go diff --git a/services/api/src/internal/entity/error.go b/services/api/internal/entity/error.go similarity index 100% rename from services/api/src/internal/entity/error.go rename to services/api/internal/entity/error.go diff --git a/services/api/src/internal/entity/oauth.go b/services/api/internal/entity/oauth.go similarity index 100% rename from services/api/src/internal/entity/oauth.go rename to services/api/internal/entity/oauth.go diff --git a/services/api/src/internal/entity/passkey.go b/services/api/internal/entity/passkey.go similarity index 100% rename from services/api/src/internal/entity/passkey.go rename to services/api/internal/entity/passkey.go diff --git a/services/api/src/internal/entity/profile.go b/services/api/internal/entity/profile.go similarity index 100% rename from services/api/src/internal/entity/profile.go rename to services/api/internal/entity/profile.go diff --git a/services/api/src/internal/entity/user.go b/services/api/internal/entity/user.go similarity index 100% rename from services/api/src/internal/entity/user.go rename to services/api/internal/entity/user.go diff --git a/services/api/src/internal/repo/contracts.go b/services/api/internal/repo/contracts.go similarity index 100% rename from services/api/src/internal/repo/contracts.go rename to services/api/internal/repo/contracts.go diff --git a/services/api/src/internal/repo/profiledb/profiledb.go b/services/api/internal/repo/profiledb/profiledb.go similarity index 100% rename from services/api/src/internal/repo/profiledb/profiledb.go rename to services/api/internal/repo/profiledb/profiledb.go diff --git a/services/api/src/internal/repo/userdb/userdb.go b/services/api/internal/repo/userdb/userdb.go similarity index 100% rename from services/api/src/internal/repo/userdb/userdb.go rename to services/api/internal/repo/userdb/userdb.go diff --git a/services/api/src/internal/usecase/contracts.go b/services/api/internal/usecase/contracts.go similarity index 100% rename from services/api/src/internal/usecase/contracts.go rename to services/api/internal/usecase/contracts.go diff --git a/services/api/src/internal/usecase/usermanager/usermanager.go b/services/api/internal/usecase/usermanager/usermanager.go similarity index 100% rename from services/api/src/internal/usecase/usermanager/usermanager.go rename to services/api/internal/usecase/usermanager/usermanager.go diff --git a/services/api/src/pkg/httpserver/server.go b/services/api/pkg/httpserver/server.go similarity index 100% rename from services/api/src/pkg/httpserver/server.go rename to services/api/pkg/httpserver/server.go diff --git a/services/api/src/pkg/postgres/postgres.go b/services/api/pkg/postgres/postgres.go similarity index 100% rename from services/api/src/pkg/postgres/postgres.go rename to services/api/pkg/postgres/postgres.go diff --git a/services/api/tmp/build-errors.log b/services/api/tmp/build-errors.log new file mode 100644 index 0000000..05e5985 --- /dev/null +++ b/services/api/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1 \ No newline at end of file diff --git a/services/auth/tmp/build-errors.log b/services/auth/tmp/build-errors.log new file mode 100644 index 0000000..05e5985 --- /dev/null +++ b/services/auth/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1 \ No newline at end of file diff --git a/services/web/src/components/layout/AppHeader.tsx b/services/web/src/components/layout/AppHeader.tsx index 912e621..72825b8 100644 --- a/services/web/src/components/layout/AppHeader.tsx +++ b/services/web/src/components/layout/AppHeader.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Layout, Menu, Button, Dropdown, Avatar, Space, Modal, Input, Divider, message } from 'antd'; +import { Layout, Menu, Button, Dropdown, Avatar, Space, Modal, Input, Divider, message, type MenuProps } from 'antd'; import { UserOutlined, SettingOutlined, LogoutOutlined, GoogleOutlined, IdcardOutlined } from '@ant-design/icons'; import { useAuth } from '../../context/AuthContext'; import { passkeyService } from '../../services/auth'; @@ -55,7 +55,7 @@ const AppHeader: React.FC = () => { window.location.href = `${config.auth_url}/v1/oauth/${provider}/auth`; }; - const menuItems = [ + const menuItems: MenuProps['items'] = [ { key: 'user-info', disabled: true, diff --git a/services/web/src/components/layout/MainLayout.tsx b/services/web/src/components/layout/MainLayout.tsx index 28a308f..2161e07 100644 --- a/services/web/src/components/layout/MainLayout.tsx +++ b/services/web/src/components/layout/MainLayout.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { type ReactNode } from 'react'; import { Layout } from 'antd'; import AppHeader from './AppHeader'; import AppFooter from './AppFooter'; diff --git a/services/web/src/context/AuthContext.tsx b/services/web/src/context/AuthContext.tsx index 691462f..a5a18b2 100644 --- a/services/web/src/context/AuthContext.tsx +++ b/services/web/src/context/AuthContext.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react'; -import { User } from '../types'; +import React, { createContext, useContext, useState, type ReactNode, useEffect } from 'react'; +import type { User } from '../types'; export interface AppConfig { auth_url: string; diff --git a/services/web/src/pages/Home.tsx b/services/web/src/pages/Home.tsx index 68460d1..ef9d693 100644 --- a/services/web/src/pages/Home.tsx +++ b/services/web/src/pages/Home.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Typography, Row, Col, Card, Avatar, Space, Button, Empty } from 'antd'; +import { Typography, Row, Col, Card, Avatar, Button, Empty } from 'antd'; import { useAuth } from '../context/AuthContext'; import { UserOutlined, PlusOutlined } from '@ant-design/icons'; From 54d2d9e2c750e311731354e553b1e4a3cc5eaed2 Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 23:33:15 +0200 Subject: [PATCH 11/12] fix: resolve lint and build issues across services - Fix unused variable 'oauthProvider' in auth service - Fix unused import 'log' in auth main.go - Replace explicit 'any' with type-safe handling in web AppHeader.tsx - Suppress react-refresh warning in web AuthContext.tsx --- services/api/tmp/build-errors.log | 2 +- services/auth/cmd/auth/main.go | 1 - services/auth/internal/controller/http/v1/oauth.go | 2 +- services/auth/tmp/build-errors.log | 2 +- services/web/src/components/layout/AppHeader.tsx | 10 ++++++---- services/web/src/context/AuthContext.tsx | 1 + 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/services/api/tmp/build-errors.log b/services/api/tmp/build-errors.log index 05e5985..4660a1c 100644 --- a/services/api/tmp/build-errors.log +++ b/services/api/tmp/build-errors.log @@ -1 +1 @@ -exit status 1 \ No newline at end of file +exit status 1exit status 1 \ No newline at end of file diff --git a/services/auth/cmd/auth/main.go b/services/auth/cmd/auth/main.go index bb3949a..f443f28 100644 --- a/services/auth/cmd/auth/main.go +++ b/services/auth/cmd/auth/main.go @@ -14,7 +14,6 @@ import ( "fitfeed/auth/internal/usecase/profilemanager" "fitfeed/auth/internal/usecase/usermanager" "fmt" - "log" "log/slog" "net/http" "os" diff --git a/services/auth/internal/controller/http/v1/oauth.go b/services/auth/internal/controller/http/v1/oauth.go index e4897d9..a249380 100644 --- a/services/auth/internal/controller/http/v1/oauth.go +++ b/services/auth/internal/controller/http/v1/oauth.go @@ -25,7 +25,7 @@ func (h *V1) getAuthCallbackFunction(w http.ResponseWriter, r *http.Request) { var user entity.User // 1. Check if this OAuth provider is already linked - oauthProvider, err := h.o.GetByProviderID(ctx, gothUser.UserID) + _, err = h.o.GetByProviderID(ctx, gothUser.UserID) if err == nil { // Found user by OAuth provider user, err = h.u.GetByUsername(ctx, gothUser.NickName) // Assuming NickName is username diff --git a/services/auth/tmp/build-errors.log b/services/auth/tmp/build-errors.log index 05e5985..4660a1c 100644 --- a/services/auth/tmp/build-errors.log +++ b/services/auth/tmp/build-errors.log @@ -1 +1 @@ -exit status 1 \ No newline at end of file +exit status 1exit status 1 \ No newline at end of file diff --git a/services/web/src/components/layout/AppHeader.tsx b/services/web/src/components/layout/AppHeader.tsx index 72825b8..6c73130 100644 --- a/services/web/src/components/layout/AppHeader.tsx +++ b/services/web/src/components/layout/AppHeader.tsx @@ -27,8 +27,9 @@ const AppHeader: React.FC = () => { login(JSON.parse(userJson)); message.success("Logged in with Passkey!"); setIsModalVisible(false); - } catch (err: any) { - message.error(`Passkey login failed: ${err.message}`); + } catch (err) { + const error = err as Error; + message.error(`Passkey login failed: ${error.message}`); } finally { setLoading(false); } @@ -43,8 +44,9 @@ const AppHeader: React.FC = () => { try { await passkeyService.register(username); message.success("Passkey registered! You can now log in."); - } catch (err: any) { - message.error(`Passkey registration failed: ${err.message}`); + } catch (err) { + const error = err as Error; + message.error(`Passkey registration failed: ${error.message}`); } finally { setLoading(false); } diff --git a/services/web/src/context/AuthContext.tsx b/services/web/src/context/AuthContext.tsx index a5a18b2..a11594c 100644 --- a/services/web/src/context/AuthContext.tsx +++ b/services/web/src/context/AuthContext.tsx @@ -50,6 +50,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => ); }; +// eslint-disable-next-line react-refresh/only-export-components export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { From 7e70503ea55ac64e11337c447c60328eefe4e499 Mon Sep 17 00:00:00 2001 From: Anton Cherkasov Date: Fri, 3 Apr 2026 23:44:01 +0200 Subject: [PATCH 12/12] feat: improve Makefile with shutdown and automated migrations - Add dev-stop target to root Makefile - Automate migrate-up in make dev after starting database - Update README and developer docs with new make commands --- Makefile | 10 +++++++++- README.md | 8 +++++++- docs/src/developer-guides/development.md | 8 +++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5a725e2..d74ac22 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Main Makefile for FitFeed project -.PHONY: help init dev dev-db dev-auth dev-api dev-web migrate-up migrate-down +.PHONY: help init dev dev-db dev-stop dev-auth dev-api dev-web migrate-up migrate-down help: @echo "FitFeed Development Environment" @@ -9,6 +9,7 @@ help: @echo " make init - Check and install required tools" @echo " make dev - Run all services in development mode" @echo " make dev-db - Run only the database (Docker)" + @echo " make dev-stop - Stop the database and other containers" @echo " make dev-auth - Run Auth service with hot-reload (Air)" @echo " make dev-api - Run API service with hot-reload (Air)" @echo " make dev-web - Run Web frontend (Vite)" @@ -29,6 +30,10 @@ dev-db: @echo "Starting Database..." @docker compose -f deployments/docker-compose/postgres/docker-compose.yml up -d +dev-stop: + @echo "Stopping dev environment..." + @docker compose -f deployments/docker-compose/postgres/docker-compose.yml down + dev-auth: @echo "Starting Auth service..." @FITFEED_CONF=$(PWD) air -c .air.auth.toml @@ -50,5 +55,8 @@ migrate-down: @cd services/dbm && FITFEED_CONF=$(PWD) go run cmd/dbm/main.go down dev: dev-db + @echo "Waiting for database to be ready..." + @sleep 3 + @make migrate-up @echo "Starting all services..." @make -j 3 dev-auth dev-api dev-web diff --git a/README.md b/README.md index 894f4ba..d5709db 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,18 @@ This will create a `config.toml` from the template. Edit it to provide your OAut ### 2. Running the Project -To start the database, backend services (with hot-reload), and the frontend (Vite) in parallel: +To start the database, apply migrations, and start backend services (with hot-reload) and the frontend (Vite) in parallel: ```bash make dev ``` +To stop the development environment (database and other containers): + +```bash +make dev-stop +``` + ### 3. Database Migrations Manage your schema evolution using the `dbm` service through the Makefile: diff --git a/docs/src/developer-guides/development.md b/docs/src/developer-guides/development.md index ebf3b76..dc68589 100644 --- a/docs/src/developer-guides/development.md +++ b/docs/src/developer-guides/development.md @@ -25,12 +25,18 @@ This command checks for required tools and initializes your local `config.toml`. ### Running for Development -To start the database, backend services, and frontend in parallel: +To start the database, apply migrations, and start backend services and frontend in parallel: ```bash make dev ``` +To stop the development environment: + +```bash +make dev-stop +``` + ## LLM Usage Policy FitFeed encourages developers to use modern Large Language Models (LLMs) to increase productivity. However, the following rules apply: