diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c72dff0..0000000 --- a/Dockerfile +++ /dev/null @@ -1,53 +0,0 @@ -FROM python:3.10-slim AS builder - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential gcc \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . - -RUN python -m pip install --upgrade pip && \ - pip wheel --no-deps -r requirements.txt -w /wheels && \ - pip wheel --no-deps gunicorn -w /wheels - -FROM python:3.10-slim AS runtime - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PORT=5000 \ - WSGI_MODULE=server:app \ - GUNICORN_WORKERS=2 \ - GUNICORN_THREADS=4 \ - GUNICORN_TIMEOUT=60 \ - FLASK_APP=server.py - -WORKDIR /app - -RUN groupadd -g 10001 app && useradd -m -u 10001 -g app app - -COPY --from=builder /wheels /wheels -RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels - -# Install python-dotenv if not already in requirements.txt -RUN pip install python-dotenv - -USER app - -COPY --chown=app:app . . - -# Copy .env file specifically -COPY --chown=app:app .env .env - -EXPOSE 5000 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD python -c "import os,socket; s=socket.socket(); s.settimeout(2); s.connect(('127.0.0.1', int(os.getenv('PORT', '5000')))); s.close()" - -CMD ["sh", "-c", "exec gunicorn $WSGI_MODULE --bind=0.0.0.0:$PORT --workers=$GUNICORN_WORKERS --threads=$GUNICORN_THREADS --timeout=$GUNICORN_TIMEOUT --access-logfile=- --error-logfile=- --keep-alive=5"] \ No newline at end of file diff --git a/README.md b/README.md index cda9868..959317f 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,336 @@ +Here's a rewritten README for your Go version: + +```markdown # RideAware API Train with Focus. Ride with Awareness -RideAware API is the backend service for the RideAware platform, providing endpoints for user authentication and structured workout management. +RideAware API is the backend service for the RideAware platform, built with Go and PostgreSQL. It provides comprehensive endpoints for user authentication, profile management, and cycling performance tracking. -RideAware is a **comprehensive cycling training platform** designed to help riders stay aware of their performance, progress, and goals. +RideAware is a **comprehensive cycling training platform** designed to help riders stay aware of their performance, progress, and goals. Whether you're building a structured training plan, analyzing ride data, or completing workouts indoors, RideAware keeps you connected to every detail of your ride. +## Tech Stack + +- **Language**: Go 1.21+ +- **Database**: PostgreSQL +- **ORM**: GORM +- **Router**: Chi v5 +- **Auth**: JWT (Access + Refresh tokens) +- **Email**: Resend +- **Containerization**: Podman/Docker + ## Getting Started ### Prerequisites Ensure you have the following installed on your system: -- Docker -- Python 3.10 or later -- pip +- Go 1.21 or later +- PostgreSQL 12 or later +- Podman or Docker +- Git ### Setting Up the Project -1. **Clone the Repository** +1. **Clone the Repository** ```bash git clone https://github.com/VeloInnovate/rideaware-api.git cd rideaware-api ``` -2. **Create a Virtual Environment** - It is recommended to use a Python virtual environment to isolate dependencies. - - ```bash - python3 -m venv .venv - ``` +2. **Install Dependencies** -3. **Activate the Virtual Environment** - - On Linux/Mac: - ```bash - source .venv/bin/activate - ``` - - On Windows: - ```cmd - .venv\Scripts\activate - ``` - -4. **Install Requirements** - Install the required Python packages using pip: ```bash - pip install -r requirements.txt + go mod download + go mod tidy ``` -### Configuration +3. **Configure Environment Variables** -The application uses environment variables for configuration. Create a `.env` file in the root directory and define the following variables: + Create a `.env` file in the root directory: -``` -DATABASE= -``` -- Replace `` with the URI of your database (e.g., SQLite, PostgreSQL). + ```env + # Database + PG_USER=postgres + PG_PASSWORD=your_password + PG_HOST=localhost + PG_PORT=5432 + PG_DATABASE=rideaware -### Running with Docker + # Server + PORT=5000 -To run the application in a containerized environment, you can use the provided Dockerfile. + # Security + JWT_SECRET_KEY=your-super-secret-key-change-in-production -1. **Build the Docker Image**: + # Email Service + RESEND_API_KEY=re_your_resend_api_key + SENDER_EMAIL=noreply@rideaware.app + ``` + +4. **Set Up the Database** + + Ensure PostgreSQL is running and create the database: + + ```bash + createdb rideaware + ``` + + GORM will automatically run migrations on startup. + +### Running Locally + +**Option 1: Direct Execution** ```bash -docker build -t rideaware-api . +go run main.go ``` +The API will be available at `http://localhost:5000` + +**Option 2: Build and Run Binary** + +```bash +go build -o server . +./server +``` + +### Running with Podman/Docker + +#### Quick Start (with build script) + +```bash +chmod +x build.sh +./build.sh --run +``` + +This will build the image and start a container. + +#### Manual Build + +1. **Build the Image** + + ```bash + podman build -t rideaware:latest . + ``` + 2. **Run the Container** + ```bash + podman run -d \ + --name rideaware-api \ + -p 5000:5000 \ + --env-file .env \ + rideaware:latest + ``` + +3. **View Logs** + + ```bash + podman logs -f rideaware-api + ``` + +The API will be available at `http://localhost:5000` + +## API Documentation + +### Health Check + ```bash -docker run -d -p 5000:5000 --env-file .env rideaware-api +GET /health ``` -The application will be available at http://127.0.0.1:5000. +Response: `OK` + +### Authentication + +#### Sign Up + +```bash +POST /api/signup +Content-Type: application/json + +{ + "username": "cyclist", + "password": "SecurePass123", + "email": "cyclist@example.com", + "first_name": "John", + "last_name": "Cyclist" +} +``` + +Response: +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "expires_in": 900, + "user_id": 1, + "username": "cyclist", + "email": "cyclist@example.com" +} +``` + +#### Login + +```bash +POST /api/login +Content-Type: application/json + +{ + "username": "cyclist", + "password": "SecurePass123" +} +``` + +#### Request Password Reset + +```bash +POST /api/password-reset/request +Content-Type: application/json + +{ + "email": "cyclist@example.com" +} +``` + +#### Confirm Password Reset + +```bash +POST /api/password-reset/confirm +Content-Type: application/json + +{ + "token": "reset_token_from_email", + "new_password": "NewSecurePass123" +} +``` + +#### Logout + +```bash +POST /api/logout +``` + +### Protected Routes + +All protected routes require the `Authorization: Bearer ` header. + +#### Get User Profile + +```bash +GET /api/protected/profile +Authorization: Bearer +``` + +## Testing + +Run the test suite: + +```bash +chmod +x test-api.sh +./test-api.sh +``` + +This will test: +- User signup +- Login +- Protected routes +- Password reset +- Error handling + +## Development ### Running Tests -To be added. +```bash +./test-api.sh +``` + +### Building a New Binary + +```bash +go build -o server . +``` + +### Code Formatting + +```bash +go fmt ./... +``` + +### Linting + +```bash +go vet ./... +``` + +## Deployment + +### Environment Variables for Production + +```env +JWT_SECRET_KEY= +RESEND_API_KEY= +PG_PASSWORD= +``` + +### Building for Production + +```bash +./build.sh -t prod --no-cache --run +``` + +Or push to registry: + +```bash +./build.sh -t prod -r docker.io/username --push +``` + +## Troubleshooting + +### Database Connection Errors + +Ensure PostgreSQL is running and `.env` variables are correct: + +```bash +psql -h localhost -U postgres -d rideaware +``` + +### Port Already in Use + +Change the PORT in `.env` or stop the running container: + +```bash +podman kill rideaware-api +podman rm rideaware-api +``` + +### Docker Permission Issues + +Run podman with sudo or add your user to the podman group: + +```bash +sudo usermod -aG podman $USER +``` ## Contributing -Contributions are welcome! Please create a pull request or open an issue for any improvements or bug fixes. +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request ## License -This project is licensed under the AGPL-3.0 License. +This project is licensed under the AGPL-3.0 License - see the LICENSE file for details. +## Support + +For issues, questions, or suggestions, please open an issue on GitHub or contact the development team. \ No newline at end of file diff --git a/TODO.md b/TODO.md index e46fd5a..f210933 100644 --- a/TODO.md +++ b/TODO.md @@ -1,98 +1,147 @@ # TODO Features ## User Management -- [ ] **User Registration & Login**: Email, OAuth (Google, Apple, Strava, Garmin). -- [ ] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP history, weight. -- [ ] **Password Recovery**: Email-based reset and magic-link login. -- [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc. -- [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces. -- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile. +- [x] **User Registration & Login**: Email authentication with JWT tokens +- [x] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP, weight +- [x] **Password Recovery**: Email-based reset with secure tokens +- [ ] **OAuth Integration**: Google, Apple, Strava, Garmin +- [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc +- [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces +- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile ## Workout Planning -- [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level. -- [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather. -- [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook). -- [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars. -- [ ] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength). -- [ ] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad. -- [ ] **Race/Event Planner**: Target events, taper builder, gear checklist. +- [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level +- [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather +- [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook) +- [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars +- [ ] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength) +- [ ] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad +- [ ] **Race/Event Planner**: Target events, taper builder, gear checklist ## Workout Tracking -- [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS. -- [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX). -- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used. -- [ ] **Equipment Tracking**: Bike/components mileage, service reminders. +- [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS +- [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX) +- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used +- [ ] **Equipment Tracking**: Bike/components mileage, service reminders ## Advanced Analytics -- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends. -- [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts. -- [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons. -- [ ] **Custom Reports**: Export CSV/PDF; shareable report links. +- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends +- [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts +- [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons +- [ ] **Custom Reports**: Export CSV/PDF; shareable report links ## Training & Coaching -- [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews. -- [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes. -- [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE). -- [ ] **Messaging**: Coach–athlete chat, comments on sessions, file attachments. +- [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews +- [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes +- [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE) +- [ ] **Messaging**: Coach–athlete chat, comments on sessions, file attachments ## Nutrition & Recovery -- [ ] **Nutrition Planning**: Meal plans, macros, carb periodization. -- [ ] **Nutrition Tracking**: Food log, barcode/manual entry, hydration tracking. -- [ ] **Recovery Optimization**: Sleep/HRV import, readiness score, rest day prompts. -- [ ] **Injury Prevention & Management**: Screeners, red-flag alerts, return-to-ride flow. -- [ ] **Supplement & Allergy Flags**: Notes and reminders in plan builder. +- [ ] **Nutrition Planning**: Meal plans, macros, carb periodization +- [ ] **Nutrition Tracking**: Food log, barcode/manual entry, hydration tracking +- [ ] **Recovery Optimization**: Sleep/HRV import, readiness score, rest day prompts +- [ ] **Injury Prevention & Management**: Screeners, red-flag alerts, return-to-ride flow +- [ ] **Supplement & Allergy Flags**: Notes and reminders in plan builder ## Community & Social -- [ ] **Social Sharing**: One-click share to Strava/social with privacy controls. -- [ ] **Community Forum**: Topics, groups/clubs, moderation tools. -- [ ] **Leaderboards**: Global, club, event, and route/segment leaderboards. -- [ ] **Challenges & Streaks**: Time-boxed events, badges, streak protection. +- [ ] **Social Sharing**: One-click share to Strava/social with privacy controls +- [ ] **Community Forum**: Topics, groups/clubs, moderation tools +- [ ] **Leaderboards**: Global, club, event, and route/segment leaderboards +- [ ] **Challenges & Streaks**: Time-boxed events, badges, streak protection ## Gamification & Engagement -- [ ] **Achievements & Badges**: Milestones (consistency, PRs, climbing, streaks). -- [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles. -- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles. +- [ ] **Achievements & Badges**: Milestones (consistency, PRs, climbing, streaks) +- [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles +- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles ## Integrations & Data -- [ ] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit. -- [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push). -- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists. -- [ ] **Data Import/Export**: Bulk FIT/TCX/GPX import; CSV/JSON export; takeout ZIP. -- [ ] **Public API & Webhooks**: For partners, coaches, clubs. +- [ ] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit +- [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push) +- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists +- [ ] **Data Import/Export**: Bulk FIT/TCX/GPX import; CSV/JSON export; takeout ZIP +- [ ] **Public API & Webhooks**: For partners, coaches, clubs ## Notifications & Comms -- [ ] **Reminders**: Email, push, SMS; smart timing. -- [ ] **Digest Emails**: Weekly plan, monthly progress. -- [ ] **Real-time Alerts**: Overtraining risk, missed session, weather hazard. +- [ ] **Reminders**: Email, push, SMS; smart timing +- [ ] **Digest Emails**: Weekly plan, monthly progress +- [ ] **Real-time Alerts**: Overtraining risk, missed session, weather hazard ## Accessibility & Internationalization -- [ ] **A11y**: WCAG 2.2 AA, keyboard nav, screen reader labels. -- [ ] **Localization**: i18n framework, units (imperial/metric), timezones. -- [ ] **Color-blind Safe Palettes**: Analytics & maps. +- [ ] **A11y**: WCAG 2.2 AA, keyboard nav, screen reader labels +- [ ] **Localization**: i18n framework, units (imperial/metric), timezones +- [ ] **Color-blind Safe Palettes**: Analytics & maps ## Mobile & Apps -- [ ] **PWA Offline Mode**: Log workouts offline; sync when online. -- [ ] **Native App Shell**: Background sync, notifications, wearables bridge. +- [ ] **PWA Offline Mode**: Log workouts offline; sync when online +- [ ] **Native App Shell**: Background sync, notifications, wearables bridge ## Security, Privacy & Compliance -- [ ] **Privacy Controls**: Public/private by item, club privacy, anonymized leaderboards. -- [ ] **Data Protection**: Encryption at rest/in transit, secrets rotation. -- [ ] **Compliance**: GDPR/CCPA requests (export/delete), age gating, COPPA checks. -- [ ] **Audit Logs**: Admin and coach actions. +- [ ] **Privacy Controls**: Public/private by item, club privacy, anonymized leaderboards +- [ ] **Data Protection**: Encryption at rest/in transit, secrets rotation +- [ ] **Compliance**: GDPR/CCPA requests (export/delete), age gating, COPPA checks +- [ ] **Audit Logs**: Admin and coach actions ## Admin, Billing & Ops -- [ ] **Admin Console**: User management, feature flags, content moderation. -- [ ] **Subscriptions**: Free/Pro/Coach tiers, trials, coupons, taxes (Stripe). -- [ ] **Telemetry & Observability**: Metrics, tracing, error reporting, uptime SLOs. -- [ ] **Scalability**: Queueing for imports/exports, background jobs. -- [ ] **Backups & DR**: Automated backups, restore drills, RTO/RPO defined. +- [ ] **Admin Console**: User management, feature flags, content moderation +- [ ] **Subscriptions**: Free/Pro/Coach tiers, trials, coupons, taxes (Stripe) +- [ ] **Telemetry & Observability**: Metrics, tracing, error reporting, uptime SLOs +- [ ] **Scalability**: Queueing for imports/exports, background jobs +- [ ] **Backups & DR**: Automated backups, restore drills, RTO/RPO defined ## Content & Library -- [ ] **Exercise Library**: Strength/mobility videos with cues and progressions. -- [ ] **Knowledge Base**: Articles on training, nutrition, recovery. -- [ ] **Route Library**: GPX planner/import, elevation profiles, weather overlays. +- [ ] **Exercise Library**: Strength/mobility videos with cues and progressions +- [ ] **Knowledge Base**: Articles on training, nutrition, recovery +- [ ] **Route Library**: GPX planner/import, elevation profiles, weather overlays ## Possible Future Features -- [ ] **Virtual Reality (VR) Integration**: Immersive rides with real-time metrics. -- [ ] **Augmented Reality (AR) Integration**: HUD overlays during rides. -- [ ] **Machine Learning (ML) Integration**: Injury risk models, plan optimization, weather-aware ETA and fueling estimates. \ No newline at end of file +- [ ] **Virtual Reality (VR) Integration**: Immersive rides with real-time metrics +- [ ] **Augmented Reality (AR) Integration**: HUD overlays during rides +- [ ] **Machine Learning (ML) Integration**: Injury risk models, plan optimization, weather-aware ETA and fueling estimates + +--- + +## Completed - Phase 1: Authentication & User Management ✅ + +### Infrastructure +- [x] Migrated from Python/Flask to Go with Chi router +- [x] Restructured project with clean architecture (`cmd/`, `internal/`, `pkg/`) +- [x] PostgreSQL + GORM ORM setup with migrations +- [x] Docker/Podman containerization with multi-stage builds + +### Authentication +- [x] User signup with validation (username, email, password strength) +- [x] User login with JWT tokens (access + refresh) +- [x] Password hashing with bcrypt +- [x] Protected routes with Bearer token authentication +- [x] Password reset flow with email tokens + +### User Profiles +- [x] User model with relationships (Profile, PasswordReset, Sessions) +- [x] User profile with stats (HR zones, FTP, weight, total rides, distance, time) +- [x] Email service integration (Resend) for notifications +- [x] Automatic profile creation on user signup + +### Code Quality +- [x] Repository pattern for data access +- [x] Service layer for business logic +- [x] Auth middleware for protected routes +- [x] Error handling and validation +- [x] Environment configuration with .env + +--- + +## Next Phase: Phase 2 - User Profiles & Stats Endpoints + +### Planned Features +- [ ] GET/PUT `/api/protected/profile` - Full profile management +- [ ] POST/GET `/api/equipment` - Bike/gear management +- [ ] POST/GET `/api/stats` - Ride statistics +- [ ] GET `/api/zones` - Calculate training zones (auto from FTP/HR) +- [ ] Equipment tracking (brand, model, weight, mileage) +- [ ] Stats aggregation and trending + +### After Phase 2: Phase 3 - OAuth Integration +- [ ] Google OAuth 2.0 +- [ ] Strava API integration +- [ ] Apple Sign-In +- [ ] Garmin Connect \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..39dea12 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + "github.com/joho/godotenv" + + "rideaware/internal/auth" + "rideaware/internal/config" + "rideaware/internal/middleware" + "rideaware/internal/user" + "rideaware/pkg/database" +) + +func main() { + godotenv.Load() + + // Initialize database connection + database.Init() + defer database.Close() + + // Run migrations + if err := database.Migrate( + &user.User{}, + &user.Profile{}, + &user.PasswordReset{}, + &user.Session{}, + ); err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } + + // Initialize JWT config + config.InitJWT() + + r := chi.NewRouter() + + // Middleware + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{ + "GET", "POST", "PUT", "DELETE", "OPTIONS", + }, + AllowedHeaders: []string{ + "Accept", "Authorization", "Content-Type", + }, + ExposedHeaders: []string{"Link"}, + MaxAge: 300, + })) + + // Routes + setupRoutes(r) + + port := os.Getenv("PORT") + if port == "" { + port = "5000" + } + + log.Printf("Server running on port %s", port) + log.Fatal(http.ListenAndServe(":"+port, r)) +} + +func setupRoutes(r *chi.Mux) { + // Public routes + r.Get("/health", healthCheck) + + // Auth routes + authHandler := auth.NewHandler() + r.Post("/api/signup", authHandler.Signup) + r.Post("/api/login", authHandler.Login) + r.Post("/api/logout", authHandler.Logout) + r.Post("/api/password-reset/request", authHandler.RequestPasswordReset) + r.Post("/api/password-reset/confirm", authHandler.ConfirmPasswordReset) + + // Protected routes + authMiddleware := middleware.NewAuthMiddleware() + r.Route("/api/protected", func(r chi.Router) { + r.Use(authMiddleware.ProtectedRoute) + + // User routes + userHandler := user.NewHandler() + r.Get("/profile", userHandler.GetProfile) + r.Put("/profile", userHandler.UpdateProfile) + }) +} + +func healthCheck(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..70a801c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,29 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=builder /app/server . +COPY .env . + +RUN chmod +x ./server + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://127.0.0.1:5000/health || exit 1 + +CMD ["./server"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..13e56c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module rideaware + +go 1.25.4 + +require ( + github.com/go-chi/chi/v5 v5.0.11 + github.com/go-chi/cors v1.2.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/joho/godotenv v1.5.1 + github.com/resend/resend-go/v2 v2.7.0 + golang.org/x/crypto v0.17.0 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9007410 --- /dev/null +++ b/go.sum @@ -0,0 +1,42 @@ +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/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/resend/resend-go/v2 v2.7.0 h1:yEze1zXRmcWVnCPXBy95bexkOTkP1ZyYnBIIJXgeNtI= +github.com/resend/resend-go/v2 v2.7.0/go.mod h1:ihnxc7wPpSgans8RV8d8dIF4hYWVsqMK5KxXAr9LIos= +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.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde h1:9DShaph9qhkIYw7QF91I/ynrr4cOO2PZra2PFD7Mfeg= +gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..47e4454 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,153 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "rideaware/internal/config" + "rideaware/internal/user" +) + +type Handler struct { + userService *user.Service +} + +func NewHandler() *Handler { + return &Handler{ + userService: user.NewService(), + } +} + +type SignupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` +} + +func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) { + var req SignupRequest + 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 + } + + newUser, err := h.userService.CreateUser(req.Username, req.Password, req.Email, req.FirstName, req.LastName) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + accessToken, _ := config.GenerateAccessToken(newUser.ID, newUser.Email, newUser.Username) + refreshToken, _ := config.GenerateRefreshToken(newUser.ID, newUser.Email, newUser.Username) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: 900, + UserID: newUser.ID, + Username: newUser.Username, + Email: newUser.Email, + }) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + 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.userService.VerifyUser(req.Username, req.Password) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + accessToken, _ := config.GenerateAccessToken(user.ID, user.Email, user.Username) + refreshToken, _ := config.GenerateRefreshToken(user.ID, user.Email, user.Username) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: 900, + UserID: user.ID, + Username: user.Username, + Email: user.Email, + }) +} + +func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + 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 + } + + h.userService.RequestPasswordReset(req.Email) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "If email exists, reset link has been sent", + }) +} + +func (h *Handler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` + } + 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 + } + + if err := h.userService.ResetPassword(req.Token, req.NewPassword); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Password reset successful", + }) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Logout successful"}) +} \ No newline at end of file diff --git a/internal/config/jwt.go b/internal/config/jwt.go new file mode 100644 index 0000000..3bd4162 --- /dev/null +++ b/internal/config/jwt.go @@ -0,0 +1,97 @@ +package config + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JWTConfig struct { + SecretKey string + AccessTokenDuration time.Duration + RefreshTokenDuration time.Duration + ResetTokenDuration time.Duration +} + +var JWT *JWTConfig + +func InitJWT() { + JWT = &JWTConfig{ + SecretKey: os.Getenv("JWT_SECRET_KEY"), + AccessTokenDuration: 15 * time.Minute, + RefreshTokenDuration: 7 * 24 * time.Hour, + ResetTokenDuration: 1 * time.Hour, + } + + if JWT.SecretKey == "" { + panic("JWT_SECRET_KEY not set in environment") + } +} + +type CustomClaims struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + TokenType string `json:"token_type"` + jwt.RegisteredClaims +} + +func GenerateAccessToken(userID uint, email, username string) (string, error) { + claims := CustomClaims{ + UserID: userID, + Email: email, + Username: username, + TokenType: "access", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT.AccessTokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "rideaware", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(JWT.SecretKey)) +} + +func GenerateRefreshToken(userID uint, email, username string) (string, error) { + claims := CustomClaims{ + UserID: userID, + Email: email, + Username: username, + TokenType: "refresh", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT.RefreshTokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "rideaware", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(JWT.SecretKey)) +} + +func VerifyToken(tokenString string) (*CustomClaims, error) { + claims := &CustomClaims{} + token, err := jwt.ParseWithClaims( + tokenString, + claims, + func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(JWT.SecretKey), nil + }, + ) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} \ No newline at end of file diff --git a/internal/email/service.go b/internal/email/service.go new file mode 100644 index 0000000..e02c9c2 --- /dev/null +++ b/internal/email/service.go @@ -0,0 +1,82 @@ +package email + +import ( + "fmt" + "os" + + "github.com/resend/resend-go/v2" +) + +type Service struct { + client *resend.Client + from string +} + +func NewService() *Service { + senderEmail := os.Getenv("SENDER_EMAIL") + if senderEmail == "" { + senderEmail = "noreply@rideaware.app" + } + + apiKey := os.Getenv("RESEND_API_KEY") + if apiKey == "" { + apiKey = "re_test" + } + + return &Service{ + client: resend.NewClient(apiKey), + from: senderEmail, + } +} + +func (s *Service) SendPasswordResetEmail(email, username, resetLink string) error { + params := &resend.SendEmailRequest{ + From: s.from, + To: []string{email}, + Subject: "Reset Your RideAware Password", + Html: fmt.Sprintf(` +

Password Reset Request

+

Hi %s,

+

We received a request to reset your password. Click the link below to create a new password:

+

Reset 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(` +

Welcome to RideAware

+

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