Compare commits
1 commit
main
...
McPringle/
| Author | SHA1 | Date | |
|---|---|---|---|
| ef148ae20e |
31 changed files with 659 additions and 1428 deletions
49
.env.example
49
.env.example
|
|
@ -1,13 +1,46 @@
|
||||||
# FitPub Environment Configuration
|
# FitPub environment configuration
|
||||||
|
#
|
||||||
|
# This file mirrors the environment variables referenced by the Spring
|
||||||
|
# configuration and uses the same effective defaults as the development profile
|
||||||
|
# where defaults exist.
|
||||||
|
|
||||||
# Registration Settings
|
# Spring profile
|
||||||
# Leave empty for open registration, or set a password to require it for new signups
|
# Use `prod` for production deployments.
|
||||||
REGISTRATION_PASSWORD=
|
# SPRING_PROFILES_ACTIVE=dev
|
||||||
|
|
||||||
# Example with password (uncomment to enable):
|
# Local PostgreSQL / PostGIS
|
||||||
# REGISTRATION_PASSWORD=my-secret-invite-code-2024
|
# SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/fitpub
|
||||||
|
# SPRING_DATASOURCE_USERNAME=fitpub
|
||||||
|
# SPRING_DATASOURCE_PASSWORD=change_me_in_production
|
||||||
|
|
||||||
# Other settings
|
# Server
|
||||||
# REGISTRATION_ENABLED=true
|
# PORT=8080
|
||||||
|
|
||||||
|
# Public FitPub URLs
|
||||||
# FITPUB_DOMAIN=localhost:8080
|
# FITPUB_DOMAIN=localhost:8080
|
||||||
# FITPUB_BASE_URL=http://localhost:8080
|
# FITPUB_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# ActivityPub
|
||||||
|
# FITPUB_ALLOW_PRIVATE_IPS=false
|
||||||
|
# FITPUB_FEDERATION_PROTOCOL=https
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
# JWT_SECRET=dev-secret-key-change-in-production-must-be-at-least-32-characters-long
|
||||||
|
|
||||||
|
# Registration
|
||||||
|
# REGISTRATION_ENABLED=true
|
||||||
|
# Leave empty for open registration, or set a password to require it for new signups
|
||||||
|
# REGISTRATION_PASSWORD=
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
# Application default is ${java.io.tmpdir}/fitpub/images
|
||||||
|
# FITPUB_IMAGES_PATH=/tmp/fitpub/images
|
||||||
|
# Application default is ${java.io.tmpdir}/fitpub/tiles
|
||||||
|
# FITPUB_TILE_CACHE_PATH=/tmp/fitpub/tiles
|
||||||
|
|
||||||
|
# Image generation
|
||||||
|
# OSM_TILES_ENABLED=true
|
||||||
|
|
||||||
|
# Weather
|
||||||
|
# WEATHER_ENABLED=false
|
||||||
|
# OPENWEATHERMAP_API_KEY=
|
||||||
|
|
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -5,7 +5,10 @@ target/
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
### IntelliJ IDEA ###
|
||||||
.idea/
|
.idea/modules.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/libraries/
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|
@ -46,10 +49,3 @@ logs/
|
||||||
/gadm_410.gpkg
|
/gadm_410.gpkg
|
||||||
/.postgresdata/
|
/.postgresdata/
|
||||||
/peaks_worldwide.geojson
|
/peaks_worldwide.geojson
|
||||||
|
|
||||||
### Coding Assistants ###
|
|
||||||
.codex/
|
|
||||||
.aider*
|
|
||||||
.cursor/
|
|
||||||
.roo/
|
|
||||||
.windsurf/
|
|
||||||
|
|
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="testdb@localhost" uuid="2564811a-81f9-4d83-b1b1-04cb2763e3fa">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:51826/testdb</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/misc.xml
generated
Normal file
17
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ClojureProjectResolveSettings">
|
||||||
|
<currentScheme>IDE</currentScheme>
|
||||||
|
</component>
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/sqldialects.xml
generated
Normal file
8
.idea/sqldialects.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V26__add_published_to_activities.sql" dialect="H2" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
273
CONTAINERS.md
Normal file
273
CONTAINERS.md
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
# Container Deployment Guide
|
||||||
|
|
||||||
|
This guide explains how to run FitPub with containers using either Docker or Podman.
|
||||||
|
|
||||||
|
The repository ships a `docker-compose.yml` file. Despite the name, it can also be used with Podman-compatible compose tooling.
|
||||||
|
|
||||||
|
## Supported runtimes
|
||||||
|
|
||||||
|
- Docker Engine with the `docker compose` plugin
|
||||||
|
- Podman with `podman compose` or another compatible compose frontend
|
||||||
|
|
||||||
|
Examples in this guide use both command styles where that adds clarity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ...
|
||||||
|
podman compose ...
|
||||||
|
```
|
||||||
|
|
||||||
|
If your environment uses a different compose wrapper, adapt the command prefix accordingly.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### 1. Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd fitpub
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create an environment file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set production values
|
||||||
|
|
||||||
|
At minimum, review and set the following values in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
|
POSTGRES_DB=fitpub
|
||||||
|
POSTGRES_USER=fitpub
|
||||||
|
POSTGRES_PASSWORD=change-this
|
||||||
|
APP_PORT=8080
|
||||||
|
FITPUB_DOMAIN=your-domain.com
|
||||||
|
FITPUB_BASE_URL=https://your-domain.com
|
||||||
|
JWT_SECRET=replace-with-a-long-random-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended command for generating secrets for values such as `JWT_SECRET` and `POSTGRES_PASSWORD`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 64
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start the stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
podman compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify the deployment
|
||||||
|
|
||||||
|
FitPub should become available at:
|
||||||
|
|
||||||
|
- Application: `http://localhost:8080` or your configured public URL
|
||||||
|
- Health check endpoint: `http://localhost:8080/actuator/health`
|
||||||
|
|
||||||
|
## Environment variables used by the container stack
|
||||||
|
|
||||||
|
The compose file expects these variables:
|
||||||
|
|
||||||
|
| Variable | Purpose | Example / default |
|
||||||
|
|--------------------------|---------------------------------------------|---------------------------|
|
||||||
|
| `SPRING_PROFILES_ACTIVE` | Spring profile for the app container | `prod` |
|
||||||
|
| `POSTGRES_DB` | PostgreSQL database name | `fitpub` |
|
||||||
|
| `POSTGRES_USER` | PostgreSQL user | `fitpub` |
|
||||||
|
| `POSTGRES_PASSWORD` | PostgreSQL password | set explicitly |
|
||||||
|
| `POSTGRES_PORT` | Host port for PostgreSQL | `5432` |
|
||||||
|
| `APP_PORT` | Host port for FitPub | `8080` |
|
||||||
|
| `FITPUB_DOMAIN` | Public domain used by the app | `your-domain.com` |
|
||||||
|
| `FITPUB_BASE_URL` | Public base URL | `https://your-domain.com` |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | set explicitly |
|
||||||
|
| `JWT_EXPIRATION_MS` | JWT token lifetime | `86400000` |
|
||||||
|
| `REGISTRATION_PASSWORD` | Optional invite-style registration password | empty |
|
||||||
|
| `JPA_SHOW_SQL` | Enable SQL logging | `false` |
|
||||||
|
| `JPA_FORMAT_SQL` | Format SQL logs | `false` |
|
||||||
|
| `LOG_LEVEL_ROOT` | Root log level | `INFO` |
|
||||||
|
| `LOG_LEVEL_APP` | App log level | `INFO` |
|
||||||
|
| `LOG_LEVEL_SPRING` | Spring log level | `INFO` |
|
||||||
|
| `LOG_LEVEL_HIBERNATE` | Hibernate log level | `WARN` |
|
||||||
|
| `LOG_LEVEL_FLYWAY` | Flyway log level | `INFO` |
|
||||||
|
|
||||||
|
`.env.example` documents the application-level environment variables and defaults. The compose file adds a smaller set of container-specific variables around database wiring, ports, and log levels.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### `postgres`
|
||||||
|
|
||||||
|
- Image: `postgis/postgis:16-3.4`
|
||||||
|
- Exposes container port `5432`
|
||||||
|
- Uses the named volume `postgres_data`
|
||||||
|
- Runs a `pg_isready` health check
|
||||||
|
|
||||||
|
### `app`
|
||||||
|
|
||||||
|
- Built from the repository `Dockerfile`
|
||||||
|
- Exposes container port `8080`
|
||||||
|
- Uses the named volumes `app_uploads` and `app_logs`
|
||||||
|
- Waits for the database health check before starting
|
||||||
|
- Publishes a health check on `/actuator/health`
|
||||||
|
|
||||||
|
## Common operations
|
||||||
|
|
||||||
|
### Show logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
docker compose logs -f app
|
||||||
|
docker compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose logs -f
|
||||||
|
podman compose logs -f app
|
||||||
|
podman compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose restart
|
||||||
|
podman compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop and remove the stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop
|
||||||
|
docker compose down
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose stop
|
||||||
|
podman compose down
|
||||||
|
podman compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`down -v` removes persistent volumes and deletes database data.
|
||||||
|
|
||||||
|
### Run commands inside containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app bash
|
||||||
|
docker compose exec postgres psql -U fitpub -d fitpub
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose exec app bash
|
||||||
|
podman compose exec postgres psql -U fitpub -d fitpub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild the application image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build app
|
||||||
|
docker compose build --no-cache app
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose up -d --build app
|
||||||
|
podman compose build --no-cache app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes and backups
|
||||||
|
|
||||||
|
The compose stack creates these named volumes:
|
||||||
|
|
||||||
|
- `postgres_data`
|
||||||
|
- `app_uploads`
|
||||||
|
- `app_logs`
|
||||||
|
|
||||||
|
Examples with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls | grep fitpub
|
||||||
|
docker volume inspect fitpub_postgres_data
|
||||||
|
docker run --rm -v fitpub_postgres_data:/data -v "$(pwd)":/backup \
|
||||||
|
alpine tar czf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples with Podman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman volume ls
|
||||||
|
podman volume inspect fitpub_postgres_data
|
||||||
|
podman run --rm -v fitpub_postgres_data:/data -v "$(pwd)":/backup \
|
||||||
|
docker.io/library/alpine tar czf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
Actual volume names can vary with the compose project name. Inspect the stack locally if your names differ.
|
||||||
|
|
||||||
|
## Health checks and troubleshooting
|
||||||
|
|
||||||
|
### Application health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database readiness
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres pg_isready -U fitpub
|
||||||
|
podman compose exec postgres pg_isready -U fitpub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check rendered compose configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config
|
||||||
|
podman compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres psql -U fitpub -d fitpub -c \
|
||||||
|
"SELECT * FROM flyway_schema_history ORDER BY installed_rank;"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose exec postgres psql -U fitpub -d fitpub -c \
|
||||||
|
"SELECT * FROM flyway_schema_history ORDER BY installed_rank;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset the stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose down -v
|
||||||
|
podman compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes all persisted data.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Set `SPRING_PROFILES_ACTIVE=prod`
|
||||||
|
- Use strong, unique values for `POSTGRES_PASSWORD` and `JWT_SECRET`
|
||||||
|
- Put FitPub behind HTTPS via a reverse proxy such as nginx, Traefik, or Caddy
|
||||||
|
- Back up the database volume regularly
|
||||||
|
- Review exposed ports and firewall rules
|
||||||
|
- Keep the container runtime and base images updated
|
||||||
|
|
||||||
|
## Related files
|
||||||
|
|
||||||
|
- [README.md](./README.md)
|
||||||
|
- [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||||
|
- [docker-compose.yml](./docker-compose.yml)
|
||||||
|
- [Dockerfile](./Dockerfile)
|
||||||
174
CONTRIBUTING.md
Normal file
174
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Contributing to FitPub
|
||||||
|
|
||||||
|
Thanks for contributing to FitPub.
|
||||||
|
|
||||||
|
This document covers the practical workflow for working on the codebase: setting up a local environment, running the application, executing tests, and preparing changes for review.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- Read the [README.md](./README.md) for project context
|
||||||
|
- Check whether an issue already exists for the change you want to make
|
||||||
|
- Keep changes focused; avoid mixing unrelated refactors with feature work or bug fixes
|
||||||
|
|
||||||
|
## Development environment
|
||||||
|
|
||||||
|
FitPub currently targets:
|
||||||
|
|
||||||
|
- Java 17
|
||||||
|
- Maven Wrapper (`./mvnw`)
|
||||||
|
- PostgreSQL with PostGIS
|
||||||
|
- Docker or Podman for local services and Testcontainers-based tests
|
||||||
|
|
||||||
|
The repository includes an `.sdkmanrc` file with Java 17 if you use SDKMAN.
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
### 1. Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd fitpub
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start PostgreSQL with PostGIS
|
||||||
|
|
||||||
|
The development profile expects a local PostgreSQL instance on port `5432`.
|
||||||
|
|
||||||
|
You can use either Docker or Podman. Example with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name fitpub-postgres \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-e POSTGRES_DB=fitpub \
|
||||||
|
-e POSTGRES_USER=fitpub \
|
||||||
|
-e POSTGRES_PASSWORD=change_me_in_production \
|
||||||
|
postgis/postgis:16-3.4
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer a Compose-based setup, see [CONTAINERS.md](./CONTAINERS.md). The same container layout can also be run with Podman-compatible compose tooling.
|
||||||
|
|
||||||
|
### 3. Optional environment configuration
|
||||||
|
|
||||||
|
The application reads configuration from environment variables, but the development profile already provides sensible defaults for the usual local setup.
|
||||||
|
|
||||||
|
In most cases, you can start the application without setting anything beyond the local PostgreSQL/PostGIS instance.
|
||||||
|
|
||||||
|
If you want to override defaults or configure registration behavior, copy the example file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add or adjust values in `.env` as needed.
|
||||||
|
|
||||||
|
`.env.example` documents the available overrides together with the defaults used for local development.
|
||||||
|
|
||||||
|
For local work, the application defaults to the `dev` profile. For production deployments, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the application
|
||||||
|
|
||||||
|
Start the app with the development profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the application runs at `http://localhost:8080`.
|
||||||
|
|
||||||
|
The development profile uses:
|
||||||
|
|
||||||
|
- Flyway for schema migrations
|
||||||
|
- verbose SQL and application logging
|
||||||
|
- a development JWT secret unless you override it
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Create the application artifact with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw clean package
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the full test suite with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test
|
||||||
|
```
|
||||||
|
|
||||||
|
Important notes:
|
||||||
|
|
||||||
|
- Tests use Testcontainers and require a working local container runtime such as Docker or Podman
|
||||||
|
- The test configuration provisions PostgreSQL/PostGIS automatically
|
||||||
|
- Some tests exercise file parsing and integration flows, so the suite is heavier than a pure unit-test run
|
||||||
|
|
||||||
|
To run a single test class or method:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -Dtest=ActivityImageServiceTest
|
||||||
|
./mvnw test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database and migrations
|
||||||
|
|
||||||
|
- Database schema changes should go through Flyway migrations in `src/main/resources/db/migration/`
|
||||||
|
- Do not rely on Hibernate schema generation for persistent changes
|
||||||
|
- Keep migrations forward-only and review them carefully for compatibility with existing data
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
There is no fully codified style toolchain checked into the repository at the moment, so contributors should follow the existing codebase closely.
|
||||||
|
|
||||||
|
In practice, that means:
|
||||||
|
|
||||||
|
- keep naming and package structure consistent with surrounding code
|
||||||
|
- prefer small, focused controller, service, and repository changes
|
||||||
|
- add tests for behavior changes and regressions
|
||||||
|
- avoid incidental formatting churn in unrelated files
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
When opening a pull request:
|
||||||
|
|
||||||
|
- explain the problem being solved
|
||||||
|
- describe the behavior change clearly
|
||||||
|
- mention any schema, API, or federation impact
|
||||||
|
- include screenshots for UI changes when useful
|
||||||
|
- call out follow-up work explicitly instead of bundling it into the same PR
|
||||||
|
|
||||||
|
Before submitting, make sure:
|
||||||
|
|
||||||
|
- the application builds successfully
|
||||||
|
- relevant tests pass locally
|
||||||
|
- new migrations have been validated
|
||||||
|
- documentation is updated when behavior or setup changed
|
||||||
|
|
||||||
|
## Commit guidance
|
||||||
|
|
||||||
|
There is no strict commit convention enforced by the repository, but clear history helps review.
|
||||||
|
|
||||||
|
Prefer commits that:
|
||||||
|
|
||||||
|
- have a single purpose
|
||||||
|
- use descriptive messages
|
||||||
|
- avoid mixing cleanup with functional changes
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
FitPub handles personal activity data, location data, and federation-facing endpoints.
|
||||||
|
|
||||||
|
Please treat the following areas carefully:
|
||||||
|
|
||||||
|
- privacy-zone behavior
|
||||||
|
- activity visibility rules
|
||||||
|
- authentication and JWT handling
|
||||||
|
- ActivityPub request validation and outbound federation logic
|
||||||
|
- file upload and parsing paths
|
||||||
|
|
||||||
|
If you find a security issue, prefer responsible disclosure over opening a public issue with exploit details.
|
||||||
350
DOCKER.md
350
DOCKER.md
|
|
@ -1,350 +0,0 @@
|
||||||
# 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 <repository-url>
|
|
||||||
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
|
|
||||||
```
|
|
||||||
71
README.md
Normal file
71
README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# FitPub
|
||||||
|
|
||||||
|
FitPub is a self-hosted fitness tracking platform for the Fediverse. It lets people upload workout files, review their activities with maps and metrics, and share them through ActivityPub instead of locking them into a closed social network.
|
||||||
|
|
||||||
|
The project is built for people who want to keep control of their training data while still participating in a social graph that reaches Mastodon-compatible platforms and other ActivityPub servers.
|
||||||
|
|
||||||
|
## What FitPub does
|
||||||
|
|
||||||
|
- Imports activity files from GPS devices and training apps
|
||||||
|
- Supports FIT and GPX uploads
|
||||||
|
- Publishes activities to followers over ActivityPub
|
||||||
|
- Provides public, followers-only, and private visibility modes
|
||||||
|
- Applies privacy zones to protect sensitive start and end locations
|
||||||
|
- Shows maps, metrics, timelines, and profile pages in a server-rendered web UI
|
||||||
|
- Includes analytics such as summaries, personal records, achievements, training load, and heatmaps
|
||||||
|
- Supports batch imports from ZIP archives
|
||||||
|
|
||||||
|
## Why this project exists
|
||||||
|
|
||||||
|
Most fitness platforms combine activity storage, analysis, and social distribution inside one vendor-controlled product. FitPub separates those concerns. You can run your own instance, keep your own data, and still share workouts with people on the wider Fediverse.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
FitPub is built with:
|
||||||
|
|
||||||
|
- Java 17
|
||||||
|
- Spring Boot 3
|
||||||
|
- Thymeleaf
|
||||||
|
- PostgreSQL with PostGIS
|
||||||
|
- Flyway
|
||||||
|
- ActivityPub-compatible federation
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
- `src/main/java/` - application code
|
||||||
|
- `src/main/resources/templates/` - server-rendered views
|
||||||
|
- `src/main/resources/static/` - frontend assets
|
||||||
|
- `src/main/resources/db/migration/` - Flyway database migrations
|
||||||
|
- `src/test/` - automated tests
|
||||||
|
- `CONTAINERS.md` - container deployment notes for Docker- or Podman-based setups
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
FitPub is intended to be self-hosted.
|
||||||
|
|
||||||
|
For container-based deployment, see [CONTAINERS.md](./CONTAINERS.md).
|
||||||
|
|
||||||
|
## Current scope
|
||||||
|
|
||||||
|
The repository already includes:
|
||||||
|
|
||||||
|
- local accounts and authentication
|
||||||
|
- activity upload and post-processing
|
||||||
|
- federation endpoints and delivery logic
|
||||||
|
- social features such as follows, comments, likes, notifications, and timelines
|
||||||
|
- analytics and heatmap views
|
||||||
|
- privacy-zone filtering for track data
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
FitPub is an actively developed public project. The feature set is already substantial, but the codebase is still evolving and interface details may change as the platform matures.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests are welcome.
|
||||||
|
|
||||||
|
For contributor workflow, local setup, build steps, and test execution, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
No license file is currently present in this repository. Until one is added, reuse terms are not explicitly defined.
|
||||||
7
pom.xml
7
pom.xml
|
|
@ -23,7 +23,7 @@
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<jjwt.version>0.12.3</jjwt.version>
|
<jjwt.version>0.12.3</jjwt.version>
|
||||||
<testcontainers.version>2.0.5</testcontainers.version>
|
<testcontainers.version>2.0.3</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
@ -170,14 +170,15 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||||
<version>${testcontainers.version}</version>
|
<version>2.0.2</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-postgresql</artifactId>
|
<artifactId>testcontainers-postgresql</artifactId>
|
||||||
<version>${testcontainers.version}</version>
|
<version>2.0.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
import net.javahippie.fitpub.service.ActivityImageService;
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
import net.javahippie.fitpub.service.InboxProcessor;
|
import net.javahippie.fitpub.service.InboxProcessor;
|
||||||
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
|
||||||
import net.javahippie.fitpub.util.ActivityFormatter;
|
import net.javahippie.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -30,7 +29,6 @@ import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
|
@ -53,7 +51,6 @@ public class ActivityPubController {
|
||||||
private final HttpSignatureValidator signatureValidator;
|
private final HttpSignatureValidator signatureValidator;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -439,10 +436,9 @@ public class ActivityPubController {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
noteObject.put("attributedTo", actorUri);
|
||||||
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
noteObject.put("published", activity.getCreatedAt().toString());
|
||||||
noteObject.put("content", formatActivityContent(activity));
|
noteObject.put("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", activityUri);
|
noteObject.put("url", activityUri);
|
||||||
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
|
||||||
|
|
||||||
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
||||||
// check above returned 403 for anything else), so audience is always
|
// check above returned 403 for anything else), so audience is always
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import lombok.NoArgsConstructor;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.Type;
|
import org.hibernate.annotations.Type;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -138,12 +137,6 @@ public class RemoteActivity {
|
||||||
@Column(name = "track_geojson_url", length = 512)
|
@Column(name = "track_geojson_url", length = 512)
|
||||||
private String trackGeojsonUrl;
|
private String trackGeojsonUrl;
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified remote route geometry for local map rendering.
|
|
||||||
*/
|
|
||||||
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
|
|
||||||
private LineString simplifiedTrack;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visibility level of the activity.
|
* Visibility level of the activity.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -39,7 +38,6 @@ public class ActivityPostProcessingService {
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -201,10 +199,9 @@ public class ActivityPostProcessingService {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
noteObject.put("attributedTo", actorUri);
|
||||||
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
noteObject.put("published", activity.getCreatedAt().toString());
|
||||||
noteObject.put("content", formatActivityContent(activity));
|
noteObject.put("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||||
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
|
||||||
|
|
||||||
// Extract hashtags from user text and add as tags
|
// Extract hashtags from user text and add as tags
|
||||||
List<String> hashtags = extractHashtags(activity);
|
List<String> hashtags = extractHashtags(activity);
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,11 @@ import net.javahippie.fitpub.repository.CommentRepository;
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.LikeRepository;
|
import net.javahippie.fitpub.repository.LikeRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
|
||||||
import org.locationtech.jts.geom.GeometryFactory;
|
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
import org.locationtech.jts.geom.PrecisionModel;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.ZonedDateTime;
|
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -40,9 +31,6 @@ import java.util.UUID;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class InboxProcessor {
|
public class InboxProcessor {
|
||||||
private static final int GEOMETRY_SRID = 4326;
|
|
||||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
|
||||||
new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID);
|
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final FollowRepository followRepository;
|
private final FollowRepository followRepository;
|
||||||
|
|
@ -423,18 +411,15 @@ public class InboxProcessor {
|
||||||
|
|
||||||
// Parse published timestamp
|
// Parse published timestamp
|
||||||
String publishedStr = (String) noteObject.get("published");
|
String publishedStr = (String) noteObject.get("published");
|
||||||
Instant publishedAt = parsePublishedAt(publishedStr);
|
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
||||||
|
|
||||||
// Build RemoteActivity entity
|
// Build RemoteActivity entity
|
||||||
RemoteActivity remoteActivity = RemoteActivity.builder()
|
RemoteActivity remoteActivity = RemoteActivity.builder()
|
||||||
.activityUri(activityUri)
|
.activityUri(activityUri)
|
||||||
.remoteActorUri(actor)
|
.remoteActorUri(actor)
|
||||||
.activityType(stringValue(workoutData.get("activityType")))
|
.activityType((String) workoutData.get("activityType"))
|
||||||
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
||||||
.description(firstNonBlank(
|
.description(stripHtml((String) noteObject.get("content")))
|
||||||
stringValue(workoutData.get("description")),
|
|
||||||
stripHtml((String) noteObject.get("content"))
|
|
||||||
))
|
|
||||||
.publishedAt(publishedAt)
|
.publishedAt(publishedAt)
|
||||||
.totalDistance(parseLong(workoutData.get("distance")))
|
.totalDistance(parseLong(workoutData.get("distance")))
|
||||||
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
||||||
|
|
@ -446,7 +431,6 @@ public class InboxProcessor {
|
||||||
.calories(parseInteger(workoutData.get("calories")))
|
.calories(parseInteger(workoutData.get("calories")))
|
||||||
.mapImageUrl(attachments.get("mapImage"))
|
.mapImageUrl(attachments.get("mapImage"))
|
||||||
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
||||||
.simplifiedTrack(extractRoute(workoutData))
|
|
||||||
.visibility(visibility)
|
.visibility(visibility)
|
||||||
.activityPubObject(serializeToJson(noteObject))
|
.activityPubObject(serializeToJson(noteObject))
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -721,88 +705,6 @@ public class InboxProcessor {
|
||||||
return workoutData;
|
return workoutData;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String stringValue(Object value) {
|
|
||||||
return value != null ? String.valueOf(value) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LineString extractRoute(Map<String, Object> workoutData) {
|
|
||||||
Object routeObj = workoutData.get("route");
|
|
||||||
if (!(routeObj instanceof Map<?, ?> routeMap)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object featuresObj = routeMap.get("features");
|
|
||||||
if (!(featuresObj instanceof java.util.List<?> features) || features.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Object featureObj : features) {
|
|
||||||
if (!(featureObj instanceof Map<?, ?> featureMap)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object geometryObj = featureMap.get("geometry");
|
|
||||||
if (!(geometryObj instanceof Map<?, ?> geometryMap)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!"LineString".equals(geometryMap.get("type"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates"));
|
|
||||||
if (lineString != null) {
|
|
||||||
return lineString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LineString parseLineStringCoordinates(Object coordinatesObj) {
|
|
||||||
if (!(coordinatesObj instanceof java.util.List<?> coordinateList) || coordinateList.size() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
java.util.List<Coordinate> coordinates = new java.util.ArrayList<>();
|
|
||||||
for (Object coordinateObj : coordinateList) {
|
|
||||||
Coordinate coordinate = parseCoordinate(coordinateObj);
|
|
||||||
if (coordinate == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
coordinates.add(coordinate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (coordinates.size() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Coordinate parseCoordinate(Object coordinateObj) {
|
|
||||||
if (!(coordinateObj instanceof java.util.List<?> coordinateValues) || coordinateValues.size() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Double longitude = parseDouble(coordinateValues.get(0));
|
|
||||||
Double latitude = parseDouble(coordinateValues.get(1));
|
|
||||||
if (longitude == null || latitude == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Coordinate(longitude, latitude);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String firstNonBlank(String... values) {
|
|
||||||
for (String value : values) {
|
|
||||||
if (value != null && !value.isBlank()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
||||||
*/
|
*/
|
||||||
|
|
@ -922,44 +824,6 @@ public class InboxProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse ActivityPub published timestamps.
|
|
||||||
*
|
|
||||||
* <p>Preferred input is a full ISO-8601 instant with timezone/offset. Some
|
|
||||||
* remote implementations still send zoneless timestamps, so we accept those
|
|
||||||
* as a compatibility fallback and interpret them as UTC.
|
|
||||||
*/
|
|
||||||
private Instant parsePublishedAt(String publishedStr) {
|
|
||||||
if (publishedStr == null || publishedStr.isBlank()) {
|
|
||||||
return Instant.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Instant.parse(publishedStr);
|
|
||||||
} catch (DateTimeParseException ignored) {
|
|
||||||
// Fall through to compatibility parsers below.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return OffsetDateTime.parse(publishedStr).toInstant();
|
|
||||||
} catch (DateTimeParseException ignored) {
|
|
||||||
// Fall through to compatibility parsers below.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return ZonedDateTime.parse(publishedStr).toInstant();
|
|
||||||
} catch (DateTimeParseException ignored) {
|
|
||||||
// Fall through to compatibility parsers below.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant();
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
log.warn("Failed to parse published timestamp: {}", publishedStr, e);
|
|
||||||
return Instant.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize object to JSON string.
|
* Serialize object to JSON string.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
package net.javahippie.fitpub.service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import net.javahippie.fitpub.model.dto.ActivityDTO;
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
|
||||||
import net.javahippie.fitpub.model.entity.PrivacyZone;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the proprietary workoutData payload for outbound ActivityPub Notes.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class WorkoutDataPayloadBuilder {
|
|
||||||
|
|
||||||
private final PrivacyZoneService privacyZoneService;
|
|
||||||
private final TrackPrivacyFilter trackPrivacyFilter;
|
|
||||||
|
|
||||||
public Map<String, Object> build(Activity activity) {
|
|
||||||
Map<String, Object> workoutData = new HashMap<>();
|
|
||||||
workoutData.put("activityType", activity.getActivityType().name());
|
|
||||||
|
|
||||||
if (activity.getDescription() != null && !activity.getDescription().isBlank()) {
|
|
||||||
workoutData.put("description", activity.getDescription());
|
|
||||||
}
|
|
||||||
if (activity.getTotalDistance() != null) {
|
|
||||||
workoutData.put("distance", activity.getTotalDistance().longValue());
|
|
||||||
}
|
|
||||||
if (activity.getTotalDurationSeconds() != null) {
|
|
||||||
workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString());
|
|
||||||
}
|
|
||||||
if (activity.getElevationGain() != null) {
|
|
||||||
workoutData.put("elevationGain", activity.getElevationGain().intValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityMetrics metrics = activity.getMetrics();
|
|
||||||
if (metrics != null) {
|
|
||||||
if (metrics.getAveragePaceSeconds() != null) {
|
|
||||||
workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString());
|
|
||||||
}
|
|
||||||
if (metrics.getAverageHeartRate() != null) {
|
|
||||||
workoutData.put("averageHeartRate", metrics.getAverageHeartRate());
|
|
||||||
}
|
|
||||||
if (metrics.getAverageSpeed() != null) {
|
|
||||||
workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue());
|
|
||||||
}
|
|
||||||
if (metrics.getMaxSpeed() != null) {
|
|
||||||
workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue());
|
|
||||||
}
|
|
||||||
if (metrics.getCalories() != null) {
|
|
||||||
workoutData.put("calories", metrics.getCalories());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> route = buildRoutePayload(activity);
|
|
||||||
if (route != null) {
|
|
||||||
workoutData.put("route", route);
|
|
||||||
}
|
|
||||||
|
|
||||||
return workoutData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> buildRoutePayload(Activity activity) {
|
|
||||||
List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
|
|
||||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter);
|
|
||||||
|
|
||||||
if (dto.getSimplifiedTrack() == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> feature = new HashMap<>();
|
|
||||||
feature.put("type", "Feature");
|
|
||||||
feature.put("geometry", dto.getSimplifiedTrack());
|
|
||||||
|
|
||||||
Map<String, Object> featureCollection = new HashMap<>();
|
|
||||||
featureCollection.put("type", "FeatureCollection");
|
|
||||||
featureCollection.put("features", List.of(feature));
|
|
||||||
return featureCollection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -98,10 +98,6 @@ public class ActivityFormatter {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
|
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
|
||||||
if (timezone == null || timezone.isBlank()) {
|
|
||||||
return utcDateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return utcDateTime.atZone(ZoneOffset.UTC)
|
return utcDateTime.atZone(ZoneOffset.UTC)
|
||||||
.withZoneSameInstant(ZoneId.of(timezone))
|
.withZoneSameInstant(ZoneId.of(timezone))
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,7 @@ public final class ActivityPubContexts {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
||||||
* that carry both interaction-policy declarations and FitPub's proprietary
|
* that carry interaction-policy declarations. Shape:
|
||||||
* {@code workoutData} extension fields. Shape:
|
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* [
|
* [
|
||||||
|
|
@ -46,20 +45,7 @@ public final class ActivityPubContexts {
|
||||||
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
||||||
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
||||||
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
|
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
|
||||||
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" },
|
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
|
||||||
* "fitpub": "https://fitpub.social/ns#",
|
|
||||||
* "workoutData": "fitpub:workoutData",
|
|
||||||
* "activityType": "fitpub:activityType",
|
|
||||||
* "description": "fitpub:description",
|
|
||||||
* "distance": "fitpub:distance",
|
|
||||||
* "duration": "fitpub:duration",
|
|
||||||
* "elevationGain": "fitpub:elevationGain",
|
|
||||||
* "averagePace": "fitpub:averagePace",
|
|
||||||
* "averageHeartRate": "fitpub:averageHeartRate",
|
|
||||||
* "averageSpeed": "fitpub:averageSpeed",
|
|
||||||
* "maxSpeed": "fitpub:maxSpeed",
|
|
||||||
* "calories": "fitpub:calories",
|
|
||||||
* "route": "fitpub:route"
|
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
* </pre>
|
* </pre>
|
||||||
|
|
@ -70,12 +56,6 @@ public final class ActivityPubContexts {
|
||||||
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
||||||
* receiver compacting our object with its own context will recognise the
|
* receiver compacting our object with its own context will recognise the
|
||||||
* field names and apply the policy.
|
* field names and apply the policy.
|
||||||
*
|
|
||||||
* <p>The {@code fitpub:} prefix is FitPub's own extension namespace
|
|
||||||
* ({@code https://fitpub.social/ns#}). It declares the proprietary
|
|
||||||
* {@code workoutData} object and its structured activity fields so FitPub
|
|
||||||
* instances can exchange machine-readable workout metadata without
|
|
||||||
* overloading the standard ActivityStreams fields.
|
|
||||||
*/
|
*/
|
||||||
public static List<Object> extendedContext() {
|
public static List<Object> extendedContext() {
|
||||||
Map<String, Object> extensions = new LinkedHashMap<>();
|
Map<String, Object> extensions = new LinkedHashMap<>();
|
||||||
|
|
@ -84,19 +64,6 @@ public final class ActivityPubContexts {
|
||||||
extensions.put("canQuote", typedRef("gts:canQuote"));
|
extensions.put("canQuote", typedRef("gts:canQuote"));
|
||||||
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
||||||
extensions.put("manualApproval", typedRef("gts:manualApproval"));
|
extensions.put("manualApproval", typedRef("gts:manualApproval"));
|
||||||
extensions.put("fitpub", "https://fitpub.social/ns#");
|
|
||||||
extensions.put("workoutData", "fitpub:workoutData");
|
|
||||||
extensions.put("activityType", "fitpub:activityType");
|
|
||||||
extensions.put("description", "fitpub:description");
|
|
||||||
extensions.put("distance", "fitpub:distance");
|
|
||||||
extensions.put("duration", "fitpub:duration");
|
|
||||||
extensions.put("elevationGain", "fitpub:elevationGain");
|
|
||||||
extensions.put("averagePace", "fitpub:averagePace");
|
|
||||||
extensions.put("averageHeartRate", "fitpub:averageHeartRate");
|
|
||||||
extensions.put("averageSpeed", "fitpub:averageSpeed");
|
|
||||||
extensions.put("maxSpeed", "fitpub:maxSpeed");
|
|
||||||
extensions.put("calories", "fitpub:calories");
|
|
||||||
extensions.put("route", "fitpub:route");
|
|
||||||
return List.of(
|
return List.of(
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
extensions
|
extensions
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
ALTER TABLE remote_activities
|
|
||||||
ADD COLUMN simplified_track geometry(LineString, 4326);
|
|
||||||
|
|
||||||
CREATE INDEX idx_remote_activity_simplified_track
|
|
||||||
ON remote_activities
|
|
||||||
USING gist (simplified_track);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN remote_activities.simplified_track IS
|
|
||||||
'Simplified remote route geometry for local map rendering';
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
<p id="bio" class="mb-3"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="followButtonContainer" class="d-none">
|
<div id="followButtonContainer" class="d-none">
|
||||||
<button class="btn btn-primary" id="followBtn">
|
<button class="btn btn-primary" id="followBtn">
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
<p id="bio" class="mb-3"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22,6 +23,8 @@ public class TestcontainersConfiguration {
|
||||||
)
|
)
|
||||||
.withDatabaseName("testdb")
|
.withDatabaseName("testdb")
|
||||||
.withUsername("test")
|
.withUsername("test")
|
||||||
.withPassword("test");
|
.withPassword("test")
|
||||||
|
.waitingFor(new HostPortWaitStrategy())
|
||||||
|
.withReuse(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
package net.javahippie.fitpub.controller;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
|
||||||
import net.javahippie.fitpub.service.InboxProcessor;
|
|
||||||
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("ActivityPubController Tests")
|
|
||||||
class ActivityPubControllerTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ActivityImageService activityImageService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private InboxProcessor inboxProcessor;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FollowRepository followRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private HttpSignatureValidator signatureValidator;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FederationService federationService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private ActivityPubController controller;
|
|
||||||
|
|
||||||
private UUID activityId;
|
|
||||||
private UUID userId;
|
|
||||||
private Activity activity;
|
|
||||||
private User user;
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
activityId = UUID.randomUUID();
|
|
||||||
userId = UUID.randomUUID();
|
|
||||||
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
|
||||||
|
|
||||||
ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example");
|
|
||||||
|
|
||||||
activity = Activity.builder()
|
|
||||||
.id(activityId)
|
|
||||||
.userId(userId)
|
|
||||||
.activityType(Activity.ActivityType.RUN)
|
|
||||||
.title("Lunch Run")
|
|
||||||
.description("Sunny run")
|
|
||||||
.visibility(Activity.Visibility.PUBLIC)
|
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
|
||||||
.totalDurationSeconds(1800L)
|
|
||||||
.createdAt(createdAt)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
user = User.builder()
|
|
||||||
.id(userId)
|
|
||||||
.username("JaneDoe")
|
|
||||||
.email("janedoe@example.com")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.privateKey("private-key")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should serialize activity published timestamp with timezone")
|
|
||||||
void getActivity_ShouldSerializePublishedTimestampWithTimezone() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
|
|
||||||
|
|
||||||
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
|
||||||
assertThat(response.getBody()).isNotNull();
|
|
||||||
assertThat(response.getBody().get("published"))
|
|
||||||
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should include workoutData and FitPub context terms in activity note")
|
|
||||||
void getActivity_ShouldIncludeWorkoutDataAndExtendedContext() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
|
|
||||||
when(workoutDataPayloadBuilder.build(activity)).thenReturn(Map.of(
|
|
||||||
"activityType", "RUN",
|
|
||||||
"description", "Sunny run",
|
|
||||||
"distance", 5000L,
|
|
||||||
"duration", "PT30M",
|
|
||||||
"averagePace", "PT6M",
|
|
||||||
"route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
|
|
||||||
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
|
||||||
assertThat(response.getBody()).isNotNull();
|
|
||||||
assertThat(response.getBody().get("workoutData")).isEqualTo(Map.of(
|
|
||||||
"activityType", "RUN",
|
|
||||||
"description", "Sunny run",
|
|
||||||
"distance", 5000L,
|
|
||||||
"duration", "PT30M",
|
|
||||||
"averagePace", "PT6M",
|
|
||||||
"route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Object> context = (List<Object>) response.getBody().get("@context");
|
|
||||||
assertThat(context).hasSize(2);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> extensions = (Map<String, Object>) context.get(1);
|
|
||||||
assertThat(extensions)
|
|
||||||
.containsEntry("fitpub", "https://fitpub.social/ns#")
|
|
||||||
.containsEntry("workoutData", "fitpub:workoutData")
|
|
||||||
.containsEntry("route", "fitpub:route");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,25 +2,19 @@ package net.javahippie.fitpub.integration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.javahippie.fitpub.config.TestcontainersConfiguration;
|
import net.javahippie.fitpub.config.TestcontainersConfiguration;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Disabled;
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import net.javahippie.fitpub.model.entity.Follow;
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActivity;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.security.JwtTokenProvider;
|
import net.javahippie.fitpub.security.JwtTokenProvider;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
@ -32,21 +26,15 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
|
@ -75,12 +63,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private RemoteActorRepository remoteActorRepository;
|
private RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RemoteActivityRepository remoteActivityRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
|
@ -90,9 +72,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private HttpSignatureValidator signatureValidator;
|
private HttpSignatureValidator signatureValidator;
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private ActivityImageService activityImageService;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
|
|
@ -122,22 +101,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
authToken = jwtTokenProvider.createToken(testUser.getUsername());
|
authToken = jwtTokenProvider.createToken(testUser.getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException {
|
|
||||||
KeyPair keyPair = generateRsaKeyPair();
|
|
||||||
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
|
|
||||||
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
|
|
||||||
|
|
||||||
return userRepository.save(User.builder()
|
|
||||||
.username(username)
|
|
||||||
.email(email)
|
|
||||||
.passwordHash(passwordEncoder.encode("password123"))
|
|
||||||
.displayName(displayName)
|
|
||||||
.publicKey(publicKey)
|
|
||||||
.privateKey(privateKey)
|
|
||||||
.enabled(true)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
|
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
|
||||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||||
keyGen.initialize(2048);
|
keyGen.initialize(2048);
|
||||||
|
|
@ -307,111 +270,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
|
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should import its own exported public activity through inbox")
|
|
||||||
void testActivityRoundtripThroughExportAndInbox() throws Exception {
|
|
||||||
User importingUser = testUser;
|
|
||||||
User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe");
|
|
||||||
|
|
||||||
Activity activity = activityRepository.save(Activity.builder()
|
|
||||||
.userId(exportingUser.getId())
|
|
||||||
.activityType(Activity.ActivityType.RUN)
|
|
||||||
.title("Lunch Run")
|
|
||||||
.description("Sunny run in the city")
|
|
||||||
.startedAt(LocalDateTime.of(2026, 5, 2, 12, 0))
|
|
||||||
.endedAt(LocalDateTime.of(2026, 5, 2, 12, 30))
|
|
||||||
.createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000))
|
|
||||||
.visibility(Activity.Visibility.PUBLIC)
|
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
|
||||||
.totalDurationSeconds(1800L)
|
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
|
||||||
.sourceFileFormat("FIT")
|
|
||||||
.published(true)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername();
|
|
||||||
when(activityImageService.getActivityImageFile(activity.getId()))
|
|
||||||
.thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image"));
|
|
||||||
|
|
||||||
remoteActorRepository.save(RemoteActor.builder()
|
|
||||||
.actorUri(exportingActorUri)
|
|
||||||
.username(exportingUser.getUsername())
|
|
||||||
.domain(java.net.URI.create(baseUrl).getHost())
|
|
||||||
.displayName(exportingUser.getDisplayName())
|
|
||||||
.inboxUrl(exportingActorUri + "/inbox")
|
|
||||||
.outboxUrl(exportingActorUri + "/outbox")
|
|
||||||
.publicKey(exportingUser.getPublicKey())
|
|
||||||
.publicKeyId(exportingActorUri + "#main-key")
|
|
||||||
.lastFetchedAt(Instant.now())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
followRepository.save(Follow.builder()
|
|
||||||
.followerId(importingUser.getId())
|
|
||||||
.followingActorUri(exportingActorUri)
|
|
||||||
.status(Follow.FollowStatus.ACCEPTED)
|
|
||||||
.activityId(baseUrl + "/activities/follow/" + UUID.randomUUID())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId())
|
|
||||||
.accept("application/activity+json"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andReturn();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
|
|
||||||
|
|
||||||
Map<String, Object> createActivity = Map.of(
|
|
||||||
"@context", "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type", "Create",
|
|
||||||
"id", baseUrl + "/activities/create/" + UUID.randomUUID(),
|
|
||||||
"actor", exportingActorUri,
|
|
||||||
"object", exportedNote
|
|
||||||
);
|
|
||||||
|
|
||||||
String privateKeyPem = exportingUser.getPrivateKey();
|
|
||||||
String inboxPath = "/users/" + importingUser.getUsername() + "/inbox";
|
|
||||||
String inboxUrl = "http://localhost" + inboxPath;
|
|
||||||
String body = objectMapper.writeValueAsString(createActivity);
|
|
||||||
HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest(
|
|
||||||
"POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key"
|
|
||||||
);
|
|
||||||
|
|
||||||
mockMvc.perform(post(inboxPath)
|
|
||||||
.contentType("application/activity+json")
|
|
||||||
.header("Host", sigHeaders.host)
|
|
||||||
.header("Date", sigHeaders.date)
|
|
||||||
.header("Digest", sigHeaders.digest)
|
|
||||||
.header("Signature", sigHeaders.signature)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isAccepted());
|
|
||||||
|
|
||||||
RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
|
|
||||||
.orElseThrow();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> workoutData = (Map<String, Object>) exportedNote.get("workoutData");
|
|
||||||
|
|
||||||
assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id"));
|
|
||||||
assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri);
|
|
||||||
assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name",
|
|
||||||
exportedNote.getOrDefault("summary", "Untitled Activity")));
|
|
||||||
assertThat(imported.getDescription()).isEqualTo(workoutData.get("description"));
|
|
||||||
assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published")));
|
|
||||||
assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC);
|
|
||||||
assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType"));
|
|
||||||
assertThat(imported.getTotalDistance()).isEqualTo(5000L);
|
|
||||||
assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L);
|
|
||||||
assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain"));
|
|
||||||
assertThat(imported.getAveragePaceSeconds()).isNull();
|
|
||||||
assertThat(imported.getAverageHeartRate()).isNull();
|
|
||||||
assertThat(imported.getMaxSpeed()).isNull();
|
|
||||||
assertThat(imported.getAverageSpeed()).isNull();
|
|
||||||
assertThat(imported.getCalories()).isNull();
|
|
||||||
assertThat(imported.getMapImageUrl()).isNull();
|
|
||||||
assertThat(imported.getTrackGeojsonUrl()).isNull();
|
|
||||||
assertThat(imported.getSimplifiedTrack()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
||||||
void testInboxRejectsUnsignedRequest() throws Exception {
|
void testInboxRejectsUnsignedRequest() throws Exception {
|
||||||
|
|
@ -452,23 +310,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String stripHtml(String html) {
|
|
||||||
if (html == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
.replaceAll("<br\\s*/?>", "\n")
|
|
||||||
.replaceAll("<p>", "")
|
|
||||||
.replaceAll("</p>", "\n")
|
|
||||||
.replaceAll("<[^>]+>", "")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace(""", "\"")
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace("&", "&")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
||||||
void testProcessUndoFollowActivity() throws Exception {
|
void testProcessUndoFollowActivity() throws Exception {
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||||
/**
|
/**
|
||||||
* Manual test for ActivityImageService.
|
* Manual test for ActivityImageService.
|
||||||
* These tests are disabled by default and should only be run manually.
|
* These tests are disabled by default and should only be run manually.
|
||||||
*
|
|
||||||
* To run this test manually:
|
|
||||||
* mvn test -Dtest=ActivityImageServiceTest
|
|
||||||
*/
|
*/
|
||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
"fitpub.image.osm-tiles.enabled=true"
|
"fitpub.image.osm-tiles.enabled=true"
|
||||||
})
|
})
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(TestcontainersConfiguration.class)
|
@Import(TestcontainersConfiguration.class)
|
||||||
@Disabled("Manual test - run explicitly when needed")
|
|
||||||
class ActivityImageServiceTest {
|
class ActivityImageServiceTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -59,6 +55,7 @@ class ActivityImageServiceTest {
|
||||||
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
|
@Disabled("Manual test - run explicitly when needed")
|
||||||
@DisplayName("Generate activity image from test FIT file")
|
@DisplayName("Generate activity image from test FIT file")
|
||||||
void testGenerateActivityImage_Manual() throws Exception {
|
void testGenerateActivityImage_Manual() throws Exception {
|
||||||
// Load test FIT file
|
// Load test FIT file
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,25 @@
|
||||||
package net.javahippie.fitpub.service;
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
|
||||||
import org.locationtech.jts.geom.GeometryFactory;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.anyBoolean;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.mockito.Mockito.anyString;
|
|
||||||
import static org.mockito.Mockito.doNothing;
|
|
||||||
import static org.mockito.Mockito.doThrow;
|
|
||||||
import static org.mockito.Mockito.eq;
|
|
||||||
import static org.mockito.Mockito.lenient;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for ActivityPostProcessingService.
|
* Unit tests for ActivityPostProcessingService.
|
||||||
|
|
@ -66,9 +49,6 @@ class ActivityPostProcessingServiceTest {
|
||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
@Mock
|
|
||||||
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ActivityPostProcessingService service;
|
private ActivityPostProcessingService service;
|
||||||
|
|
||||||
|
|
@ -76,13 +56,11 @@ class ActivityPostProcessingServiceTest {
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
private Activity testActivity;
|
private Activity testActivity;
|
||||||
private User testUser;
|
private User testUser;
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
activityId = UUID.randomUUID();
|
activityId = UUID.randomUUID();
|
||||||
userId = UUID.randomUUID();
|
userId = UUID.randomUUID();
|
||||||
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
|
||||||
|
|
||||||
// Set baseUrl via reflection (since it's @Value injected)
|
// Set baseUrl via reflection (since it's @Value injected)
|
||||||
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
||||||
|
|
@ -98,39 +76,9 @@ class ActivityPostProcessingServiceTest {
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
.totalDistance(BigDecimal.valueOf(5000))
|
||||||
.totalDurationSeconds(1800L)
|
.totalDurationSeconds(1800L)
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
.elevationGain(BigDecimal.valueOf(100))
|
||||||
.startedAt(createdAt.minusMinutes(30))
|
.startedAt(LocalDateTime.now())
|
||||||
.createdAt(createdAt)
|
.createdAt(LocalDateTime.now())
|
||||||
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
|
||||||
new Coordinate(8.55, 47.37),
|
|
||||||
new Coordinate(8.56, 47.38)
|
|
||||||
}))
|
|
||||||
.build();
|
.build();
|
||||||
testActivity.setMetrics(ActivityMetrics.builder()
|
|
||||||
.averagePaceSeconds(321L)
|
|
||||||
.build());
|
|
||||||
Map<String, Object> workoutData = new HashMap<>();
|
|
||||||
workoutData.put("activityType", "RUN");
|
|
||||||
workoutData.put("description", "Morning jog");
|
|
||||||
workoutData.put("distance", 5000L);
|
|
||||||
workoutData.put("duration", "PT30M");
|
|
||||||
workoutData.put("averagePace", "PT5M21S");
|
|
||||||
workoutData.put("elevationGain", 100);
|
|
||||||
workoutData.put("route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of(
|
|
||||||
Map.of(
|
|
||||||
"type", "Feature",
|
|
||||||
"geometry", Map.of(
|
|
||||||
"type", "LineString",
|
|
||||||
"coordinates", List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
));
|
|
||||||
lenient().when(workoutDataPayloadBuilder.build(testActivity)).thenReturn(workoutData);
|
|
||||||
|
|
||||||
// Create test user
|
// Create test user
|
||||||
testUser = User.builder()
|
testUser = User.builder()
|
||||||
|
|
@ -284,24 +232,6 @@ class ActivityPostProcessingServiceTest {
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should serialize federation note published timestamp with timezone")
|
|
||||||
void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
|
||||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
|
|
||||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
|
|
||||||
service.publishToFederationAsync(activityId, userId);
|
|
||||||
|
|
||||||
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
|
|
||||||
assertThat(noteCaptor.getValue().get("published"))
|
|
||||||
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should skip federation for PRIVATE activity")
|
@DisplayName("Should skip federation for PRIVATE activity")
|
||||||
void testPublishToFederationAsync_PrivateActivity() {
|
void testPublishToFederationAsync_PrivateActivity() {
|
||||||
|
|
@ -387,47 +317,4 @@ class ActivityPostProcessingServiceTest {
|
||||||
// Then: Verify federation was called (content formatting is tested indirectly)
|
// Then: Verify federation was called (content formatting is tested indirectly)
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should include workoutData payload in federation note")
|
|
||||||
void testPublishToFederationAsync_IncludesWorkoutDataPayload() {
|
|
||||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
|
||||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
|
|
||||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(Map.class);
|
|
||||||
|
|
||||||
service.publishToFederationAsync(activityId, userId);
|
|
||||||
|
|
||||||
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> workoutData = (Map<String, Object>) noteCaptor.getValue().get("workoutData");
|
|
||||||
assertThat(workoutData)
|
|
||||||
.containsEntry("activityType", "RUN")
|
|
||||||
.containsEntry("description", "Morning jog")
|
|
||||||
.containsEntry("distance", 5000L)
|
|
||||||
.containsEntry("duration", "PT30M")
|
|
||||||
.containsEntry("averagePace", "PT5M21S")
|
|
||||||
.containsEntry("elevationGain", 100);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> route = (Map<String, Object>) workoutData.get("route");
|
|
||||||
assertThat(route).containsEntry("type", "FeatureCollection");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
|
|
||||||
assertThat(features).hasSize(1);
|
|
||||||
assertThat(features.get(0)).containsEntry("type", "Feature");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> geometry = (Map<String, Object>) features.get(0).get("geometry");
|
|
||||||
assertThat(geometry).containsEntry("type", "LineString");
|
|
||||||
assertThat(geometry.get("coordinates")).isEqualTo(List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
package net.javahippie.fitpub.service;
|
|
||||||
|
|
||||||
import net.javahippie.fitpub.model.entity.Follow;
|
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActivity;
|
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.CommentRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
|
||||||
import net.javahippie.fitpub.repository.LikeRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("InboxProcessor Tests")
|
|
||||||
class InboxProcessorTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FollowRepository followRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FederationService federationService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private LikeRepository likeRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private CommentRepository commentRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private NotificationService notificationService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private RemoteActivityRepository remoteActivityRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private RemoteActorRepository remoteActorRepository;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private InboxProcessor inboxProcessor;
|
|
||||||
|
|
||||||
private User localUser;
|
|
||||||
private String remoteActorUri;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
localUser = User.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.username("JaneDoe")
|
|
||||||
.email("janedoe@example.com")
|
|
||||||
.passwordHash("irrelevant")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.privateKey("private-key")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
remoteActorUri = "https://fitpub.example.com/users/JohnDoe";
|
|
||||||
|
|
||||||
ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone")
|
|
||||||
void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() {
|
|
||||||
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123"))
|
|
||||||
.thenReturn(false);
|
|
||||||
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
|
|
||||||
.actorUri(remoteActorUri)
|
|
||||||
.username("JohnDoe")
|
|
||||||
.domain("fitpub.example.com")
|
|
||||||
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.build());
|
|
||||||
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
|
|
||||||
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
|
|
||||||
.thenReturn(Optional.of(Follow.builder()
|
|
||||||
.followerId(localUser.getId())
|
|
||||||
.followingActorUri(remoteActorUri)
|
|
||||||
.status(Follow.FollowStatus.ACCEPTED)
|
|
||||||
.build()));
|
|
||||||
|
|
||||||
Map<String, Object> note = Map.of(
|
|
||||||
"id", "https://fitpub.example.com/activities/123",
|
|
||||||
"type", "Note",
|
|
||||||
"name", "Lunch Run",
|
|
||||||
"content", "<p>Sunny run</p>",
|
|
||||||
"published", "2026-05-02T09:24:50.921241",
|
|
||||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public")
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> activity = Map.of(
|
|
||||||
"type", "Create",
|
|
||||||
"actor", remoteActorUri,
|
|
||||||
"object", note
|
|
||||||
);
|
|
||||||
|
|
||||||
ArgumentCaptor<net.javahippie.fitpub.model.entity.RemoteActivity> remoteActivityCaptor =
|
|
||||||
ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class);
|
|
||||||
|
|
||||||
inboxProcessor.processActivity("JaneDoe", activity);
|
|
||||||
|
|
||||||
verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123");
|
|
||||||
verify(federationService).fetchRemoteActor(remoteActorUri);
|
|
||||||
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
|
|
||||||
|
|
||||||
assertThat(remoteActivityCaptor.getValue().getPublishedAt())
|
|
||||||
.isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should prefer workoutData fields over legacy content parsing")
|
|
||||||
void processCreateRemoteActivity_WithWorkoutDataPayload_ShouldPreferWorkoutDataFields() {
|
|
||||||
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/456"))
|
|
||||||
.thenReturn(false);
|
|
||||||
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
|
|
||||||
.actorUri(remoteActorUri)
|
|
||||||
.username("JohnDoe")
|
|
||||||
.domain("fitpub.example.com")
|
|
||||||
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
|
|
||||||
.publicKey("public-key")
|
|
||||||
.build());
|
|
||||||
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
|
|
||||||
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
|
|
||||||
.thenReturn(Optional.of(Follow.builder()
|
|
||||||
.followerId(localUser.getId())
|
|
||||||
.followingActorUri(remoteActorUri)
|
|
||||||
.status(Follow.FollowStatus.ACCEPTED)
|
|
||||||
.build()));
|
|
||||||
|
|
||||||
Map<String, Object> workoutData = new HashMap<>();
|
|
||||||
workoutData.put("activityType", "RUN");
|
|
||||||
workoutData.put("description", "Direct workoutData description");
|
|
||||||
workoutData.put("distance", 9800L);
|
|
||||||
workoutData.put("duration", "PT41M9S");
|
|
||||||
workoutData.put("averagePace", "PT4M12S");
|
|
||||||
workoutData.put("elevationGain", 123);
|
|
||||||
workoutData.put("route", Map.of(
|
|
||||||
"type", "FeatureCollection",
|
|
||||||
"features", List.of(Map.of(
|
|
||||||
"type", "Feature",
|
|
||||||
"geometry", Map.of(
|
|
||||||
"type", "LineString",
|
|
||||||
"coordinates", List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38),
|
|
||||||
List.of(8.57, 47.39)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
))
|
|
||||||
));
|
|
||||||
|
|
||||||
Map<String, Object> note = Map.of(
|
|
||||||
"id", "https://fitpub.example.com/activities/456",
|
|
||||||
"type", "Note",
|
|
||||||
"name", "Kraremanns Lauf 2026",
|
|
||||||
"content", "<p>Kraremanns Lauf 2026</p><p>Run · 9.80 km · 41:09</p><p>Legacy content fallback</p>",
|
|
||||||
"published", "2026-05-02T09:24:50.921241",
|
|
||||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public"),
|
|
||||||
"workoutData", workoutData
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> activity = Map.of(
|
|
||||||
"type", "Create",
|
|
||||||
"actor", remoteActorUri,
|
|
||||||
"object", note
|
|
||||||
);
|
|
||||||
|
|
||||||
ArgumentCaptor<RemoteActivity> remoteActivityCaptor =
|
|
||||||
ArgumentCaptor.forClass(RemoteActivity.class);
|
|
||||||
|
|
||||||
inboxProcessor.processActivity("JaneDoe", activity);
|
|
||||||
|
|
||||||
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
|
|
||||||
|
|
||||||
RemoteActivity remoteActivity = remoteActivityCaptor.getValue();
|
|
||||||
assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026");
|
|
||||||
assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description");
|
|
||||||
assertThat(remoteActivity.getActivityType()).isEqualTo("RUN");
|
|
||||||
assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L);
|
|
||||||
assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L);
|
|
||||||
assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L);
|
|
||||||
assertThat(remoteActivity.getElevationGain()).isEqualTo(123);
|
|
||||||
LineString simplifiedTrack = remoteActivity.getSimplifiedTrack();
|
|
||||||
assertThat(simplifiedTrack).isNotNull();
|
|
||||||
assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3);
|
|
||||||
assertThat(simplifiedTrack.getSRID()).isEqualTo(4326);
|
|
||||||
assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55);
|
|
||||||
assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package net.javahippie.fitpub.service;
|
|
||||||
|
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
|
||||||
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
|
||||||
import org.locationtech.jts.geom.GeometryFactory;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
@DisplayName("WorkoutDataPayloadBuilder Tests")
|
|
||||||
class WorkoutDataPayloadBuilderTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private PrivacyZoneService privacyZoneService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private TrackPrivacyFilter trackPrivacyFilter;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private WorkoutDataPayloadBuilder builder;
|
|
||||||
|
|
||||||
private UUID userId;
|
|
||||||
private Activity activity;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
userId = UUID.randomUUID();
|
|
||||||
activity = Activity.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.userId(userId)
|
|
||||||
.activityType(Activity.ActivityType.RUN)
|
|
||||||
.description("Morning jog")
|
|
||||||
.visibility(Activity.Visibility.PUBLIC)
|
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
|
||||||
.totalDurationSeconds(1800L)
|
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
|
||||||
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
|
||||||
new Coordinate(8.55, 47.37),
|
|
||||||
new Coordinate(8.56, 47.38)
|
|
||||||
}))
|
|
||||||
.build();
|
|
||||||
activity.setMetrics(ActivityMetrics.builder()
|
|
||||||
.averagePaceSeconds(321L)
|
|
||||||
.averageHeartRate(150)
|
|
||||||
.averageSpeed(BigDecimal.valueOf(10.4))
|
|
||||||
.maxSpeed(BigDecimal.valueOf(14.2))
|
|
||||||
.calories(420)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Should build workoutData payload with route and metrics")
|
|
||||||
void build_ShouldIncludeWorkoutDataRouteAndMetrics() {
|
|
||||||
when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of());
|
|
||||||
|
|
||||||
Map<String, Object> workoutData = builder.build(activity);
|
|
||||||
|
|
||||||
assertThat(workoutData)
|
|
||||||
.containsEntry("activityType", "RUN")
|
|
||||||
.containsEntry("description", "Morning jog")
|
|
||||||
.containsEntry("distance", 5000L)
|
|
||||||
.containsEntry("duration", "PT30M")
|
|
||||||
.containsEntry("elevationGain", 100)
|
|
||||||
.containsEntry("averagePace", "PT5M21S")
|
|
||||||
.containsEntry("averageHeartRate", 150)
|
|
||||||
.containsEntry("averageSpeed", 10.4)
|
|
||||||
.containsEntry("maxSpeed", 14.2)
|
|
||||||
.containsEntry("calories", 420);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> route = (Map<String, Object>) workoutData.get("route");
|
|
||||||
assertThat(route).containsEntry("type", "FeatureCollection");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
|
|
||||||
assertThat(features).hasSize(1);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> geometry = (Map<String, Object>) features.get(0).get("geometry");
|
|
||||||
assertThat(geometry).containsEntry("type", "LineString");
|
|
||||||
assertThat(geometry.get("coordinates")).isEqualTo(List.of(
|
|
||||||
List.of(8.55, 47.37),
|
|
||||||
List.of(8.56, 47.38)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue