Hi %s,
+We received a request to reset your password. Click the link below to create a new password:
+ +This link will expire in 1 hour.
+If you didn't request this, you can ignore this email.
+ `, username, resetLink), + } + + sent, err := s.client.Emails.Send(params) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + if sent.Id == "" { + return fmt.Errorf("failed to send email") + } + + return nil +} + +func (s *Service) SendWelcomeEmail(email, username string) error { + params := &resend.SendEmailRequest{ + From: s.from, + To: []string{email}, + Subject: "Welcome to RideAware", + Html: fmt.Sprintf(` +Hi %s,
+Your account has been created successfully!
+Start tracking your rides and improve your performance.
+ `, username), + } + + sent, err := s.client.Emails.Send(params) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + if sent.Id == "" { + return fmt.Errorf("failed to send email") + } + + return nil +} \ No newline at end of file diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..02a2964 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "rideaware/internal/config" +) + +const UserContextKey = "user" + +type AuthMiddleware struct{} + +func NewAuthMiddleware() *AuthMiddleware { + return &AuthMiddleware{} +} + +func (am *AuthMiddleware) ProtectedRoute(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "missing authorization header", + }) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid authorization header format", + }) + return + } + + token := parts[1] + claims, err := config.VerifyToken(token) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid or expired token", + }) + return + } + + if claims.TokenType != "access" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "refresh token cannot be used for access", + }) + return + } + + ctx := context.WithValue(r.Context(), UserContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} \ No newline at end of file diff --git a/internal/profile/model.go b/internal/profile/model.go new file mode 100644 index 0000000..2bb77c8 --- /dev/null +++ b/internal/profile/model.go @@ -0,0 +1,29 @@ +package profile + +import "time" + +type Equipment struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Name string `gorm:"not null" json:"name"` + Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc. + Brand string `gorm:"default:''" json:"brand"` + Model string `gorm:"default:''" json:"model"` + Weight float64 `gorm:"default:0" json:"weight"` // grams + Notes string `gorm:"default:''" json:"notes"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Stats struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;uniqueIndex" json:"user_id"` + TotalRides int `gorm:"default:0" json:"total_rides"` + TotalDistance float64 `gorm:"default:0" json:"total_distance"` + TotalTime int `gorm:"default:0" json:"total_time"` + AverageSpeed float64 `gorm:"default:0" json:"average_speed"` + MaxSpeed float64 `gorm:"default:0" json:"max_speed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} \ No newline at end of file diff --git a/internal/user/handler.go b/internal/user/handler.go new file mode 100644 index 0000000..e70e96d --- /dev/null +++ b/internal/user/handler.go @@ -0,0 +1,93 @@ +package user + +import ( + "encoding/json" + "net/http" + + "rideaware/internal/config" + "rideaware/internal/middleware" +) + +type Handler struct { + service *Service +} + +func NewHandler() *Handler { + return &Handler{ + service: NewService(), + } +} + +type GetProfileResponse struct { + User *User `json:"user"` + Profile *Profile `json:"profile"` +} + +func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + user, err := h.service.repo.GetUserByID(claims.UserID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "user not found"}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(GetProfileResponse{ + User: user, + Profile: user.Profile, + }) +} + +func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims) + + var req struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Bio string `json:"bio"` + FTP int `json:"ftp"` + MaxHR int `json:"max_hr"` + Weight float64 `json:"weight"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"}) + return + } + + user, err := h.service.repo.GetUserByID(claims.UserID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "user not found"}) + return + } + + // Update profile + if user.Profile != nil { + user.Profile.FirstName = req.FirstName + user.Profile.LastName = req.LastName + user.Profile.Bio = req.Bio + user.Profile.FTP = req.FTP + user.Profile.MaxHR = req.MaxHR + user.Profile.Weight = req.Weight + + if err := h.service.repo.UpdateUser(user); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "failed to update profile"}) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(GetProfileResponse{ + User: user, + Profile: user.Profile, + }) +} \ No newline at end of file diff --git a/internal/user/model.go b/internal/user/model.go new file mode 100644 index 0000000..e69856d --- /dev/null +++ b/internal/user/model.go @@ -0,0 +1,105 @@ +package user + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;not null" json:"username"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `gorm:"not null" json:"-"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Profile *Profile `gorm:"foreignKey:UserID;constraint:OnDelete:Cascade" json:"profile,omitempty"` + PasswordResets []PasswordReset `gorm:"foreignKey:UserID;constraint:OnDelete:Cascade" json:"password_resets,omitempty"` + Sessions []Session `gorm:"foreignKey:UserID;constraint:OnDelete:Cascade" json:"sessions,omitempty"` +} + +type Profile struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;uniqueIndex" json:"user_id"` + FirstName string `gorm:"default:''" json:"first_name"` + LastName string `gorm:"default:''" json:"last_name"` + Bio string `gorm:"default:''" json:"bio"` + ProfilePicture string `gorm:"default:''" json:"profile_picture"` + RestingHR int `gorm:"default:0" json:"resting_hr"` + MaxHR int `gorm:"default:0" json:"max_hr"` + FTP int `gorm:"default:0" json:"ftp"` + Weight float64 `gorm:"default:0" json:"weight"` + TotalRides int `gorm:"default:0" json:"total_rides"` + TotalDistance float64 `gorm:"default:0" json:"total_distance"` + TotalTime int `gorm:"default:0" json:"total_time"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PasswordReset struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + Token string `gorm:"uniqueIndex;not null" json:"token"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + UsedAt *time.Time `json:"used_at"` + CreatedAt time.Time `json:"created_at"` +} + +type Session struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Token string `gorm:"uniqueIndex;not null" json:"token"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` + DeviceName string `gorm:"default:''" json:"device_name"` + UserAgent string `gorm:"default:''" json:"user_agent"` + IPAddress string `gorm:"default:''" json:"ip_address"` + CreatedAt time.Time `json:"created_at"` +} + +// ===== Methods ===== + +// SetPassword hashes and sets the password +func (u *User) SetPassword(rawPassword string) error { + if len(rawPassword) < 8 { + return errors.New("password must be at least 8 characters long") + } + hashedPassword, err := bcrypt.GenerateFromPassword( + []byte(rawPassword), + bcrypt.DefaultCost, + ) + if err != nil { + return err + } + u.Password = string(hashedPassword) + return nil +} + +// CheckPassword verifies the password +func (u *User) CheckPassword(password string) bool { + return bcrypt.CompareHashAndPassword( + []byte(u.Password), + []byte(password), + ) == nil +} + +// AfterCreate hook: automatically create profile after user insert +func (u *User) AfterCreate(tx *gorm.DB) error { + profile := &Profile{ + UserID: u.ID, + } + return tx.Create(profile).Error +} + +// IsPasswordResetTokenValid checks if token exists and is not expired +func (prt *PasswordReset) IsValid() bool { + return prt.UsedAt == nil && time.Now().Before(prt.ExpiresAt) +} + +// IsSessionValid checks if session is not expired +func (s *Session) IsValid() bool { + return time.Now().Before(s.ExpiresAt) +} \ No newline at end of file diff --git a/internal/user/repository.go b/internal/user/repository.go new file mode 100644 index 0000000..41f34ca --- /dev/null +++ b/internal/user/repository.go @@ -0,0 +1,62 @@ +package user + +import ( + "errors" + "rideaware/pkg/database" + "gorm.io/gorm" +) + +type Repository struct{} + +func NewRepository() *Repository { + return &Repository{} +} + +func (r *Repository) CreateUser(user *User) error { + return database.DB.Create(user).Error +} + +func (r *Repository) GetUserByUsername(username string) (*User, error) { + var user User + if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +func (r *Repository) GetUserByEmail(email string) (*User, error) { + var user User + if err := database.DB.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +func (r *Repository) GetUserByID(id uint) (*User, error) { + var user User + if err := database.DB.Preload("Profile").Where("id = ?", id).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +func (r *Repository) UpdateUser(user *User) error { + return database.DB.Save(user).Error +} + +func (r *Repository) UserExists(username, email string) (bool, error) { + var count int64 + err := database.DB.Model(&User{}). + Where("username = ? OR email = ?", username, email). + Count(&count).Error + return count > 0, err +} \ No newline at end of file diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..3e61607 --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,159 @@ +package user + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "regexp" + "time" + + "rideaware/internal/config" + "rideaware/internal/email" + "rideaware/pkg/database" +) + +type Service struct { + repo *Repository + email *email.Service +} + +func NewService() *Service { + return &Service{ + repo: NewRepository(), + email: email.NewService(), + } +} + +func (s *Service) CreateUser(username, password, email, firstName, lastName string) (*User, error) { + if username == "" || password == "" { + return nil, errors.New("username and password are required") + } + + if email != "" { + if !isValidEmail(email) { + return nil, errors.New("invalid email format") + } + } + + exists, err := s.repo.UserExists(username, email) + if err != nil { + return nil, err + } + if exists { + return nil, errors.New("username or email already exists") + } + + user := &User{ + Username: username, + Email: email, + } + + if err := user.SetPassword(password); err != nil { + return nil, err + } + + if err := s.repo.CreateUser(user); err != nil { + return nil, err + } + + _ = s.email.SendWelcomeEmail(email, username) + + return user, nil +} + +func (s *Service) VerifyUser(username, password string) (*User, error) { + user, err := s.repo.GetUserByUsername(username) + if err != nil { + return nil, errors.New("invalid username or password") + } + + if !user.CheckPassword(password) { + return nil, errors.New("invalid username or password") + } + + return user, nil +} + +func (s *Service) RequestPasswordReset(email string) error { + user, err := s.repo.GetUserByEmail(email) + if err != nil { + // Don't leak if email exists + return nil + } + + token, err := generateSecureToken(32) + if err != nil { + return err + } + + resetToken := &PasswordReset{ + UserID: user.ID, + Token: token, + ExpiresAt: time.Now().Add(config.JWT.ResetTokenDuration), + } + + if err := database.DB.Create(resetToken).Error; err != nil { + return err + } + + resetLink := "https://rideaware.app/reset-password?token=" + token + return s.email.SendPasswordResetEmail(user.Email, user.Username, resetLink) +} + +func (s *Service) ResetPassword(token, newPassword string) error { + if len(newPassword) < 8 { + return errors.New("password must be at least 8 characters long") + } + + var resetToken PasswordReset + if err := database.DB.Where("token = ?", token).First(&resetToken).Error; err != nil { + return errors.New("invalid or expired reset token") + } + + if !resetToken.IsValid() { + return errors.New("reset token has expired") + } + + user, err := s.repo.GetUserByID(resetToken.UserID) + if err != nil { + return err + } + + if err := user.SetPassword(newPassword); err != nil { + return err + } + + now := time.Now() + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + if err := tx.Model(user).Update("password", user.Password).Error; err != nil { + tx.Rollback() + return err + } + + if err := tx.Model(&resetToken).Update("used_at", now).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +// Helper functions +func isValidEmail(email string) bool { + regex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return regex.MatchString(email) +} + +func generateSecureToken(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} \ No newline at end of file diff --git a/migrations/README b/migrations/README deleted file mode 100644 index 0e04844..0000000 --- a/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini deleted file mode 100644 index ec9d45c..0000000 --- a/migrations/alembic.ini +++ /dev/null @@ -1,50 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic,flask_migrate - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[logger_flask_migrate] -level = INFO -handlers = -qualname = flask_migrate - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py deleted file mode 100644 index 4c97092..0000000 --- a/migrations/env.py +++ /dev/null @@ -1,113 +0,0 @@ -import logging -from logging.config import fileConfig - -from flask import current_app - -from alembic import context - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') - - -def get_engine(): - try: - # this works with Flask-SQLAlchemy<3 and Alchemical - return current_app.extensions['migrate'].db.get_engine() - except (TypeError, AttributeError): - # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine - - -def get_engine_url(): - try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') - except AttributeError: - return str(get_engine().url).replace('%', '%%') - - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -config.set_main_option('sqlalchemy.url', get_engine_url()) -target_db = current_app.extensions['migrate'].db - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def get_metadata(): - if hasattr(target_db, 'metadatas'): - return target_db.metadatas[None] - return target_db.metadata - - -def run_migrations_offline(): - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online(): - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - - # this callback is used to prevent an auto-migration from being generated - # when there are no changes to the schema - # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html - def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): - script = directives[0] - if script.upgrade_ops.is_empty(): - directives[:] = [] - logger.info('No changes in schema detected.') - - conf_args = current_app.extensions['migrate'].configure_args - if conf_args.get("process_revision_directives") is None: - conf_args["process_revision_directives"] = process_revision_directives - - connectable = get_engine() - - with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=get_metadata(), - **conf_args - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako deleted file mode 100644 index 2c01563..0000000 --- a/migrations/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0e07095d2961_initial_migration.py b/migrations/versions/0e07095d2961_initial_migration.py deleted file mode 100644 index 594c8d6..0000000 --- a/migrations/versions/0e07095d2961_initial_migration.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Initial migration - -Revision ID: 0e07095d2961 -Revises: -Create Date: 2025-08-29 01:28:57.822103 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = '0e07095d2961' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('admins') - with op.batch_alter_table('subscribers', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('idx_subscribers_created_at')) - batch_op.drop_index(batch_op.f('idx_subscribers_email')) - batch_op.drop_index(batch_op.f('idx_subscribers_status')) - - op.drop_table('subscribers') - op.drop_table('admin_users') - op.drop_table('email_deliveries') - with op.batch_alter_table('newsletters', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('idx_newsletters_sent_at')) - - op.drop_table('newsletters') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('newsletters', - sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('newsletters_id_seq'::regclass)"), autoincrement=True, nullable=False), - sa.Column('subject', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('sent_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True), - sa.Column('sent_by', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('recipient_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True), - sa.Column('success_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True), - sa.Column('failure_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name='newsletters_pkey'), - postgresql_ignore_search_path=False - ) - with op.batch_alter_table('newsletters', schema=None) as batch_op: - batch_op.create_index(batch_op.f('idx_newsletters_sent_at'), [sa.literal_column('sent_at DESC')], unique=False) - - op.create_table('email_deliveries', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('newsletter_id', sa.INTEGER(), autoincrement=False, nullable=True), - sa.Column('email', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('status', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True), - sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True), - sa.CheckConstraint("status = ANY (ARRAY['sent'::text, 'failed'::text, 'bounced'::text])", name=op.f('email_deliveries_status_check')), - sa.ForeignKeyConstraint(['newsletter_id'], ['newsletters.id'], name=op.f('email_deliveries_newsletter_id_fkey')), - sa.PrimaryKeyConstraint('id', name=op.f('email_deliveries_pkey')) - ) - op.create_table('admin_users', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('username', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('password', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True), - sa.Column('last_login', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), - sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name=op.f('admin_users_pkey')), - sa.UniqueConstraint('username', name=op.f('admin_users_username_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) - ) - op.create_table('subscribers', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('email', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True), - sa.Column('subscribed_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True), - sa.Column('status', sa.TEXT(), server_default=sa.text("'active'::text"), autoincrement=False, nullable=True), - sa.Column('source', sa.TEXT(), server_default=sa.text("'manual'::text"), autoincrement=False, nullable=True), - sa.CheckConstraint("status = ANY (ARRAY['active'::text, 'unsubscribed'::text])", name=op.f('subscribers_status_check')), - sa.PrimaryKeyConstraint('id', name=op.f('subscribers_pkey')), - sa.UniqueConstraint('email', name=op.f('subscribers_email_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) - ) - with op.batch_alter_table('subscribers', schema=None) as batch_op: - batch_op.create_index(batch_op.f('idx_subscribers_status'), ['status'], unique=False) - batch_op.create_index(batch_op.f('idx_subscribers_email'), ['email'], unique=False) - batch_op.create_index(batch_op.f('idx_subscribers_created_at'), [sa.literal_column('created_at DESC')], unique=False) - - op.create_table('admins', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False), - sa.Column('password_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False), - sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name=op.f('admins_pkey')), - sa.UniqueConstraint('username', name=op.f('admins_username_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) - ) - # ### end Alembic commands ### diff --git a/models/User/user.py b/models/User/user.py deleted file mode 100644 index 552796c..0000000 --- a/models/User/user.py +++ /dev/null @@ -1,40 +0,0 @@ -from models.UserProfile.user_profile import UserProfile -from werkzeug.security import generate_password_hash, check_password_hash -from models import db -from sqlalchemy import event - -class User(db.Model): - __tablename__ = 'users' - - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80), unique=True, nullable=False) - email = db.Column(db.String(120), unique=True, nullable=False) # Add email field - _password = db.Column("password", db.String(255), nullable=False) - - profile = db.relationship('UserProfile', back_populates='user', uselist=False, cascade="all, delete-orphan") - - @property - def password(self): - return self._password - - @password.setter - def password(self, raw_password): - if not raw_password.startswith("pbkdf2:sha256:"): - self._password = generate_password_hash(raw_password) - else: - self._password = raw_password - - def check_password(self, password): - return check_password_hash(self._password, password) - -@event.listens_for(User, 'after_insert') -def create_user_profile(mapper, connection, target): - connection.execute( - UserProfile.__table__.insert().values( - user_id=target.id, - first_name="", - last_name="", - bio="", - profile_picture="" - ) - ) \ No newline at end of file diff --git a/models/UserProfile/user_profile.py b/models/UserProfile/user_profile.py deleted file mode 100644 index d3fa194..0000000 --- a/models/UserProfile/user_profile.py +++ /dev/null @@ -1,13 +0,0 @@ -from models import db - -class UserProfile(db.Model): - __tablename__ = 'user_profiles' - - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - first_name = db.Column(db.String(50), nullable=False, default="") - last_name = db.Column(db.String(50), nullable=False, default="") - bio = db.Column(db.Text, default="") - profile_picture = db.Column(db.String(255), default="") - - user = db.relationship('User', back_populates='profile') \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py deleted file mode 100644 index 8dd3fe9..0000000 --- a/models/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -from flask_sqlalchemy import SQLAlchemy -from dotenv import load_dotenv -from urllib.parse import quote_plus - -load_dotenv() - -PG_USER = quote_plus(os.getenv("PG_USER", "postgres")) -PG_PASSWORD = quote_plus(os.getenv("PG_PASSWORD", "postgres")) -PG_HOST = os.getenv("PG_HOST", "localhost") -PG_PORT = os.getenv("PG_PORT", "5432") -PG_DATABASE = os.getenv("PG_DATABASE", "rideaware") - -DATABASE_URI = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DATABASE}" - -db = SQLAlchemy() - -def init_db(app): - """Initialize the SQLAlchemy app with the configuration.""" - app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URI - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - db.init_app(app) diff --git a/pkg/database/db.go b/pkg/database/db.go new file mode 100644 index 0000000..0fd8c3b --- /dev/null +++ b/pkg/database/db.go @@ -0,0 +1,43 @@ +package database + +import ( + "fmt" + "log" + "os" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func Init() { + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + os.Getenv("PG_HOST"), + os.Getenv("PG_USER"), + os.Getenv("PG_PASSWORD"), + os.Getenv("PG_DATABASE"), + os.Getenv("PG_PORT"), + ) + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + log.Println("Database connected successfully") +} + +func Migrate(models ...interface{}) error { + return DB.AutoMigrate(models...) +} + +func Close() error { + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Close() +} \ No newline at end of file diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..c312d74 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,26 @@ +package errors + +type AppError struct { + Code int + Message string + Details string +} + +func (e *AppError) Error() string { + return e.Message +} + +func NewAppError(code int, message, details string) *AppError { + return &AppError{ + Code: code, + Message: message, + Details: details, + } +} + +var ( + ErrUnauthorized = NewAppError(401, "Unauthorized", "") + ErrNotFound = NewAppError(404, "Not Found", "") + ErrBadRequest = NewAppError(400, "Bad Request", "") + ErrInternal = NewAppError(500, "Internal Server Error", "") +) \ No newline at end of file diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..0ac3d05 --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,16 @@ +package utils + +import ( + "encoding/json" + "net/http" +) + +func JSONResponse(w http.ResponseWriter, code int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(payload) +} + +func JSONError(w http.ResponseWriter, code int, message string) { + JSONResponse(w, code, map[string]string{"error": message}) +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 001e473..0000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -Flask -flask_bcrypt -flask_cors -flask_sqlalchemy -python-dotenv -werkzeug -psycopg2-binary -Flask-Migrate \ No newline at end of file diff --git a/routes/user_auth/auth.py b/routes/user_auth/auth.py deleted file mode 100644 index 899d7ba..0000000 --- a/routes/user_auth/auth.py +++ /dev/null @@ -1,60 +0,0 @@ -from flask import Blueprint, request, jsonify, session -from services.UserService.user import UserService - -auth_bp = Blueprint("auth", __name__, url_prefix="/api") -user_service = UserService() - -@auth_bp.route("/signup", methods=["POST"]) -def signup(): - data = request.get_json() - if not data: - return jsonify({"message": "No data provided"}), 400 - - required_fields = ['username', 'password'] - for field in required_fields: - if not data.get(field): - return jsonify({"message": f"{field} is required"}), 400 - - try: - new_user = user_service.create_user( - username=data["username"], - password=data["password"], - email=data.get("email"), - first_name=data.get("first_name"), - last_name=data.get("last_name") - ) - - return jsonify({ - "message": "User created successfully", - "username": new_user.username, - "user_id": new_user.id - }), 201 - - except ValueError as e: - return jsonify({"message": str(e)}), 400 - except Exception as e: - # Log the error - print(f"Signup error: {e}") - return jsonify({"message": "Internal server error"}), 500 - -@auth_bp.route("/login", methods=["POST"]) -def login(): - data = request.get_json() - username = data.get("username") - password = data.get("password") - print(f"Login attempt: username={username}, password={password}") - try: - user = user_service.verify_user(username, password) - session["user_id"] = user.id - return jsonify({"message": "Login successful", "user_id": user.id}), 200 - except ValueError as e: - print(f"Login failed: {str(e)}") - return jsonify({"error": str(e)}), 401 - except Exception as e: - print(f"Login error: {e}") - return jsonify({"error": "Internal server error"}), 500 - -@auth_bp.route("/logout", methods=["POST"]) -def logout(): - session.clear() - return jsonify({"message": "Logout successful"}), 200 \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..d31a74c --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default values +IMAGE_NAME="rideaware" +IMAGE_TAG="latest" +NO_CACHE=false +RUN_CONTAINER=false +CONTAINER_NAME="rideaware-api" + +# Help function +show_help() { + cat << EOF +Usage: $0 [OPTIONS] + +OPTIONS: + -t, --tag TAG Image tag (default: latest) + -n, --name NAME Image name (default: rideaware) + -r, --run Run container after build + -c, --container NAME Container name when running (default: rideaware-api) + --no-cache Build without cache + -h, --help Show this help message + +EXAMPLES: + $0 # Build as rideaware:latest + $0 -t v1.0 # Build as rideaware:v1.0 + $0 -t dev --run # Build and run + $0 --no-cache -t prod # Build without cache as rideaware:prod + +EOF + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -t|--tag) + IMAGE_TAG="$2" + shift 2 + ;; + -n|--name) + IMAGE_NAME="$2" + shift 2 + ;; + -r|--run) + RUN_CONTAINER=true + shift + ;; + -c|--container) + CONTAINER_NAME="$2" + shift 2 + ;; + --no-cache) + NO_CACHE=true + shift + ;; + -h|--help) + show_help + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + show_help + ;; + esac +done + +FULL_IMAGE="$IMAGE_NAME:$IMAGE_TAG" +BUILD_ARGS="" + +if [ "$NO_CACHE" = true ]; then + BUILD_ARGS="--no-cache" +fi + +# Function to stop and remove container +cleanup_container() { + local name=$1 + + if podman ps -a --format "{{.Names}}" | grep -q "^${name}\$"; then + echo -e "${YELLOW}Removing existing container: $name${NC}" + + # Stop if running + if podman ps --format "{{.Names}}" | grep -q "^${name}\$"; then + echo " Stopping container..." + podman kill "$name" 2>/dev/null || true + fi + + # Remove + echo " Removing container..." + if podman rm "$name" 2>/dev/null; then + echo -e "${GREEN} ✓ Container removed${NC}" + else + echo -e "${RED} ✗ Failed to remove container${NC}" + return 1 + fi + fi + return 0 +} + +echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Building Podman Image ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" +echo -e "${YELLOW}Image: $FULL_IMAGE${NC}" +echo "" + +if ! podman build $BUILD_ARGS -f docker/Dockerfile -t "$FULL_IMAGE" .; then + echo -e "${RED}✗ Build failed${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Image built successfully${NC}" +echo "" + +# Show image info +echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Image Details ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" +podman images "$IMAGE_NAME:$IMAGE_TAG" \ + --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.Created}}" +echo "" + +if [ "$RUN_CONTAINER" = true ]; then + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ Starting Container ║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" + + # Cleanup existing container + if ! cleanup_container "$CONTAINER_NAME"; then + echo -e "${RED}✗ Failed to clean up existing container${NC}" + exit 1 + fi + + echo "" + echo "Starting new container: $CONTAINER_NAME" + + if podman run -d \ + --name "$CONTAINER_NAME" \ + -p 5000:5000 \ + --env-file .env \ + "$FULL_IMAGE"; then + echo -e "${GREEN}✓ Container running: $CONTAINER_NAME${NC}" + echo "" + + # Wait for startup + sleep 2 + + echo -e "${YELLOW}Container logs:${NC}" + podman logs "$CONTAINER_NAME" + echo "" + + echo -e "${GREEN}API available at: http://localhost:5000${NC}" + echo -e "${YELLOW}To view logs: podman logs -f $CONTAINER_NAME${NC}" + echo -e "${YELLOW}To stop: podman kill $CONTAINER_NAME${NC}" + else + echo -e "${RED}✗ Failed to start container${NC}" + exit 1 + fi +else + echo -e "${YELLOW}To run the container:${NC}" + echo " podman run -d --name $CONTAINER_NAME -p 5000:5000 --env-file .env $FULL_IMAGE" + echo "" + echo -e "${YELLOW}Or use this script with --run:${NC}" + echo " $0 -t $IMAGE_TAG --run" +fi + +echo "" +echo -e "${GREEN}✓ Done!${NC}" \ No newline at end of file diff --git a/scripts/migrate.sh b/scripts/migrate.sh deleted file mode 100644 index 405f399..0000000 --- a/scripts/migrate.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -e - -echo "Running database migrations..." -flask db upgrade - -echo "Starting application..." -exec "$@" \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..dbccbe2 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +BASE_URL="http://localhost:5000" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Testing RideAware API ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════╝${NC}\n" + +# Test 1: Health check +echo -e "${YELLOW}1. Health Check${NC}" +curl -s -X GET "$BASE_URL/health" +echo -e "\n\n" + +# Test 2: Signup +echo -e "${YELLOW}2. Signup (New User)${NC}" +SIGNUP_RESPONSE=$(curl -s -X POST "$BASE_URL/api/signup" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "blakearidgway", + "password": "SecurePass123", + "email": "blakearidgway@gmail.com", + "first_name": "Blake", + "last_name": "Ridgway" + }') +echo "$SIGNUP_RESPONSE" | jq . +ACCESS_TOKEN=$(echo "$SIGNUP_RESPONSE" | jq -r '.access_token // empty') +echo -e "\n" + +# Test 3: Login +echo -e "${YELLOW}3. Login${NC}" +LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/api/login" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "blakearidgway", + "password": "SecurePass123" + }') +echo "$LOGIN_RESPONSE" | jq . +ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token // empty') +echo -e "\n" + +# Test 4: Protected route with access token +echo -e "${YELLOW}4. Protected Route (with Access Token)${NC}" +if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" == "null" ]; then + echo -e "${RED}No access token available${NC}" +else + echo "Using token: ${ACCESS_TOKEN:0:50}..." + curl -s -X GET "$BASE_URL/api/protected/profile" \ + -H "Authorization: Bearer $ACCESS_TOKEN" | jq . +fi +echo -e "\n" + +# Test 5: Invalid token +echo -e "${YELLOW}5. Protected Route (with Invalid Token - should fail)${NC}" +curl -s -X GET "$BASE_URL/api/protected/profile" \ + -H "Authorization: Bearer invalid_token_here" | jq . +echo -e "\n" + +# Test 6: Missing auth header (should fail) +echo -e "${YELLOW}6. Protected Route (without Auth Header - should fail)${NC}" +curl -s -X GET "$BASE_URL/api/protected/profile" | jq . +echo -e "\n" + +# Test 7: Password reset request +echo -e "${YELLOW}7. Request Password Reset${NC}" +curl -s -X POST "$BASE_URL/api/password-reset/request" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "blakearidgway@gmail.com" + }' | jq . +echo -e "\n" + +# Test 8: Logout +echo -e "${YELLOW}8. Logout${NC}" +curl -s -X POST "$BASE_URL/api/logout" \ + -H "Content-Type: application/json" | jq . +echo -e "\n" + +echo -e "${GREEN}✓ Tests complete!${NC}" \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index 5800353..0000000 --- a/server.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -from flask import Flask -from flask_cors import CORS -from dotenv import load_dotenv -from flask_migrate import Migrate -from flask.cli import FlaskGroup - -from models import db, init_db -from routes.user_auth import auth - -load_dotenv() - -app = Flask(__name__) -app.config["SECRET_KEY"] = os.getenv("SECRET_KEY") -app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv("DATABASE") -app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False - -CORS(app) - -init_db(app) -migrate = Migrate(app, db) -app.register_blueprint(auth.auth_bp) - - -@app.route("/health") -def health_check(): - """Health check endpoint.""" - return "OK", 200 - -cli = FlaskGroup(app) - -if __name__ == "__main__": - cli() \ No newline at end of file diff --git a/services/UserService/user.py b/services/UserService/user.py deleted file mode 100644 index 6f1c030..0000000 --- a/services/UserService/user.py +++ /dev/null @@ -1,60 +0,0 @@ -from models.User.user import User -from models.UserProfile.user_profile import UserProfile -from models import db -import re - -class UserService: - def create_user(self, username, password, email=None, first_name=None, last_name=None): - if not username or not password: - raise ValueError("Username and password are required") - - if email: - email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - if not re.match(email_regex, email): - raise ValueError("Invalid email format") - - existing_user = User.query.filter( - (User.username == username) | (User.email == email) - ).first() - - if existing_user: - if existing_user.username == username: - raise ValueError("Username already exists") - else: - raise ValueError("Email already exists") - - if len(password) < 8: - raise ValueError("Password must be at least 8 characters long") - - try: - new_user = User( - username=username, - email=email or "", - password=password - ) - - db.session.add(new_user) - db.session.flush() - - user_profile = UserProfile( - user_id=new_user.id, - first_name=first_name or "", - last_name=last_name or "", - bio="", - profile_picture="" - ) - - db.session.add(user_profile) - db.session.commit() - - return new_user - - except Exception as e: - db.session.rollback() - raise Exception(f"Error creating user: {str(e)}") - - def verify_user(self, username, password): - user = User.query.filter_by(username=username).first() - if not user or not user.check_password(password): - raise ValueError("Invalid username or password") - return user \ No newline at end of file