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
|
# 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
179
TODO.md
@@ -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**: Coach–athlete chat, comments on sessions, file attachments.
|
- [ ] **Messaging**: Coach–athlete 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
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