feat: migrate Flask API to Go with JWT auth

This commit is contained in:
Cipher Vance
2025-11-20 19:00:53 -06:00
parent c6e330c063
commit 3bf3a9b24d
34 changed files with 1774 additions and 689 deletions

View File

@@ -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"]

325
README.md
View File

@@ -1,22 +1,36 @@
Here's a rewritten README for your Go version:
```markdown
# RideAware API # RideAware API
<i>Train with Focus. Ride with Awareness</i> <i>Train with Focus. Ride with Awareness</i>
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. 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 ## Getting Started
### Prerequisites ### Prerequisites
Ensure you have the following installed on your system: Ensure you have the following installed on your system:
- Docker - Go 1.21 or later
- Python 3.10 or later - PostgreSQL 12 or later
- pip - Podman or Docker
- Git
### Setting Up the Project ### Setting Up the Project
@@ -27,65 +41,296 @@ Ensure you have the following installed on your system:
cd rideaware-api cd rideaware-api
``` ```
2. **Create a Virtual Environment** 2. **Install Dependencies**
It is recommended to use a Python virtual environment to isolate dependencies.
```bash ```bash
python3 -m venv .venv go mod download
go mod tidy
``` ```
3. **Activate the Virtual Environment** 3. **Configure Environment Variables**
- On Linux/Mac:
```bash Create a `.env` file in the root directory:
source .venv/bin/activate
``` ```env
- On Windows: # Database
```cmd PG_USER=postgres
.venv\Scripts\activate PG_PASSWORD=your_password
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=rideaware
# Server
PORT=5000
# Security
JWT_SECRET_KEY=your-super-secret-key-change-in-production
# Email Service
RESEND_API_KEY=re_your_resend_api_key
SENDER_EMAIL=noreply@rideaware.app
``` ```
4. **Install Requirements** 4. **Set Up the Database**
Install the required Python packages using pip:
```bash
pip install -r requirements.txt
```
### Configuration Ensure PostgreSQL is running and create the database:
The application uses environment variables for configuration. Create a `.env` file in the root directory and define the following variables:
```
DATABASE=<your_database_connection_string>
```
- Replace `<your_database_connection_string>` with the URI of your database (e.g., SQLite, PostgreSQL).
### Running with Docker
To run the application in a containerized environment, you can use the provided Dockerfile.
1. **Build the Docker Image**:
```bash ```bash
docker build -t rideaware-api . createdb rideaware
```
GORM will automatically run migrations on startup.
### Running Locally
**Option 1: Direct Execution**
```bash
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** 2. **Run the Container**
```bash ```bash
docker run -d -p 5000:5000 --env-file .env rideaware-api podman run -d \
--name rideaware-api \
-p 5000:5000 \
--env-file .env \
rideaware:latest
``` ```
The application will be available at http://127.0.0.1:5000. 3. **View Logs**
```bash
podman logs -f rideaware-api
```
The API will be available at `http://localhost:5000`
## API Documentation
### Health Check
```bash
GET /health
```
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 <access_token>` header.
#### Get User Profile
```bash
GET /api/protected/profile
Authorization: Bearer <access_token>
```
## 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 ### 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=<generate-secure-random-key>
RESEND_API_KEY=<your-resend-production-key>
PG_PASSWORD=<strong-database-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 ## 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 ## 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.

179
TODO.md
View File

@@ -1,98 +1,147 @@
# TODO Features # TODO Features
## User Management ## User Management
- [ ] **User Registration & Login**: Email, OAuth (Google, Apple, Strava, Garmin). - [x] **User Registration & Login**: Email authentication with JWT tokens
- [ ] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP history, weight. - [x] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP, weight
- [ ] **Password Recovery**: Email-based reset and magic-link login. - [x] **Password Recovery**: Email-based reset with secure tokens
- [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc. - [ ] **OAuth Integration**: Google, Apple, Strava, Garmin
- [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces. - [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc
- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile. - [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces
- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile
## Workout Planning ## Workout Planning
- [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level. - [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level
- [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather. - [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather
- [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook). - [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook)
- [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars. - [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars
- [ ] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength). - [ ] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength)
- [ ] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad. - [ ] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad
- [ ] **Race/Event Planner**: Target events, taper builder, gear checklist. - [ ] **Race/Event Planner**: Target events, taper builder, gear checklist
## Workout Tracking ## Workout Tracking
- [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS. - [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS
- [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX). - [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX)
- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used. - [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used
- [ ] **Equipment Tracking**: Bike/components mileage, service reminders. - [ ] **Equipment Tracking**: Bike/components mileage, service reminders
## Advanced Analytics ## Advanced Analytics
- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends. - [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends
- [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts. - [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts
- [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons. - [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons
- [ ] **Custom Reports**: Export CSV/PDF; shareable report links. - [ ] **Custom Reports**: Export CSV/PDF; shareable report links
## Training & Coaching ## Training & Coaching
- [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews. - [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews
- [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes. - [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes
- [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE). - [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE)
- [ ] **Messaging**: Coachathlete chat, comments on sessions, file attachments. - [ ] **Messaging**: Coachathlete chat, comments on sessions, file attachments
## Nutrition & Recovery ## Nutrition & Recovery
- [ ] **Nutrition Planning**: Meal plans, macros, carb periodization. - [ ] **Nutrition Planning**: Meal plans, macros, carb periodization
- [ ] **Nutrition Tracking**: Food log, barcode/manual entry, hydration tracking. - [ ] **Nutrition Tracking**: Food log, barcode/manual entry, hydration tracking
- [ ] **Recovery Optimization**: Sleep/HRV import, readiness score, rest day prompts. - [ ] **Recovery Optimization**: Sleep/HRV import, readiness score, rest day prompts
- [ ] **Injury Prevention & Management**: Screeners, red-flag alerts, return-to-ride flow. - [ ] **Injury Prevention & Management**: Screeners, red-flag alerts, return-to-ride flow
- [ ] **Supplement & Allergy Flags**: Notes and reminders in plan builder. - [ ] **Supplement & Allergy Flags**: Notes and reminders in plan builder
## Community & Social ## Community & Social
- [ ] **Social Sharing**: One-click share to Strava/social with privacy controls. - [ ] **Social Sharing**: One-click share to Strava/social with privacy controls
- [ ] **Community Forum**: Topics, groups/clubs, moderation tools. - [ ] **Community Forum**: Topics, groups/clubs, moderation tools
- [ ] **Leaderboards**: Global, club, event, and route/segment leaderboards. - [ ] **Leaderboards**: Global, club, event, and route/segment leaderboards
- [ ] **Challenges & Streaks**: Time-boxed events, badges, streak protection. - [ ] **Challenges & Streaks**: Time-boxed events, badges, streak protection
## Gamification & Engagement ## Gamification & Engagement
- [ ] **Achievements & Badges**: Milestones (consistency, PRs, climbing, streaks). - [ ] **Achievements & Badges**: Milestones (consistency, PRs, climbing, streaks)
- [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles. - [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles
- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles. - [ ] **Rewards & Incentives**: Points store, partner discounts, raffles
## Integrations & Data ## Integrations & Data
- [ ] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit. - [ ] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit
- [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push). - [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push)
- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists. - [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists
- [ ] **Data Import/Export**: Bulk FIT/TCX/GPX import; CSV/JSON export; takeout ZIP. - [ ] **Data Import/Export**: Bulk FIT/TCX/GPX import; CSV/JSON export; takeout ZIP
- [ ] **Public API & Webhooks**: For partners, coaches, clubs. - [ ] **Public API & Webhooks**: For partners, coaches, clubs
## Notifications & Comms ## Notifications & Comms
- [ ] **Reminders**: Email, push, SMS; smart timing. - [ ] **Reminders**: Email, push, SMS; smart timing
- [ ] **Digest Emails**: Weekly plan, monthly progress. - [ ] **Digest Emails**: Weekly plan, monthly progress
- [ ] **Real-time Alerts**: Overtraining risk, missed session, weather hazard. - [ ] **Real-time Alerts**: Overtraining risk, missed session, weather hazard
## Accessibility & Internationalization ## Accessibility & Internationalization
- [ ] **A11y**: WCAG 2.2 AA, keyboard nav, screen reader labels. - [ ] **A11y**: WCAG 2.2 AA, keyboard nav, screen reader labels
- [ ] **Localization**: i18n framework, units (imperial/metric), timezones. - [ ] **Localization**: i18n framework, units (imperial/metric), timezones
- [ ] **Color-blind Safe Palettes**: Analytics & maps. - [ ] **Color-blind Safe Palettes**: Analytics & maps
## Mobile & Apps ## Mobile & Apps
- [ ] **PWA Offline Mode**: Log workouts offline; sync when online. - [ ] **PWA Offline Mode**: Log workouts offline; sync when online
- [ ] **Native App Shell**: Background sync, notifications, wearables bridge. - [ ] **Native App Shell**: Background sync, notifications, wearables bridge
## Security, Privacy & Compliance ## Security, Privacy & Compliance
- [ ] **Privacy Controls**: Public/private by item, club privacy, anonymized leaderboards. - [ ] **Privacy Controls**: Public/private by item, club privacy, anonymized leaderboards
- [ ] **Data Protection**: Encryption at rest/in transit, secrets rotation. - [ ] **Data Protection**: Encryption at rest/in transit, secrets rotation
- [ ] **Compliance**: GDPR/CCPA requests (export/delete), age gating, COPPA checks. - [ ] **Compliance**: GDPR/CCPA requests (export/delete), age gating, COPPA checks
- [ ] **Audit Logs**: Admin and coach actions. - [ ] **Audit Logs**: Admin and coach actions
## Admin, Billing & Ops ## Admin, Billing & Ops
- [ ] **Admin Console**: User management, feature flags, content moderation. - [ ] **Admin Console**: User management, feature flags, content moderation
- [ ] **Subscriptions**: Free/Pro/Coach tiers, trials, coupons, taxes (Stripe). - [ ] **Subscriptions**: Free/Pro/Coach tiers, trials, coupons, taxes (Stripe)
- [ ] **Telemetry & Observability**: Metrics, tracing, error reporting, uptime SLOs. - [ ] **Telemetry & Observability**: Metrics, tracing, error reporting, uptime SLOs
- [ ] **Scalability**: Queueing for imports/exports, background jobs. - [ ] **Scalability**: Queueing for imports/exports, background jobs
- [ ] **Backups & DR**: Automated backups, restore drills, RTO/RPO defined. - [ ] **Backups & DR**: Automated backups, restore drills, RTO/RPO defined
## Content & Library ## Content & Library
- [ ] **Exercise Library**: Strength/mobility videos with cues and progressions. - [ ] **Exercise Library**: Strength/mobility videos with cues and progressions
- [ ] **Knowledge Base**: Articles on training, nutrition, recovery. - [ ] **Knowledge Base**: Articles on training, nutrition, recovery
- [ ] **Route Library**: GPX planner/import, elevation profiles, weather overlays. - [ ] **Route Library**: GPX planner/import, elevation profiles, weather overlays
## Possible Future Features ## Possible Future Features
- [ ] **Virtual Reality (VR) Integration**: Immersive rides with real-time metrics. - [ ] **Virtual Reality (VR) Integration**: Immersive rides with real-time metrics
- [ ] **Augmented Reality (AR) Integration**: HUD overlays during rides. - [ ] **Augmented Reality (AR) Integration**: HUD overlays during rides
- [ ] **Machine Learning (ML) Integration**: Injury risk models, plan optimization, weather-aware ETA and fueling estimates. - [ ] **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

93
cmd/server/main.go Normal file
View File

@@ -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"))
}

29
docker/Dockerfile Normal file
View File

@@ -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"]

23
go.mod Normal file
View File

@@ -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
)

42
go.sum Normal file
View File

@@ -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=

153
internal/auth/handler.go Normal file
View File

@@ -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"})
}

97
internal/config/jwt.go Normal file
View File

@@ -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
}

82
internal/email/service.go Normal file
View File

@@ -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(`
<h2>Password Reset Request</h2>
<p>Hi %s,</p>
<p>We received a request to reset your password. Click the link below to create a new password:</p>
<p><a href="%s">Reset Password</a></p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, you can ignore this email.</p>
`, 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(`
<h2>Welcome to RideAware</h2>
<p>Hi %s,</p>
<p>Your account has been created successfully!</p>
<p>Start tracking your rides and improve your performance.</p>
`, 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
}

View File

@@ -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))
})
}

29
internal/profile/model.go Normal file
View File

@@ -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"`
}

93
internal/user/handler.go Normal file
View File

@@ -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,
})
}

105
internal/user/model.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

159
internal/user/service.go Normal file
View File

@@ -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
}

View File

@@ -1 +0,0 @@
Single-database configuration for Flask.

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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 ###

View File

@@ -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=""
)
)

View File

@@ -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')

View File

@@ -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)

43
pkg/database/db.go Normal file
View File

@@ -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()
}

26
pkg/errors/errors.go Normal file
View File

@@ -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", "")
)

16
pkg/utils/utils.go Normal file
View File

@@ -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})
}

View File

@@ -1,8 +0,0 @@
Flask
flask_bcrypt
flask_cors
flask_sqlalchemy
python-dotenv
werkzeug
psycopg2-binary
Flask-Migrate

View File

@@ -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

174
scripts/build.sh Executable file
View File

@@ -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}"

View File

@@ -1,8 +0,0 @@
#!/bin/bash
set -e
echo "Running database migrations..."
flask db upgrade
echo "Starting application..."
exec "$@"

84
scripts/test.sh Executable file
View File

@@ -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}"

View File

@@ -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()

View File

@@ -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