feat: migrate Flask API to Go with JWT auth
This commit is contained in:
53
Dockerfile
53
Dockerfile
@@ -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
325
README.md
@@ -1,22 +1,36 @@
|
||||
Here's a rewritten README for your Go version:
|
||||
|
||||
```markdown
|
||||
# RideAware API
|
||||
|
||||
<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.
|
||||
|
||||
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
|
||||
|
||||
@@ -27,65 +41,296 @@ Ensure you have the following installed on your system:
|
||||
cd rideaware-api
|
||||
```
|
||||
|
||||
2. **Create a Virtual Environment**
|
||||
It is recommended to use a Python virtual environment to isolate dependencies.
|
||||
2. **Install Dependencies**
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
go mod download
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
3. **Activate the Virtual Environment**
|
||||
- On Linux/Mac:
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
- On Windows:
|
||||
```cmd
|
||||
.venv\Scripts\activate
|
||||
3. **Configure Environment Variables**
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```env
|
||||
# Database
|
||||
PG_USER=postgres
|
||||
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**
|
||||
Install the required Python packages using pip:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
4. **Set Up the Database**
|
||||
|
||||
### Configuration
|
||||
|
||||
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**:
|
||||
Ensure PostgreSQL is running and create the database:
|
||||
|
||||
```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**
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
179
TODO.md
179
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.
|
||||
- [ ] **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
|
||||
93
cmd/server/main.go
Normal file
93
cmd/server/main.go
Normal 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
29
docker/Dockerfile
Normal 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
23
go.mod
Normal 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
42
go.sum
Normal 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
153
internal/auth/handler.go
Normal 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
97
internal/config/jwt.go
Normal 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
82
internal/email/service.go
Normal 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
|
||||
}
|
||||
65
internal/middleware/auth.go
Normal file
65
internal/middleware/auth.go
Normal 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
29
internal/profile/model.go
Normal 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
93
internal/user/handler.go
Normal 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
105
internal/user/model.go
Normal 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)
|
||||
}
|
||||
62
internal/user/repository.go
Normal file
62
internal/user/repository.go
Normal 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
159
internal/user/service.go
Normal 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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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=""
|
||||
)
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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
43
pkg/database/db.go
Normal 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
26
pkg/errors/errors.go
Normal 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
16
pkg/utils/utils.go
Normal 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})
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
Flask
|
||||
flask_bcrypt
|
||||
flask_cors
|
||||
flask_sqlalchemy
|
||||
python-dotenv
|
||||
werkzeug
|
||||
psycopg2-binary
|
||||
Flask-Migrate
|
||||
@@ -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
174
scripts/build.sh
Executable 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}"
|
||||
@@ -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
84
scripts/test.sh
Executable 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}"
|
||||
33
server.py
33
server.py
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user