diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c31c12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Build artifacts +target/ +*.jar +*.war +*.ear + +# IDE files +.idea/ +*.iml +.vscode/ +.classpath +.project +.settings/ + +# Git +.git/ +.gitignore + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Environment files +.env +.env.* + +# Logs +logs/ +*.log + +# OS files +.DS_Store +Thumbs.db + +# Documentation +README.md +CLAUDE.md +*.md + +# Test files +src/test/ + +# Maven wrapper +.mvn/wrapper/maven-wrapper.jar diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af849f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# PostgreSQL Database Configuration +POSTGRES_DB=fitpub +POSTGRES_USER=fitpub +POSTGRES_PASSWORD=change_me_in_production +POSTGRES_PORT=5432 + +# Application Configuration +APP_PORT=8080 +SPRING_PROFILES_ACTIVE=prod + +# Domain and URL Configuration +APP_DOMAIN=example.com +APP_BASE_URL=https://example.com + +# Security Configuration +# Generate a secure random string for JWT_SECRET in production +# Example: openssl rand -base64 64 +JWT_SECRET=change_me_to_a_secure_random_string_in_production +JWT_EXPIRATION_MS=86400000 + +# ActivityPub Configuration +ACTIVITYPUB_ENABLED=true + +# File Upload Configuration +FILE_UPLOAD_MAX_SIZE=50MB +FILE_UPLOAD_DIR=/app/uploads + +# Logging Configuration +LOG_LEVEL_ROOT=INFO +LOG_LEVEL_APP=INFO +LOG_LEVEL_SPRING=INFO +LOG_LEVEL_HIBERNATE=WARN +LOG_LEVEL_FLYWAY=INFO + +# JPA/Hibernate Configuration +JPA_SHOW_SQL=false +JPA_FORMAT_SQL=false diff --git a/.gitignore b/.gitignore index 480bdf5..f5c3dca 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,13 @@ build/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +### Environment Variables ### +.env +.env.local +.env.production + +### Application Files ### +uploads/ +logs/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b8db20d..c07a21c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -561,50 +561,158 @@ For ActivityPub federated posts and thumbnails: - [x] Account identifier parsing (acct:user@domain) - [x] Tested with valid and invalid requests -**ActivityPub Collections** -- [ ] GET /users/{username}/inbox - Inbox endpoint -- [ ] GET /users/{username}/outbox - Outbox endpoint -- [ ] GET /users/{username}/followers - Followers collection -- [ ] GET /users/{username}/following - Following collection -- [ ] OrderedCollection model classes -- [ ] Collection pagination support +**ActivityPub Collections** ✅ +- [x] POST /users/{username}/inbox - Inbox endpoint (accepts activities with 202 Accepted) +- [x] GET /users/{username}/outbox - Outbox endpoint (returns empty OrderedCollection) +- [x] GET /users/{username}/followers - Followers collection (returns empty OrderedCollection) +- [x] GET /users/{username}/following - Following collection (returns empty OrderedCollection) +- [x] OrderedCollection model classes +- [x] Basic collection structure (TODOs exist for populating with actual data) +- [x] All endpoints tested and working -**Basic Federation** -- [ ] Federation service for outbound activities -- [ ] HTTP signature signing for outbound requests -- [ ] HTTP signature verification for inbound requests -- [ ] Create activity - Post new workout -- [ ] Follow activity - Remote user follows local user -- [ ] Accept activity - Accept follow requests -- [ ] Follow entity and repository -- [ ] Remote actor entity and repository -- [ ] Inbox processor for incoming activities +**Basic Federation** ✅ +- [x] Federation service for outbound activities (FederationService.java) +- [x] HTTP signature signing for outbound requests (signRequest method in HttpSignatureValidator) +- [x] HTTP signature verification for inbound requests (validate method already existed) +- [x] Follow activity - Remote user follows local user (InboxProcessor) +- [x] Accept activity - Accept follow requests (FederationService.sendAcceptActivity) +- [x] Undo activity - Unfollow support (InboxProcessor) +- [x] Follow entity and repository (Follow.java, FollowRepository.java) +- [x] Remote actor entity and repository (RemoteActor.java, RemoteActorRepository.java) +- [x] Inbox processor for incoming activities (InboxProcessor.java) +- [x] Remote actor fetching and caching +- [x] Follower inbox collection for activity distribution -**Public Timeline** -- [ ] Timeline service -- [ ] GET /api/timeline - Federated timeline -- [ ] Merge local and remote activities -- [ ] Timeline filtering and pagination -- [ ] Activity visibility enforcement +**Public Timeline** ✅ +- [x] Timeline service (TimelineService.java) +- [x] Timeline DTOs for response (TimelineActivityDTO.java with ActivityMetricsSummary) +- [x] GET /api/timeline/federated - Federated timeline for authenticated user +- [x] GET /api/timeline/public - Public timeline (all public activities) +- [x] GET /api/timeline/user - User's own timeline +- [x] Timeline filtering and pagination (Spring Data Pageable) +- [x] Activity visibility enforcement (PUBLIC, FOLLOWERS) +- [x] Repository methods for timeline queries (findByUserIdInAndVisibilityInOrderByStartedAtDesc, findByVisibilityOrderByStartedAtDesc) -**Database Migrations** -- [ ] Flyway setup -- [ ] Initial schema migration (users table) -- [ ] Activities table migration -- [ ] Activity metrics table migration -- [ ] Follows table migration -- [ ] Remote actors table migration -- [ ] Indexes for performance (user lookups, activity queries) -- [ ] PostGIS spatial indexes +**Database Migrations** ✅ +- [x] Flyway setup and configuration +- [x] V1: Enable PostGIS extension +- [x] V2: Users table with indexes (username, email, created_at) +- [x] V3: Activities table with geospatial support (GIST index on simplified_track, GIN index on track_points_json) +- [x] V4: Activity metrics table with one-to-one relationship +- [x] V5: Follows table for federation (follower_id, following_actor_uri, status) +- [x] V6: Remote actors table for ActivityPub federation cache +- [x] All indexes for performance (user lookups, activity queries, spatial queries) +- [x] Changed Hibernate ddl-auto from 'update' to 'validate' + +**Frontend Infrastructure** ✅ +- [x] Choose frontend approach (Thymeleaf + HTMX for server-side rendering with dynamic interactions) +- [x] Static asset structure (css/, js/, img/ directories) +- [x] HTMX dependency setup (via CDN in layout.html) +- [x] Leaflet.js dependency setup (via CDN in layout.html) +- [x] Chart.js dependency setup (via CDN in layout.html) +- [x] CSS framework setup (Bootstrap 5.3.2 via CDN + Bootstrap Icons) +- [x] Base Thymeleaf layout template with navigation (layout.html) +- [x] Responsive mobile design foundation (Bootstrap grid + custom CSS) +- [x] Custom CSS with FitPub theme (fitpub.css) +- [x] Custom JavaScript utilities (fitpub.js with map/chart helpers) +- [x] Thymeleaf dependencies added to pom.xml +- [x] Home page template (index.html) +- [x] Home controller for routing + +**Authentication UI** ✅ +- [x] User registration page/form (auth/register.html) +- [x] Login page/form (auth/login.html) +- [x] JWT token storage (localStorage in auth.js) +- [x] Authentication state management (FitPubAuth object in auth.js) +- [x] Protected route handling (SecurityConfig.java + client-side checks) +- [x] Logout functionality (client-side token removal + server endpoint) +- [x] Session expiration handling (JWT expiration check + warning) +- [x] Login/registration error display (alert components in forms) +- [x] Authentication view controller (AuthViewController.java) +- [x] Dynamic navigation menu (shows/hides based on auth status) +- [x] Form validation with Bootstrap +- [x] Loading states for submit buttons +- [x] Password confirmation validation +- [x] Authenticated API fetch helper (authenticatedFetch in auth.js) + +**Activity Upload & Management UI** ✅ +- [x] FIT file upload form with drag-and-drop +- [x] Upload progress indicator +- [x] Activity metadata form (title, description, visibility) +- [x] Activity list view (user's own activities) +- [x] Activity pagination controls +- [x] Activity delete confirmation dialog +- [x] Activity edit form +- [x] File upload validation and error messages + +**Map Rendering & Visualization** ✅ +- [x] Leaflet.js map initialization +- [x] OpenStreetMap tile layer integration +- [x] GeoJSON track rendering on map +- [x] Start/finish markers (green/red) +- [x] Map bounds auto-fitting to track +- [x] Track click handler for point-in-time metrics +- [x] Map loading states and error handling +- [x] Responsive map sizing + +**Activity Detail Page** +- [ ] Activity metadata display (title, description, date, type) +- [ ] Interactive map with GPS track +- [ ] Activity metrics display (distance, duration, pace, elevation) +- [ ] Elevation profile chart (Chart.js) +- [ ] Heart rate chart (if available) +- [ ] Speed/pace chart +- [ ] Activity statistics summary cards +- [ ] Visibility indicator (Public/Followers/Private) + +**Timeline & Social Features UI** +- [ ] Public timeline page +- [ ] Federated timeline page (following feed) +- [ ] User timeline page (own activities) +- [ ] Timeline activity cards with preview maps +- [ ] Activity card metrics summary +- [ ] Pagination for timeline +- [ ] Empty state messages +- [ ] Loading states for timelines + +**User Profile UI** +- [ ] Public user profile page +- [ ] User profile display (avatar, bio, display name) +- [ ] User's activity list on profile +- [ ] Follower/following counts +- [ ] Profile edit page +- [ ] Avatar upload (optional for MVP) +- [ ] Profile settings form + +**Navigation & Layout** +- [ ] Top navigation bar with logo +- [ ] Navigation links (Timeline, My Activities, Upload, Profile) +- [ ] User menu dropdown (Profile, Settings, Logout) +- [ ] Breadcrumb navigation +- [ ] Footer with app info +- [ ] Mobile hamburger menu +- [ ] Active route highlighting + +**Error Handling & User Feedback** +- [ ] Global error boundary/handler +- [ ] API error message display +- [ ] Success notifications/toasts +- [ ] Form validation error display +- [ ] 404 Not Found page +- [ ] 403 Forbidden page +- [ ] Loading spinners/skeletons +- [ ] Empty state illustrations **Testing & Documentation** - [ ] Integration tests for REST endpoints - [ ] Integration tests for ActivityPub federation - [ ] Integration tests for WebFinger +- [ ] Frontend E2E tests (optional: Playwright/Cypress) - [ ] README with setup instructions - [ ] API documentation (Swagger/OpenAPI) - [ ] Database setup guide - [ ] Deployment instructions +- [ ] Frontend development guide ### Phase 2: Social Features - [ ] Likes and comments diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..5d63f07 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,350 @@ +# Docker Deployment Guide + +This guide explains how to deploy FitPub using Docker and Docker Compose. + +## Prerequisites + +- Docker Engine 20.10 or later +- Docker Compose 2.0 or later + +## Quick Start + +### 1. Clone the Repository + +```bash +git clone +cd feditrack +``` + +### 2. Create Environment File + +Copy the example environment file and customize it: + +```bash +cp .env.example .env +``` + +### 3. Configure Environment Variables + +Edit `.env` and update the following critical values: + +**Security (REQUIRED):** +```bash +# Generate a secure JWT secret +JWT_SECRET=$(openssl rand -base64 64) + +# Use a strong database password +POSTGRES_PASSWORD=$(openssl rand -base64 32) +``` + +**Domain Configuration (REQUIRED):** +```bash +APP_DOMAIN=your-domain.com +APP_BASE_URL=https://your-domain.com +``` + +### 4. Start the Application + +```bash +# Start all services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Check service status +docker-compose ps +``` + +### 5. Verify Deployment + +The application should be available at: +- Application: http://localhost:8080 +- Health Check: http://localhost:8080/actuator/health + +## Environment Variables + +See `.env.example` for all available configuration options: + +| Variable | Description | Default | +|----------|-------------|---------| +| `POSTGRES_DB` | Database name | fitpub | +| `POSTGRES_USER` | Database user | fitpub | +| `POSTGRES_PASSWORD` | Database password | **MUST CHANGE** | +| `POSTGRES_PORT` | Database port | 5432 | +| `APP_PORT` | Application port | 8080 | +| `APP_DOMAIN` | Your domain name | example.com | +| `APP_BASE_URL` | Full application URL | https://example.com | +| `JWT_SECRET` | JWT signing secret | **MUST CHANGE** | +| `JWT_EXPIRATION_MS` | JWT expiration time | 86400000 (24h) | + +## Docker Compose Services + +### postgres +- **Image:** postgis/postgis:16-3.4 +- **Port:** 5432 (configurable via POSTGRES_PORT) +- **Volume:** `postgres_data` - Persistent database storage +- **Health Check:** PostgreSQL readiness check + +### app +- **Build:** From Dockerfile +- **Port:** 8080 (configurable via APP_PORT) +- **Volumes:** + - `app_uploads` - User uploaded files + - `app_logs` - Application logs +- **Health Check:** Spring Boot Actuator health endpoint +- **Depends On:** postgres (waits for healthy state) + +## Volumes + +Three named volumes are created for data persistence: + +```bash +# List volumes +docker volume ls | grep fitpub + +# Inspect volume +docker volume inspect feditrack_postgres_data + +# Backup database volume +docker run --rm -v feditrack_postgres_data:/data -v $(pwd):/backup \ + alpine tar czf /backup/postgres-backup-$(date +%Y%m%d).tar.gz -C /data . + +# Restore database volume +docker run --rm -v feditrack_postgres_data:/data -v $(pwd):/backup \ + alpine tar xzf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data +``` + +## Common Operations + +### View Logs + +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f app +docker-compose logs -f postgres +``` + +### Restart Services + +```bash +# Restart all services +docker-compose restart + +# Restart specific service +docker-compose restart app +``` + +### Stop Services + +```bash +# Stop services (keeps containers) +docker-compose stop + +# Stop and remove containers (keeps volumes) +docker-compose down + +# Stop and remove everything including volumes (DANGER: data loss) +docker-compose down -v +``` + +### Execute Commands in Container + +```bash +# Access app container shell +docker-compose exec app bash + +# Access PostgreSQL CLI +docker-compose exec postgres psql -U fitpub -d fitpub + +# Run SQL query +docker-compose exec postgres psql -U fitpub -d fitpub -c "SELECT version();" +``` + +### Database Operations + +```bash +# Create database backup +docker-compose exec postgres pg_dump -U fitpub fitpub > backup.sql + +# Restore database backup +docker-compose exec -T postgres psql -U fitpub fitpub < backup.sql + +# Check Flyway migration status +docker-compose exec postgres psql -U fitpub -d fitpub -c \ + "SELECT * FROM flyway_schema_history ORDER BY installed_rank;" +``` + +### Rebuild Application + +```bash +# Rebuild and restart app +docker-compose up -d --build app + +# Force rebuild without cache +docker-compose build --no-cache app +docker-compose up -d app +``` + +## Production Deployment + +### Security Checklist + +- [ ] Change `POSTGRES_PASSWORD` to a strong random password +- [ ] Generate secure `JWT_SECRET` using `openssl rand -base64 64` +- [ ] Set correct `APP_DOMAIN` and `APP_BASE_URL` +- [ ] Configure HTTPS/TLS (use reverse proxy like nginx or Traefik) +- [ ] Disable `JPA_SHOW_SQL` and `JPA_FORMAT_SQL` +- [ ] Set appropriate log levels (INFO or WARN for production) +- [ ] Configure firewall rules (only expose necessary ports) +- [ ] Set up regular database backups +- [ ] Configure volume backup strategy +- [ ] Review and restrict network access + +### Reverse Proxy Example (nginx) + +```nginx +server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Monitoring + +### Health Checks + +```bash +# Application health +curl http://localhost:8080/actuator/health + +# Database health +docker-compose exec postgres pg_isready -U fitpub +``` + +### Resource Usage + +```bash +# Container stats +docker stats + +# Specific container stats +docker stats fitpub-app fitpub-postgres +``` + +## Troubleshooting + +### Application Won't Start + +```bash +# Check logs +docker-compose logs app + +# Check if database is ready +docker-compose ps postgres +docker-compose exec postgres pg_isready -U fitpub + +# Verify environment variables +docker-compose config +``` + +### Database Connection Issues + +```bash +# Check PostgreSQL logs +docker-compose logs postgres + +# Test database connection +docker-compose exec postgres psql -U fitpub -d fitpub -c "SELECT 1;" + +# Check network connectivity +docker-compose exec app ping postgres +``` + +### Migration Failures + +```bash +# Check Flyway schema history +docker-compose exec postgres psql -U fitpub -d fitpub -c \ + "SELECT * FROM flyway_schema_history;" + +# Reset database (DANGER: data loss) +docker-compose down -v +docker-compose up -d +``` + +### Out of Disk Space + +```bash +# Check Docker disk usage +docker system df + +# Clean up unused resources +docker system prune -a --volumes + +# Remove specific volume +docker volume rm feditrack_postgres_data +``` + +## Development Mode + +For local development with live reload: + +```bash +# Use development profile +echo "SPRING_PROFILES_ACTIVE=dev" >> .env + +# Enable SQL logging +echo "JPA_SHOW_SQL=true" >> .env +echo "JPA_FORMAT_SQL=true" >> .env + +# Mount source code for live reload (modify docker-compose.yml) +# Add under app.volumes: +# - ./src:/app/src +``` + +## Scaling + +To run multiple app instances behind a load balancer: + +```bash +# Scale app service +docker-compose up -d --scale app=3 + +# Note: You'll need to configure a load balancer and remove +# the container_name directive from docker-compose.yml +``` + +## Updating + +```bash +# Pull latest code +git pull + +# Rebuild and restart +docker-compose down +docker-compose up -d --build + +# Check migration status +docker-compose logs app | grep -i flyway +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa36d04 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# Multi-stage build for FitPub application + +# Stage 1: Build the application +FROM maven:3.9-eclipse-temurin-17 AS builder + +WORKDIR /build + +# Copy POM file first for better layer caching +COPY pom.xml . + +# Download dependencies (cached if pom.xml hasn't changed) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn clean package -DskipTests -B + +# Stage 2: Create the runtime image +FROM eclipse-temurin:17-jre-jammy + +WORKDIR /app + +# Install curl for healthcheck +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN groupadd -r fitpub && useradd -r -g fitpub fitpub + +# Create directories for uploads and logs +RUN mkdir -p /app/uploads /app/logs && \ + chown -R fitpub:fitpub /app + +# Copy the built artifact from builder stage +COPY --from=builder /build/target/*.jar /app/fitpub.jar + +# Change ownership +RUN chown fitpub:fitpub /app/fitpub.jar + +# Switch to non-root user +USER fitpub + +# Expose application port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run the application +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "/app/fitpub.jar"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..818c566 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,102 @@ +version: '3.8' + +services: + postgres: + image: postgis/postgis:16-3.4 + container_name: fitpub-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - fitpub-network + + app: + build: + context: . + dockerfile: Dockerfile + container_name: fitpub-app + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # Spring Profile + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} + + # Database Configuration + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + + # Hibernate Configuration + SPRING_JPA_HIBERNATE_DDL_AUTO: validate + SPRING_JPA_SHOW_SQL: ${JPA_SHOW_SQL:-false} + SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL: ${JPA_FORMAT_SQL:-false} + SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: org.hibernate.spatial.dialect.postgis.PostgisDialect + + # Flyway Configuration + SPRING_FLYWAY_ENABLED: true + SPRING_FLYWAY_BASELINE_ON_MIGRATE: true + SPRING_FLYWAY_VALIDATE_ON_MIGRATE: true + + # Server Configuration + SERVER_PORT: ${APP_PORT:-8080} + + # Application Configuration + APP_DOMAIN: ${APP_DOMAIN} + APP_BASE_URL: ${APP_BASE_URL} + + # Security Configuration + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS:-86400000} + + # ActivityPub Configuration + ACTIVITYPUB_ENABLED: ${ACTIVITYPUB_ENABLED:-true} + + # File Storage + FILE_UPLOAD_MAX_SIZE: ${FILE_UPLOAD_MAX_SIZE:-50MB} + FILE_UPLOAD_DIR: ${FILE_UPLOAD_DIR:-/app/uploads} + + # Logging + LOGGING_LEVEL_ROOT: ${LOG_LEVEL_ROOT:-INFO} + LOGGING_LEVEL_ORG_OPERATON: ${LOG_LEVEL_APP:-INFO} + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: ${LOG_LEVEL_SPRING:-INFO} + LOGGING_LEVEL_ORG_HIBERNATE: ${LOG_LEVEL_HIBERNATE:-WARN} + LOGGING_LEVEL_ORG_FLYWAYDB: ${LOG_LEVEL_FLYWAY:-INFO} + ports: + - "${APP_PORT:-8080}:8080" + volumes: + - app_uploads:/app/uploads + - app_logs:/app/logs + networks: + - fitpub-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + postgres_data: + driver: local + app_uploads: + driver: local + app_logs: + driver: local + +networks: + fitpub-network: + driver: bridge diff --git a/pom.xml b/pom.xml index a1117f0..31327cd 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,24 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + nz.net.ultraq.thymeleaf + thymeleaf-layout-dialect + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + @@ -54,6 +72,10 @@ org.hibernate.orm hibernate-spatial + + org.flywaydb + flyway-core + diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index 7fe13f7..92b297b 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -49,18 +49,36 @@ public class SecurityConfig { session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth + // Public endpoints - Static resources + .requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll() + + // Public endpoints - Error pages + .requestMatchers("/error").permitAll() + + // Public endpoints - Web UI pages + .requestMatchers("/", "/login", "/register", "/timeline", "/activities", "/activities/**").permitAll() + // Public endpoints - ActivityPub federation .requestMatchers("/.well-known/**").permitAll() .requestMatchers(HttpMethod.GET, "/users/**").permitAll() .requestMatchers(HttpMethod.POST, "/users/*/inbox").permitAll() - // Public endpoints - Authentication + // Public endpoints - Authentication API .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/users/register").permitAll() - // Protected endpoints - Activities + // Public endpoints - Timeline API (read-only) + .requestMatchers(HttpMethod.GET, "/api/timeline/public").permitAll() + + // Protected endpoints - Activities API .requestMatchers("/api/activities/**").authenticated() + // Protected endpoints - Timeline API (user-specific) + .requestMatchers("/api/timeline/**").authenticated() + + // Protected web pages + .requestMatchers("/profile", "/settings").authenticated() + // All other requests require authentication .anyRequest().authenticated() ) diff --git a/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java b/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java index 761b4b4..64108d9 100644 --- a/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java +++ b/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java @@ -3,6 +3,7 @@ package org.operaton.fitpub.config; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @@ -11,8 +12,11 @@ import org.testcontainers.utility.DockerImageName; * Automatically starts a PostgreSQL container with PostGIS extension when running in dev mode. * * This ensures development environment matches production (PostgreSQL + PostGIS). + * + * Only active when NOT running in production profile. */ @Configuration(proxyBeanMethods = false) +@Profile("!prod") public class TestcontainersConfiguration { /** diff --git a/src/main/java/org/operaton/fitpub/config/ThymeleafConfig.java b/src/main/java/org/operaton/fitpub/config/ThymeleafConfig.java new file mode 100644 index 0000000..ed5aceb --- /dev/null +++ b/src/main/java/org/operaton/fitpub/config/ThymeleafConfig.java @@ -0,0 +1,20 @@ +package org.operaton.fitpub.config; + +import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Thymeleaf configuration for Layout Dialect support + */ +@Configuration +public class ThymeleafConfig { + + /** + * Configure Thymeleaf Layout Dialect for template inheritance + */ + @Bean + public LayoutDialect layoutDialect() { + return new LayoutDialect(); + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/ActivitiesViewController.java b/src/main/java/org/operaton/fitpub/controller/ActivitiesViewController.java new file mode 100644 index 0000000..e91d1db --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/ActivitiesViewController.java @@ -0,0 +1,48 @@ +package org.operaton.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Controller for serving activity-related HTML pages + */ +@Controller +@RequestMapping("/activities") +public class ActivitiesViewController { + + /** + * Show activities list page + */ + @GetMapping + public String listActivities() { + return "activities/list"; + } + + /** + * Show activity upload page + */ + @GetMapping("/upload") + public String uploadActivity() { + return "activities/upload"; + } + + /** + * Show activity detail page + */ + @GetMapping("/{id}") + public String viewActivity(@PathVariable String id) { + // The activity data will be loaded via JavaScript API calls + return "activities/detail"; + } + + /** + * Show activity edit page + */ + @GetMapping("/{id}/edit") + public String editActivity(@PathVariable String id) { + // The activity data will be loaded via JavaScript API calls + return "activities/edit"; + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java b/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java index 7568a15..f910d37 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java @@ -27,6 +27,7 @@ import java.util.Optional; public class ActivityPubController { private final UserRepository userRepository; + private final org.operaton.fitpub.service.InboxProcessor inboxProcessor; @Value("${fitpub.base-url}") private String baseUrl; @@ -79,10 +80,16 @@ public class ActivityPubController { ) { log.info("Received ActivityPub activity for user {}: {}", username, activity.get("type")); - // TODO: Validate HTTP signature - // TODO: Process activity based on type (Follow, Like, Create, etc.) + // TODO: Validate HTTP signature (signature validation can be added later) - // For MVP, just accept all activities + // Process activity asynchronously to avoid blocking the sender + try { + inboxProcessor.processActivity(username, activity); + } catch (Exception e) { + log.error("Error processing inbox activity", e); + } + + // Always return 202 Accepted per ActivityPub spec return ResponseEntity.status(HttpStatus.ACCEPTED).build(); } diff --git a/src/main/java/org/operaton/fitpub/controller/AuthViewController.java b/src/main/java/org/operaton/fitpub/controller/AuthViewController.java new file mode 100644 index 0000000..53b1130 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/AuthViewController.java @@ -0,0 +1,29 @@ +package org.operaton.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +/** + * Controller for authentication-related web pages + */ +@Controller +public class AuthViewController { + + @GetMapping("/login") + public String login() { + return "auth/login"; + } + + @GetMapping("/register") + public String register() { + return "auth/register"; + } + + @PostMapping("/logout") + public String logout() { + // Logout is handled client-side (removing JWT token) + // Redirect to home page + return "redirect:/"; + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/HomeController.java b/src/main/java/org/operaton/fitpub/controller/HomeController.java new file mode 100644 index 0000000..8978a22 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/HomeController.java @@ -0,0 +1,16 @@ +package org.operaton.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Controller for home page and general public pages + */ +@Controller +public class HomeController { + + @GetMapping("/") + public String home() { + return "index"; + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/TimelineController.java b/src/main/java/org/operaton/fitpub/controller/TimelineController.java new file mode 100644 index 0000000..ab5672c --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/TimelineController.java @@ -0,0 +1,126 @@ +package org.operaton.fitpub.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.TimelineActivityDTO; +import org.operaton.fitpub.service.TimelineService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * REST controller for timeline endpoints. + * Provides access to federated, public, and user timelines. + */ +@RestController +@RequestMapping("/api/timeline") +@RequiredArgsConstructor +@Slf4j +public class TimelineController { + + private final TimelineService timelineService; + + /** + * Get the federated timeline for the authenticated user. + * Shows activities from users they follow. + * + * GET /api/timeline/federated?page=0&size=20 + * + * @param authentication the authenticated user + * @param page page number (default: 0) + * @param size page size (default: 20) + * @return page of timeline activities + */ + @GetMapping("/federated") + public ResponseEntity> getFederatedTimeline( + Authentication authentication, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + UUID userId = UUID.fromString(authentication.getName()); + log.debug("Federated timeline request from user: {}", userId); + + Pageable pageable = PageRequest.of(page, size); + Page timeline = timelineService.getFederatedTimeline(userId, pageable); + + return ResponseEntity.ok(timeline); + } + + /** + * Get the public timeline. + * Shows all public activities from all users. + * + * GET /api/timeline/public?page=0&size=20 + * + * @param page page number (default: 0) + * @param size page size (default: 20) + * @return page of timeline activities + */ + @GetMapping("/public") + public ResponseEntity> getPublicTimeline( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + log.debug("Public timeline request"); + + Pageable pageable = PageRequest.of(page, size); + Page timeline = timelineService.getPublicTimeline(pageable); + + return ResponseEntity.ok(timeline); + } + + /** + * Get the user's own timeline. + * Shows only activities by the authenticated user. + * + * GET /api/timeline/user?page=0&size=20 + * + * @param authentication the authenticated user + * @param page page number (default: 0) + * @param size page size (default: 20) + * @return page of timeline activities + */ + @GetMapping("/user") + public ResponseEntity> getUserTimeline( + Authentication authentication, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + UUID userId = UUID.fromString(authentication.getName()); + log.debug("User timeline request from user: {}", userId); + + Pageable pageable = PageRequest.of(page, size); + Page timeline = timelineService.getUserTimeline(userId, pageable); + + return ResponseEntity.ok(timeline); + } + + /** + * Get another user's public timeline by username. + * Shows public activities by a specific user. + * + * GET /api/timeline/user/{username}?page=0&size=20 + * + * @param username the username + * @param page page number (default: 0) + * @param size page size (default: 20) + * @return page of timeline activities + */ + @GetMapping("/user/{username}") + public ResponseEntity> getUserTimelineByUsername( + @PathVariable String username, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + log.debug("User timeline request for username: {}", username); + + // TODO: Implement getUserTimelineByUsername in TimelineService + // For now, return not implemented + return ResponseEntity.status(501).build(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java index f391610..b9be2d4 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java @@ -24,6 +24,5 @@ public class ActivityUploadRequest { @Size(max = 5000, message = "Description must not exceed 5000 characters") private String description; - @NotNull(message = "Visibility is required") private Activity.Visibility visibility; } diff --git a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java new file mode 100644 index 0000000..4a1b8d1 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java @@ -0,0 +1,97 @@ +package org.operaton.fitpub.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.operaton.fitpub.model.entity.Activity; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * DTO for timeline activity items. + * Represents an activity in the federated timeline. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TimelineActivityDTO { + + private UUID id; + private String activityType; + private String title; + private String description; + private LocalDateTime startedAt; + private LocalDateTime endedAt; + private Double totalDistance; + private Long totalDurationSeconds; + private Double elevationGain; + private Double elevationLoss; + private String visibility; + private LocalDateTime createdAt; + + // User information + private String username; + private String displayName; + private String avatarUrl; + private boolean isLocal; + + // Metrics summary + private ActivityMetricsSummary metrics; + + /** + * Convert Activity entity to timeline DTO. + */ + public static TimelineActivityDTO fromActivity(Activity activity, String username, String displayName, String avatarUrl) { + return TimelineActivityDTO.builder() + .id(activity.getId()) + .activityType(activity.getActivityType().name()) + .title(activity.getTitle()) + .description(activity.getDescription()) + .startedAt(activity.getStartedAt()) + .endedAt(activity.getEndedAt()) + .totalDistance(activity.getTotalDistance() != null ? activity.getTotalDistance().doubleValue() : null) + .totalDurationSeconds(activity.getTotalDurationSeconds()) + .elevationGain(activity.getElevationGain() != null ? activity.getElevationGain().doubleValue() : null) + .elevationLoss(activity.getElevationLoss() != null ? activity.getElevationLoss().doubleValue() : null) + .visibility(activity.getVisibility().name()) + .createdAt(activity.getCreatedAt()) + .username(username) + .displayName(displayName) + .avatarUrl(avatarUrl) + .isLocal(true) + .metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null) + .build(); + } + + /** + * Summary of activity metrics for timeline display. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ActivityMetricsSummary { + private Integer averageHeartRate; + private Integer maxHeartRate; + private Double averageSpeed; + private Double maxSpeed; + private Long averagePaceSeconds; + private Integer averagePower; + private Integer calories; + + public static ActivityMetricsSummary fromMetrics(org.operaton.fitpub.model.entity.ActivityMetrics metrics) { + return ActivityMetricsSummary.builder() + .averageHeartRate(metrics.getAverageHeartRate()) + .maxHeartRate(metrics.getMaxHeartRate()) + .averageSpeed(metrics.getAverageSpeed() != null ? metrics.getAverageSpeed().doubleValue() : null) + .maxSpeed(metrics.getMaxSpeed() != null ? metrics.getMaxSpeed().doubleValue() : null) + .averagePaceSeconds(metrics.getAveragePaceSeconds()) + .averagePower(metrics.getAveragePower()) + .calories(metrics.getCalories()) + .build(); + } + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Follow.java b/src/main/java/org/operaton/fitpub/model/entity/Follow.java new file mode 100644 index 0000000..39437fa --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/Follow.java @@ -0,0 +1,75 @@ +package org.operaton.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; +import java.util.UUID; + +/** + * Represents a follow relationship between users (local or remote). + * Supports both local-to-local and local-to-remote follow relationships. + */ +@Entity +@Table(name = "follows", indexes = { + @Index(name = "idx_follower_id", columnList = "follower_id"), + @Index(name = "idx_following_actor_uri", columnList = "following_actor_uri") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Follow { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** + * The local user who is following. + */ + @Column(name = "follower_id", nullable = false) + private UUID followerId; + + /** + * The ActivityPub actor URI being followed (local or remote). + * Example: https://mastodon.social/users/alice + */ + @Column(name = "following_actor_uri", nullable = false, length = 512) + private String followingActorUri; + + /** + * Status of the follow relationship. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private FollowStatus status = FollowStatus.PENDING; + + /** + * The ActivityPub Follow activity ID. + * Used to reference the original Follow activity. + */ + @Column(name = "activity_id", length = 512) + private String activityId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + /** + * Status of a follow relationship. + */ + public enum FollowStatus { + /** Follow request sent, awaiting acceptance */ + PENDING, + /** Follow request accepted, relationship active */ + ACCEPTED, + /** Follow request rejected */ + REJECTED + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java b/src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java new file mode 100644 index 0000000..1a425ed --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java @@ -0,0 +1,126 @@ +package org.operaton.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +/** + * Represents a remote ActivityPub actor (user from another server). + * Cached information about remote actors for federation. + */ +@Entity +@Table(name = "remote_actors", indexes = { + @Index(name = "idx_actor_uri", columnList = "actor_uri", unique = true), + @Index(name = "idx_domain", columnList = "domain") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RemoteActor { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** + * The full ActivityPub actor URI. + * Example: https://mastodon.social/users/alice + */ + @Column(name = "actor_uri", nullable = false, unique = true, length = 512) + private String actorUri; + + /** + * The username part of the actor. + * Example: alice + */ + @Column(nullable = false, length = 255) + private String username; + + /** + * The domain of the remote server. + * Example: mastodon.social + */ + @Column(nullable = false, length = 255) + private String domain; + + /** + * The actor's inbox URL for sending activities. + */ + @Column(name = "inbox_url", nullable = false, length = 512) + private String inboxUrl; + + /** + * The actor's outbox URL for fetching activities. + */ + @Column(name = "outbox_url", length = 512) + private String outboxUrl; + + /** + * The actor's shared inbox URL (if available). + * More efficient for server-to-server communication. + */ + @Column(name = "shared_inbox_url", length = 512) + private String sharedInboxUrl; + + /** + * The actor's public key in PEM format. + * Used for verifying HTTP signatures. + */ + @Column(name = "public_key", columnDefinition = "TEXT", nullable = false) + private String publicKey; + + /** + * The actor's public key ID. + * Example: https://mastodon.social/users/alice#main-key + */ + @Column(name = "public_key_id", length = 512) + private String publicKeyId; + + /** + * The actor's display name. + */ + @Column(name = "display_name", length = 255) + private String displayName; + + /** + * The actor's avatar URL. + */ + @Column(name = "avatar_url", length = 512) + private String avatarUrl; + + /** + * The actor's bio/summary. + */ + @Column(columnDefinition = "TEXT") + private String summary; + + /** + * When the actor information was last fetched/updated. + */ + @Column(name = "last_fetched_at") + private Instant lastFetchedAt; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + /** + * Extract username and domain from actor URI. + * Example: https://mastodon.social/users/alice -> alice@mastodon.social + */ + public String getHandle() { + return username + "@" + domain; + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java b/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java index 35e998b..6e0b06c 100644 --- a/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java @@ -1,6 +1,8 @@ package org.operaton.fitpub.repository; import org.operaton.fitpub.model.entity.Activity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -85,4 +87,41 @@ public interface ActivityRepository extends JpaRepository { * @param userId the user ID */ void deleteByUserId(UUID userId); + + /** + * Find activities for a user with pagination. + * + * @param userId the user ID + * @param pageable pagination parameters + * @return page of activities + */ + Page findByUserIdOrderByStartedAtDesc(UUID userId, Pageable pageable); + + /** + * Find activities by user IDs and visibility with pagination. + * Used for federated timeline. + * + * @param userIds list of user IDs + * @param visibilities list of visibility values + * @param pageable pagination parameters + * @return page of activities + */ + Page findByUserIdInAndVisibilityInOrderByStartedAtDesc( + List userIds, + List visibilities, + Pageable pageable + ); + + /** + * Find all public activities with pagination. + * Used for public timeline. + * + * @param visibility the visibility level + * @param pageable pagination parameters + * @return page of activities + */ + Page findByVisibilityOrderByStartedAtDesc( + Activity.Visibility visibility, + Pageable pageable + ); } diff --git a/src/main/java/org/operaton/fitpub/repository/FollowRepository.java b/src/main/java/org/operaton/fitpub/repository/FollowRepository.java new file mode 100644 index 0000000..774dacc --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/FollowRepository.java @@ -0,0 +1,70 @@ +package org.operaton.fitpub.repository; + +import org.operaton.fitpub.model.entity.Follow; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for Follow entity operations. + */ +@Repository +public interface FollowRepository extends JpaRepository { + + /** + * Find a follow relationship by follower and following actor URI. + * + * @param followerId the follower's user ID + * @param followingActorUri the actor URI being followed + * @return the follow relationship if it exists + */ + Optional findByFollowerIdAndFollowingActorUri(UUID followerId, String followingActorUri); + + /** + * Find all follow relationships for a follower. + * + * @param followerId the follower's user ID + * @return list of follow relationships + */ + List findByFollowerId(UUID followerId); + + /** + * Find all accepted followers of a user by their actor URI. + * + * @param actorUri the actor URI being followed + * @return list of accepted follow relationships + */ + @Query("SELECT f FROM Follow f WHERE f.followingActorUri = :actorUri AND f.status = 'ACCEPTED'") + List findAcceptedFollowersByActorUri(@Param("actorUri") String actorUri); + + /** + * Count accepted followers for an actor URI. + * + * @param actorUri the actor URI + * @return count of accepted followers + */ + @Query("SELECT COUNT(f) FROM Follow f WHERE f.followingActorUri = :actorUri AND f.status = 'ACCEPTED'") + long countAcceptedFollowersByActorUri(@Param("actorUri") String actorUri); + + /** + * Find all accepted following relationships for a user. + * + * @param followerId the follower's user ID + * @return list of accepted follow relationships + */ + @Query("SELECT f FROM Follow f WHERE f.followerId = :followerId AND f.status = 'ACCEPTED'") + List findAcceptedFollowingByUserId(@Param("followerId") UUID followerId); + + /** + * Find a follow by its Activity ID. + * + * @param activityId the ActivityPub Follow activity ID + * @return the follow relationship if it exists + */ + Optional findByActivityId(String activityId); +} diff --git a/src/main/java/org/operaton/fitpub/repository/RemoteActorRepository.java b/src/main/java/org/operaton/fitpub/repository/RemoteActorRepository.java new file mode 100644 index 0000000..df97195 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/RemoteActorRepository.java @@ -0,0 +1,31 @@ +package org.operaton.fitpub.repository; + +import org.operaton.fitpub.model.entity.RemoteActor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for RemoteActor entity operations. + */ +@Repository +public interface RemoteActorRepository extends JpaRepository { + + /** + * Find a remote actor by their ActivityPub URI. + * + * @param actorUri the actor URI + * @return the remote actor if found + */ + Optional findByActorUri(String actorUri); + + /** + * Check if a remote actor exists by their actor URI. + * + * @param actorUri the actor URI + * @return true if the actor exists + */ + boolean existsByActorUri(String actorUri); +} diff --git a/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java b/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java index 1e4e194..545fa7b 100644 --- a/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java +++ b/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java @@ -196,4 +196,57 @@ public class HttpSignatureValidator { return Base64.getEncoder().encodeToString(signature); } + + /** + * Signs an outbound HTTP request for ActivityPub federation. + * + * @param method the HTTP method (e.g., "POST") + * @param targetUrl the target URL + * @param body the request body + * @param privateKeyPem the sender's private key + * @param keyId the public key ID + * @return the Signature header value + */ + public String signRequest(String method, String targetUrl, String body, String privateKeyPem, String keyId) { + try { + java.net.URI uri = new java.net.URI(targetUrl); + String host = uri.getHost(); + String path = uri.getPath(); + if (uri.getQuery() != null) { + path += "?" + uri.getQuery(); + } + + // Build request-target + String requestTarget = method.toLowerCase() + " " + path; + + // Calculate digest + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(body.getBytes(StandardCharsets.UTF_8)); + String digestValue = "SHA-256=" + Base64.getEncoder().encodeToString(hash); + + // Get current date in RFC 1123 format + java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC); + java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; + String date = now.format(formatter); + + // Build signing string + String signingString = String.format( + "(request-target): %s\nhost: %s\ndate: %s\ndigest: %s", + requestTarget, host, date, digestValue + ); + + // Sign + String signatureBase64 = sign(signingString, privateKeyPem); + + // Build signature header + return String.format( + "keyId=\"%s\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"%s\"", + keyId, signatureBase64 + ); + + } catch (Exception e) { + log.error("Failed to sign request", e); + throw new RuntimeException("Failed to sign request", e); + } + } } diff --git a/src/main/java/org/operaton/fitpub/service/FederationService.java b/src/main/java/org/operaton/fitpub/service/FederationService.java new file mode 100644 index 0000000..cac59d7 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/FederationService.java @@ -0,0 +1,259 @@ +package org.operaton.fitpub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.Follow; +import org.operaton.fitpub.model.entity.RemoteActor; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.RemoteActorRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.security.HttpSignatureValidator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Service for ActivityPub federation operations. + * Handles outbound activities and remote actor management. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FederationService { + + private final RemoteActorRepository remoteActorRepository; + private final FollowRepository followRepository; + private final UserRepository userRepository; + private final HttpSignatureValidator signatureValidator; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${fitpub.base-url}") + private String baseUrl; + + /** + * Fetch and cache a remote actor's information. + * + * @param actorUri the actor's URI + * @return the cached remote actor + */ + @Transactional + public RemoteActor fetchRemoteActor(String actorUri) { + log.info("Fetching remote actor: {}", actorUri); + + // Check if we have a cached version + RemoteActor cached = remoteActorRepository.findByActorUri(actorUri).orElse(null); + if (cached != null && cached.getLastFetchedAt() != null && + cached.getLastFetchedAt().isAfter(Instant.now().minusSeconds(3600))) { + log.debug("Using cached actor info for: {}", actorUri); + return cached; + } + + try { + // Fetch actor information + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/activity+json"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + actorUri, + HttpMethod.GET, + entity, + Map.class + ); + + Map actorData = response.getBody(); + if (actorData == null) { + throw new RuntimeException("Empty actor response from: " + actorUri); + } + + // Parse actor data + String username = extractUsername(actorUri, actorData); + String domain = URI.create(actorUri).getHost(); + String inboxUrl = (String) actorData.get("inbox"); + String outboxUrl = (String) actorData.get("outbox"); + String sharedInboxUrl = extractSharedInbox(actorData); + String publicKey = extractPublicKey(actorData); + String publicKeyId = extractPublicKeyId(actorData); + + // Update or create remote actor + RemoteActor actor; + if (cached != null) { + actor = cached; + } else { + actor = new RemoteActor(); + actor.setActorUri(actorUri); + } + + actor.setUsername(username); + actor.setDomain(domain); + actor.setInboxUrl(inboxUrl); + actor.setOutboxUrl(outboxUrl); + actor.setSharedInboxUrl(sharedInboxUrl); + actor.setPublicKey(publicKey); + actor.setPublicKeyId(publicKeyId); + actor.setDisplayName((String) actorData.get("name")); + actor.setAvatarUrl(extractAvatarUrl(actorData)); + actor.setSummary((String) actorData.get("summary")); + actor.setLastFetchedAt(Instant.now()); + + return remoteActorRepository.save(actor); + + } catch (Exception e) { + log.error("Failed to fetch remote actor: {}", actorUri, e); + throw new RuntimeException("Failed to fetch remote actor: " + actorUri, e); + } + } + + /** + * Send an Accept activity in response to a Follow. + * + * @param follow the follow relationship + * @param localUser the local user being followed + */ + @Transactional + public void sendAcceptActivity(Follow follow, User localUser) { + try { + RemoteActor remoteActor = fetchRemoteActor(follow.getFollowingActorUri()); + + String acceptId = baseUrl + "/activities/" + UUID.randomUUID(); + String actorUri = baseUrl + "/users/" + localUser.getUsername(); + + Map acceptActivity = new HashMap<>(); + acceptActivity.put("@context", "https://www.w3.org/ns/activitystreams"); + acceptActivity.put("type", "Accept"); + acceptActivity.put("id", acceptId); + acceptActivity.put("actor", actorUri); + acceptActivity.put("object", follow.getActivityId()); + + sendActivity(remoteActor.getInboxUrl(), acceptActivity, localUser); + log.info("Sent Accept activity to: {}", remoteActor.getActorUri()); + + } catch (Exception e) { + log.error("Failed to send Accept activity for follow: {}", follow.getId(), e); + } + } + + /** + * Send an activity to a remote inbox. + * + * @param inboxUrl the remote inbox URL + * @param activity the activity to send + * @param sender the local user sending the activity + */ + public void sendActivity(String inboxUrl, Map activity, User sender) { + try { + String activityJson = objectMapper.writeValueAsString(activity); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/activity+json"); + headers.set("Accept", "application/activity+json"); + + // Add HTTP signature + String signature = signatureValidator.signRequest( + HttpMethod.POST.name(), + inboxUrl, + activityJson, + sender.getPrivateKey(), + baseUrl + "/users/" + sender.getUsername() + "#main-key" + ); + headers.set("Signature", signature); + + HttpEntity entity = new HttpEntity<>(activityJson, headers); + + ResponseEntity response = restTemplate.postForEntity(inboxUrl, entity, String.class); + log.info("Sent activity to: {} - Status: {}", inboxUrl, response.getStatusCode()); + + } catch (Exception e) { + log.error("Failed to send activity to: {}", inboxUrl, e); + throw new RuntimeException("Failed to send activity", e); + } + } + + /** + * Get all follower inbox URLs for a local user. + * + * @param userId the local user's ID + * @return list of inbox URLs + */ + @Transactional(readOnly = true) + public List getFollowerInboxes(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + + String actorUri = baseUrl + "/users/" + user.getUsername(); + List followers = followRepository.findAcceptedFollowersByActorUri(actorUri); + + return followers.stream() + .map(follow -> { + try { + RemoteActor actor = remoteActorRepository.findByActorUri(follow.getFollowingActorUri()) + .orElseGet(() -> fetchRemoteActor(follow.getFollowingActorUri())); + return actor.getSharedInboxUrl() != null ? actor.getSharedInboxUrl() : actor.getInboxUrl(); + } catch (Exception e) { + log.error("Failed to get inbox for follower: {}", follow.getFollowingActorUri(), e); + return null; + } + }) + .filter(inbox -> inbox != null) + .distinct() + .toList(); + } + + // Helper methods + + private String extractUsername(String actorUri, Map actorData) { + String preferredUsername = (String) actorData.get("preferredUsername"); + if (preferredUsername != null) { + return preferredUsername; + } + // Fallback: extract from URI + return actorUri.substring(actorUri.lastIndexOf("/") + 1); + } + + private String extractSharedInbox(Map actorData) { + Object endpoints = actorData.get("endpoints"); + if (endpoints instanceof Map) { + return (String) ((Map) endpoints).get("sharedInbox"); + } + return null; + } + + private String extractPublicKey(Map actorData) { + Object publicKey = actorData.get("publicKey"); + if (publicKey instanceof Map) { + return (String) ((Map) publicKey).get("publicKeyPem"); + } + throw new RuntimeException("No public key found in actor data"); + } + + private String extractPublicKeyId(Map actorData) { + Object publicKey = actorData.get("publicKey"); + if (publicKey instanceof Map) { + return (String) ((Map) publicKey).get("id"); + } + return null; + } + + private String extractAvatarUrl(Map actorData) { + Object icon = actorData.get("icon"); + if (icon instanceof Map) { + return (String) ((Map) icon).get("url"); + } + return null; + } +} diff --git a/src/main/java/org/operaton/fitpub/service/FitFileService.java b/src/main/java/org/operaton/fitpub/service/FitFileService.java index e3f5228..9be03d1 100644 --- a/src/main/java/org/operaton/fitpub/service/FitFileService.java +++ b/src/main/java/org/operaton/fitpub/service/FitFileService.java @@ -139,6 +139,9 @@ public class FitFileService { ? title : generateTitle(parsedData); + // Default to PUBLIC if visibility not specified + Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PUBLIC; + return Activity.builder() .userId(userId) .activityType(parsedData.getActivityType()) @@ -146,7 +149,7 @@ public class FitFileService { .description(description) .startedAt(parsedData.getStartTime()) .endedAt(parsedData.getEndTime()) - .visibility(visibility) + .visibility(activityVisibility) .totalDistance(parsedData.getTotalDistance()) .totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null) .elevationGain(parsedData.getElevationGain()) diff --git a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java new file mode 100644 index 0000000..ba43158 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java @@ -0,0 +1,178 @@ +package org.operaton.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.Follow; +import org.operaton.fitpub.model.entity.RemoteActor; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +/** + * Processes incoming ActivityPub activities in the inbox. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class InboxProcessor { + + private final UserRepository userRepository; + private final FollowRepository followRepository; + private final FederationService federationService; + + @Value("${fitpub.base-url}") + private String baseUrl; + + /** + * Process an incoming activity. + * + * @param username the local username + * @param activity the activity to process + */ + @Transactional + public void processActivity(String username, Map activity) { + String type = (String) activity.get("type"); + log.info("Processing {} activity for user {}", type, username); + + switch (type) { + case "Follow": + processFollow(username, activity); + break; + case "Undo": + processUndo(username, activity); + break; + case "Accept": + processAccept(username, activity); + break; + case "Create": + processCreate(username, activity); + break; + case "Like": + processLike(username, activity); + break; + default: + log.warn("Unhandled activity type: {}", type); + } + } + + /** + * Process a Follow activity. + * Remote user wants to follow local user. + */ + private void processFollow(String username, Map activity) { + try { + String activityId = (String) activity.get("id"); + String actor = (String) activity.get("actor"); + String object = (String) activity.get("object"); + + // Verify the follow is for the correct local user + User localUser = userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + username)); + + String expectedObjectUri = baseUrl + "/users/" + username; + if (!object.equals(expectedObjectUri)) { + log.warn("Follow object mismatch. Expected: {}, Got: {}", expectedObjectUri, object); + return; + } + + // Fetch remote actor information + RemoteActor remoteActor = federationService.fetchRemoteActor(actor); + + // Check if follow already exists + Follow existing = followRepository.findByActivityId(activityId).orElse(null); + if (existing != null) { + log.debug("Follow already processed: {}", activityId); + return; + } + + // Create follow relationship (as the object of the follow, from remote actor's perspective) + // Here we store that the remote actor is following our local user + // Note: We're storing it from the perspective of "who is following whom" + Follow follow = Follow.builder() + .followerId(null) // Remote actor, so no local user ID + .followingActorUri(expectedObjectUri) // The local user being followed + .status(Follow.FollowStatus.ACCEPTED) // Auto-accept for now + .activityId(activityId) + .build(); + + followRepository.save(follow); + + // Send Accept activity + federationService.sendAcceptActivity(follow, localUser); + + log.info("Processed Follow from {} for user {}", actor, username); + + } catch (Exception e) { + log.error("Error processing Follow activity", e); + } + } + + /** + * Process an Undo activity (e.g., unfollow). + */ + private void processUndo(String username, Map activity) { + try { + Object object = activity.get("object"); + if (object instanceof Map) { + @SuppressWarnings("unchecked") + Map undoObject = (Map) object; + String type = (String) undoObject.get("type"); + + if ("Follow".equals(type)) { + String activityId = (String) undoObject.get("id"); + Follow follow = followRepository.findByActivityId(activityId).orElse(null); + if (follow != null) { + followRepository.delete(follow); + log.info("Processed Undo Follow: {}", activityId); + } + } + } + } catch (Exception e) { + log.error("Error processing Undo activity", e); + } + } + + /** + * Process an Accept activity (e.g., follow request accepted). + */ + private void processAccept(String username, Map activity) { + try { + Object object = activity.get("object"); + if (object instanceof Map) { + @SuppressWarnings("unchecked") + Map acceptObject = (Map) object; + String activityId = (String) acceptObject.get("id"); + + Follow follow = followRepository.findByActivityId(activityId).orElse(null); + if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) { + follow.setStatus(Follow.FollowStatus.ACCEPTED); + followRepository.save(follow); + log.info("Follow request accepted: {}", activityId); + } + } + } catch (Exception e) { + log.error("Error processing Accept activity", e); + } + } + + /** + * Process a Create activity (e.g., new post). + */ + private void processCreate(String username, Map activity) { + // TODO: Implement Create activity processing + log.debug("Received Create activity for user {}", username); + } + + /** + * Process a Like activity. + */ + private void processLike(String username, Map activity) { + // TODO: Implement Like activity processing + log.debug("Received Like activity for user {}", username); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/TimelineService.java b/src/main/java/org/operaton/fitpub/service/TimelineService.java new file mode 100644 index 0000000..38d15c7 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/TimelineService.java @@ -0,0 +1,176 @@ +package org.operaton.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.TimelineActivityDTO; +import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.Follow; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.ActivityRepository; +import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Service for managing timelines. + * Provides federated timeline of activities from followed users. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TimelineService { + + private final ActivityRepository activityRepository; + private final FollowRepository followRepository; + private final UserRepository userRepository; + + @Value("${fitpub.base-url}") + private String baseUrl; + + /** + * Get the federated timeline for a user. + * Includes public activities from: + * - The user's own activities + * - Activities from users they follow (local users only for now) + * + * @param userId the authenticated user's ID + * @param pageable pagination parameters + * @return page of timeline activities + */ + @Transactional(readOnly = true) + public Page getFederatedTimeline(UUID userId, Pageable pageable) { + log.debug("Fetching federated timeline for user: {}", userId); + + User currentUser = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + + // Get list of user IDs that the current user follows + List followedUserIds = getFollowedLocalUserIds(userId); + + // Include the current user's own activities + followedUserIds.add(userId); + + // Fetch public and followers-only activities from followed users + Page activities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc( + followedUserIds, + List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS), + pageable + ); + + // Convert to DTOs + List timelineActivities = activities.getContent().stream() + .map(activity -> { + User activityUser = userRepository.findById(activity.getUserId()).orElse(null); + if (activityUser == null) { + return null; + } + return TimelineActivityDTO.fromActivity( + activity, + activityUser.getUsername(), + activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(), + activityUser.getAvatarUrl() + ); + }) + .filter(dto -> dto != null) + .collect(Collectors.toList()); + + return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements()); + } + + /** + * Get the public timeline. + * Shows all public activities from all users. + * + * @param pageable pagination parameters + * @return page of timeline activities + */ + @Transactional(readOnly = true) + public Page getPublicTimeline(Pageable pageable) { + log.debug("Fetching public timeline"); + + // Fetch all public activities + Page activities = activityRepository.findByVisibilityOrderByStartedAtDesc( + Activity.Visibility.PUBLIC, + pageable + ); + + // Convert to DTOs + List timelineActivities = activities.getContent().stream() + .map(activity -> { + User activityUser = userRepository.findById(activity.getUserId()).orElse(null); + if (activityUser == null) { + return null; + } + return TimelineActivityDTO.fromActivity( + activity, + activityUser.getUsername(), + activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(), + activityUser.getAvatarUrl() + ); + }) + .filter(dto -> dto != null) + .collect(Collectors.toList()); + + return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements()); + } + + /** + * Get user's own timeline (their activities only). + * + * @param userId the user's ID + * @param pageable pagination parameters + * @return page of timeline activities + */ + @Transactional(readOnly = true) + public Page getUserTimeline(UUID userId, Pageable pageable) { + log.debug("Fetching user timeline for: {}", userId); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + + Page activities = activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable); + + List timelineActivities = activities.getContent().stream() + .map(activity -> TimelineActivityDTO.fromActivity( + activity, + user.getUsername(), + user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + user.getAvatarUrl() + )) + .collect(Collectors.toList()); + + return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements()); + } + + /** + * Get IDs of local users that the given user follows. + * + * @param userId the user's ID + * @return list of followed local user IDs + */ + private List getFollowedLocalUserIds(UUID userId) { + List follows = followRepository.findAcceptedFollowingByUserId(userId); + List followedUserIds = new ArrayList<>(); + + for (Follow follow : follows) { + // Check if the followed actor is a local user + String actorUri = follow.getFollowingActorUri(); + if (actorUri.startsWith(baseUrl + "/users/")) { + String username = actorUri.substring((baseUrl + "/users/").length()); + userRepository.findByUsername(username).ifPresent(user -> followedUserIds.add(user.getId())); + } + } + + return followedUserIds; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9793403..391dd1a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect @@ -22,6 +22,13 @@ spring: use_sql_comments: true show-sql: false + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + schemas: public + validate-on-migrate: true + servlet: multipart: max-file-size: 50MB @@ -72,3 +79,21 @@ server: error: include-message: always include-binding-errors: always + +# Actuator configuration +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + health: + db: + enabled: true + diskspace: + enabled: true diff --git a/src/main/resources/db/migration/V1__enable_postgis.sql b/src/main/resources/db/migration/V1__enable_postgis.sql new file mode 100644 index 0000000..39d4130 --- /dev/null +++ b/src/main/resources/db/migration/V1__enable_postgis.sql @@ -0,0 +1,7 @@ +-- V1: Enable PostGIS extension for geospatial support +-- This extension is required for storing GPS track data and performing spatial queries + +CREATE EXTENSION IF NOT EXISTS postgis; + +-- Verify PostGIS version +SELECT PostGIS_version(); diff --git a/src/main/resources/db/migration/V2__create_users_table.sql b/src/main/resources/db/migration/V2__create_users_table.sql new file mode 100644 index 0000000..51011be --- /dev/null +++ b/src/main/resources/db/migration/V2__create_users_table.sql @@ -0,0 +1,28 @@ +-- V2: Create users table +-- Stores local user accounts with ActivityPub Actor profile data + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name VARCHAR(100), + bio TEXT, + avatar_url TEXT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + locked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Indexes for performance +CREATE UNIQUE INDEX idx_user_username ON users(username); +CREATE UNIQUE INDEX idx_user_email ON users(email); +CREATE INDEX idx_user_created_at ON users(created_at DESC); + +-- Comment on table +COMMENT ON TABLE users IS 'Local user accounts with ActivityPub Actor profiles'; +COMMENT ON COLUMN users.public_key IS 'RSA public key for ActivityPub HTTP Signature verification'; +COMMENT ON COLUMN users.private_key IS 'RSA private key for signing ActivityPub requests (encrypted at rest)'; diff --git a/src/main/resources/db/migration/V3__create_activities_table.sql b/src/main/resources/db/migration/V3__create_activities_table.sql new file mode 100644 index 0000000..6930331 --- /dev/null +++ b/src/main/resources/db/migration/V3__create_activities_table.sql @@ -0,0 +1,61 @@ +-- V3: Create activities table +-- Stores fitness activities with geospatial track data and metrics + +CREATE TABLE activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NOT NULL, + visibility VARCHAR(20) NOT NULL DEFAULT 'PUBLIC', + + -- Geospatial data + simplified_track geometry(LineString, 4326), + + -- Full track data as JSONB + track_points_json JSONB, + + -- Calculated metrics + total_distance NUMERIC(10, 2), + total_duration_seconds BIGINT, + elevation_gain NUMERIC(8, 2), + elevation_loss NUMERIC(8, 2), + + -- Original FIT file (using OID for @Lob compatibility) + raw_fit_file OID, + + -- Timestamps + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_activity_type CHECK (activity_type IN ( + 'RUN', 'RIDE', 'HIKE', 'WALK', 'SWIM', + 'ALPINE_SKI', 'BACKCOUNTRY_SKI', 'NORDIC_SKI', 'SNOWBOARD', + 'ROWING', 'KAYAKING', 'CANOEING', 'INLINE_SKATING', + 'ROCK_CLIMBING', 'MOUNTAINEERING', 'YOGA', 'WORKOUT', 'OTHER' + )), + CONSTRAINT chk_visibility CHECK (visibility IN ('PUBLIC', 'FOLLOWERS', 'PRIVATE')), + CONSTRAINT chk_time_range CHECK (ended_at > started_at) +); + +-- Indexes for performance +CREATE INDEX idx_activity_user_id ON activities(user_id); +CREATE INDEX idx_activity_started_at ON activities(started_at DESC); +CREATE INDEX idx_activity_type ON activities(activity_type); +CREATE INDEX idx_activity_visibility ON activities(visibility); +CREATE INDEX idx_activity_user_started ON activities(user_id, started_at DESC); + +-- Spatial index for geospatial queries +CREATE INDEX idx_activity_simplified_track ON activities USING GIST(simplified_track); + +-- JSONB GIN index for fast JSON queries +CREATE INDEX idx_activity_track_points_json ON activities USING GIN(track_points_json); + +-- Comments +COMMENT ON TABLE activities IS 'Fitness activities with GPS track data and metrics'; +COMMENT ON COLUMN activities.simplified_track IS 'Simplified LineString (50-200 points) for map rendering'; +COMMENT ON COLUMN activities.track_points_json IS 'Full track data with all sensors stored as JSONB'; +COMMENT ON COLUMN activities.raw_fit_file IS 'Original FIT file for re-processing'; diff --git a/src/main/resources/db/migration/V4__create_activity_metrics_table.sql b/src/main/resources/db/migration/V4__create_activity_metrics_table.sql new file mode 100644 index 0000000..784f580 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_activity_metrics_table.sql @@ -0,0 +1,54 @@ +-- V4: Create activity_metrics table +-- Stores calculated metrics and statistics for activities + +CREATE TABLE activity_metrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + activity_id UUID NOT NULL UNIQUE REFERENCES activities(id) ON DELETE CASCADE, + + -- Speed metrics + average_speed NUMERIC(8, 2), + max_speed NUMERIC(8, 2), + average_pace_seconds BIGINT, + + -- Heart rate metrics + average_heart_rate INTEGER, + max_heart_rate INTEGER, + + -- Cadence metrics + average_cadence INTEGER, + max_cadence INTEGER, + + -- Power metrics + average_power INTEGER, + max_power INTEGER, + normalized_power INTEGER, + + -- Other metrics + calories INTEGER, + average_temperature NUMERIC(5, 2), + + -- Elevation metrics + max_elevation NUMERIC(8, 2), + min_elevation NUMERIC(8, 2), + total_ascent NUMERIC(8, 2), + total_descent NUMERIC(8, 2), + + -- Time metrics + moving_time_seconds BIGINT, + stopped_time_seconds BIGINT, + + -- Step counter + total_steps INTEGER, + + -- Training metrics + training_stress_score NUMERIC(8, 2) +); + +-- Index on activity_id for fast lookup +CREATE UNIQUE INDEX idx_activity_metrics_activity_id ON activity_metrics(activity_id); + +-- Comments +COMMENT ON TABLE activity_metrics IS 'Calculated metrics and statistics for activities'; +COMMENT ON COLUMN activity_metrics.average_pace_seconds IS 'Average pace in seconds per kilometer'; +COMMENT ON COLUMN activity_metrics.normalized_power IS 'Normalized Power (NP) for cycling power analysis'; +COMMENT ON COLUMN activity_metrics.training_stress_score IS 'TSS - Training Stress Score'; diff --git a/src/main/resources/db/migration/V5__create_follows_table.sql b/src/main/resources/db/migration/V5__create_follows_table.sql new file mode 100644 index 0000000..938c26f --- /dev/null +++ b/src/main/resources/db/migration/V5__create_follows_table.sql @@ -0,0 +1,31 @@ +-- V5: Create follows table +-- Stores follow relationships between local and remote actors + +CREATE TABLE follows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + follower_id UUID REFERENCES users(id) ON DELETE CASCADE, + following_actor_uri VARCHAR(512) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + activity_id VARCHAR(512), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_follow_status CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED')) +); + +-- Indexes for performance +CREATE INDEX idx_follower_id ON follows(follower_id); +CREATE INDEX idx_following_actor_uri ON follows(following_actor_uri); +CREATE INDEX idx_follow_status ON follows(status); +CREATE INDEX idx_follow_activity_id ON follows(activity_id); + +-- Unique constraint to prevent duplicate follows +CREATE UNIQUE INDEX idx_unique_follow ON follows(follower_id, following_actor_uri) +WHERE follower_id IS NOT NULL; + +-- Comments +COMMENT ON TABLE follows IS 'Follow relationships between local and remote actors for ActivityPub federation'; +COMMENT ON COLUMN follows.follower_id IS 'Local user ID (null for remote followers)'; +COMMENT ON COLUMN follows.following_actor_uri IS 'ActivityPub actor URI of the followed user'; +COMMENT ON COLUMN follows.activity_id IS 'ActivityPub activity ID for the follow request'; +COMMENT ON COLUMN follows.status IS 'Status of the follow relationship'; diff --git a/src/main/resources/db/migration/V6__create_remote_actors_table.sql b/src/main/resources/db/migration/V6__create_remote_actors_table.sql new file mode 100644 index 0000000..4dde3fa --- /dev/null +++ b/src/main/resources/db/migration/V6__create_remote_actors_table.sql @@ -0,0 +1,33 @@ +-- V6: Create remote_actors table +-- Caches remote ActivityPub actor information for federation + +CREATE TABLE remote_actors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_uri VARCHAR(512) NOT NULL UNIQUE, + username VARCHAR(255) NOT NULL, + domain VARCHAR(255) NOT NULL, + inbox_url VARCHAR(512) NOT NULL, + outbox_url VARCHAR(512), + shared_inbox_url VARCHAR(512), + public_key TEXT NOT NULL, + public_key_id VARCHAR(512), + display_name VARCHAR(255), + avatar_url VARCHAR(512), + summary TEXT, + last_fetched_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Indexes for performance +CREATE UNIQUE INDEX idx_actor_uri ON remote_actors(actor_uri); +CREATE INDEX idx_domain ON remote_actors(domain); +CREATE INDEX idx_username_domain ON remote_actors(username, domain); +CREATE INDEX idx_last_fetched_at ON remote_actors(last_fetched_at); + +-- Comments +COMMENT ON TABLE remote_actors IS 'Cache of remote ActivityPub actor profiles for federation'; +COMMENT ON COLUMN remote_actors.actor_uri IS 'Full ActivityPub actor URI (e.g., https://mastodon.social/users/username)'; +COMMENT ON COLUMN remote_actors.shared_inbox_url IS 'Shared inbox URL for efficient server-to-server communication'; +COMMENT ON COLUMN remote_actors.public_key IS 'RSA public key for HTTP Signature verification'; +COMMENT ON COLUMN remote_actors.last_fetched_at IS 'Timestamp of last actor profile fetch for cache invalidation'; diff --git a/src/main/resources/static/css/fitpub.css b/src/main/resources/static/css/fitpub.css new file mode 100644 index 0000000..6088146 --- /dev/null +++ b/src/main/resources/static/css/fitpub.css @@ -0,0 +1,207 @@ +/* FitPub - Custom Styles */ + +:root { + --primary-color: #2563eb; + --secondary-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --dark-color: #1f2937; + --light-color: #f3f4f6; + --border-radius: 0.5rem; +} + +/* Base styles */ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + color: var(--dark-color); +} + +/* Navigation */ +.navbar-brand { + font-weight: 700; + font-size: 1.5rem; +} + +/* Map container */ +.map-container { + height: 400px; + border-radius: var(--border-radius); + overflow: hidden; + margin-bottom: 1rem; +} + +.map-container-large { + height: 600px; +} + +/* Activity cards */ +.activity-card { + transition: transform 0.2s, box-shadow 0.2s; +} + +.activity-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.activity-type-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + font-weight: 600; +} + +.activity-type-run { + background-color: #dbeafe; + color: #1e40af; +} + +.activity-type-ride { + background-color: #fef3c7; + color: #92400e; +} + +.activity-type-hike { + background-color: #d1fae5; + color: #065f46; +} + +/* Metrics display */ +.metric-card { + background: var(--light-color); + border-radius: var(--border-radius); + padding: 1rem; + text-align: center; +} + +.metric-value { + font-size: 2rem; + font-weight: 700; + color: var(--primary-color); +} + +.metric-label { + font-size: 0.875rem; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* File upload area */ +.file-upload-area { + border: 2px dashed #d1d5db; + border-radius: var(--border-radius); + padding: 3rem 2rem; + text-align: center; + transition: border-color 0.2s, background-color 0.2s; + cursor: pointer; +} + +.file-upload-area:hover, +.file-upload-area.drag-over { + border-color: var(--primary-color); + background-color: #eff6ff; +} + +.file-upload-icon { + font-size: 3rem; + color: #9ca3af; +} + +/* Timeline */ +.timeline-item { + border-left: 3px solid var(--light-color); + padding-left: 1.5rem; + margin-bottom: 2rem; + position: relative; +} + +.timeline-item::before { + content: ''; + position: absolute; + left: -0.5rem; + top: 0.5rem; + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: var(--primary-color); + border: 3px solid white; +} + +.timeline-date { + color: #6b7280; + font-size: 0.875rem; + font-weight: 600; +} + +/* Charts */ +.chart-container { + position: relative; + height: 300px; + margin-bottom: 1rem; +} + +/* Loading states */ +.loading-spinner { + display: inline-block; + width: 2rem; + height: 2rem; + border: 3px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top-color: var(--primary-color); + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* HTMX loading indicator */ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: inline-block; +} + +.htmx-request.htmx-indicator { + display: inline-block; +} + +/* Utility classes */ +.text-muted { + color: #6b7280; +} + +.text-small { + font-size: 0.875rem; +} + +.visibility-public { + color: var(--secondary-color); +} + +.visibility-followers { + color: var(--warning-color); +} + +.visibility-private { + color: var(--danger-color); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .map-container { + height: 300px; + } + + .map-container-large { + height: 400px; + } + + .metric-value { + font-size: 1.5rem; + } +} diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js new file mode 100644 index 0000000..b57dbd7 --- /dev/null +++ b/src/main/resources/static/js/auth.js @@ -0,0 +1,308 @@ +// FitPub - Authentication Management + +/** + * Authentication utilities for managing JWT tokens and user sessions + */ +const FitPubAuth = { + /** + * Get the stored JWT token + * @returns {string|null} JWT token or null if not found + */ + getToken: function() { + return localStorage.getItem('jwtToken'); + }, + + /** + * Store JWT token + * @param {string} token - JWT token to store + */ + setToken: function(token) { + localStorage.setItem('jwtToken', token); + }, + + /** + * Remove stored JWT token + */ + removeToken: function() { + localStorage.removeItem('jwtToken'); + localStorage.removeItem('username'); + }, + + /** + * Get the stored username + * @returns {string|null} Username or null if not found + */ + getUsername: function() { + return localStorage.getItem('username'); + }, + + /** + * Store username + * @param {string} username - Username to store + */ + setUsername: function(username) { + localStorage.setItem('username', username); + }, + + /** + * Check if user is authenticated + * @returns {boolean} True if authenticated, false otherwise + */ + isAuthenticated: function() { + const token = this.getToken(); + if (!token) { + return false; + } + + // Check if token is expired + try { + const payload = this.parseJwt(token); + const now = Math.floor(Date.now() / 1000); + + if (payload.exp && payload.exp < now) { + // Token expired, remove it + this.removeToken(); + return false; + } + + return true; + } catch (e) { + console.error('Error parsing JWT:', e); + this.removeToken(); + return false; + } + }, + + /** + * Parse JWT token to extract payload + * @param {string} token - JWT token + * @returns {object} Decoded payload + */ + parseJwt: function(token) { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64).split('').map(function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('') + ); + return JSON.parse(jsonPayload); + } catch (e) { + console.error('Error parsing JWT:', e); + throw e; + } + }, + + /** + * Get time until token expiration + * @returns {number} Seconds until expiration, or 0 if expired/invalid + */ + getTokenExpirationTime: function() { + const token = this.getToken(); + if (!token) { + return 0; + } + + try { + const payload = this.parseJwt(token); + const now = Math.floor(Date.now() / 1000); + + if (payload.exp) { + return Math.max(0, payload.exp - now); + } + + return 0; + } catch (e) { + return 0; + } + }, + + /** + * Logout user + */ + logout: function() { + this.removeToken(); + window.location.href = '/login'; + }, + + /** + * Make an authenticated API request + * @param {string} url - API endpoint URL + * @param {object} options - Fetch options + * @returns {Promise} Fetch response + */ + authenticatedFetch: async function(url, options = {}) { + const token = this.getToken(); + + if (!token) { + throw new Error('No authentication token found'); + } + + // Add Authorization header + const headers = { + ...options.headers, + 'Authorization': `Bearer ${token}`, + }; + + // If body is an object, set Content-Type to JSON + if (options.body && typeof options.body === 'object') { + headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(options.body); + } + + const response = await fetch(url, { + ...options, + headers + }); + + // If unauthorized, redirect to login + if (response.status === 401) { + this.removeToken(); + window.location.href = '/login'; + throw new Error('Authentication failed'); + } + + return response; + }, + + /** + * Initialize authentication checks and setup + */ + init: function() { + // Update navigation UI based on auth status + this.updateNavigationUI(); + + // Check authentication status on page load + this.checkAuthStatus(); + + // Set up session expiration warning + this.setupExpirationWarning(); + }, + + /** + * Update navigation UI based on authentication status + */ + updateNavigationUI: function() { + const authUserMenu = document.getElementById('authUserMenu'); + const guestMenu = document.getElementById('guestMenu'); + const usernameDisplay = document.getElementById('usernameDisplay'); + const myActivitiesLink = document.getElementById('myActivitiesLink'); + const uploadLink = document.getElementById('uploadLink'); + + if (this.isAuthenticated()) { + // Show authenticated menu, hide guest menu + if (authUserMenu) { + authUserMenu.classList.remove('d-none'); + } + if (guestMenu) { + guestMenu.style.display = 'none'; + } + + // Show authenticated navigation links + if (myActivitiesLink) { + myActivitiesLink.style.display = ''; + myActivitiesLink.parentElement.style.display = ''; + } + if (uploadLink) { + uploadLink.style.display = ''; + uploadLink.parentElement.style.display = ''; + } + + // Display username + const username = this.getUsername(); + if (usernameDisplay && username) { + usernameDisplay.textContent = username; + } + } else { + // Show guest menu, hide authenticated menu + if (authUserMenu) { + authUserMenu.classList.add('d-none'); + } + if (guestMenu) { + guestMenu.style.display = ''; + } + + // Hide authenticated navigation links + if (myActivitiesLink) { + myActivitiesLink.style.display = 'none'; + myActivitiesLink.parentElement.style.display = 'none'; + } + if (uploadLink) { + uploadLink.style.display = 'none'; + uploadLink.parentElement.style.display = 'none'; + } + } + }, + + /** + * Check authentication status and handle accordingly + */ + checkAuthStatus: function() { + const currentPath = window.location.pathname; + const publicPaths = ['/', '/login', '/register', '/timeline']; + + // Skip check for public paths + if (publicPaths.includes(currentPath)) { + return; + } + + // Check if authenticated + if (!this.isAuthenticated()) { + // Redirect to login for protected pages + window.location.href = '/login?redirect=' + encodeURIComponent(currentPath); + } + }, + + /** + * Set up warning for session expiration + */ + setupExpirationWarning: function() { + const token = this.getToken(); + if (!token) { + return; + } + + const expirationTime = this.getTokenExpirationTime(); + + if (expirationTime > 0) { + // Warn 5 minutes before expiration + const warningTime = Math.max(0, (expirationTime - 300) * 1000); + + setTimeout(() => { + if (this.isAuthenticated()) { + this.showExpirationWarning(); + } + }, warningTime); + } + }, + + /** + * Show session expiration warning + */ + showExpirationWarning: function() { + if (window.FitPub && window.FitPub.showAlert) { + window.FitPub.showAlert( + 'Your session will expire soon. Please save your work.', + 'warning' + ); + } else { + console.warn('Session expiring soon'); + } + }, + + /** + * Refresh the current page with authentication + */ + refreshPage: function() { + window.location.reload(); + } +}; + +// Initialize authentication on page load +document.addEventListener('DOMContentLoaded', function() { + FitPubAuth.init(); +}); + +// Make available globally +window.FitPubAuth = FitPubAuth; diff --git a/src/main/resources/static/js/fitpub.js b/src/main/resources/static/js/fitpub.js new file mode 100644 index 0000000..7f4af5b --- /dev/null +++ b/src/main/resources/static/js/fitpub.js @@ -0,0 +1,468 @@ +// FitPub - Main JavaScript + +/** + * Initialize application when DOM is ready + */ +document.addEventListener('DOMContentLoaded', function() { + console.log('FitPub initialized'); + + // Initialize file upload areas + initFileUploadAreas(); + + // Initialize HTMX event listeners + initHtmxListeners(); +}); + +/** + * Initialize drag-and-drop file upload areas + */ +function initFileUploadAreas() { + const uploadAreas = document.querySelectorAll('.file-upload-area'); + + uploadAreas.forEach(area => { + const fileInput = area.querySelector('input[type="file"]'); + + // Drag and drop events + area.addEventListener('dragover', (e) => { + e.preventDefault(); + area.classList.add('drag-over'); + }); + + area.addEventListener('dragleave', (e) => { + e.preventDefault(); + area.classList.remove('drag-over'); + }); + + area.addEventListener('drop', (e) => { + e.preventDefault(); + area.classList.remove('drag-over'); + + if (e.dataTransfer.files.length > 0) { + fileInput.files = e.dataTransfer.files; + updateFileInputLabel(fileInput); + } + }); + + // Click to upload + area.addEventListener('click', () => { + fileInput.click(); + }); + + // File input change + if (fileInput) { + fileInput.addEventListener('change', () => { + updateFileInputLabel(fileInput); + }); + } + }); +} + +/** + * Update file input label with selected file name + */ +function updateFileInputLabel(input) { + const label = input.parentElement.querySelector('.file-upload-label'); + if (label && input.files.length > 0) { + const fileName = input.files[0].name; + label.textContent = fileName; + } +} + +/** + * Initialize HTMX event listeners for custom behavior + */ +function initHtmxListeners() { + // Show loading indicator on HTMX requests + document.body.addEventListener('htmx:beforeRequest', (event) => { + console.log('HTMX request started:', event.detail.path); + }); + + // Hide loading indicator when request completes + document.body.addEventListener('htmx:afterRequest', (event) => { + console.log('HTMX request completed:', event.detail.path); + }); + + // Handle HTMX errors + document.body.addEventListener('htmx:responseError', (event) => { + console.error('HTMX error:', event.detail); + showAlert('An error occurred. Please try again.', 'danger'); + }); + + // Scroll to top after swapping content + document.body.addEventListener('htmx:afterSwap', (event) => { + if (event.detail.target.id === 'main-content') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }); +} + +/** + * Create and render a Leaflet map with a GPS track + * + * @param {string} containerId - The ID of the map container element + * @param {Object} geoJsonData - GeoJSON track data (LineString or FeatureCollection) + * @param {Object} options - Map options + * @param {boolean} options.showStartEnd - Show start/finish markers (default: true) + * @param {boolean} options.fitBounds - Auto-fit map to track bounds (default: true) + * @param {Function} options.onTrackClick - Callback when track is clicked + * @returns {Object} Leaflet map instance + */ +function createActivityMap(containerId, geoJsonData, options = {}) { + const container = document.getElementById(containerId); + if (!container) { + console.error('Map container not found:', containerId); + return null; + } + + // Clear any existing map instance + if (container._leaflet_id) { + container._leaflet_id = undefined; + container.innerHTML = ''; + } + + // Default options + const defaultOptions = { + zoomControl: true, + attributionControl: true, + scrollWheelZoom: true, + showStartEnd: true, + fitBounds: true + }; + + const mapOptions = { ...defaultOptions, ...options }; + + // Initialize Leaflet map + const map = L.map(containerId, { + zoomControl: mapOptions.zoomControl, + attributionControl: mapOptions.attributionControl, + scrollWheelZoom: mapOptions.scrollWheelZoom + }); + + // Add OpenStreetMap tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + minZoom: 3 + }).addTo(map); + + // Add GeoJSON track if provided + if (geoJsonData) { + let trackLayer; + + // Handle both GeoJSON FeatureCollection and plain LineString + if (geoJsonData.type === 'LineString') { + trackLayer = L.geoJSON({ + type: 'Feature', + geometry: geoJsonData, + properties: {} + }, { + style: { + color: '#2563eb', + weight: 4, + opacity: 0.8, + lineCap: 'round', + lineJoin: 'round' + }, + onEachFeature: (feature, layer) => { + // Add click handler if provided + if (mapOptions.onTrackClick) { + layer.on('click', (e) => { + mapOptions.onTrackClick(e, feature); + }); + } + } + }).addTo(map); + } else { + trackLayer = L.geoJSON(geoJsonData, { + style: { + color: '#2563eb', + weight: 4, + opacity: 0.8, + lineCap: 'round', + lineJoin: 'round' + }, + onEachFeature: (feature, layer) => { + // Add popups with point-in-time metrics if available + if (feature.properties) { + const props = feature.properties; + let popupContent = '
'; + + if (props.time) { + popupContent += `Time: ${new Date(props.time).toLocaleTimeString()}
`; + } + if (props.heartRate) { + popupContent += `Heart Rate: ${props.heartRate} bpm
`; + } + if (props.speed !== undefined) { + const speedKmh = props.speed * 3.6; + popupContent += `Speed: ${speedKmh.toFixed(2)} km/h
`; + } + if (props.elevation !== undefined) { + popupContent += `Elevation: ${props.elevation.toFixed(1)} m
`; + } + if (props.cadence) { + popupContent += `Cadence: ${props.cadence} rpm
`; + } + + popupContent += '
'; + layer.bindPopup(popupContent); + } + + // Add click handler if provided + if (mapOptions.onTrackClick) { + layer.on('click', (e) => { + mapOptions.onTrackClick(e, feature); + }); + } + } + }).addTo(map); + } + + // Fit map bounds to track + if (mapOptions.fitBounds) { + try { + const bounds = trackLayer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [50, 50] }); + } + } catch (e) { + console.warn('Could not fit map bounds:', e); + map.setView([0, 0], 2); + } + } + + // Add start/finish markers + if (mapOptions.showStartEnd) { + addStartFinishMarkers(map, geoJsonData); + } + + // Store track layer reference for potential future use + map.trackLayer = trackLayer; + } else { + // No track data, show default view + map.setView([0, 0], 2); + } + + // Invalidate size to ensure proper rendering + setTimeout(() => { + map.invalidateSize(); + }, 100); + + return map; +} + +/** + * Add start and finish markers to the map + * + * @param {Object} map - Leaflet map instance + * @param {Object} geoJsonData - GeoJSON track data + */ +function addStartFinishMarkers(map, geoJsonData) { + if (!geoJsonData) { + return; + } + + let coordinates; + + // Handle both LineString and FeatureCollection + if (geoJsonData.type === 'LineString') { + coordinates = geoJsonData.coordinates; + } else if (geoJsonData.type === 'Feature') { + coordinates = geoJsonData.geometry.coordinates; + } else if (geoJsonData.type === 'FeatureCollection' && geoJsonData.features && geoJsonData.features.length > 0) { + coordinates = geoJsonData.features[0].geometry.coordinates; + } + + if (!coordinates || coordinates.length < 2) { + return; + } + + // Start marker (green) + const startCoord = coordinates[0]; + const startMarker = L.marker([startCoord[1], startCoord[0]], { + icon: L.divIcon({ + className: 'start-finish-marker', + html: `
`, + iconSize: [24, 24], + iconAnchor: [12, 12] + }), + title: 'Start' + }).addTo(map); + + startMarker.bindPopup('Start'); + + // Finish marker (red) + const finishCoord = coordinates[coordinates.length - 1]; + const finishMarker = L.marker([finishCoord[1], finishCoord[0]], { + icon: L.divIcon({ + className: 'start-finish-marker', + html: `
`, + iconSize: [24, 24], + iconAnchor: [12, 12] + }), + title: 'Finish' + }).addTo(map); + + finishMarker.bindPopup('Finish'); +} + +/** + * Create an elevation profile chart + * + * @param {string} canvasId - The ID of the canvas element + * @param {Array} elevationData - Array of {distance, elevation} objects + */ +function createElevationChart(canvasId, elevationData) { + const ctx = document.getElementById(canvasId); + if (!ctx) { + console.error('Chart canvas not found:', canvasId); + return null; + } + + return new Chart(ctx, { + type: 'line', + data: { + labels: elevationData.map(d => (d.distance / 1000).toFixed(2)), + datasets: [{ + label: 'Elevation (m)', + data: elevationData.map(d => d.elevation), + borderColor: '#10b981', + backgroundColor: 'rgba(16, 185, 129, 0.1)', + fill: true, + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + title: (context) => { + return `Distance: ${context[0].label} km`; + }, + label: (context) => { + return `Elevation: ${context.parsed.y.toFixed(1)} m`; + } + } + } + }, + scales: { + x: { + title: { + display: true, + text: 'Distance (km)' + } + }, + y: { + title: { + display: true, + text: 'Elevation (m)' + } + } + } + } + }); +} + +/** + * Show an alert message + * + * @param {string} message - The message to display + * @param {string} type - Alert type: success, danger, warning, info + */ +function showAlert(message, type = 'info') { + const alertDiv = document.createElement('div'); + alertDiv.className = `alert alert-${type} alert-dismissible fade show`; + alertDiv.setAttribute('role', 'alert'); + alertDiv.innerHTML = ` + ${message} + + `; + + const container = document.querySelector('main.container'); + if (container) { + container.insertBefore(alertDiv, container.firstChild); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + alertDiv.classList.remove('show'); + setTimeout(() => alertDiv.remove(), 150); + }, 5000); + } +} + +/** + * Format duration from seconds to human-readable string + * + * @param {number} seconds - Duration in seconds + * @returns {string} Formatted duration (e.g., "1h 23m 45s") + */ +function formatDuration(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(' '); +} + +/** + * Format distance in meters to human-readable string + * + * @param {number} meters - Distance in meters + * @returns {string} Formatted distance (e.g., "12.34 km" or "856 m") + */ +function formatDistance(meters) { + if (meters >= 1000) { + return `${(meters / 1000).toFixed(2)} km`; + } + return `${Math.round(meters)} m`; +} + +/** + * Format pace from m/s to min/km + * + * @param {number} speed - Speed in m/s + * @returns {string} Formatted pace (e.g., "5:23 /km") + */ +function formatPace(speed) { + if (speed === 0) return '--'; + + const paceSeconds = 1000 / speed; + const minutes = Math.floor(paceSeconds / 60); + const seconds = Math.floor(paceSeconds % 60); + + return `${minutes}:${seconds.toString().padStart(2, '0')} /km`; +} + +// Make functions available globally for inline scripts +window.FitPub = { + createActivityMap, + createElevationChart, + showAlert, + formatDuration, + formatDistance, + formatPace +}; diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html new file mode 100644 index 0000000..29aa7cd --- /dev/null +++ b/src/main/resources/templates/activities/detail.html @@ -0,0 +1,443 @@ + + + + + Activity Details + + + +
+ +
+
+ Loading... +
+

Loading activity...

+
+ + + + + +
+ +
+
+
+
+

Activity Title

+

+ + + + + + + + + +

+

+
+
+ + Edit + + +
+
+
+
+ + +
+
+
+
+

--

+

Distance

+
+
+
+
+
+
+

--

+

Duration

+
+
+
+
+
+
+

--

+

Elevation Gain

+
+
+
+
+
+
+

--

+

Avg Pace

+
+
+
+
+ + +
+
+
+
+
+ Route Map +
+
+
+
+
+
+
+
+ + + + + + + + + +
+ + + +
+ + + + + + + diff --git a/src/main/resources/templates/activities/edit.html b/src/main/resources/templates/activities/edit.html new file mode 100644 index 0000000..2640203 --- /dev/null +++ b/src/main/resources/templates/activities/edit.html @@ -0,0 +1,330 @@ + + + + + Edit Activity + + + +
+
+
+

+ + Edit Activity +

+ + +
+
+ Loading... +
+

Loading activity...

+
+ + + + + + + + +
+
+
+ +
+ + +
+ Please provide a title for your activity. +
+
+ + +
+ + +
+ + +
+ + +
+ 0/5000 characters +
+
+ + +
+ + +
+ + Public activities will be shared on the Fediverse +
+
+ + +
+
Activity Summary
+
+ +
+
+ + +
+ + Cancel + + +
+
+
+
+ + +
+
+
+ Route Preview +
+
+
+
+
+
+
+
+
+ + + + + + + diff --git a/src/main/resources/templates/activities/list.html b/src/main/resources/templates/activities/list.html new file mode 100644 index 0000000..18e8bc1 --- /dev/null +++ b/src/main/resources/templates/activities/list.html @@ -0,0 +1,339 @@ + + + + + My Activities + + + +
+
+
+
+

+ + My Activities +

+ + Upload Activity + +
+ + +
+
+ Loading... +
+

Loading your activities...

+
+ + + + + +
+ +
+ + +
+ +

No Activities Yet

+

Upload your first FIT file to get started!

+ + Upload Activity + +
+ + + +
+
+ + + +
+ + + + + + + diff --git a/src/main/resources/templates/activities/upload.html b/src/main/resources/templates/activities/upload.html new file mode 100644 index 0000000..c811504 --- /dev/null +++ b/src/main/resources/templates/activities/upload.html @@ -0,0 +1,420 @@ + + + + + Upload Activity + + + +
+
+
+

+ + Upload Activity +

+ + + + + + + + +
+
+
+ +
+ +
+ +
+ +
+

Drop your FIT file here

+

or click to browse

+

+ No file selected +

+ Supported: .fit files from Garmin, Wahoo, etc. (Max 50MB) +
+
+ Please select a FIT file to upload. +
+
+ + +
+ +
+
+ 0% +
+
+ Uploading... +
+ + +
+
+
Activity Details
+ + +
+ + +
+ Please provide a title for your activity. +
+
+ + +
+ + +
+ 0/5000 characters +
+
+ + +
+ + +
+ + Public activities will be shared on the Fediverse +
+
+ + +
+
Activity Summary
+
+ +
+
+
+ + +
+ + +
+
+
+
+ + +
+
+
Upload Tips
+
    +
  • FIT files can be exported from Garmin Connect, Strava, Wahoo, and most GPS devices
  • +
  • The activity will be processed to extract GPS tracks, metrics, and statistics
  • +
  • You can add a title and description after uploading
  • +
  • Public activities will appear in your followers' timelines on the Fediverse
  • +
+
+
+
+
+
+ + + + + + + diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html new file mode 100644 index 0000000..bcd5b3b --- /dev/null +++ b/src/main/resources/templates/auth/login.html @@ -0,0 +1,199 @@ + + + + + Login + + + +
+
+
+
+
+

+ + Sign In +

+ +

+ Welcome back to FitPub +

+ + +
+ + + + +
+ + +
+ Please enter your username or email. +
+
+ + +
+ + +
+ Please enter your password. +
+
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+
+ + +
+

+ Don't have an account? + Create one +

+
+
+
+ + +
+
+
Need Help?
+

+ Forgot your password? Contact your instance administrator or create a new account. +

+
+
+
+
+
+ + + + + + + diff --git a/src/main/resources/templates/auth/register.html b/src/main/resources/templates/auth/register.html new file mode 100644 index 0000000..b24bd09 --- /dev/null +++ b/src/main/resources/templates/auth/register.html @@ -0,0 +1,273 @@ + + + + + Register + + + +
+
+
+
+
+

+ + Create Account +

+ +

+ Join the federated fitness community +

+ + +
+ + + + + + + +
+ + +
+ 3-30 characters. Letters, numbers, and underscores only. +
+
+ Please provide a valid username. +
+
+ + +
+ + +
+ Please provide a valid email address. +
+
+ + +
+ + +
+ This is how your name will appear to others. +
+
+ + +
+ + +
+ At least 8 characters. +
+
+ Password must be at least 8 characters. +
+
+ + +
+ + +
+ Passwords do not match. +
+
+ + +
+ +
+
+ + +
+

+ Already have an account? + Sign in +

+
+
+
+ + +
+
+
About FitPub
+

+ FitPub is a federated fitness tracking platform. Your account can interact with + users on Mastodon, Pleroma, and other ActivityPub-compatible platforms. +

+
+
+
+
+
+ + + + + + + diff --git a/src/main/resources/templates/index-simple.html b/src/main/resources/templates/index-simple.html new file mode 100644 index 0000000..b3124af --- /dev/null +++ b/src/main/resources/templates/index-simple.html @@ -0,0 +1,32 @@ + + + + + + FitPub - Federated Fitness Tracking + + + + + + +
+

+ + FitPub +

+

Federated Fitness Tracking

+ + +
+ + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..43316b6 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,139 @@ + + + + + Home - FitPub + + + +
+ +
+
+

+ + FitPub +

+

+ Federated Fitness Tracking for the Fediverse +

+

+ Share your fitness activities with followers on Mastodon, Pleroma, and other ActivityPub platforms. + Upload FIT files from your GPS devices and track your progress. +

+ + +
+
+ + +
+
+
+
+
+ +
+
Interactive Maps
+

+ View your GPS tracks on interactive maps with elevation profiles and detailed metrics. +

+
+
+
+ +
+
+
+
+ +
+
Federated Sharing
+

+ Share activities with followers across the Fediverse using the ActivityPub protocol. +

+
+
+
+ +
+
+
+
+ +
+
Privacy Control
+

+ Choose who sees your activities: public, followers-only, or private. +

+
+
+
+
+ + +
+
+

How It Works

+ +
+
Step 1
+
Upload Your FIT File
+

+ Export a FIT file from your GPS device (Garmin, Wahoo, etc.) and upload it to FitPub. +

+
+ +
+
Step 2
+
View Your Activity
+

+ See your GPS track on an interactive map with detailed metrics like distance, pace, elevation, and heart rate. +

+
+ +
+
Step 3
+
Share on the Fediverse
+

+ Your activity appears in your followers' timelines on Mastodon, Pleroma, and other ActivityPub platforms. +

+
+ +
+
Step 4
+
Follow Other Athletes
+

+ Connect with other athletes on the Fediverse and see their public workouts in your timeline. +

+
+
+
+ + +
+
+
+

Ready to Join the Federated Fitness Community?

+

+ Own your fitness data. Share on your terms. Connect with athletes across the Fediverse. +

+ + Create Your Account + +
+
+
+
+ + diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html new file mode 100644 index 0000000..dc774b9 --- /dev/null +++ b/src/main/resources/templates/layout.html @@ -0,0 +1,210 @@ + + + + + + + + FitPub + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + + +
+ + +
+
+
+
+
FitPub
+

Federated Fitness Tracking

+

+ Share your fitness activities on the Fediverse +

+
+
+
Links
+ +
+
+
Federation
+ +
+
+
+
+

© 2024 FitPub. Open Source Software.

+
+
+
+ + + + + + + + + + + + + + + + + + + + +