diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9f26edb..ce9aff9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,31 @@ "Bash(Out-Null)", "Bash(Write-Output \"done\")", "Bash(./mvnw.cmd compile *)", - "Bash(git *)" + "Bash(git *)", + "Bash(Get-ChildItem *)", + "Bash(xargs grep -l \"enroll\\\\|heartbeat\")", + "Bash(findstr /C:\"hypersistence\" /C:\"jackson\" /C:\"lombok\" /C:\"java.version\" /C:\"\")", + "Bash(./mvnw -q -DskipTests compile)", + "Bash(./mvnw -q test \"-Dtest=Ed25519VerifierTest+EnrollmentTokenServiceTest\")", + "Bash(./mvnw -q test \"-Dtest=Ed25519VerifierTest,EnrollmentTokenServiceTest\")", + "Bash(./mvnw test *)", + "Bash(findstr /C:\"Tests run\" /C:\"BUILD\")", + "Bash(findstr \"Tests\")", + "Bash(findstr /V \"^??\")", + "Bash(findstr /V \"^[ ]M .claude\")", + "Bash(mvn -q -DskipTests compile)", + "PowerShell(mvn *)", + "PowerShell(.\\\\mvnw.cmd -q -DskipTests compile 2>&1 | Select-Object -Last 100)", + "PowerShell(.\\\\mvnw.cmd -DskipTests compile 2>&1 | Select-Object -Last 60)", + "PowerShell(.\\\\mvnw.cmd -DskipTests clean compile 2>&1 | Select-Object -Last 80)", + "PowerShell(.\\\\mvnw.cmd test-compile 2>&1 | Select-Object -Last 40)", + "Bash(go build *)", + "Bash(go vet *)", + "Bash(wsl *)", + "Bash(Select-Object -First 50)", + "Bash(gh pr *)", + "Read(//tmp/**)", + "Bash(Select-Object Name)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d836b7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +target/ +.idea/ +.vscode/ +.git/ +.gitignore +.claude/ +.mvn/wrapper/maven-wrapper.jar +*.db +*.log +docs/ +README.md +Dockerfile +docker-compose.yml +docker/ +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d68a10 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy to .env and fill in. .env is gitignored. + +# --- Postgres --- +POSTGRES_DB=lensbridge +POSTGRES_USER=lensbridge +POSTGRES_PASSWORD=change-me-strong-password + +# --- App secrets --- +# Generate with: openssl rand -base64 32 +JWT_SECRET= +ONBOARDING_SECRET= + +# --- Cloudflare R2 --- +CLOUDFLARE_R2_ACCESS_KEY_ID= +CLOUDFLARE_R2_SECRET_ACCESS_KEY= +CLOUDFLARE_R2_BUCKET_NAME=lensbridge-media +CLOUDFLARE_R2_PUBLIC_URL=https://media.lensbridge.tech + +# --- SMTP (Purelymail) --- +MAIL_USERNAME=noreply@lensbridge.tech +MAIL_PASSWORD= + +# --- Allowed CORS origins (full URLs, no trailing slash) --- +FRONTEND_BASEURL=https://lensbridge.tech +MUSALLAHBOARD_BASEURL=https://musallah.lensbridge.tech + +# --- API docs (keep false in prod) --- +SPRINGDOC_API_DOCS_ENABLED=false diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..c78f776 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,61 @@ +name: OpenAPI contract + +# openapi.yaml is the contract three separate repositories generate their clients +# from: LensBridgeFrontend, MusallahBoard (kiosk) and the Go agent. It is committed +# so that a change to a response shape shows up as a reviewable diff -- this job +# exists to make sure the committed copy actually matches the controllers. + +on: + push: + branches: [master, develop] + pull_request: + workflow_dispatch: + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + + - name: Set up Python for the spec quality gate + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - run: pip install pyyaml + + - name: Run tests + run: ./mvnw --batch-mode test + + - name: Regenerate openapi.yaml + # Boots the app on a dedicated port and scrapes /v3/api-docs.yaml. + run: ./mvnw --batch-mode -P openapi verify -DskipTests + + - name: Fail if the committed spec is stale + run: | + if ! git diff --exit-code --stat -- openapi.yaml; then + echo "::error::openapi.yaml does not match the controllers." + echo "A contract change was made without regenerating the spec." + echo "Run './mvnw -P openapi verify -DskipTests' and commit openapi.yaml." + git diff -- openapi.yaml + exit 1 + fi + echo "openapi.yaml matches the controllers." + + - name: Check spec quality + # Untyped responses generate `unknown` in TypeScript and `interface{}` in Go, + # which is indistinguishable from having no contract at all. + run: python tools/check_openapi.py openapi.yaml --strict-ids + + - name: Publish the spec for downstream repos + uses: actions/upload-artifact@v4 + with: + name: openapi + path: openapi.yaml + if-no-files-found: error diff --git a/.gitignore b/.gitignore index b18b290..b3a2fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules HELP.md target/ +docs/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ @@ -16,6 +17,11 @@ target/ *.properties +# Test configuration holds no secrets and must be committed: CI checks out a tree with no +# application.properties at all, so without this the test suite runs on the inline @Value +# defaults and behaves differently there than on a developer machine. +!src/test/resources/application.properties + ### IntelliJ IDEA ### .idea *.iws @@ -34,3 +40,13 @@ build/ ### VS Code ### .vscode/ + +lensbridge-dev.db + +# Docker +.env +.env.* +!.env.example +docker/nginx/certs/* +!docker/nginx/certs/.gitkeep +.claude/worktrees/* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3422d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1.7 + +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /build +COPY pom.xml ./ +RUN mvn -B -e -ntp dependency:go-offline +COPY src ./src +RUN mvn -B -e -ntp -DskipTests package \ + && cp target/*.jar /build/app.jar + +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app +RUN useradd -r -u 1001 -g root lensbridge \ + && mkdir -p /app/thumbnails \ + && chown -R lensbridge:root /app +COPY --from=build /build/app.jar /app/app.jar +USER lensbridge +EXPOSE 8080 +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0" +ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar"] diff --git a/README.md b/README.md index f9192bf..12bf67f 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,16 @@ # LensBridge Backend -![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.2-blue) +![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.14-blue) ![Java](https://img.shields.io/badge/Java-21-orange) ![Maven](https://img.shields.io/badge/Maven-3.3.2-red) -![MongoDB](https://img.shields.io/badge/MongoDB-4.4-green) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-blue)

-> A backend service for LensBridge, a platform for managing crowdsourced event photos and videos. +> A backend service for LensBridge, a platform for managing crowdsourced boardEvent photos and videos. -LensBridge is the all-in-one solution for universities to manage and showcase event photos and videos. This backend service provides APIs for user management, event management, and upload management. - -## Features - -- **User Management**: Register, login, and manage user profiles. All authentication is handled via JWT. -- **Event Management**: Create, update, and delete events. Uploads are associated with events for ease of organization. -- **Upload Management**: Upload photos and videos, with options for approval, featuring, and anonymization. -- **Admin Operations**: Admins can manage uploads, events, and user roles with enhanced endpoints for better user information. - -## General Information - -This project is built using Java (duh), Spring Boot, and MongoDB. It follows the MVC architecture and uses JWT for secure authentication. The API is designed to be RESTful, providing a clean and intuitive interface for frontend applications. +LensBridge is the _ultimate_ platform for collecting and sharing photos from events. Whether you're organizing a conference, a concert, or a community gathering, LensBridge makes it easy to gather and showcase photos from attendees. This backend service provides the core functionality for user management, event management, and upload handling, all while ensuring security and scalability. ## Getting Started @@ -30,9 +19,11 @@ This project is built using Java (duh), Spring Boot, and MongoDB. It follows the - Java 21 or higher - Maven 3.3.2 or higher (Or use the wrapper) -- Some form of MongoDB and Cloudflare R2 access +- Some form of SQL distribution (PostgreSQL, MySQL, etc.) - I use PostgreSQL in prod and SQLite for local development. +- Cloudflare R2 (Or any S3-compatible storage service) for file storage +- (Optional) SMTP server for email notifications -### Setup +### Setup - Local Development 1. Clone the repository: @@ -40,69 +31,15 @@ This project is built using Java (duh), Spring Boot, and MongoDB. It follows the git clone https://github.com/LensBridge/LensBridgeBackend.git ``` -2. Create the `application.properties` file in `src/main/resources/` with the following content: +2. Create the `application.properties` file: -``` -# Server configuration -server.port=8080 -# Note: Uncomment the following lines to enable SSL (for production use) -#server.ssl.enabled= -#server.ssl.key-store= -#server.ssl.key-store-type= -#server.ssl.key-store-password= -#server.ssl.key-alias= -#server.address=0.0.0.0 - -# Database configuration - MongoDB -spring.data.mongodb.uri= -spring.data.mongodb.database= - -# For CORS - Fill in with the frontend URL. -frontend.baseurl= - -# Cloudflare R2 configuration -cloudflare.r2.access-key-id=your-access-key-id -cloudflare.r2.secret-access-key=your-secret-access-key -cloudflare.r2.endpoint=https://your-account-id.r2.cloudflarestorage.com -cloudflare.r2.bucket-name=your-bucket-name -cloudflare.r2.public-url=https://your-account-id.r2.cloudflarestorage.com/your-bucket-name - -# Uploads Config -uploads.video.maxduration=240 -uploads.max-size=1000000000 -uploads.allowed-file-types=video/mp4,video/quicktime,video/x-matroska,video/x-msvideo,image/jpeg,image/png,image/heic -# Rate limiting configuration -ratelimit.requests=100 -ratelimit.duration.minutes=1 - -# File upload configuration -spring.servlet.multipart.max-file-size=100MB -spring.servlet.multipart.max-request-size=150MB - -# Logging configuration -logging.level.com.ibrasoft.lensbridge=INFO -logging.level.org.springframework.security=DEBUG -logging.level.org.springframework.mail=DEBUG -logging.level.org.springframework.mail.javamail=DEBUG - -# SMTP Configuration for email notifications -spring.mail.host= -spring.mail.port= -spring.mail.username= -spring.mail.password= -spring.mail.properties.mail.smtp.auth= -spring.mail.properties.mail.smtp.ssl.enable= -spring.mail.properties.mail.smtp.ssl.trust= -spring.mail.properties.mail.smtp.connectiontimeout= -spring.mail.properties.mail.smtp.timeout=10000 -spring.mail.properties.mail.smtp.writetimeout -spring.mail.properties.mail.debug= -spring.mail.from= -spring.mail.from.name= - -lensbridge.app.jwtSecret= -lensbridge.app.jwtExpirationMs= -``` + ```bash + cp src/main/resources/application.properties.example src/main/resources/application.properties + ``` + + Then edit `application.properties` to configure your database connection, R2 credentials, and other settings. + + > I suggest using SQlite for local development to avoid the overhead of setting up a full database. Just change the datasource URL to `jdbc:sqlite:lensbridge.db` and remove the username/password. The dependency is already included in the project. 3. Build the project using Maven: @@ -120,6 +57,24 @@ lensbridge.app.jwtExpirationMs= And that's it! You now have a running instance of the LensBridge backend. Pair this with the frontend application to create a complete LensBridge experience. +## Deployment + +This project utilizes Docker for containerization, making it easy to deploy on any platform that supports Docker (AWS, Heroku, DigitalOcean, etc.). A `Dockerfile` is included in the repository. To build and run the Docker container, use the following commands: + +```bash +docker compose up --build +``` + +...aaaand that's it. Pretty anti-climactic. Make sure to include your SSL certs in the `docker/nginx/certs` directory and configure the environment variables accordingly. Once the container is running, the API will be accessible at `https://localhost/api/`. + ## API Documentation -*Coming soon* \ No newline at end of file +This project uses SpringDoc OpenAPI to generate API documentation. Once the application is running, you can access the API docs at: + +``` +http://localhost:8080/swagger-ui.html +``` + +Consider disabling this in production. This _is_ an open source project, though; Not sure how effective that would be. + +A local copy of the API documentation will be added sometime in the distant future \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..44f17fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - lensbridge-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + lensbridge: + build: + context: . + dockerfile: Dockerfile + image: lensbridge:latest + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: prod + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + ONBOARDING_SECRET: ${ONBOARDING_SECRET} + CLOUDFLARE_R2_ACCESS_KEY_ID: ${CLOUDFLARE_R2_ACCESS_KEY_ID} + CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${CLOUDFLARE_R2_SECRET_ACCESS_KEY} + CLOUDFLARE_R2_BUCKET_NAME: ${CLOUDFLARE_R2_BUCKET_NAME} + CLOUDFLARE_R2_PUBLIC_URL: ${CLOUDFLARE_R2_PUBLIC_URL} + MAIL_USERNAME: ${MAIL_USERNAME} + MAIL_PASSWORD: ${MAIL_PASSWORD} + FRONTEND_BASEURL: ${FRONTEND_BASEURL} + MUSALLAHBOARD_BASEURL: ${MUSALLAHBOARD_BASEURL} + SPRINGDOC_API_DOCS_ENABLED: ${SPRINGDOC_API_DOCS_ENABLED:-false} + volumes: + - thumbnails:/app/thumbnails + networks: + - lensbridge-net + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/actuator/health | grep -q '\"status\":\"UP\"' || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + depends_on: + lensbridge: + condition: service_healthy + ports: + - "80:80" + - "443:443" + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx/certs:/etc/nginx/certs:ro + networks: + - lensbridge-net + +networks: + lensbridge-net: + driver: bridge + +volumes: + postgres-data: + thumbnails: diff --git a/src/test/java/com/ibrasoft/lensbridge/service/UploadServiceIntegrationTest.java b/docker/nginx/certs/.gitkeep similarity index 100% rename from src/test/java/com/ibrasoft/lensbridge/service/UploadServiceIntegrationTest.java rename to docker/nginx/certs/.gitkeep diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..dff1b4c --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,111 @@ +worker_processes auto; +worker_rlimit_nofile 8192; + +events { + worker_connections 4096; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + server_tokens off; + + # Matches Spring's spring.servlet.multipart.max-request-size + client_max_body_size 5000m; + client_body_timeout 300s; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + image/svg+xml; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # WebSocket upgrade map + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream lensbridge_upstream { + server lensbridge:8080; + keepalive 32; + } + + # HTTP -> HTTPS redirect + server { + listen 80; + listen [::]:80; + server_name api.lensbridge.tech; + + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS + server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name api.lensbridge.tech; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Actuator endpoints are for the container's internal healthcheck only + location /actuator/ { + return 404; + } + + location / { + proxy_pass http://lensbridge_upstream; + + proxy_http_version 1.1; + 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; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # WebSocket support (/api/agent/ws, /api/dashboard/ws/**) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + proxy_buffering off; + } + } +} diff --git a/docs/BOARD_ADMIN_API.md b/docs/BOARD_ADMIN_API.md deleted file mode 100644 index f00f081..0000000 --- a/docs/BOARD_ADMIN_API.md +++ /dev/null @@ -1,444 +0,0 @@ -# Board Admin API Specification - -This document describes the admin endpoints for managing Musallah Board content. These endpoints are used by the LensBridge Admin Console to manage posters, calendar events, board configuration, and weekly content. - -> **Authentication**: All endpoints require `ROLE_ROOT` authorization. Include the JWT token in the `Authorization: Bearer ` header. - -**Base URL**: `/api/admin/board` - ---- - -## Data Models - -### BoardLocation (Enum) -``` -BROTHERS_MUSALLAH -SISTERS_MUSALLAH -``` - -### Audience (Enum) -``` -BROTHERS -SISTERS -BOTH -``` - -### BoardConfig -```json -{ - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "Toronto", - "country": "Canada", - "latitude": 43.6532, - "longitude": -79.3832, - "timezone": "America/Toronto", - "method": 2 - }, - "posterCycleInterval": 10000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 15, - "enableScrollingMessage": true, - "scrollingMessage": "Welcome to the Musallah" -} -``` - -### Poster -```json -{ - "id": "uuid", - "title": "Poster Title", - "image": "posters/poster-uuid.jpg", - "duration": 10, - "startDate": "2026-01-15", - "endDate": "2026-02-15", - "audience": "BOTH" -} -``` -- `duration`: Display duration in seconds -- `startDate`: Date from which the poster is active (inclusive) -- `endDate`: Date until the poster is active (exclusive) - -### Event -```json -{ - "id": "uuid", - "name": "Event Name", - "description": "Event description", - "location": "Main Hall", - "startTimestamp": 1737158400000, - "endTimestamp": 1737162000000, - "allDay": false, - "audience": "BROTHERS" -} -``` -- Timestamps are in milliseconds since Unix epoch - -### WeeklyContent -```json -{ - "weekId": { - "year": 2026, - "weekNumber": 3 - }, - "verse": { - "arabic": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ", - "transliteration": "Bismillah ir-Rahman ir-Raheem", - "translation": "In the name of Allah, the Most Gracious, the Most Merciful", - "reference": "Surah Al-Fatiha 1:1" - }, - "hadith": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "Sahih Bukhari" - }, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } -} -``` - ---- - -## Board Configuration Endpoints - -### Get All Board Configs -``` -GET /configs -``` -**Response**: `200 OK` - Array of `BoardConfig` - -### Get Board Config -``` -GET /configs/{boardLocation} -``` -**Path Parameters**: -- `boardLocation`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Response**: `200 OK` - `BoardConfig` - -### Create/Replace Board Config -``` -PUT /configs/{boardLocation} -``` -**Path Parameters**: -- `boardLocation`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Request Body**: `BoardConfig` (full object) - -**Response**: `200 OK` - Saved `BoardConfig` - -### Update Board Config (Partial) -``` -PATCH /configs/{boardLocation} -``` -**Path Parameters**: -- `boardLocation`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Request Body**: (all fields optional) -```json -{ - "location": { ... }, - "posterCycleInterval": 15000, - "refreshAfterIshaaMinutes": 45, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 20, - "enableScrollingMessage": false, - "scrollingMessage": "New message" -} -``` - -**Response**: `200 OK` - Updated `BoardConfig` - ---- - -## Weekly Content Endpoints - -### Get All Weekly Content -``` -GET /weekly-content -``` -**Response**: `200 OK` - Array of `WeeklyContent` - -### Get Weekly Content by Year -``` -GET /weekly-content/year/{year} -``` -**Path Parameters**: -- `year`: Integer (e.g., 2026) - -**Response**: `200 OK` - Array of `WeeklyContent` - -### Get Specific Week's Content -``` -GET /weekly-content/{year}/{weekNumber} -``` -**Path Parameters**: -- `year`: Integer (e.g., 2026) -- `weekNumber`: Integer 1-53 - -**Response**: `200 OK` - `WeeklyContent` - -### Create/Update Weekly Content -``` -PUT /weekly-content -``` -**Request Body**: -```json -{ - "year": 2026, - "weekNumber": 3, - "verse": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "..." - }, - "hadith": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "..." - }, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } -} -``` -- `year` and `weekNumber` are required -- `verse`, `hadith`, and `jummahPrayer` are optional (partial updates supported) - -**Response**: `200 OK` - Saved `WeeklyContent` - -### Delete Weekly Content -``` -DELETE /weekly-content/{year}/{weekNumber} -``` -**Path Parameters**: -- `year`: Integer -- `weekNumber`: Integer 1-53 - -**Response**: `200 OK` -```json -{ - "message": "Weekly content deleted successfully" -} -``` - ---- - -## Poster Endpoints - -### Get All Posters -``` -GET /posters -``` -**Response**: `200 OK` - Array of `Poster` (sorted by startDate descending, newest first) - -### Get Posters by Board -``` -GET /posters/by-board?board={boardLocation} -``` -**Query Parameters**: -- `board`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Response**: `200 OK` - Array of `Poster` matching the board's audience or `BOTH` - -### Get Single Poster -``` -GET /posters/{posterId} -``` -**Path Parameters**: -- `posterId`: UUID - -**Response**: `200 OK` - `Poster` - -### Create Poster -``` -POST /posters -Content-Type: multipart/form-data -``` -**Form Data**: -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `title` | string | Yes | Poster title | -| `duration` | int | Yes | Display duration in seconds | -| `startDate` | string | Yes | ISO date (YYYY-MM-DD) | -| `endDate` | string | Yes | ISO date (YYYY-MM-DD) | -| `audience` | string | Yes | `BROTHERS`, `SISTERS`, or `BOTH` | -| `image` | file | Yes | Image file (max 10MB) | - -**Response**: `201 Created` - Created `Poster` - -### Update Poster Metadata -``` -PATCH /posters/{posterId} -``` -**Path Parameters**: -- `posterId`: UUID - -**Request Body**: (all fields optional) -```json -{ - "title": "Updated Title", - "duration": 15, - "startDate": "2026-01-20", - "endDate": "2026-02-20", - "audience": "SISTERS" -} -``` - -**Response**: `200 OK` - Updated `Poster` - -### Update Poster Image -``` -PUT /posters/{posterId}/image -Content-Type: multipart/form-data -``` -**Path Parameters**: -- `posterId`: UUID - -**Form Data**: -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `image` | file | Yes | New image file (max 10MB) | - -**Response**: `200 OK` - Updated `Poster` - -### Delete Poster -``` -DELETE /posters/{posterId} -``` -**Path Parameters**: -- `posterId`: UUID - -**Response**: `200 OK` -```json -{ - "message": "Poster deleted successfully" -} -``` - ---- - -## Calendar Event Endpoints - -### Get All Events -``` -GET /events -``` -**Response**: `200 OK` - Array of `Event` (sorted by startTimestamp ascending) - -### Get Events by Board -``` -GET /events/by-board?board={boardLocation} -``` -**Query Parameters**: -- `board`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Response**: `200 OK` - Array of `Event` matching the board's audience or `BOTH` - -### Get Single Event -``` -GET /events/{eventId} -``` -**Path Parameters**: -- `eventId`: UUID - -**Response**: `200 OK` - `Event` - -### Create Event -``` -POST /events -``` -**Request Body**: -```json -{ - "name": "Event Name", - "description": "Event description", - "location": "Main Hall", - "startTimestamp": 1737158400000, - "endTimestamp": 1737162000000, - "allDay": false, - "audience": "BOTH" -} -``` -- `name` and `audience` are required -- Timestamps are in milliseconds since Unix epoch - -**Response**: `201 Created` - Created `Event` - -### Update Event -``` -PATCH /events/{eventId} -``` -**Path Parameters**: -- `eventId`: UUID - -**Request Body**: (all fields optional) -```json -{ - "name": "Updated Name", - "description": "Updated description", - "location": "New Location", - "startTimestamp": 1737244800000, - "endTimestamp": 1737248400000, - "allDay": true, - "audience": "SISTERS" -} -``` - -**Response**: `200 OK` - Updated `Event` - -### Delete Event -``` -DELETE /events/{eventId} -``` -**Path Parameters**: -- `eventId`: UUID - -**Response**: `200 OK` -```json -{ - "message": "Calendar event deleted successfully" -} -``` - ---- - -## Error Responses - -### 400 Bad Request -```json -{ - "error": "Validation error message" -} -``` - -### 401 Unauthorized -Missing or invalid JWT token. - -### 403 Forbidden -User does not have `ROLE_ROOT`. - -### 404 Not Found -```json -{ - "error": "Resource not found with id: ..." -} -``` - -### 413 Payload Too Large -```json -{ - "error": "Poster image must be less than 10MB" -} -``` diff --git a/docs/MUSALLAH_BOARD_API.md b/docs/MUSALLAH_BOARD_API.md deleted file mode 100644 index d1b0526..0000000 --- a/docs/MUSALLAH_BOARD_API.md +++ /dev/null @@ -1,366 +0,0 @@ -# Musallah Board API Specification - -This document describes the public API endpoints consumed by the Musallah Board display hardware. These endpoints return data formatted specifically for rendering on the board screens. - -> **Authentication**: These endpoints are **publicly accessible** (no authentication required) as they are consumed by the board display hardware. - -**Base URL**: `/api/musallah` - ---- - -## Data Models - -### BoardLocation (Enum) -Used as query parameter to specify which board is requesting data: -``` -BROTHERS_MUSALLAH -SISTERS_MUSALLAH -``` - -### Audience (Enum) -``` -BROTHERS -SISTERS -BOTH -``` -- The API automatically filters content based on the requesting board: - - `BROTHERS_MUSALLAH` → Returns content with audience `BROTHERS` or `BOTH` - - `SISTERS_MUSALLAH` → Returns content with audience `SISTERS` or `BOTH` - -### BoardConfig -Display settings for the board: -```json -{ - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "Toronto", - "country": "Canada", - "latitude": 43.6532, - "longitude": -79.3832, - "timezone": "America/Toronto", - "method": 2 - }, - "posterCycleInterval": 10000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 15, - "enableScrollingMessage": true, - "scrollingMessage": "Welcome to the Musallah" -} -``` -- `posterCycleInterval`: Milliseconds between poster transitions -- `location.method`: Prayer calculation method (for Adhan times) - -### FrameDefinition (Poster Frame) -Poster data formatted for display: -```json -{ - "frameType": "POSTER", - "durationInSeconds": 10, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-uuid.jpg", - "title": "Poster Title" - } -} -``` -- `frameType`: Always `"POSTER"` for poster frames -- `durationInSeconds`: How long to display this poster -- `frameConfig.posterUrl`: Full URL to the poster image - -### Event -Calendar event for display: -```json -{ - "id": "uuid", - "name": "Event Name", - "description": "Event description", - "location": "Main Hall", - "startTimestamp": 1737158400000, - "endTimestamp": 1737162000000, - "allDay": false, - "audience": "BROTHERS" -} -``` -- Timestamps are in **milliseconds** since Unix epoch - -### WeeklyContent -Weekly rotating content (verse, hadith, Jummah info): -```json -{ - "weekId": { - "year": 2026, - "weekNumber": 3 - }, - "verse": { - "arabic": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ", - "transliteration": "Bismillah ir-Rahman ir-Raheem", - "translation": "In the name of Allah, the Most Gracious, the Most Merciful", - "reference": "Surah Al-Fatiha 1:1" - }, - "hadith": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "Sahih Bukhari" - }, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } -} -``` - -### MusallahBoardPayload -Combined payload containing all data needed for the board: -```json -{ - "boardConfig": { ... }, - "posterFrames": [ ... ], - "upcomingEvents": [ ... ], - "weeklyContent": { ... } -} -``` - ---- - -## Endpoints - -### Get Board Configuration -``` -GET /config?board={boardLocation} -``` -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - `BoardConfig` - -**Response**: `404 Not Found` - If no config exists for this board - ---- - -### Get Current Weekly Content -``` -GET /weekly-content -``` -Returns the weekly content for the **current week** (based on server date). - -**Response**: `200 OK` - `WeeklyContent` - -**Response**: `404 Not Found` - If no content is set for the current week - ---- - -### Get Specific Week's Content -``` -GET /weekly-content/{year}/{weekNumber} -``` -**Path Parameters**: -| Parameter | Type | Description | -|-----------|------|-------------| -| `year` | int | Year (e.g., 2026) | -| `weekNumber` | int | ISO week number (1-53) | - -**Response**: `200 OK` - `WeeklyContent` - -**Response**: `404 Not Found` - If no content exists for this week - ---- - -### Get Active Poster Frames -``` -GET /posters?board={boardLocation} -``` -Returns poster frames that are: -1. Currently active (today is within `startDate` to `endDate`) -2. Match the board's audience (or `BOTH`) - -Sorted by `startDate` descending (newest posters first). - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - Array of `FrameDefinition` - -**Example Response**: -```json -[ - { - "frameType": "POSTER", - "durationInSeconds": 10, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-1.jpg", - "title": "Friday Lecture" - } - }, - { - "frameType": "POSTER", - "durationInSeconds": 15, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-2.jpg", - "title": "Community Event" - } - } -] -``` - ---- - -### Get Upcoming Events -``` -GET /events?board={boardLocation} -``` -Returns events that: -1. Have `startTimestamp >= now` -2. Match the board's audience (or `BOTH`) - -Sorted by `startTimestamp` ascending (soonest events first). - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - Array of `Event` - ---- - -### Get Events in Time Range -``` -GET /events/range?board={boardLocation}&start={startTimestamp}&end={endTimestamp} -``` -Returns events that **overlap** with the given time range. Useful for "week at a glance" or daily schedule views. - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | -| `start` | long | Yes | Range start (milliseconds since epoch) | -| `end` | long | Yes | Range end (milliseconds since epoch) | - -**Response**: `200 OK` - Array of `Event` - -**Example**: Get events for the current week -``` -GET /events/range?board=BROTHERS_MUSALLAH&start=1737072000000&end=1737676800000 -``` - ---- - -### Get Full Board Payload (Recommended) -``` -GET /payload?board={boardLocation} -``` -Returns **all data** needed for the board in a single request. This is the **recommended endpoint** for initial load and periodic refresh as it reduces the number of API calls. - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - `MusallahBoardPayload` - -**Example Response**: -```json -{ - "boardConfig": { - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "Toronto", - "country": "Canada", - "latitude": 43.6532, - "longitude": -79.3832, - "timezone": "America/Toronto", - "method": 2 - }, - "posterCycleInterval": 10000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 15, - "enableScrollingMessage": true, - "scrollingMessage": "Welcome to the Musallah" - }, - "posterFrames": [ - { - "frameType": "POSTER", - "durationInSeconds": 10, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-1.jpg", - "title": "Friday Lecture" - } - } - ], - "upcomingEvents": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Friday Prayer", - "description": "Weekly Jummah prayer", - "location": "Main Hall", - "startTimestamp": 1737135000000, - "endTimestamp": 1737138600000, - "allDay": false, - "audience": "BOTH" - } - ], - "weeklyContent": { - "weekId": { - "year": 2026, - "weekNumber": 3 - }, - "verse": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "..." - }, - "hadith": null, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } - } -} -``` - -**Note**: Fields may be `null` if no data is configured: -- `boardConfig`: `null` if no config exists for the board -- `weeklyContent`: `null` if no content is set for the current week -- `posterFrames`: Empty array `[]` if no active posters -- `upcomingEvents`: Empty array `[]` if no upcoming events - ---- - -## Usage Recommendations - -### Initial Load -On application start, call `/payload` to get all data at once: -```javascript -const response = await fetch('/api/musallah/payload?board=BROTHERS_MUSALLAH'); -const payload = await response.json(); -``` - -### Periodic Refresh -Use the `refreshAfterIshaaMinutes` from `boardConfig` to determine when to refresh data after Isha prayer. Call `/payload` again to get updated content. - -### Poster Display Loop -1. Use `posterFrames` array from the payload -2. Display each poster for its `durationInSeconds` -3. Loop back to the first poster after displaying all -4. If `posterFrames` is empty, show a default frame - -### Dark Mode -Use `boardConfig.darkModeAfterIsha` and `boardConfig.darkModeMinutesAfterIsha` along with Isha prayer time to enable dark mode automatically. - -### Prayer Times -Use `boardConfig.location` (latitude, longitude, method) with a prayer time calculation library (e.g., Adhan.js) to calculate prayer times locally. diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..e2cc5c8 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,4646 @@ +openapi: 3.1.0 +info: + description: LensBridge backend — media upload and MusallahBoard management + title: LensBridge API + version: 0.0.1-SNAPSHOT +servers: +- url: / +security: +- bearerAuth: [] +paths: + /api/admin/audit: + get: + operationId: getAuditEvents + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/action/{action}: + get: + operationId: getAuditEventsByAction + parameters: + - in: path + name: action + required: true + schema: + type: string + enum: + - APPROVE_UPLOAD + - UNAPPROVE_UPLOAD + - DELETE_UPLOAD + - FEATURE_UPLOAD + - UNFEATURE_UPLOAD + - CREATE_EVENT + - UPDATE_EVENT + - DELETE_EVENT + - CREATE_CALENDAR_EVENT + - UPDATE_CALENDAR_EVENT + - DELETE_CALENDAR_EVENT + - CREATE_POSTER + - UPDATE_POSTER + - DELETE_POSTER + - CREATE_SOCIAL + - UPDATE_SOCIAL + - DELETE_SOCIAL + - SAVE_WEEKLY_CONTENT + - DELETE_WEEKLY_CONTENT + - UPDATE_BOARD_CONFIG + - UPDATE_BOARD_TICKER + - REFRESH_BOARDS + - ISSUE_ENROLLMENT_TOKEN + - REVOKE_DEVICE + - ISSUE_DEVICE_COMMAND + - PROMOTE_USER + - DEMOTE_USER + - DISABLE_USER + - ENABLE_USER + - VIEW_AUDIT_LOGS + - EXPORT_DATA + - SYSTEM_MAINTENANCE + - VERIFY_USER + - UNVERIFY_USER + - RESET_USER_PASSWORD + - TRIGGER_PASSWORD_RESET_EMAIL + - ADD_USER_ROLE + - REMOVE_USER_ROLE + - GRANT_PERMISSION + - REVOKE_PERMISSION + - ADD_USER + - REMOVE_USER + - UPDATE_USER + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/actions: + get: + operationId: getAvailableAuditActions + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: string + enum: + - APPROVE_UPLOAD + - UNAPPROVE_UPLOAD + - DELETE_UPLOAD + - FEATURE_UPLOAD + - UNFEATURE_UPLOAD + - CREATE_EVENT + - UPDATE_EVENT + - DELETE_EVENT + - CREATE_CALENDAR_EVENT + - UPDATE_CALENDAR_EVENT + - DELETE_CALENDAR_EVENT + - CREATE_POSTER + - UPDATE_POSTER + - DELETE_POSTER + - CREATE_SOCIAL + - UPDATE_SOCIAL + - DELETE_SOCIAL + - SAVE_WEEKLY_CONTENT + - DELETE_WEEKLY_CONTENT + - UPDATE_BOARD_CONFIG + - UPDATE_BOARD_TICKER + - REFRESH_BOARDS + - ISSUE_ENROLLMENT_TOKEN + - REVOKE_DEVICE + - ISSUE_DEVICE_COMMAND + - PROMOTE_USER + - DEMOTE_USER + - DISABLE_USER + - ENABLE_USER + - VIEW_AUDIT_LOGS + - EXPORT_DATA + - SYSTEM_MAINTENANCE + - VERIFY_USER + - UNVERIFY_USER + - RESET_USER_PASSWORD + - TRIGGER_PASSWORD_RESET_EMAIL + - ADD_USER_ROLE + - REMOVE_USER_ROLE + - GRANT_PERMISSION + - REVOKE_PERMISSION + - ADD_USER + - REMOVE_USER + - UPDATE_USER + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List the audit action types available for filtering + tags: + - admin-controller + /api/admin/audit/daterange: + get: + operationId: getAuditEventsByDateRange + parameters: + - in: query + name: start + required: true + schema: + type: string + format: date-time + - in: query + name: end + required: true + schema: + type: string + format: date-time + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/failed: + get: + operationId: getFailedOperations + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/upload/{uploadId}: + get: + operationId: getUploadAuditHistory + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/board/configs: + get: + operationId: getAllBoardConfigs + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/configs/{deviceId}: + get: + operationId: getAdminBoardConfig + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a device config as an administrator + tags: + - board-admin-controller + patch: + operationId: updateBoardConfig + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateBoardConfigRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + put: + operationId: saveBoardConfig + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/configs/{deviceId}/ticker: + patch: + operationId: updateBoardTicker + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTickerRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Update a board's scrolling messages + tags: + - board-admin-controller + /api/admin/board/devices: + get: + operationId: listDevices + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DeviceSummary" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List every enrolled board device + tags: + - device-admin-controller + /api/admin/board/devices/enrollment-tokens: + post: + operationId: issueEnrollmentToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IssueEnrollmentTokenRequest" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/IssueEnrollmentTokenResponse" + description: Token issued; the plaintext is returned exactly once + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Mint a one-time token a new device can exchange for an identity + tags: + - device-admin-controller + /api/admin/board/devices/{deviceId}: + get: + operationId: getDevice + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceSummary" + description: Device found + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: No device with that id + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a single board device + tags: + - device-admin-controller + patch: + operationId: updateDevice + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateDeviceRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceSummary" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/devices/{deviceId}/commands: + get: + operationId: listDeviceCommands + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/CommandView" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch the 50 most recent commands for a device + tags: + - device-admin-controller + post: + description: "Returns as soon as the command is queued; delivery and execution\ + \ are reported asynchronously over the agent websocket. The permission required\ + \ depends on the command kind: board:command:benign, :disruptive, or :inspect." + operationId: issueDeviceCommand + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCommandRequest" + required: true + responses: + "202": + content: + application/json: + schema: + $ref: "#/components/schemas/CommandIssuedResponse" + description: Command accepted and queued for delivery + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Caller lacks the permission for this command kind + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Queue a command for a device + tags: + - device-admin-controller + /api/admin/board/devices/{deviceId}/revoke: + post: + description: "Closes any live agent session. Idempotent: re-revoking an already\ + \ revoked device is a no-op." + operationId: revokeDevice + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceSummary" + description: Device revoked + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: No device with that id + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke a device's enrollment + tags: + - device-admin-controller + /api/admin/board/events: + get: + operationId: getBoardEvents + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List every board event + tags: + - board-admin-controller + post: + operationId: createBoardEvent + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCalendarEventRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Create a board event + tags: + - board-admin-controller + /api/admin/board/events/by-audience: + get: + operationId: getEventsForAudience + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/events/{eventId}: + delete: + operationId: deleteEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getBoardEventById + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a single board event + tags: + - board-admin-controller + patch: + operationId: updateEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCalendarEventRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters: + get: + operationId: getAllPosters + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + post: + operationId: createPoster + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/CreatePosterRequest" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters/by-audience: + get: + operationId: getPostersForAudience + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters/{posterId}: + delete: + operationId: deletePoster + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getPosterById + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + patch: + operationId: updatePoster + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePosterRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters/{posterId}/image: + put: + operationId: updatePosterImage + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + required: + - image + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/refresh: + post: + operationId: refreshSignboard + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/socials: + get: + operationId: getAllSocials + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + post: + operationId: createSocial + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePromotableSocialMediaRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/socials/by-audience: + get: + operationId: getSocialsForAudience + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/socials/{socialId}: + delete: + operationId: deleteSocial + parameters: + - in: path + name: socialId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getSocialById + parameters: + - in: path + name: socialId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + patch: + operationId: updateSocial + parameters: + - in: path + name: socialId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePromotableSocialMediaRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/weekly-content: + get: + operationId: getAllWeeklyContent + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/weekly-content/year/{year}: + get: + operationId: getWeeklyContentByYear + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/weekly-content/{year}/{weekNumber}: + delete: + operationId: deleteWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getAdminWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch weekly content as an administrator + tags: + - board-admin-controller + put: + operationId: saveWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContentRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/create-event: + post: + operationId: createMediaEvent + parameters: + - in: query + name: eventName + required: true + schema: + type: string + - in: query + name: eventDate + required: true + schema: + type: string + format: date-time + - in: query + name: status + required: false + schema: + type: string + enum: + - UPCOMING + - ONGOING + - PAST + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Create a media event + tags: + - admin-controller + /api/admin/events: + get: + operationId: getAllEventsForAdmin + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MediaEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: "List every event, including unpublished ones" + tags: + - admin-controller + /api/admin/feature-upload/{uploadId}: + post: + operationId: featureUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/permissions: + get: + operationId: getAvailablePermissions + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PermissionResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: "List every permission, for building a direct-grant picker" + tags: + - admin-controller + /api/admin/roles: + get: + operationId: getAvailableRoles + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RoleDefinitionResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List assignable roles with the permissions each one confers + tags: + - admin-controller + /api/admin/upload/{uploadId}: + delete: + operationId: deleteUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + post: + operationId: approveUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/upload/{uploadId}/approval: + delete: + operationId: unapproveUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/upload/{uploadId}/featured: + delete: + operationId: unfeatureUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/uploads: + get: + operationId: getAdminUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: "Page through every upload, any approval state" + tags: + - admin-controller + /api/admin/uploads/approved: + get: + operationId: getApprovedUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/uploads/featured: + get: + operationId: getFeaturedUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/uploads/pending: + get: + operationId: getPendingUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/create: + post: + description: "Omit the password — the normal case — and the account is created\ + \ disabled and the user is emailed a password reset link; setting a password\ + \ through that link is what enables the account. Supply a password only when\ + \ the account has to be usable immediately: it is then enabled on creation\ + \ and no email is sent." + operationId: createUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Create an account for someone else + tags: + - admin-controller + /api/admin/user/verify: + post: + operationId: verifyUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyUserRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/{userId}/add-role: + post: + operationId: addRoleToUser + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - USER + - ADMIN + - BOARD_VIEWER + - BOARD_EDITOR + - BOARD_ADMIN + - ROOT + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/{userId}/grant-permission: + post: + operationId: grantUserPermission + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - MEDIA_UPLOAD_SELF + - MEDIA_UPLOAD_MODERATE + - MEDIA_UPLOAD_READ + - MEDIA_EVENT_WRITE + - BOARD_CONTENT_READ + - BOARD_POSTER_WRITE + - BOARD_EVENT_WRITE + - BOARD_SOCIAL_WRITE + - BOARD_WEEKLY_WRITE + - BOARD_TICKER_WRITE + - BOARD_CONFIG_READ + - BOARD_CONFIG_WRITE + - BOARD_REFRESH + - BOARD_DEVICE_READ + - BOARD_DEVICE_ENROLL + - BOARD_DEVICE_REVOKE + - BOARD_TELEMETRY_SUBSCRIBE + - BOARD_COMMAND_BENIGN + - BOARD_COMMAND_DISRUPTIVE + - BOARD_COMMAND_INSPECT + - IAM_USER_READ + - IAM_USER_WRITE + - IAM_ROLE_GRANT + - AUDIT_READ + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Grant a single permission directly to a user + tags: + - admin-controller + /api/admin/user/{userId}/remove-role: + post: + operationId: removeRoleFromUser + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - USER + - ADMIN + - BOARD_VIEWER + - BOARD_EDITOR + - BOARD_ADMIN + - ROOT + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/{userId}/revoke-permission: + post: + operationId: revokeUserPermission + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - MEDIA_UPLOAD_SELF + - MEDIA_UPLOAD_MODERATE + - MEDIA_UPLOAD_READ + - MEDIA_EVENT_WRITE + - BOARD_CONTENT_READ + - BOARD_POSTER_WRITE + - BOARD_EVENT_WRITE + - BOARD_SOCIAL_WRITE + - BOARD_WEEKLY_WRITE + - BOARD_TICKER_WRITE + - BOARD_CONFIG_READ + - BOARD_CONFIG_WRITE + - BOARD_REFRESH + - BOARD_DEVICE_READ + - BOARD_DEVICE_ENROLL + - BOARD_DEVICE_REVOKE + - BOARD_TELEMETRY_SUBSCRIBE + - BOARD_COMMAND_BENIGN + - BOARD_COMMAND_DISRUPTIVE + - BOARD_COMMAND_INSPECT + - IAM_USER_READ + - IAM_USER_WRITE + - IAM_ROLE_GRANT + - AUDIT_READ + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke a directly-granted permission from a user + tags: + - admin-controller + /api/admin/users: + get: + operationId: getAllUsers + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageUserInfoResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/agent/enroll: + post: + description: Called once per device by the MusallahBoard agent. The returned + websocketUrl is persisted verbatim into the agent's config and never requested + again. + operationId: enrollAgent + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AgentEnrollRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AgentEnrollResponse" + description: Device enrolled; identity and websocket URL returned + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Public key malformed or unsupported + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Enrollment token invalid, expired, or already used" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Exchange a one-time enrollment token for a device identity + tags: + - agent-enrollment-controller + /api/auth/change-password: + post: + description: "Revokes every refresh token for the user, so all sessions must\ + \ sign in again." + operationId: changePassword + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ChangePasswordRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Password changed; all sessions revoked + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Current password incorrect or new password rejected + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Not signed in + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Change the signed-in user's password + tags: + - auth-controller + /api/auth/forgot-password: + post: + description: Always reports success so the response cannot be used to probe + which addresses are registered. + operationId: requestPasswordReset + parameters: + - in: query + name: email + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request accepted + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Email missing + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Send a password reset email + tags: + - auth-controller + /api/auth/logout: + post: + description: "Succeeds even when no token is supplied, so signing out is idempotent." + operationId: logout + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshRequest" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Session ended + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke a single refresh token + tags: + - auth-controller + /api/auth/logout-all-devices: + post: + operationId: logoutAllDevices + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: All sessions ended + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Not signed in + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke every refresh token for the signed-in user + tags: + - auth-controller + /api/auth/refresh-token: + post: + description: "Rotates the refresh token: the supplied one is revoked and a new\ + \ one returned." + operationId: refreshToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshResponse" + description: New access and refresh tokens issued + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Refresh token unknown, expired or already revoked" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Exchange a refresh token for a new access token + tags: + - auth-controller + /api/auth/reset-password: + post: + description: "The token and password travel in the request body, not the query\ + \ string: query strings are recorded in access logs, proxies and browser history." + operationId: resetPassword + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResetPasswordRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Password reset + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Token or password missing, invalid or expired" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Set a new password using a reset token + tags: + - auth-controller + /api/auth/signin: + post: + operationId: signIn + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/JwtResponse" + description: Authenticated; access and refresh tokens issued + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Bad credentials or disabled account + "429": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Too many failed attempts; account temporarily locked + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Authenticate with email and password + tags: + - auth-controller + /api/auth/signup: + post: + operationId: signUp + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Account created; verification email sent + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Validation failed or email already registered + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Register a new account + tags: + - auth-controller + /api/auth/validate-reset-token: + post: + description: "A POST despite being a read: the token is a credential, and only\ + \ a body keeps it out of access logs, proxy logs and browser history. Being\ + \ a POST also stops the result being cached by an intermediary." + operationId: validateResetToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ValidateResetTokenRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Token is valid + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Token missing, invalid or expired" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Check whether a password reset token is still usable + tags: + - auth-controller + /api/auth/validate-token: + get: + operationId: validateToken + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TokenValidationResponse" + description: Token valid; user returned + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Token missing, expired, or user no longer exists" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Check the current access token and return the user it belongs to + tags: + - auth-controller + /api/auth/verify-email: + post: + operationId: verifyEmail + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyEmailRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Email verified + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Missing, invalid or expired token" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Confirm an email address using the token from the verification email + tags: + - auth-controller + /api/events: + get: + operationId: getPublicEvents + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MediaEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List publicly visible events + tags: + - events-controller + /api/events/{id}: + get: + operationId: getPublicEventById + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MediaEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a single publicly visible event + tags: + - events-controller + /api/gallery: + get: + operationId: getGalleryUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageGalleryItemDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Page through approved gallery media + tags: + - gallery-controller + /api/gallery/event/{eventId}: + get: + operationId: getGalleryByEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageGalleryItemDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - gallery-controller + /api/musallah/config: + get: + operationId: getPublicBoardConfig + parameters: + - in: query + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch the config a kiosk should render + tags: + - musallah-board-controller + /api/musallah/events: + get: + operationId: getUpcomingEvents + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/events/range: + get: + operationId: getEventsInRange + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + - in: query + name: start + required: true + schema: + type: string + format: date-time + - in: query + name: end + required: true + schema: + type: string + format: date-time + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/payload: + get: + operationId: getBoardPayload + parameters: + - in: query + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MusallahBoardPayload" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/weekly-content: + get: + operationId: getCurrentWeeklyContent + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/weekly-content/{year}/{weekNumber}: + get: + operationId: getPublicWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch weekly content for a kiosk + tags: + - musallah-board-controller + /api/upload/event/{eventId}: + get: + operationId: getUploadsByEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/limits: + get: + operationId: getUploadLimits + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UploadLimitsResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/{eventId}/direct/complete: + post: + operationId: completeDirectUpload + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - in: query + name: objectKey + required: true + schema: + type: string + - in: query + name: filename + required: true + schema: + type: string + - in: query + name: contentType + required: true + schema: + type: string + - in: query + name: fileSize + required: true + schema: + type: integer + format: int64 + - in: query + name: instagramHandle + required: false + schema: + type: string + - in: query + name: description + required: false + schema: + type: string + - in: query + name: anon + required: false + schema: + type: boolean + default: false + - in: query + name: expectedSha256 + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UploadCompletionResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/{eventId}/direct/presign: + post: + operationId: generatePresignedUploadUrl + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - in: query + name: filename + required: true + schema: + type: string + - in: query + name: contentType + required: true + schema: + type: string + - in: query + name: fileSize + required: true + schema: + type: integer + format: int64 + - in: query + name: expectedSha256 + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PresignedUploadResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/{uploadId}: + get: + operationId: getUploadById + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/user/profile: + get: + operationId: getUserProfile + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfoResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + patch: + operationId: updateUserProfile + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateProfileRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfoResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + /api/user/stats: + get: + operationId: getUserStats + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserStatsResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + /api/user/uploads: + get: + operationId: getUserUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageGalleryItemDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + /api/user/uploads/{uploadId}: + delete: + operationId: deleteUserUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller +components: + schemas: + AdminUploadDto: + type: object + properties: + anon: + type: boolean + approved: + type: boolean + contentType: + type: string + enum: + - image + - video + - audio + - document + createdDate: + type: string + format: date-time + displayName: + type: string + eventId: + type: string + format: uuid + eventName: + type: string + featured: + type: boolean + fileName: + type: string + fileUrl: + type: string + instagramHandle: + type: string + secureUrl: + type: string + thumbnailUrl: + type: string + uploadDescription: + type: string + uploadedBy: + type: string + format: uuid + uploaderEmail: + type: string + uploaderFirstName: + type: string + uploaderFullName: + type: string + uploaderLastName: + type: string + uploaderStudentNumber: + type: string + uuid: + type: string + format: uuid + AgendaFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + days: + type: array + items: + $ref: "#/components/schemas/DayBucket" + heading: + type: string + AgentEnrollRequest: + type: object + properties: + agentVersion: + type: string + hardwareModel: + type: string + hostname: + type: string + minLength: 1 + publicKey: + type: string + minLength: 1 + token: + type: string + minLength: 1 + required: + - hostname + - publicKey + - token + AgentEnrollResponse: + type: object + properties: + deviceId: + type: string + format: uuid + websocketUrl: + type: string + AuditEventDto: + type: object + properties: + action: + type: string + enum: + - APPROVE_UPLOAD + - UNAPPROVE_UPLOAD + - DELETE_UPLOAD + - FEATURE_UPLOAD + - UNFEATURE_UPLOAD + - CREATE_EVENT + - UPDATE_EVENT + - DELETE_EVENT + - CREATE_CALENDAR_EVENT + - UPDATE_CALENDAR_EVENT + - DELETE_CALENDAR_EVENT + - CREATE_POSTER + - UPDATE_POSTER + - DELETE_POSTER + - CREATE_SOCIAL + - UPDATE_SOCIAL + - DELETE_SOCIAL + - SAVE_WEEKLY_CONTENT + - DELETE_WEEKLY_CONTENT + - UPDATE_BOARD_CONFIG + - UPDATE_BOARD_TICKER + - REFRESH_BOARDS + - ISSUE_ENROLLMENT_TOKEN + - REVOKE_DEVICE + - ISSUE_DEVICE_COMMAND + - PROMOTE_USER + - DEMOTE_USER + - DISABLE_USER + - ENABLE_USER + - VIEW_AUDIT_LOGS + - EXPORT_DATA + - SYSTEM_MAINTENANCE + - VERIFY_USER + - UNVERIFY_USER + - RESET_USER_PASSWORD + - TRIGGER_PASSWORD_RESET_EMAIL + - ADD_USER_ROLE + - REMOVE_USER_ROLE + - GRANT_PERMISSION + - REVOKE_PERMISSION + - ADD_USER + - REMOVE_USER + - UPDATE_USER + adminEmail: + type: string + adminId: + type: string + format: uuid + adminName: + type: string + details: + type: string + id: + type: string + format: uuid + ipAddress: + type: string + targetEntityId: + type: string + format: uuid + targetEntityType: + type: string + enum: + - USER + - UPLOAD + - MUSALLAH_BOARD + - DEVICE + - EVENT + timestamp: + type: string + format: date-time + userAgent: + type: string + BoardEvent: + type: object + properties: + allDay: + type: boolean + audience: + type: string + enum: + - brothers + - sisters + - both + description: + type: string + endTime: + type: string + format: date-time + id: + type: string + format: uuid + location: + type: string + name: + type: string + startTime: + type: string + format: date-time + ChangePasswordRequest: + type: object + properties: + currentPassword: + type: string + maxLength: 256 + minLength: 6 + newPassword: + type: string + maxLength: 256 + minLength: 6 + required: + - currentPassword + - newPassword + CommandIssuedResponse: + type: object + properties: + commandId: + type: string + format: uuid + deviceId: + type: string + format: uuid + expiresAt: + type: string + format: date-time + issuedAt: + type: string + format: date-time + kind: + type: string + status: + type: string + enum: + - PENDING + - DELIVERED + - ACKED + - RUNNING + - SUCCEEDED + - FAILED + - TIMEOUT + - REJECTED + - EXPIRED + CommandView: + type: object + properties: + ackedAt: + type: string + format: date-time + deadlineMs: + type: integer + format: int32 + deliveredAt: + type: string + format: date-time + deviceId: + type: string + format: uuid + errorMessage: + type: string + expiresAt: + type: string + format: date-time + finishedAt: + type: string + format: date-time + id: + type: string + format: uuid + issuedAt: + type: string + format: date-time + issuedBy: + type: string + kind: + type: string + output: + $ref: "#/components/schemas/JsonNode" + payload: + $ref: "#/components/schemas/JsonNode" + startedAt: + type: string + format: date-time + status: + type: string + enum: + - PENDING + - DELIVERED + - ACKED + - RUNNING + - SUCCEEDED + - FAILED + - TIMEOUT + - REJECTED + - EXPIRED + CreateCalendarEventRequest: + type: object + properties: + allDay: + type: boolean + audience: + type: string + enum: + - brothers + - sisters + - both + description: + type: string + minLength: 1 + endEpochMs: + type: integer + format: int64 + location: + type: string + maxLength: 255 + minLength: 0 + name: + type: string + minLength: 1 + startEpochMs: + type: integer + format: int64 + required: + - audience + - description + - endEpochMs + - location + - name + - startEpochMs + CreatePosterRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + minimum: 0 + endTime: + type: string + format: date-time + imageFile: + type: string + format: binary + signupUrl: + type: string + pattern: "" + startTime: + type: string + format: date-time + title: + type: string + minLength: 1 + required: + - audience + - endTime + - imageFile + - startTime + - title + CreatePromotableSocialMediaRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + minimum: 0 + footerText: + type: string + minLength: 1 + handle: + type: string + headerText: + type: string + minLength: 1 + heroText: + type: string + minLength: 1 + name: + type: string + minLength: 1 + type: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string + minLength: 1 + pattern: "" + required: + - audience + - footerText + - headerText + - heroText + - name + - type + - url + CreateUserRequest: + type: object + properties: + email: + type: string + format: email + maxLength: 50 + minLength: 0 + firstName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + lastName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + password: + type: string + description: Optional. Omit it to create a disabled account and email the + user a password reset link; supply it to create an account that can sign + in right away. + maxLength: 40 + minLength: 6 + studentNumber: + type: string + maxLength: 10 + minLength: 0 + required: + - email + - firstName + - lastName + - studentNumber + DayBucket: + type: object + properties: + date: + type: string + format: date + events: + type: array + items: + $ref: "#/components/schemas/EventView" + DeviceConfig: + type: object + properties: + agendaDurationSeconds: + type: integer + format: int32 + darkModeAfterIsha: + type: boolean + enableScrollingMessage: + type: boolean + id: + type: string + format: uuid + location: + $ref: "#/components/schemas/Location" + nextPrayerDurationSeconds: + type: integer + format: int32 + scrollingMessages: + type: array + items: + type: string + DeviceSummary: + type: object + properties: + agentVersion: + type: string + audience: + type: string + enum: + - brothers + - sisters + - both + displayName: + type: string + enrolledAt: + type: string + format: date-time + hardwareModel: + type: string + id: + type: string + format: uuid + lastHeartbeat: + type: string + format: date-time + lastSeenIp: + type: string + revokedAt: + type: string + format: date-time + EventView: + type: object + properties: + allDay: + type: boolean + description: + type: string + endTime: + type: string + format: date-time + location: + type: string + name: + type: string + startTime: + type: string + format: date-time + FrameConfig: + discriminator: + mapping: + agenda: "#/components/schemas/AgendaFrameConfig" + islamic_quote: "#/components/schemas/IslamicQuoteFrameConfig" + jummah: "#/components/schemas/JummahFrameConfig" + next_prayer: "#/components/schemas/NextPrayerFrameConfig" + poster: "#/components/schemas/PosterFrameConfig" + socials: "#/components/schemas/PromotableSocialMediaFrameConfig" + propertyName: type + properties: + type: + type: string + required: + - type + FrameDefinition: + type: object + properties: + durationInSeconds: + type: integer + format: int32 + frameConfig: + oneOf: + - $ref: "#/components/schemas/AgendaFrameConfig" + - $ref: "#/components/schemas/IslamicQuoteFrameConfig" + - $ref: "#/components/schemas/JummahFrameConfig" + - $ref: "#/components/schemas/NextPrayerFrameConfig" + - $ref: "#/components/schemas/PosterFrameConfig" + - $ref: "#/components/schemas/PromotableSocialMediaFrameConfig" + frameId: + type: string + frameType: + type: string + enum: + - poster + - next_prayer + - agenda + - jummah + - islamic_quote + - socials + GalleryItemDto: + type: object + properties: + author: + type: string + date: + type: string + event: + type: string + featured: + type: boolean + id: + type: string + src: + type: string + thumbnail: + type: string + title: + type: string + type: + type: string + IslamicQuote: + type: object + properties: + arabic: + type: string + durationSeconds: + type: integer + format: int32 + id: + type: string + format: uuid + kind: + type: string + enum: + - VERSE + - HADITH + reference: + type: string + translation: + type: string + transliteration: + type: string + IslamicQuoteFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + arabic: + type: string + kind: + type: string + enum: + - VERSE + - HADITH + reference: + type: string + translation: + type: string + transliteration: + type: string + IssueCommandRequest: + type: object + properties: + deadlineMs: + type: integer + format: int32 + minimum: 0 + kind: + type: string + minLength: 1 + pattern: "[a-z]+\\.[a-z_]+" + payload: + $ref: "#/components/schemas/JsonNode" + ttlSeconds: + type: integer + format: int32 + minimum: 0 + required: + - kind + IssueEnrollmentTokenRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + displayName: + type: string + minLength: 1 + expiresInMinutes: + type: integer + format: int32 + minimum: 0 + required: + - audience + - displayName + IssueEnrollmentTokenResponse: + type: object + properties: + expiresAt: + type: string + format: date-time + token: + type: string + tokenId: + type: string + format: uuid + JsonNode: {} + JummahFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + prayers: + type: array + items: + $ref: "#/components/schemas/JummahSlot" + JummahPrayer: + type: object + properties: + id: + type: string + format: uuid + khatib: + type: string + prayerTime: + type: string + room: + type: string + JummahSlot: + type: object + properties: + khatib: + type: string + prayerTime: + type: string + room: + type: string + JwtResponse: + type: object + properties: + email: + type: string + firstName: + type: string + id: + type: string + format: uuid + lastName: + type: string + permissions: + type: array + items: + type: string + refreshToken: + type: string + roles: + type: array + items: + type: string + token: + type: string + type: + type: string + Location: + type: object + properties: + city: + type: string + country: + type: string + latitude: + type: number + format: double + longitude: + type: number + format: double + method: + type: string + enum: + - KARACHI + - ISNA + - MWL + - MAKKAH + - EGYPT + - TEHRAN + - GULF + - KUWAIT + - QATAR + - SINGAPORE + - FRANCE + - TURKEY + - RUSSIA + - DUBAI + timezone: + type: string + LoginRequest: + type: object + properties: + email: + type: string + format: email + minLength: 1 + password: + type: string + minLength: 1 + required: + - email + - password + MediaEvent: + type: object + properties: + date: + type: string + format: date-time + id: + type: string + format: uuid + name: + type: string + status: + type: string + enum: + - UPCOMING + - ONGOING + - PAST + MessageResponse: + type: object + properties: + message: + type: string + MusallahBoardPayload: + type: object + properties: + deviceConfig: + $ref: "#/components/schemas/DeviceConfig" + frames: + type: array + items: + $ref: "#/components/schemas/FrameDefinition" + weather: + $ref: "#/components/schemas/JsonNode" + NextPrayerFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + PageAdminUploadDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/AdminUploadDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageAuditEventDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/AuditEventDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageGalleryItemDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/GalleryItemDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageUploadDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/UploadDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageUserInfoResponse: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/UserInfoResponse" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + Pageable: + type: object + properties: + page: + type: integer + format: int32 + minimum: 0 + size: + type: integer + format: int32 + minimum: 1 + sort: + type: array + items: + type: string + PermissionResponse: + type: object + properties: + authority: + type: string + domain: + type: string + name: + type: string + Poster: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + endTime: + type: string + format: date-time + id: + type: string + format: uuid + image: + type: string + signupUrl: + type: string + startTime: + type: string + format: date-time + title: + type: string + PosterFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + posterUrl: + type: string + signupUrl: + type: string + title: + type: string + PresignedUploadResponse: + type: object + properties: + contentType: + type: string + eventId: + type: string + format: uuid + expectedSha256: + type: string + expiresInMinutes: + type: integer + format: int32 + method: + type: string + objectKey: + type: string + uploadUrl: + type: string + PromotableSocialMedia: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + footerText: + type: string + handle: + type: string + headerText: + type: string + heroText: + type: string + id: + type: string + format: uuid + name: + type: string + type: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string + PromotableSocialMediaFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + footerText: + type: string + handle: + type: string + headerText: + type: string + heroText: + type: string + socialType: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string + QuoteEntry: + type: object + properties: + arabic: + type: string + durationSeconds: + type: integer + format: int32 + maximum: 120 + minimum: 5 + kind: + type: string + enum: + - VERSE + - HADITH + reference: + type: string + translation: + type: string + transliteration: + type: string + ResetPasswordRequest: + type: object + properties: + newPassword: + type: string + maxLength: 256 + minLength: 6 + token: + type: string + minLength: 1 + required: + - newPassword + - token + RoleDefinitionResponse: + type: object + properties: + description: + type: string + name: + type: string + permissions: + type: array + items: + type: string + SignupRequest: + type: object + properties: + email: + type: string + format: email + maxLength: 50 + minLength: 0 + pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]*\\.utoronto\\.ca$" + firstName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + lastName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + password: + type: string + maxLength: 40 + minLength: 6 + studentNumber: + type: string + maxLength: 10 + minLength: 0 + required: + - email + - firstName + - lastName + - password + - studentNumber + SortObject: + type: object + properties: + empty: + type: boolean + sorted: + type: boolean + unsorted: + type: boolean + TokenRefreshRequest: + type: object + properties: + refreshToken: + type: string + minLength: 1 + required: + - refreshToken + TokenRefreshResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + tokenType: + type: string + TokenValidationResponse: + type: object + properties: + email: + type: string + firstName: + type: string + id: + type: string + format: uuid + lastName: + type: string + permissions: + type: array + items: + type: string + roles: + type: array + items: + type: string + valid: + type: boolean + verified: + type: boolean + UpdateBoardConfigRequest: + type: object + properties: + agendaDurationSeconds: + type: integer + format: int32 + maximum: 120 + minimum: 0 + darkModeAfterIsha: + type: boolean + enableScrollingMessage: + type: boolean + location: + $ref: "#/components/schemas/Location" + nextPrayerDurationSeconds: + type: integer + format: int32 + maximum: 120 + minimum: 5 + scrollingMessages: + type: array + items: + type: string + UpdateCalendarEventRequest: + type: object + properties: + allDay: + type: boolean + audience: + type: string + enum: + - brothers + - sisters + - both + description: + type: string + endTime: + type: string + format: date-time + location: + type: string + name: + type: string + startTime: + type: string + format: date-time + UpdateDeviceRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + displayName: + type: string + maxLength: 255 + minLength: 0 + pattern: .*\S.* + UpdatePosterRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + minimum: 0 + endTime: + type: string + format: date-time + signupUrl: + type: string + startTime: + type: string + format: date-time + title: + type: string + UpdateProfileRequest: + type: object + properties: + firstName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + lastName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + studentNumber: + type: string + maxLength: 10 + minLength: 0 + UpdatePromotableSocialMediaRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + minimum: 0 + footerText: + type: string + handle: + type: string + headerText: + type: string + heroText: + type: string + name: + type: string + type: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string + pattern: "" + UpdateTickerRequest: + type: object + properties: + enableScrollingMessage: + type: boolean + scrollingMessages: + type: array + items: + type: string + UploadCompletionResponse: + type: object + properties: + eventId: + type: string + format: uuid + fileSize: + type: integer + format: int64 + objectKey: + type: string + uploadId: + type: string + format: uuid + verified: + type: boolean + UploadDto: + type: object + properties: + anon: + type: boolean + approved: + type: boolean + contentType: + type: string + enum: + - image + - video + - audio + - document + createdDate: + type: string + format: date-time + eventId: + type: string + format: uuid + eventName: + type: string + featured: + type: boolean + fileName: + type: string + fileUrl: + type: string + instagramHandle: + type: string + thumbnailUrl: + type: string + uploadDescription: + type: string + uploadedBy: + type: string + format: uuid + uuid: + type: string + format: uuid + UploadLimitsResponse: + type: object + properties: + allowedContentTypes: + type: array + items: + type: string + dailyLimit: + type: integer + format: int32 + maxSizeBytes: + type: integer + format: int64 + maxSizeMB: + type: integer + format: int64 + role: + type: string + uploadsRemaining: + type: integer + format: int64 + uploadsToday: + type: integer + format: int64 + videoMaxDurationSeconds: + type: integer + format: int32 + UserInfoResponse: + type: object + properties: + directPermissions: + type: array + items: + type: string + effectivePermissions: + type: array + items: + type: string + email: + type: string + firstName: + type: string + id: + type: string + format: uuid + lastName: + type: string + roles: + type: array + items: + type: string + enum: + - USER + - ADMIN + - BOARD_VIEWER + - BOARD_EDITOR + - BOARD_ADMIN + - ROOT + uniqueItems: true + studentNumber: + type: string + verified: + type: boolean + UserStatsResponse: + type: object + properties: + approvedUploads: + type: integer + format: int32 + featuredUploads: + type: integer + format: int32 + pendingUploads: + type: integer + format: int32 + totalUploads: + type: integer + format: int32 + ValidateResetTokenRequest: + type: object + properties: + token: + type: string + minLength: 1 + required: + - token + VerifyEmailRequest: + type: object + properties: + token: + type: string + minLength: 1 + required: + - token + VerifyUserRequest: + type: object + properties: + userId: + type: string + format: uuid + required: + - userId + WeeklyContent: + type: object + properties: + id: + type: string + format: uuid + jummahPrayers: + type: array + items: + $ref: "#/components/schemas/JummahPrayer" + quotes: + type: array + items: + $ref: "#/components/schemas/IslamicQuote" + weekNumber: + type: integer + format: int32 + year: + type: integer + format: int32 + WeeklyContentRequest: + type: object + properties: + jummahPrayers: + type: array + items: + $ref: "#/components/schemas/JummahSlot" + quotes: + type: array + items: + $ref: "#/components/schemas/QuoteEntry" + securitySchemes: + bearerAuth: + bearerFormat: JWT + name: bearerAuth + scheme: bearer + type: http diff --git a/pom.xml b/pom.xml index ce8ac20..856f179 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.2 + 3.5.16 com.ibrasoft @@ -29,12 +29,27 @@ 21 + 2.9.0 + 1.4 + 8089 + 9011 + + + + com.nimbusds + nimbus-jose-jwt + 9.37.4 + + + org.apache.commons + commons-lang3 + 3.20.0 + + + + - - org.springframework.boot - spring-boot-starter-data-mongodb - org.springframework.boot spring-boot-starter-web @@ -89,8 +104,6 @@ caffeine 3.1.8 - - software.amazon.awssdk s3 @@ -157,6 +170,58 @@ spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + org.xerial + sqlite-jdbc + 3.47.1.0 + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + org.hibernate.orm + hibernate-community-dialects + + + + org.postgresql + postgresql + runtime + + + + + @@ -192,4 +257,76 @@ - \ No newline at end of file + + + + openapi + + + + org.springframework.boot + spring-boot-maven-plugin + + + pre-integration-test + + start + + + dev + ${openapi.gen.jmxPort} + + --server.port=${openapi.gen.port} + + + true + + + + + post-integration-test + + stop + + + ${openapi.gen.jmxPort} + + + + + + org.springdoc + springdoc-openapi-maven-plugin + ${springdoc-maven-plugin.version} + + + generate-openapi-spec + integration-test + + generate + + + + + http://localhost:${openapi.gen.port}/v3/api-docs.yaml + openapi.yaml + ${project.basedir} + + + + + + + + diff --git a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java index c9901a4..fb69799 100644 --- a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java +++ b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAsync +@EnableScheduling public class LensBridgeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java deleted file mode 100644 index bf73179..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.ibrasoft.lensbridge.config; - -import jakarta.annotation.PostConstruct; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.index.Index; -import org.springframework.data.mongodb.core.index.IndexOperations; - -@Configuration -public class AuditIndexConfig { - - private final MongoTemplate mongoTemplate; - - public AuditIndexConfig(MongoTemplate mongoTemplate) { - this.mongoTemplate = mongoTemplate; - } - - @PostConstruct - public void createAuditIndexes() { - IndexOperations indexOps = mongoTemplate.indexOps("audit_events"); - - // Index on timestamp for chronological queries - indexOps.createIndex(new Index().on("timestamp", Sort.Direction.DESC)); - - // Index on adminId for admin-specific queries - indexOps.createIndex(new Index().on("adminId", Sort.Direction.ASC)); - - // Index on entityType and entityId for entity-specific queries - indexOps.createIndex(new Index().on("entityType", Sort.Direction.ASC).on("entityId", Sort.Direction.ASC)); - - // Index on action for action-specific queries - indexOps.createIndex(new Index().on("action", Sort.Direction.ASC)); - - // Index on result for failed operation queries - indexOps.createIndex(new Index().on("result", Sort.Direction.ASC)); - - // Compound index for date range queries with admin filter - indexOps.createIndex(new Index().on("timestamp", Sort.Direction.DESC).on("adminId", Sort.Direction.ASC)); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java new file mode 100644 index 0000000..aced262 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "auth.token") +@Getter +@Setter +public class AuthTokenProperties { + private long verificationExpirationMs; + private long passwordResetExpirationMs; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java new file mode 100644 index 0000000..8305918 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "image-processing") +@Data +public class ImageProcessingProperties { + private int thumbnailWidth; + private int thumbnailHeight; + private double thumbnailQuality; + private String thumbnailFolder; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java new file mode 100644 index 0000000..fd6020e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.ZoneId; + +@Configuration +public class MusallahBoardConfig { + + /** + * Zone used for a board whose device config has no timezone, or an unparseable one. + * Designed to be decoupled from the JVM's default timezone to avoid issues with container images + * that don't set it. + */ + @Bean + public ZoneId boardDefaultZone( + @Value("${musallahboard.defaultTimezone:America/Toronto}") String timezone) { + return ZoneId.of(timezone); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java new file mode 100644 index 0000000..9d0631b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java @@ -0,0 +1,78 @@ +package com.ibrasoft.lensbridge.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import com.ibrasoft.lensbridge.security.CurrentUser; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.utils.SpringDocUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + static { + // Workaround for @CurrentUser being an argument, which springdoc cannot infer + SpringDocUtils.getConfig().addAnnotationsToIgnore(CurrentUser.class); + + // Likewise Pageable, which is documented as a single object-typed query + // parameter instead of the page/size/sort trio the resolver actually reads. + SpringDocUtils.getConfig().replaceWithClass( + org.springframework.data.domain.Pageable.class, + org.springdoc.core.converters.models.Pageable.class); + } + + @Value("${lensbridge.app.version:unknown}") + private String appVersion; + + @Bean + public OpenApiCustomizer defaultErrorResponseCustomizer() { + return openApi -> { + Schema messageResponse = new Schema<>() + .$ref("#/components/schemas/MessageResponse"); + ApiResponse fallback = new ApiResponse() + .description("Request failed; body carries a human-readable message") + .content(new Content().addMediaType( + org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + new MediaType().schema(messageResponse))); + + openApi.getPaths().values().stream() + .flatMap(pathItem -> pathItem.readOperations().stream()) + .forEach(operation -> { + ApiResponses responses = operation.getResponses(); + if (responses != null && responses.getDefault() == null) { + responses.addApiResponse("default", fallback); + } + }); + }; + } + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("LensBridge API") + .version(appVersion) + .description("LensBridge backend — media upload and MusallahBoard management")) + .servers(List.of(new Server().url("/"))) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .name("bearerAuth") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java b/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java index 4b2dfb7..dc18568 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java @@ -35,7 +35,7 @@ public class RateLimitingFilter extends OncePerRequestFilter { @Value("${ratelimit.duration.minutes:1}") private int durationMinutes; - @Value("${ratelimit.exemptRoles:ROLE_ROOT,ROLE_ADMIN}") + @Value("${ratelimit.exemptRoles:ROOT,ADMIN}") private List exemptRoles; private final Cache bucketCache = Caffeine.newBuilder() diff --git a/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java index dd7e481..38bdbf5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java @@ -11,85 +11,41 @@ import java.util.List; import java.util.Map; -/** - * Configuration properties for file uploads. - */ @Component @ConfigurationProperties(prefix = "uploads") @Data public class UploadProperties { - - /** - * Maximum video duration in seconds - */ - private int videoMaxduration = 240; - - /** - * Global maximum file size (fallback) - */ + @DataSizeUnit(DataUnit.BYTES) - private DataSize globalMaxSize = DataSize.ofGigabytes(1); - - /** - * Per-role maximum file sizes - */ + private DataSize globalMaxSize; + private Map maxSize = new HashMap<>(); - - /** - * Allowed file types (MIME types) - */ - private List allowedFileTypes = List.of( -// "video/mp4", "video/quicktime", "video/x-matroska", "video/x-msvideo", - "image/jpeg", "image/png", "image/heic" - ); - - /** - * Per-role daily upload limits - */ + + private List allowedFileTypes; + private Map dailyLimit = new HashMap<>(); - - /** - * Global daily upload limit (fallback) - */ - private int globalDailyLimit = 25; - - /** - * Get the maximum allowed file size for a specific role. - * Falls back to verified limit if role not found. - */ + + private int globalDailyLimit; + public DataSize getMaxSizeForRole(String role) { - String roleKey = role; - if (roleKey.startsWith("ROLE_")) { - roleKey = roleKey.substring(5).toLowerCase(); - } - - DataSize roleLimit = maxSize.get(roleKey); - if (roleLimit != null) { - return roleLimit; - } - - // Fallback to verified limit, then global limit - DataSize verifiedLimit = maxSize.get("verified"); - return verifiedLimit != null ? verifiedLimit : globalMaxSize; + String key = normalizeRoleKey(role); + DataSize roleLimit = maxSize.get(key); + if (roleLimit != null) return roleLimit; + DataSize userLimit = maxSize.get("user"); + return userLimit != null ? userLimit : globalMaxSize; } - - /** - * Get the daily upload limit for a specific role. - * Falls back to verified limit if role not found. - */ + public int getDailyLimitForRole(String role) { - String roleKey = role; - if (roleKey.startsWith("ROLE_")) { - roleKey = roleKey.substring(5).toLowerCase(); - } - - Integer roleLimit = dailyLimit.get(roleKey); - if (roleLimit != null) { - return roleLimit; - } - - // Fallback to verified limit, then global limit - Integer verifiedLimit = dailyLimit.get("verified"); - return verifiedLimit != null ? verifiedLimit : globalDailyLimit; + String key = normalizeRoleKey(role); + Integer roleLimit = dailyLimit.get(key); + if (roleLimit != null) return roleLimit; + Integer userLimit = dailyLimit.get("user"); + return userLimit != null ? userLimit : globalDailyLimit; + } + + private String normalizeRoleKey(String role) { + if (role == null) return "user"; + String key = role.startsWith("ROLE_") ? role.substring(5) : role; + return key.toLowerCase(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java index 5448b35..b84f5b6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java @@ -2,12 +2,14 @@ import java.util.List; +import com.ibrasoft.lensbridge.security.CurrentUserArgumentResolver; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.lang.NonNull; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -23,6 +25,9 @@ public class WebConfig implements WebMvcConfigurer { @Autowired private RateLimitingFilter rateLimitingFilter; + @Autowired + private CurrentUserArgumentResolver currentUserArgumentResolver; + @Bean public FilterRegistrationBean rateLimitingFilterRegistration() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); @@ -32,6 +37,11 @@ public FilterRegistrationBean rateLimitingFilterRegistration return registrationBean; } + @Override + public void addArgumentResolvers(@NonNull List resolvers) { + resolvers.add(currentUserArgumentResolver); + } + @Override public void addCorsMappings(@NonNull CorsRegistry registry) { registry.addMapping("/api/**") diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java index 49871d7..09520a7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java @@ -1,33 +1,57 @@ package com.ibrasoft.lensbridge.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; -import com.ibrasoft.lensbridge.handler.SignboardHandler; +import com.ibrasoft.lensbridge.handler.AgentWebSocketHandler; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { - - private final SignboardHandler signboardHandler; - + + private final BoardStreamHandler boardStreamHandler; + private final AgentWebSocketHandler agentWebSocketHandler; + @Value("${frontend.baseurl}") String frontendBaseUrl; @Value("${musallahboard.baseurl}") String musallahBoardBaseUrl; - public WebSocketConfig(SignboardHandler signboardHandler) { - this.signboardHandler = signboardHandler; + public WebSocketConfig(BoardStreamHandler boardStreamHandler, + AgentWebSocketHandler agentWebSocketHandler) { + this.boardStreamHandler = boardStreamHandler; + this.agentWebSocketHandler = agentWebSocketHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(signboardHandler, "/api/refresh-musallahboard") + // Path is the deployed kiosk's; boards scope themselves with ?deviceId=. + registry.addHandler(boardStreamHandler, "/api/refresh-musallahboard") .setAllowedOrigins(frontendBaseUrl, musallahBoardBaseUrl); + + // Agents authenticate per-frame inside the channel; the WS upgrade itself is open. + registry.addHandler(agentWebSocketHandler, "/api/agent/ws") + .setAllowedOrigins("*"); + } + + @Bean + @ConditionalOnProperty(name = "lensbridge.websocket.container-customizer.enabled", havingValue = "true") + public ServletServerContainerFactoryBean webSocketContainer() { + ServletServerContainerFactoryBean container = + new ServletServerContainerFactoryBean(); + + container.setMaxTextMessageBufferSize(10 * 1024 * 1024); // 10 MB + container.setMaxBinaryMessageBufferSize(10 * 1024 * 1024); + + return container; } -} \ No newline at end of file +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java b/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java deleted file mode 100644 index 815d908..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ibrasoft.lensbridge.config.converter; - -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import org.springframework.core.convert.converter.Converter; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -@Component -public class StringToBoardLocationConverter implements Converter { - @Override - public BoardLocation convert(@NonNull String source) { - return BoardLocation.from(source) - .orElseThrow(() -> new IllegalArgumentException("Unknown board location: " + source)); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java b/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java new file mode 100644 index 0000000..6bb8010 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java @@ -0,0 +1,20 @@ +package com.ibrasoft.lensbridge.config.converter; + +import com.ibrasoft.lensbridge.model.board.SocialType; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +/** + * Binds {@code SocialType} path and query params. Spring's default enum binder matches on + * {@code name()} only, so without this a request carrying the lowercase form the API itself + * emits — {@code ?type=instagram} — would 400. + */ +@Component +public class StringToSocialTypeConverter implements Converter { + @Override + public SocialType convert(@NonNull String source) { + return SocialType.from(source) + .orElseThrow(() -> new IllegalArgumentException("Unknown social type: " + source)); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java new file mode 100644 index 0000000..715c3dc --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java @@ -0,0 +1,61 @@ +package com.ibrasoft.lensbridge.config.stomp; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +/** + * STOMP-over-WebSocket endpoint at {@code /api/dashboard/ws} for the admin dashboard. + *

+ * Topics published by the backend: + *

    + *
  • {@code /topic/devices/{id}} — heartbeat + online/offline transitions
  • + *
  • {@code /topic/devices/{id}/commands/{cmdId}} — per-command lifecycle events
  • + *
  • {@code /topic/devices/{id}/commands} — fan-out of all command events for a device
  • + *
+ * Authentication is enforced by {@link StompJwtChannelInterceptor} on the + * {@code clientInboundChannel} — every STOMP {@code CONNECT} must carry a valid JWT. + */ +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class StompConfig implements WebSocketMessageBrokerConfigurer { + + private final StompJwtChannelInterceptor jwtInterceptor; + + @Value("${frontend.baseurl}") + private String frontendBaseUrl; + + @Value("${musallahboard.baseurl}") + private String musallahBoardBaseUrl; + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/api/dashboard/ws") + .setAllowedOrigins(frontendBaseUrl, musallahBoardBaseUrl); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(jwtInterceptor); + } + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setMessageSizeLimit(10 * 1024 * 1024); // inbound message size + registry.setSendBufferSizeLimit(10 * 1024 * 1024); // outbound buffer + registry.setSendTimeLimit(20_000); // optional + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java new file mode 100644 index 0000000..d5ee5ae --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java @@ -0,0 +1,159 @@ +package com.ibrasoft.lensbridge.config.stomp; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.lang.NonNull; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Principal; + +/** + * Authenticates STOMP {@code CONNECT} and authorizes STOMP {@code SUBSCRIBE}. + *

+ * CONNECT: validates the JWT carried in the {@code Authorization} native header. + * Browsers cannot set arbitrary headers on the WebSocket HTTP upgrade, so we authenticate + * at the STOMP layer rather than the HTTP handshake. Subsequent frames inherit the resolved + * principal via {@link StompHeaderAccessor#setUser(Principal)}. + *

+ * SUBSCRIBE: a valid JWT is not enough to read the device topics. Command results + * are published to {@code /topic/devices/{id}/commands} including the agent's raw output, + * which for {@code chrome.screenshot} is an image of a display and for {@code logs.tail} is + * agent internals. Reading those requires {@link Permission#BOARD_TELEMETRY_SUBSCRIBE}. + * Without this check any verified user — including a student who signed up only to upload a + * photo — could subscribe and receive them. + *

+ * Pattern destinations are refused outright, before any permission is considered. The + * broker treats a subscription destination as an Ant pattern, so {@code /topic/**} would + * otherwise pass a prefix test while still receiving every device message. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class StompJwtChannelInterceptor implements ChannelInterceptor { + + /** + * The only destination prefix the backend publishes to today (see DeviceEventPublisher). + * A new topic family must make its own authorization decision here. + */ + private static final String DEVICE_TOPIC_PREFIX = "/topic/devices"; + + /** + * AntPathMatcher metacharacters. {@code DefaultSubscriptionRegistry} matches a + * subscription destination against published destinations as a pattern, so any + * of these turns a SUBSCRIBE into a wildcard that a prefix test cannot reason about: + * {@code /topic/**} does not start with {@code /topic/devices} yet receives everything + * published there. Nothing in this application subscribes by pattern, so they are + * refused outright rather than pattern-matched against a policy. + */ + private static final char[] PATTERN_CHARS = {'*', '?', '{'}; + + private final JwtUtils jwtUtils; + private final UserDetailsServiceImpl userDetailsService; + + @SuppressWarnings("null") // accessor is null-checked prior to switch statement + @Override + public Message preSend(@NonNull Message message, @NonNull MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null || accessor.getCommand() == null) { + return message; + } + + switch (accessor.getCommand()) { + case CONNECT -> authenticate(accessor); + case SUBSCRIBE -> authorizeSubscription(accessor); + default -> { /* other frames inherit the principal resolved at CONNECT */ } + } + return message; + } + + private void authenticate(StompHeaderAccessor accessor) { + String header = accessor.getFirstNativeHeader("Authorization"); + String token = stripBearer(header); + if (token == null) { + log.warn("STOMP CONNECT rejected: missing Authorization header"); + throw new IllegalArgumentException("Missing Authorization header on STOMP CONNECT"); + } + if (!jwtUtils.validateJwtToken(token)) { + log.warn("STOMP CONNECT rejected: invalid JWT"); + throw new IllegalArgumentException("Invalid JWT on STOMP CONNECT"); + } + + String username = jwtUtils.getUserNameFromJwtToken(token); + UserDetails user = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + accessor.setUser(auth); + log.debug("STOMP authenticated as {}", username); + } + + private void authorizeSubscription(StompHeaderAccessor accessor) { + String destination = accessor.getDestination(); + if (destination == null) { + return; + } + + // Checked before the prefix test, and regardless of what the subscriber holds: a + // pattern is not a destination this gate can authorize, so there is no version of it + // that is allowed through. + if (isPattern(destination)) { + log.warn("STOMP SUBSCRIBE rejected: pattern destination {}", destination); + throw new AccessDeniedException( + "Pattern subscriptions are not permitted; subscribe to an exact destination"); + } + + if (!destination.startsWith(DEVICE_TOPIC_PREFIX)) { + return; + } + + Principal principal = accessor.getUser(); + if (!(principal instanceof Authentication auth) || !auth.isAuthenticated()) { + log.warn("STOMP SUBSCRIBE to {} rejected: no authenticated principal", destination); + throw new AccessDeniedException("Not authenticated"); + } + if (!hasAuthority(auth, Permission.BOARD_TELEMETRY_SUBSCRIBE)) { + log.warn("STOMP SUBSCRIBE to {} rejected for {}: missing {}", + destination, auth.getName(), Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + throw new AccessDeniedException("Missing permission " + + Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + } + log.debug("STOMP SUBSCRIBE to {} authorized for {}", destination, auth.getName()); + } + + private static boolean isPattern(String destination) { + for (char c : PATTERN_CHARS) { + if (destination.indexOf(c) >= 0) return true; + } + return false; + } + + private static boolean hasAuthority(Authentication auth, Permission permission) { + for (GrantedAuthority granted : auth.getAuthorities()) { + if (permission.getAuthority().equals(granted.getAuthority())) { + return true; + } + } + return false; + } + + private static String stripBearer(String header) { + if (header == null) return null; + if (header.regionMatches(true, 0, "Bearer ", 0, 7)) { + return header.substring(7).trim(); + } + return header.trim(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 75b3da1..dc429a9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -1,41 +1,65 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; -import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import com.ibrasoft.lensbridge.dto.request.SignupRequest; -import com.ibrasoft.lensbridge.dto.response.AdminUploadDto; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.request.CreateUserRequest; +import com.ibrasoft.lensbridge.dto.auth.request.VerifyUserRequest; +import com.ibrasoft.lensbridge.dto.auth.response.PermissionResponse; +import com.ibrasoft.lensbridge.dto.auth.response.RoleDefinitionResponse; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; +import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; +import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.service.EventsService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; +import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; -import java.util.Map; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; - +import org.springdoc.core.annotations.ParameterObject; + +/** + * Media moderation, identity administration, and audit reporting. + *

+ * The class-level gate covers the legacy media endpoints only — the identity and audit + * methods below override it with their own permission. Media authorization is unchanged: + * the same people reach the same endpoints they always did. + *

+ * The permission is accepted alongside the role because the role check alone is + * name-based, and a name-based check cannot see that {@code ROOT}'s bundle already contains + * {@code media:upload:moderate}. Without this, a ROOT-only account is refused media + * moderation while holding, on paper, every permission in the system — and the only fix + * would be granting it {@code ROLE_ADMIN} as well, which is the hidden role coupling this + * redesign exists to remove. + */ @RestController @RequestMapping("/api/admin") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.ADMIN + "')") +// Hacky fix until I remove media-related permissions from the legacy ROLE_ADMIN +@PreAuthorize("hasRole('" + Role.Authority.ADMIN + "') " + + "or hasAuthority('" + Permission.Authority.MEDIA_UPLOAD_MODERATE + "')") @Slf4j public class AdminController { @@ -44,263 +68,255 @@ public class AdminController { private final AdminAuditService auditService; private final UserService userService; - private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AdminAction auditAction, String successMessage) { + private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(uploadId); - UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - this.auditService.logAuditEvent(curr.getEmail(), auditAction, "Upload", uploadId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "Upload", uploadId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } - private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AdminAction auditAction, String successMessage) { + private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { + return executeUserAction(userId, request, serviceAction, auditAction, successMessage, null); + } + + /** + * @param details what was granted or revoked. Without it the audit log cannot tell a + * grant of {@code board:content:read} from a grant of + * {@code iam:role:grant} — the two highest- and lowest-stakes actions + * available here produce identical rows. + */ + private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage, String details) { serviceAction.accept(userId); - UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - this.auditService.logAuditEvent(curr.getEmail(), auditAction, "User", userId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "User", userId, request.getRemoteAddr(), details); return ResponseEntity.ok(new MessageResponse(successMessage)); } - private ResponseEntity executeCreationAction(T input, HttpServletRequest request, - Function serviceAction, AdminAction auditAction, + private ResponseEntity executeCreationAction(T input, HttpServletRequest request, + Function serviceAction, AuditAction auditAction, String entityType, Function idExtractor, String successMessage) { R createdEntity = serviceAction.apply(input); - UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); UUID entityId = idExtractor.apply(createdEntity); - - this.auditService.logAuditEvent(curr.getEmail(), auditAction, entityType, entityId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, entityType, entityId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } @PostMapping("/create-event") - public ResponseEntity createEvent(@RequestParam("eventName") String eventName, - @RequestParam(value = "eventDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime eventDate, + @Operation(operationId = "createMediaEvent", summary = "Create a media event") + public ResponseEntity createEvent(@RequestParam("eventName") String eventName, + @RequestParam(value = "eventDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant eventDate, @RequestParam(value = "status", required = false) EventStatus status, HttpServletRequest request) { - return executeCreationAction(null, request, (ignored) -> eventsService.createEvent(eventName, eventDate), - AdminAction.CREATE_EVENT, + AuditAction.CREATE_EVENT, "Event", - Event::getId, + MediaEvent::getId, "Event created successfully"); } // Upload Management Operations @PostMapping("/upload/{uploadId}") - public ResponseEntity approveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::approveUpload, AdminAction.APPROVE_UPLOAD, "Upload approved successfully"); + public ResponseEntity approveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + return executeUploadAction(uploadId, request, uploadService::approveUpload, AuditAction.APPROVE_UPLOAD, "Upload approved successfully"); } @DeleteMapping("/upload/{uploadId}") - public ResponseEntity deleteUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::deleteUpload, AdminAction.DELETE_UPLOAD, "Upload deleted successfully"); + public ResponseEntity deleteUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + return executeUploadAction(uploadId, request, uploadService::deleteUpload, AuditAction.DELETE_UPLOAD, "Upload deleted successfully"); } @PostMapping("/feature-upload/{uploadId}") - public ResponseEntity featureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::featureUpload, AdminAction.FEATURE_UPLOAD, "Upload featured successfully"); + public ResponseEntity featureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + return executeUploadAction(uploadId, request, uploadService::featureUpload, AuditAction.FEATURE_UPLOAD, "Upload featured successfully"); } @DeleteMapping("/upload/{uploadId}/approval") - public ResponseEntity unapproveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::unapproveUpload, AdminAction.UNAPPROVE_UPLOAD, "Upload unapproved successfully"); + public ResponseEntity unapproveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + return executeUploadAction(uploadId, request, uploadService::unapproveUpload, AuditAction.UNAPPROVE_UPLOAD, "Upload unapproved successfully"); } @DeleteMapping("/upload/{uploadId}/featured") - public ResponseEntity unfeatureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::unfeatureUpload, AdminAction.UNFEATURE_UPLOAD, "Upload unfeatured successfully"); + public ResponseEntity unfeatureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + return executeUploadAction(uploadId, request, uploadService::unfeatureUpload, AuditAction.UNFEATURE_UPLOAD, "Upload unfeatured successfully"); } // Data Retrieval Operations @GetMapping("/uploads") - public ResponseEntity getAllUploads(Pageable pageable) { - log.debug("Admin retrieving all uploads with user information, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getAllUploadsForAdmin(pageable); - return ResponseEntity.ok(uploads); + @Operation(operationId = "getAdminUploads", summary = "Page through every upload, any approval state") + public ResponseEntity> getAllUploads(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(uploadService.getAllUploadsForAdmin(pageable)); } @GetMapping("/uploads/pending") - public ResponseEntity getPendingUploads(Pageable pageable) { - log.debug("Admin retrieving pending uploads, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getUploadsByApprovalStatus(false, pageable); - return ResponseEntity.ok(uploads); + public ResponseEntity> getPendingUploads(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByApprovalStatus(false, pageable)); } @GetMapping("/uploads/approved") - public ResponseEntity getApprovedUploads(Pageable pageable) { - try { - log.debug("Admin retrieving approved uploads, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getUploadsByApprovalStatus(true, pageable); - return ResponseEntity.ok(uploads); - } catch (Exception e) { - log.error("Error retrieving approved uploads: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve approved uploads: " + e.getMessage())); - } + public ResponseEntity> getApprovedUploads(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByApprovalStatus(true, pageable)); } @GetMapping("/uploads/featured") - public ResponseEntity getFeaturedUploads(Pageable pageable) { - try { - log.debug("Admin retrieving featured uploads, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getUploadsByFeaturedStatus(true, pageable); - return ResponseEntity.ok(uploads); - } catch (Exception e) { - log.error("Error retrieving featured uploads: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve featured uploads: " + e.getMessage())); - } + public ResponseEntity> getFeaturedUploads(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByFeaturedStatus(true, pageable)); } + @Operation(operationId = "getAllEventsForAdmin", summary = "List every event, including unpublished ones") @GetMapping("/events") - public ResponseEntity getAllEvents() { - try { - log.debug("Admin retrieving all events"); - return ResponseEntity.ok(eventsService.getAllEvents()); - } catch (Exception e) { - log.error("Error retrieving events: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve events: " + e.getMessage())); - } + public ResponseEntity> getAllEvents() { + return ResponseEntity.ok(eventsService.getAllEvents()); } @GetMapping("/users") - @PreAuthorize("hasRole('" + Role.ROOT + "')") - public ResponseEntity getAllUsers(Pageable pageable) { - try { - log.debug("Admin retrieving all users, page: {}", pageable.getPageNumber()); - return ResponseEntity.ok(userService.getAllUsers(pageable)); - } catch (Exception e) { - log.error("Error retrieving users: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve users: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')") + public ResponseEntity> getAllUsers(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(userService.getAllUsers(pageable)); } @PostMapping("/user/{userId}/add-role") - @PreAuthorize("hasRole('" + Role.ROOT + "')") - public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") + public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, - (id) -> { - log.debug("Admin adding role {} to user: {}", role, id); - userService.addRole(id, role); - }, - AdminAction.ADD_USER_ROLE, - "Role added successfully to user: " + userId); + (id) -> userService.addRole(getCurrentUserEmail(), id, role), + AuditAction.ADD_USER_ROLE, + "Role added successfully to user: " + userId, + "role=" + role.name()); } @PostMapping("/user/{userId}/remove-role") - @PreAuthorize("hasRole('" + Role.ROOT + "')") - public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") + public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { + return executeUserAction(userId, request, + (id) -> userService.removeRole(getCurrentUserEmail(), id, role), + AuditAction.REMOVE_USER_ROLE, + "Role removed successfully from user: " + userId, + "role=" + role.name()); + } + + /** + * Grants one permission on top of a user's roles. This is the pressure valve that keeps + * the role list short: a one-off need is a direct grant, not a new role. + */ + @PostMapping("/user/{userId}/grant-permission") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") + @Operation(operationId = "grantUserPermission", summary = "Grant a single permission directly to a user") + public ResponseEntity grantPermission(@PathVariable UUID userId, + @RequestBody Permission permission, + HttpServletRequest request) { return executeUserAction(userId, request, - (id) -> { - log.debug("Admin removing role {} from user: {}", role, id); - userService.removeRole(id, role); - }, - AdminAction.REMOVE_USER_ROLE, - "Role removed successfully from user: " + userId); + (id) -> userService.grantPermission(getCurrentUserEmail(), id, permission), + AuditAction.GRANT_PERMISSION, + "Permission " + permission.getAuthority() + " granted to user: " + userId, + "permission=" + permission.getAuthority()); + } + + @PostMapping("/user/{userId}/revoke-permission") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") + @Operation(operationId = "revokeUserPermission", summary = "Revoke a directly-granted permission from a user") + public ResponseEntity revokePermission(@PathVariable UUID userId, + @RequestBody Permission permission, + HttpServletRequest request) { + return executeUserAction(userId, request, + (id) -> userService.revokePermission(getCurrentUserEmail(), id, permission), + AuditAction.REVOKE_PERMISSION, + "Permission " + permission.getAuthority() + " revoked from user: " + userId, + "permission=" + permission.getAuthority()); } @PostMapping("/user/verify") - @PreAuthorize("hasRole('" + Role.ROOT + "')") - public ResponseEntity verifyUser(@RequestBody Map payload, HttpServletRequest request) { - return executeUserAction(payload.get("userId"), request, - userService::verifyDirectly, AdminAction.VERIFY_USER, + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_WRITE + "')") + public ResponseEntity verifyUser(@Valid @RequestBody VerifyUserRequest payload, HttpServletRequest request) { + return executeUserAction(payload.getUserId(), request, + userService::verifyDirectly, AuditAction.VERIFY_USER, "User verified successfully"); } + @Operation(operationId = "createUser", + summary = "Create an account for someone else", + description = "Omit the password — the normal case — and the account is created disabled " + + "and the user is emailed a password reset link; setting a password through that " + + "link is what enables the account. Supply a password only when the account has " + + "to be usable immediately: it is then enabled on creation and no email is sent.") @PostMapping("/user/create") - @PreAuthorize("hasRole('" + Role.ROOT + "')") - public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, HttpServletRequest request) { - return executeCreationAction(signUpRequest, request, - (signupRequest) -> { - log.debug("Admin creating user with email: {}", signupRequest.getEmail()); - User newUser = userService.createUser(signupRequest, false); - userService.verifyDirectly(newUser.getId()); - userService.requestPasswordReset(newUser.getEmail()); - return newUser; - }, - AdminAction.ADD_USER, + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_WRITE + "')") + public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest createUserRequest, HttpServletRequest request) { + return executeCreationAction(createUserRequest, request, + userService::createUserByAdmin, + AuditAction.ADD_USER, "User", User::getId, - "User created successfully: " + signUpRequest.getEmail()); + createUserRequest.hasPassword() + ? "User created successfully: " + createUserRequest.getEmail() + : "User created and invited: " + createUserRequest.getEmail() + + ". The account stays disabled until they set a password from the email."); } + @Operation(operationId = "getAvailableRoles", + summary = "List assignable roles with the permissions each one confers") @GetMapping("/roles") - @PreAuthorize("hasRole('" + Role.ROOT + "')") - public ResponseEntity getAvailableRoles() { - try { - log.debug("Admin retrieving available roles"); - return ResponseEntity.ok(Role.values()); - } catch (Exception e) { - log.error("Error retrieving roles: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve roles: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')") + public ResponseEntity> getAvailableRoles() { + return ResponseEntity.ok(Arrays.stream(Role.values()) + .map(RoleDefinitionResponse::of) + .toList()); + } + + @Operation(operationId = "getAvailablePermissions", + summary = "List every permission, for building a direct-grant picker") + @GetMapping("/permissions") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')") + public ResponseEntity> getAvailablePermissions() { + return ResponseEntity.ok(Arrays.stream(Permission.values()) + .map(PermissionResponse::of) + .toList()); } // Audit Reporting Endpoints + // + // Annotated with audit:read rather than inheriting the class-level ADMIN role, because + // the audit log now covers board and device actions too — a BOARD_ADMIN needs to read + // it without being handed media moderation. + @GetMapping("/audit") - public ResponseEntity getAuditEvents(Pageable pageable) { - try { - log.debug("Admin retrieving audit events, page: {}", pageable.getPageNumber()); - Page auditEvents = auditService.getAllAuditEvents(pageable); - return ResponseEntity.ok(auditEvents); - } catch (Exception e) { - log.error("Error retrieving audit events: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve audit events: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") + public ResponseEntity> getAuditEvents(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(auditService.getAllAuditEvents(pageable)); } @GetMapping("/audit/failed") - public ResponseEntity getFailedOperations(Pageable pageable) { - try { - log.debug("Admin retrieving failed operations"); - Page failedOps = auditService.getFailedOperations(pageable); - return ResponseEntity.ok(failedOps); - } catch (Exception e) { - log.error("Error retrieving failed operations: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve failed operations: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") + public ResponseEntity> getFailedOperations(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(auditService.getFailedOperations(pageable)); } @GetMapping("/audit/upload/{uploadId}") - public ResponseEntity getUploadAuditHistory(@PathVariable UUID uploadId) { - try { - log.debug("Admin retrieving audit history for upload: {}", uploadId); - return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId)); - } catch (Exception e) { - log.error("Error retrieving upload audit history: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve upload audit history: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") + public ResponseEntity> getUploadAuditHistory(@PathVariable UUID uploadId) { + return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId)); } @GetMapping("/audit/action/{action}") - public ResponseEntity getAuditEventsByAction(@PathVariable AdminAction action, Pageable pageable) { - try { - log.debug("Admin retrieving audit events for action: {}", action); - Page events = auditService.getAuditEventsByAction(action, pageable); - return ResponseEntity.ok(events); - } catch (Exception e) { - log.error("Error retrieving audit events by action: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve audit events: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") + public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, @ParameterObject Pageable pageable) { + return ResponseEntity.ok(auditService.getAuditEventsByAction(action, pageable)); } @GetMapping("/audit/daterange") - public ResponseEntity getAuditEventsByDateRange(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end, Pageable pageable) { - try { - log.debug("Admin retrieving audit events between {} and {}", start, end); - Page events = auditService.getAuditEventsByDateRange(start, end, pageable); - return ResponseEntity.ok(events); - } catch (Exception e) { - log.error("Error retrieving audit events by date range: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve audit events: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") + public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, @ParameterObject Pageable pageable) { + return ResponseEntity.ok(auditService.getAuditEventsByDateRange(start, end, pageable)); } + @Operation(operationId = "getAvailableAuditActions", summary = "List the audit action types available for filtering") @GetMapping("/audit/actions") - public ResponseEntity getAvailableActions() { - try { - log.debug("Admin retrieving available audit actions"); - return ResponseEntity.ok(AdminAction.values()); - } catch (Exception e) { - log.error("Error retrieving available actions: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve available actions: " + e.getMessage())); - } + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") + public ResponseEntity> getAvailableActions() { + return ResponseEntity.ok(List.of(AuditAction.values())); + } + + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java new file mode 100644 index 0000000..f51708f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -0,0 +1,88 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.dto.board.request.AgentEnrollRequest; +import com.ibrasoft.lensbridge.dto.board.response.AgentEnrollResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; +import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Public endpoint used by a freshly-flashed agent to convert a one-time enrollment token + * into a Device row + Ed25519 public-key registration. + *

+ * No auth required (the token is the credential), but rate-limiting belongs at the gateway. + */ +@RestController +@RequestMapping("/api/agent") +@RequiredArgsConstructor +@Slf4j +public class AgentEnrollmentController { + + private final DeviceEnrollmentService enrollmentService; + + @Value("${musallahboard.agent.websocketUrl}") + private String websocketUrl; + + @Operation(operationId = "enrollAgent", + summary = "Exchange a one-time enrollment token for a device identity", + description = "Called once per device by the MusallahBoard agent. The returned websocketUrl is " + + "persisted verbatim into the agent's config and never requested again.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Device enrolled; identity and websocket URL returned"), + @ApiResponse(responseCode = "400", description = "Public key malformed or unsupported", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "401", description = "Enrollment token invalid, expired, or already used", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @PostMapping("/enroll") + public ResponseEntity enroll(@Valid @RequestBody AgentEnrollRequest request, + HttpServletRequest httpRequest) { + String remoteIp = resolveClientIp(httpRequest); + + Outcome outcome = enrollmentService.enroll( + request.getToken(), + request.getPublicKey(), + request.getHostname(), + request.getHardwareModel(), + request.getAgentVersion(), + remoteIp + ); + + return switch (outcome) { + case Outcome.Ok ok -> ResponseEntity.ok(AgentEnrollResponse.builder() + .deviceId(ok.device().getId()) + .websocketUrl(websocketUrl) + .build()); + case Outcome.InvalidPublicKey bad -> throw new ApiResponseException(HttpStatus.BAD_REQUEST, + new MessageResponse("Invalid public key: " + bad.reason())); + case Outcome.InvalidToken ignored -> throw new ApiResponseException(HttpStatus.UNAUTHORIZED, + new MessageResponse("Enrollment token is invalid, expired, or already used")); + }; + } + + private static String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + int comma = forwarded.indexOf(','); + return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index f94003f..92bbf78 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -1,70 +1,83 @@ package com.ibrasoft.lensbridge.controller; +import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.ibrasoft.lensbridge.model.auth.*; - -import com.ibrasoft.lensbridge.dto.request.*; -import com.ibrasoft.lensbridge.dto.response.*; - -import com.ibrasoft.lensbridge.security.jwt.JwtUtils; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import org.springframework.web.bind.annotation.*; + +import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.LoginRequest; +import com.ibrasoft.lensbridge.dto.auth.request.ResetPasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.ValidateResetTokenRequest; +import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.TokenRefreshRequest; +import com.ibrasoft.lensbridge.dto.auth.request.VerifyEmailRequest; +import com.ibrasoft.lensbridge.dto.auth.response.JwtResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.response.TokenRefreshResponse; +import com.ibrasoft.lensbridge.dto.auth.response.TokenValidationResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.exception.RefreshTokenException; +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.RefreshToken; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.security.services.AuthorityResolver; +import com.ibrasoft.lensbridge.security.CurrentUser; import com.ibrasoft.lensbridge.security.LoginAttemptService; -import com.ibrasoft.lensbridge.service.UserService; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; import com.ibrasoft.lensbridge.service.RefreshTokenService; -import com.ibrasoft.lensbridge.model.auth.RefreshToken; - -import jakarta.servlet.http.HttpServletRequest; +import com.ibrasoft.lensbridge.service.UserService; @RestController @RequestMapping("/api/auth") +@RequiredArgsConstructor +@Slf4j public class AuthController { - private static final Logger log = LoggerFactory.getLogger(AuthController.class); - - @Autowired - AuthenticationManager authenticationManager; - - @Autowired - UserService userService; - - @Autowired - JwtUtils jwtUtils; - - @Autowired - LoginAttemptService loginAttemptService; - - @Autowired - RefreshTokenService refreshTokenService; + private final AuthenticationManager authenticationManager; + private final UserService userService; + private final JwtUtils jwtUtils; + private final LoginAttemptService loginAttemptService; + private final RefreshTokenService refreshTokenService; + private final AuthorityResolver authorityResolver; + + @Operation(operationId = "signIn", summary = "Authenticate with email and password") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Authenticated; access and refresh tokens issued"), + @ApiResponse(responseCode = "401", description = "Bad credentials or disabled account", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "429", description = "Too many failed attempts; account temporarily locked", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/signin") - public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest, HttpServletRequest request) { - + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest, + HttpServletRequest request) { String clientKey = loginRequest.getEmail(); - // Check if account is temporarily locked due to failed attempts if (loginAttemptService.isBlocked(clientKey)) { - return ResponseEntity.status(429) // Too Many Requests - .body(new MessageResponse("Account temporarily locked due to too many failed login attempts. Please try again later.")); + throw new ApiResponseException(HttpStatus.TOO_MANY_REQUESTS, + new MessageResponse("Account temporarily locked due to too many failed login attempts. Please try again later.")); } try { @@ -73,279 +86,231 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login SecurityContextHolder.getContext().setAuthentication(authentication); - // Check if user's email is verified - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findByEmail(userDetails.getUsername()).orElse(null); - - if (user != null && !user.isVerified()) { - return ResponseEntity.badRequest() - .body(new MessageResponse("Please verify your email before logging in.")); - } + User user = userService.findByEmail(authentication.getName()) + .orElseThrow(() -> new RuntimeException("Authenticated user not found in database")); - // Record successful login loginAttemptService.recordSuccessfulAttempt(clientKey); String jwt = jwtUtils.generateJwtToken(authentication); - - // Create refresh token - String deviceInfo = request.getHeader("User-Agent"); - String ipAddress = getClientIpAddress(request); RefreshToken refreshToken = refreshTokenService.createRefreshToken( - userDetails.getId(), deviceInfo, ipAddress); - - log.debug("Created refresh token for user {}: {}", userDetails.getId(), refreshToken.getToken()); - - List roles = userDetails.getAuthorities().stream() - .map(item -> item.getAuthority()) + user.getId(), request.getHeader("User-Agent"), getClientIpAddress(request)); + + // TODO: This is probably slow and should be fitted in the data model somewhere + // Replace with a more efficient way to get roles and permissions if performance becomes an issue + List authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + List roles = authorities.stream() + .filter(a -> a.startsWith("ROLE_")) + .collect(Collectors.toList()); + List permissions = authorities.stream() + .filter(a -> !a.startsWith("ROLE_")) + .sorted() .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, - refreshToken.getToken(), - userDetails.getFirstName(), - userDetails.getLastName(), - userDetails.getId(), - userDetails.getEmail(), - roles)); + refreshToken.getTokenHash(), + user.getFirstName(), + user.getLastName(), + user.getId(), + user.getEmail(), + roles, + permissions)); } catch (Exception e) { - // Record failed login attempt loginAttemptService.recordFailedAttempt(clientKey); - throw e; // Let global exception handler deal with it + throw e; } } + @Operation(operationId = "signUp", summary = "Register a new account") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Account created; verification email sent"), + @ApiResponse(responseCode = "400", description = "Validation failed or email already registered", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/signup") - public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) throws Exception { - try { - userService.createUser(signUpRequest); - return ResponseEntity.ok(new MessageResponse("User registered successfully! Please check your email to verify your account.")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse("Error: " + e.getMessage())); - } + public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { + userService.createUser(signUpRequest); + return ResponseEntity.ok(new MessageResponse("User registered successfully! Please check your email to verify your account.")); } + @Operation(operationId = "verifyEmail", summary = "Confirm an email address using the token from the verification email") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Email verified"), + @ApiResponse(responseCode = "400", description = "Missing, invalid or expired token", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/verify-email") - public ResponseEntity verifyEmail(@RequestBody(required = true) java.util.Map payload) { - String token = payload.get("token"); - if (token == null || token.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Invalid verification token.")); - } - - try { - userService.verifyUserEmail(token); - return ResponseEntity.ok(new MessageResponse("Email verified successfully!")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse(e.getMessage())); - } + public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + userService.verifyUserEmail(request.getToken()); + return ResponseEntity.ok(new MessageResponse("Email verified successfully!")); } + @Operation(operationId = "requestPasswordReset", + summary = "Send a password reset email", + description = "Always reports success so the response cannot be used to probe which addresses are registered.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Request accepted"), + @ApiResponse(responseCode = "400", description = "Email missing", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/forgot-password") - public ResponseEntity requestPasswordReset(@RequestParam String email) { + public ResponseEntity requestPasswordReset(@RequestParam String email) { if (email == null || email.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Email is required.")); + throw new IllegalArgumentException("Email is required."); } - userService.requestPasswordReset(email); - return ResponseEntity.ok(new MessageResponse("If an account with that email exists, a password reset email has been sent.")); } - @GetMapping("/validate-reset-token") - public ResponseEntity validateResetToken(@RequestParam String token) { - if (token == null || token.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Token is required.")); - } - - if (userService.validateResetToken(token)) { - return ResponseEntity.ok(new MessageResponse("Token is valid.")); - } else { - return ResponseEntity.badRequest().body(new MessageResponse("Invalid or expired token.")); + @Operation(operationId = "validateResetToken", + summary = "Check whether a password reset token is still usable", + description = "A POST despite being a read: the token is a credential, and only a body keeps it " + + "out of access logs, proxy logs and browser history. Being a POST also stops the " + + "result being cached by an intermediary.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Token is valid"), + @ApiResponse(responseCode = "400", description = "Token missing, invalid or expired", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @PostMapping("/validate-reset-token") + public ResponseEntity validateResetToken(@Valid @RequestBody ValidateResetTokenRequest request) { + // Presence is enforced by bean validation; GlobalExceptionHandler renders + // both that and the rejection below as a MessageResponse. + if (!userService.validateResetToken(request.getToken())) { + throw new IllegalArgumentException("Invalid or expired token."); } + return ResponseEntity.ok(new MessageResponse("Token is valid.")); } + @Operation(operationId = "resetPassword", + summary = "Set a new password using a reset token", + description = "The token and password travel in the request body, not the query string: " + + "query strings are recorded in access logs, proxies and browser history.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Password reset"), + @ApiResponse(responseCode = "400", description = "Token or password missing, invalid or expired", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/reset-password") - public ResponseEntity resetPassword(@RequestParam String token, @RequestParam String newPassword) { - if (token == null || token.isEmpty() || newPassword == null || newPassword.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Token and new password are required.")); - } - - try { - userService.resetPassword(token, newPassword); - return ResponseEntity.ok(new MessageResponse("Password reset successfully.")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse(e.getMessage())); - } + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + // Presence and length are enforced by bean validation; MethodArgumentNotValidException + // is translated to a MessageResponse by GlobalExceptionHandler. + userService.resetPassword(request.getToken(), request.getNewPassword()); + return ResponseEntity.ok(new MessageResponse("Password reset successfully.")); } + @Operation(operationId = "changePassword", + summary = "Change the signed-in user's password", + description = "Revokes every refresh token for the user, so all sessions must sign in again.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Password changed; all sessions revoked"), + @ApiResponse(responseCode = "400", description = "Current password incorrect or new password rejected", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "403", description = "Not signed in", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/change-password") - public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest, - Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.badRequest().body(new MessageResponse("You must be logged in to change your password.")); - } - - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findByEmail(userDetails.getUsername()).orElse(null); - - if (user == null) { - return ResponseEntity.badRequest().body(new MessageResponse("User not found.")); - } - - try { - userService.changePassword(user, changePasswordRequest); - refreshTokenService.revokeAllUserTokens(user.getId()); - // Create new refresh token + jwt and return to user.... at some point - return ResponseEntity.ok(new MessageResponse("Password changed successfully. Please log in again.")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse(e.getMessage())); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest, + @CurrentUser User user) { + userService.changePassword(user, changePasswordRequest); + refreshTokenService.revokeAllUserTokens(user.getId()); + return ResponseEntity.ok(new MessageResponse("Password changed successfully. Please log in again.")); } + @Operation(operationId = "refreshToken", + summary = "Exchange a refresh token for a new access token", + description = "Rotates the refresh token: the supplied one is revoked and a new one returned.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "New access and refresh tokens issued"), + @ApiResponse(responseCode = "401", description = "Refresh token unknown, expired or already revoked", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/refresh-token") - public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest request) { + public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest request) { String requestRefreshToken = request.getRefreshToken(); - - log.debug("Attempting to refresh token: {}", requestRefreshToken); - try { - Optional refreshTokenOpt = refreshTokenService.findByToken(requestRefreshToken); - - if (refreshTokenOpt.isEmpty()) { - log.warn("Refresh token not found in database: {}", requestRefreshToken); - return ResponseEntity.status(401) - .body(new MessageResponse("Refresh token is not in database. Please login again.")); - } - - RefreshToken refreshToken = refreshTokenOpt.get(); - log.debug("Found refresh token for user: {}", refreshToken.getUserId()); - - // Verify token is not expired or revoked - RefreshToken verifiedToken = refreshTokenService.verifyExpiration(refreshToken); - - UUID userId = verifiedToken.getUserId(); - User user = userService.findById(userId).orElse(null); - - if (user == null) { - log.warn("User not found for refresh token: {}", userId); - return ResponseEntity.status(401) - .body(new MessageResponse("User not found. Please login again.")); - } - - // Create new access token - UserDetailsImpl userDetails = UserDetailsImpl.build(user); - Authentication auth = new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); - String newAccessToken = jwtUtils.generateJwtToken(auth); - - // Rotate refresh token (recommended for security) - refreshTokenService.revokeRefreshToken(requestRefreshToken); - RefreshToken newRefreshToken = refreshTokenService.createRefreshToken( - userId, "Token refresh", "System"); - - log.debug("Successfully refreshed token for user: {}, new refresh token: {}", userId, newRefreshToken.getToken()); - - return ResponseEntity.ok(new TokenRefreshResponse( - newAccessToken, newRefreshToken.getToken())); - - } catch (RuntimeException e) { - // Handle expired or revoked tokens - if (e.getMessage().contains("expired") || e.getMessage().contains("revoked")) { - log.warn("Refresh token expired or revoked: {}", e.getMessage()); - return ResponseEntity.status(401) - .body(new MessageResponse("Refresh token expired or revoked. Please login again.")); - } - - // Log unexpected errors - log.error("Error refreshing token: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("An error occurred while refreshing token. Please try again.")); - } + RefreshToken refreshToken = refreshTokenService.findByToken(requestRefreshToken) + .orElseThrow(() -> new RefreshTokenException( + "Refresh token not found. Please login again.", HttpStatus.UNAUTHORIZED)); + + RefreshToken verifiedToken = refreshTokenService.verifyExpiration(refreshToken); + + UUID userId = verifiedToken.getUserId(); + User user = userService.findById(userId) + .orElseThrow(() -> new RefreshTokenException( + "User not found. Please login again.", HttpStatus.UNAUTHORIZED)); + + List authorities = new ArrayList<>(user.getRoles()); + Authentication auth = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities); + String newAccessToken = jwtUtils.generateJwtToken(auth); + + refreshTokenService.revokeRefreshToken(requestRefreshToken); + RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(userId, "Token refresh", "System"); + + return ResponseEntity.ok(new TokenRefreshResponse(newAccessToken, newRefreshToken.getToken())); } + @Operation(operationId = "logout", + summary = "Revoke a single refresh token", + description = "Succeeds even when no token is supplied, so signing out is idempotent.") + @ApiResponse(responseCode = "200", description = "Session ended") @PostMapping("/logout") - public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request, - Authentication authentication) { - try { - // Revoke refresh token if provided - if (request != null && request.getRefreshToken() != null) { - refreshTokenService.revokeRefreshToken(request.getRefreshToken()); - } - - // If user is authenticated, we could also revoke all their tokens - if (authentication != null && authentication.isAuthenticated()) { - // Optionally revoke all user tokens here if needed - // UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - // refreshTokenService.revokeAllUserTokens(userDetails.getId()); - } - - return ResponseEntity.ok(new MessageResponse("Logged out successfully")); - } catch (Exception e) { - return ResponseEntity.ok(new MessageResponse("Logged out successfully")); // Always return success for logout + public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { + if (request != null && request.getRefreshToken() != null) { + refreshTokenService.revokeRefreshToken(request.getRefreshToken()); } + return ResponseEntity.ok(new MessageResponse("Logged out successfully")); } + @Operation(operationId = "logoutAllDevices", summary = "Revoke every refresh token for the signed-in user") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "All sessions ended"), + @ApiResponse(responseCode = "403", description = "Not signed in", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/logout-all-devices") - public ResponseEntity logoutAllDevices(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.badRequest().body(new MessageResponse("You must be logged in.")); - } - - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - refreshTokenService.revokeAllUserTokens(userDetails.getId()); - + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity logoutAllDevices(@CurrentUser User user) { + refreshTokenService.revokeAllUserTokens(user.getId()); return ResponseEntity.ok(new MessageResponse("Logged out from all devices successfully")); } - @PostMapping("/validate-token") - public ResponseEntity validateToken(Authentication authentication) { + @Operation(operationId = "validateToken", + summary = "Check the current access token and return the user it belongs to") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Token valid; user returned"), + @ApiResponse(responseCode = "401", description = "Token missing, expired, or user no longer exists", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @GetMapping("/validate-token") + public ResponseEntity validateToken(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Invalid or expired token")); + throw new ApiResponseException(HttpStatus.UNAUTHORIZED, new MessageResponse("Invalid or expired token")); } - - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findById(userDetails.getId()).orElse(null); - - if (user == null) { - return ResponseEntity.status(401) - .body(new MessageResponse("User not found")); - } - - // Return user information if token is valid - return ResponseEntity.ok(new TokenValidationResponse( - true, - userDetails.getId(), - userDetails.getFirstName(), - userDetails.getLastName(), - userDetails.getEmail(), - userDetails.isVerified(), - userDetails.getAuthorities().stream() - .map(authority -> authority.getAuthority()) - .collect(Collectors.toList()) - )); - - } catch (Exception e) { - log.error("Error validating token: {}", e.getMessage(), e); - return ResponseEntity.status(401) - .body(new MessageResponse("Invalid or expired token")); + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + throw new ApiResponseException(HttpStatus.UNAUTHORIZED, new MessageResponse("User not found")); } + return ResponseEntity.ok(new TokenValidationResponse( + true, + user.getId(), + user.getFirstName(), + user.getLastName(), + user.getEmail(), + user.isVerified(), + user.getRoles().stream().map(Role::getAuthority).collect(Collectors.toList()), + authorityResolver.resolvePermissions(user).stream() + .map(Permission::getAuthority).sorted().collect(Collectors.toList()) + )); } - @GetMapping("/validate-token") - public ResponseEntity validateTokenGet(Authentication authentication) { - return validateToken(authentication); - } - - /** - * Helper method to get client IP address - */ private String getClientIpAddress(HttpServletRequest request) { - String xForwardedForHeader = request.getHeader("X-Forwarded-For"); - if (xForwardedForHeader == null) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor == null) { return request.getRemoteAddr(); - } else { - // X-Forwarded-For can contain multiple IPs, get the first one - return xForwardedForHeader.split(",")[0].trim(); } + return xForwardedFor.split(",")[0].trim(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index a2b6e77..2f8ae52 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -1,136 +1,160 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.request.CreateCalendarEventRequest; -import com.ibrasoft.lensbridge.dto.request.CreatePosterRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateBoardConfigRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateCalendarEventRequest; -import com.ibrasoft.lensbridge.dto.request.UpdatePosterRequest; -import com.ibrasoft.lensbridge.dto.request.WeeklyContentRequest; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; -import com.ibrasoft.lensbridge.handler.SignboardHandler; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.CreatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateTickerRequest; +import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; +import com.ibrasoft.lensbridge.dto.board.response.DeviceSummary; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import com.ibrasoft.lensbridge.model.board.Event; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Poster; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import com.ibrasoft.lensbridge.model.auth.Role; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDate; - import java.util.List; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; /** * Admin controller for managing board content (posters, calendar events, board config, weekly content). * Returns raw entity data for admin console viewing/editing. - * All endpoints require ROOT role access. + *

+ * Authorization is per-endpoint by permission, not by role. There is deliberately no + * class-level {@code @PreAuthorize}: reading a poster and moving a board's coordinates are + * not the same privilege, and a blanket annotation is what made them the same before. + * See {@code docs/PERMISSIONS.md}. */ @RestController @RequestMapping("/api/admin/board") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.ROOT + "')") @Slf4j public class BoardAdminController { private final PosterService posterService; + private final PromotableSocialMediaService socialMediaService; private final BoardService boardService; private final AdminAuditService auditService; - @Autowired - private SignboardHandler signboardHandler; - + private final BoardStreamHandler boardStream; + // ==================== Board Config Endpoints ==================== - /** - * Get all board configurations. - */ @GetMapping("/configs") - public ResponseEntity> getAllBoardConfigs() { + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_READ + "')") + public ResponseEntity> getAllBoardConfigs() { log.debug("Admin fetching all board configs"); return ResponseEntity.ok(boardService.getAllBoardConfigs()); } - /** - * Get board configuration for a specific location. - */ - @GetMapping("/configs/{boardLocation}") - public ResponseEntity getBoardConfig(@PathVariable BoardLocation boardLocation) { - log.debug("Admin fetching board config for: {}", boardLocation); - return ResponseEntity.ok(boardService.getBoardConfigOrThrow(boardLocation)); + @GetMapping("/configs/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_READ + "')") + @Operation(operationId = "getAdminBoardConfig", summary = "Fetch a device config as an administrator") + public ResponseEntity getBoardConfig(@PathVariable UUID deviceId) { + log.debug("Admin fetching board config for device: {}", deviceId); + return ResponseEntity.ok(boardService.getBoardConfigOrThrow(deviceId)); } - /** - * Create or replace a board configuration. - */ - @PutMapping("/configs/{boardLocation}") - public ResponseEntity saveBoardConfig( - @PathVariable BoardLocation boardLocation, - @Valid @RequestBody BoardConfig boardConfig, + @PutMapping("/configs/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_WRITE + "')") + public ResponseEntity saveBoardConfig( + @PathVariable UUID deviceId, + @Valid @RequestBody DeviceConfig deviceConfig, HttpServletRequest request) { - - log.info("Admin saving board config for: {}", boardLocation); - boardConfig.setBoardLocation(boardLocation); - BoardConfig saved = boardService.saveBoardConfig(boardConfig); + + log.info("Admin saving board config for device: {}", deviceId); + DeviceConfig saved = boardService.saveBoardConfig(deviceId, deviceConfig); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_CONFIG, + "BoardConfig", deviceId, request.getRemoteAddr()); + return ResponseEntity.ok(saved); } + @PatchMapping("/configs/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_WRITE + "')") + public ResponseEntity updateBoardConfig( + @PathVariable UUID deviceId, + @Valid @RequestBody UpdateBoardConfigRequest updateRequest, + HttpServletRequest request) { + + log.info("Admin updating board config for device: {}", deviceId); + DeviceConfig updated = boardService.updateBoardConfig(deviceId, updateRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_CONFIG, + "BoardConfig", deviceId, request.getRemoteAddr()); + + return ResponseEntity.ok(updated); + } + /** - * Update board configuration (partial update). + * Ticker-only door into the config, for holders of {@code board:ticker:write} who have no + * business setting the board's coordinates. Anyone with {@code board:config:write} can + * reach the same fields through the PATCH above. */ - @PatchMapping("/configs/{boardLocation}") - public ResponseEntity updateBoardConfig( - @PathVariable BoardLocation boardLocation, - @Valid @RequestBody UpdateBoardConfigRequest updateRequest, + @PatchMapping("/configs/{deviceId}/ticker") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_TICKER_WRITE + "')") + @Operation(operationId = "updateBoardTicker", summary = "Update a board's scrolling messages") + public ResponseEntity updateTicker( + @PathVariable UUID deviceId, + @Valid @RequestBody UpdateTickerRequest updateRequest, HttpServletRequest request) { - - log.info("Admin updating board config for: {}", boardLocation); - BoardConfig updated = boardService.updateBoardConfig(boardLocation, updateRequest); + + log.info("Admin updating ticker for device: {}", deviceId); + DeviceConfig updated = boardService.updateTicker(deviceId, updateRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_TICKER, + "BoardConfig", deviceId, request.getRemoteAddr()); + return ResponseEntity.ok(updated); } // ==================== Weekly Content Endpoints ==================== - /** - * Get all weekly content. - */ @GetMapping("/weekly-content") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getAllWeeklyContent() { log.debug("Admin fetching all weekly content"); return ResponseEntity.ok(boardService.getAllWeeklyContent()); } - /** - * Get weekly content for a specific year. - */ @GetMapping("/weekly-content/year/{year}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getWeeklyContentByYear(@PathVariable int year) { log.debug("Admin fetching weekly content for year: {}", year); return ResponseEntity.ok(boardService.getWeeklyContentByYear(year)); } - /** - * Get weekly content for a specific week. - */ @GetMapping("/weekly-content/{year}/{weekNumber}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + @Operation(operationId = "getAdminWeeklyContent", summary = "Fetch weekly content as an administrator") public ResponseEntity getWeeklyContent( @PathVariable int year, @PathVariable int weekNumber) { @@ -139,276 +163,296 @@ public ResponseEntity getWeeklyContent( } /** - * Create or update weekly content. + * Writes quotes and jummah prayer times together — they share this endpoint, so they + * share one permission. The audit event is therefore the only record of who last moved + * a khutbah time; see {@code docs/PERMISSIONS.md} §3. */ - @PutMapping("/weekly-content") + @PutMapping("/weekly-content/{year}/{weekNumber}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_WEEKLY_WRITE + "')") public ResponseEntity saveWeeklyContent( + @PathVariable int year, + @PathVariable int weekNumber, @Valid @RequestBody WeeklyContentRequest contentRequest, HttpServletRequest request) { - - log.info("Admin saving weekly content for week {} of {}", - contentRequest.getWeekNumber(), contentRequest.getYear()); - WeeklyContent saved = boardService.saveWeeklyContent(contentRequest); + + log.info("Admin saving weekly content for week {} of {}", weekNumber, year); + WeeklyContent saved = boardService.saveWeeklyContent(year, weekNumber, contentRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.SAVE_WEEKLY_CONTENT, + "WeeklyContent", saved.getId(), request.getRemoteAddr()); + return ResponseEntity.ok(saved); } - /** - * Delete weekly content. - */ @DeleteMapping("/weekly-content/{year}/{weekNumber}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_WEEKLY_WRITE + "')") public ResponseEntity deleteWeeklyContent( @PathVariable int year, @PathVariable int weekNumber, HttpServletRequest request) { - + log.info("Admin deleting weekly content for week {} of {}", weekNumber, year); - boardService.deleteWeeklyContent(year, weekNumber); + UUID deletedId = boardService.deleteWeeklyContent(year, weekNumber); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_WEEKLY_CONTENT, + "WeeklyContent", deletedId, request.getRemoteAddr()); + return ResponseEntity.ok(new MessageResponse("Weekly content deleted successfully")); } // ==================== Poster Endpoints ==================== - /** - * Get all posters sorted by newest first (most recent startDate). - */ @GetMapping("/posters") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getAllPosters() { log.debug("Admin fetching all posters"); return ResponseEntity.ok(posterService.getAllPosters()); } - /** - * Get posters for a specific board location. - * Returns posters matching the board's audience or BOTH, sorted by newest first. - */ - @GetMapping("/posters/by-board") - public ResponseEntity> getPostersForBoard( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Admin fetching posters for board: {}", boardLocation); - return ResponseEntity.ok(posterService.getPostersForBoard(boardLocation)); + @GetMapping("/posters/by-audience") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity> getPostersForAudience(@RequestParam Audience audience) { + log.debug("Admin fetching posters for audience: {}", audience); + return ResponseEntity.ok(posterService.getPostersForAudience(audience)); } - /** - * Get a single poster by ID. - */ @GetMapping("/posters/{posterId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity getPosterById(@PathVariable UUID posterId) { log.debug("Admin fetching poster: {}", posterId); return ResponseEntity.ok(posterService.getPosterById(posterId)); } - /** - * Create a new poster with an uploaded image. - */ @PostMapping(value = "/posters", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity createPoster( - @RequestParam("title") String title, - @RequestParam("duration") int duration, - @RequestParam("startDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - @RequestParam("endDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, - @RequestParam("audience") Audience audience, - @RequestParam("image") MultipartFile imageFile, + // @ModelAttribute, not @RequestParam: on a complex type @RequestParam + // makes Spring look for one parameter literally named "createRequest" + // and convert it, which no converter can do. Binding a multipart form + // field-by-field onto a DTO is what @ModelAttribute is for, and it is + // also what makes springdoc document the individual fields instead of + // a single opaque object. + @ModelAttribute @Valid CreatePosterRequest createRequest, HttpServletRequest request) { - log.info("Admin creating new poster: title={}", title); - - CreatePosterRequest createRequest = CreatePosterRequest.builder() - .title(title) - .duration(duration) - .startDate(startDate) - .endDate(endDate) - .audience(audience) - .build(); + log.info("Admin creating new poster: title={}", createRequest.getTitle()); - Poster response = posterService.createPoster(createRequest, imageFile); + Poster response = posterService.createPoster(createRequest); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } - /** - * Update poster metadata (duration, dates, audience). - */ @PatchMapping("/posters/{posterId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity updatePoster( @PathVariable UUID posterId, @Valid @RequestBody UpdatePosterRequest updateRequest, HttpServletRequest request) { - + log.info("Admin updating poster: {}", posterId); Poster response = posterService.updatePoster(posterId, updateRequest); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } - /** - * Update poster image. - */ @PutMapping(value = "/posters/{posterId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity updatePosterImage( @PathVariable UUID posterId, @RequestParam("image") MultipartFile imageFile, HttpServletRequest request) { - + log.info("Admin updating poster image: {}", posterId); Poster response = posterService.updatePosterImage(posterId, imageFile); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } - /** - * Delete a poster. - */ @DeleteMapping("/posters/{posterId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity deletePoster( @PathVariable UUID posterId, HttpServletRequest request) { - + log.info("Admin deleting poster: {}", posterId); posterService.deletePoster(posterId); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Poster deleted successfully")); } + // ==================== Promotable Social Media Endpoints ==================== + + @GetMapping("/socials") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity> getAllSocials() { + log.debug("Admin fetching all promotable social media"); + return ResponseEntity.ok(socialMediaService.getAll()); + } + + @GetMapping("/socials/by-audience") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity> getSocialsForAudience(@RequestParam Audience audience) { + log.debug("Admin fetching promotable social media for audience: {}", audience); + return ResponseEntity.ok(socialMediaService.getForAudience(audience)); + } + + @GetMapping("/socials/{socialId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity getSocialById(@PathVariable UUID socialId) { + log.debug("Admin fetching promotable social media: {}", socialId); + return ResponseEntity.ok(socialMediaService.getById(socialId)); + } + + @PostMapping("/socials") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_SOCIAL_WRITE + "')") + public ResponseEntity createSocial( + @Valid @RequestBody CreatePromotableSocialMediaRequest createRequest, + HttpServletRequest request) { + + log.info("Admin creating promotable social media: type={}, name={}", + createRequest.getType(), createRequest.getName()); + + PromotableSocialMedia response = socialMediaService.create(createRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_SOCIAL, "PromotableSocialMedia", response.getId(), request.getRemoteAddr()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PatchMapping("/socials/{socialId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_SOCIAL_WRITE + "')") + public ResponseEntity updateSocial( + @PathVariable UUID socialId, + @Valid @RequestBody UpdatePromotableSocialMediaRequest updateRequest, + HttpServletRequest request) { + + log.info("Admin updating promotable social media: {}", socialId); + PromotableSocialMedia response = socialMediaService.update(socialId, updateRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_SOCIAL, "PromotableSocialMedia", socialId, request.getRemoteAddr()); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/socials/{socialId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_SOCIAL_WRITE + "')") + public ResponseEntity deleteSocial( + @PathVariable UUID socialId, + HttpServletRequest request) { + + log.info("Admin deleting promotable social media: {}", socialId); + socialMediaService.delete(socialId); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_SOCIAL, "PromotableSocialMedia", socialId, request.getRemoteAddr()); + + return ResponseEntity.ok(new MessageResponse("Promotable social media deleted successfully")); + } + // ==================== Calendar Event Endpoints ==================== - /** - * Get all calendar events. - */ @GetMapping("/events") - public ResponseEntity> getAllEvents() { + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + @Operation(operationId = "getBoardEvents", summary = "List every board event") + public ResponseEntity> getAllEvents() { log.debug("Admin fetching all calendar events"); - List events = boardService.getAllEvents(); - return ResponseEntity.ok(events); + return ResponseEntity.ok(boardService.getAllEvents()); } - /** - * Get calendar events for a specific board location. - * Returns events matching the board's audience or BOTH. - */ - @GetMapping("/events/by-board") - public ResponseEntity> getEventsForBoard( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Admin fetching calendar events for board: {}", boardLocation); - List events = boardService.getEventsForBoard(boardLocation); - return ResponseEntity.ok(events); + @GetMapping("/events/by-audience") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity> getEventsForAudience(@RequestParam Audience audience) { + log.debug("Admin fetching calendar events for audience: {}", audience); + return ResponseEntity.ok(boardService.getEventsForAudience(audience)); } - /** - * Get a single calendar event by ID. - */ @GetMapping("/events/{eventId}") - public ResponseEntity getEventById(@PathVariable UUID eventId) { + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + @Operation(operationId = "getBoardEventById", summary = "Fetch a single board event") + public ResponseEntity getEventById(@PathVariable UUID eventId) { log.debug("Admin fetching calendar event: {}", eventId); - Event event = boardService.getEventById(eventId); - return ResponseEntity.ok(event); + return ResponseEntity.ok(boardService.getEventById(eventId)); } - /** - * Create a new calendar event. - */ @PostMapping("/events") - public ResponseEntity createEvent( + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_EVENT_WRITE + "')") + @Operation(operationId = "createBoardEvent", summary = "Create a board event") + public ResponseEntity createEvent( @Valid @RequestBody CreateCalendarEventRequest createRequest, HttpServletRequest request) { - - log.info("Admin creating new calendar event: name={}", createRequest.getName()); - Event event = Event.builder() - .name(createRequest.getName()) - .description(createRequest.getDescription()) - .location(createRequest.getLocation()) - .startTimestamp(createRequest.getStartTimestamp()) - .endTimestamp(createRequest.getEndTimestamp()) - .allDay(createRequest.getAllDay()) - .audience(createRequest.getAudience()) - .build(); - - Event created = boardService.createEvent(event); + log.info("Admin creating new calendar event: name={}", createRequest.getName()); + BoardEvent created = boardService.createEvent(createRequest); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(created); } - /** - * Update a calendar event. - */ @PatchMapping("/events/{eventId}") - public ResponseEntity updateEvent( + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_EVENT_WRITE + "')") + public ResponseEntity updateEvent( @PathVariable UUID eventId, @Valid @RequestBody UpdateCalendarEventRequest updateRequest, HttpServletRequest request) { - - log.info("Admin updating calendar event: {}", eventId); - - Event updates = Event.builder() - .name(updateRequest.getName()) - .description(updateRequest.getDescription()) - .location(updateRequest.getLocation()) - .startTimestamp(updateRequest.getStartTimestamp()) - .endTimestamp(updateRequest.getEndTimestamp()) - .allDay(updateRequest.getAllDay()) - .audience(updateRequest.getAudience()) - .build(); - Event updated = boardService.updateEvent(eventId, updates); + log.info("Admin updating calendar event: {}", eventId); + BoardEvent updated = boardService.updateEvent(eventId, updateRequest); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(updated); } - /** - * Delete a calendar event. - */ @DeleteMapping("/events/{eventId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_EVENT_WRITE + "')") public ResponseEntity deleteEvent( @PathVariable UUID eventId, HttpServletRequest request) { - + log.info("Admin deleting calendar event: {}", eventId); boardService.deleteEvent(eventId); - // Audit the action - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Calendar event deleted successfully")); } - /** - * Refresh a MusallahBoard instance - * @return - */ @PostMapping("/refresh") - public ResponseEntity refreshSignboard() { - signboardHandler.sendRefreshCommand(); - return ResponseEntity.ok("Refresh command sent to MusallahBoard instances"); + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_REFRESH + "')") + public ResponseEntity refreshSignboard(HttpServletRequest request) { + boardStream.refreshAll(); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.REFRESH_BOARDS, + "Musallah Board", null, request.getRemoteAddr()); + + return ResponseEntity.ok(new MessageResponse("Refresh sent to all MusallahBoard instances")); } - // ==================== Helper Methods ==================== + @PatchMapping("/devices/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_WRITE + "')") + public ResponseEntity updateDevice( + @PathVariable UUID deviceId, + @Valid @RequestBody UpdateDeviceRequest updateRequest, + HttpServletRequest request) { + + log.info("Admin updating device: {}", deviceId); + Device updated = boardService.updateDevice(deviceId, updateRequest); + boardStream.configChanged(deviceId); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_CONFIG, "Device", deviceId, request.getRemoteAddr()); + + return ResponseEntity.ok(DeviceSummary.of(updated)); + } - private UserDetailsImpl getCurrentUser() { - return (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java new file mode 100644 index 0000000..d98dc3e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -0,0 +1,202 @@ +package com.ibrasoft.lensbridge.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.board.request.IssueEnrollmentTokenRequest; +import com.ibrasoft.lensbridge.dto.board.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.dto.board.response.CommandView; +import com.ibrasoft.lensbridge.dto.board.response.DeviceSummary; +import com.ibrasoft.lensbridge.dto.board.response.IssueEnrollmentTokenResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.commands.CommandKind; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.AdminAuditService; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; +import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService.Issued; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.socket.CloseStatus; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Fleet administration. Authorization is per-endpoint by permission: reading device status + * is something you hand out freely, minting an enrollment token is not, and issuing a + * command depends on which command it is. See {@code docs/PERMISSIONS.md}. + */ +@RestController +@RequestMapping("/api/admin/board/devices") +@RequiredArgsConstructor +@Slf4j +public class DeviceAdminController { + + private final EnrollmentTokenService enrollmentTokenService; + private final DeviceRepository deviceRepository; + private final DeviceCommandRepository commandRepository; + private final CommandDispatcher commandDispatcher; + private final ObjectMapper objectMapper; + private final AgentSessionRegistry sessionRegistry; + private final AdminAuditService auditService; + + @Operation(operationId = "listDevices", summary = "List every enrolled board device") + @GetMapping + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')") + public ResponseEntity> list() { + return ResponseEntity.ok( + deviceRepository.findAll().stream().map(DeviceSummary::of).toList() + ); + } + + @Operation(operationId = "getDevice", summary = "Fetch a single board device") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Device found"), + @ApiResponse(responseCode = "404", description = "No device with that id", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @GetMapping("/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')") + public ResponseEntity get(@PathVariable UUID deviceId) { + return deviceRepository.findById(deviceId) + .map(d -> ResponseEntity.ok(DeviceSummary.of(d))) + .orElseThrow(() -> new ApiResponseException(HttpStatus.NOT_FOUND, + new MessageResponse("Device not found"))); + } + + @Operation(operationId = "issueEnrollmentToken", + summary = "Mint a one-time token a new device can exchange for an identity") + @ApiResponse(responseCode = "201", description = "Token issued; the plaintext is returned exactly once") + @PostMapping("/enrollment-tokens") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_ENROLL + "')") + public ResponseEntity issueEnrollmentToken( + @Valid @RequestBody IssueEnrollmentTokenRequest request, + HttpServletRequest httpRequest) { + String adminEmail = getCurrentUserEmail(); + Issued issued = enrollmentTokenService.issue( + request.getDisplayName(), + request.getAudience(), + request.getExpiresInMinutes(), + adminEmail + ); + log.info("Admin {} issued enrollment token {} ({})", + adminEmail, issued.token().getId(), request.getDisplayName()); + + auditService.logAuditEvent(adminEmail, AuditAction.ISSUE_ENROLLMENT_TOKEN, + "Device", issued.token().getId(), httpRequest.getRemoteAddr()); + + return ResponseEntity.status(HttpStatus.CREATED).body( + IssueEnrollmentTokenResponse.builder() + .tokenId(issued.token().getId()) + .token(issued.plaintext()) + .expiresAt(issued.token().getExpiresAt()) + .build() + ); + } + + @Operation(operationId = "revokeDevice", + summary = "Revoke a device's enrollment", + description = "Closes any live agent session. Idempotent: re-revoking an already revoked device is a no-op.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Device revoked"), + @ApiResponse(responseCode = "404", description = "No device with that id", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @PostMapping("/{deviceId}/revoke") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_REVOKE + "')") + public ResponseEntity revoke(@PathVariable UUID deviceId, + HttpServletRequest httpRequest) { + Optional found = deviceRepository.findById(deviceId); + if (found.isEmpty()) { + throw new ApiResponseException(HttpStatus.NOT_FOUND, + new MessageResponse("Device not found")); + } + Device d = found.get(); + if (d.getRevokedAt() == null) { + d.setRevokedAt(Instant.now()); + deviceRepository.save(d); + sessionRegistry.closeIfPresent(deviceId, CloseStatus.POLICY_VIOLATION.withReason("device_revoked")); + log.warn("Device {} revoked by {}", deviceId, getCurrentUserEmail()); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.REVOKE_DEVICE, + "Device", deviceId, httpRequest.getRemoteAddr()); + } + return ResponseEntity.ok(DeviceSummary.of(d)); + } + + /** + * The required permission depends on the command's blast radius, which is only knowable + * once the body is bound - hence the authorizer bean rather than a {@code hasAuthority} + * expression. {@code chrome.reload} and {@code chrome.screenshot} are not the same + * privilege; see {@link com.ibrasoft.lensbridge.model.board.commands.CommandRisk}. + */ + @Operation(operationId = "issueDeviceCommand", + summary = "Queue a command for a device", + description = "Returns as soon as the command is queued; delivery and execution are reported " + + "asynchronously over the agent websocket. The permission required depends on the " + + "command kind: board:command:benign, :disruptive, or :inspect.") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "Command accepted and queued for delivery"), + @ApiResponse(responseCode = "403", description = "Caller lacks the permission for this command kind", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @PostMapping("/{deviceId}/commands") + @PreAuthorize("@commandAuthorizer.canIssue(authentication, #request)") + public ResponseEntity issueCommand(@PathVariable UUID deviceId, + @Valid @RequestBody IssueCommandRequest request, + HttpServletRequest httpRequest) { + CommandIssuedResponse response = commandDispatcher.issue(deviceId, getCurrentUserEmail(), request); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.ISSUE_DEVICE_COMMAND, + "Device", deviceId, httpRequest.getRemoteAddr(), + describe(request.kind(), response.commandId())); + + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + @Operation(operationId = "listDeviceCommands", summary = "Fetch the 50 most recent commands for a device") + @GetMapping("/{deviceId}/commands") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')") + public ResponseEntity> recentCommands(@PathVariable UUID deviceId) { + List commands = commandRepository + .findTop50ByDeviceIdOrderByIssuedAtDesc(deviceId) + .stream() + .map(c -> CommandView.of(c, objectMapper)) + .toList(); + return ResponseEntity.ok(commands); + } + + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; + } + + /** Audit detail for a command: the kind and its risk class, so the log distinguishes a reload from a screenshot. */ + private static String describe(String kind, UUID commandId) { + String risk = CommandKind.from(kind) + .map(k -> k.getRisk().name()) + .orElse("UNKNOWN"); + return "kind=" + kind + " risk=" + risk + " commandId=" + commandId; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java index 5506d81..c107388 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java @@ -1,16 +1,14 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.service.EventsService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; @RestController @RequestMapping("/api/events") @@ -20,21 +18,16 @@ public class EventsController { private final EventsService eventsService; @GetMapping - public ResponseEntity> getAllEvents() { - eventsService.cleanUpOldEvents(); - List events = eventsService.getAllEvents().stream() - .filter(event -> event.getStatus() == EventStatus.ONGOING || - (event.getStatus() == EventStatus.PAST && - event.getDate().isAfter(LocalDateTime.now().minusDays(7)))) - .toList(); - return ResponseEntity.ok(events); + @Operation(operationId = "getPublicEvents", summary = "List publicly visible events") + public ResponseEntity> getAllEvents() { + return ResponseEntity.ok(eventsService.getPublicVisibleEvents()); } - @GetMapping("/{id}") - public ResponseEntity getEventById(@PathVariable UUID id) { - Optional event = eventsService.getEventById(id); - return event.map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); + @Operation(operationId = "getPublicEventById", summary = "Fetch a single publicly visible event") + public ResponseEntity getEventById(@PathVariable UUID id) { + return eventsService.getEventById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index 35d1662..29e2324 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -1,26 +1,26 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.config.UploadProperties; -import com.ibrasoft.lensbridge.dto.response.*; -import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadDto; +import com.ibrasoft.lensbridge.dto.upload.response.UploadLimitsResponse; import com.ibrasoft.lensbridge.model.auth.Role; -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; -import com.ibrasoft.lensbridge.service.DirectUploadService; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.security.CurrentUser; +import com.ibrasoft.lensbridge.service.UploadLimitsService; import com.ibrasoft.lensbridge.service.UploadService; +import com.ibrasoft.lensbridge.service.UploadWorkflowService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.util.unit.DataSize; import org.springframework.web.bind.annotation.*; -import java.util.Collection; + import java.util.UUID; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api/upload") @@ -29,164 +29,60 @@ public class FileUploadController { private final UploadService uploadService; - private final DirectUploadService directUploadService; - private final UploadProperties uploadProperties; + private final UploadWorkflowService uploadWorkflowService; + private final UploadLimitsService uploadLimitsService; @GetMapping("/{uploadId}") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUploadById(@PathVariable UUID uploadId) { - try { - return uploadService.getUploadById(uploadId).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of("Error fetching upload: " + e.getMessage())); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUploadById(@PathVariable UUID uploadId) { + return uploadService.getUploadByIdAsDto(uploadId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); } @GetMapping("/event/{eventId}") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUploadsByEvent(@PathVariable UUID eventId, Pageable pageable) { - try { - Page uploads = uploadService.getUploadsByEvent(eventId, pageable); - return ResponseEntity.ok(uploads); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of("Error fetching uploads: " + e.getMessage())); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity> getUploadsByEvent(@PathVariable UUID eventId, @ParameterObject Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByEventAsDto(eventId, pageable)); } - /** - * Generate a presigned URL for direct upload to R2 for a specific event. - * This allows files to be uploaded directly to R2 without going through the backend. - */ @PostMapping("/{eventId}/direct/presign") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity generatePresignedUploadUrl( + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity generatePresignedUploadUrl( @PathVariable UUID eventId, @RequestParam String filename, @RequestParam String contentType, @RequestParam long fileSize, @RequestParam String expectedSha256, + @CurrentUser User user, Authentication authentication) { - try { - UserDetailsImpl user = (UserDetailsImpl) authentication.getPrincipal(); - String highestRole = getHighestRole(authentication); - - PresignedUploadResponse response = directUploadService.createPresignedUpload( - eventId, - filename, - contentType, - fileSize, - expectedSha256, - user.getId(), - highestRole); - - return ResponseEntity.ok(response); - } catch (ApiResponseException e) { - return ResponseEntity.status(e.getStatus()).body(e.getBody()); - } catch (Exception e) { - log.error("Failed to generate presigned upload URL for event: {}", eventId, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of("Failed to generate upload URL: " + e.getMessage())); - } + Role role = uploadLimitsService.getHighestRole(authentication); + return ResponseEntity.ok(uploadWorkflowService.initiateUpload( + eventId, filename, contentType, fileSize, expectedSha256, user.getId(), role)); } - /** - * Complete a direct upload by creating the Upload entity in the database. - * This should be called after the file has been successfully uploaded to R2. - */ @PostMapping("/{eventId}/direct/complete") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity completeDirectUpload( + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity completeDirectUpload( @PathVariable UUID eventId, @RequestParam String objectKey, @RequestParam String filename, @RequestParam String contentType, @RequestParam long fileSize, - @RequestParam(value = "instagramHandle", required = false) String instagramHandle, - @RequestParam(value = "description", required = false) String description, - @RequestParam(value = "anon", defaultValue = "false") boolean anon, + @RequestParam(required = false) String instagramHandle, + @RequestParam(required = false) String description, + @RequestParam(defaultValue = "false") boolean anon, @RequestParam String expectedSha256, - Authentication authentication) { - try { - UserDetailsImpl user = (UserDetailsImpl) authentication.getPrincipal(); - - UploadCompletionResponse response = directUploadService.completeDirectUpload( - eventId, - objectKey, - filename, - contentType, - fileSize, - instagramHandle, - description, - anon, - expectedSha256, - user.getId()); - - return ResponseEntity.ok(response); - } catch (ApiResponseException e) { - return ResponseEntity.status(e.getStatus()).body(e.getBody()); - } catch (Exception e) { - log.error("Failed to complete direct upload for event '{}', objectKey: {}", eventId, objectKey, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of("Failed to complete upload: " + e.getMessage())); - } + @CurrentUser User user) { + return ResponseEntity.ok(uploadWorkflowService.completeUpload( + eventId, objectKey, filename, contentType, fileSize, + instagramHandle, description, anon, expectedSha256, user.getId())); } - /** - * Get upload limits for the current user. - */ @GetMapping("/limits") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUploadLimits(Authentication authentication) { - try { - UserDetailsImpl user = (UserDetailsImpl) authentication.getPrincipal(); - String highestRole = getHighestRole(authentication); - DataSize maxSize = uploadProperties.getMaxSizeForRole(highestRole); - int dailyLimit = uploadProperties.getDailyLimitForRole(highestRole); - long uploadsToday = uploadService.countUploadsToday(user.getId()); - - UploadLimitsResponse limits = UploadLimitsResponse.builder() - .role(highestRole) - .maxSizeBytes(maxSize.toBytes()) - .maxSizeMB(maxSize.toMegabytes()) - .allowedContentTypes(uploadProperties.getAllowedFileTypes()) - .videoMaxDurationSeconds(uploadProperties.getVideoMaxduration()) - .dailyLimit(dailyLimit) - .uploadsToday(uploadsToday) - .uploadsRemaining(Math.max(0, dailyLimit - uploadsToday)) - .build(); - - return ResponseEntity.ok(limits); - } catch (Exception e) { - log.error("Failed to get upload limits", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of("Failed to get upload limits")); - } - } - - /** - * Get the highest role for the authenticated user. - */ - private String getHighestRole(Authentication authentication) { - if (authentication == null || authentication.getAuthorities() == null) { - return "verified"; // Default to verified (lowest role) - } - - Collection authorities = authentication.getAuthorities(); - - if (authorities.stream().anyMatch(a -> Role.ROOT.equals(a.getAuthority()))) { - return "root"; - } - if (authorities.stream().anyMatch(a -> Role.ADMIN.equals(a.getAuthority()))) { - return "admin"; - } - if (authorities.stream().anyMatch(a -> Role.MODERATOR.equals(a.getAuthority()))) { - return "moderator"; - } - if (authorities.stream().anyMatch(a -> Role.VERIFIED.equals(a.getAuthority()))) { - return "verified"; - } - - return "verified"; // Default to verified + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUploadLimits(@CurrentUser User user, Authentication authentication) { + Role role = uploadLimitsService.getHighestRole(authentication); + return ResponseEntity.ok(uploadLimitsService.getLimitsForRole(role, user.getId())); } - } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java index 635d4e1..02ee80f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java @@ -1,41 +1,34 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.service.GalleryService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api") @RequiredArgsConstructor +@Slf4j public class GalleryController { private final GalleryService galleryService; @GetMapping("/gallery") - public ResponseEntity> getAllUploads(Pageable pageable) { - try { - Page response = galleryService.getAllApprovedGalleryItems(pageable); - return ResponseEntity.ok(response); - } catch (Exception e) { - System.err.println("Error fetching gallery: " + e.getMessage()); - return ResponseEntity.ok(Page.empty()); - } + @Operation(operationId = "getGalleryUploads", summary = "Page through approved gallery media") + public ResponseEntity> getAllUploads(@ParameterObject Pageable pageable) { + return ResponseEntity.ok(galleryService.getAllApprovedGalleryItems(pageable)); } @GetMapping("/gallery/event/{eventId}") - public ResponseEntity> getGalleryByEvent(@PathVariable UUID eventId, Pageable pageable) { - try { - Page response = galleryService.getGalleryItemsByEvent(eventId, pageable); - return ResponseEntity.ok(response); - } catch (Exception e) { - System.err.println("Error fetching gallery for event: " + e.getMessage()); - return ResponseEntity.ok(Page.empty()); - } + public ResponseEntity> getGalleryByEvent(@PathVariable UUID eventId, @ParameterObject Pageable pageable) { + return ResponseEntity.ok(galleryService.getGalleryItemsByEvent(eventId, pageable)); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index 92c8133..a7d8229 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -1,58 +1,44 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.response.MusallahBoardPayload; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import com.ibrasoft.lensbridge.model.board.Event; +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.service.BoardService; -import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.util.List; +import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; -/** - * Controller for the Musallah Board display. - * Returns data formatted specifically for rendering on the board screens. - * These endpoints are publicly accessible (no authentication required) - * as they're consumed by the board display hardware. - */ @RestController @RequestMapping("/api/musallah") @RequiredArgsConstructor @Slf4j public class MusallahBoardController { - private final PosterService posterService; private final BoardService boardService; private final BoardPayloadAssembler payloadAssembler; // ==================== Board Configuration ==================== - /** - * Get the board configuration for a specific board location. - * Contains display settings like poster cycle interval, dark mode settings, etc. - */ @GetMapping("/config") - public ResponseEntity getBoardConfig( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching config for: {}", boardLocation); - return boardService.getBoardConfig(boardLocation) + @Operation(operationId = "getPublicBoardConfig", summary = "Fetch the config a kiosk should render") + public ResponseEntity getBoardConfig(@RequestParam UUID deviceId) { + log.debug("Musallah board fetching config for device: {}", deviceId); + return boardService.getBoardConfig(deviceId) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } // ==================== Weekly Content ==================== - /** - * Get the current week's content (verse, hadith, jummah prayer info). - * Returns 404 if no content is set for the current week. - */ @GetMapping("/weekly-content") public ResponseEntity getCurrentWeeklyContent() { log.debug("Musallah board fetching current weekly content"); @@ -61,10 +47,8 @@ public ResponseEntity getCurrentWeeklyContent() { .orElse(ResponseEntity.notFound().build()); } - /** - * Get weekly content for a specific week. - */ @GetMapping("/weekly-content/{year}/{weekNumber}") + @Operation(operationId = "getPublicWeeklyContent", summary = "Fetch weekly content for a kiosk") public ResponseEntity getWeeklyContent( @PathVariable int year, @PathVariable int weekNumber) { @@ -74,65 +58,30 @@ public ResponseEntity getWeeklyContent( .orElse(ResponseEntity.notFound().build()); } - // ==================== Poster Frames ==================== - - /** - * Get active poster frames for display on the board. - * Returns FrameDefinition objects containing poster URLs and display duration. - * Only returns posters that are currently active (within their date window) - * and match the board's audience. - * Sorted by startDate descending (newest first). - */ - @GetMapping("/posters") - public ResponseEntity> getActivePosterFrames( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching active poster frames for: {}", boardLocation); - List frames = posterService.getActivePosterFrameDefinitions(boardLocation); - return ResponseEntity.ok(frames); - } - // ==================== Calendar Events ==================== - /** - * Get upcoming calendar events for display on the board. - * Returns events that match the board's audience (or BOTH), - * filtered to only upcoming events (startTimestamp >= now). - * Sorted by startTimestamp ascending. - */ @GetMapping("/events") - public ResponseEntity> getUpcomingEvents( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching upcoming events for: {}", boardLocation); - List events = boardService.getUpcomingEventsForBoard(boardLocation); - return ResponseEntity.ok(events); + public ResponseEntity> getUpcomingEvents(@RequestParam Audience audience) { + log.debug("Musallah board fetching upcoming events for audience: {}", audience); + List boardEvents = boardService.getUpcomingEventsForAudience(audience); + return ResponseEntity.ok(boardEvents); } - /** - * Get calendar events within a specific time range for display on the board. - * Useful for "week at a glance" or daily schedule views. - * Returns events that overlap with the given time range. - */ @GetMapping("/events/range") - public ResponseEntity> getEventsInRange( - @RequestParam("board") BoardLocation boardLocation, - @RequestParam("start") long rangeStart, - @RequestParam("end") long rangeEnd) { - log.debug("Musallah board fetching events for {} in range {} - {}", boardLocation, rangeStart, rangeEnd); - List events = boardService.getEventsForBoardInRange(boardLocation, rangeStart, rangeEnd); - return ResponseEntity.ok(events); + public ResponseEntity> getEventsInRange( + @RequestParam Audience audience, + @RequestParam("start") Instant rangeStart, + @RequestParam("end") Instant rangeEnd) { + log.debug("Musallah board fetching events for {} in range {} - {}", audience, rangeStart, rangeEnd); + List boardEvents = boardService.getEventsForAudienceInRange(audience, rangeStart, rangeEnd); + return ResponseEntity.ok(boardEvents); } // ==================== Combined Payload ==================== - /** - * Get all data needed for the board in a single request. - * Returns board config, active posters (as FrameDefinitions), upcoming events, and current week's content. - * This reduces the number of API calls the board needs to make on refresh. - */ @GetMapping("/payload") - public ResponseEntity getBoardPayload( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching full payload for: {}", boardLocation); - return ResponseEntity.ok(payloadAssembler.assemble(boardLocation)); + public ResponseEntity getBoardPayload(@RequestParam UUID deviceId) { + log.debug("Musallah board fetching full payload for device: {}", deviceId); + return ResponseEntity.ok(payloadAssembler.assemble(deviceId)); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java index efee40a..0b2d65c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java @@ -4,179 +4,74 @@ import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -import com.ibrasoft.lensbridge.dto.request.UpdateProfileRequest; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; -import com.ibrasoft.lensbridge.dto.response.UserInfoResponse; -import com.ibrasoft.lensbridge.dto.response.UserStatsResponse; +import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; +import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.security.CurrentUser; +import com.ibrasoft.lensbridge.service.GalleryService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api/user") +@RequiredArgsConstructor +@Slf4j public class UserController { - private static final Logger log = LoggerFactory.getLogger(UserController.class); - - @Autowired - UserService userService; - - @Autowired - UploadService uploadService; - @GetMapping("/profile") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUserProfile(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } + private final UserService userService; + private final UploadService uploadService; + private final GalleryService galleryService; - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findById(userDetails.getId()).orElse(null); - - if (user == null) { - return ResponseEntity.status(404) - .body(new MessageResponse("User not found")); - } - - UserInfoResponse response = new UserInfoResponse( - user.getId(), - user.getFirstName(), - user.getLastName(), - user.getEmail(), - user.isVerified(), - user.getRoles() - ); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - log.error("Error retrieving user profile: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error retrieving profile")); - } + @GetMapping("/profile") + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUserProfile(@CurrentUser User user) { + return ResponseEntity.ok(new UserInfoResponse( + user.getId(), user.getFirstName(), user.getLastName(), + user.getEmail(), user.getStudentNumber(), user.isVerified(), user.getRoles())); } @PatchMapping("/profile") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity updateUserProfile(@Valid @RequestBody UpdateProfileRequest updateRequest, - Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User updatedUser = userService.updateProfile(userDetails.getId(), updateRequest); - - UserInfoResponse response = new UserInfoResponse( - updatedUser.getId(), - updatedUser.getFirstName(), - updatedUser.getLastName(), - updatedUser.getEmail(), - updatedUser.isVerified(), - updatedUser.getRoles() - ); - - return ResponseEntity.ok(response); - - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest() - .body(new MessageResponse("Error: " + e.getMessage())); - } catch (Exception e) { - log.error("Error updating user profile: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error updating profile")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity updateUserProfile( + @Valid @RequestBody UpdateProfileRequest updateRequest, + @CurrentUser User user) { + User updated = userService.updateProfile(user.getId(), updateRequest); + return ResponseEntity.ok(new UserInfoResponse( + updated.getId(), updated.getFirstName(), updated.getLastName(), + updated.getEmail(), updated.getStudentNumber(), updated.isVerified(), updated.getRoles())); } @GetMapping("/stats") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUserStats(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - UserStatsResponse stats = uploadService.getUserStats(userDetails.getId()); - - return ResponseEntity.ok(stats); - - } catch (Exception e) { - log.error("Error retrieving user stats: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error retrieving stats")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUserStats(@CurrentUser User user) { + return ResponseEntity.ok(uploadService.getUserStats(user.getId())); } @GetMapping("/uploads") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUserUploads(Pageable pageable, Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - Page uploads = uploadService.getUserUploadsAsGalleryItems(userDetails.getId(), pageable); - - return ResponseEntity.ok(uploads); - - } catch (Exception e) { - log.error("Error retrieving user uploads: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error retrieving uploads")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity> getUserUploads(@ParameterObject Pageable pageable, @CurrentUser User user) { + return ResponseEntity.ok(galleryService.getUserGallery(user.getId(), pageable)); } @DeleteMapping("/uploads/{uploadId}") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity deleteUserUpload(@PathVariable UUID uploadId, Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - uploadService.deleteUserUpload(uploadId, userDetails.getId()); - - return ResponseEntity.ok(new MessageResponse("Upload deleted successfully")); - - } catch (IllegalArgumentException e) { - return ResponseEntity.status(404) - .body(new MessageResponse("Error: " + e.getMessage())); - } catch (SecurityException e) { - return ResponseEntity.status(403) - .body(new MessageResponse("Error: " + e.getMessage())); - } catch (Exception e) { - log.error("Error deleting user upload {}: {}", uploadId, e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error deleting upload")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity deleteUserUpload( + @PathVariable UUID uploadId, + @CurrentUser User user) { + uploadService.deleteUserUpload(uploadId, user.getId()); + return ResponseEntity.ok(new MessageResponse("Upload deleted successfully")); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java new file mode 100644 index 0000000..bc0c082 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java @@ -0,0 +1,27 @@ +package com.ibrasoft.lensbridge.dto.audit; + +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuditEventDto { + private UUID id; + private AuditAction action; + private Instant timestamp; + private UUID adminId; + private String adminName; + private String adminEmail; + private AuditEntityType targetEntityType; + private UUID targetEntityId; + private String details; + private String ipAddress; + private String userAgent; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/ChangePasswordRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ChangePasswordRequest.java similarity index 69% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/ChangePasswordRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ChangePasswordRequest.java index 391c497..9347e1a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/ChangePasswordRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ChangePasswordRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -7,10 +7,10 @@ @Data public class ChangePasswordRequest { @NotBlank - @Size(min = 6, max = 40) + @Size(min = 6, max = 256) private String currentPassword; @NotBlank - @Size(min = 6, max = 40) + @Size(min = 6, max = 256) private String newPassword; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java new file mode 100644 index 0000000..b760f0f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java @@ -0,0 +1,65 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * An administrator creating an account for somebody else. + *

+ * Distinct from {@link SignupRequest} solely because of the password: on self-signup it is + * mandatory, and here it is optional. Relaxing it on {@code SignupRequest} instead would + * have let anyone register a passwordless account through the public endpoint. + *

+ * Leaving the password out is the normal case — the account is created disabled and the + * invitee sets their own password from the reset email. Supplying one is for the rare case + * where the account has to be usable immediately, and hands the administrator a credential + * they had to invent and then transmit somehow. + */ +@Data +public class CreateUserRequest { + + @NotBlank + @Size(max = 20) + @Pattern(regexp = "^[A-Za-z]+([ '-][A-Za-z]+)*$", message = + "First name can only contain letters, spaces, hyphens, and apostrophes") + private String firstName; + + @NotBlank + @Size(max = 20) + @Pattern(regexp = "^[A-Za-z]+([ '-][A-Za-z]+)*$", message = "Last name can only contain letters, spaces, hyphens, and apostrophes") + private String lastName; + + @NotBlank + @Size(max = 10) + private String studentNumber; + + @NotBlank + @Size(max = 50) + @Email + // @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]*\\.utoronto\\.ca$", message = "Email must be a valid University of Toronto email address (*.utoronto.ca)") + private String email; + + @Size(min = 6, max = 40, message = "Password must be between 6 and 40 characters long") + @Schema(description = "Optional. Omit it to create a disabled account and email the user a " + + "password reset link; supply it to create an account that can sign in right away.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String password; + + public void setEmail(String email) { + this.email = email.toLowerCase(); + } + + /** Blank is treated as absent, so {@code ""} invites rather than failing @Size validation. */ + public void setPassword(String password) { + this.password = (password == null || password.isBlank()) ? null : password; + } + + @Schema(hidden = true) + public boolean hasPassword() { + return password != null; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java similarity index 87% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java index 6722eba..0531f33 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java new file mode 100644 index 0000000..5117c2c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java @@ -0,0 +1,24 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * Body for POST /api/auth/reset-password. + * + * Both fields were previously query parameters. A URL query string is recorded in + * server access logs, proxy logs and browser history, so the reset token and the + * new password were being written to places that outlive the request and are not + * treated as secret storage. A request body is not logged by default. + */ +@Data +public class ResetPasswordRequest { + + @NotBlank(message = "Reset token is required") + private String token; + + @NotBlank(message = "New password is required") + @Size(min = 6, max = 256, message = "Password must be between 6 and 256 characters long") + private String newPassword; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java similarity index 95% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java index b623778..4ec4785 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import lombok.Data; import jakarta.validation.constraints.*; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java similarity index 80% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java index e2a656d..b4eb877 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java similarity index 92% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java index c136e24..d7688bf 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java new file mode 100644 index 0000000..6948d73 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java @@ -0,0 +1,18 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * Body for POST /api/auth/validate-reset-token. + * + * Deliberately separate from {@link VerifyEmailRequest}, which is also a lone + * token field: the two carry different credentials, and sharing one type would + * name the wrong thing in every generated client. + */ +@Data +public class ValidateResetTokenRequest { + + @NotBlank(message = "Reset token is required") + private String token; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java new file mode 100644 index 0000000..c33ba87 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java @@ -0,0 +1,11 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class VerifyEmailRequest { + + @NotBlank(message = "Verification token is required") + private String token; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java new file mode 100644 index 0000000..3a6200d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.UUID; + +@Data +public class VerifyUserRequest { + + @NotNull(message = "userId is required") + private UUID userId; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java similarity index 81% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java index 45fa3c1..fe3c827 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import java.util.List; import java.util.UUID; @@ -17,4 +17,5 @@ public class JwtResponse { private UUID id; private String email; private List roles; + private List permissions; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/MessageResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/MessageResponse.java similarity index 73% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/MessageResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/MessageResponse.java index fc02ed6..f8a0b86 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/MessageResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/MessageResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java new file mode 100644 index 0000000..ec957fb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java @@ -0,0 +1,24 @@ +package com.ibrasoft.lensbridge.dto.auth.response; + +import com.ibrasoft.lensbridge.model.auth.Permission; + +/** + * One permission, in both the form the API accepts ({@code name}, the enum constant) and + * the form that appears in tokens and annotations ({@code authority}). + *

+ * {@code domain} is the namespace segment {@code board}, {@code media}, {@code iam}, + * {@code audit} which is enough for the console to group the picker without hardcoding + * a list of groups that would then drift. + */ +public record PermissionResponse( + String name, + String authority, + String domain +) { + public static PermissionResponse of(Permission permission) { + String authority = permission.getAuthority(); + int firstColon = authority.indexOf(':'); + String domain = firstColon > 0 ? authority.substring(0, firstColon) : authority; + return new PermissionResponse(permission.name(), authority, domain); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java new file mode 100644 index 0000000..dce1245 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.dto.auth.response; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; + +import java.util.List; + +/** + * A role plus what it actually confers, so the admin console can show an + * operator what + * they are about to hand someone instead of an opaque enum name. + */ +public record RoleDefinitionResponse( + String name, + String description, + List permissions) { + public static RoleDefinitionResponse of(Role role) { + return new RoleDefinitionResponse( + role.name(), + role.getDescription(), + role.getPermissions().stream() + .map(Permission::getAuthority) + .sorted() + .toList()); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenRefreshResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenRefreshResponse.java similarity index 81% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/TokenRefreshResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenRefreshResponse.java index 56ebf38..cf84158 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenRefreshResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenRefreshResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenValidationResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java similarity index 80% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/TokenValidationResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java index a8cc459..ef6c472 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenValidationResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import java.util.List; import java.util.UUID; @@ -16,4 +16,5 @@ public class TokenValidationResponse { private String email; private boolean verified; private List roles; + private List permissions; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java new file mode 100644 index 0000000..3683091 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.dto.auth.response; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Data; + +import com.ibrasoft.lensbridge.model.auth.Role; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserInfoResponse { + private UUID id; + private String firstName; + private String lastName; + private String email; + private String studentNumber; + private boolean isVerified; + private Set roles; + private List directPermissions; + private List effectivePermissions; + + public UserInfoResponse(UUID id, String firstName, String lastName, String email, + String studentNumber, boolean isVerified, Set roles) { + this(id, firstName, lastName, email, studentNumber, isVerified, roles, List.of(), List.of()); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserStatsResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserStatsResponse.java similarity index 82% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UserStatsResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserStatsResponse.java index a8b6f3e..5123bdb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserStatsResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserStatsResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/AuthFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/AuthFrame.java new file mode 100644 index 0000000..7b3002b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/AuthFrame.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** + * Agent → backend authentication frame, sent in response to a {@code hello}. + *

+ * The signature covers a fixed canonical string. See {@link com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload}. + */ +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +public class AuthFrame extends IncomingAgentFrame { + + private UUID deviceId; + + /** Unix milliseconds; server enforces |timestamp - now| within a skew window. */ + private long timestamp; + + /** Base64-encoded 64-byte Ed25519 signature. */ + private String signature; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java new file mode 100644 index 0000000..168390d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** Agent → backend: "I received and accepted command {@code commandId}, will execute". */ +@Getter +@Setter +public class CommandAckFrame extends IncomingAgentFrame { + private UUID commandId; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java new file mode 100644 index 0000000..6c2cb98 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java @@ -0,0 +1,17 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** Agent → backend: optional progress update for a long-running command. */ +@Getter +@Setter +public class CommandProgressFrame extends IncomingAgentFrame { + private UUID commandId; + private String stage; + private String message; + /** 0..100, optional. */ + private Integer percent; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java new file mode 100644 index 0000000..d9a7d79 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** + * Agent → backend: terminal frame for a command. {@link #status} is one of + * {@code "ok"}, {@code "error"}, {@code "timeout"}, {@code "rejected"} (free-form + * to keep the wire flexible; the backend maps to {@link com.ibrasoft.lensbridge.model.board.DeviceCommandStatus}). + */ +@Getter +@Setter +public class CommandResultFrame extends IncomingAgentFrame { + private UUID commandId; + private String status; + private JsonNode output; + private String errorMessage; + private Long durationMs; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java new file mode 100644 index 0000000..ccdc0f3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import com.fasterxml.jackson.annotation.JsonAlias; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +public class HeartbeatFrame extends IncomingAgentFrame { + + private Telemetry telemetry; + + @Getter + @Setter + public static class Telemetry { + private Long uptimeSec; + private Double cpuTempC; + private String throttleFlags; + private Integer memUsedMb; + private Integer memTotalMb; + private Integer diskUsedPct; + private Boolean kioskAlive; + private List ipv4; + private String wifiSsid; + + /** + * Agent build currently running. Reported on every heartbeat rather than only at + * enrollment, so {@code Device.agentVersion} tracks what is actually deployed after + * a binary push instead of whatever first enrolled. + */ + private String agentVersion; + + // JSON alias for back-compat. will be removed in a future release. + @JsonAlias("displayedFrameId") + private String displayedFrameKey; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/IncomingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/IncomingAgentFrame.java new file mode 100644 index 0000000..b2292fb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/IncomingAgentFrame.java @@ -0,0 +1,44 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.util.UUID; + +/** + * Polymorphic envelope for frames sent from the agent to the backend. + * Discriminated by the {@code type} field. + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AuthFrame.class, name = "auth"), + @JsonSubTypes.Type(value = HeartbeatFrame.class, name = "heartbeat"), + @JsonSubTypes.Type(value = CommandAckFrame.class, name = "command_ack"), + @JsonSubTypes.Type(value = CommandProgressFrame.class, name = "command_progress"), + @JsonSubTypes.Type(value = CommandResultFrame.class, name = "command_result") +}) +public abstract class IncomingAgentFrame { + + /** Discriminator (kept for logging / rejection messages even though Jackson uses it as the type id). */ + private String type; + + /** Monotonic sequence number, starts at 1 from the agent. */ + private long seq; + + /** Session identifier issued by the backend in the {@code hello} frame. */ + private UUID sessionId; + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public long getSeq() { return seq; } + public void setSeq(long seq) { this.seq = seq; } + + public UUID getSessionId() { return sessionId; } + public void setSessionId(UUID sessionId) { this.sessionId = sessionId; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/OutgoingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/OutgoingAgentFrame.java new file mode 100644 index 0000000..e81f2eb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/OutgoingAgentFrame.java @@ -0,0 +1,42 @@ +package com.ibrasoft.lensbridge.dto.board.agent; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +/** + * Backend → agent frame envelope. Built up via the builder for each emission; + * Jackson serializes to {@code type, seq, sessionId, ...payload}. + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OutgoingAgentFrame { + + private String type; + private long seq; + private UUID sessionId; + + // ── hello ──────────────────────────────────────────────────────────── + /** Base64-encoded 32 random bytes. Single-use, scoped to this session. */ + private String challenge; + private Long serverTime; + + // ── auth_ok ────────────────────────────────────────────────────────── + private UUID deviceId; + private Integer heartbeatIntervalMs; + + // ── command ────────────────────────────────────────────────────────── + private UUID commandId; + private String kind; + private String issuedBy; + private Integer deadlineMs; + /** Serialized command payload (typically a {@link com.fasterxml.jackson.databind.JsonNode}). */ + private Object payload; + + // ── error ──────────────────────────────────────────────────────────── + private String error; + private String reason; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/AgentEnrollRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/AgentEnrollRequest.java new file mode 100644 index 0000000..d7fbef9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/AgentEnrollRequest.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AgentEnrollRequest { + + /** Plaintext one-time token issued by an admin. */ + @NotBlank + private String token; + + /** Base64-encoded 32-byte Ed25519 public key generated on the device. */ + @NotBlank + private String publicKey; + + @NotBlank + private String hostname; + + private String hardwareModel; + private String agentVersion; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java new file mode 100644 index 0000000..a26b72d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class CreateCalendarEventRequest { + @NotBlank(message = "Event name is required") + private String name; + + @NotBlank(message = "Event description is required") + private String description; + + @NotBlank(message = "Event location is required") + @Size(max = 255, message = "Location must be less than 255 characters") + private String location; + + @NotNull(message = "Start time is required") + private long startEpochMs; + + @NotNull(message = "End time is required") + private long endEpochMs; + + private Boolean allDay; + + @NotNull(message = "Audience is required") + private Audience audience; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java new file mode 100644 index 0000000..acd4f94 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java @@ -0,0 +1,36 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.*; + +import java.time.Instant; + +import org.hibernate.validator.constraints.URL; +import org.springframework.web.multipart.MultipartFile; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class CreatePosterRequest { + @NotBlank(message = "Title is required") + private String title; + + @Positive(message = "Duration must be positive") + private int duration; + + @NotNull(message = "Start time is required") + private Instant startTime; + + @NotNull(message = "End time is required") + private Instant endTime; + + @NotNull(message = "Audience is required") + private Audience audience; + + @NotNull(message = "Image file is required") + MultipartFile imageFile; + + @URL(message = "Signup URL must be a valid URL") + private String signupUrl; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java new file mode 100644 index 0000000..e71c5d2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java @@ -0,0 +1,45 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.SocialType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.*; +import org.hibernate.validator.constraints.URL; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class CreatePromotableSocialMediaRequest { + + /** Admin-facing label, not shown on the board. */ + @NotBlank(message = "Name is required") + private String name; + + @Positive(message = "Duration must be positive") + private int duration; + + @NotNull(message = "Audience is required") + private Audience audience; + + @NotNull(message = "Social type is required") + private SocialType type; + + /** QR destination. */ + @NotBlank(message = "URL is required") + @URL(message = "URL must be a valid URL") + private String url; + + // The text fields below are stored as markdown and rendered to HTML by the frontend. + + @NotBlank(message = "Header text is required") + private String headerText; + + @NotBlank(message = "Hero text is required") + private String heroText; + + /** Optional — platforms like WhatsApp have no handle. */ + private String handle; + + @NotBlank(message = "Footer text is required") + private String footerText; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java new file mode 100644 index 0000000..ba6f718 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java @@ -0,0 +1,27 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +/** + * Admin request to issue a command at a single device. + *

+ * {@code kind} is the polymorphic discriminator (must match a registered + * {@link com.ibrasoft.lensbridge.model.board.commands.CommandPayload} subtype). + * {@code payload} is the kind-specific JSON body (may be empty for parameterless commands). + *

+ * {@code deadlineMs} bounds execution once the agent starts. {@code ttlSeconds} bounds how + * long the command stays deliverable while the device is offline. Keep it short for + * anything disruptive (a reboot that fires a day late is a bug, not a feature ;) ). Both fall + * back to server defaults when null. + */ +public record IssueCommandRequest( + @NotBlank + @Pattern(regexp = "[a-z]+\\.[a-z_]+", message = "kind must be in dotted lowercase form, e.g. chrome.reload") + String kind, + JsonNode payload, + @Positive Integer deadlineMs, + @Positive Integer ttlSeconds +) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java new file mode 100644 index 0000000..82505a3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java @@ -0,0 +1,21 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Data +public class IssueEnrollmentTokenRequest { + + @NotBlank + private String displayName; + + @NotNull + private Audience audience; + + /** Lifetime of the token in minutes. Server clamps to a sane range. */ + @Positive + private Integer expiresInMinutes; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java new file mode 100644 index 0000000..e1976d1 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -0,0 +1,40 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Location; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.*; +import java.util.List; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class UpdateBoardConfigRequest { + private Location location; + private Boolean darkModeAfterIsha; + private Boolean enableScrollingMessage; + private List scrollingMessages; + + /** + * Seconds to pin the agenda slide for, or {@code 0} to hand the decision back to the board + * ("auto"). + * + * Zero rather than null carries that meaning because this endpoint patches by skipping + * nulls — null already means "leave it alone", so it cannot also mean "clear it". + * + * The 1–4 gap is rejected in {@code BoardService}: bean validation cannot express + * "zero or at least five" without a custom constraint that would leak a bogus property + * into the generated schema. + */ + @Min(value = 0, message = "agendaDurationSeconds must be 0 (auto) or between 5 and 120 seconds") + @Max(value = DeviceConfig.MAX_SLIDE_SECONDS, + message = "agendaDurationSeconds must be 0 (auto) or between 5 and 120 seconds") + private Integer agendaDurationSeconds; + + /** Seconds to pin the next-prayer countdown for. No auto mode — the slide's content is fixed. */ + @Min(value = DeviceConfig.MIN_SLIDE_SECONDS, + message = "nextPrayerDurationSeconds must be between 5 and 120 seconds") + @Max(value = DeviceConfig.MAX_SLIDE_SECONDS, + message = "nextPrayerDurationSeconds must be between 5 and 120 seconds") + private Integer nextPrayerDurationSeconds; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateCalendarEventRequest.java new file mode 100644 index 0000000..ab8cd60 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateCalendarEventRequest.java @@ -0,0 +1,17 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import lombok.*; + +import java.time.Instant; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class UpdateCalendarEventRequest { + private String name; + private String description; + private String location; + private Instant startTime; + private Instant endTime; + private Boolean allDay; + private Audience audience; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java new file mode 100644 index 0000000..094d2ca --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Partial update: a null field means "leave it alone", so nothing here is + * {@code @NotNull}. The constraints below therefore describe what a supplied + * value must look like — Bean Validation skips them all for null. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateDeviceRequest { + + private Audience audience; + + /** + * {@code @Size(min = 1)} alone would let {@code " "} through, and the column is + * only {@code nullable = false} — a whitespace name persists happily and leaves the + * device looking unnamed in every admin listing. The pattern requires at least one + * non-whitespace character; {@code BoardService} trims before persisting. + */ + @Size(max = 255, message = "displayName must be at most 255 characters") + @Pattern(regexp = ".*\\S.*", message = "displayName must not be blank") + private String displayName; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java new file mode 100644 index 0000000..c6cca3a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import jakarta.validation.constraints.Positive; +import lombok.*; + +import java.time.Instant; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class UpdatePosterRequest { + private String title; + + @Positive(message = "Duration must be positive") + private Integer duration; + + private Instant startTime; + private Instant endTime; + private Audience audience; + + /** + * Sign-up link rendered as a QR code beside the poster. Send an empty string + * to clear it; null leaves the existing value alone, like every other field + * on this patch request. + */ + private String signupUrl; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java new file mode 100644 index 0000000..684baf3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java @@ -0,0 +1,37 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.SocialType; +import jakarta.validation.constraints.Positive; +import lombok.*; +import org.hibernate.validator.constraints.URL; + +/** + * Patch request: null leaves a field alone. The one exception is {@link #handle}, where an + * empty string clears it — a page that drops its handle would otherwise be unfixable without + * deleting and recreating the entry. + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class UpdatePromotableSocialMediaRequest { + + private String name; + + @Positive(message = "Duration must be positive") + private Integer duration; + + private Audience audience; + + private SocialType type; + + @URL(message = "URL must be a valid URL") + private String url; + + private String headerText; + + private String heroText; + + /** Send an empty string to clear it. */ + private String handle; + + private String footerText; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java new file mode 100644 index 0000000..5e2ad65 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java @@ -0,0 +1,25 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import lombok.*; + +import java.util.List; + +/** + * The editorial slice of {@code DeviceConfig}: the scrolling message ticker. + *

+ * Split out from {@link UpdateBoardConfigRequest} so ticker copy can be edited with + * {@code board:ticker:write} alone. The rest of the config sets the board's coordinates, + * which silently change every prayer time on it — not something a copy edit should reach. + * Both fields are nullable and ignored when null, matching the PATCH semantics of the + * config endpoint. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateTickerRequest { + + private Boolean enableScrollingMessage; + + private List scrollingMessages; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java new file mode 100644 index 0000000..344d894 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java @@ -0,0 +1,33 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.IslamicQuote; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.*; +import java.util.List; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class WeeklyContentRequest { + @Valid + private List quotes; + private List jummahPrayers; + + public record QuoteEntry( + IslamicQuote.Kind kind, + String arabic, + String transliteration, + String translation, + String reference, + @Min(value = DeviceConfig.MIN_SLIDE_SECONDS, + message = "durationSeconds must be null (auto) or between 5 and 120 seconds") + @Max(value = DeviceConfig.MAX_SLIDE_SECONDS, + message = "durationSeconds must be null (auto) or between 5 and 120 seconds") + Integer durationSeconds) {} + + public record JummahSlot( + String prayerTime, + String khatib, + String room) {} +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java new file mode 100644 index 0000000..9e94285 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java @@ -0,0 +1,16 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class AgentEnrollResponse { + + private UUID deviceId; + + /** Absolute URL the agent should connect to for the persistent command channel. */ + private String websocketUrl; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java new file mode 100644 index 0000000..6f01779 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; + +import java.time.Instant; +import java.util.UUID; + +public record CommandIssuedResponse( + UUID commandId, + UUID deviceId, + String kind, + DeviceCommandStatus status, + Instant issuedAt, + Instant expiresAt +) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java new file mode 100644 index 0000000..de7cd51 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java @@ -0,0 +1,57 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; + +import java.time.Instant; +import java.util.UUID; + +/** Read-only view of a {@link DeviceCommand} for admin REST responses. */ +public record CommandView( + UUID id, + UUID deviceId, + String kind, + JsonNode payload, + String issuedBy, + Integer deadlineMs, + DeviceCommandStatus status, + Instant issuedAt, + Instant expiresAt, + Instant deliveredAt, + Instant ackedAt, + Instant startedAt, + Instant finishedAt, + JsonNode output, + String errorMessage +) { + public static CommandView of(DeviceCommand cmd, ObjectMapper mapper) { + return new CommandView( + cmd.getId(), + cmd.getDeviceId(), + cmd.getKind(), + parseOrNull(cmd.getPayloadJson(), mapper), + cmd.getIssuedBy(), + cmd.getDeadlineMs(), + cmd.getStatus(), + cmd.getIssuedAt(), + cmd.getExpiresAt(), + cmd.getDeliveredAt(), + cmd.getAckedAt(), + cmd.getStartedAt(), + cmd.getFinishedAt(), + parseOrNull(cmd.getOutputJson(), mapper), + cmd.getErrorMessage() + ); + } + + private static JsonNode parseOrNull(String json, ObjectMapper mapper) { + if (json == null || json.isBlank()) return null; + try { + return mapper.readTree(json); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java new file mode 100644 index 0000000..bee555e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.UUID; + +/** + * Admin-facing summary of a Device. Excludes the public key bytes (operators don't need them). + */ +@Data +@Builder +public class DeviceSummary { + + private UUID id; + private String displayName; + private Audience audience; + private Instant enrolledAt; + private Instant lastHeartbeat; + private Instant revokedAt; + private String agentVersion; + private String hardwareModel; + private String lastSeenIp; + + public static DeviceSummary of(Device d) { + return DeviceSummary.builder() + .id(d.getId()) + .displayName(d.getDisplayName()) + .audience(d.getAudience()) + .enrolledAt(d.getEnrolledAt()) + .lastHeartbeat(d.getLastHeartbeat()) + .revokedAt(d.getRevokedAt()) + .agentVersion(d.getAgentVersion()) + .hardwareModel(d.getHardwareModel()) + .lastSeenIp(d.getLastSeenIp()) + .build(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/IssueEnrollmentTokenResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/IssueEnrollmentTokenResponse.java new file mode 100644 index 0000000..98bfe5a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/IssueEnrollmentTokenResponse.java @@ -0,0 +1,23 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.UUID; + +/** + * Returned exactly once when an admin issues a new enrollment token. + * The plaintext token is shown to the operator and never persisted. + */ +@Data +@Builder +public class IssueEnrollmentTokenResponse { + + private UUID tokenId; + + /** Human-typeable plaintext (base32, dash-separated). Show once, never store. */ + private String token; + + private Instant expiresAt; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java similarity index 58% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java index b618c7f..49a6aae 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java @@ -1,6 +1,7 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; -import com.ibrasoft.lensbridge.model.board.BoardConfig; +import com.fasterxml.jackson.databind.JsonNode; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,6 +15,7 @@ @NoArgsConstructor @AllArgsConstructor public class MusallahBoardPayload { - private BoardConfig boardConfig; + private DeviceConfig deviceConfig; private List frames; + private JsonNode weather; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java new file mode 100644 index 0000000..31ec9d2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java @@ -0,0 +1,30 @@ +package com.ibrasoft.lensbridge.dto.board.response.frames; + +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +/** + * One day of the agenda window, in the device's own timezone. + *

+ * Buckets are emitted for every day in the window, including days with no events — the board + * renders an empty day as "nothing scheduled", which it could not distinguish from "day outside + * the window" if empty buckets were dropped. + *

+ * Deliberately carries no {@code isToday} flag. A board can run for days without a payload + * refresh, so any "today" marker computed here goes stale at the first midnight. The client + * compares its own current date against {@link #date} instead. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DayBucket { + + /** Local date in the device's timezone, not UTC. */ + private LocalDate date; + + /** Events overlapping this day, earliest first. Never null; empty for a free day. */ + private List events; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java new file mode 100644 index 0000000..ff87ceb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java @@ -0,0 +1,33 @@ +package com.ibrasoft.lensbridge.dto.board.response.frames; + +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import lombok.*; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +/** + * Read-only view of a {@link BoardEvent} for REST responses. + */ +public class EventView { + private String name; + private String description; + private String location; + private Instant startTime; + private Instant endTime; + private Boolean allDay; + + public static EventView of(BoardEvent event) { + return EventView.builder() + .name(event.getName()) + .description(event.getDescription()) + .location(event.getLocation()) + .startTime(event.getStartTime()) + .endTime(event.getEndTime()) + .allDay(event.getAllDay()) + .build(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java deleted file mode 100644 index db3ef98..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.ibrasoft.lensbridge.dto.request; - -import com.ibrasoft.lensbridge.model.board.Audience; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class CreateCalendarEventRequest { - - @NotBlank(message = "Event name is required") - private String name; - - private String description; - - private String location; - - @Positive(message = "Start timestamp must be positive") - private long startTimestamp; - - @Positive(message = "End timestamp must be positive") - private long endTimestamp; - - private Boolean allDay; - - @NotNull(message = "Audience is required") - private Audience audience; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java deleted file mode 100644 index 2e890e7..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.ibrasoft.lensbridge.dto.request; - -import java.time.LocalDate; - -import com.ibrasoft.lensbridge.model.board.Audience; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class CreatePosterRequest { - - @NotBlank(message = "Title is required") - private String title; - - /** - * Duration in seconds for how long the poster should be displayed. - */ - @Positive(message = "Duration must be positive") - private int duration; - - /** - * The date from which the poster becomes active (inclusive). - */ - @NotNull(message = "Start date is required") - private LocalDate startDate; - - /** - * The date until which the poster remains active (exclusive). - */ - @NotNull(message = "End date is required") - private LocalDate endDate; - - /** - * Target audience for the poster. - */ - @NotNull(message = "Audience is required") - private Audience audience; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java deleted file mode 100644 index 67dc6fa..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.ibrasoft.lensbridge.dto.request; - -import com.ibrasoft.lensbridge.model.board.Location; - -import jakarta.validation.constraints.Min; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -/** - * Request DTO for updating board configuration. - * All fields are optional - only non-null fields will be updated. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class UpdateBoardConfigRequest { - - /** - * The prayer location details. - */ - private Location location; - - /** - * Default poster cycle duration in milliseconds. - */ - @Min(value = 1000, message = "Poster cycle interval must be at least 1000ms") - private Integer posterCycleInterval; - - /** - * How long to wait before refreshing data after Ishaa. - */ - @Min(value = 0, message = "Refresh after Ishaa minutes cannot be negative") - private Integer refreshAfterIshaaMinutes; - - /** - * Enable dark mode after Ishaa. - */ - private Boolean darkModeAfterIsha; - - /** - * How long to wait after isha (in minutes) before enabling dark mode. - */ - @Min(value = 0, message = "Dark mode minutes after Isha cannot be negative") - private Integer darkModeMinutesAfterIsha; - - /** - * Enable scrolling message at the bottom of the screen. - */ - private Boolean enableScrollingMessage; - - /** - * The scrolling message texts (will rotate through them). - */ - private List scrollingMessages; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java deleted file mode 100644 index 13f3635..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.ibrasoft.lensbridge.dto.request; - -import com.ibrasoft.lensbridge.model.board.Audience; - -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Request DTO for updating calendar event information. - * All fields are optional - only non-null fields will be updated. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class UpdateCalendarEventRequest { - - private String name; - - private String description; - - private String location; - - @Positive(message = "Start timestamp must be positive") - private Long startTimestamp; - - @Positive(message = "End timestamp must be positive") - private Long endTimestamp; - - private Boolean allDay; - - private Audience audience; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java deleted file mode 100644 index bb95024..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.ibrasoft.lensbridge.dto.request; - -import java.time.LocalDate; - -import com.ibrasoft.lensbridge.model.board.Audience; - -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Request DTO for updating poster information. - * All fields are optional - only non-null fields will be updated. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class UpdatePosterRequest { - - private String title; - - /** - * Duration in seconds for how long the poster should be displayed. - */ - @Positive(message = "Duration must be positive") - private Integer duration; - - /** - * The date from which the poster becomes active (inclusive). - */ - private LocalDate startDate; - - /** - * The date until which the poster remains active (exclusive). - */ - private LocalDate endDate; - - /** - * Target audience for the poster. - */ - private Audience audience; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java deleted file mode 100644 index d83e109..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.ibrasoft.lensbridge.dto.request; - -import com.ibrasoft.lensbridge.model.board.IslamicQuote; -import com.ibrasoft.lensbridge.model.board.JummahPrayer; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -/** - * Request DTO for creating or updating weekly content. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class WeeklyContentRequest { - - @NotNull(message = "Year is required") - @Min(value = 2020, message = "Year must be 2020 or later") - @Max(value = 2100, message = "Year must be before 2100") - private Integer year; - - @NotNull(message = "Week number is required") - @Min(value = 1, message = "Week number must be between 1 and 53") - @Max(value = 53, message = "Week number must be between 1 and 53") - private Integer weekNumber; - - /** - * Quranic verse for the week. - */ - private IslamicQuote verse; - - /** - * Hadith for the week. - */ - private IslamicQuote hadith; - - /** - * Jummah prayer details for the week. - */ - private List jummahPrayer; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserInfoResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/UserInfoResponse.java deleted file mode 100644 index e274a73..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserInfoResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ibrasoft.lensbridge.dto.response; - -import java.util.List; -import java.util.UUID; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class UserInfoResponse { - private UUID id; - private String firstName; - private String lastName; - private String email; - private boolean isVerified; - private List roles; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java new file mode 100644 index 0000000..b4a759b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java @@ -0,0 +1,20 @@ +package com.ibrasoft.lensbridge.dto.upload.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class CreateEventDto { + @NotBlank(message = "Event name is required") + private String name; + + @NotNull(message = "Event date is required") + private Instant date; + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminDataDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminDataDto.java similarity index 81% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/AdminDataDto.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminDataDto.java index 6c39c37..06b3063 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminDataDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminDataDto.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java similarity index 93% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java index 16910ac..675f984 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java @@ -1,13 +1,14 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; -import com.ibrasoft.lensbridge.model.upload.UploadType; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; +import com.ibrasoft.lensbridge.model.upload.UploadType; + @Data @NoArgsConstructor @AllArgsConstructor @@ -31,7 +32,7 @@ public class AdminUploadDto { private UUID eventId; private String eventName; - private LocalDateTime createdDate; + private Instant createdDate; private boolean approved; private boolean featured; private boolean isAnon; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/DailyLimitErrorResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/DailyLimitErrorResponse.java similarity index 91% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/DailyLimitErrorResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/DailyLimitErrorResponse.java index 8113cc2..5900d36 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/DailyLimitErrorResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/DailyLimitErrorResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/ErrorResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/ErrorResponse.java similarity index 87% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/ErrorResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/ErrorResponse.java index b01d28c..714f20f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/ErrorResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/FileSizeErrorResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/FileSizeErrorResponse.java similarity index 90% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/FileSizeErrorResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/FileSizeErrorResponse.java index 35d61c1..cd0bde3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/FileSizeErrorResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/FileSizeErrorResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/GalleryItemDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/GalleryItemDto.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/GalleryItemDto.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/GalleryItemDto.java index 8a30ff5..8975d20 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/GalleryItemDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/GalleryItemDto.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/PresignedUploadResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/PresignedUploadResponse.java similarity index 90% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/PresignedUploadResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/PresignedUploadResponse.java index ead3d24..759434a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/PresignedUploadResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/PresignedUploadResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadCompletionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadCompletionResponse.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UploadCompletionResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadCompletionResponse.java index c99fdf5..02fd660 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadCompletionResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadCompletionResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java new file mode 100644 index 0000000..e8862e9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java @@ -0,0 +1,29 @@ +package com.ibrasoft.lensbridge.dto.upload.response; + +import com.ibrasoft.lensbridge.model.upload.UploadType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UploadDto { + private UUID uuid; + private String fileName; + private String fileUrl; + private String thumbnailUrl; + private String uploadDescription; + private String instagramHandle; + private UUID uploadedBy; + private UUID eventId; + private String eventName; + private Instant createdDate; + private boolean approved; + private boolean featured; + private boolean isAnon; + private UploadType contentType; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadLimitsResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadLimitsResponse.java similarity index 91% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UploadLimitsResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadLimitsResponse.java index 92bdcb8..9cae6a7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadLimitsResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadLimitsResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java b/src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java new file mode 100644 index 0000000..b9c3218 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +@Getter +public class DailyLimitExceededException extends RuntimeException { + private final int limit; + private final long current; + + public DailyLimitExceededException(int limit, long current) { + super("Daily upload limit of " + limit + " reached (" + current + " uploaded today)"); + this.limit = limit; + this.current = current; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java b/src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java new file mode 100644 index 0000000..069a4a4 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +import java.util.UUID; + +@Getter +public class EventNotAcceptingUploadsException extends RuntimeException { + private final UUID eventId; + + public EventNotAcceptingUploadsException(UUID eventId) { + super("Event " + eventId + " is not currently accepting uploads"); + this.eventId = eventId; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java b/src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java new file mode 100644 index 0000000..7363d3c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +@Getter +public class FileSizeLimitExceededException extends RuntimeException { + private final long maxBytes; + private final long actualBytes; + + public FileSizeLimitExceededException(long maxBytes, long actualBytes) { + super("File size " + (actualBytes / 1024 / 1024) + "MB exceeds limit of " + (maxBytes / 1024 / 1024) + "MB"); + this.maxBytes = maxBytes; + this.actualBytes = actualBytes; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java index 86718e7..b2aba38 100644 --- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java @@ -1,72 +1,205 @@ package com.ibrasoft.lensbridge.exception; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.upload.response.DailyLimitErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.FileSizeErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.lang.Nullable; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.util.CollectionUtils; import org.springframework.validation.FieldError; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import java.util.HashMap; import java.util.Map; - +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Extends {@link ResponseEntityExceptionHandler} so Spring's own request-handling + * failures keep their intended status codes. + *

+ * Without it, {@link #handleGenericException} below was the only handler that matched + * them, and {@code ExceptionHandlerExceptionResolver} runs ahead of + * {@code DefaultHandlerExceptionResolver} — so a request that was merely malformed came + * back as a 500 "An unexpected error occurred". That masked a real incident: a client + * sending a signup body with no {@code Content-Type} header produced a 500 rather than a + * 415, and the response said nothing about the header being the problem. + *

+ * Bodies stay as {@link MessageResponse} rather than the {@code ProblemDetail} the parent + * class produces by default, because the frontend reads {@code error.message} everywhere. + * {@link #handleExceptionInternal} performs that conversion in one place. + */ @RestControllerAdvice @Slf4j -public class GlobalExceptionHandler { +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * Rewrites every body produced by the parent class into a {@link MessageResponse}, + * leaving the status and headers it chose alone. + */ + @Override + protected ResponseEntity handleExceptionInternal(Exception ex, @Nullable Object body, + HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) { + ResponseEntity response = super.handleExceptionInternal(ex, body, headers, statusCode, request); + if (response == null) { + return null; // response already committed; the parent declined to write one + } + return new ResponseEntity<>(asMessage(response.getBody(), ex), response.getHeaders(), + response.getStatusCode()); + } + + private static MessageResponse asMessage(@Nullable Object body, Exception ex) { + if (body instanceof MessageResponse message) { + return message; // an override below already picked the wording + } + if (body instanceof ProblemDetail problem && problem.getDetail() != null) { + return new MessageResponse(problem.getDetail()); + } + return new MessageResponse(ex.getMessage() != null ? ex.getMessage() : "Request could not be processed"); + } + + /** + * Names the header that is wrong. The default message reports the media type the + * server inferred, which for a request with no {@code Content-Type} at all is + * {@code application/octet-stream} — accurate, and useless to whoever has to fix + * the client. + */ + @Override + protected ResponseEntity handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + String supported = ex.getSupportedMediaTypes().stream() + .map(MediaType::toString) + .collect(Collectors.joining(", ")); + String message = ex.getContentType() == null + ? "Request is missing a Content-Type header" + : "Content-Type '" + ex.getContentType() + "' is not supported"; + if (!supported.isEmpty()) { + message += "; this endpoint accepts " + supported; + } + log.warn("Rejected {}: {}", request.getDescription(false), message); + return handleExceptionInternal(ex, new MessageResponse(message), headers, status, request); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + String details = ex.getBindingResult().getAllErrors().stream() + .map(error -> error instanceof FieldError fieldError + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return handleExceptionInternal(ex, new MessageResponse(details), headers, status, request); + } + + @Override + protected ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + log.error("Malformed JSON request: ", ex); + return handleExceptionInternal(ex, new MessageResponse("Malformed JSON request"), headers, status, request); + } + + /** + * Sets {@code Allow}, which RFC 9110 requires on a 405 and which the parent + * implementation would have set for us. Overriding it to swap the body means taking + * that on: without the header a client has no machine-readable way to discover that + * the path exists and only its method was wrong. + */ + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + Set supported = ex.getSupportedHttpMethods(); + String message = "HTTP method not supported"; + if (!CollectionUtils.isEmpty(supported)) { + headers.setAllow(supported); + message += "; this endpoint accepts " + supported.stream() + .map(HttpMethod::name) + .sorted() + .collect(Collectors.joining(", ")); + } + return handleExceptionInternal(ex, new MessageResponse(message), headers, status, request); + } + + /** Keeps the pre-existing body shape; this one predates the MessageResponse convention. */ + @Override + protected ResponseEntity handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + log.error("Multipart upload too large: {}", ex.getMessage()); + Map body = new HashMap<>(); + body.put("error", "File Too Large"); + body.put("message", "The uploaded file exceeds the maximum allowed size"); + body.put("status", HttpStatus.PAYLOAD_TOO_LARGE.value()); + return new ResponseEntity<>(body, headers, HttpStatus.PAYLOAD_TOO_LARGE); + } + @ExceptionHandler(ApiResponseException.class) public ResponseEntity handleApiResponseException(ApiResponseException ex) { - return ResponseEntity.status(ex.getStatus()).body(ex.getBody()); + Object body = ex.getBody() instanceof ErrorResponse error + ? new MessageResponse(error.getError()) + : ex.getBody(); + return ResponseEntity.status(ex.getStatus()).body(body); + } + + @ExceptionHandler(RefreshTokenException.class) + public ResponseEntity handleRefreshToken(RefreshTokenException ex) { + log.warn("Refresh token rejected: {}", ex.getMessage()); + return ResponseEntity.status(ex.getStatus()).body(new MessageResponse(ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(new MessageResponse(ex.getMessage())); } + @ExceptionHandler(SecurityException.class) + public ResponseEntity handleSecurity(SecurityException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse(ex.getMessage())); + } - @ExceptionHandler(VideoProcessingException.class) - public ResponseEntity> handleVideoProcessingException(VideoProcessingException ex) { - log.error("Video processing error: {}", ex.getMessage()); - Map response = new HashMap<>(); - response.put("error", "Video Processing Error"); - response.put("message", ex.getMessage()); - response.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + @ExceptionHandler(DailyLimitExceededException.class) + public ResponseEntity handleDailyLimitExceeded(DailyLimitExceededException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(DailyLimitErrorResponse.of(ex.getMessage(), ex.getLimit(), ex.getCurrent(), "unknown")); } - @ExceptionHandler(VideoTooLongException.class) - public ResponseEntity> handleVideoTooLongException(VideoTooLongException ex) { - log.error("Video too long error: {}", ex.getMessage()); - Map response = new HashMap<>(); - response.put("error", "Video Too Long"); - response.put("message", ex.getMessage()); - response.put("status", HttpStatus.BAD_REQUEST.value()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + @ExceptionHandler(FileSizeLimitExceededException.class) + public ResponseEntity handleFileSizeLimitExceeded(FileSizeLimitExceededException ex) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(FileSizeErrorResponse.of(ex.getMessage(), + (ex.getMaxBytes() / 1024 / 1024) + "MB", + (ex.getActualBytes() / 1024 / 1024) + "MB")); } - @ExceptionHandler(MaxUploadSizeExceededException.class) - public ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) { - log.error("File size too large: {}", ex.getMessage()); - Map response = new HashMap<>(); - response.put("error", "File Too Large"); - response.put("message", "The uploaded file exceeds the maximum allowed size"); - response.put("status", HttpStatus.BAD_REQUEST.value()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + @ExceptionHandler(InvalidContentTypeException.class) + public ResponseEntity handleInvalidContentType(InvalidContentTypeException ex) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(ErrorResponse.of(ex.getMessage())); } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { - Map errors = new HashMap<>(); - ex.getBindingResult().getAllErrors().forEach((error) -> { - String fieldName = ((FieldError) error).getField(); - String errorMessage = error.getDefaultMessage(); - errors.put(fieldName, errorMessage); - }); - return ResponseEntity.badRequest().body(errors); + @ExceptionHandler(EventNotAcceptingUploadsException.class) + public ResponseEntity handleEventNotAcceptingUploads(EventNotAcceptingUploadsException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ErrorResponse.of(ex.getMessage())); } @ExceptionHandler(UsernameNotFoundException.class) @@ -89,31 +222,19 @@ public ResponseEntity handleDisabledException(DisabledException @ExceptionHandler(AuthorizationDeniedException.class) public ResponseEntity handleAuthorizationDeniedException(AuthorizationDeniedException ex) { - log.warn("Authorization failed - access denied: {}", ex.getMessage()); + log.warn("Authorization failed: {}", ex.getMessage()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse("Access denied.")); } - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { - log.error("Malformed JSON request: ", ex); - return ResponseEntity.badRequest().body(new MessageResponse("Malformed JSON request")); - } - - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { - log.error("HTTP method not supported: ", ex); - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(new MessageResponse("HTTP method not supported")); - } - @ExceptionHandler(FileProcessingException.class) - public ResponseEntity handleIOException(FileProcessingException ex) { - log.error("I/O error occurred: {}", ex.getMessage(), ex); + public ResponseEntity handleFileProcessingException(FileProcessingException ex) { + log.error("File processing error: {}", ex.getMessage(), ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to process upload")); } @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + log.error("Unexpected error: {}", ex.getMessage(), ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("An unexpected error occurred")); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java b/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java new file mode 100644 index 0000000..8456933 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +@Getter +public class InvalidContentTypeException extends RuntimeException { + private final String contentType; + + public InvalidContentTypeException(String contentType) { + super("Content type not allowed: " + contentType); + this.contentType = contentType; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java b/src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java new file mode 100644 index 0000000..83ea21e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java @@ -0,0 +1,16 @@ +package com.ibrasoft.lensbridge.exception; + +import org.springframework.http.HttpStatus; + +public class RefreshTokenException extends RuntimeException { + private final HttpStatus status; + + public RefreshTokenException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java b/src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java deleted file mode 100644 index 0bbca5d..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.ibrasoft.lensbridge.exception; -public class VideoProcessingException extends RuntimeException { - public VideoProcessingException(String message) { - super(message); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java b/src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java deleted file mode 100644 index d631ed6..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ibrasoft.lensbridge.exception; - -public class VideoTooLongException extends RuntimeException { - public VideoTooLongException(String message) { - super(message); - } - - public VideoTooLongException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java new file mode 100644 index 0000000..2335945 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java @@ -0,0 +1,244 @@ +package com.ibrasoft.lensbridge.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.board.agent.AuthFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.dto.board.agent.IncomingAgentFrame; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.AgentSession; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; +import com.ibrasoft.lensbridge.service.agent.HeartbeatService; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload; +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +/** + * Lifecycle of one agent WebSocket: + *
+ *   open      → server generates challenge, sends hello (seq:1)
+ *   recv auth → verify Ed25519 sig + timestamp + revocation; on success bind deviceId,
+ *               send auth_ok (seq:2), register in {@link AgentSessionRegistry}
+ *   recv hb   → persist via {@link HeartbeatService}
+ *   recv ?    → close 4001 (unauth) / 4003 (bad-state) / 4002 (seq replay)
+ *   close     → unregister
+ * 
+ */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AgentWebSocketHandler extends TextWebSocketHandler { + + static final String SESSION_ATTR = "agent.session"; + static final long TIMESTAMP_SKEW_MS = 5 * 60 * 1000L; + static final int CHALLENGE_BYTES = 32; + static final int HEARTBEAT_INTERVAL_MS = 30_000; + + static final CloseStatus CLOSE_AUTH_FAILED = new CloseStatus(4001, "auth_failed"); + static final CloseStatus CLOSE_SEQ_VIOLATION = new CloseStatus(4002, "seq_violation"); + static final CloseStatus CLOSE_BAD_STATE = new CloseStatus(4003, "bad_state"); + static final CloseStatus CLOSE_BAD_FRAME = new CloseStatus(4004, "bad_frame"); + + private final ObjectMapper objectMapper; + private final Ed25519Verifier ed25519; + private final DeviceRepository deviceRepository; + private final AgentSessionRegistry registry; + private final HeartbeatService heartbeatService; + private final CommandDispatcher commandDispatcher; + private final DeviceEventPublisher events; + private final SecureRandom random = new SecureRandom(); + + @Override + public void afterConnectionEstablished(WebSocketSession transport) throws Exception { + String challenge = newChallenge(); + AgentSession session = new AgentSession(transport, challenge, objectMapper); + transport.getAttributes().put(SESSION_ATTR, session); + + OutgoingAgentFrame hello = session.populateOutgoing(OutgoingAgentFrame.builder() + .type("hello") + .challenge(challenge) + .serverTime(System.currentTimeMillis())); + session.send(hello); + } + + @Override + protected void handleTextMessage(WebSocketSession transport, TextMessage message) { + AgentSession session = (AgentSession) transport.getAttributes().get(SESSION_ATTR); + if (session == null) { + safeClose(transport, CLOSE_BAD_STATE); + return; + } + + IncomingAgentFrame frame; + try { + frame = objectMapper.readValue(message.getPayload(), IncomingAgentFrame.class); + } catch (Exception e) { + log.warn("session={} unparseable frame: {}", session.getSessionId(), e.getMessage()); + session.close(CLOSE_BAD_FRAME); + return; + } + + if (frame.getSessionId() == null || !frame.getSessionId().equals(session.getSessionId())) { + log.warn("session={} frame sessionId mismatch (got {})", session.getSessionId(), frame.getSessionId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + if (!session.acceptIncomingSeq(frame.getSeq())) { + session.close(CLOSE_SEQ_VIOLATION); + return; + } + + switch (session.getPhase()) { + case UNAUTH -> { + if (frame instanceof AuthFrame auth) { + handleAuth(session, auth); + } else { + log.warn("session={} expected auth frame, got {}", session.getSessionId(), frame.getType()); + session.close(CLOSE_BAD_STATE); + } + } + case AUTHED -> { + if (frame instanceof HeartbeatFrame hb) { + handleHeartbeat(session, hb); + } else if (frame instanceof CommandAckFrame ack) { + commandDispatcher.onAck(ack, session); + } else if (frame instanceof CommandProgressFrame progress) { + commandDispatcher.onProgress(progress, session); + } else if (frame instanceof CommandResultFrame result) { + commandDispatcher.onResult(result, session); + } else if (frame instanceof AuthFrame) { + log.warn("session={} duplicate auth after auth_ok", session.getSessionId()); + session.close(CLOSE_BAD_STATE); + } else { + log.debug("session={} unhandled frame type={}", session.getSessionId(), frame.getType()); + } + } + case CLOSED -> { /* drop */ } + } + } + + private void handleAuth(AgentSession session, AuthFrame frame) { + long now = System.currentTimeMillis(); + if (Math.abs(now - frame.getTimestamp()) > TIMESTAMP_SKEW_MS) { + log.warn("session={} timestamp skew {} ms (limit {})", + session.getSessionId(), now - frame.getTimestamp(), TIMESTAMP_SKEW_MS); + session.close(CLOSE_AUTH_FAILED); + return; + } + if (frame.getDeviceId() == null || frame.getSignature() == null) { + session.close(CLOSE_AUTH_FAILED); + return; + } + + Optional deviceOpt = deviceRepository.findById(frame.getDeviceId()); + if (deviceOpt.isEmpty()) { + log.warn("session={} unknown deviceId {}", session.getSessionId(), frame.getDeviceId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + Device device = deviceOpt.get(); + if (device.getRevokedAt() != null) { + log.warn("session={} revoked device {} attempting to auth", session.getSessionId(), device.getId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + if (device.getPublicKey() == null) { + log.error("session={} device {} has no public key on file", session.getSessionId(), device.getId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + + byte[] signed = AuthSignaturePayload.build( + session.getSessionId(), + session.getChallenge(), + frame.getDeviceId(), + frame.getTimestamp()); + byte[] signature; + try { + signature = Base64.getDecoder().decode(frame.getSignature()); + } catch (IllegalArgumentException e) { + session.close(CLOSE_AUTH_FAILED); + return; + } + + if (!ed25519.verify(device.getPublicKey(), signed, signature)) { + log.warn("session={} bad Ed25519 signature for device {}", session.getSessionId(), device.getId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + + session.markAuthenticated(device.getId()); + registry.register(device.getId(), session); + log.info("session={} authenticated as device {}", session.getSessionId(), device.getId()); + + OutgoingAgentFrame ok = session.populateOutgoing(OutgoingAgentFrame.builder() + .type("auth_ok") + .deviceId(device.getId()) + .serverTime(now) + .heartbeatIntervalMs(HEARTBEAT_INTERVAL_MS)); + session.send(ok); + + events.deviceOnline(device.getId()); + try { + commandDispatcher.flushPending(device.getId(), session); + } catch (Exception e) { + log.error("session={} failed to flush pending commands for device {}", + session.getSessionId(), device.getId(), e); + } + } + + private void handleHeartbeat(AgentSession session, HeartbeatFrame frame) { + try { + String remoteIp = session.getTransport().getRemoteAddress() == null + ? null + : session.getTransport().getRemoteAddress().getAddress().getHostAddress(); + heartbeatService.record(session.getDeviceId(), frame, remoteIp); + } catch (Exception e) { + log.error("session={} heartbeat persistence failed", session.getSessionId(), e); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession transport, CloseStatus status) { + AgentSession session = (AgentSession) transport.getAttributes().get(SESSION_ATTR); + if (session != null) { + session.markClosed(); + if (session.getDeviceId() != null) { + registry.unregister(session.getDeviceId(), session); + events.deviceOffline(session.getDeviceId()); + } + log.debug("session={} closed status={}", session.getSessionId(), status); + } + } + + private String newChallenge() { + byte[] raw = new byte[CHALLENGE_BYTES]; + random.nextBytes(raw); + return Base64.getEncoder().withoutPadding().encodeToString(raw); + } + + private static void safeClose(WebSocketSession transport, CloseStatus status) { + try { + if (transport.isOpen()) transport.close(status); + } catch (Exception ignored) { + // best effort + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java new file mode 100644 index 0000000..c69269b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java @@ -0,0 +1,200 @@ +package com.ibrasoft.lensbridge.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Push channel for kiosk pages: tells a board its content or config changed so it can + * refetch without waiting out its ten-minute poll. + *

+ * Every board on this channel is an enrolled device. A connection must name a known, + * unrevoked {@code ?deviceId=} or it is closed on the spot — there is no anonymous + * or pre-enrollment state here. A board that has not been enrolled has no business on the + * stream: it has no payload to refetch and nothing can be addressed to it. + *

+ */ +@Component +@RequiredArgsConstructor +@Slf4j +public class BoardStreamHandler extends TextWebSocketHandler { + + // Should probably be enums but this is sufficient for now + static final CloseStatus CLOSE_NO_DEVICE = new CloseStatus(4001, "device_id_required"); + static final CloseStatus CLOSE_UNKNOWN_DEVICE = new CloseStatus(4002, "unknown_device"); + static final CloseStatus CLOSE_REVOKED = new CloseStatus(4003, "device_revoked"); + static final CloseStatus CLOSE_SUPERSEDED = CloseStatus.NORMAL.withReason("superseded"); + + /** Session attribute holding the device a connection was accepted as. */ + private static final String DEVICE_ATTR = "board.deviceId"; + + private final ObjectMapper objectMapper; + private final DeviceRepository deviceRepository; + + /** + * Live boards keyed by device. One session per device: a reloading kiosk can briefly + * open its replacement before the old socket finishes closing, so the newcomer wins + * and the stale one is closed rather than left to accumulate. + */ + private final Map sessions = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(@NonNull WebSocketSession session) { + UUID deviceId = parseDeviceId(session); + if (deviceId == null) { + close(session, CLOSE_NO_DEVICE); + return; + } + + Optional device = deviceRepository.findById(deviceId); + if (device.isEmpty()) { + log.warn("Board stream session={} named unknown device {}", session.getId(), deviceId); + close(session, CLOSE_UNKNOWN_DEVICE); + return; + } + if (device.get().getRevokedAt() != null) { + log.warn("Board stream session={} is a revoked device {}", session.getId(), deviceId); + close(session, CLOSE_REVOKED); + return; + } + + session.getAttributes().put(DEVICE_ATTR, deviceId); + WebSocketSession previous = sessions.put(deviceId, session); + if (previous != null && previous != session) { + log.info("Board stream device {} superseded session {} with {}", + deviceId, previous.getId(), session.getId()); + close(previous, CLOSE_SUPERSEDED); + } + log.info("Board stream connected: session={} device={}", session.getId(), deviceId); + } + + @Override + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) { + UUID deviceId = (UUID) session.getAttributes().get(DEVICE_ATTR); + if (deviceId == null) return; // rejected before it was ever registered + + // Two-arg remove: a superseded session closing later must not evict the + // replacement that has already taken its place. + sessions.remove(deviceId, session); + log.info("Board stream disconnected: session={} device={} status={}", + session.getId(), deviceId, status); + } + + /** Content every board shares — posters, events, weekly content. */ + public void contentChanged(String reason) { + push(reason, null); + } + + /** Config for one board. */ + public void configChanged(UUID deviceId) { + push("config", deviceId); + } + + /** Operator-triggered "refresh everything", kept as a manual escape hatch. */ + public void refreshAll() { + push("manual-refresh", null); + } + + /** + * Push a message to every connected board, or to a single one. + * + * @param reason why the board should refetch. Carried on the wire for logging and for + * a frontend that wants to react selectively. + * @param target device to notify, or null to reach every board. + */ + private void push(String reason, UUID target) { + String json = serialize(reason, target); + if (json == null) return; + + if (target != null) { + WebSocketSession session = sessions.get(target); + if (session == null) { + log.debug("Board stream reason={} device={} not connected — it will pick the change up on its next poll", + reason, target); + return; + } + if (send(target, session, json)) { + log.debug("Board stream push reason={} device={}", reason, target); + } + return; + } + + int sent = 0; + for (Map.Entry entry : sessions.entrySet()) { + if (send(entry.getKey(), entry.getValue(), json)) sent++; + } + log.debug("Board stream broadcast reason={} boards={}", reason, sent); + } + + /** @return true when the frame was written; drops the session if it has gone away. */ + private boolean send(UUID deviceId, WebSocketSession session, String json) { + if (!session.isOpen()) { + sessions.remove(deviceId, session); + return false; + } + try { + session.sendMessage(new TextMessage(json)); + return true; + } catch (Exception e) { + log.warn("Board stream send failed: session={} device={} err={}", + session.getId(), deviceId, e.getMessage()); + return false; + } + } + + private String serialize(String reason, UUID target) { + Map body = new LinkedHashMap<>(); + body.put("type", "refresh"); + body.put("reason", reason); + body.put("deviceId", target); + // Pre-formatted rather than a raw Instant: a mapper without JavaTimeModule throws on + // one, and the only symptom would be pushes silently going nowhere. + body.put("at", Instant.now().toString()); + try { + return objectMapper.writeValueAsString(body); + } catch (Exception e) { + log.error("Board stream message could not be serialized", e); + return null; + } + } + + private static UUID parseDeviceId(WebSocketSession session) { + String query = session.getUri() == null ? null : session.getUri().getQuery(); + if (query == null) return null; + for (String param : query.split("&")) { + if (!param.startsWith("deviceId=")) continue; + String raw = param.substring("deviceId=".length()); + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException e) { + log.warn("Board stream session={} sent a malformed deviceId: {}", session.getId(), raw); + return null; + } + } + return null; + } + + private static void close(WebSocketSession session, CloseStatus status) { + try { + if (session.isOpen()) session.close(status); + } catch (Exception e) { + log.debug("Board stream close failed: session={} err={}", session.getId(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java deleted file mode 100644 index 122ef3a..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.ibrasoft.lensbridge.handler; - -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -@Component -public class SignboardHandler extends TextWebSocketHandler { - - private final Set sessions = ConcurrentHashMap.newKeySet(); - - @Override - public void afterConnectionEstablished(WebSocketSession session) { - sessions.add(session); - System.out.println("Signboard connected: " + session.getId()); - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - sessions.remove(session); - System.out.println("Signboard disconnected: " + session.getId()); - } - - public void sendRefreshCommand() { - sessions.removeIf(session -> !session.isOpen()); - sessions.forEach(session -> { - if (session.isOpen()) { - try { - session.sendMessage(new TextMessage("REFRESH")); - System.out.println("Sent REFRESH to session: " + session.getId()); - } catch (IOException e) { - System.err.println("Failed to send to session " + session.getId() + ": " + e.getMessage()); - } - } - }); - } -} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AdminAction.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java similarity index 63% rename from src/main/java/com/ibrasoft/lensbridge/model/audit/AdminAction.java rename to src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java index deb0025..6df17b8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AdminAction.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public enum AdminAction { +public enum AuditAction { // Upload Management Actions APPROVE_UPLOAD("Approve Upload"), UNAPPROVE_UPLOAD("Remove Upload Approval"), @@ -25,7 +25,27 @@ public enum AdminAction { CREATE_POSTER("Create Poster"), UPDATE_POSTER("Update Poster"), DELETE_POSTER("Delete Poster"), - + + // Promotable Social Media Actions + CREATE_SOCIAL("Create Promotable Social Media"), + UPDATE_SOCIAL("Update Promotable Social Media"), + DELETE_SOCIAL("Delete Promotable Social Media"), + + // Board Content Actions (Musallah Board) + // SAVE_WEEKLY_CONTENT is the only record of who last changed a jummah time, since + // quotes and prayer times share one permission and one endpoint. + SAVE_WEEKLY_CONTENT("Save Weekly Content"), + DELETE_WEEKLY_CONTENT("Delete Weekly Content"), + UPDATE_BOARD_CONFIG("Update Board Config"), + UPDATE_BOARD_TICKER("Update Board Ticker"), + REFRESH_BOARDS("Refresh All Boards"), + + // Device Fleet Actions (Musallah Board) + ISSUE_ENROLLMENT_TOKEN("Issue Device Enrollment Token"), + REVOKE_DEVICE("Revoke Device"), + ISSUE_DEVICE_COMMAND("Issue Device Command"), + + // User Management Actions PROMOTE_USER("Promote User to Admin"), DEMOTE_USER("Remove Admin Role"), @@ -45,6 +65,8 @@ public enum AdminAction { ADD_USER_ROLE("Add User Role"), REMOVE_USER_ROLE("Remove User Role"), + GRANT_PERMISSION("Grant Direct Permission"), + REVOKE_PERMISSION("Revoke Direct Permission"), ADD_USER("Add User"), REMOVE_USER("Remove User"), @@ -52,7 +74,7 @@ public enum AdminAction { private final String description; - AdminAction(String description) { + AuditAction(String description) { this.description = description; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java new file mode 100644 index 0000000..1090874 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java @@ -0,0 +1,21 @@ +package com.ibrasoft.lensbridge.model.audit; + +public enum AuditEntityType { + USER("User"), + UPLOAD("Upload"), + MUSALLAH_BOARD("Musallah Board"), + DEVICE("Device"), + EVENT("Event"); + + + private final String description; + + AuditEntityType(String description) { + this.description = description; + } + + @Override + public String toString() { + return this.name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java index eae5701..43c8957 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java @@ -2,27 +2,59 @@ import lombok.Builder; import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import java.time.LocalDateTime; +import com.ibrasoft.lensbridge.model.auth.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.Instant; import java.util.UUID; @Data @Builder -@Document(collection = "audit_events") +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "audit_events") public class AuditEvent { + public static final int DETAILS_MAX = 512; + @Id - private String id; - private LocalDateTime timestamp; - private String adminEmail; - private UUID adminId; - private AdminAction action; - private String entityType; - private UUID entityId; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AuditAction action; + + @Column(nullable = false) + private Instant timestamp; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "admin_id", nullable = false) + private User admin; + + + @Enumerated(EnumType.STRING) + private AuditEntityType targetEntityType; + + private UUID targetEntityId; + + @Column(length = DETAILS_MAX) private String details; - private String result; + private String ipAddress; private String userAgent; - private String sessionId; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java new file mode 100644 index 0000000..7bb326a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java @@ -0,0 +1,119 @@ +package com.ibrasoft.lensbridge.model.auth; + +import org.springframework.security.core.GrantedAuthority; + +/** + * The unit of authorization. Every {@code @PreAuthorize} in this codebase checks a + * permission via {@code hasAuthority(...)}; none of them check a role name. + * {@link Role} exists only as a named bundle of these. + *

+ * Authority strings are namespaced {@code domain:resource:verb} and carry no + * {@code ROLE_} prefix, which is what distinguishes them from {@link Role} authorities + * in the same granted-authority collection. + *

+ * The literals live in {@link Authority} and are referenced from the constructor, rather + * than the other way round, because {@code @PreAuthorize} needs compile-time constants. + * Declaring them once here means the annotation sites and the enum cannot drift apart. + */ +public enum Permission implements GrantedAuthority { + + // ==================== Legacy media sharing ==================== + // These name what USER and ADMIN already do. Media authorization is unchanged; + // it is only expressed as a bundle now so it composes like everything else. + + MEDIA_UPLOAD_SELF(Authority.MEDIA_UPLOAD_SELF), + MEDIA_UPLOAD_MODERATE(Authority.MEDIA_UPLOAD_MODERATE), + MEDIA_UPLOAD_READ(Authority.MEDIA_UPLOAD_READ), + MEDIA_EVENT_WRITE(Authority.MEDIA_EVENT_WRITE), + + // ==================== MusallahBoard content ==================== + + BOARD_CONTENT_READ(Authority.BOARD_CONTENT_READ), + BOARD_POSTER_WRITE(Authority.BOARD_POSTER_WRITE), + BOARD_EVENT_WRITE(Authority.BOARD_EVENT_WRITE), + /** The social accounts promoted to a board. Carries a QR destination, so it publishes a link. */ + BOARD_SOCIAL_WRITE(Authority.BOARD_SOCIAL_WRITE), + /** Quotes and jummah times together — they share one endpoint. See docs/PERMISSIONS.md §3. */ + BOARD_WEEKLY_WRITE(Authority.BOARD_WEEKLY_WRITE), + /** Ticker copy only. Deliberately not BOARD_CONFIG_WRITE, which can move the board's coordinates. */ + BOARD_TICKER_WRITE(Authority.BOARD_TICKER_WRITE), + + // ==================== MusallahBoard configuration ==================== + + BOARD_CONFIG_READ(Authority.BOARD_CONFIG_READ), + BOARD_CONFIG_WRITE(Authority.BOARD_CONFIG_WRITE), + BOARD_REFRESH(Authority.BOARD_REFRESH), + + // ==================== Fleet ==================== + + BOARD_DEVICE_READ(Authority.BOARD_DEVICE_READ), + /** Mints a credential an unknown machine can exchange for a device identity. */ + BOARD_DEVICE_ENROLL(Authority.BOARD_DEVICE_ENROLL), + BOARD_DEVICE_REVOKE(Authority.BOARD_DEVICE_REVOKE), + /** Required to SUBSCRIBE to /topic/devices/** — command output lands there. */ + BOARD_TELEMETRY_SUBSCRIBE(Authority.BOARD_TELEMETRY_SUBSCRIBE), + + // ==================== Device commands, split by blast radius ==================== + + /** Recoverable, no data egress: chrome.reload, config.refresh. */ + BOARD_COMMAND_BENIGN(Authority.BOARD_COMMAND_BENIGN), + /** Takes a physical display down: kiosk.restart, system.reboot. */ + BOARD_COMMAND_DISRUPTIVE(Authority.BOARD_COMMAND_DISRUPTIVE), + /** Data egress: chrome.screenshot returns an image of a screen in a prayer space; logs.tail leaks internals. */ + BOARD_COMMAND_INSPECT(Authority.BOARD_COMMAND_INSPECT), + + // ==================== Identity and audit ==================== + + IAM_USER_READ(Authority.IAM_USER_READ), + IAM_USER_WRITE(Authority.IAM_USER_WRITE), + /** The escalation vector. ROOT only. */ + IAM_ROLE_GRANT(Authority.IAM_ROLE_GRANT), + AUDIT_READ(Authority.AUDIT_READ); + + private final String authority; + + Permission(String authority) { + this.authority = authority; + } + + @Override + public String getAuthority() { + return authority; + } + + /** + * Compile-time constants for {@code @PreAuthorize("hasAuthority(Permission.Authority.X)")}. + * Mirrors the {@link Role.Authority} idiom. + */ + public interface Authority { + String MEDIA_UPLOAD_SELF = "media:upload:self"; + String MEDIA_UPLOAD_MODERATE = "media:upload:moderate"; + String MEDIA_UPLOAD_READ = "media:upload:read"; + String MEDIA_EVENT_WRITE = "media:event:write"; + + String BOARD_CONTENT_READ = "board:content:read"; + String BOARD_POSTER_WRITE = "board:poster:write"; + String BOARD_EVENT_WRITE = "board:event:write"; + String BOARD_SOCIAL_WRITE = "board:social:write"; + String BOARD_WEEKLY_WRITE = "board:weekly:write"; + String BOARD_TICKER_WRITE = "board:ticker:write"; + + String BOARD_CONFIG_READ = "board:config:read"; + String BOARD_CONFIG_WRITE = "board:config:write"; + String BOARD_REFRESH = "board:refresh"; + + String BOARD_DEVICE_READ = "board:device:read"; + String BOARD_DEVICE_ENROLL = "board:device:enroll"; + String BOARD_DEVICE_REVOKE = "board:device:revoke"; + String BOARD_TELEMETRY_SUBSCRIBE = "board:telemetry:subscribe"; + + String BOARD_COMMAND_BENIGN = "board:command:benign"; + String BOARD_COMMAND_DISRUPTIVE = "board:command:disruptive"; + String BOARD_COMMAND_INSPECT = "board:command:inspect"; + + String IAM_USER_READ = "iam:user:read"; + String IAM_USER_WRITE = "iam:user:write"; + String IAM_ROLE_GRANT = "iam:role:grant"; + String AUDIT_READ = "audit:read"; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java index f1d91ef..1fbf2c5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java @@ -1,17 +1,24 @@ package com.ibrasoft.lensbridge.model.auth; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.index.Indexed; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.Builder; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; -@Document(collection = "refresh_tokens") +@Entity +@Table(name = "refresh_tokens") @Data @NoArgsConstructor @AllArgsConstructor @@ -19,27 +26,38 @@ public class RefreshToken { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Indexed(unique = true) - private String token; - - @Indexed - private UUID userId; - - private LocalDateTime expiryDate; - - private LocalDateTime createdDate; + @NotBlank + @Column(nullable = false, unique = true) + private String tokenHash; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; - private LocalDateTime lastUsedDate; + @Column(nullable = false) + private Instant expiryDate; - private String deviceInfo; + @Column(nullable = false) + private Instant createdDate; - private String ipAddress; + @Column(nullable = false) + private Instant lastUsedDate; + @Column(nullable = false) private boolean revoked; - + + public String getToken() { + return tokenHash; + } + + public UUID getUserId() { + return user != null ? user.getId() : null; + } + public boolean isExpired() { - return LocalDateTime.now().isAfter(this.expiryDate); + return expiryDate != null && expiryDate.isBefore(Instant.now()); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java index 098158e..016efba 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java @@ -1,62 +1,62 @@ package com.ibrasoft.lensbridge.model.auth; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; + /** - * Enum representing user roles in the system. - * This enum provides type safety and prevents typos in role names. + * A named bundle of {@link Permission}s. Roles are what you assign to a person, because + * "make them a board editor" is how humans think — but nothing in this codebase branches + * on a role name. Every {@code @PreAuthorize} checks a permission. + *

+ * The bundles themselves are in {@link RolePermissions}. + *

+ * Role authorities are still emitted as {@code ROLE_*} alongside the expanded permission + * set, so any surviving {@code hasRole(...)} check keeps working. */ -public enum Role { - ROLE_USER("ROLE_USER"), - ROLE_MODERATOR("ROLE_MODERATOR"), - ROLE_ADMIN("ROLE_ADMIN"), - ROLE_VERIFIED("ROLE_VERIFIED"), - ROLE_ROOT("ROLE_ROOT"); - - // Static constants for use in @PreAuthorize annotations - public static final String USER = "ROLE_USER"; - public static final String MODERATOR = "ROLE_MODERATOR"; - public static final String ADMIN = "ROLE_ADMIN"; - public static final String VERIFIED = "ROLE_VERIFIED"; - public static final String ROOT = "ROLE_ROOT"; - - private final String authority; - - Role(String authority) { - this.authority = authority; +public enum Role implements GrantedAuthority { + + // ---------- Legacy media sharing ---------- + USER("Upload and manage your own media submissions"), + ADMIN("Moderate media submissions and read the audit log"), + + // ---------- MusallahBoard ---------- + BOARD_VIEWER("View board content, config, and device status; change nothing"), + BOARD_EDITOR("Create and edit everything that appears on a board"), + BOARD_ADMIN("Full control of boards and devices, including enrollment and remote commands"), + + // ---------- Everything ---------- + ROOT("Unrestricted, including granting roles to other users"); + + private final String description; + + Role(String description) { + this.description = description; } - /** - * Gets the authority string used by Spring Security. - * @return the authority string (e.g., "ROLE_USER") - */ - public String getAuthority() { - return authority; + public String getDescription() { + return description; } - /** - * Gets the role name without the ROLE_ prefix. - * @return the role name (e.g., "USER") - */ - public String getRoleName() { - return authority.substring(5); + /** The permissions this role confers. Never null; may not be modified. */ + public Set getPermissions() { + return RolePermissions.of(this); } - /** - * Converts a string to a Role enum. - * @param authority the authority string - * @return the corresponding Role enum - * @throws IllegalArgumentException if the authority is not recognized - */ - public static Role fromAuthority(String authority) { - for (Role role : values()) { - if (role.authority.equals(authority)) { - return role; - } - } - throw new IllegalArgumentException("Unknown role authority: " + authority); + // String constants for @PreAuthorize("hasRole('" + Role.Authority.ADMIN + "')") + // Spring Security's hasRole() prepends ROLE_ automatically, so these are just the name. + // These will be removed once media-related + // features are stripped from the codebase - they are here purely for back-compat until I fully + // transition over to Minbar + @Deprecated + public interface Authority { + String USER = "USER"; + String ADMIN = "ADMIN"; + String ROOT = "ROOT"; } @Override - public String toString() { - return authority; + public String getAuthority() { + return "ROLE_" + name(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java new file mode 100644 index 0000000..65d68c1 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java @@ -0,0 +1,100 @@ +package com.ibrasoft.lensbridge.model.auth; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +/** + * The role -> permission bundles. This is the whole authorization policy of the system, + * in one file, on purpose: if you want to know what a role can do, you read this and + * nothing else. + *

+ * Bundles live here rather than in {@link Role}'s constructor because the JLS forbids an + * enum constant's initializer from referencing another constant of the same enum, and the + * bundles are defined by composition ({@code BOARD_ADMIN = BOARD_EDITOR ∪ …}). + *

+ * There is deliberately no Spring {@code RoleHierarchy} bean. Inheritance is set union + * performed here, once, at class-init time — a hierarchy string in security config could + * silently disagree with these sets. + */ +final class RolePermissions { + + private static final Map> BUNDLES = new EnumMap<>(Role.class); + + static { + // ---------- Legacy media sharing ---------- + + Set user = EnumSet.of( + Permission.MEDIA_UPLOAD_SELF); + + Set admin = union(user, EnumSet.of( + Permission.MEDIA_UPLOAD_MODERATE, + Permission.MEDIA_UPLOAD_READ, + Permission.MEDIA_EVENT_WRITE, + Permission.AUDIT_READ)); + + // ---------- MusallahBoard ---------- + + // Read-only across the whole board surface. The role you hand someone so they can + // tell you a board is offline without being able to touch it. + Set boardViewer = EnumSet.of( + Permission.BOARD_CONTENT_READ, + Permission.BOARD_CONFIG_READ, + Permission.BOARD_DEVICE_READ, + Permission.BOARD_TELEMETRY_SUBSCRIBE); + + // Can change anything that appears on a board, and nothing about the board itself. + Set boardEditor = union(boardViewer, EnumSet.of( + Permission.BOARD_POSTER_WRITE, + Permission.BOARD_EVENT_WRITE, + Permission.BOARD_SOCIAL_WRITE, + Permission.BOARD_WEEKLY_WRITE, + Permission.BOARD_TICKER_WRITE, + Permission.BOARD_REFRESH)); + + // Owns the board product. Note the absence of everything under iam: — owning the + // boards is not owning the org's accounts. + Set boardAdmin = union(boardEditor, EnumSet.of( + Permission.BOARD_CONFIG_WRITE, + Permission.BOARD_REFRESH, + Permission.BOARD_COMMAND_BENIGN, + Permission.BOARD_COMMAND_DISRUPTIVE, + Permission.BOARD_DEVICE_ENROLL, + Permission.BOARD_DEVICE_REVOKE, + Permission.BOARD_COMMAND_INSPECT, + Permission.AUDIT_READ)); + + BUNDLES.put(Role.USER, user); + BUNDLES.put(Role.ADMIN, admin); + BUNDLES.put(Role.BOARD_VIEWER, boardViewer); + BUNDLES.put(Role.BOARD_EDITOR, boardEditor); + BUNDLES.put(Role.BOARD_ADMIN, boardAdmin); + + // ROOT is allOf rather than an explicit list so a newly added permission is held by + // root on deploy. For a single-org deployment that is correct: the alternative locks + // the owner out of every new feature until someone remembers to edit this file. + // Revisit if Minbar ever becomes multi-tenant. + BUNDLES.put(Role.ROOT, EnumSet.allOf(Permission.class)); + + for (Role role : Role.values()) { + if (!BUNDLES.containsKey(role)) { + throw new IllegalStateException("Role " + role + " has no permission bundle"); + } + } + } + + private RolePermissions() { + } + + static Set of(Role role) { + return Collections.unmodifiableSet(BUNDLES.get(role)); + } + + private static Set union(Set a, Set b) { + EnumSet merged = EnumSet.copyOf(a); + merged.addAll(b); + return merged; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index 359558e..4ed30e4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -1,26 +1,46 @@ package com.ibrasoft.lensbridge.model.auth; -import java.util.ArrayList; -import java.util.List; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.Indexed; +import jakarta.persistence.Id; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -import org.springframework.data.mongodb.core.mapping.Document; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @AllArgsConstructor -@Document(collection = "users") +@NoArgsConstructor @Data +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_users_email", columnList = "email") +}) public class User { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @NotBlank @@ -30,84 +50,74 @@ public class User { private String lastName; @NotBlank - @Indexed(unique = true) @Size(max = 10) + @Column(unique = true, nullable = false) private String studentNumber; @NotBlank - @Size(max = 50) + @Size(max = 254) @Email - @Indexed(unique = true) + @Column(nullable = false, unique = true) private String email; @NotBlank - @Size(max = 120) @JsonIgnore - private String password; - - private List roles; - - private boolean verified; - private String verificationToken; - - public User() { - this.id = UUID.randomUUID(); + @Column(nullable = false) + private String passwordHash; + + @ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER) + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_roles") + @Column(name = "role", nullable = false) + private Set roles = new HashSet<>(); + + @ElementCollection(targetClass = Permission.class, fetch = FetchType.LAZY) + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_permissions", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "permission", nullable = false) + private Set directPermissions = new HashSet<>(); + + @Column(nullable = true) + private Instant verifiedAt; + + public User(String firstName, String lastName, String studentNumber, String email, String passwordHash) { + this.firstName = firstName; + this.lastName = lastName; + this.studentNumber = studentNumber; + this.email = email; + this.passwordHash = passwordHash; + this.roles = new HashSet<>(); + this.directPermissions = new HashSet<>(); } - public User(String firstName, String lastName, String studentNumber, String email, String password){ - this( - UUID.randomUUID(), - firstName, - lastName, - studentNumber, - email, - password, - List.of(), - false, - null - ); + @JsonIgnore + public String getPassword() { + return passwordHash; } - public void addRole(String role) { - if (this.roles == null) { - this.roles = new ArrayList<>(); - } - this.roles.add(role); + public void setPassword(String passwordHash) { + this.passwordHash = passwordHash; } - /** - * Adds a role using the Role enum for type safety. - * @param role the role to add - */ public void addRole(Role role) { - addRole(role.getAuthority()); + if (roles == null) roles = new HashSet<>(); + roles.add(role); } - /** - * Checks if the user has a specific role using the Role enum. - * @param role the role to check for - * @return true if the user has the role, false otherwise - */ public boolean hasRole(Role role) { - return this.roles != null && this.roles.contains(role.getAuthority()); + return roles != null && roles.contains(role); + } + + public void addDirectPermission(Permission permission) { + if (directPermissions == null) directPermissions = new HashSet<>(); + directPermissions.add(permission); } - /** - * Checks if the user has a specific role using a string. - * @param role the role string to check for - * @return true if the user has the role, false otherwise - */ - public boolean hasRole(String role) { - return this.roles != null && this.roles.contains(role); + public boolean hasDirectPermission(Permission permission) { + return directPermissions != null && directPermissions.contains(permission); } - /** - * Removes a role using the Role enum. - * @param role the role to remove - */ - public void removeRole(Role role) { - if (this.roles != null) { - this.roles.remove(role.getAuthority()); - } + public boolean isVerified() { + return verifiedAt != null; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java new file mode 100644 index 0000000..74e36d2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java @@ -0,0 +1,53 @@ +package com.ibrasoft.lensbridge.model.auth; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Table(name = "verification_tokens") +public class VerificationToken { + + public enum TokenType { + EMAIL_VERIFICATION, PASSWORD_RESET + } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String tokenHash; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TokenType type; + + private Instant createdAt; + private Instant expiresAt; + private Instant usedAt; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java index da35769..f22fb5a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java @@ -1,8 +1,16 @@ package com.ibrasoft.lensbridge.model.board; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + import java.util.Arrays; import java.util.Optional; +// TECH DEBT: +// Eventually I would like to have tenancy-defined audiences and allow audience meshing rather than a binary +// brothers/sisters/everyone (i.e: more than 3 audiences) +// This is sufficient for a version 1 prototype, though + public enum Audience { BROTHERS("brothers"), SISTERS("sisters"), @@ -21,6 +29,17 @@ public static Optional from(String s) { .findFirst(); } + @JsonValue + public String toJson() { + return audienceName; + } + + /** Accepts either case, so pre-existing callers sending "BROTHERS" still bind. */ + @JsonCreator + public static Audience fromJson(String s) { + return from(s).orElseThrow(() -> new IllegalArgumentException("Unknown audience: " + s)); + } + @Override public String toString() { return audienceName; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java deleted file mode 100644 index deb0a10..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.ibrasoft.lensbridge.model.board; - -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Document("board_configs") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class BoardConfig { - /** - * The location of the board (e.g., brothers' musallah, sisters' musallah). - * Since there will only be one of each, we use the location as the document ID. - * Two-for-one design! - */ - @Id - private BoardLocation boardLocation; - - /** - * The prayer location details (city, country, coordinates, timezone, calculation method). - */ - private Location location; - - /** - * Default poster cycle duration in milliseconds - */ - private int posterCycleInterval; - - /** - * How long to wait before refreshing data after Ishaa - */ - private int refreshAfterIshaaMinutes; - - /** - * Enable dark mode after Ishaa - */ - private boolean darkModeAfterIsha; - - /** - * How long to wait after isha (in minutes) before enabling dark mode - */ - private int darkModeMinutesAfterIsha; - - /** - * Enable scrolling message at the bottom of the screen - */ - private boolean enableScrollingMessage; - - /** - * The scrolling message texts (will rotate through them) - */ - private List scrollingMessages; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java new file mode 100644 index 0000000..24e8b0c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java @@ -0,0 +1,38 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "board_events") +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class BoardEvent { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + private String location; + + @Column(nullable = false) + private Instant startTime; + + @Column(nullable = false) + private Instant endTime; + + @Builder.Default + @Column(nullable = false) + private Boolean allDay = false; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Audience audience; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java deleted file mode 100644 index 09d0199..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ibrasoft.lensbridge.model.board; - -import java.util.Arrays; -import java.util.Optional; - -public enum BoardLocation { - BROTHERS_MUSALLAH("brothers", Audience.BROTHERS), - SISTERS_MUSALLAH("sisters", Audience.SISTERS); - - private final String locationName; - private final Audience audience; - - BoardLocation(String locationName, Audience audience) { - this.locationName = locationName; - this.audience = audience; - } - - public Audience audience() { return audience; } - - public static Optional from(String s) { - if (s == null) return Optional.empty(); - return Arrays.stream(values()) - .filter(b -> b.name().equalsIgnoreCase(s) || b.toString().equalsIgnoreCase(s)) - .findFirst(); - } - - @Override - public String toString() { return locationName; } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java b/src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java new file mode 100644 index 0000000..4d698fc --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java @@ -0,0 +1,11 @@ +package com.ibrasoft.lensbridge.model.board; + +public enum CalculationMethod { + KARACHI(1), ISNA(2), MWL(3), MAKKAH(4), EGYPT(5), + TEHRAN(7), GULF(8), KUWAIT(9), QATAR(10), SINGAPORE(11), + FRANCE(12), TURKEY(13), RUSSIA(14), DUBAI(16); + + private final int code; + CalculationMethod(int code) { this.code = code; } + public int getCode() { return code; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java new file mode 100644 index 0000000..0185e86 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java @@ -0,0 +1,60 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "devices") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Device { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + // If you ever add multi-tenancy, remove the default and make this required + // Hardcoded for now because we only have one organization + @Builder.Default + @Column(nullable = false) + private String organizationId = "utmmsa"; + + @Column(nullable = false) + private String displayName; + + private Instant enrolledAt; + private Instant lastHeartbeat; + + @OneToOne(mappedBy = "device", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private DeviceConfig config; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Audience audience; + + /** Ed25519 public key (32 bytes). Set during enrollment; null before. */ + @Column(name = "public_key", length = 32) + private byte[] publicKey; + + private String agentVersion; + private String hardwareModel; + private String lastSeenIp; + + /** When set, all auth attempts are rejected and any live session is closed. */ + private Instant revokedAt; + + @PrePersist + private void prePersist() { + if (enrolledAt == null) enrolledAt = Instant.now(); + if (organizationId == null) organizationId = "utmmsa"; + } + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java new file mode 100644 index 0000000..9a32127 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java @@ -0,0 +1,78 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Audit log + delivery queue for admin-issued commands targeted at a single device. + *

+ * The polymorphic command shape (kind + payload) is captured here as a discriminator + * string and a JSON blob, so adding new command kinds does not require schema changes. + */ +@Entity +@Table(name = "device_commands", indexes = { + @Index(name = "idx_device_commands_device_status", + columnList = "device_id, status"), + @Index(name = "idx_device_commands_pending", + columnList = "device_id, issued_at") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceCommand { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "device_id", nullable = false) + private UUID deviceId; + + /** Polymorphic discriminator (e.g. "chrome.reload"). */ + @Column(nullable = false) + private String kind; + + /** Serialized CommandPayload subclass (Jackson). */ + @Column(columnDefinition = "TEXT") + private String payloadJson; + + /** Username of the admin that issued the command (from JWT). */ + @Column(nullable = false) + private String issuedBy; + + /** Maximum execution time once the agent picks the command up; null = server default. */ + private Integer deadlineMs; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DeviceCommandStatus status; + + @Column(nullable = false) + private Instant issuedAt; + + private Instant deliveredAt; + private Instant ackedAt; + private Instant startedAt; + private Instant finishedAt; + + /** Free-form result payload from the agent (JSON). */ + @Column(columnDefinition = "TEXT") + private String outputJson; + + /** Short error message when status is FAILED / TIMEOUT / REJECTED / EXPIRED. */ + @Column(columnDefinition = "TEXT") + private String errorMessage; + + @PrePersist + private void prePersist() { + if (issuedAt == null) issuedAt = Instant.now(); + if (status == null) status = DeviceCommandStatus.PENDING; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java new file mode 100644 index 0000000..fcd147f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.model.board; + +/** + * State machine for an admin-issued device command. + *

+ * Terminal states: SUCCEEDED, FAILED, TIMEOUT, REJECTED, EXPIRED. + */ +public enum DeviceCommandStatus { + /** Persisted, awaiting an authenticated agent session. */ + PENDING, + /** Frame written to the agent's WS session. */ + DELIVERED, + /** Agent acknowledged receipt and accepted the command. */ + ACKED, + /** Agent reported it has begun executing. */ + RUNNING, + /** Agent finished successfully. */ + SUCCEEDED, + /** Agent reported a runtime error. */ + FAILED, + /** Agent (or backend) hit the deadline before completion. */ + TIMEOUT, + /** Agent refused the command (e.g. unknown kind). */ + REJECTED, + /** Backend gave up waiting for a session before the command could be delivered. */ + EXPIRED; + + public boolean isTerminal() { + return switch (this) { + case SUCCEEDED, FAILED, TIMEOUT, REJECTED, EXPIRED -> true; + default -> false; + }; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java new file mode 100644 index 0000000..8e28af9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java @@ -0,0 +1,54 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Append-only telemetry sample emitted by an agent over its WebSocket session. + *

+ * Currently unbounded — one row per heartbeat, forever. A retention prune is still owed. + */ +@Entity +@Table(name = "device_telemetry", indexes = { + @Index(name = "idx_device_telemetry_device_recorded", + columnList = "device_id, recorded_at") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceTelemetry { + + public static final int MAX_FRAME_KEY_LENGTH = 64; + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "device_id", nullable = false) + private UUID deviceId; + + @Column(name = "recorded_at", nullable = false) + private Instant recordedAt; + + private Long uptimeSec; + private Double cpuTempC; + private String throttleFlags; + + private Integer memUsedMb; + private Integer memTotalMb; + private Integer diskUsedPct; + + private Boolean kioskAlive; + + /** Comma-separated list of non-loopback IPv4 addresses. */ + private String ipv4; + + private String wifiSsid; + + @Column(name = "displayed_frame_key", length = MAX_FRAME_KEY_LENGTH) + private String displayedFrameKey; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java b/src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java new file mode 100644 index 0000000..b6a42a8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java @@ -0,0 +1,60 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * One-time pairing token issued by an admin and consumed by an agent during enrollment. + * Plaintext is shown to the operator once at issue time; only its SHA-256 hash is persisted. + */ +@Entity +@Table(name = "enrollment_tokens", indexes = { + @Index(name = "idx_enrollment_tokens_hash", columnList = "token_hash", unique = true) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EnrollmentToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** SHA-256 of the plaintext token (32 bytes). */ + @Column(name = "token_hash", length = 32, nullable = false) + private byte[] tokenHash; + + /** Display name to assign to the resulting Device. */ + @Column(nullable = false) + private String displayName; + + /** Audience baked in at issue time so the operator can't typo it on the Pi. */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Audience audience; + + /** Username of the admin that issued the token (from JWT). */ + @Column(nullable = false) + private String createdBy; + + @Column(nullable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant expiresAt; + + /** Set the moment the token is consumed; subsequent attempts fail. */ + private Instant consumedAt; + + /** Device created from this token, if consumed. */ + private UUID consumedByDeviceId; + + @PrePersist + private void prePersist() { + if (createdAt == null) createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java deleted file mode 100644 index 7e6841d..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.ibrasoft.lensbridge.model.board; - -import java.time.LocalDateTime; -import java.util.UUID; - -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Document(collection = "board_events") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class Event { - @Id - private UUID id; - - /** - * Event name/title - */ - private String name; - - private String description; - - private String location; - - private Long startTimestamp; - - private Long endTimestamp; - - private Boolean allDay; - - private Audience audience; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java b/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java index 080bd9e..d694131 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java @@ -1,17 +1,44 @@ package com.ibrasoft.lensbridge.model.board; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; +import java.util.UUID; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "islamic_quotes") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class IslamicQuote { + public enum Kind { VERSE, HADITH } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_content_id", nullable = false) + @JsonIgnore + private WeeklyContent weeklyContent; + + @Enumerated(EnumType.STRING) + private Kind kind; private String arabic; private String transliteration; private String translation; private String reference; + + /** + * How long this quote's slide is pinned for, or null for "auto" — the board then sizes the + * dwell time to the text it is actually rendering. + *

+ * Per-quote rather than per-device, like {@link com.ibrasoft.lensbridge.model.board.Poster#getDuration()}: + * a two-line verse and a full-paragraph hadith do not deserve the same dwell time, and that + * difference belongs to the content, not to the screen showing it. Every board renders the + * same weekly content, so a device-level setting could not express it. + *

+ * Nullable because "auto" has to remain reachable — a hadith nobody has timed by hand is + * better measured by the board than pinned to a guessed constant. + */ + @Column(name = "duration_seconds") + private Integer durationSeconds; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java b/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java index 1794ff8..8bb0a2c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java @@ -1,19 +1,25 @@ package com.ibrasoft.lensbridge.model.board; -import java.time.LocalDate; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; import java.time.LocalTime; +import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "jummah_prayers") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class JummahPrayer { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_content_id", nullable = false) + @JsonIgnore + private WeeklyContent weeklyContent; + private LocalTime prayerTime; private String khatib; - private String location; + private String room; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java index 2a29ad2..3d382b2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java @@ -1,17 +1,16 @@ package com.ibrasoft.lensbridge.model.board; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; +import jakarta.persistence.*; +import lombok.*; -@Builder -@Data -@AllArgsConstructor +@Embeddable +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Location { private String city; private String country; private double latitude; private double longitude; private String timezone; - private int method; + @Enumerated(EnumType.STRING) + private CalculationMethod method; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java index 71966be..9c2cc3d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java @@ -1,40 +1,40 @@ package com.ibrasoft.lensbridge.model.board; -import java.time.LocalDate; +import jakarta.persistence.*; +import lombok.*; +import java.time.Instant; import java.util.UUID; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Document(collection = "posters") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "posters") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Poster { @Id - public UUID id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + @Column(nullable = false) private String title; + @Column(nullable = false) private String image; + @Column(nullable = false) private int duration; - /** - * The date from which the poster becomes active (inclusive) - */ - private LocalDate startDate; + @Column(nullable = false) + private Instant startTime; - /** - * The date until which the poster remains active (exclusive) - */ - private LocalDate endDate; + @Column(nullable = false) + private Instant endTime; + @Enumerated(EnumType.STRING) + @Column(nullable = false) private Audience audience; + + /** + * Optional URL for the associated signup page. If provided, the frontend will show a QR Code linking to this URL. + */ + @Column(nullable = true) + private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java b/src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java new file mode 100644 index 0000000..8b65fa3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java @@ -0,0 +1,76 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * Represents a social media platform that can be promoted to a board + * Includes platforms like Instagram pages, TikTok accounts, YouTube channels, and + * WhatsApp chats + */ +@Entity +@Table(name = "promotable_social_media") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotableSocialMedia { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String name; + + /** + * Duration of frame, in seconds + */ + @Column(nullable = false) + private int duration; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Audience audience; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private SocialType type; + + @Column(nullable = false) + private String url; + + /* All fields below will be stored as markdown in the database and serialized to HTML in the frontend + That way, users can add italics for things like "your home on campus" + */ + + /** + * Small 2-3 word tagline (i.e: follow along) + */ + @Column(nullable = false) + private String headerText; + + /** + * Main text shown to the user + */ + @Column(nullable = false) + private String heroText; + + /** + * Social media handle, if applicable (i.e: WhatsApp doesn't have a handle) + */ + @Column(nullable = true) + private String handle; + + /** + * Descriptive block of text + * i.e: Follow your home on campus for event recaps, announcements, and more! + */ + @Column(nullable = false) + private String footerText; + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java new file mode 100644 index 0000000..05654e0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java @@ -0,0 +1,58 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +/** + * The platform a {@link PromotableSocialMedia} entry points at, as it appears in + * {@code PromotableSocialMediaFrameConfig.socialType}. + *

+ * {@code @JsonValue} is crucial, else Jackson serializes the enum name and the wire carries + * {@code INSTAGRAM} where the frontend — which keys its platform icons and brand colours off + * the lowercase label — expects {@code instagram}. + *

+ * Mirrors {@link Audience} and {@link com.ibrasoft.lensbridge.model.board.frames.FrameType}, + * which had the same shape and got this right. + *

+ * This affects the wire only. The column is {@code @Enumerated(EnumType.STRING)}, so the + * database still stores {@code name()}. + */ +public enum SocialType { + INSTAGRAM("instagram"), + YOUTUBE("youtube"), + TIKTOK("tiktok"), + WHATSAPP("whatsapp"), + OTHER("other"); + + private final String label; + + SocialType(String label) { + this.label = label; + } + + public static Optional from(String s) { + if (s == null) return Optional.empty(); + return Arrays.stream(values()) + .filter(t -> t.name().equalsIgnoreCase(s) || t.label.equalsIgnoreCase(s)) + .findFirst(); + } + + @JsonValue + public String toJson() { + return label; + } + + /** Accepts either spelling, so anything persisted or sent as {@code INSTAGRAM} still binds. */ + @JsonCreator + public static SocialType fromJson(String s) { + return from(s).orElseThrow(() -> new IllegalArgumentException("Unknown social type: " + s)); + } + + @Override + public String toString() { + return label; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java b/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java index c4303ed..95b4b2f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java @@ -1,23 +1,34 @@ package com.ibrasoft.lensbridge.model.board; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; import java.util.List; +import java.util.UUID; -@Document(collection = "weekly_content") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "weekly_content", + uniqueConstraints = @UniqueConstraint(columnNames = {"year", "week_number"})) +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class WeeklyContent { @Id - private WeekId weekId; - private IslamicQuote verse; - private IslamicQuote hadith; - private List jummahPrayer; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "year", nullable = false) + private int year; + + @Column(name = "week_number", nullable = false) + private int weekNumber; + + @OneToMany(mappedBy = "weeklyContent", cascade = CascadeType.ALL, + orphanRemoval = true, fetch = FetchType.EAGER) + @Builder.Default + private List quotes = new ArrayList<>(); + + @OneToMany(mappedBy = "weeklyContent", cascade = CascadeType.ALL, + orphanRemoval = true, fetch = FetchType.EAGER) + @OrderColumn(name = "slot_order") + @Builder.Default + private List jummahPrayers = new ArrayList<>(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java new file mode 100644 index 0000000..418cacd --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Force-reload the kiosk Chrome page via CDP {@code Page.reload(ignoreCache:true)}. */ +public record ChromeReloadPayload() implements CommandPayload { + @Override public String kind() { return "chrome.reload"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java new file mode 100644 index 0000000..3485ed9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Capture the current kiosk frame via CDP {@code Page.captureScreenshot}. PNG, full viewport. */ +public record ChromeScreenshotPayload() implements CommandPayload { + @Override public String kind() { return "chrome.screenshot"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java new file mode 100644 index 0000000..94cd69f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java @@ -0,0 +1,65 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.ibrasoft.lensbridge.model.auth.Permission; + +import java.util.Arrays; +import java.util.Optional; + +/** + * The registry of issuable command kinds and their {@link CommandRisk}. + *

+ * This replaces the hardcoded {@code KNOWN_KINDS} string set that used to live in + * {@code CommandDispatcher}, which duplicated the {@code @JsonSubTypes} registration on + * {@link CommandPayload} with nothing keeping the two in step. Adding a command kind is + * now: new record + new {@code @Type} entry + a constant here (which forces a risk + * classification) + agent-side handler. {@code CommandKindTest} fails if the wire strings + * here and the Jackson subtypes ever diverge. + *

+ * The dispatcher never deserializes the payload into a {@link CommandPayload}; it stores + * the raw JSON and hands it to the agent so classification has to hang off the wire + * string rather than the payload type. + */ +public enum CommandKind { + + CHROME_RELOAD("chrome.reload", CommandRisk.BENIGN), + CONFIG_REFRESH("config.refresh", CommandRisk.BENIGN), + + KIOSK_RESTART("kiosk.restart", CommandRisk.DISRUPTIVE), + SYSTEM_REBOOT("system.reboot", CommandRisk.DISRUPTIVE), + + CHROME_SCREENSHOT("chrome.screenshot", CommandRisk.INSPECT), + LOGS_TAIL("logs.tail", CommandRisk.INSPECT); + + private final String wireName; + private final CommandRisk risk; + + CommandKind(String wireName, CommandRisk risk) { + this.wireName = wireName; + this.risk = risk; + } + + /** The dotted discriminator sent over the wire, e.g. {@code chrome.reload}. */ + public String getWireName() { + return wireName; + } + + public CommandRisk getRisk() { + return risk; + } + + public Permission getRequiredPermission() { + return risk.getRequiredPermission(); + } + + public static Optional from(String wireName) { + if (wireName == null) return Optional.empty(); + return Arrays.stream(values()) + .filter(k -> k.wireName.equals(wireName)) + .findFirst(); + } + + @Override + public String toString() { + return wireName; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java new file mode 100644 index 0000000..13ddfd3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Polymorphic command payload, mirroring the {@link com.ibrasoft.lensbridge.model.board.frames.FrameConfig} + * pattern: adding a new command kind = new record + new {@code @Type} entry + agent-side handler. + *

+ * The discriminator is {@code kind} (e.g. {@code "chrome.reload"}). Stored as JSON in + * {@code device_commands.payload_json} and re-parsed when delivering to the agent. + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "kind" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = ChromeReloadPayload.class, name = "chrome.reload"), + @JsonSubTypes.Type(value = ChromeScreenshotPayload.class, name = "chrome.screenshot"), + @JsonSubTypes.Type(value = KioskRestartPayload.class, name = "kiosk.restart"), + @JsonSubTypes.Type(value = SystemRebootPayload.class, name = "system.reboot"), + @JsonSubTypes.Type(value = ConfigRefreshPayload.class, name = "config.refresh"), + @JsonSubTypes.Type(value = LogsTailPayload.class, name = "logs.tail"), +}) +public sealed interface CommandPayload + permits ChromeReloadPayload, ChromeScreenshotPayload, KioskRestartPayload, + SystemRebootPayload, ConfigRefreshPayload, LogsTailPayload { + + String kind(); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java new file mode 100644 index 0000000..a64bf54 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.ibrasoft.lensbridge.model.auth.Permission; + +/** + * Blast radius of a device command, and the permission required to issue one. + *

+ * These are separately grantable because they are not comparable risks: a reload is a + * page refresh, a reboot takes a display in a prayer space down for minutes, and a + * screenshot is a camera pointed at whatever is on that display. Holding one implies + * nothing about the others + */ +public enum CommandRisk { + + /* Harmless commands, typically configuration-related */ + BENIGN(Permission.BOARD_COMMAND_BENIGN), + + /* Commands which may result in brief downtime */ + DISRUPTIVE(Permission.BOARD_COMMAND_DISRUPTIVE), + + /* Commands - typically harmless - which fetch information about the board state and thus require appropriate auditing + */ + INSPECT(Permission.BOARD_COMMAND_INSPECT); + + private final Permission requiredPermission; + + CommandRisk(Permission requiredPermission) { + this.requiredPermission = requiredPermission; + } + + public Permission getRequiredPermission() { + return requiredPermission; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java new file mode 100644 index 0000000..5e434e8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Trigger an in-page re-fetch of the board payload (no full reload). */ +public record ConfigRefreshPayload() implements CommandPayload { + @Override public String kind() { return "config.refresh"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java new file mode 100644 index 0000000..4720723 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Restart the {@code musallahboard-kiosk.service} systemd unit on the Pi. */ +public record KioskRestartPayload() implements CommandPayload { + @Override public String kind() { return "kiosk.restart"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java new file mode 100644 index 0000000..20270ca --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java @@ -0,0 +1,11 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Tail the last {@code lines} entries from the agent + kiosk journald units. Capped at 500. */ +public record LogsTailPayload(Integer lines) implements CommandPayload { + public LogsTailPayload { + if (lines != null && (lines < 1 || lines > 500)) { + throw new IllegalArgumentException("lines must be between 1 and 500"); + } + } + @Override public String kind() { return "logs.tail"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java new file mode 100644 index 0000000..8800261 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Reboot the host. Agent acks + reports {@code ok} *before* invoking shutdown. */ +public record SystemRebootPayload() implements CommandPayload { + @Override public String kind() { return "system.reboot"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java new file mode 100644 index 0000000..bc4395f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -0,0 +1,70 @@ +package com.ibrasoft.lensbridge.model.board.embedded; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.Location; +import jakarta.persistence.*; +import lombok.*; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "board_configs") +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class DeviceConfig { + @Id + private UUID id; + + @JsonIgnore + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "device_id") + private Device device; + + @Embedded + private Location location; + + private boolean darkModeAfterIsha; + private boolean enableScrollingMessage; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "board_config_messages", + joinColumns = @JoinColumn(name = "device_id")) + @Column(name = "message") + private List scrollingMessages; + + /** Shortest and longest a slide may be pinned for. A 2s slide cannot be read; a 5m one is a hang. */ + public static final int MIN_SLIDE_SECONDS = 5; + public static final int MAX_SLIDE_SECONDS = 120; + + /** Used whenever {@link #nextPrayerDurationSeconds} has no stored value. */ + public static final int DEFAULT_NEXT_PRAYER_DURATION_SECONDS = 12; + + /** + * How long the agenda slide is pinned for, or null for "auto" — the board then grows the + * duration with the number of events it is actually showing. + * + * The one genuinely nullable duration: an agenda with two events and one with a full week + * do not deserve the same dwell time, and only the board knows which it is rendering. + */ + @Column(name = "agenda_duration_seconds") + private Integer agendaDurationSeconds; + + /** + * How long the next-prayer countdown is pinned for. Never null to a caller — see the getter. + * + * The column stays nullable even though the API contract does not, because {@code + * ddl-auto=update} cannot add a NOT NULL column to a table that already has rows: Hibernate + * logs the failure and continues, leaving the column absent and every read broken. Nullable + * plus a defaulting getter reaches the same place without a migration. + */ + @Column(name = "next_prayer_duration_seconds") + private Integer nextPrayerDurationSeconds; + + /** Never returns null, so the payload always carries a concrete duration for this frame. */ + public int getNextPrayerDurationSeconds() { + return nextPrayerDurationSeconds == null + ? DEFAULT_NEXT_PRAYER_DURATION_SECONDS + : nextPrayerDurationSeconds; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java new file mode 100644 index 0000000..b695e2c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java @@ -0,0 +1,37 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import com.ibrasoft.lensbridge.dto.board.response.frames.DayBucket; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * The board's rolling agenda: every day from today through the end of the window, bucketed in + * the device's timezone. + *

+ * This replaced the former {@code event_list} and {@code daily_schedule} frames, which queried + * the same events over a 7-day and a 1-day window respectively and differed only in layout. + * Today's events are simply the first bucket. + *

+ * The backend fixes the content of the window; the board advances through it as the day + * passes. Which day is current, which event is next, and how far to scroll all change by the + * minute, far faster than a payload refresh, so they are necessarily client-side. Every time here + * is an absolute {@code Instant} for that reason — a pre-formatted "in 2 hours" would be wrong + * within two hours. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AgendaFrameConfig extends FrameConfig { + + private String heading; + + /** One entry per day in the window, chronological, including days with no events. */ + private List days; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java deleted file mode 100644 index 82bcc4e..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class DailyScheduleFrameConfig extends FrameConfig { - - private String heading; - - private List events; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java deleted file mode 100644 index e9acc3b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class EventListFrameConfig extends FrameConfig { - - /** e.g. "This Week" */ - private String heading; - - /** Flat presentation records */ - private List events; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class EventView { - private String name; - private String description; - private String location; - private Long startTimestamp; - private Long endTimestamp; - private Boolean allDay; - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java index bb441d0..62ddafc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java @@ -2,10 +2,18 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; /** * Base class for frame-specific configuration. * Uses Jackson annotations for polymorphic serialization. + *

+ * {@link FrameType#NEXT_PRAYER} uses empty marker configs + * ({@link NextPrayerFrameConfig}) — the backend has no data to + * contribute to either (prayer times and the QR destination are both resolved client-side), but + * the frame still needs a real position and {@code durationInSeconds} in the sequence rather + * than being synthesized outside the API contract. */ @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -13,13 +21,24 @@ property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = PosterFrameConfig.class, name = "poster"), - @JsonSubTypes.Type(value = EventListFrameConfig.class, name = "event_list"), - @JsonSubTypes.Type(value = DailyScheduleFrameConfig.class, name = "daily_schedule"), - @JsonSubTypes.Type(value = NextPrayerFrameConfig.class, name = "next_prayer"), - @JsonSubTypes.Type(value = JummahFrameConfig.class, name = "jummah"), - @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote") + @JsonSubTypes.Type(value = PosterFrameConfig.class, name = "poster"), + @JsonSubTypes.Type(value = JummahFrameConfig.class, name = "jummah"), + @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote"), + @JsonSubTypes.Type(value = NextPrayerFrameConfig.class, name = "next_prayer"), + @JsonSubTypes.Type(value = AgendaFrameConfig.class, name = "agenda"), + @JsonSubTypes.Type(value = PromotableSocialMediaFrameConfig.class, name = "socials") }) +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "poster", schema = PosterFrameConfig.class), + @DiscriminatorMapping(value = "jummah", schema = JummahFrameConfig.class), + @DiscriminatorMapping(value = "islamic_quote", schema = IslamicQuoteFrameConfig.class), + @DiscriminatorMapping(value = "next_prayer", schema = NextPrayerFrameConfig.class), + @DiscriminatorMapping(value = "agenda", schema = AgendaFrameConfig.class), + @DiscriminatorMapping(value = "socials", schema = PromotableSocialMediaFrameConfig.class) + } +) public abstract class FrameConfig { } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java index 60a03e8..3b2230b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java @@ -11,6 +11,18 @@ @AllArgsConstructor public class FrameDefinition { + /** + * Stable identity for this frame, independent of its position in the response. + * Entity-backed, multi-instance types use {@code ":"} (e.g. + * {@code "poster:3fa8..."}); singleton types (there is only ever one per assembly, e.g. + * {@code daily_schedule} or {@code agenda}) use a fixed slug equal to the type name. + *

+ * This is what a future per-frame ordering override will reference, so it must stay + * stable across payload rebuilds even though {@code FrameDefinition} itself is + * reconstructed from scratch on every request. + */ + private String frameId; + /** * The type of the frame. */ @@ -27,14 +39,4 @@ public class FrameDefinition { * This will likely be some subclass of FrameConfig. */ private FrameConfig frameConfig; - - /** - * Grouping for the frontend layout. - */ - private FrameSlot slot; - - /** - * Higher = shown first within a slot; null = neutral. - */ - private Integer priority; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java deleted file mode 100644 index 69ee4ea..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -public enum FrameSlot { - PRIMARY, // main carousel area - TICKER, // bottom scroller / secondary - SIDEBAR, // persistent side panel - OVERLAY // modals / interruptions (e.g. next prayer countdown) -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java index 59ad3ce..867cecc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java @@ -1,18 +1,53 @@ package com.ibrasoft.lensbridge.model.board.frames; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +/** + * The kind of frame the assembler emitted, as it appears in + * {@code FrameDefinition.frameType}. + *

+ * {@code @JsonValue} is crucial, else Jackson will serialize the enum name instead of the wire + * string, which makes things like {@code POSTER} appear in the database and on the wire instead of + * {@code poster}. + *

+ * Mirrors {@link com.ibrasoft.lensbridge.model.board.Audience}, which had the same + * shape and got this right. + */ public enum FrameType { POSTER("poster"), - EVENT_LIST("event_list"), - DAILY_SCHEDULE("daily_schedule"), NEXT_PRAYER("next_prayer"), + AGENDA("agenda"), JUMMAH("jummah"), - ISLAMIC_QUOTE("islamic_quote"); + ISLAMIC_QUOTE("islamic_quote"), + SOCIALS("socials"); private final String typeName; FrameType(String typeName) { this.typeName = typeName; } + public static Optional from(String s) { + if (s == null) return Optional.empty(); + return Arrays.stream(values()) + .filter(t -> t.name().equalsIgnoreCase(s) || t.typeName.equalsIgnoreCase(s)) + .findFirst(); + } + + @JsonValue + public String toJson() { + return typeName; + } + + /** Accepts either spelling, so anything persisted or sent as {@code POSTER} still binds. */ + @JsonCreator + public static FrameType fromJson(String s) { + return from(s).orElseThrow(() -> new IllegalArgumentException("Unknown frame type: " + s)); + } + @Override public String toString() { return typeName; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java index 336a507..12c2316 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java @@ -25,6 +25,6 @@ public static class JummahSlot { /** ISO-8601 LocalTime string, e.g. "13:30" */ private String prayerTime; private String khatib; - private String location; + private String room; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java index 0894f06..e3cbe41 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java @@ -1,19 +1,15 @@ package com.ibrasoft.lensbridge.model.board.frames; -import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; +/** + * Marker config for {@link FrameType#NEXT_PRAYER}. Carries no fields: the backend holds no + * prayer-time source, so the board computes the countdown itself from + * {@code deviceConfig.location}. This class exists only so the frame has a real position and + * {@code durationInSeconds} in the sequence, instead of being synthesized client-side. + */ @Data -@Builder -@NoArgsConstructor -@AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class NextPrayerFrameConfig extends FrameConfig { - - private String locationCity; - private String timezone; - private String calculationMethod; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java index db47cb4..95f3f86 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java @@ -26,4 +26,11 @@ public class PosterFrameConfig extends FrameConfig { * The title of the poster (optional, for accessibility/logging). */ private String title; + + /** + * Optional sign-up link. When present the board renders the poster beside a + * QR code encoding this URL rather than full-bleed, so someone walking past + * can register without typing anything. + */ + private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java new file mode 100644 index 0000000..820bde8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java @@ -0,0 +1,46 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import com.ibrasoft.lensbridge.model.board.SocialType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * One promoted social account, as the board renders it: platform, QR destination, and the copy + * around them. All of it comes from a {@link com.ibrasoft.lensbridge.model.board.PromotableSocialMedia} + * row, so an operator can add or reword an account without a frontend deploy. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PromotableSocialMediaFrameConfig extends FrameConfig { + + private SocialType socialType; + + private String url; + + /** + * Small 2-3 word tagline (i.e: follow along) + */ + private String headerText; + + /** + * Main text shown to the user + */ + private String heroText; + + /** + * Social media handle, if applicable (i.e: WhatsApp doesn't have a handle) + */ + private String handle; + + /** + * Descriptive block of text + * i.e: Follow your home on campus for event recaps, announcements, and more! + */ + private String footerText; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java deleted file mode 100644 index 29808f0..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ibrasoft.lensbridge.model.event; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.LocalDateTime; -import java.util.UUID; - -@NoArgsConstructor -@AllArgsConstructor -@Data -@Builder -@Document(collection = "events") -public class Event { - @Id - private UUID id; - private String name; - private LocalDateTime date; - private EventStatus status; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/event/EventStatus.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/EventStatus.java similarity index 74% rename from src/main/java/com/ibrasoft/lensbridge/model/event/EventStatus.java rename to src/main/java/com/ibrasoft/lensbridge/model/upload/EventStatus.java index 4f30841..31b1bbc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/event/EventStatus.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/EventStatus.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.model.event; +package com.ibrasoft.lensbridge.model.upload; public enum EventStatus { UPCOMING("Upcoming"), diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java new file mode 100644 index 0000000..84c024f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java @@ -0,0 +1,49 @@ +package com.ibrasoft.lensbridge.model.upload; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import jakarta.persistence.Id; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Column; +import jakarta.persistence.Table; +import jakarta.persistence.PrePersist; + +import java.time.Instant; +import java.util.UUID; + +@NoArgsConstructor +@AllArgsConstructor +@Data +@Builder +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) +@Entity +@Table(name = "events") +public class MediaEvent { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Instant date; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventStatus status = EventStatus.UPCOMING; + + @PrePersist + private void prePersist() { + if (status == null) status = EventStatus.UPCOMING; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index b4c4806..eaa21a9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -1,35 +1,75 @@ package com.ibrasoft.lensbridge.model.upload; import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import java.time.LocalDateTime; +import lombok.Setter; + +import jakarta.persistence.Id; + +import com.ibrasoft.lensbridge.model.auth.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; import java.util.UUID; -@Data +@Entity +@Table(name = "uploads") +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor -@Document(collection = "uploads") +/** + * Represents an uploaded media file, including its metadata and status. + * This class is akin to the inode models in a filesystem, storing metadata and the location of the uploaded content (R2) + */ public class Upload { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID uuid; - private String fileName; + private String fileName; private String fileUrl; private String thumbnailUrl; + private String uploadDescription; private String instagramHandle; - private UUID uploadedBy; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "uploaded_by", nullable = false) + private User uploadedBy; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "event_id") + private MediaEvent mediaEvent; - private UUID eventId; - private LocalDateTime createdDate; + private Instant createdDate; private boolean approved; private boolean featured; private boolean isAnon; + @Enumerated(EnumType.STRING) private UploadType contentType; + + @Column(nullable = true) + /** + * We do not delete things anymore + * DB is append-only, we just mark things as deleted and filter them out in queries + * deletedAt = null => not deleted + */ + private Instant deletedAt; + @ManyToOne(fetch = FetchType.LAZY) + private User deletedBy; } \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java deleted file mode 100644 index 973b8be..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.audit.AdminAction; -import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -@Repository -public interface AuditEventRepository extends MongoRepository { - - List findByAdminIdOrderByTimestampDesc(UUID adminId); - - List findByEntityTypeAndEntityIdOrderByTimestampDesc(String entityType, UUID entityId); - - Page findByActionOrderByTimestampDesc(AdminAction action, Pageable pageable); - - @Query("{'timestamp': {'$gte': ?0, '$lte': ?1}}") - Page findByTimestampBetweenOrderByTimestampDesc(LocalDateTime start, LocalDateTime end, Pageable pageable); - - @Query("{'result': {'$regex': '^ERROR|FAILED', '$options': 'i'}}") - Page findFailedOperationsOrderByTimestampDesc(Pageable pageable); - - Page findAllByOrderByTimestampDesc(Pageable pageable); - - long countByAdminId(UUID adminId); - - long countByAction(AdminAction action); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java deleted file mode 100644 index 879e05b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface BoardConfigRepository extends MongoRepository { -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java deleted file mode 100644 index dca984f..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.Event; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.UUID; - -@Repository -public interface EventRepository extends MongoRepository { - - /** - * Find all events for a specific audience or BOTH. - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] } }") - List findByAudienceOrBoth(Audience audience, Sort sort); - - /** - * Find events whose audience matches the given audience or BOTH, AND whose time interval - * [startTimestamp, endTimestamp] overlaps the given range [rangeStart, rangeEnd]. - * - * Overlap semantics: event.startTimestamp <= rangeEnd AND event.endTimestamp >= rangeStart. - * (An event overlaps the range iff it starts before the range ends and ends after the range starts.) - * - * Parameter indices: ?0 = audience, ?1 = rangeStart, ?2 = rangeEnd. - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] }, 'startTimestamp': { $lte: ?2 }, 'endTimestamp': { $gte: ?1 } }") - List findOverlappingForAudienceOrBoth(Audience audience, long rangeStart, long rangeEnd, Sort sort); - - /** - * Find all upcoming events for a specific audience or BOTH (startTimestamp >= now). - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] }, 'startTimestamp': { $gte: ?1 } }") - List findUpcomingByAudienceOrBoth(Audience audience, long nowTimestamp, Sort sort); - - /** - * Find all events sorted by startTimestamp ascending. - */ - List findAllByOrderByStartTimestampAsc(); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/EventsRepository.java deleted file mode 100644 index f84d911..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/EventsRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.event.Event; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface EventsRepository extends MongoRepository { - Optional findEventById(UUID uuid); -} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java deleted file mode 100644 index 5b13480..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.Poster; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.stereotype.Repository; - -import java.time.LocalDate; -import java.util.List; -import java.util.UUID; - -@Repository -public interface PosterRepository extends MongoRepository { - - /** - * Find all posters active at a given date. - */ - @Query("{ 'startDate': { $lte: ?0 }, 'endDate': { $gt: ?0 } }") - List findActivePostersAt(LocalDate date); - - /** - * Find all posters for a specific audience or BOTH, active at a given date. - * Sorted by startDate descending (newest first). - */ - @Query("{ 'startDate': { $lte: ?0 }, 'endDate': { $gt: ?0 }, 'audience': { $in: [?1, 'BOTH'] } }") - List findActivePostersForAudienceAt(LocalDate date, Audience audience, Sort sort); - - /** - * Find all posters for a specific audience or BOTH. - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] } }") - List findByAudienceOrBoth(Audience audience, Sort sort); - - /** - * Find all posters sorted by startDate descending (newest first). - */ - List findAllByOrderByStartDateDesc(); - - /** - * Find all posters for a specific audience. - */ - List findByAudience(Audience audience, Sort sort); - -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/RefreshTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/RefreshTokenRepository.java deleted file mode 100644 index 7af41c7..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.auth.RefreshToken; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface RefreshTokenRepository extends MongoRepository { - - Optional findByToken(String token); - - List findByUserId(UUID userId); - - List findByUserIdAndRevokedFalse(UUID userId); - - void deleteByUserId(UUID userId); - - void deleteByToken(String token); - - void deleteByExpiryDateBefore(LocalDateTime dateTime); - - void deleteByUserIdAndRevokedTrue(UUID userId); - - long countByUserIdAndRevokedFalse(UUID userId); - - List findByRevokedTrueAndCreatedDateBefore(LocalDateTime minusDays); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/UploadRepository.java deleted file mode 100644 index 8fb5f5b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/UploadRepository.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.upload.Upload; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface UploadRepository extends MongoRepository { - Page findByEventId(UUID eventId, Pageable pageable); - - Page findByApprovedTrue(Pageable pageable); - - // Admin-specific query methods - Page findByApproved(boolean approved, Pageable pageable); - - Page findByFeatured(boolean featured, Pageable pageable); - - Page findByApprovedTrueAndFeaturedTrue(Pageable pageable); - - Page findByApprovedTrueAndFeaturedFalse(Pageable pageable); - - Page findByUploadedBy(UUID uploadedBy, Pageable pageable); - - // Count methods for user statistics - long countByUploadedBy(UUID uploadedBy); - - long countByUploadedByAndApproved(UUID uploadedBy, boolean approved); - - long countByUploadedByAndFeatured(UUID uploadedBy, boolean featured); - - // Count uploads by user within date range for daily limits - long countByUploadedByAndCreatedDateBetween(UUID uploadedBy, LocalDateTime start, LocalDateTime end); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/UserRepository.java deleted file mode 100644 index c512136..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/UserRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import java.util.Optional; -import java.util.UUID; - -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import com.ibrasoft.lensbridge.model.auth.User; - -@Repository -public interface UserRepository extends MongoRepository { - - Optional findByEmail(String email); - - Optional findByVerificationToken(String verificationToken); - - Boolean existsByEmail(String email); - - Boolean existsByStudentNumber(String studentNumber); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java deleted file mode 100644 index 4a597d1..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.WeekId; -import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface WeeklyContentRepository extends MongoRepository { - - /** - * Find weekly content by year. - */ - List findByWeekIdYear(Integer year); - - /** - * Find weekly content by year and week number. - */ - List findByWeekIdYearAndWeekIdWeekNumber(Integer year, Integer weekNumber); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java new file mode 100644 index 0000000..6aecd38 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.repository.audit; + +import com.ibrasoft.lensbridge.model.audit.AuditEvent; +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface AuditEventRepository extends JpaRepository { + + Page findAllByOrderByTimestampDesc(Pageable pageable); + + List findByAdminIdOrderByTimestampDesc(UUID adminId); + + List findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc(AuditEntityType entityType, UUID entityId); + + Page findByActionOrderByTimestampDesc(AuditAction action, Pageable pageable); + + Page findByTimestampBetweenOrderByTimestampDesc(Instant start, Instant end, Pageable pageable); + + long countByAdminId(UUID adminId); + + long countByAction(AuditAction action); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java new file mode 100644 index 0000000..cd686ea --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java @@ -0,0 +1,33 @@ +package com.ibrasoft.lensbridge.repository.auth; + +import com.ibrasoft.lensbridge.model.auth.RefreshToken; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + List findByUser_Id(UUID userId); + + List findByUser_IdAndRevokedFalse(UUID userId); + + void deleteByUser_Id(UUID userId); + + void deleteByTokenHash(String tokenHash); + + void deleteByExpiryDateBefore(Instant dateTime); + + void deleteByUser_IdAndRevokedTrue(UUID userId); + + long countByUser_IdAndRevokedFalse(UUID userId); + + List findByRevokedTrueAndCreatedDateBefore(Instant minusDays); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java new file mode 100644 index 0000000..10211ab --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.repository.auth; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + /** + * Loads {@code directPermissions} on its own rather than eagerly with the entity, so + * the two collection tables are never joined in the same query. See the field comment + * on {@link User#getDirectPermissions()}. + */ + @Query("select p from User u join u.directPermissions p where u.id = :userId") + Set findDirectPermissions(@Param("userId") UUID userId); + + /** Ids of users holding {@code role}. Used to check nobody is about to be locked out. */ + @Query("select u.id from User u join u.roles r where r in :roles") + Set findIdsByRoleIn(@Param("roles") Collection roles); + + /** Ids of users holding {@code permission} as a direct grant, ignoring anything roles confer. */ + @Query("select u.id from User u join u.directPermissions p where p = :permission") + Set findIdsByDirectPermission(@Param("permission") Permission permission); + + Boolean existsByEmail(String email); + + Boolean existsByStudentNumber(String studentNumber); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java new file mode 100644 index 0000000..d03c8e7 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.repository.auth; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.model.auth.VerificationToken.TokenType; + +@Repository +public interface VerificationTokenRepository extends JpaRepository { + + @Query("select v.user from VerificationToken v where v.tokenHash = :tokenHash") + User findUserByToken(@Param("tokenHash") String tokenHash); + + @Query("select v from VerificationToken v where v.tokenHash = :tokenHash and v.type = :type and v.usedAt is null and v.expiresAt > :now") + Optional findValidToken(@Param("tokenHash") String tokenHash, @Param("type") TokenType type, @Param("now") java.time.Instant now); + + @Query("select v from VerificationToken v where v.user = :user order by v.createdAt desc limit 1") + Optional findLatestByUser(@Param("user") User user); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java new file mode 100644 index 0000000..c006365 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java @@ -0,0 +1,10 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface BoardConfigRepository extends JpaRepository {} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java new file mode 100644 index 0000000..5ae75a8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java @@ -0,0 +1,27 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface BoardEventRepository extends JpaRepository { + + List findAllByOrderByStartTimeAsc(); + + @Query("SELECT e FROM BoardEvent e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY e.startTime ASC") + List findByAudienceOrBoth(@Param("aud") Audience audience); + + @Query("SELECT e FROM BoardEvent e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime >= :now ORDER BY e.startTime ASC") + List findUpcomingByAudienceOrBoth(@Param("aud") Audience audience, @Param("now") Instant now); + + @Query("SELECT e FROM BoardEvent e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime <= :rangeEnd AND e.endTime >= :rangeStart ORDER BY e.startTime ASC") + List findOverlappingForAudienceOrBoth(@Param("aud") Audience audience, @Param("rangeStart") Instant rangeStart, @Param("rangeEnd") Instant rangeEnd); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java new file mode 100644 index 0000000..c0624b3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Repository +public interface DeviceCommandRepository extends JpaRepository { + + /** Pending commands for a device, oldest first — used to flush the queue on session auth. */ + List findByDeviceIdAndStatusOrderByIssuedAtAsc(UUID deviceId, DeviceCommandStatus status); + + /** Recent command history for a device (any status), newest first. */ + List findTop50ByDeviceIdOrderByIssuedAtDesc(UUID deviceId); + + /** Commands that outlived their delivery window — reaper input. */ + List findByStatusAndExpiresAtBefore(DeviceCommandStatus status, Instant cutoff); + + /** + * In-flight commands delivered before {@code cutoff}. A coarse filter: the reaper still + * checks each row against its own {@code deadlineMs} before declaring it timed out. + */ + List findByStatusInAndDeliveredAtBefore( + Collection statuses, Instant cutoff); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java new file mode 100644 index 0000000..066e03c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java @@ -0,0 +1,19 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Device; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface DeviceRepository extends JpaRepository { + + /** Devices whose last heartbeat is older than the cutoff (or never reported). Used by the offline-detection cron. */ + List findByRevokedAtIsNullAndLastHeartbeatBefore(Instant cutoff); + + /** Devices that have been enrolled but never reported a heartbeat. */ + List findByRevokedAtIsNullAndLastHeartbeatIsNull(); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java new file mode 100644 index 0000000..d2897c0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface DeviceTelemetryRepository extends JpaRepository { + + List findByDeviceIdOrderByRecordedAtDesc(UUID deviceId); + + @Modifying + @Query("DELETE FROM DeviceTelemetry t WHERE t.recordedAt < :cutoff") + int deleteOlderThan(@Param("cutoff") Instant cutoff); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java new file mode 100644 index 0000000..50d6c0c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java @@ -0,0 +1,14 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface EnrollmentTokenRepository extends JpaRepository { + + Optional findByTokenHash(byte[] tokenHash); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java new file mode 100644 index 0000000..30ec438 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java @@ -0,0 +1,27 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Poster; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface PosterRepository extends JpaRepository { + + List findAllByOrderByStartTimeDesc(); + + @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY p.startTime DESC") + List findByAudienceOrBoth(@Param("aud") Audience audience); + + @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND p.startTime <= :now AND p.endTime > :now ORDER BY p.startTime DESC") + List findActivePostersForAudienceAt(@Param("now") Instant now, @Param("aud") Audience audience); + + @Query("SELECT p FROM Poster p WHERE p.startTime <= :now AND p.endTime > :now") + List findActivePostersAt(@Param("now") Instant now); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java new file mode 100644 index 0000000..e1dd3c5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java @@ -0,0 +1,30 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.SocialType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface PromotableSocialMediaRepository extends JpaRepository { + + List findAllByOrderByNameAsc(); + + /** + * Socials a board with this audience should show. Ordered by name so the frame sequence + * is stable between assemblies — there is no explicit ordering column, and an unordered + * result would let the same board reshuffle its social frames on every refetch. + */ + @Query("SELECT s FROM PromotableSocialMedia s WHERE (s.audience = :aud OR s.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY s.name ASC") + List findByAudienceOrBoth(@Param("aud") Audience audience); + + List findByTypeOrderByNameAsc(SocialType type); + + boolean existsByTypeAndUrl(SocialType type, String url); +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java new file mode 100644 index 0000000..6ee53fb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java @@ -0,0 +1,16 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.WeeklyContent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface WeeklyContentRepository extends JpaRepository { + Optional findByYearAndWeekNumber(int year, int weekNumber); + boolean existsByYearAndWeekNumber(int year, int weekNumber); + List findByYear(int year); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java new file mode 100644 index 0000000..70639c0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java @@ -0,0 +1,14 @@ +package com.ibrasoft.lensbridge.repository.upload; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.upload.MediaEvent; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface EventsRepository extends JpaRepository { + Optional findEventById(UUID uuid); +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java new file mode 100644 index 0000000..e315321 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java @@ -0,0 +1,44 @@ +package com.ibrasoft.lensbridge.repository.upload; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; +import com.ibrasoft.lensbridge.model.upload.Upload; + +import java.time.Instant; +import java.util.UUID; + +@Repository +public interface UploadRepository extends JpaRepository { + /** + * Recall that deletedAt = null => not deleted. Therefore, this method will only return uploads that are not marked as deleted. + */ + + Upload findByUuidAndDeletedAtIsNull(UUID uuid); + + Page findByDeletedAtIsNull(Pageable pageable); + + Page findByApprovedTrueAndDeletedAtIsNull(Pageable pageable); + + Page findByMediaEventAndApprovedTrueAndDeletedAtIsNull(MediaEvent mediaEvent, Pageable pageable); + + Page findByMediaEventAndDeletedAtIsNull(MediaEvent mediaEvent, Pageable pageable); + + Page findByApprovedAndDeletedAtIsNull(boolean approved, Pageable pageable); + + Page findByFeaturedAndDeletedAtIsNull(boolean featured, Pageable pageable); + + Page findByUploadedByAndDeletedAtIsNull(User uploadedBy, Pageable pageable); + + long countByUploadedByAndDeletedAtIsNull(User uploadedBy); + + long countByUploadedByAndApprovedAndDeletedAtIsNull(User uploadedBy, boolean approved); + + long countByUploadedByAndFeaturedAndDeletedAtIsNull(User uploadedBy, boolean featured); + + long countByUploadedByAndCreatedDateBetweenAndDeletedAtIsNull(User uploadedBy, Instant start, Instant end); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java b/src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java new file mode 100644 index 0000000..46fc20b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java @@ -0,0 +1,53 @@ +package com.ibrasoft.lensbridge.security; + +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.board.commands.CommandKind; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +/** + * Authorizes a device command by its {@code kind}, for use from + * {@code @PreAuthorize("@commandAuthorizer.canIssue(authentication, #request)")}. + *

+ * The required permission depends on the request body, which a plain {@code hasAuthority} + * expression cannot see. Method security runs after argument resolution, so a bean call in + * the expression can — and this keeps the rule declarative at the endpoint instead of + * buried in the dispatcher. + */ +@Component +@Slf4j +public class CommandAuthorizer { + + public boolean canIssue(Authentication authentication, IssueCommandRequest request) { + if (authentication == null || !authentication.isAuthenticated() || request == null) { + return false; + } + + // An unrecognised kind is deliberately allowed through: CommandDispatcher rejects it + // with 400, which is a more useful answer to a typo than 403. Nothing is issued either way. + CommandKind kind = CommandKind.from(request.kind()).orElse(null); + if (kind == null) { + return true; + } + + Permission required = kind.getRequiredPermission(); + boolean allowed = hasAuthority(authentication, required); + if (!allowed) { + log.warn("Denied command {} for {}: missing {}", + kind.getWireName(), authentication.getName(), required.getAuthority()); + } + return allowed; + } + + private static boolean hasAuthority(Authentication authentication, Permission permission) { + for (GrantedAuthority granted : authentication.getAuthorities()) { + if (permission.getAuthority().equals(granted.getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java new file mode 100644 index 0000000..0050f85 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface CurrentUser { +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..01c87d9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.security; + +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final UserService userService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && User.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new UsernameNotFoundException("No authenticated user in security context"); + } + return userService.findByEmail(authentication.getName()) + .orElseThrow(() -> new UsernameNotFoundException( + "Authenticated user not found in database: " + authentication.getName())); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index 3f680b4..43b78cd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -82,11 +82,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.requestMatchers("/api/gallery/**").permitAll() + .authorizeHttpRequests(auth -> auth.requestMatchers("/actuator/health").permitAll() + .requestMatchers("/api/gallery/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/events/**").permitAll() .requestMatchers("/api/musallah/**").permitAll() .requestMatchers("/api/refresh-musallahboard").permitAll() + .requestMatchers("/api/agent/enroll").permitAll() + .requestMatchers("/api/agent/ws").permitAll() + .requestMatchers("/api/dashboard/ws/**").permitAll() + // Disable these endpoints in production; they are only for local dev and CI. + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", + "/v3/api-docs", "/v3/api-docs.yaml", "/v3/api-docs/**").permitAll() .anyRequest().authenticated()); http.authenticationProvider(authenticationProvider()); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java index 6a7221b..0897abc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java @@ -3,14 +3,12 @@ import java.security.Key; import java.util.Date; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; @@ -23,14 +21,11 @@ public class JwtUtils { private String jwtSecret; @Value("${lensbridge.app.jwtExpirationMs}") - private int jwtExpirationMs; + private long jwtExpirationMs; public String generateJwtToken(Authentication authentication) { - - UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal(); - return Jwts.builder() - .setSubject((userPrincipal.getUsername())) + .setSubject(authentication.getName()) .setIssuedAt(new Date()) .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) .signWith(key(), SignatureAlgorithm.HS256) @@ -48,7 +43,7 @@ public String getUserNameFromJwtToken(String token) { public boolean validateJwtToken(String authToken) { try { - Jwts.parserBuilder().setSigningKey(key()).build().parse(authToken); + Jwts.parserBuilder().setSigningKey(key()).build().parseClaimsJws(authToken); return true; } catch (MalformedJwtException e) { logger.error("Invalid JWT token: {}", e.getMessage()); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java b/src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java new file mode 100644 index 0000000..fb358b5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java @@ -0,0 +1,58 @@ +package com.ibrasoft.lensbridge.security.services; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; + +import lombok.RequiredArgsConstructor; + +/** + * Expands a {@link User} into the authority set Spring Security checks against: + *

+ *   authorities = roles (as ROLE_*) ∪ ⋃(role bundles) ∪ direct grants
+ * 
+ */ +@Service +@RequiredArgsConstructor +public class AuthorityResolver { + + private final UserRepository userRepository; + + public Collection resolveAuthorities(User user) { + Set authorities = new HashSet<>(resolvePermissions(user)); + authorities.addAll(user.getRoles()); + return authorities; + } + + public Set resolvePermissions(User user) { + Set permissions = EnumSet.noneOf(Permission.class); + for (Role role : user.getRoles()) { + permissions.addAll(role.getPermissions()); + } + permissions.addAll(directGrants(user.getId())); + return permissions; + } + + public Set resolvePermissions(UUID userId) { + return userRepository.findById(userId) + .map(this::resolvePermissions) + .orElseGet(() -> EnumSet.noneOf(Permission.class)); + } + + private Set directGrants(UUID userId) { + if (userId == null) { + return Set.of(); // unsaved entity, e.g. in tests + } + return userRepository.findDirectPermissions(userId); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java b/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java index 5824328..aa4585b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java @@ -9,8 +9,8 @@ import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; +import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.Map; import org.slf4j.Logger; @@ -122,7 +122,9 @@ private void sendHtmlEmail(String to, String subject, String htmlContent) { private String loadEmailTemplate(String templateName) { try { ClassPathResource resource = new ClassPathResource("templates/" + templateName); - return new String(Files.readAllBytes(resource.getFile().toPath()), StandardCharsets.UTF_8); + try (InputStream in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } } catch (Exception e) { logger.error("Failed to load email template: {} - Error: {}", templateName, e.getMessage(), e); throw new RuntimeException("Failed to load email template", e); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java deleted file mode 100644 index 4cd3b6b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.ibrasoft.lensbridge.security.services; - -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.stream.Collectors; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import com.ibrasoft.lensbridge.model.auth.User; -import com.fasterxml.jackson.annotation.JsonIgnore; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class UserDetailsImpl implements UserDetails { - private static final long serialVersionUID = 1L; - - private UUID id; - - private String firstName; - - private String lastName; - - private String email; - - @JsonIgnore - private String studentNumber; - - @JsonIgnore - private String password; - - private boolean isVerified; - - private Collection authorities; - - public static UserDetailsImpl build(User user) { - List authorities = user.getRoles().stream() - .map(role -> new SimpleGrantedAuthority(role)) - .collect(Collectors.toList()); - - return new UserDetailsImpl( - user.getId(), - user.getFirstName(), - user.getLastName(), - user.getEmail(), - user.getStudentNumber(), - user.getPassword(), - user.isVerified(), - authorities); - } - - public String getUsername() { - return email; - } - - - @Override - public Collection getAuthorities() { - return authorities; - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index 92ce60b..bcd4131 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -7,19 +7,28 @@ import org.springframework.stereotype.Service; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.repository.UserRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; + +import lombok.RequiredArgsConstructor; @Service +@RequiredArgsConstructor public class UserDetailsServiceImpl implements UserDetailsService { - @Autowired - UserRepository userRepository; + private final UserRepository userRepository; + private final AuthorityResolver authorityResolver; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); - return UserDetailsImpl.build(user); + // No collision with lensbridge.model.auth.User + return org.springframework.security.core.userdetails.User + .withUsername(user.getEmail()) + .password(user.getPassword()) + .authorities(authorityResolver.resolveAuthorities(user)) + .disabled(!user.isVerified()) + .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index c63cec6..a76ef8a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -1,15 +1,21 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import com.ibrasoft.lensbridge.repository.AuditEventRepository; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.repository.audit.AuditEventRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -19,12 +25,13 @@ public class AdminAuditService { private final AuditEventRepository auditEventRepository; + private final UserRepository userRepository; public AuditEvent logAuditEvent(AuditEvent event) { try { - event.setTimestamp(LocalDateTime.now()); + event.setTimestamp(Instant.now()); AuditEvent savedEvent = auditEventRepository.save(event); - log.debug("Audit event saved: {} by {} on {}", event.getAction(), event.getAdminEmail(), event.getEntityType()); + log.debug("Audit event saved: {} by {} on {}", event.getAction(), event.getAdmin().getEmail(), event.getTargetEntityType()); return savedEvent; } catch (Exception e) { log.error("Failed to save audit event: {}", e.getMessage(), e); @@ -32,33 +39,68 @@ public AuditEvent logAuditEvent(AuditEvent event) { } } - public AuditEvent logAuditEvent(String adminEmail, AdminAction action, String entityType, UUID entityId, String IPAddress) { - AuditEvent event = AuditEvent.builder().adminEmail(adminEmail).action(action).entityType(entityType).entityId(entityId).timestamp(LocalDateTime.now()).ipAddress(IPAddress).build(); + public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, String IPAddress) { + return logAuditEvent(adminEmail, action, entityType, entityId, IPAddress, null); + } + + /** + * @param details short free-text describing what was done, for actions where + * the action name alone is ambiguous (e.g. the command kind on + * {@link AuditAction#ISSUE_DEVICE_COMMAND}). Truncated to the column width. + */ + public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, + String IPAddress, String details) { + AuditEvent event = AuditEvent.builder() + .admin(userRepository.findByEmail(adminEmail).orElseThrow(() -> new IllegalArgumentException("Admin user not found: " + adminEmail))) + .action(action) + .targetEntityType(toEntityType(entityType)) + .targetEntityId(entityId) + .timestamp(Instant.now()) + .details(truncate(details)) + .ipAddress(IPAddress) + .build(); return logAuditEvent(event); } - public Page getAllAuditEvents(Pageable pageable) { - return auditEventRepository.findAllByOrderByTimestampDesc(pageable); + private static String truncate(String details) { + if (details == null || details.length() <= AuditEvent.DETAILS_MAX) return details; + return details.substring(0, AuditEvent.DETAILS_MAX); + } + + public Page getAllAuditEvents(Pageable pageable) { + return auditEventRepository.findAllByOrderByTimestampDesc(pageable).map(this::toDto); + } + + public List getAuditEventsByAdmin(UUID adminId) { + return auditEventRepository.findByAdminIdOrderByTimestampDesc(adminId).stream().map(this::toDto).toList(); } - public List getAuditEventsByAdmin(UUID adminId) { - return auditEventRepository.findByAdminIdOrderByTimestampDesc(adminId); + public List getAuditEventsByEntity(String entityType, UUID entityId) { + return auditEventRepository.findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc(toEntityType(entityType), entityId).stream().map(this::toDto).toList(); } - public List getAuditEventsByEntity(String entityType, UUID entityId) { - return auditEventRepository.findByEntityTypeAndEntityIdOrderByTimestampDesc(entityType, entityId); + public Page getAuditEventsByAction(AuditAction action, Pageable pageable) { + return auditEventRepository.findByActionOrderByTimestampDesc(action, pageable).map(this::toDto); } - public Page getAuditEventsByAction(AdminAction action, Pageable pageable) { - return auditEventRepository.findByActionOrderByTimestampDesc(action, pageable); + public Page getAuditEventsByDateRange(Instant start, Instant end, Pageable pageable) { + return auditEventRepository.findByTimestampBetweenOrderByTimestampDesc(start, end, pageable).map(this::toDto); } - public Page getAuditEventsByDateRange(LocalDateTime start, LocalDateTime end, Pageable pageable) { - return auditEventRepository.findByTimestampBetweenOrderByTimestampDesc(start, end, pageable); + public Page getFailedOperations(Pageable pageable) { + return Page.empty(pageable); } - public Page getFailedOperations(Pageable pageable) { - return auditEventRepository.findFailedOperationsOrderByTimestampDesc(pageable); + private AuditEventDto toDto(AuditEvent event) { + User admin = event.getAdmin(); + String adminName = admin != null ? admin.getFirstName() + " " + admin.getLastName() : null; + String adminEmail = admin != null ? admin.getEmail() : null; + UUID adminId = admin != null ? admin.getId() : null; + return new AuditEventDto( + event.getId(), event.getAction(), event.getTimestamp(), + adminId, adminName, adminEmail, + event.getTargetEntityType(), event.getTargetEntityId(), event.getDetails(), + event.getIpAddress(), event.getUserAgent()); } // Statistics methods @@ -66,7 +108,19 @@ public long getOperationCountByAdmin(UUID adminId) { return auditEventRepository.countByAdminId(adminId); } - public long getOperationCountByAction(AdminAction action) { + public long getOperationCountByAction(AuditAction action) { return auditEventRepository.countByAction(action); } + + private AuditEntityType toEntityType(String entityType) { + if (entityType == null) return AuditEntityType.EVENT; + return switch (entityType.toLowerCase()) { + case "user" -> AuditEntityType.USER; + case "upload" -> AuditEntityType.UPLOAD; + case "poster", "calendarevent", "boardevent", "musallah board", + "boardconfig", "weeklycontent" -> AuditEntityType.MUSALLAH_BOARD; + case "device" -> AuditEntityType.DEVICE; + default -> AuditEntityType.EVENT; + }; + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 0e8d74d..f8adf2c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -1,26 +1,30 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.request.UpdateBoardConfigRequest; -import com.ibrasoft.lensbridge.dto.request.WeeklyContentRequest; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateTickerRequest; +import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import com.ibrasoft.lensbridge.model.board.Event; -import com.ibrasoft.lensbridge.model.board.WeekId; -import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import com.ibrasoft.lensbridge.repository.BoardConfigRepository; -import com.ibrasoft.lensbridge.repository.EventRepository; -import com.ibrasoft.lensbridge.repository.WeeklyContentRepository; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.board.*; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; +import com.ibrasoft.lensbridge.repository.sql.BoardEventRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.repository.sql.WeeklyContentRepository; import com.ibrasoft.lensbridge.util.Patch; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -31,227 +35,248 @@ public class BoardService { private final BoardConfigRepository boardConfigRepository; - private final EventRepository eventRepository; + private final DeviceRepository deviceRepository; + private final BoardEventRepository boardEventRepository; private final WeeklyContentRepository weeklyContentRepository; + private final BoardStreamHandler boardStream; - private static final Sort SORT_BY_START_TIMESTAMP_ASC = Sort.by(Sort.Direction.ASC, "startTimestamp"); + // ==================== Board Config ==================== - // ==================== Board Config Operations ==================== - - /** - * Get the board configuration for a specific board location. - */ - public Optional getBoardConfig(BoardLocation boardLocation) { - return boardConfigRepository.findById(boardLocation); + public Optional getBoardConfig(UUID deviceId) { + return boardConfigRepository.findById(deviceId); } - /** - * Get board config or throw if not found. - */ - public BoardConfig getBoardConfigOrThrow(BoardLocation boardLocation) { - return boardConfigRepository.findById(boardLocation) + public DeviceConfig getBoardConfigOrThrow(UUID deviceId) { + return boardConfigRepository.findById(deviceId) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, - ErrorResponse.of("Board configuration not found for location: " + boardLocation))); + ErrorResponse.of("Board configuration not found for device: " + deviceId))); } - /** - * Save or update a board configuration. - */ - public BoardConfig saveBoardConfig(BoardConfig boardConfig) { - BoardConfig saved = boardConfigRepository.save(boardConfig); - log.info("Saved board config for location: {}", boardConfig.getBoardLocation()); + public DeviceConfig saveBoardConfig(UUID deviceId, DeviceConfig deviceConfig) { + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Device not found: " + deviceId))); + deviceConfig.setDevice(device); + DeviceConfig saved = boardConfigRepository.save(deviceConfig); + log.info("Saved board config for device: {}", deviceId); + boardStream.configChanged(deviceId); return saved; } - /** - * Update an existing board configuration with partial updates. - */ - public BoardConfig updateBoardConfig(BoardLocation boardLocation, UpdateBoardConfigRequest request) { - BoardConfig existing = getBoardConfigOrThrow(boardLocation); - + public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest request) { + DeviceConfig existing = getBoardConfigOrThrow(deviceId); Patch.apply(request.getLocation(), existing::setLocation); - Patch.apply(request.getPosterCycleInterval(), existing::setPosterCycleInterval); - Patch.apply(request.getRefreshAfterIshaaMinutes(), existing::setRefreshAfterIshaaMinutes); Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); - Patch.apply(request.getDarkModeMinutesAfterIsha(), existing::setDarkModeMinutesAfterIsha); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); - - BoardConfig saved = boardConfigRepository.save(existing); - log.info("Updated board config for location: {}", boardLocation); + applyAgendaDuration(existing, request.getAgendaDurationSeconds()); + Patch.apply(request.getNextPrayerDurationSeconds(), existing::setNextPrayerDurationSeconds); + DeviceConfig saved = boardConfigRepository.save(existing); + log.info("Updated board config for device: {}", deviceId); + boardStream.configChanged(deviceId); return saved; } /** - * Get all board configurations. + * Stores the agenda duration, translating the {@code 0} sentinel back into null ("auto"). + *

+ * The 1–4 second gap is rejected here rather than on the DTO: expressing "zero or at least + * five" in bean validation needs an {@code @AssertTrue} method, which Jackson and springdoc + * would both surface as a phantom boolean property on the request schema. */ - public List getAllBoardConfigs() { + private void applyAgendaDuration(DeviceConfig existing, Integer requested) { + if (requested == null) return; // omitted — leave whatever is stored + if (requested != 0 && requested < DeviceConfig.MIN_SLIDE_SECONDS) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("agendaDurationSeconds must be 0 (auto) or between " + + DeviceConfig.MIN_SLIDE_SECONDS + " and " + + DeviceConfig.MAX_SLIDE_SECONDS + " seconds")); + } + existing.setAgendaDurationSeconds(requested == 0 ? null : requested); + } + + public DeviceConfig updateTicker(UUID deviceId, UpdateTickerRequest request) { + DeviceConfig existing = getBoardConfigOrThrow(deviceId); + Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); + Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); + DeviceConfig saved = boardConfigRepository.save(existing); + log.info("Updated ticker for device: {}", deviceId); + boardStream.configChanged(deviceId); + return saved; + } + + public List getAllBoardConfigs() { return boardConfigRepository.findAll(); } - // ==================== Weekly Content Operations ==================== + // ==================== Weekly Content ==================== - /** - * Get all weekly content. - */ public List getAllWeeklyContent() { return weeklyContentRepository.findAll(); } - /** - * Get weekly content for a specific week. - */ - public Optional getWeeklyContent(WeekId weekId) { - return weeklyContentRepository.findById(weekId); - } - - /** - * Get weekly content for a specific year and week number. - */ public Optional getWeeklyContent(int year, int weekNumber) { - return weeklyContentRepository.findById(new WeekId(year, weekNumber)); + return weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber); } - /** - * Get weekly content or throw if not found. - */ public WeeklyContent getWeeklyContentOrThrow(int year, int weekNumber) { - WeekId weekId = new WeekId(year, weekNumber); - return weeklyContentRepository.findById(weekId) + return weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); } /** - * Get the current week's content. + * @param today the board's local date. Callers with a device in hand must pass its date, + * not the server's — near midnight the two disagree and the board would + * show the wrong week's jummah times. */ + public Optional getWeeklyContentFor(LocalDate today) { + WeekId week = WeekId.fromDate(today); + return weeklyContentRepository.findByYearAndWeekNumber(week.getYear(), week.getWeekNumber()); + } + + /** Current week in the server's zone. For callers with no device context. */ public Optional getCurrentWeeklyContent() { - WeekId currentWeek = WeekId.fromDate(LocalDate.now()); - return weeklyContentRepository.findById(currentWeek); + return getWeeklyContentFor(LocalDate.now()); } - /** - * Get weekly content for a specific year. - */ public List getWeeklyContentByYear(int year) { - return weeklyContentRepository.findByWeekIdYear(year); + return weeklyContentRepository.findByYear(year); } - /** - * Create or update weekly content. - */ - public WeeklyContent saveWeeklyContent(WeeklyContentRequest request) { - WeekId weekId = new WeekId(request.getYear(), request.getWeekNumber()); - - WeeklyContent content = weeklyContentRepository.findById(weekId) - .orElse(WeeklyContent.builder().weekId(weekId).build()); + @Transactional + public WeeklyContent saveWeeklyContent(int year, int weekNumber, WeeklyContentRequest request) { + WeeklyContent content = weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) + .orElseGet(() -> WeeklyContent.builder().year(year).weekNumber(weekNumber).build()); + + if (request.getQuotes() != null) { + content.getQuotes().clear(); + for (WeeklyContentRequest.QuoteEntry entry : request.getQuotes()) { + IslamicQuote quote = IslamicQuote.builder() + .weeklyContent(content) + .kind(entry.kind()) + .arabic(entry.arabic()) + .transliteration(entry.transliteration()) + .translation(entry.translation()) + .reference(entry.reference()) + .durationSeconds(entry.durationSeconds()) + .build(); + content.getQuotes().add(quote); + } + } - Patch.apply(request.getVerse(), content::setVerse); - Patch.apply(request.getHadith(), content::setHadith); - Patch.apply(request.getJummahPrayer(), content::setJummahPrayer); + if (request.getJummahPrayers() != null) { + content.getJummahPrayers().clear(); + for (WeeklyContentRequest.JummahSlot slot : request.getJummahPrayers()) { + JummahPrayer prayer = JummahPrayer.builder() + .weeklyContent(content) + .prayerTime(slot.prayerTime() != null + ? java.time.LocalTime.parse(slot.prayerTime(), DateTimeFormatter.ofPattern("HH:mm")) + : null) + .khatib(slot.khatib()) + .room(slot.room()) + .build(); + content.getJummahPrayers().add(prayer); + } + } WeeklyContent saved = weeklyContentRepository.save(content); - log.info("Saved weekly content for week {} of {}", request.getWeekNumber(), request.getYear()); + log.info("Saved weekly content for week {} of {}", weekNumber, year); + boardStream.contentChanged("weekly-content"); return saved; } - /** - * Delete weekly content. - */ - public void deleteWeeklyContent(int year, int weekNumber) { - WeekId weekId = new WeekId(year, weekNumber); - if (!weeklyContentRepository.existsById(weekId)) { - throw new ApiResponseException( - HttpStatus.NOT_FOUND, - ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year)); - } - weeklyContentRepository.deleteById(weekId); + /** @return the id of the deleted row, so callers can record what was destroyed. */ + public UUID deleteWeeklyContent(int year, int weekNumber) { + WeeklyContent content = weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); + UUID deletedId = content.getId(); + weeklyContentRepository.delete(content); log.info("Deleted weekly content for week {} of {}", weekNumber, year); + boardStream.contentChanged("weekly-content"); + return deletedId; } - // ==================== Event Operations ==================== + // ==================== Events ==================== - /** - * Get all events. - */ - public List getAllEvents() { - return eventRepository.findAllByOrderByStartTimestampAsc(); + public List getAllEvents() { + return boardEventRepository.findAllByOrderByStartTimeAsc(); } - /** - * Get an event by ID. - */ - public Event getEventById(UUID eventId) { - return eventRepository.findById(eventId) + public BoardEvent getEventById(UUID eventId) { + return boardEventRepository.findById(eventId) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Event not found with id: " + eventId))); } - /** - * Get all events for a specific board location. - * Returns events that match the board's audience or BOTH. - */ - public List getEventsForBoard(BoardLocation boardLocation) { - return eventRepository.findByAudienceOrBoth(boardLocation.audience(), SORT_BY_START_TIMESTAMP_ASC); + public List getEventsForAudience(Audience audience) { + return boardEventRepository.findByAudienceOrBoth(audience); } - /** - * Get upcoming events for a specific board location. - * Returns events with startTimestamp >= now. - */ - public List getUpcomingEventsForBoard(BoardLocation boardLocation) { - long nowTimestamp = Instant.now().toEpochMilli(); - return eventRepository.findUpcomingByAudienceOrBoth(boardLocation.audience(), nowTimestamp, SORT_BY_START_TIMESTAMP_ASC); + public List getUpcomingEventsForAudience(Audience audience) { + return boardEventRepository.findUpcomingByAudienceOrBoth(audience, Instant.now()); } - /** - * Get events for a specific board within a time range. - */ - public List getEventsForBoardInRange(BoardLocation boardLocation, long rangeStart, long rangeEnd) { - return eventRepository.findOverlappingForAudienceOrBoth(boardLocation.audience(), rangeStart, rangeEnd, SORT_BY_START_TIMESTAMP_ASC); + public List getEventsForAudienceInRange(Audience audience, Instant rangeStart, Instant rangeEnd) { + return boardEventRepository.findOverlappingForAudienceOrBoth(audience, rangeStart, rangeEnd); } - /** - * Create a new event. - */ - public Event createEvent(Event event) { - if (event.getId() == null) { - event.setId(UUID.randomUUID()); - } - Event saved = eventRepository.save(event); + public BoardEvent createEvent(CreateCalendarEventRequest request) { + BoardEvent boardEvent = BoardEvent.builder() + .name(request.getName()) + .description(request.getDescription()) + .location(request.getLocation()) + .startTime(Instant.ofEpochMilli(request.getStartEpochMs())) + .endTime(Instant.ofEpochMilli(request.getEndEpochMs())) + .allDay(request.getAllDay()) + .audience(request.getAudience()) + .build(); + BoardEvent saved = boardEventRepository.save(boardEvent); log.info("Created event: id={}, name={}", saved.getId(), saved.getName()); + boardStream.contentChanged("events"); return saved; } - /** - * Update an existing event. - */ - public Event updateEvent(UUID eventId, Event updates) { - Event existing = getEventById(eventId); - - Patch.apply(updates.getName(), existing::setName); - Patch.apply(updates.getDescription(), existing::setDescription); - Patch.apply(updates.getLocation(), existing::setLocation); - Patch.apply(updates.getStartTimestamp(), existing::setStartTimestamp); - Patch.apply(updates.getEndTimestamp(), existing::setEndTimestamp); - Patch.apply(updates.getAllDay(), existing::setAllDay); - Patch.apply(updates.getAudience(), existing::setAudience); - - Event saved = eventRepository.save(existing); + public BoardEvent updateEvent(UUID eventId, UpdateCalendarEventRequest request) { + BoardEvent existing = getEventById(eventId); + Patch.apply(request.getName(), existing::setName); + Patch.apply(request.getDescription(), existing::setDescription); + Patch.apply(request.getLocation(), existing::setLocation); + Patch.apply(request.getStartTime(), existing::setStartTime); + Patch.apply(request.getEndTime(), existing::setEndTime); + Patch.apply(request.getAllDay(), existing::setAllDay); + Patch.apply(request.getAudience(), existing::setAudience); + BoardEvent saved = boardEventRepository.save(existing); log.info("Updated event: id={}", eventId); + boardStream.contentChanged("events"); return saved; } - /** - * Delete an event. - */ public void deleteEvent(UUID eventId) { - Event event = getEventById(eventId); - eventRepository.delete(event); + BoardEvent boardEvent = getEventById(eventId); + boardEventRepository.delete(boardEvent); log.info("Deleted event: id={}", eventId); + boardStream.contentChanged("events"); + } + + // ================== Device Management Changes ==================== + + public Device updateDevice(UUID deviceId, UpdateDeviceRequest request) { + Device existing = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Device not found: " + deviceId))); + Patch.apply(request.getDisplayName(), name -> existing.setDisplayName(name.trim())); + Patch.apply(request.getAudience(), existing::setAudience); + log.info("Updated device: id={}", deviceId); + return deviceRepository.save(existing); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java deleted file mode 100644 index 814b47b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.config.UploadProperties; -import com.ibrasoft.lensbridge.dto.response.DailyLimitErrorResponse; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; -import com.ibrasoft.lensbridge.dto.response.FileSizeErrorResponse; -import com.ibrasoft.lensbridge.dto.response.PresignedUploadResponse; -import com.ibrasoft.lensbridge.dto.response.UploadCompletionResponse; -import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.upload.Upload; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.util.unit.DataSize; - -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Slf4j -public class DirectUploadService { - - private final UploadService uploadService; - private final R2StorageService r2StorageService; - private final UploadProperties uploadProperties; - private final EventsService eventsService; - private final ThumbnailService thumbnailService; - - public PresignedUploadResponse createPresignedUpload( - UUID eventId, - String filename, - String contentType, - long fileSize, - String expectedSha256, - UUID userId, - String highestRole) { - - validateEventAcceptingUploads(eventId); - enforceDailyLimit(userId, highestRole); - validateContentType(contentType); - - DataSize maxAllowed = uploadProperties.getMaxSizeForRole(highestRole); - if (fileSize > maxAllowed.toBytes()) { - throw new ApiResponseException( - HttpStatus.PAYLOAD_TOO_LARGE, - FileSizeErrorResponse.of( - "File size exceeds limit for role " + highestRole, - maxAllowed.toMegabytes() + "MB", - (fileSize / 1024 / 1024) + "MB"), - "File size exceeds role limit"); - } - - String objectKey = resolveObjectKey(contentType); - String presignedUrl = r2StorageService.generatePresignedUploadUrl( - objectKey, - contentType, - expectedSha256, - fileSize); - - PresignedUploadResponse.PresignedUploadResponseBuilder responseBuilder = PresignedUploadResponse.builder() - .uploadUrl(presignedUrl) - .objectKey(objectKey) - .eventId(eventId) - .method("PUT") - .contentType(contentType) - .expiresInMinutes(15); - - if (expectedSha256 != null) { - responseBuilder.expectedSha256(expectedSha256); - } - - PresignedUploadResponse response = responseBuilder.build(); - - log.info("Generated presigned upload URL for event '{}', user role '{}', file: '{}', size: {}MB", - eventId, highestRole, filename, fileSize / 1024 / 1024); - - return response; - } - - public UploadCompletionResponse completeDirectUpload( - UUID eventId, - String objectKey, - String filename, - String contentType, - long fileSize, - String instagramHandle, - String description, - boolean anon, - String expectedSha256, - UUID userId) { - - if (!r2StorageService.objectExists(objectKey)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("File not found in storage: " + objectKey), - "File not found in storage"); - } - - // TODO: Implement size and hash verification methods in R2StorageService - // long storedSize = r2StorageService.getObjectContentLength(objectKey); - // if (storedSize != fileSize) { - // throw new ApiResponseException( - // HttpStatus.BAD_REQUEST, - // ErrorResponse.of("File size mismatch in storage"), - // "File size mismatch in storage"); - // } - - // if (expectedSha256 != null) { - // try { - // String actualSha256 = r2StorageService.calculateSha256Hash(objectKey); - // if (!actualSha256.equalsIgnoreCase(expectedSha256)) { - // throw new ApiResponseException( - // HttpStatus.BAD_REQUEST, - // ErrorResponse.of("File integrity verification failed"), - // "File integrity verification failed"); - // } - // } catch (ApiResponseException e) { - // throw e; - // } catch (Exception e) { - // throw new ApiResponseException( - // HttpStatus.BAD_REQUEST, - // ErrorResponse.of("Failed to verify file integrity: " + e.getMessage()), - // "Failed to verify file integrity"); - // } - // } - - Upload upload = uploadService.createDirectUpload( - objectKey, - filename, - contentType, - eventId, - description, - instagramHandle, - anon, - userId); - - if (contentType != null && contentType.startsWith("image")) { - thumbnailService.generateThumbnailAsync(upload.getUuid(), objectKey); - log.debug("Triggered async thumbnail generation for upload: {}", upload.getUuid()); - } - - UploadCompletionResponse response = UploadCompletionResponse.builder() - .uploadId(upload.getUuid()) - .objectKey(objectKey) - .eventId(eventId) - .verified(true) - .fileSize(fileSize) - .build(); - - log.info("Successfully completed direct upload for event '{}': {}", eventId, upload.getUuid()); - return response; - } - - private void validateEventAcceptingUploads(UUID eventId) { - if (!eventsService.isEventAcceptingUploads(eventId)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("Event is not accepting uploads"), - "Event is not accepting uploads"); - } - } - - private void enforceDailyLimit(UUID userId, String role) { - int dailyLimit = uploadProperties.getDailyLimitForRole(role); - if (uploadService.hasReachedDailyLimit(userId, dailyLimit)) { - long uploadCount = uploadService.countUploadsToday(userId); - throw new ApiResponseException( - HttpStatus.TOO_MANY_REQUESTS, - DailyLimitErrorResponse.of( - "Daily upload limit exceeded", - dailyLimit, - uploadCount, - role), - "Daily upload limit exceeded"); - } - } - - private void validateContentType(String contentType) { - if (contentType == null || !uploadProperties.getAllowedFileTypes().contains(contentType)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("Content type not allowed: " + contentType), - "Content type not allowed"); - } - } - - private String resolveObjectKey(String contentType) { - String folder; - if (contentType != null && contentType.startsWith("image")) { - folder = "images/"; - } else if (contentType != null && contentType.startsWith("video")) { - folder = "videos/"; - } else { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("Unsupported content type: " + contentType), - "Unsupported content type"); - } - return folder + UUID.randomUUID(); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index 1bfb144..39c207e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -1,14 +1,19 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.repository.EventsRepository; +import com.ibrasoft.lensbridge.dto.upload.request.CreateEventDto; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; +import com.ibrasoft.lensbridge.model.upload.EventStatus; +import com.ibrasoft.lensbridge.repository.upload.EventsRepository; + import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -19,79 +24,84 @@ public class EventsService { private final EventsRepository eventsRepository; - public Event createEvent(Event event) { - if (event.getId() == null) { - event.setId(UUID.randomUUID()); - } - Event saved = eventsRepository.save(event); - this.cleanUpOldEvents(); - return saved; + @Value("${lensbridge.app.daysCutoffForPastEvent:7}") + private int daysCutoffForPastEvent; + + public MediaEvent createEvent(CreateEventDto event) { + MediaEvent newMediaEvent = MediaEvent.builder() + .name(event.getName()) + .date(event.getDate()) + .status(computeStatus(event.getDate(), Instant.now())) + .build(); + + return eventsRepository.save(newMediaEvent); } - public Event createEvent(String name, LocalDateTime date) { - Event event = Event.builder().id(UUID.randomUUID()).name(name).date(date).status(EventStatus.UPCOMING).build(); - return createEvent(event); + private EventStatus computeStatus(Instant eventDate, Instant now) { + ZoneId zone = ZoneId.systemDefault(); + LocalDate today = LocalDate.ofInstant(now, zone); + LocalDate eventDay = LocalDate.ofInstant(eventDate, zone); + + if (eventDay.isBefore(today)) return EventStatus.PAST; + if (eventDay.isAfter(today)) return EventStatus.UPCOMING; + return now.isBefore(eventDate) ? EventStatus.UPCOMING : EventStatus.ONGOING; } - public List getAllEvents() { + public List getAllEvents() { return eventsRepository.findAll(); } - public Optional getEventById(UUID id) { + public List getPublicVisibleEvents() { + Instant cutoff = Instant.now().minus(java.time.Duration.ofDays(daysCutoffForPastEvent)); + return eventsRepository.findAll().stream() + .filter(e -> e.getStatus() == EventStatus.ONGOING || + (e.getStatus() == EventStatus.PAST && e.getDate().isAfter(cutoff))) + .toList(); + } + + public MediaEvent createEvent(String eventName, Instant eventDate) { + return createEvent(CreateEventDto.builder().name(eventName).date(eventDate).build()); + } + + public Optional getEventById(UUID id) { return eventsRepository.findById(id); } - public Event updateEvent(Event event) { - return eventsRepository.save(event); + public MediaEvent updateEvent(MediaEvent mediaEvent) { + return eventsRepository.save(mediaEvent); } public void deleteEvent(UUID id) { eventsRepository.deleteById(id); } - @Scheduled(cron = "0 0 0 * * ?") // Runs daily at midnight - public void cleanUpOldEvents() { - this.cleanUpOldEvents(LocalDateTime.now()); + @Scheduled(cron = "0 0 0 * * ?") + void cleanUpOldEvents() { + this.cleanUpOldEvents(Instant.now()); } public boolean isEventAcceptingUploads(UUID eventId) { - Optional eventOpt = eventsRepository.findById(eventId); + Optional eventOpt = eventsRepository.findById(eventId); if (eventOpt.isEmpty()) { return false; } - Event event = eventOpt.get(); + MediaEvent mediaEvent = eventOpt.get(); - return event.getStatus() == EventStatus.ONGOING || - (event.getStatus() == EventStatus.PAST && - event.getDate().isAfter(LocalDateTime.now().minusDays(7))); + return mediaEvent.getStatus() == EventStatus.ONGOING || + (mediaEvent.getStatus() == EventStatus.PAST && + mediaEvent.getDate().isAfter(Instant.now().minus(java.time.Duration.ofDays(7)))); } - public void cleanUpOldEvents(LocalDateTime now) { - LocalDate today = now.toLocalDate(); - List allEvents = eventsRepository.findAll(); + public void cleanUpOldEvents(Instant now) { + List allMediaEvents = eventsRepository.findAll(); - for (Event event : allEvents) { - if (event.getDate() == null) + for (MediaEvent mediaEvent : allMediaEvents) { + if (mediaEvent.getDate() == null) continue; - LocalDate eventDate = event.getDate().toLocalDate(); - EventStatus newStatus; - - if (eventDate.isBefore(today)) { - newStatus = EventStatus.PAST; - } else if (eventDate.isAfter(today)) { - newStatus = EventStatus.UPCOMING; - } else { - if (!now.isBefore(event.getDate())) { - newStatus = EventStatus.ONGOING; - } else { - newStatus = EventStatus.UPCOMING; - } - } - - event.setStatus(newStatus); - eventsRepository.save(event); + mediaEvent.setStatus(computeStatus(mediaEvent.getDate(), now)); + eventsRepository.save(mediaEvent); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 7e236aa..0ab1cf9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -1,17 +1,19 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.repository.EventsRepository; -import com.ibrasoft.lensbridge.repository.UploadRepository; +import com.ibrasoft.lensbridge.repository.upload.EventsRepository; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.UUID; @@ -25,80 +27,59 @@ public class GalleryService { private final EventsRepository eventsRepository; private final R2StorageService r2StorageService; - /** - * Get all approved gallery items with secure URLs for public access. - * Only approved content is returned with time-limited signed URLs. - */ public Page getAllApprovedGalleryItems(Pageable pageable) { - Page uploads = uploadRepository.findByApprovedTrue(pageable); - return uploads.map(this::convertToPublicGalleryItem); // Use secure URLs for public + return uploadRepository.findByApprovedTrueAndDeletedAtIsNull(pageable) + .map(u -> toGalleryItem(u, false)); } - /** - * Get all gallery items (admin only) with secure URLs. - * This method should only be called by admin users. - */ public Page getAllGalleryItems(Pageable pageable) { - Page uploads = uploadRepository.findAll(pageable); - return uploads.map(this::convertToAdminGalleryItem); // Admin can see all with secure URLs + return uploadRepository.findByDeletedAtIsNull(pageable) + .map(u -> toGalleryItem(u, true)); } - /** - * Get gallery items by event with secure URLs (public access, approved only). - */ public Page getGalleryItemsByEvent(UUID eventId, Pageable pageable) { - // For public event galleries, only return approved content - Page uploads = uploadRepository.findByEventId(eventId, pageable); - return uploads.map(this::convertToPublicGalleryItem); + MediaEvent mediaEvent = eventsRepository.findById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + return uploadRepository.findByMediaEventAndApprovedTrueAndDeletedAtIsNull(mediaEvent, pageable) + .map(u -> toGalleryItem(u, false)); } - /** - * Convert upload to gallery item for public access (approved content only). - */ - private GalleryItemDto convertToPublicGalleryItem(Upload upload) { - if (!upload.isApproved()) { - throw new SecurityException("Cannot generate public gallery item for unapproved content"); - } - return convertToGalleryItem(upload, false); // false = not admin + public Page getUserGallery(UUID userId, Pageable pageable) { + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable) + .map(upload -> { + if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { + throw new SecurityException("User can only access their own uploads"); + } + GalleryItemDto item = toGalleryItem(upload, true); + // always show real name for own uploads regardless of anon flag + item.setAuthor(user.getFirstName() + " " + user.getLastName()); + return item; + }); } - /** - * Convert upload to gallery item for admin access (can see all content). - */ - private GalleryItemDto convertToAdminGalleryItem(Upload upload) { - return convertToGalleryItem(upload, true); // true = admin - } - - /** - * Convert upload to gallery item with secure URLs. - * @param upload The upload to convert - * @param isAdmin Whether the requesting user is an admin - * @return GalleryItemDto with secure URLs - */ - private GalleryItemDto convertToGalleryItem(Upload upload, boolean isAdmin) { + private GalleryItemDto toGalleryItem(Upload upload, boolean isAdmin) { GalleryItemDto item = new GalleryItemDto(); - User uploader = userService.findById(upload.getUploadedBy()) - .orElse(null); - - // Basic info + User uploader = upload.getUploadedBy(); + item.setId(upload.getUuid().toString()); - - // Generate secure URL instead of using direct R2 URL + item.setTitle(upload.getUploadDescription() != null ? upload.getUploadDescription() : "Untitled"); + item.setFeatured(upload.isFeatured()); + item.setType(upload.getContentType().toString().toLowerCase()); + try { - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String secureUrl = r2StorageService.getSecureUrl(objectKey, upload.isApproved(), isAdmin); - item.setSrc(secureUrl); + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + item.setSrc(r2StorageService.getSecureUrl(key, upload.isApproved(), isAdmin)); } catch (SecurityException e) { log.warn("Access denied for upload {}: {}", upload.getUuid(), e.getMessage()); - item.setSrc(null); // Don't provide URL if access is denied + item.setSrc(null); } catch (Exception e) { - log.error("Failed to generate secure URL for upload {}: {}", upload.getUuid(), e.getMessage()); - item.setSrc(null); // Don't provide URL if generation fails + log.error("Failed to generate URL for upload {}: {}", upload.getUuid(), e.getMessage()); + item.setSrc(null); } - - item.setTitle(upload.getUploadDescription() != null ? upload.getUploadDescription() : "Untitled"); - item.setFeatured(upload.isFeatured()); - item.setType(upload.getContentType().toString().toLowerCase()); + + item.setThumbnail(resolveSecureThumbnail(upload, isAdmin)); if (upload.isAnon()) { item.setAuthor("Anonymous"); @@ -108,58 +89,29 @@ private GalleryItemDto convertToGalleryItem(Upload upload, boolean isAdmin) { item.setAuthor("Unknown"); } - // Generate secure thumbnail using the stored thumbnail key - String thumbnail = generateSecureThumbnail(upload, isAdmin); - item.setThumbnail(thumbnail); - - String eventName = getEventName(upload.getEventId()); - item.setEvent(eventName); - - // Format date - String date = formatDate(upload); - item.setDate(date); - + item.setEvent(upload.getMediaEvent().getName()); + + item.setDate(upload.getCreatedDate() != null + ? DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.systemDefault()).format(upload.getCreatedDate()) + : "Unknown"); + return item; } - /** - * Generate secure thumbnail URL with access control. - * Uses the stored thumbnail key if available, otherwise falls back to original image. - */ - private String generateSecureThumbnail(Upload upload, boolean isAdmin) { - if (upload == null || upload.getFileUrl() == null) return null; - + private String resolveSecureThumbnail(Upload upload, boolean isAdmin) { + if (upload.getFileUrl() == null) return null; try { - // Use stored thumbnail key if available - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - return r2StorageService.getSecureThumbnailUrl(thumbnailKey, upload.isApproved(), isAdmin); + String thumbKey = upload.getThumbnailUrl(); + if (thumbKey != null && !thumbKey.isBlank()) { + return r2StorageService.getSecureThumbnailUrl(thumbKey, upload.isApproved(), isAdmin); } - - // Fallback: use original image URL as thumbnail - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - return r2StorageService.getSecureUrl(objectKey, upload.isApproved(), isAdmin); + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + return r2StorageService.getSecureUrl(key, upload.isApproved(), isAdmin); } catch (SecurityException e) { - log.warn("Access denied for thumbnail: {}", e.getMessage()); return null; } catch (Exception e) { - log.error("Failed to generate secure thumbnail for upload {}: {}", upload.getUuid(), e.getMessage()); + log.error("Failed to generate thumbnail URL for upload {}: {}", upload.getUuid(), e.getMessage()); return null; } } - - private String getEventName(UUID eventId) { - if (eventId == null) return "General"; - - return eventsRepository.findEventById(eventId) - .map(Event::getName) - .orElse("General"); - } - - private String formatDate(Upload upload) { - if (upload.getCreatedDate() != null) { - return upload.getCreatedDate().format(DateTimeFormatter.ISO_LOCAL_DATE); - } - return "Unknown"; - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java b/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java new file mode 100644 index 0000000..61a803a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java @@ -0,0 +1,73 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.ImageProcessingProperties; +import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ImageProcessingService { + + private final R2StorageService r2StorageService; + private final UploadRepository uploadRepository; + private final ImageProcessingProperties properties; + + @Async + public CompletableFuture generateThumbnail(Upload upload) { + try { + String objectKey = upload.getFileUrl(); + log.info("Generating thumbnail for upload {} (key: {})", upload.getUuid(), objectKey); + + byte[] originalBytes = r2StorageService.getObjectBytes(objectKey); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Thumbnails.of(new ByteArrayInputStream(originalBytes)) + .size(properties.getThumbnailWidth(), properties.getThumbnailHeight()) + .keepAspectRatio(true) + .outputQuality(properties.getThumbnailQuality()) + .outputFormat("jpg") + .toOutputStream(out); + + byte[] thumbnailBytes = out.toByteArray(); + String thumbnailKey = resolveThumbnailKey(objectKey); + + r2StorageService.putObject( + thumbnailKey, + new ByteArrayInputStream(thumbnailBytes), + thumbnailBytes.length, + "image/jpeg"); + + Optional fresh = uploadRepository.findById(upload.getUuid()); + if (fresh.isPresent()) { + fresh.get().setThumbnailUrl(thumbnailKey); + uploadRepository.save(fresh.get()); + } + + log.info("Thumbnail generated: {} -> {} ({} bytes)", objectKey, thumbnailKey, thumbnailBytes.length); + return CompletableFuture.completedFuture(thumbnailKey); + + } catch (Exception e) { + log.error("Failed to generate thumbnail for upload {}: {}", upload.getUuid(), e.getMessage(), e); + return CompletableFuture.failedFuture(e); + } + } + + private String resolveThumbnailKey(String objectKey) { + String filename = objectKey.contains("/") + ? objectKey.substring(objectKey.lastIndexOf('/') + 1) + : objectKey; + return properties.getThumbnailFolder() + filename; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java b/src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java deleted file mode 100644 index df85039..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.exception.ImageProcessingException; -import com.ibrasoft.lensbridge.exception.VideoProcessingException; -import com.ibrasoft.lensbridge.exception.VideoTooLongException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.*; - -@Service -public class MediaConversionService { - - @Value("${uploads.video.maxduration}") - private double MAX_DURATION_SECONDS; - - private static String magickExecutable; - - @Value("${conversion.magick.executable:magick}") - public void setMagickExecutable(String magickExecutable) { - MediaConversionService.magickExecutable = magickExecutable; - } - - /** - * Converts a HEIC image to JPEG using ImageMagick. - * - * @param inputHeic the HEIC file - * @param outputJpg the desired JPEG file - * @throws IOException if process fails - * @throws InterruptedException if process is interrupted - */ - public static void convertHeicToJpg(File inputHeic, File outputJpg) throws IOException, InterruptedException { - - if (!inputHeic.exists()) { - throw new IllegalArgumentException("Input file does not exist: " + inputHeic.getAbsolutePath()); - } - - if (outputJpg.getParentFile() != null) { - outputJpg.getParentFile().mkdirs(); - } - - ProcessBuilder pb = new ProcessBuilder( - magickExecutable, - inputHeic.getAbsolutePath(), - outputJpg.getAbsolutePath() - ); - - pb.redirectErrorStream(true); - Process process = pb.start(); - - // wait for da fork - // For the CSC209 Students, this is the equivalent of waitpid ;P - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new ImageProcessingException("ImageMagick failed with exit code " + exitCode); - } - } - - - public File transcodeToHevc(InputStream inputStream, String fileName) throws IOException, InterruptedException { - File inputFile = File.createTempFile("input-", ".mp4"); - File outputFile = File.createTempFile("output-", ".mp4"); - - // Save stream to file - try (FileOutputStream fos = new FileOutputStream(inputFile)) { - inputStream.transferTo(fos); - } - - // Enforce max duration - double duration = getVideoDurationInSeconds(inputFile); - if (duration > MAX_DURATION_SECONDS) { - inputFile.delete(); - outputFile.delete(); - throw new VideoTooLongException("Video is too long! Maximum allowed is " + MAX_DURATION_SECONDS + " seconds."); - } - - ProcessBuilder pb = new ProcessBuilder( - "ffmpeg", - "-hwaccel", "cuda", // optional: GPU-assisted decoding - "-i", inputFile.getAbsolutePath(), - "-c:v", "hevc_nvenc", // GPU-based HEVC encoder (NVENC) - "-preset", "p5", // p1 = fastest, p7 = slowest (best quality) - "-cq", "28", // Constant quality (lower = better) - "-c:a", "aac", "-b:a", "128k", - "-y", - outputFile.getAbsolutePath() - ); - - Process process = pb.inheritIO().start(); - int exitCode = process.waitFor(); - - inputFile.delete(); - - if (exitCode != 0) { - outputFile.delete(); - throw new VideoProcessingException("FFmpeg failed with exit code " + exitCode); - } - - return outputFile; - } - - private double getVideoDurationInSeconds(File file) throws IOException, InterruptedException { - ProcessBuilder pb = new ProcessBuilder( - "ffprobe", "-v", "error", - "-select_streams", "v:0", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", - file.getAbsolutePath() - ); - - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line = reader.readLine(); - int exitCode = process.waitFor(); - - if (exitCode != 0 || line == null) { - throw new VideoProcessingException("Failed to get video duration"); - } - - return Double.parseDouble(line.trim()); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java b/src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java new file mode 100644 index 0000000..d4df334 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java @@ -0,0 +1,81 @@ +package com.ibrasoft.lensbridge.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Thin proxy around the OpenWeatherMap "current weather" API. The backend holds + * the API key and forwards the upstream JSON response verbatim so clients (e.g. + * the Musallah board) never see the key. + * + *

Weather changes slowly and must never sit in the board's request critical + * path, so the upstream call runs on a background schedule (every ~10 min) and + * the latest result is held in memory. {@link #getCurrentWeather()} is a + * non-blocking field read that never fails — at worst it returns {@code null} + * (no key configured, or before the first successful fetch). + */ +@Service +@Slf4j +public class OpenWeatherService { + + private final RestClient restClient; + private final String apiKey; + private final String location; + private final String units; + + private volatile JsonNode currentWeather; + + public OpenWeatherService( + @Value("${openweather.api.url:https://api.openweathermap.org/data/2.5/weather}") String apiUrl, + @Value("${openweather.api.key:}") String apiKey, + @Value("${openweather.location:Mississauga,ON,CA}") String location, + @Value("${openweather.units:metric}") String units) { + this.restClient = RestClient.create(apiUrl); + this.apiKey = apiKey; + this.location = location; + this.units = units; + } + + /** Returns the most recently fetched weather, or {@code null} if none yet. */ + public JsonNode getCurrentWeather() { + return currentWeather; + } + + /** + * Refreshes the cached weather from OpenWeatherMap. Runs on a fixed delay + * (so a slow upstream call can't pile up overlapping requests) and swallows + * all failures — a transient outage just leaves the last good value in + * place rather than propagating to the board. + */ + @Scheduled(fixedDelayString = "${openweather.refresh-interval-ms:600000}") + void refreshWeather() { + if (apiKey == null || apiKey.isBlank()) { + log.warn("OpenWeather API key not configured; skipping weather refresh"); + return; + } + try { + String uri = UriComponentsBuilder.newInstance() + .queryParam("q", location) + .queryParam("units", units) + .queryParam("appid", apiKey) + .build() + .toUriString(); + JsonNode response = restClient.get() + .uri(uri) + .retrieve() + .body(JsonNode.class); + if (response != null) { + currentWeather = response; + log.debug("Refreshed weather for {}", location); + } + } catch (Exception e) { + log.warn("Failed to refresh weather for {}: {} (keeping last known value)", + location, e.getMessage()); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index 618bd09..4f9bbcc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -1,28 +1,25 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.request.CreatePosterRequest; -import com.ibrasoft.lensbridge.dto.request.UpdatePosterRequest; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; +import java.time.Instant; import com.ibrasoft.lensbridge.util.Patch; -import com.ibrasoft.lensbridge.repository.PosterRepository; +import com.ibrasoft.lensbridge.repository.sql.PosterRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.time.LocalDate; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -31,18 +28,16 @@ public class PosterService { private final PosterRepository posterRepository; private final R2StorageService r2StorageService; - private final PosterFrameTransformer posterFrameTransformer; + private final BoardStreamHandler boardStream; @Value("${cloudflare.r2.public-url}") private String publicUrl; - private static final Sort SORT_BY_START_DATE_DESC = Sort.by(Sort.Direction.DESC, "startDate"); - /** * Get all posters, sorted by startDate descending (newest first). */ public List getAllPosters() { - return posterRepository.findAllByOrderByStartDateDesc(); + return posterRepository.findAllByOrderByStartTimeDesc(); } /** @@ -60,40 +55,38 @@ public Poster getPosterById(UUID posterId) { * Get all posters for a specific board location. * Returns posters that match the board's audience or BOTH. */ - public List getPostersForBoard(BoardLocation boardLocation) { - return posterRepository.findByAudienceOrBoth(boardLocation.audience(), SORT_BY_START_DATE_DESC); + public List getPostersForAudience(Audience audience) { + return posterRepository.findByAudienceOrBoth(audience); } /** - * Get active posters for a specific board location as FrameDefinitions. + * Get active posters for a specific audience as FrameDefinitions. * Returns posters that are currently active (startDate <= today < endDate) - * and match the board's audience or BOTH. + * and match the audience or BOTH. */ - public List getActivePosterFramesForBoard(BoardLocation boardLocation) { - LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience(), SORT_BY_START_DATE_DESC); + public List getActivePosterFramesForAudience(Audience audience) { + return posterRepository.findActivePostersForAudienceAt(Instant.now(), audience); } /** * Get all active posters (currently within their viewing window). */ public List getActivePosters() { - LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersAt(today); + return posterRepository.findActivePostersAt(Instant.now()); } /** * Create a new poster with an uploaded image. */ - public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) { - validateDates(request.getStartDate(), request.getEndDate()); - validateImageFile(imageFile); + public Poster createPoster(CreatePosterRequest request) { + validateDates(request.getStartTime(), request.getEndTime()); + validateImageFile(request.getImageFile()); // Upload the image to R2 String objectKey; try { - String filename = generatePosterFilename(imageFile.getOriginalFilename()); - objectKey = r2StorageService.uploadImage(imageFile.getBytes(), filename); + String filename = generatePosterFilename(request.getImageFile().getOriginalFilename()); + objectKey = r2StorageService.uploadImage(filename, request.getImageFile()); log.info("Uploaded poster image to R2: {}", objectKey); } catch (IOException e) { log.error("Failed to upload poster image", e); @@ -103,17 +96,18 @@ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) } Poster poster = Poster.builder() - .id(UUID.randomUUID()) .title(request.getTitle()) .image(publicUrl + "/" + objectKey) .duration(request.getDuration()) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) + .startTime(request.getStartTime()) + .endTime(request.getEndTime()) .audience(request.getAudience()) + .signupUrl(request.getSignupUrl()) .build(); poster = posterRepository.save(poster); log.info("Created poster: id={}, title={}", poster.getId(), poster.getTitle()); + boardStream.contentChanged("posters"); return poster; } @@ -130,15 +124,22 @@ public Poster updatePoster(UUID posterId, UpdatePosterRequest request) { // Update only non-null fields Patch.apply(request.getTitle(), poster::setTitle); Patch.apply(request.getDuration(), poster::setDuration); - Patch.apply(request.getStartDate(), poster::setStartDate); - Patch.apply(request.getEndDate(), poster::setEndDate); + Patch.apply(request.getStartTime(), poster::setStartTime); + Patch.apply(request.getEndTime(), poster::setEndTime); Patch.apply(request.getAudience(), poster::setAudience); + // An empty string clears the link, so the QR panel can be removed from a + // poster without deleting and re-uploading it. Not Patch.apply: the lambda + // would capture `poster`, which is reassigned by save() below. + if (request.getSignupUrl() != null) { + String signupUrl = request.getSignupUrl().trim(); + poster.setSignupUrl(signupUrl.isEmpty() ? null : signupUrl); + } - // Validate dates after update - validateDates(poster.getStartDate(), poster.getEndDate()); + validateDates(poster.getStartTime(), poster.getEndTime()); poster = posterRepository.save(poster); log.info("Updated poster: id={}", posterId); + boardStream.contentChanged("posters"); return poster; } @@ -168,7 +169,7 @@ public Poster updatePosterImage(UUID posterId, MultipartFile imageFile) { String objectKey; try { String filename = generatePosterFilename(imageFile.getOriginalFilename()); - objectKey = r2StorageService.uploadImage(imageFile.getBytes(), filename); + objectKey = r2StorageService.uploadImage(filename, imageFile); log.info("Uploaded new poster image to R2: {}", objectKey); } catch (IOException e) { log.error("Failed to upload poster image", e); @@ -180,6 +181,7 @@ public Poster updatePosterImage(UUID posterId, MultipartFile imageFile) { poster.setImage(publicUrl + "/" + objectKey); poster = posterRepository.save(poster); log.info("Updated poster image: id={}", posterId); + boardStream.contentChanged("posters"); return poster; } @@ -205,29 +207,7 @@ public void deletePoster(UUID posterId) { posterRepository.delete(poster); log.info("Deleted poster: id={}", posterId); - } - - // ==================== Musallah Board Methods ==================== - - /** - * Get active posters as FrameDefinitions for display on the musallah board. - * Returns only posters that are currently active and match the board's audience. - * Sorted by startDate descending (newest first). - */ - public List getActivePosterFrameDefinitions(BoardLocation boardLocation) { - LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience(), SORT_BY_START_DATE_DESC) - .stream() - .map(this::toFrameDefinition) - .collect(Collectors.toList()); - } - - /** - * Convert a Poster to a FrameDefinition for the musallah board. - * Delegates to PosterFrameTransformer — single source of truth. - */ - private com.ibrasoft.lensbridge.model.board.frames.FrameDefinition toFrameDefinition(Poster poster) { - return posterFrameTransformer.transform(poster, null); + boardStream.contentChanged("posters"); } // ==================== Helper Methods ==================== @@ -240,7 +220,7 @@ private String generatePosterFilename(String originalFilename) { return "poster-" + UUID.randomUUID() + extension; } - private void validateDates(LocalDate startDate, LocalDate endDate) { + private void validateDates(Instant startDate, Instant endDate) { if (startDate != null && endDate != null && !endDate.isAfter(startDate)) { throw new ApiResponseException( HttpStatus.BAD_REQUEST, diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java b/src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java new file mode 100644 index 0000000..6c3c142 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java @@ -0,0 +1,138 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.board.request.CreatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.SocialType; +import com.ibrasoft.lensbridge.repository.sql.PromotableSocialMediaRepository; +import com.ibrasoft.lensbridge.util.Patch; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +/** + * CRUD for the social accounts a board can promote. Unlike posters these have no viewing + * window — a social page is either promoted or it isn't — so the only filter that matters + * for assembly is audience. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PromotableSocialMediaService { + + private final PromotableSocialMediaRepository repository; + private final BoardStreamHandler boardStream; + + /** Every promotable social, ordered by name. */ + public List getAll() { + return repository.findAllByOrderByNameAsc(); + } + + public PromotableSocialMedia getById(UUID id) { + return repository.findById(id) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Promotable social media not found with id: " + id))); + } + + /** + * Socials a board should show: those targeting this audience plus those targeting BOTH. + * This is what the socials frame producer consumes. + */ + public List getForAudience(Audience audience) { + return repository.findByAudienceOrBoth(audience); + } + + public List getByType(SocialType type) { + return repository.findByTypeOrderByNameAsc(type); + } + + public PromotableSocialMedia create(CreatePromotableSocialMediaRequest request) { + String url = normalize(request.getUrl()); + if (repository.existsByTypeAndUrl(request.getType(), url)) { + throw new ApiResponseException( + HttpStatus.CONFLICT, + ErrorResponse.of("That " + request.getType() + " URL is already promoted")); + } + + PromotableSocialMedia social = PromotableSocialMedia.builder() + .name(request.getName()) + .duration(request.getDuration()) + .audience(request.getAudience()) + .type(request.getType()) + .url(url) + .headerText(request.getHeaderText()) + .heroText(request.getHeroText()) + .handle(blankToNull(request.getHandle())) + .footerText(request.getFooterText()) + .build(); + + social = repository.save(social); + log.info("Created promotable social media: id={}, type={}, name={}", + social.getId(), social.getType(), social.getName()); + boardStream.contentChanged("socials"); + + return social; + } + + public PromotableSocialMedia update(UUID id, UpdatePromotableSocialMediaRequest request) { + PromotableSocialMedia social = getById(id); + + Patch.apply(request.getName(), social::setName); + Patch.apply(request.getDuration(), social::setDuration); + Patch.apply(request.getAudience(), social::setAudience); + Patch.apply(request.getType(), social::setType); + Patch.apply(request.getHeaderText(), social::setHeaderText); + Patch.apply(request.getHeroText(), social::setHeroText); + Patch.apply(request.getFooterText(), social::setFooterText); + + if (request.getUrl() != null) { + social.setUrl(normalize(request.getUrl())); + } + // Not Patch.apply: an empty string means "clear the handle", which the null-guard + // there would let through unchanged. + if (request.getHandle() != null) { + social.setHandle(blankToNull(request.getHandle())); + } + + social = repository.save(social); + log.info("Updated promotable social media: id={}", id); + boardStream.contentChanged("socials"); + + return social; + } + + public void delete(UUID id) { + PromotableSocialMedia social = getById(id); + + repository.delete(social); + log.info("Deleted promotable social media: id={}", id); + boardStream.contentChanged("socials"); + } + + // ==================== Helper Methods ==================== + + private static String normalize(String url) { + String trimmed = url == null ? "" : url.trim(); + if (trimmed.isEmpty()) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("URL is required")); + } + return trimmed; + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java b/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java index fad4290..03e549a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java @@ -1,28 +1,33 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.exception.ImageProcessingException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Configuration; -import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import java.io.File; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; + import java.io.IOException; +import java.io.InputStream; import java.net.URI; -import java.nio.file.Files; +import java.security.MessageDigest; import java.time.Duration; +import java.util.HexFormat; +import java.util.Map; @Service @RequiredArgsConstructor @@ -31,387 +36,344 @@ public class R2StorageService { private final S3Client s3Client; private final S3Presigner presigner; - private final MediaConversionService mediaConversionService; - - @Value("${cloudflare.r2.access-key-id}") - private String accessKeyId; - - @Value("${cloudflare.r2.secret-access-key}") - private String secretAccessKey; - - @Value("${cloudflare.r2.endpoint}") - private String endpoint; @Value("${cloudflare.r2.bucket-name}") private String bucketName; - @Value("${cloudflare.r2.public-url}") + @Value("${cloudflare.r2.public-url:}") private String publicUrl; @Value("${cloudflare.r2.url-expiration-minutes:15}") private long urlExpirationMinutes; - @PostConstruct - public void init() { - log.info("R2StorageService initialized (endpoint='{}', bucket='{}', publicUrl='{}')", endpoint, bucketName, publicUrl); - } - - private String normalizeEndpoint(String ep) { - if (ep == null) return null; - String trimmed = ep.trim(); - // Remove any trailing slash - while (trimmed.endsWith("/")) { - trimmed = trimmed.substring(0, trimmed.length() - 1); - } - return trimmed; - } - /** - * Replace the CloudFlare R2 endpoint in a presigned URL with our custom domain + * Upload an object directly to R2. + * + * This should generally only be used internally + * for backend-generated objects like thumbnails, + * transcoded media, exports, etc. + * + * User uploads should use presigned URLs instead. */ - private String replaceEndpointWithCustomDomain(String presignedUrl) { - if (publicUrl == null || publicUrl.isBlank()) { - return presignedUrl; // Return original URL if no custom domain configured - } - - try { - URI originalUri = URI.create(presignedUrl); - String originalHost = originalUri.getHost(); - String originalPath = originalUri.getPath(); - String queryString = originalUri.getQuery(); - - // Extract the custom domain from publicUrl - URI customUri = URI.create(publicUrl); - String customHost = customUri.getHost(); - String customScheme = customUri.getScheme(); - - // Remove bucket name from path if it exists - String newPath = originalPath; - if (originalPath != null && originalPath.startsWith("/" + bucketName + "/")) { - newPath = originalPath.substring(bucketName.length() + 1); // Remove "/bucketName" - } - - // Construct the new URL with custom domain - StringBuilder customUrl = new StringBuilder(); - customUrl.append(customScheme).append("://").append(customHost); - customUrl.append(newPath); - if (queryString != null && !queryString.isEmpty()) { - customUrl.append("?").append(queryString); - } - - String result = customUrl.toString(); - log.debug("Replaced CloudFlare endpoint '{}' with custom domain '{}' and removed bucket from path: '{}' -> '{}'", - originalHost, customHost, originalPath, newPath); - return result; - } catch (Exception e) { - log.error("Failed to replace endpoint with custom domain in URL: {}", presignedUrl, e); - return presignedUrl; // Return original URL on error - } - } + public void putObject( + String objectKey, + InputStream inputStream, + long contentLength, + String contentType + ) { + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentLength(contentLength) + .contentType(contentType) + .build(); + s3Client.putObject( + request, + RequestBody.fromInputStream(inputStream, contentLength) + ); - /** - * Upload an image file to R2 - */ - public String uploadImage(File imageFile, String fileName) throws IOException { - String lowerName = fileName.toLowerCase(); - if (lowerName.endsWith(".heic") || lowerName.endsWith(".heif")) { - // Convert HEIC to JPG before uploading - File jpgFile = File.createTempFile("converted-", ".jpg"); - try { - MediaConversionService.convertHeicToJpg(imageFile, jpgFile); - String jpgName = fileName.replaceAll("(?i)\\.heic$", ".jpg"); - String result = uploadFile(jpgFile, jpgName, "images/"); - jpgFile.delete(); - return result; - } catch (Exception e) { - jpgFile.delete(); - log.error("HEIC to JPG conversion failed", e); - throw new IOException("HEIC to JPG conversion failed", e); - } - } - return uploadFile(imageFile, fileName, "images/"); + log.info("Uploaded object to R2: {}", objectKey); } /** - * Upload an image from bytes to R2 + * Generate a presigned upload URL. + * + * The client uploads directly to R2 using this URL. */ - public String uploadImage(byte[] fileBytes, String fileName) throws IOException { - String lowerName = fileName.toLowerCase(); - if (lowerName.endsWith(".heic") || lowerName.endsWith(".heif")) { - // Convert HEIC bytes to JPG before uploading - File tempHeicFile = File.createTempFile("temp-heic-", ".heic"); - File jpgFile = File.createTempFile("converted-", ".jpg"); - try { - Files.write(tempHeicFile.toPath(), fileBytes); - MediaConversionService.convertHeicToJpg(tempHeicFile, jpgFile); - String jpgName = fileName.replaceAll("(?i)\\.heic$", ".jpg"); - String result = uploadFile(jpgFile, jpgName, "images/"); - tempHeicFile.delete(); - jpgFile.delete(); - return result; - } catch (Exception e) { - tempHeicFile.delete(); - jpgFile.delete(); - log.error("HEIC to JPG conversion failed", e); - throw new ImageProcessingException("HEIC to JPG conversion failed", e); - } - } - return uploadFile(fileBytes, fileName, "images/", detectContentType(fileName)); - } + public String generatePresignedUploadUrl( + String objectKey, + String contentType, + long contentLength, + Duration expiration + ) { + + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentType(contentType) + .contentLength(contentLength) + .build(); - /** - * Upload a video file to R2 - */ - public String uploadVideo(File videoFile, String fileName) throws IOException { - return uploadFile(videoFile, fileName, "videos/"); + PutObjectPresignRequest presignRequest = + PutObjectPresignRequest.builder() + .signatureDuration(expiration) + .putObjectRequest(putRequest) + .build(); + + String url = presigner + .presignPutObject(presignRequest) + .url() + .toString(); + + log.debug("Generated presigned upload URL for {}", objectKey); + + return url; } /** - * Upload a video from bytes to R2 + * Convenience overload using configured expiration. */ - public String uploadVideo(byte[] fileBytes, String fileName) throws IOException { - return uploadFile(fileBytes, fileName, "videos/", detectContentType(fileName)); + public String generatePresignedUploadUrl( + String objectKey, + String contentType, + long contentLength + ) { + + return generatePresignedUploadUrl( + objectKey, + contentType, + contentLength, + Duration.ofMinutes(urlExpirationMinutes) + ); } /** - * Upload file from File object - * Returns the object key (not a URL) since we'll generate presigned URLs on demand + * Generate a secure temporary download URL. */ - private String uploadFile(File file, String fileName, String folder) throws IOException { - String key = folder + fileName; - String contentType = Files.probeContentType(file.toPath()); - - if (contentType == null) { - contentType = detectContentType(fileName); - } + public String generatePresignedDownloadUrl( + String objectKey + ) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() + GetObjectRequest getRequest = GetObjectRequest.builder() .bucket(bucketName) - .key(key) - .contentType(contentType) + .key(objectKey) .build(); - try { - s3Client.putObject(putObjectRequest, RequestBody.fromFile(file)); - log.info("Successfully uploaded file to R2: {}", key); - // Return the object key instead of a URL - URLs will be generated on demand - return key; - } catch (Exception e) { - log.error("Failed to upload file to R2: {}", key, e); - throw e; - } + GetObjectPresignRequest presignRequest = + GetObjectPresignRequest.builder() + .signatureDuration( + Duration.ofMinutes(urlExpirationMinutes) + ) + .getObjectRequest(getRequest) + .build(); + + String url = presigner + .presignGetObject(presignRequest) + .url() + .toString(); + + return rewriteToCustomDomain(url); } /** - * Upload file from byte array - * Returns the object key (not a URL) since we'll generate presigned URLs on demand + * Delete an object. */ - private String uploadFile(byte[] fileBytes, String fileName, String folder, String contentType) throws IOException { - String key = folder + fileName; + public void deleteObject(String objectKey) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() + DeleteObjectRequest request = DeleteObjectRequest.builder() .bucket(bucketName) - .key(key) - .contentType(contentType) + .key(objectKey) .build(); - try { - s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); - log.info("Successfully uploaded file to R2: {}", key); - // Return the object key instead of a URL - URLs will be generated on demand - return key; - } catch (Exception e) { - log.error("Failed to upload file to R2: {}", key, e); - throw e; - } - } + s3Client.deleteObject(request); - /** - * Generate a secure time-limited URL for accessing content using S3 presigner. - * @param objectKey The object key (without bucket name) - * @param isApproved Whether the content is approved - * @param isAdmin Whether the requester is an admin (admins can view unapproved) - */ - public String getSecureUrl(String objectKey, boolean isApproved, boolean isAdmin) { - if (objectKey == null) { - throw new IllegalArgumentException("Object key cannot be null"); - } - if (!isApproved && !isAdmin) { - throw new SecurityException("Access denied: Content not approved for public viewing"); - } - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(urlExpirationMinutes)) - .getObjectRequest(getObjectRequest) - .build(); - String presignedUrl = presigner.presignGetObject(presignRequest).url().toString(); - - // Replace the CloudFlare endpoint with our custom domain - String customUrl = replaceEndpointWithCustomDomain(presignedUrl); - - if (log.isDebugEnabled()) { - log.debug("Generated presigned URL (adminAccess={}): key='{}' -> '{}'", isAdmin, objectKey, customUrl.split("\\?",2)[0]); - } - return customUrl; - } catch (Exception e) { - log.error("Failed to presign GET URL for {}: {}", objectKey, e.getMessage()); - throw new RuntimeException("Failed to generate secure URL", e); - } + log.info("Deleted object from R2: {}", objectKey); } /** - * Generate a secure URL for thumbnails. - * @param thumbnailKey The object key of the thumbnail (e.g., "thumbnails/uuid") - * @param isApproved Whether the content is approved - * @param isAdmin Whether the requester is an admin + * Check whether an object exists. */ - public String getSecureThumbnailUrl(String thumbnailKey, boolean isApproved, boolean isAdmin) { - if (thumbnailKey == null || thumbnailKey.isBlank()) { - return null; - } - return getSecureUrl(thumbnailKey, isApproved, isAdmin); - } + public boolean objectExists(String objectKey) { - /** - * Generate a presigned PUT URL for direct uploads (optional helper if needed later). - */ - public String generatePresignedUploadUrl(String objectKey, String contentType, String sha256hash, long contentLength) { try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() + + HeadObjectRequest request = HeadObjectRequest.builder() .bucket(bucketName) .key(objectKey) - .contentType(contentType) - .contentLength(contentLength) - // .checksumSHA256(sha256hash) .build(); - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(urlExpirationMinutes)) - .putObjectRequest(putObjectRequest) - .build(); - String presignedUrl = presigner.presignPutObject(presignRequest).url().toString(); - - // For upload URLs, we must return the original R2 endpoint, not the custom domain - // Custom domains only work for downloads, not authenticated uploads - return presignedUrl; + + s3Client.headObject(request); + + return true; + + } catch (NoSuchKeyException e) { + + return false; + } catch (Exception e) { - log.error("Failed to presign PUT URL for {}: {}", objectKey, e.getMessage()); - throw new RuntimeException("Failed to generate upload URL", e); + + log.error( + "Failed checking object existence: {}", + objectKey, + e + ); + + return false; } } /** - * Delete an object from R2 + * Retrieve object metadata. */ - public void deleteObject(String objectKey) { - try { - DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); + public R2ObjectMetadata getObjectMetadata(String objectKey) { - s3Client.deleteObject(deleteObjectRequest); - log.info("Successfully deleted object from R2: {}", objectKey); - } catch (Exception e) { - log.error("Failed to delete object from R2: {}", objectKey, e); - throw new RuntimeException("Failed to delete object from R2", e); - } + HeadObjectRequest request = HeadObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build(); + + HeadObjectResponse response = + s3Client.headObject(request); + + return new R2ObjectMetadata( + objectKey, + response.contentLength(), + response.contentType(), + response.eTag(), + response.lastModified(), + response.metadata() + ); } /** - * Extract object key from a full URL or return as-is if already a key. - * Handles both path-style (endpoint/bucket/key) and virtual-host styles. + * Extract object key from URL or return original. */ - public String extractObjectKeyFromUrl(String urlOrKey) { - if (urlOrKey == null || urlOrKey.isBlank()) { + public String extractObjectKey(String value) { + + if (value == null || value.isBlank()) { return null; } - // If not an URL assume it's an object key already - if (!urlOrKey.startsWith("http")) { - return urlOrKey; + + if (!value.startsWith("http")) { + return value; } + try { - URI uri = URI.create(urlOrKey); - String path = uri.getPath(); // e.g. /bucketName/folder/file or /folder/file (if virtual host) + + URI uri = URI.create(value); + + String path = uri.getPath(); + if (path == null || path.isBlank()) { return null; } - // Remove leading slash + if (path.startsWith("/")) { path = path.substring(1); } - // If path starts with bucketName/, strip it + if (path.startsWith(bucketName + "/")) { path = path.substring(bucketName.length() + 1); } + return path; + } catch (Exception e) { - log.error("Error extracting object key from URL: {}", urlOrKey, e); + + log.error("Failed extracting object key", e); + return null; } } /** - * TEMP retained methods for compatibility; now return presigned URLs. + * Replace R2 endpoint with public CDN/custom domain. */ - public String generateImageThumbnail(String objectKey) { - return getSecureUrl(objectKey, true, true); + private String rewriteToCustomDomain(String originalUrl) { + + if (publicUrl == null || publicUrl.isBlank()) { + return originalUrl; + } + + try { + + URI originalUri = URI.create(originalUrl); + URI publicUri = URI.create(publicUrl); + + String path = originalUri.getPath(); + + if (path.startsWith("/" + bucketName + "/")) { + path = path.substring(bucketName.length() + 1); + } + + StringBuilder rewritten = new StringBuilder(); + + rewritten.append(publicUri.getScheme()) + .append("://") + .append(publicUri.getHost()) + .append(path); + + if (originalUri.getQuery() != null) { + rewritten.append("?") + .append(originalUri.getQuery()); + } + + return rewritten.toString(); + + } catch (Exception e) { + + log.error( + "Failed rewriting presigned URL to custom domain", + e + ); + + return originalUrl; + } } - public String generateVideoThumbnail(String objectKey) { - return getSecureUrl(objectKey, true, true); + /** + * Upload an image file from a MultipartFile directly to R2. + * Used for backend-initiated uploads (e.g. poster images). + */ + public String uploadImage(String key, MultipartFile file) throws IOException { + putObject(key, file.getInputStream(), file.getSize(), file.getContentType()); + return key; } /** - * Detect content type based on file extension + * Download object bytes from R2. */ - private String detectContentType(String fileName) { - String lowerCaseFileName = fileName.toLowerCase(); - - // Images - if (lowerCaseFileName.endsWith(".jpg") || lowerCaseFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } else if (lowerCaseFileName.endsWith(".png")) { - return "image/png"; - } else if (lowerCaseFileName.endsWith(".heic")) { - return "image/heic"; + public byte[] getObjectBytes(String objectKey) throws IOException { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build(); + try (ResponseInputStream stream = s3Client.getObject(getRequest)) { + return stream.readAllBytes(); } - - // Videos - else if (lowerCaseFileName.endsWith(".mp4")) { - return "video/mp4"; - } else if (lowerCaseFileName.endsWith(".mov")) { - return "video/quicktime"; - } else if (lowerCaseFileName.endsWith(".mkv")) { - return "video/x-matroska"; - } else if (lowerCaseFileName.endsWith(".avi")) { - return "video/x-msvideo"; + } + + /** + * Compute the SHA-256 hex digest of a stored object. + */ + public String calculateSha256Hash(String objectKey) throws Exception { + byte[] bytes = getObjectBytes(objectKey); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(bytes)); + } + + /** + * Generate a presigned download URL for an object, with access control. + * Throws SecurityException if the content is unapproved and the caller is not admin. + */ + public String getSecureUrl(String objectKey, boolean approved, boolean isAdmin) { + if (!approved && !isAdmin) { + throw new SecurityException("Access denied: content is not approved"); } - - return "application/octet-stream"; + return generatePresignedDownloadUrl(objectKey); } /** - * Check if object exists in R2 + * Generate a presigned download URL for a thumbnail, with access control. */ - public boolean objectExists(String objectKey) { - try { - HeadObjectRequest headObjectRequest = HeadObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - - s3Client.headObject(headObjectRequest); - return true; - } catch (NoSuchKeyException e) { - return false; - } catch (Exception e) { - log.error("Error checking if object exists: {}", objectKey, e); - return false; + public String getSecureThumbnailUrl(String thumbnailKey, boolean approved, boolean isAdmin) { + if (!approved && !isAdmin) { + throw new SecurityException("Access denied: content is not approved"); } + return generatePresignedDownloadUrl(thumbnailKey); } -} + + /** + * Lightweight immutable metadata DTO. + */ + public record R2ObjectMetadata( + String objectKey, + long contentLength, + String contentType, + String etag, + java.time.Instant lastModified, + Map metadata + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java index d228809..ea347e3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java @@ -1,16 +1,21 @@ package com.ibrasoft.lensbridge.service; +import com.ibrasoft.lensbridge.exception.RefreshTokenException; import com.ibrasoft.lensbridge.model.auth.RefreshToken; -import com.ibrasoft.lensbridge.repository.RefreshTokenRepository; +import com.ibrasoft.lensbridge.repository.auth.RefreshTokenRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; -import java.time.LocalDateTime; +import java.time.Duration; +import java.time.Instant; import java.util.Base64; import java.util.List; import java.util.Optional; @@ -22,6 +27,7 @@ public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; @Value("${lensbridge.app.refreshTokenExpirationMs:604800000}") // 7 days default private long refreshTokenDurationMs; @@ -32,7 +38,7 @@ public class RefreshTokenService { private final SecureRandom secureRandom = new SecureRandom(); public void invalidateAllRefreshTokensForUser(UUID userId) { - List tokens = refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + List tokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); tokens.forEach(token -> token.setRevoked(true)); refreshTokenRepository.saveAll(tokens); } @@ -46,29 +52,27 @@ public RefreshToken createRefreshToken(UUID userId, String deviceInfo, String ip deleteExpiredTokensByUser(userId); // Check if user has too many active tokens - long activeTokenCount = refreshTokenRepository.countByUserIdAndRevokedFalse(userId); + long activeTokenCount = refreshTokenRepository.countByUser_IdAndRevokedFalse(userId); if (activeTokenCount >= maxRefreshTokensPerUser) { // Revoke oldest token - List userTokens = refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + List userTokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); if (!userTokens.isEmpty()) { RefreshToken oldestToken = userTokens.stream() .min((t1, t2) -> t1.getCreatedDate().compareTo(t2.getCreatedDate())) .orElse(null); if (oldestToken != null) { - revokeRefreshToken(oldestToken.getToken()); + revokeRefreshToken(oldestToken.getTokenHash()); } } } + Instant now = Instant.now(); RefreshToken refreshToken = RefreshToken.builder() - .id(UUID.randomUUID()) - .token(generateRefreshToken()) - .userId(userId) - .expiryDate(LocalDateTime.now().plusSeconds(refreshTokenDurationMs / 1000)) - .createdDate(LocalDateTime.now()) - .lastUsedDate(LocalDateTime.now()) - .deviceInfo(deviceInfo) - .ipAddress(ipAddress) + .tokenHash(generateRefreshToken()) + .user(userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found"))) + .expiryDate(now.plusSeconds(refreshTokenDurationMs / 1000)) + .createdDate(now) + .lastUsedDate(now) .revoked(false) .build(); @@ -79,20 +83,23 @@ public RefreshToken createRefreshToken(UUID userId, String deviceInfo, String ip * Find refresh token by token string */ public Optional findByToken(String token) { - return refreshTokenRepository.findByToken(token); + return refreshTokenRepository.findByTokenHash(token); } /** * Verify refresh token is valid and not expired */ public RefreshToken verifyExpiration(RefreshToken token) { - if (token.isExpired() || token.isRevoked()) { + if (token.isRevoked()) { refreshTokenRepository.delete(token); - throw new RuntimeException("Refresh token was expired or revoked. Please make a new signin request"); + throw new RefreshTokenException("Refresh token revoked. Please login again.", HttpStatus.UNAUTHORIZED); } - - // Update last used date - token.setLastUsedDate(LocalDateTime.now()); + if (token.isExpired()) { + refreshTokenRepository.delete(token); + throw new RefreshTokenException("Refresh token expired. Please login again.", HttpStatus.UNAUTHORIZED); + } + + token.setLastUsedDate(Instant.now()); return refreshTokenRepository.save(token); } @@ -101,7 +108,7 @@ public RefreshToken verifyExpiration(RefreshToken token) { */ @Transactional public void revokeRefreshToken(String token) { - refreshTokenRepository.findByToken(token).ifPresent(refreshToken -> { + refreshTokenRepository.findByTokenHash(token).ifPresent(refreshToken -> { refreshToken.setRevoked(true); refreshTokenRepository.save(refreshToken); }); @@ -112,7 +119,7 @@ public void revokeRefreshToken(String token) { */ @Transactional public void revokeAllUserTokens(UUID userId) { - List userTokens = refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + List userTokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); userTokens.forEach(token -> token.setRevoked(true)); refreshTokenRepository.saveAll(userTokens); } @@ -122,7 +129,7 @@ public void revokeAllUserTokens(UUID userId) { */ @Transactional public void deleteExpiredTokensByUser(UUID userId) { - List userTokens = refreshTokenRepository.findByUserId(userId); + List userTokens = refreshTokenRepository.findByUser_Id(userId); List expiredTokens = userTokens.stream() .filter(token -> token.isExpired() || token.isRevoked()) .toList(); @@ -136,7 +143,7 @@ public void deleteExpiredTokensByUser(UUID userId) { * Get all active refresh tokens for a user (for security dashboard) */ public List getActiveTokensForUser(UUID userId) { - return refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + return refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); } /** @@ -155,12 +162,12 @@ private String generateRefreshToken() { @Transactional public void cleanupExpiredTokens() { try { - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); refreshTokenRepository.deleteByExpiryDateBefore(now); // Clean up revoked tokens (older than 7 days) List oldRevokedTokens = refreshTokenRepository - .findByRevokedTrueAndCreatedDateBefore(now.minusDays(7)); + .findByRevokedTrueAndCreatedDateBefore(now.minus(Duration.ofDays(7))); if (!oldRevokedTokens.isEmpty()) { refreshTokenRepository.deleteAll(oldRevokedTokens); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java deleted file mode 100644 index b665cf1..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.UploadRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * One-time backfill script to generate thumbnails for existing uploads. - * - * To run this backfill, add the following to application.properties: - * thumbnail.backfill.enabled=true - * - * Then start the application. After completion, remove the property or set to false. - * The application will continue running normally after backfill completes. - */ -@Component -@ConditionalOnProperty(name = "thumbnail.backfill.enabled", havingValue = "true") -@RequiredArgsConstructor -@Slf4j -public class ThumbnailBackfillService implements CommandLineRunner { - - private final UploadRepository uploadRepository; - private final ThumbnailService thumbnailService; - - @Override - public void run(String... args) { - log.info("=".repeat(60)); - log.info("THUMBNAIL BACKFILL: Starting thumbnail generation for existing uploads"); - log.info("=".repeat(60)); - - // Find all image uploads without thumbnails - List uploadsWithoutThumbnails = uploadRepository.findAll().stream() - .filter(u -> u.getContentType() == UploadType.IMAGE) - .filter(u -> u.getThumbnailUrl() == null || u.getThumbnailUrl().isBlank()) - .filter(u -> u.getFileUrl() != null && !u.getFileUrl().isBlank()) - .toList(); - - int total = uploadsWithoutThumbnails.size(); - log.info("Found {} image uploads without thumbnails", total); - - if (total == 0) { - log.info("No uploads need thumbnail backfill. Exiting backfill."); - return; - } - - AtomicInteger success = new AtomicInteger(0); - AtomicInteger failed = new AtomicInteger(0); - - for (int i = 0; i < uploadsWithoutThumbnails.size(); i++) { - Upload upload = uploadsWithoutThumbnails.get(i); - int current = i + 1; - - try { - log.info("[{}/{}] Processing upload: {} (key: {})", - current, total, upload.getUuid(), upload.getFileUrl()); - - // Generate thumbnail synchronously (blocking) - String thumbnailKey = thumbnailService.generateThumbnail(upload.getFileUrl()); - - // Update the upload record - upload.setThumbnailUrl(thumbnailKey); - uploadRepository.save(upload); - - success.incrementAndGet(); - log.info("[{}/{}] SUCCESS: Generated thumbnail: {}", current, total, thumbnailKey); - - } catch (Exception e) { - failed.incrementAndGet(); - log.error("[{}/{}] FAILED: Upload {} - {}", - current, total, upload.getUuid(), e.getMessage()); - } - - // Progress update every 10 items - if (current % 10 == 0) { - log.info("Progress: {}/{} processed ({} success, {} failed)", - current, total, success.get(), failed.get()); - } - } - - log.info("=".repeat(60)); - log.info("THUMBNAIL BACKFILL COMPLETE"); - log.info("Total processed: {}", total); - log.info("Successful: {}", success.get()); - log.info("Failed: {}", failed.get()); - log.info("=".repeat(60)); - log.info("You can now set 'thumbnail.backfill.enabled=false' and restart."); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java deleted file mode 100644 index b4b5cb3..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.repository.UploadRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import net.coobird.thumbnailator.Thumbnails; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import software.amazon.awssdk.core.ResponseInputStream; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectResponse; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.util.Optional; -import java.util.UUID; - -/** - * Service for generating and managing image thumbnails. - * Thumbnails are generated asynchronously after image uploads complete. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class ThumbnailService { - - private final S3Client s3Client; - private final UploadRepository uploadRepository; - - @Value("${cloudflare.r2.bucket-name}") - private String bucketName; - - @Value("${thumbnail.width:400}") - private int thumbnailWidth; - - @Value("${thumbnail.height:400}") - private int thumbnailHeight; - - @Value("${thumbnail.quality:0.85}") - private double thumbnailQuality; - - @Value("${thumbnail.folder:thumbnails/}") - private String thumbnailFolder; - - /** - * Generate a thumbnail for an uploaded image asynchronously. - * The thumbnail is stored in the thumbnails/ folder with the same filename. - * Updates the Upload entity with the thumbnail URL after generation. - * - * @param uploadId The UUID of the upload to generate thumbnail for - * @param objectKey The R2 object key of the original image (e.g., "images/uuid") - */ - @Async - public void generateThumbnailAsync(UUID uploadId, String objectKey) { - try { - log.info("Starting thumbnail generation for upload {} (key: {})", uploadId, objectKey); - String thumbnailKey = generateThumbnail(objectKey); - - // Update the upload entity with the thumbnail URL - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setThumbnailUrl(thumbnailKey); - uploadRepository.save(upload); - log.info("Successfully updated upload {} with thumbnail: {}", uploadId, thumbnailKey); - } else { - log.warn("Upload {} not found when trying to update thumbnail URL", uploadId); - } - } catch (Exception e) { - log.error("Failed to generate thumbnail for upload {} (key: {}): {}", - uploadId, objectKey, e.getMessage(), e); - } - } - - /** - * Generate a thumbnail for an image stored in R2. - * - * @param objectKey The R2 object key of the original image - * @return The R2 object key of the generated thumbnail - */ - public String generateThumbnail(String objectKey) throws Exception { - // Download the original image from R2 - GetObjectRequest getRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - - byte[] thumbnailBytes; - try (ResponseInputStream objectStream = s3Client.getObject(getRequest)) { - // Generate thumbnail using Thumbnailator - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - Thumbnails.of(objectStream) - .size(thumbnailWidth, thumbnailHeight) - .keepAspectRatio(true) - .outputQuality(thumbnailQuality) - .outputFormat("jpg") - .toOutputStream(outputStream); - - thumbnailBytes = outputStream.toByteArray(); - } - - // Generate thumbnail key: thumbnails/ - // e.g., images/uuid -> thumbnails/uuid - String filename = extractFilename(objectKey); - String thumbnailKey = thumbnailFolder + filename; - - // Upload thumbnail to R2 - PutObjectRequest putRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(thumbnailKey) - .contentType("image/jpeg") - .contentLength((long) thumbnailBytes.length) - .build(); - - s3Client.putObject(putRequest, RequestBody.fromBytes(thumbnailBytes)); - - log.info("Generated thumbnail: {} -> {} ({}x{}, {} bytes)", - objectKey, thumbnailKey, thumbnailWidth, thumbnailHeight, thumbnailBytes.length); - - return thumbnailKey; - } - - /** - * Extract the filename from an object key. - * e.g., "images/abc-123" -> "abc-123" - */ - private String extractFilename(String objectKey) { - if (objectKey == null) { - return null; - } - int lastSlash = objectKey.lastIndexOf('/'); - return lastSlash >= 0 ? objectKey.substring(lastSlash + 1) : objectKey; - } - - /** - * Check if a thumbnail exists for the given object key. - */ - public boolean thumbnailExists(String objectKey) { - String filename = extractFilename(objectKey); - String thumbnailKey = thumbnailFolder + filename; - - try { - s3Client.headObject(builder -> builder.bucket(bucketName).key(thumbnailKey)); - return true; - } catch (Exception e) { - return false; - } - } - - /** - * Get the thumbnail key for a given original object key. - */ - public String getThumbnailKey(String objectKey) { - String filename = extractFilename(objectKey); - return thumbnailFolder + filename; - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java new file mode 100644 index 0000000..b9618ad --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java @@ -0,0 +1,75 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.UploadProperties; +import com.ibrasoft.lensbridge.dto.upload.response.UploadLimitsResponse; +import com.ibrasoft.lensbridge.exception.DailyLimitExceededException; +import com.ibrasoft.lensbridge.exception.FileSizeLimitExceededException; +import com.ibrasoft.lensbridge.exception.InvalidContentTypeException; +import com.ibrasoft.lensbridge.model.auth.Role; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.util.unit.DataSize; + +import java.util.Comparator; +import java.util.Objects; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UploadLimitsService { + + private final UploadProperties uploadProperties; + private final UploadService uploadService; + + public Role getHighestRole(Authentication authentication) { + if (authentication == null || authentication.getAuthorities() == null) { + return Role.USER; + } + return authentication.getAuthorities().stream() + .map(a -> { + String name = a.getAuthority(); + if (name.startsWith("ROLE_")) name = name.substring(5); + try { return Role.valueOf(name); } + catch (IllegalArgumentException e) { return null; } + }) + .filter(Objects::nonNull) + .max(Comparator.comparingInt(Enum::ordinal)) + .orElse(Role.USER); + } + + public void validateUpload(UUID userId, Role role, long fileSize, String contentType) { + if (!uploadProperties.getAllowedFileTypes().contains(contentType)) { + throw new InvalidContentTypeException(contentType); + } + + DataSize maxAllowed = uploadProperties.getMaxSizeForRole(role.name().toLowerCase()); + if (fileSize > maxAllowed.toBytes()) { + throw new FileSizeLimitExceededException(maxAllowed.toBytes(), fileSize); + } + + int dailyLimit = uploadProperties.getDailyLimitForRole(role.name().toLowerCase()); + if (uploadService.hasReachedDailyLimit(userId, dailyLimit)) { + long count = uploadService.countUploadsToday(userId); + throw new DailyLimitExceededException(dailyLimit, count); + } + } + + public UploadLimitsResponse getLimitsForRole(Role role, UUID userId) { + String roleKey = role.name().toLowerCase(); + DataSize maxSize = uploadProperties.getMaxSizeForRole(roleKey); + int dailyLimit = uploadProperties.getDailyLimitForRole(roleKey); + long uploadsToday = uploadService.countUploadsToday(userId); + + return UploadLimitsResponse.builder() + .role(roleKey) + .maxSizeBytes(maxSize.toBytes()) + .maxSizeMB(maxSize.toMegabytes()) + .allowedContentTypes(uploadProperties.getAllowedFileTypes()) + .dailyLimit(dailyLimit) + .uploadsToday(uploadsToday) + .uploadsRemaining(Math.max(0, dailyLimit - uploadsToday)) + .build(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index cd4b873..6f1b647 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -1,22 +1,25 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.response.AdminUploadDto; -import com.ibrasoft.lensbridge.dto.response.UserStatsResponse; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; +import com.ibrasoft.lensbridge.dto.upload.response.UploadDto; +import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.event.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.UploadRepository; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; + +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Optional; import java.util.UUID; @@ -36,382 +39,245 @@ public class UploadService { @Value("${uploads.default-featured:false}") private boolean defaultFeatured; - public void approveUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setApproved(true); - uploadRepository.save(upload); - log.info("Upload {} approved successfully", uploadId); - } else { - log.warn("Attempted to approve non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - public void featureUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setFeatured(true); - uploadRepository.save(upload); - log.info("Upload {} featured successfully", uploadId); - } else { - log.warn("Attempted to feature non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - public void unfeatureUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setFeatured(false); - uploadRepository.save(upload); - log.info("Upload {} unfeatured successfully", uploadId); - } else { - log.warn("Attempted to unfeature non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - public void unapproveUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setApproved(false); - uploadRepository.save(upload); - log.info("Upload {} unapproved successfully", uploadId); - } else { - log.warn("Attempted to unapprove non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - /** - * Count uploads for a user today - */ - public long countUploadsToday(UUID userId) { - LocalDate today = LocalDate.now(); - LocalDateTime startOfDay = today.atStartOfDay(); - LocalDateTime endOfDay = today.atTime(23, 59, 59); - - return uploadRepository.countByUploadedByAndCreatedDateBetween( - userId, - startOfDay, - endOfDay); - } - - /** - * Check if user has reached their daily upload limit - */ - public boolean hasReachedDailyLimit(UUID userId, int dailyLimit) { - return countUploadsToday(userId) >= dailyLimit; - } + // ── Entity creation ─────────────────────────────────────────────────────── - /** - * Create an Upload entity for a file that has been directly uploaded to R2. - * This method is used when files are uploaded via presigned URLs. - */ - public Upload createDirectUpload(String objectKey, String fileName, String contentType, + public Upload createUpload(String objectKey, String fileName, UUID eventId, String description, String instagramHandle, boolean anon, UUID uploadedBy) { try { - // Determine upload type from content type - UploadType uploadType; - if (contentType != null && contentType.startsWith("image")) { - uploadType = UploadType.IMAGE; - } else if (contentType != null && contentType.startsWith("video")) { - uploadType = UploadType.VIDEO; - } else { - uploadType = UploadType.IMAGE; // Default fallback - } - - // Create Upload entity - UUID uuid = UUID.randomUUID(); - Upload upload = new Upload( - uuid, - fileName, - objectKey, - null, // thumbnailUrl - will be set async by ThumbnailService - description, - instagramHandle, - uploadedBy, - eventId, - LocalDateTime.now(), - defaultApproved, - defaultFeatured, - anon, - uploadType); + User user = userService.findById(uploadedBy) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + + Upload upload = new Upload(); + upload.setFileName(fileName); + upload.setFileUrl(objectKey); + upload.setThumbnailUrl(null); + upload.setUploadDescription(description); + upload.setInstagramHandle(instagramHandle); + upload.setUploadedBy(user); + upload.setMediaEvent(mediaEvent); + upload.setCreatedDate(Instant.now()); + upload.setApproved(defaultApproved); + upload.setFeatured(defaultFeatured); + upload.setAnon(anon); + upload.setContentType(UploadType.IMAGE); uploadRepository.save(upload); - log.info("Created direct upload record: {} for object: {}", uuid, objectKey); + log.info("Created upload record: {} for object: {}", upload.getUuid(), objectKey); return upload; - } catch (Exception e) { - log.error("Failed to create direct upload record for object: {}", objectKey, e); - throw new FileProcessingException("Failed to create upload record for direct upload"); + log.error("Failed to create upload record for object: {}", objectKey, e); + throw new FileProcessingException("Failed to create upload record"); } } - public Page getAllUploads(Pageable pageable) { - return uploadRepository.findAll(pageable); + // ── Status mutations ────────────────────────────────────────────────────── + + public void approveUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setApproved(true); + uploadRepository.save(upload); + log.info("Upload {} approved", uploadId); } - public Page getUploadsByEvent(UUID eventId, Pageable pageable) { - return uploadRepository.findByEventId(eventId, pageable); + public void unapproveUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setApproved(false); + uploadRepository.save(upload); + log.info("Upload {} unapproved", uploadId); } - public Optional getUploadById(UUID id) { - return uploadRepository.findById(id); + public void featureUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setFeatured(true); + uploadRepository.save(upload); + log.info("Upload {} featured", uploadId); } - public Upload updateUpload(Upload upload) { - return uploadRepository.save(upload); + public void unfeatureUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setFeatured(false); + uploadRepository.save(upload); + log.info("Upload {} unfeatured", uploadId); } + // ── Deletion ────────────────────────────────────────────────────────────── + public void deleteUpload(UUID id) { Optional uploadOpt = uploadRepository.findById(id); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - try { - // Delete original file from R2 storage - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - if (objectKey != null) { - r2StorageService.deleteObject(objectKey); - } - // Delete thumbnail from R2 storage if exists - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - r2StorageService.deleteObject(thumbnailKey); - } - } catch (Exception e) { - log.warn("Failed to delete file from R2 storage for upload {}: {}", id, e.getMessage()); - // Continue with database deletion even if R2 deletion fails - } + uploadOpt.ifPresent(upload -> { + deleteFromStorage(upload); + upload.setDeletedAt(Instant.now()); + uploadRepository.save(upload); + }); + if (uploadOpt.isEmpty()) { + log.warn("Attempted to delete non-existent upload: {}", id); + throw new IllegalArgumentException("Upload not found"); } - uploadRepository.deleteById(id); } - /** - * Delete user's own upload with ownership validation - */ public void deleteUserUpload(UUID uploadId, UUID userId) { - log.info("User {} attempting to delete upload {}", userId, uploadId); - - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isEmpty()) { - throw new IllegalArgumentException("Upload not found"); - } + log.info("User {} deleting upload {}", userId, uploadId); - Upload upload = uploadOpt.get(); + Upload upload = uploadRepository.findById(uploadId) + .orElseThrow(() -> new IllegalArgumentException("Upload not found")); - // Verify ownership - if (!upload.getUploadedBy().equals(userId)) { + if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { throw new SecurityException("You can only delete your own uploads"); } + deleteFromStorage(upload); + upload.setDeletedAt(Instant.now()); + userService.findById(userId).ifPresent(upload::setDeletedBy); + uploadRepository.save(upload); + log.info("Upload {} deleted by user {}", uploadId, userId); + } + + private void deleteFromStorage(Upload upload) { try { - // Delete original file from R2 storage - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - if (objectKey != null) { - r2StorageService.deleteObject(objectKey); - } - // Delete thumbnail from R2 storage if exists - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - r2StorageService.deleteObject(thumbnailKey); - } + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + if (key != null) r2StorageService.deleteObject(key); + String thumbKey = upload.getThumbnailUrl(); + if (thumbKey != null && !thumbKey.isBlank()) r2StorageService.deleteObject(thumbKey); } catch (Exception e) { - log.warn("Failed to delete file from R2 storage for upload {}: {}", uploadId, e.getMessage()); - // Continue with database deletion even if R2 deletion fails + log.warn("Failed to delete R2 objects for upload {}: {}", upload.getUuid(), e.getMessage()); } + } - // Delete the upload from database - uploadRepository.deleteById(uploadId); - log.info("Upload {} deleted successfully by user {}", uploadId, userId); + // ── Queries ─────────────────────────────────────────────────────────────── + + public Optional getUploadById(UUID id) { + return uploadRepository.findById(id); } - /** - * Get all uploads with user information populated for admin interface. - * This method fetches uploads and includes the uploader's name and details. - */ - public Page getAllUploadsForAdmin(Pageable pageable) { - Page uploads = uploadRepository.findAll(pageable); - return uploads.map(this::convertToAdminUploadDto); + public Optional getUploadByIdAsDto(UUID id) { + return uploadRepository.findById(id).map(this::toUploadDto); } - /** - * Get uploads by approval status with user information for admin interface. - */ - public Page getUploadsByApprovalStatus(boolean approved, Pageable pageable) { - Page uploads = uploadRepository.findByApproved(approved, pageable); - return uploads.map(this::convertToAdminUploadDto); + public Page getAllUploads(Pageable pageable) { + return uploadRepository.findByDeletedAtIsNull(pageable); } - /** - * Get uploads by featured status with user information for admin interface. - */ - public Page getUploadsByFeaturedStatus(boolean featured, Pageable pageable) { - Page uploads = uploadRepository.findByFeatured(featured, pageable); - return uploads.map(this::convertToAdminUploadDto); + public Page getUploadsByEvent(UUID eventId, Pageable pageable) { + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable); } - public Page getUploadsByUploadedBy(UUID userId, Pageable pageable) { - return uploadRepository.findByUploadedBy(userId, pageable); + public Page getUploadsByEventAsDto(UUID eventId, Pageable pageable) { + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable).map(this::toUploadDto); } - /** - * Get user upload statistics - */ - public UserStatsResponse getUserStats(UUID userId) { - long totalUploads = uploadRepository.countByUploadedBy(userId); - long approvedUploads = uploadRepository.countByUploadedByAndApproved(userId, true); - long featuredUploads = uploadRepository.countByUploadedByAndFeatured(userId, true); - long pendingUploads = totalUploads - approvedUploads; - - return new UserStatsResponse( - (int) totalUploads, - (int) approvedUploads, - (int) featuredUploads, - (int) pendingUploads); + public Page getUploadsByUploadedBy(UUID userId, Pageable pageable) { + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable); } - /** - * Get user uploads as GalleryItemDTOs (user can see their own uploads - * regardless of approval status) - */ - public Page getUserUploadsAsGalleryItems(UUID userId, Pageable pageable) { - Page uploads = uploadRepository.findByUploadedBy(userId, pageable); - return uploads.map(upload -> convertToUserGalleryItem(upload, userId)); + public Page getAllUploadsForAdmin(Pageable pageable) { + return uploadRepository.findByDeletedAtIsNull(pageable).map(this::toAdminDto); } - /** - * Convert Upload to GalleryItemDto for user's own uploads (can see all their - * own content) - */ - private GalleryItemDto convertToUserGalleryItem(Upload upload, UUID userId) { - // Verify the user owns this upload - if (!upload.getUploadedBy().equals(userId)) { - throw new SecurityException("User can only access their own uploads"); - } + public Page getUploadsByApprovalStatus(boolean approved, Pageable pageable) { + return uploadRepository.findByApprovedAndDeletedAtIsNull(approved, pageable).map(this::toAdminDto); + } - GalleryItemDto item = new GalleryItemDto(); + public Page getUploadsByFeaturedStatus(boolean featured, Pageable pageable) { + return uploadRepository.findByFeaturedAndDeletedAtIsNull(featured, pageable).map(this::toAdminDto); + } - // Basic info - item.setId(upload.getUuid().toString()); - item.setTitle(upload.getUploadDescription() != null ? upload.getUploadDescription() : "Untitled"); - item.setFeatured(upload.isFeatured()); - item.setType(upload.getContentType().toString().toLowerCase()); + // ── Stats ───────────────────────────────────────────────────────────────── - // Generate secure URL (user can see their own content regardless of approval) - try { - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String secureUrl = r2StorageService.getSecureUrl(objectKey, true, false); // true for approved access, false - // for not admin - item.setSrc(secureUrl); - } catch (Exception e) { - log.error("Failed to generate secure URL for user upload {}: {}", upload.getUuid(), e.getMessage()); - item.setSrc(null); - } + public long countUploadsToday(UUID userId) { + LocalDate today = LocalDate.now(); + Instant startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return uploadRepository.countByUploadedByAndCreatedDateBetweenAndDeletedAtIsNull(user, startOfDay, endOfDay); + } - // Generate secure thumbnail - try { - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String thumbnailUrl = r2StorageService.getSecureThumbnailUrl(objectKey, true, false); - item.setThumbnail(thumbnailUrl); - } catch (Exception e) { - log.error("Failed to generate thumbnail for user upload {}: {}", upload.getUuid(), e.getMessage()); - item.setThumbnail(null); - } + public boolean hasReachedDailyLimit(UUID userId, int dailyLimit) { + return countUploadsToday(userId) >= dailyLimit; + } - // Set author (always the user's name for their own uploads, even if marked - // anonymous) - Optional userOpt = userService.findById(userId); - if (userOpt.isPresent()) { - User user = userOpt.get(); - item.setAuthor(user.getFirstName() + " " + user.getLastName()); - } else { - item.setAuthor("You"); - } + public UserStatsResponse getUserStats(UUID userId) { + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + long total = uploadRepository.countByUploadedByAndDeletedAtIsNull(user); + long approved = uploadRepository.countByUploadedByAndApprovedAndDeletedAtIsNull(user, true); + long featured = uploadRepository.countByUploadedByAndFeaturedAndDeletedAtIsNull(user, true); + return new UserStatsResponse((int) total, (int) approved, (int) featured, (int) (total - approved)); + } - // Event information - if (upload.getEventId() != null) { - Optional eventOpt = eventsService.getEventById(upload.getEventId()); - item.setEvent(eventOpt.map(Event::getName).orElse("General")); - } else { - item.setEvent("General"); - } + // ── Internal helpers ────────────────────────────────────────────────────── - // Format date - if (upload.getCreatedDate() != null) { - item.setDate(upload.getCreatedDate().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE)); - } else { - item.setDate("Unknown"); - } + private Upload findRequiredById(UUID id) { + return uploadRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Upload not found: " + id)); + } - return item; + private UploadDto toUploadDto(Upload upload) { + return new UploadDto( + upload.getUuid(), + upload.getFileName(), + upload.getFileUrl(), + upload.getThumbnailUrl(), + upload.getUploadDescription(), + upload.getInstagramHandle(), + upload.getUploadedBy() != null ? upload.getUploadedBy().getId() : null, + upload.getMediaEvent() != null ? upload.getMediaEvent().getId() : null, + upload.getMediaEvent() != null ? upload.getMediaEvent().getName() : null, + upload.getCreatedDate(), + upload.isApproved(), + upload.isFeatured(), + upload.isAnon(), + upload.getContentType()); } - /** - * Convert Upload entity to AdminUploadDto with user information and secure URLs - * populated. - * Generates time-limited signed URLs for admin access. - */ - private AdminUploadDto convertToAdminUploadDto(Upload upload) { + private AdminUploadDto toAdminDto(Upload upload) { AdminUploadDto dto = new AdminUploadDto(); - - // Copy upload fields dto.setUuid(upload.getUuid()); dto.setFileName(upload.getFileName()); - dto.setFileUrl(upload.getFileUrl()); // Keep original URL for internal reference + dto.setFileUrl(upload.getFileUrl()); dto.setUploadDescription(upload.getUploadDescription()); dto.setInstagramHandle(upload.getInstagramHandle()); - dto.setUploadedBy(upload.getUploadedBy()); - dto.setEventId(upload.getEventId()); + dto.setUploadedBy(upload.getUploadedBy() != null ? upload.getUploadedBy().getId() : null); + dto.setEventId(upload.getMediaEvent() != null ? upload.getMediaEvent().getId() : null); dto.setCreatedDate(upload.getCreatedDate()); dto.setApproved(upload.isApproved()); dto.setFeatured(upload.isFeatured()); dto.setAnon(upload.isAnon()); dto.setContentType(upload.getContentType()); - // Generate secure URLs for admin access try { - // Admins can view both approved and unapproved content - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String secureUrl = r2StorageService.getSecureUrl(objectKey, upload.isApproved(), true); - dto.setSecureUrl(secureUrl); - - // Generate secure thumbnail URL using the stored thumbnail key - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - String thumbnailUrl = r2StorageService.getSecureThumbnailUrl(thumbnailKey, upload.isApproved(), true); - dto.setThumbnailUrl(thumbnailUrl); - } else { - // Fallback: use original image URL as thumbnail if no thumbnail generated yet - dto.setThumbnailUrl(secureUrl); - } - + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + dto.setSecureUrl(r2StorageService.getSecureUrl(key, upload.isApproved(), true)); + String thumbKey = upload.getThumbnailUrl(); + String thumbUrl = (thumbKey != null && !thumbKey.isBlank()) + ? r2StorageService.getSecureThumbnailUrl(thumbKey, upload.isApproved(), true) + : dto.getSecureUrl(); + dto.setThumbnailUrl(thumbUrl); } catch (Exception e) { - // If secure URL generation fails, log error but don't break the DTO creation log.error("Failed to generate secure URLs for upload {}: {}", upload.getUuid(), e.getMessage()); dto.setSecureUrl(null); dto.setThumbnailUrl(null); } - Optional userOpt = userService.findById(upload.getUploadedBy()); - if (userOpt.isPresent()) { - User user = userOpt.get(); - dto.setUploaderFirstName(user.getFirstName()); - dto.setUploaderLastName(user.getLastName()); - dto.setUploaderEmail(user.getEmail()); - dto.setUploaderStudentNumber(user.getStudentNumber()); + if (upload.getUploadedBy() != null) { + User u = upload.getUploadedBy(); + dto.setUploaderFirstName(u.getFirstName()); + dto.setUploaderLastName(u.getLastName()); + dto.setUploaderEmail(u.getEmail()); + dto.setUploaderStudentNumber(u.getStudentNumber()); } - // Fetch and populate event information - if (upload.getEventId() != null) { - Optional eventOpt = eventsService.getEventById(upload.getEventId()); - eventOpt.ifPresent(event -> dto.setEventName(event.getName())); + if (upload.getMediaEvent() != null) { + dto.setEventName(upload.getMediaEvent().getName()); } return dto; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java new file mode 100644 index 0000000..1ab0fcd --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java @@ -0,0 +1,132 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.exception.EventNotAcceptingUploadsException; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.upload.Upload; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UploadWorkflowService { + + private final UploadService uploadService; + private final R2StorageService r2StorageService; + private final UploadLimitsService uploadLimitsService; + private final EventsService eventsService; + private final ImageProcessingService imageProcessingService; + + public PresignedUploadResponse initiateUpload( + UUID eventId, + String filename, + String contentType, + long fileSize, + String expectedSha256, + UUID userId, + Role role) { + + if (!eventsService.isEventAcceptingUploads(eventId)) { + throw new EventNotAcceptingUploadsException(eventId); + } + + uploadLimitsService.validateUpload(userId, role, fileSize, contentType); + + String objectKey = "images/" + UUID.randomUUID(); + String presignedUrl = r2StorageService.generatePresignedUploadUrl(objectKey, contentType, fileSize); + + PresignedUploadResponse.PresignedUploadResponseBuilder builder = PresignedUploadResponse.builder() + .uploadUrl(presignedUrl) + .objectKey(objectKey) + .eventId(eventId) + .method("PUT") + .contentType(contentType) + .expiresInMinutes(15) + .expectedSha256(expectedSha256); + + log.info("Presigned upload initiated for event {}, role {}, file: {}, size: {}MB", + eventId, role, filename, fileSize / 1024 / 1024); + + return builder.build(); + } + + public UploadCompletionResponse completeUpload( + UUID eventId, + String objectKey, + String filename, + String contentType, + long fileSize, + String instagramHandle, + String description, + boolean anon, + String expectedSha256, + UUID userId) { + + if (!r2StorageService.objectExists(objectKey)) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File not found in storage: " + objectKey), + "File not found in storage"); + } + + try { + verifyIntegrity(objectKey, fileSize, expectedSha256); + } catch (ApiResponseException e) { + r2StorageService.deleteObject(objectKey); + throw e; + } + + Upload upload = uploadService.createUpload( + objectKey, filename, eventId, description, instagramHandle, anon, userId); + + imageProcessingService.generateThumbnail(upload); + + log.info("Upload completed for event {}: {}", eventId, upload.getUuid()); + + return UploadCompletionResponse.builder() + .uploadId(upload.getUuid()) + .objectKey(objectKey) + .eventId(eventId) + .verified(true) + .fileSize(fileSize) + .build(); + } + + private void verifyIntegrity(String objectKey, long expectedSize, String expectedSha256) { + try { + long storedSize = r2StorageService.getObjectMetadata(objectKey).contentLength(); + if (storedSize != expectedSize) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File size mismatch: expected " + expectedSize + " bytes, got " + storedSize), + "File size mismatch"); + } + + String actualHash = r2StorageService.calculateSha256Hash(objectKey); + if (!actualHash.equalsIgnoreCase(expectedSha256)) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File integrity check failed"), + "SHA-256 mismatch"); + } + + } catch (ApiResponseException e) { + throw e; + } catch (Exception e) { + log.warn("Integrity verification failed for {}: {}", objectKey, e.getMessage()); + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("Failed to verify file integrity"), + "Integrity verification error"); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index e5d6756..8670086 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -1,21 +1,37 @@ package com.ibrasoft.lensbridge.service; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.HexFormat; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import com.ibrasoft.lensbridge.dto.request.ChangePasswordRequest; -import com.ibrasoft.lensbridge.dto.request.SignupRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.CreateUserRequest; +import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.repository.UserRepository; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.security.services.AuthorityResolver; import com.ibrasoft.lensbridge.security.services.EmailService; import lombok.RequiredArgsConstructor; @@ -25,293 +41,451 @@ @RequiredArgsConstructor @Slf4j public class UserService { - + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; - + private final VerificationTokenService verificationTokenService; + private final RefreshTokenService refreshTokenService; + private final AuthorityResolver authorityResolver; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + @Value("${frontend.baseurl}") private String frontendBaseUrl; - /** - * Get all users with pagination - */ - public Page getAllUsers(Pageable pageable) { - log.info("Fetching all users with pagination: {}", pageable); - return userRepository.findAll(pageable); + public Page getAllUsers(Pageable pageable) { + return userRepository.findAll(pageable).map(u -> { + // Fetched once and used for both lists. Going through AuthorityResolver here + // would re-query the same direct grants, making a 50-row page 101 queries. + Set direct = userRepository.findDirectPermissions(u.getId()); + + Set effective = EnumSet.noneOf(Permission.class); + for (Role role : u.getRoles()) { + effective.addAll(role.getPermissions()); + } + effective.addAll(direct); + + return new UserInfoResponse( + u.getId(), u.getFirstName(), u.getLastName(), + u.getEmail(), u.getStudentNumber(), u.isVerified(), u.getRoles(), + authorities(direct), authorities(effective)); + }); } - - /** - * Find user by ID - */ + + private static List authorities(Set permissions) { + return permissions.stream().map(Permission::getAuthority).sorted().toList(); + } + public Optional findById(UUID id) { - log.debug("Finding user by ID: {}", id); return userRepository.findById(id); } - - /** - * Find user by email - */ + public Optional findByEmail(String email) { - log.debug("Finding user by email: {}", email); return userRepository.findByEmail(email); } - - /** - * Find user by verification token - */ - public Optional findByVerificationToken(String token) { - log.debug("Finding user by verification token"); - return userRepository.findByVerificationToken(token); - } - - /** - * Check if email exists - */ + public boolean existsByEmail(String email) { return userRepository.existsByEmail(email); } - - /** - * Check if student number exists - */ + public boolean existsByStudentNumber(String studentNumber) { return userRepository.existsByStudentNumber(studentNumber); } - - /** - * Save user - */ + public User saveUser(User user) { - log.debug("Saving user: {}", user.getEmail()); return userRepository.save(user); } - + + public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { + log.info("Creating new user: {}", signUpRequest.getEmail()); + + User savedUser = register( + signUpRequest.getFirstName(), + signUpRequest.getLastName(), + signUpRequest.getStudentNumber(), + signUpRequest.getEmail(), + () -> signUpRequest.getPassword() == null ? null : passwordEncoder.encode(signUpRequest.getPassword()) + ); + + if (sendConfirmEmail) { + String token = verificationTokenService.generateEmailVerificationToken(savedUser); + String verificationUrl = frontendBaseUrl + "/verify-email?token=" + token; + emailService.sendVerificationEmail(savedUser.getEmail(), + savedUser.getFirstName(), verificationUrl); + } + + log.info("User created: {}", savedUser.getEmail()); + return savedUser; + } + + public User createUser(SignupRequest signUpRequest) { + return createUser(signUpRequest, true); + } + /** - * Create a new user from signup request + * Creates an account on an administrator's behalf. + *

+ * With no password supplied the account is created disabled — {@code verifiedAt} + * stays null, which is what {@code UserDetailsServiceImpl} reports as + * {@code disabled(true)} — and the invitee gets a password reset email. Setting a password + * through that link is what turns the account on, so the administrator never handles a + * credential and never has to transmit one out of band. + *

+ * A supplied password is taken as "make this usable now", so the account is marked verified + * immediately and no email goes out. Nothing else here needs an unverified branch: the + * address was chosen by an administrator, not self-asserted by a stranger, so email + * verification is not what is being established. */ - public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { - log.info("Creating new user with email: {}", signUpRequest.getEmail()); - - // Check if user already exists - if (existsByEmail(signUpRequest.getEmail()) || existsByStudentNumber(signUpRequest.getStudentNumber())) { - throw new IllegalArgumentException("User with this email or student number already exists"); - } - - // Create new user - User user = new User( - signUpRequest.getFirstName(), - signUpRequest.getLastName(), - signUpRequest.getStudentNumber(), - signUpRequest.getEmail(), - (signUpRequest.getPassword() == null) ? null : passwordEncoder.encode(signUpRequest.getPassword()) + public User createUserByAdmin(CreateUserRequest request) { + log.info("Admin creating user: {} (password supplied: {})", request.getEmail(), request.hasPassword()); + + User user = register( + request.getFirstName(), + request.getLastName(), + request.getStudentNumber(), + request.getEmail(), + () -> request.hasPassword() ? passwordEncoder.encode(request.getPassword()) : unusablePassword() ); - - user.setRoles(List.of()); - user.setVerified(false); - - // Generate verification token and send email - String verificationToken = emailService.generateToken(); - user.setVerificationToken(verificationToken); - - User savedUser = saveUser(user); - - // Send verification email - String verifyUrl = String.format("%s/confirm?token=%s", frontendBaseUrl, verificationToken); - if (sendConfirmEmail) { - emailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), verifyUrl); + + if (request.hasPassword()) { + user.setVerifiedAt(Instant.now()); + user = saveUser(user); + log.info("Admin created enabled user: {}", user.getEmail()); } else { - log.info("Skipping email confirmation for user: {}", user.getEmail()); + sendPasswordResetEmail(user); + log.info("Admin created disabled user {}, password reset email sent", user.getEmail()); } - - log.info("User created successfully: {}", user.getEmail()); - return savedUser; + + return user; } - public User createUser(SignupRequest signUpRequest) { - return createUser(signUpRequest, true); // Default to sending confirmation email + /** + * Duplicate check, {@code ROLE_USER}, save. Shared by self-signup and admin creation. + * + * @param passwordHash supplied lazily so a rejected duplicate does not pay for a bcrypt round + * first — and so the check cannot be reordered after it by accident + */ + private User register(String firstName, String lastName, String studentNumber, String email, + Supplier passwordHash) { + if (existsByEmail(email) || existsByStudentNumber(studentNumber)) { + throw new IllegalArgumentException("User with this email or student number already exists"); + } + + User user = new User(firstName, lastName, studentNumber, email, passwordHash.get()); + user.setRoles(new HashSet<>()); + user.addRole(Role.USER); + + return saveUser(user); } - + /** - * Verify user email using verification token + * A hash nothing can match, for an account whose owner has not chosen a password yet. + *

+ * {@code User.passwordHash} is {@code @NotBlank} and {@code nullable = false}, so a null is + * not storable; a placeholder rather than a schema change also keeps + * {@code UserDetailsServiceImpl} away from a null password, which the Spring + * {@code User} builder rejects outright. The input is 32 random bytes that are never + * retained, so the only route into the account is the reset link. */ - public User verifyUserEmail(String verificationToken) { - log.info("Verifying user email with token"); - - User user = findByVerificationToken(verificationToken) - .orElseThrow(() -> new IllegalArgumentException("Invalid or expired verification token")); - - user.setVerified(true); - user.setVerificationToken(null); - user.addRole(Role.ROLE_VERIFIED); - - User savedUser = saveUser(user); - log.info("User email verified successfully: {}", user.getEmail()); - emailService.sendWelcomeEmail(user.getEmail(), user.getFirstName()); - return savedUser; + private String unusablePassword() { + byte[] secret = new byte[32]; + SECURE_RANDOM.nextBytes(secret); + return passwordEncoder.encode(HexFormat.of().formatHex(secret)); + } + + public User verifyUserEmail(String plaintextToken) { + return verificationTokenService.consumeEmailVerification(plaintextToken); } public User verifyDirectly(UUID userId) { - log.info("Directly verifying user with ID: {}", userId); - + log.info("Directly verifying user: {}", userId); User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - + .orElseThrow(() -> new IllegalArgumentException("User not found")); + if (user.isVerified()) { throw new IllegalArgumentException("User is already verified"); } - - user.setVerified(true); - user.setVerificationToken(null); - user.addRole(Role.ROLE_VERIFIED); - - User savedUser = saveUser(user); - log.info("User email verified successfully: {}", user.getEmail()); - return savedUser; + + user.setVerifiedAt(Instant.now()); + User saved = saveUser(user); + log.info("User directly verified: {}", user.getEmail()); + return saved; } - - /** - * Add admin role to user - */ - public User addRole(UUID userId, Role role) { - log.info("Adding admin role to user: {}", userId); - + + // ==================== Grants ==================== + // + // Three rules apply to every change below, and they are the difference between a + // permission system and a suggestion: + // + // 1. Nobody edits their own grants. Otherwise iam:role:grant is self-service ROOT. + // 2. Nobody hands out what they do not hold. Otherwise iam:role:grant is ROOT anyway, + // one step removed. + // 3. Nobody takes away what they do not hold either. Rule 2 alone is not enough: + // demotion is an escalation path. Someone holding a direct iam:role:grant could + // otherwise strip ROLE_ROOT from every root account one at a time — rule 4 never + // fires, because their own direct grant keeps the holder set non-empty — and end + // as the only administrator left standing. + // 4. The last holder of iam:role:grant cannot be stripped of it, or the org locks + // itself out of its own admin console with no way back in short of a DB edit. + + public User addRole(String actingEmail, UUID userId, Role role) { User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "roles"); + denyEscalation(acting, role.getPermissions(), "role " + role); + if (user.hasRole(role)) { - throw new IllegalArgumentException("User already has this role: " + role.getAuthority()); + throw new IllegalArgumentException("User already has role: " + role); } - user.addRole(role); - User savedUser = saveUser(user); - - log.info("Role {} added successfully to user: {}", role.getAuthority(), user.getEmail()); - return savedUser; + log.info("{} granted role {} to {}", actingEmail, role, user.getEmail()); + return saveUser(user); } - public User removeRole(UUID userId, Role role) { - log.info("Removing role {} from user: {}", role.getAuthority(), userId); - + public User removeRole(String actingEmail, UUID userId, Role role) { User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "roles"); + denyDeprivation(acting, role.getPermissions(), "role " + role); + if (!user.hasRole(role)) { - throw new IllegalArgumentException("User does not have this role: " + role.getAuthority()); + throw new IllegalArgumentException("User does not have role: " + role); } - - user.getRoles().remove(role.getAuthority()); - User savedUser = saveUser(user); - - log.info("Role {} removed successfully from user: {}", role.getAuthority(), user.getEmail()); - return savedUser; + denyLastGrantHolderRemovalIfLosingIt(user, withoutRole(user, role)); + user.getRoles().remove(role); + log.info("{} removed role {} from {}", actingEmail, role, user.getEmail()); + return saveUser(user); } - + /** - * Request password reset - generates token and sends email + * Grants a single permission on top of whatever the user's roles already confer. + *

+ * {@code @Transactional} is load-bearing: {@code User.directPermissions} is LAZY, and + * without an enclosing transaction {@code findById} hands back a detached entity whose + * collection throws on first touch. {@code open-in-view} is false in prod and true in + * dev, so the failure would appear only in production. */ - public void requestPasswordReset(String email) { - log.info("Password reset requested for email: {}", email); - - Optional userOpt = findByEmail(email); - if (userOpt.isEmpty()) { - // Don't reveal if email exists for security - but still log for monitoring - log.warn("Password reset requested for non-existent email: {}", email); - return; + @Transactional + public User grantPermission(String actingEmail, UUID userId, Permission permission) { + User user = findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "permissions"); + denyEscalation(acting, Set.of(permission), permission.getAuthority()); + + if (user.hasDirectPermission(permission)) { + throw new IllegalArgumentException("User already has permission: " + permission.getAuthority()); + } + user.addDirectPermission(permission); + log.info("{} granted permission {} to {}", actingEmail, permission.getAuthority(), user.getEmail()); + return saveUser(user); + } + + /** See {@link #grantPermission} on why this is transactional. */ + @Transactional + public User revokePermission(String actingEmail, UUID userId, Permission permission) { + User user = findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "permissions"); + denyDeprivation(acting, Set.of(permission), permission.getAuthority()); + + if (!user.hasDirectPermission(permission)) { + throw new IllegalArgumentException( + "User does not have a direct grant of: " + permission.getAuthority() + + " (a permission conferred by a role is removed by removing the role)"); + } + denyLastGrantHolderRemovalIfLosingIt(user, withoutDirectPermission(user, permission)); + user.getDirectPermissions().remove(permission); + log.info("{} revoked permission {} from {}", actingEmail, permission.getAuthority(), user.getEmail()); + return saveUser(user); + } + + private User requireActingUser(String actingEmail) { + return findByEmail(actingEmail) + .orElseThrow(() -> new SecurityException("Acting user not found: " + actingEmail)); + } + + private void denySelfModification(User acting, UUID targetUserId, String what) { + if (Objects.equals(acting.getId(), targetUserId)) { + throw new SecurityException("You cannot change your own " + what); } - - User user = userOpt.get(); - String resetToken = emailService.generateToken(); - user.setVerificationToken(resetToken); - saveUser(user); - - // Send password reset email - String resetUrl = String.format("%s/reset-password?token=%s", frontendBaseUrl, resetToken); - emailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), resetUrl); - - log.info("Password reset email sent to: {}", email); } - + + private void denyEscalation(User acting, Set requested, String label) { + Set held = authorityResolver.resolvePermissions(acting); + Set missing = new HashSet<>(requested); + missing.removeAll(held); + if (!missing.isEmpty()) { + throw new SecurityException("You cannot grant " + label + + " because you do not hold: " + missing.stream() + .map(Permission::getAuthority).sorted().toList()); + } + } + /** - * Validate reset token + * The mirror of {@link #denyEscalation}. Taking a capability away is as much a route to + * sole control as handing one out, so removals are held to the same bar. */ - public boolean validateResetToken(String token) { - log.debug("Validating reset token"); - return findByVerificationToken(token).isPresent(); + private void denyDeprivation(User acting, Set requested, String label) { + Set held = authorityResolver.resolvePermissions(acting); + Set missing = new HashSet<>(requested); + missing.removeAll(held); + if (!missing.isEmpty()) { + throw new SecurityException("You cannot remove " + label + + " because you do not hold: " + missing.stream() + .map(Permission::getAuthority).sorted().toList()); + } } - + /** - * Reset password using token + * Refuses to leave nobody able to grant roles. + *

+ * Only fires when the target would actually lose {@code iam:role:grant}: a ROOT who also + * carries it as a direct grant keeps it after {@code ROLE_ROOT} is removed, and that + * removal is nobody's lockout. Holders are counted by role bundle and by direct grant, + * so this stays correct if the permission is ever added to a role other than ROOT. + * + * @param remaining the target's permissions as they would be after the removal */ - public User resetPassword(String token, String newPassword) { - log.info("Resetting password using token"); - - if (newPassword == null || newPassword.length() < 6) { - throw new IllegalArgumentException("Password must be at least 6 characters long"); + private void denyLastGrantHolderRemovalIfLosingIt(User target, Set remaining) { + if (remaining.contains(Permission.IAM_ROLE_GRANT)) { + return; // keeps the capability by another path + } + if (!effectivePermissions(target).contains(Permission.IAM_ROLE_GRANT)) { + return; // never had it + } + + Set grantingRoles = Arrays.stream(Role.values()) + .filter(r -> r.getPermissions().contains(Permission.IAM_ROLE_GRANT)) + .collect(Collectors.toSet()); + + Set holders = new HashSet<>(); + if (!grantingRoles.isEmpty()) { + holders.addAll(userRepository.findIdsByRoleIn(grantingRoles)); + } + holders.addAll(userRepository.findIdsByDirectPermission(Permission.IAM_ROLE_GRANT)); + holders.remove(target.getId()); + + if (holders.isEmpty()) { + throw new SecurityException("Refusing to remove " + Permission.IAM_ROLE_GRANT.getAuthority() + + " from the last user holding it — grant it to someone else first"); } - - User user = findByVerificationToken(token) - .orElseThrow(() -> new IllegalArgumentException("Invalid or expired token")); - - user.setPassword(passwordEncoder.encode(newPassword)); - user.setVerificationToken(null); - - User savedUser = saveUser(user); - log.info("Password reset successfully for user: {}", user.getEmail()); - return savedUser; } - + /** - * Change user password (requires current password verification) + * Roles expanded plus direct grants. Computed here rather than through + * {@code AuthorityResolver} so the "held before" and "held after" sides of the lockout + * check are derived the same way and cannot disagree. */ + private Set effectivePermissions(User target) { + Set permissions = EnumSet.noneOf(Permission.class); + for (Role role : target.getRoles()) { + permissions.addAll(role.getPermissions()); + } + permissions.addAll(directPermissionsOf(target)); + return permissions; + } + + /** The target's effective permissions with {@code role} taken away, without mutating them. */ + private Set withoutRole(User target, Role role) { + Set remaining = EnumSet.noneOf(Permission.class); + for (Role kept : target.getRoles()) { + if (kept != role) remaining.addAll(kept.getPermissions()); + } + remaining.addAll(directPermissionsOf(target)); + return remaining; + } + + /** The target's effective permissions with a direct grant taken away. */ + private Set withoutDirectPermission(User target, Permission permission) { + Set remaining = EnumSet.noneOf(Permission.class); + for (Role kept : target.getRoles()) { + remaining.addAll(kept.getPermissions()); + } + for (Permission direct : directPermissionsOf(target)) { + if (direct != permission) remaining.add(direct); + } + return remaining; + } + + private Set directPermissionsOf(User target) { + if (target.getId() == null) return Set.of(); // unsaved entity + Set direct = userRepository.findDirectPermissions(target.getId()); + return direct == null ? Set.of() : direct; + } + + public void requestPasswordReset(String email) { + userRepository.findByEmail(email).ifPresent(this::sendPasswordResetEmail); + } + + private void sendPasswordResetEmail(User user) { + String token = verificationTokenService.generatePasswordResetToken(user); + String resetUrl = frontendBaseUrl + "/reset-password?token=" + token; + emailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), resetUrl); + } + + public boolean validateResetToken(String token) { + return verificationTokenService.isValidResetToken(token); + } + + /** + * Consumes a reset token and sets the new password. + *

+ * A still-unverified account is verified here as well, and that is load-bearing for + * {@link #createUserByAdmin}: an invited account is disabled until verified, so without + * this the invitee would set a password through the emailed link and still be refused at + * sign-in, with no way out. Following a link sent to the address proves control of it — + * the same thing the verification email proves — so nothing is being taken on trust. + */ + public User resetPassword(String token, String newPassword) { + VerificationToken verificationToken = verificationTokenService.consumePasswordReset(token); + User user = verificationToken.getUser(); + user.setPassword(passwordEncoder.encode(newPassword)); + if (!user.isVerified()) { + user.setVerifiedAt(Instant.now()); + } + User saved = saveUser(user); + refreshTokenService.revokeAllUserTokens(user.getId()); + return saved; + } + public User changePassword(User user, ChangePasswordRequest changePasswordRequest) { - log.info("Changing password for user: {}", user.getEmail()); - - // Verify current password if (!passwordEncoder.matches(changePasswordRequest.getCurrentPassword(), user.getPassword())) { throw new IllegalArgumentException("Current password is incorrect"); } - if (changePasswordRequest.getNewPassword().length() < 6) { throw new IllegalArgumentException("New password must be at least 6 characters long"); } - user.setPassword(passwordEncoder.encode(changePasswordRequest.getNewPassword())); - User savedUser = saveUser(user); - - log.info("Password changed successfully for user: {}", user.getEmail()); - return savedUser; + return saveUser(user); } - /** - * Update user profile information - */ public User updateProfile(UUID userId, UpdateProfileRequest updateRequest) { - log.info("Updating profile for user: {}", userId); - User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - - // Update fields if provided - if (updateRequest.getFirstName() != null && !updateRequest.getFirstName().trim().isEmpty()) { + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (updateRequest.getFirstName() != null && !updateRequest.getFirstName().isBlank()) { user.setFirstName(updateRequest.getFirstName().trim()); } - - if (updateRequest.getLastName() != null && !updateRequest.getLastName().trim().isEmpty()) { + if (updateRequest.getLastName() != null && !updateRequest.getLastName().isBlank()) { user.setLastName(updateRequest.getLastName().trim()); } - - if (updateRequest.getStudentNumber() != null && !updateRequest.getStudentNumber().trim().isEmpty()) { - // Check if the new student number is already taken by another user - String newStudentNumber = updateRequest.getStudentNumber().trim(); - if (!newStudentNumber.equals(user.getStudentNumber()) && existsByStudentNumber(newStudentNumber)) { - throw new IllegalArgumentException("Student number is already taken by another user"); + if (updateRequest.getStudentNumber() != null && !updateRequest.getStudentNumber().isBlank()) { + String newNum = updateRequest.getStudentNumber().trim(); + if (!newNum.equals(user.getStudentNumber()) && existsByStudentNumber(newNum)) { + throw new IllegalArgumentException("Student number is already taken"); } - user.setStudentNumber(newStudentNumber); + user.setStudentNumber(newNum); } - - User savedUser = saveUser(user); - log.info("Profile updated successfully for user: {}", user.getEmail()); - return savedUser; + + return saveUser(user); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java new file mode 100644 index 0000000..7bd414a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java @@ -0,0 +1,96 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.AuthTokenProperties; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.model.auth.VerificationToken.TokenType; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.repository.auth.VerificationTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; + +@Service +@RequiredArgsConstructor +public class VerificationTokenService { + + private final VerificationTokenRepository tokenRepository; + private final UserRepository userRepository; + private final AuthTokenProperties authTokenProperties; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + public String generateEmailVerificationToken(User user) { + return createToken(user, TokenType.EMAIL_VERIFICATION, + Duration.ofMillis(authTokenProperties.getVerificationExpirationMs())); + } + + public String generatePasswordResetToken(User user) { + return createToken(user, TokenType.PASSWORD_RESET, + Duration.ofMillis(authTokenProperties.getPasswordResetExpirationMs())); + } + + @Transactional + public User consumeEmailVerification(String plaintextToken) { + String hash = resolveHash(plaintextToken); + VerificationToken token = tokenRepository.findValidToken(hash, TokenType.EMAIL_VERIFICATION, Instant.now()) + .orElseThrow(() -> new IllegalArgumentException("Invalid or expired verification token")); + + token.setUsedAt(Instant.now()); + tokenRepository.save(token); + + User user = token.getUser(); + user.setVerifiedAt(Instant.now()); + return userRepository.save(user); + } + + @Transactional + public VerificationToken consumePasswordReset(String plaintextToken) { + String hash = resolveHash(plaintextToken); + VerificationToken token = tokenRepository.findValidToken(hash, TokenType.PASSWORD_RESET, Instant.now()) + .orElseThrow(() -> new IllegalArgumentException("Invalid or expired password reset token")); + + token.setUsedAt(Instant.now()); + return tokenRepository.save(token); + } + + public boolean isValidResetToken(String plaintextToken) { + String hash = resolveHash(plaintextToken); + return tokenRepository.findValidToken(hash, TokenType.PASSWORD_RESET, Instant.now()).isPresent(); + } + + private String createToken(User user, TokenType type, Duration ttl) { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + String plaintext = HexFormat.of().formatHex(bytes); + String hash = resolveHash(plaintext); + + VerificationToken token = VerificationToken.builder() + .tokenHash(hash) + .user(user) + .type(type) + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus(ttl)) + .build(); + + tokenRepository.save(token); + return plaintext; + } + + private String resolveHash(String plaintext) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(plaintext.getBytes()); + return HexFormat.of().formatHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java new file mode 100644 index 0000000..4bba70e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java @@ -0,0 +1,112 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Server-side state for one open agent WebSocket connection. + *

+ * A session moves through three phases: + *

    + *
  1. UNAUTH — connected, server has issued a {@code hello} with a challenge, + * but the client has not yet authenticated. Only an {@code auth} frame is allowed.
  2. + *
  3. AUTHED — Ed25519 challenge–response succeeded; {@link #deviceId} is bound.
  4. + *
  5. CLOSED — underlying transport closed.
  6. + *
+ */ +@Slf4j +public class AgentSession { + + public enum Phase { UNAUTH, AUTHED, CLOSED } + + @Getter private final UUID sessionId; + @Getter private final WebSocketSession transport; + @Getter private final String challenge; + + private final ObjectMapper objectMapper; + + private final AtomicLong outgoingSeq = new AtomicLong(0); + + /** Last seq received from the client (must be strictly increasing). 0 means none yet. */ + private long lastIncomingSeq = 0; + + @Getter private volatile Phase phase = Phase.UNAUTH; + @Getter private volatile UUID deviceId; + + public AgentSession(WebSocketSession transport, String challenge, ObjectMapper objectMapper) { + this.transport = transport; + this.objectMapper = objectMapper; + this.sessionId = UUID.randomUUID(); + this.challenge = challenge; + } + + /** Records that the next valid incoming seq is {@code seq + 1}. Returns false if this seq is a replay/regression. */ + public synchronized boolean acceptIncomingSeq(long seq) { + if (seq <= lastIncomingSeq) { + log.warn("session={} rejecting non-monotonic seq {} (last seen {})", sessionId, seq, lastIncomingSeq); + return false; + } + lastIncomingSeq = seq; + return true; + } + + public synchronized void markAuthenticated(UUID deviceId) { + if (phase != Phase.UNAUTH) { + throw new IllegalStateException("Session already past UNAUTH phase: " + phase); + } + this.deviceId = deviceId; + this.phase = Phase.AUTHED; + } + + public synchronized void markClosed() { + this.phase = Phase.CLOSED; + } + + /** Allocates the next outbound seq and returns the populated frame. */ + public OutgoingAgentFrame populateOutgoing(OutgoingAgentFrame.OutgoingAgentFrameBuilder builder) { + return builder.seq(outgoingSeq.incrementAndGet()).sessionId(sessionId).build(); + } + + public boolean send(OutgoingAgentFrame frame) { + try { + String json = objectMapper.writeValueAsString(frame); + synchronized (transport) { + if (transport.isOpen()) { + transport.sendMessage(new TextMessage(json)); + return true; + } + } + log.warn("session={} transport is closed; frame {} was not sent", sessionId, frame.getType()); + markClosed(); + return false; + } catch (JsonProcessingException e) { + log.error("session={} failed to serialize frame {}", sessionId, frame.getType(), e); + close(CloseStatus.SERVER_ERROR); + return false; + } catch (IOException e) { + log.warn("session={} transport send failed: {}", sessionId, e.getMessage()); + close(CloseStatus.SESSION_NOT_RELIABLE); + return false; + } + } + + public void close(CloseStatus status) { + try { + if (transport.isOpen()) transport.close(status); + } catch (IOException ignored) { + // best effort + } finally { + markClosed(); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java new file mode 100644 index 0000000..e9067cb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java @@ -0,0 +1,53 @@ +package com.ibrasoft.lensbridge.service.agent; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks live, authenticated agent sessions keyed by deviceId. + *

+ * A device has at most one live session at a time: if a second session authenticates as + * the same device, the older one is evicted (assumed to be a stale connection from a + * NAT-rebound agent reconnecting before the previous TCP session timed out). + */ +@Component +@Slf4j +public class AgentSessionRegistry { + + private final ConcurrentHashMap byDevice = new ConcurrentHashMap<>(); + + public Optional get(UUID deviceId) { + return Optional.ofNullable(byDevice.get(deviceId)); + } + + public void register(UUID deviceId, AgentSession session) { + AgentSession previous = byDevice.put(deviceId, session); + if (previous != null && previous != session) { + log.info("Evicting prior session for device {} (sessionId {}) in favour of {}", + deviceId, previous.getSessionId(), session.getSessionId()); + previous.close(CloseStatus.NORMAL.withReason("superseded")); + } + } + + /** Removes the session if it is still the registered one for this device. */ + public void unregister(UUID deviceId, AgentSession session) { + byDevice.remove(deviceId, session); + } + + public void closeIfPresent(UUID deviceId, CloseStatus status) { + AgentSession session = byDevice.remove(deviceId); + if (session != null) { + log.info("Closing session {} for device {}", session.getSessionId(), deviceId); + session.close(status); + } + } + + public int size() { + return byDevice.size(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java new file mode 100644 index 0000000..2539923 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java @@ -0,0 +1,249 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.ibrasoft.lensbridge.dto.board.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.board.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import com.ibrasoft.lensbridge.model.board.commands.CommandKind; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Bridges admin REST → agent WebSocket and the reverse path (ack/progress/result frames). + *

+ * Responsibilities: + *

    + *
  1. Issue: persist a {@link DeviceCommand}, push to a live session if one exists, + * otherwise leave PENDING for {@link #flushPending(UUID, AgentSession)}.
  2. + *
  3. Flush: on session auth, deliver all PENDING commands oldest-first.
  4. + *
  5. State machine: consume ack/progress/result frames and advance the + * {@link DeviceCommandStatus} state machine.
  6. + *
+ * Idempotency: the agent dedupes by {@code commandId}, so re-sending a command after a + * reconnect is safe — the agent re-emits a cached result. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class CommandDispatcher { + + private static final int DEFAULT_DEADLINE_MS = 30_000; + + /** + * How long an undelivered command stays deliverable. Long enough to survive a kiosk + * restart or a brief WiFi drop, short enough that nothing surprising fires after an + * outage. Overridable per command via {@code ttlSeconds}. + */ + private static final Duration DEFAULT_TTL = Duration.ofMinutes(5); + + private final DeviceCommandRepository commandRepository; + private final DeviceRepository deviceRepository; + private final AgentSessionRegistry registry; + private final ObjectMapper objectMapper; + private final DeviceEventPublisher events; + + @Transactional + public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandRequest request) { + CommandKind kind = CommandKind.from(request.kind()).orElseThrow(() -> + new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown command kind: " + request.kind())); + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Device not found")); + if (device.getRevokedAt() != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Device is revoked"); + } + + JsonNode payload = request.payload() == null ? NullNode.getInstance() : request.payload(); + String payloadJson; + try { + payloadJson = objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid payload"); + } + + int deadline = request.deadlineMs() == null ? DEFAULT_DEADLINE_MS : request.deadlineMs(); + Duration ttl = request.ttlSeconds() == null ? DEFAULT_TTL : Duration.ofSeconds(request.ttlSeconds()); + Instant issuedAt = Instant.now(); + + DeviceCommand cmd = commandRepository.save(DeviceCommand.builder() + .deviceId(deviceId) + .kind(request.kind()) + .payloadJson(payloadJson) + .issuedBy(issuedBy) + .deadlineMs(deadline) + .issuedAt(issuedAt) + .expiresAt(issuedAt.plus(ttl)) + .status(DeviceCommandStatus.PENDING) + .build()); + + events.commandIssued(cmd); + + registry.get(deviceId).ifPresent(session -> deliverTo(cmd, session)); + + log.info("Issued command {} ({}, risk={}) for device {} by {} — initial status {}", + cmd.getId(), cmd.getKind(), kind.getRisk(), deviceId, issuedBy, cmd.getStatus()); + + return new CommandIssuedResponse( + cmd.getId(), cmd.getDeviceId(), cmd.getKind(), cmd.getStatus(), + cmd.getIssuedAt(), cmd.getExpiresAt()); + } + + /** + * Delivers PENDING commands for the device to the freshly-authenticated session, + * oldest first. Anything past its {@code expiresAt} is retired rather than delivered — + * a device returning from a long outage must not replay a queue of stale commands. + */ + @Transactional + public void flushPending(UUID deviceId, AgentSession session) { + List pending = commandRepository + .findByDeviceIdAndStatusOrderByIssuedAtAsc(deviceId, DeviceCommandStatus.PENDING); + if (pending.isEmpty()) return; + + Instant now = Instant.now(); + int delivered = 0; + int expired = 0; + for (DeviceCommand cmd : pending) { + if (isExpired(cmd, now)) { + expire(cmd, now); + expired++; + } else { + deliverTo(cmd, session); + delivered++; + } + } + log.info("Flushed {} pending command(s) to device {} ({} expired)", delivered, deviceId, expired); + } + + /** Retires a command that outlived its delivery window. */ + void expire(DeviceCommand cmd, Instant now) { + cmd.setStatus(DeviceCommandStatus.EXPIRED); + cmd.setFinishedAt(now); + cmd.setErrorMessage("Expired before delivery (device offline since " + cmd.getIssuedAt() + ")"); + commandRepository.save(cmd); + events.commandTerminated(cmd); + log.info("Command {} ({}) for device {} expired undelivered", cmd.getId(), cmd.getKind(), cmd.getDeviceId()); + } + + /** Null {@code expiresAt} means a row predating expiry support; treat it as non-expiring. */ + static boolean isExpired(DeviceCommand cmd, Instant now) { + return cmd.getExpiresAt() != null && cmd.getExpiresAt().isBefore(now); + } + + private void deliverTo(DeviceCommand cmd, AgentSession session) { + try { + JsonNode payload = cmd.getPayloadJson() == null + ? NullNode.getInstance() + : objectMapper.readTree(cmd.getPayloadJson()); + + OutgoingAgentFrame frame = session.populateOutgoing(OutgoingAgentFrame.builder() + .type("command") + .commandId(cmd.getId()) + .kind(cmd.getKind()) + .issuedBy(cmd.getIssuedBy()) + .deadlineMs(cmd.getDeadlineMs()) + .payload(payload)); + if (!session.send(frame)) { + log.warn("Leaving command {} PENDING because delivery to device {} did not complete", + cmd.getId(), cmd.getDeviceId()); + return; + } + + cmd.setStatus(DeviceCommandStatus.DELIVERED); + cmd.setDeliveredAt(Instant.now()); + commandRepository.save(cmd); + events.commandDelivered(cmd); + } catch (Exception e) { + log.error("Failed to deliver command {} to device {}: {}", + cmd.getId(), cmd.getDeviceId(), e.getMessage(), e); + } + } + + @Transactional + public void onAck(CommandAckFrame frame, AgentSession session) { + loadOwned(frame.getCommandId(), session.getDeviceId()).ifPresent(cmd -> { + if (cmd.getStatus().isTerminal()) return; + if (cmd.getAckedAt() != null) return; // idempotent re-ack + cmd.setStatus(DeviceCommandStatus.ACKED); + cmd.setAckedAt(Instant.now()); + commandRepository.save(cmd); + events.commandAcked(cmd); + }); + } + + @Transactional + public void onProgress(CommandProgressFrame frame, AgentSession session) { + loadOwned(frame.getCommandId(), session.getDeviceId()).ifPresent(cmd -> { + if (cmd.getStatus().isTerminal()) return; + if (cmd.getStatus() != DeviceCommandStatus.RUNNING) { + cmd.setStatus(DeviceCommandStatus.RUNNING); + if (cmd.getStartedAt() == null) cmd.setStartedAt(Instant.now()); + commandRepository.save(cmd); + } + events.commandProgress(cmd, frame); + }); + } + + @Transactional + public void onResult(CommandResultFrame frame, AgentSession session) { + loadOwned(frame.getCommandId(), session.getDeviceId()).ifPresent(cmd -> { + if (cmd.getStatus().isTerminal()) return; + + DeviceCommandStatus terminal = mapResultStatus(frame.getStatus()); + cmd.setStatus(terminal); + cmd.setFinishedAt(Instant.now()); + if (frame.getOutput() != null && !frame.getOutput().isNull()) { + cmd.setOutputJson(frame.getOutput().toString()); + } + if (frame.getErrorMessage() != null) { + cmd.setErrorMessage(frame.getErrorMessage()); + } + commandRepository.save(cmd); + events.commandResult(cmd, frame); + + log.info("Command {} for device {} terminated as {} (durationMs={})", + cmd.getId(), cmd.getDeviceId(), terminal, frame.getDurationMs()); + }); + } + + private Optional loadOwned(UUID commandId, UUID expectedDeviceId) { + if (commandId == null || expectedDeviceId == null) return Optional.empty(); + return commandRepository.findById(commandId).filter(cmd -> { + if (!expectedDeviceId.equals(cmd.getDeviceId())) { + log.warn("Frame references command {} owned by device {}, but session is for {}", + commandId, cmd.getDeviceId(), expectedDeviceId); + return false; + } + return true; + }); + } + + private static DeviceCommandStatus mapResultStatus(String status) { + if (status == null) return DeviceCommandStatus.FAILED; + return switch (status.toLowerCase()) { + case "ok", "success", "succeeded" -> DeviceCommandStatus.SUCCEEDED; + case "timeout" -> DeviceCommandStatus.TIMEOUT; + case "rejected" -> DeviceCommandStatus.REJECTED; + default -> DeviceCommandStatus.FAILED; + }; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java new file mode 100644 index 0000000..d73fcdf --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java @@ -0,0 +1,83 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Drives commands to a terminal state when the agent never will. + *

+ * Two failure modes are covered. A command can sit PENDING because the device never came + * back — that becomes {@link DeviceCommandStatus#EXPIRED}. Or the agent can take delivery + * and then die mid-execution, leaving the row DELIVERED/ACKED/RUNNING forever — that + * becomes {@link DeviceCommandStatus#TIMEOUT}. Without this, both statuses were declared + * but never assigned, and the admin UI showed commands as permanently in flight. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DeviceCommandReaper { + + /** Non-terminal states that imply the agent has the command and owes a result. */ + private static final Set IN_FLIGHT = EnumSet.of( + DeviceCommandStatus.DELIVERED, + DeviceCommandStatus.ACKED, + DeviceCommandStatus.RUNNING); + + /** Slack on top of a command's own deadline, covering agent→backend result latency. */ + private static final Duration RESULT_GRACE = Duration.ofSeconds(15); + + private final DeviceCommandRepository commandRepository; + private final CommandDispatcher dispatcher; + private final DeviceEventPublisher events; + + @Scheduled(fixedDelayString = "${musallahboard.command.reaperIntervalMs:60000}") + @Transactional + public void reap() { + Instant now = Instant.now(); + expireUndelivered(now); + timeoutInFlight(now); + } + + private void expireUndelivered(Instant now) { + List stale = + commandRepository.findByStatusAndExpiresAtBefore(DeviceCommandStatus.PENDING, now); + for (DeviceCommand cmd : stale) { + dispatcher.expire(cmd, now); + } + } + + private void timeoutInFlight(Instant now) { + Instant cutoff = now.minus(RESULT_GRACE); + List candidates = + commandRepository.findByStatusInAndDeliveredAtBefore(IN_FLIGHT, cutoff); + + for (DeviceCommand cmd : candidates) { + Instant deadline = cmd.getDeliveredAt() + .plusMillis(cmd.getDeadlineMs() == null ? 0 : cmd.getDeadlineMs()) + .plus(RESULT_GRACE); + if (!deadline.isBefore(now)) continue; + + DeviceCommandStatus stuckAt = cmd.getStatus(); + cmd.setStatus(DeviceCommandStatus.TIMEOUT); + cmd.setFinishedAt(now); + cmd.setErrorMessage("No result from agent within " + cmd.getDeadlineMs() + "ms of delivery"); + commandRepository.save(cmd); + events.commandTerminated(cmd); + log.warn("Command {} ({}) for device {} timed out while {}", + cmd.getId(), cmd.getKind(), cmd.getDeviceId(), stuckAt); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java new file mode 100644 index 0000000..bf456ba --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java @@ -0,0 +1,108 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.CalculationMethod; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.model.board.Location; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Orchestrates the agent enrollment flow: + *

    + *
  1. Validate and decode the device-supplied Ed25519 public key.
  2. + *
  3. Atomically consume the one-time enrollment token.
  4. + *
  5. Persist a Device row carrying the public key and metadata reported by the agent.
  6. + *
+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DeviceEnrollmentService { + + private static final int ED25519_PUBLIC_KEY_LEN = 32; + + private final EnrollmentTokenService enrollmentTokenService; + private final DeviceRepository deviceRepository; + private final BoardConfigRepository boardConfigRepository; + + public record EnrollResult(Device device) {} + + public sealed interface Outcome { + record Ok(Device device) implements Outcome {} + record InvalidPublicKey(String reason) implements Outcome {} + record InvalidToken() implements Outcome {} + } + + @Transactional + public Outcome enroll(String plaintextToken, + String publicKeyBase64, + String hostname, + String hardwareModel, + String agentVersion, + String remoteIp) { + byte[] publicKey; + try { + publicKey = Base64.getDecoder().decode(publicKeyBase64); + } catch (IllegalArgumentException e) { + return new Outcome.InvalidPublicKey("not base64"); + } + if (publicKey.length != ED25519_PUBLIC_KEY_LEN) { + return new Outcome.InvalidPublicKey("expected 32 bytes, got " + publicKey.length); + } + + UUID provisionalDeviceId = UUID.randomUUID(); + Optional consumed = enrollmentTokenService.consume(plaintextToken, provisionalDeviceId); + if (consumed.isEmpty()) { + return new Outcome.InvalidToken(); + } + EnrollmentToken token = consumed.get(); + + Device device = Device.builder() + .displayName(hostname != null && !hostname.isBlank() + ? token.getDisplayName() + " (" + hostname + ")" + : token.getDisplayName()) + .audience(token.getAudience()) + .publicKey(publicKey) + .hardwareModel(hardwareModel) + .agentVersion(agentVersion) + .lastSeenIp(remoteIp) + .enrolledAt(Instant.now()) + .build(); + + Device saved = deviceRepository.save(device); + + token.setConsumedByDeviceId(saved.getId()); + + DeviceConfig defaultConfig = DeviceConfig.builder() + .device(saved) + .location(Location.builder() + .city("Mississauga") + .country("Canada") + .latitude(43.589) + .longitude(-79.6441) + .timezone("America/Toronto") + .method(CalculationMethod.ISNA) + .build()) + .darkModeAfterIsha(true) + .enableScrollingMessage(true) + .scrollingMessages(List.of("Welcome to UTM MSA - Follow us @utmmsa for updates!")) + .build(); + boardConfigRepository.save(defaultConfig); + + log.info("Enrolled device {} from token {} (issued by {})", + saved.getId(), token.getId(), token.getCreatedBy()); + return new Outcome.Ok(saved); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java new file mode 100644 index 0000000..2aab2e2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java @@ -0,0 +1,108 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.repository.sql.EnrollmentTokenRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +/** + * Issues and consumes one-time enrollment tokens. + *

+ * The plaintext token is returned to the admin once at issue time and is never persisted — + * we only store its SHA-256 hash. Consumption is single-attempt: a successful consume marks + * the row before the caller is told "ok," so two concurrent agents racing on the same token + * cannot both win. + */ +@Service +@RequiredArgsConstructor +public class EnrollmentTokenService { + + /** 18 random bytes → 24 chars of url-safe base64 with no padding. ~144 bits of entropy. */ + private static final int TOKEN_RANDOM_BYTES = 18; + + private static final int DEFAULT_TTL_MINUTES = 30; + private static final int MAX_TTL_MINUTES = 60 * 24; + + private final EnrollmentTokenRepository repository; + private final SecureRandom random = new SecureRandom(); + + @Value("${musallahboard.enrollment.tokenPepper:}") + private String pepper; + + public record Issued(EnrollmentToken token, String plaintext) {} + + @Transactional + public Issued issue(String displayName, Audience audience, Integer ttlMinutes, String createdBy) { + int ttl = clampTtl(ttlMinutes); + String plaintext = generatePlaintext(); + + EnrollmentToken row = EnrollmentToken.builder() + .tokenHash(hash(plaintext)) + .displayName(displayName) + .audience(audience) + .createdBy(createdBy) + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus(Duration.ofMinutes(ttl))) + .build(); + + EnrollmentToken saved = repository.save(row); + return new Issued(saved, plaintext); + } + + /** + * Atomically marks a token as consumed iff it is unconsumed and unexpired. + * + * @return the consumed row on success, empty if the token is unknown / expired / already used + */ + @Transactional + public Optional consume(String plaintext, UUID consumedByDeviceId) { + if (plaintext == null || plaintext.isBlank()) return Optional.empty(); + + Optional found = repository.findByTokenHash(hash(plaintext)); + if (found.isEmpty()) return Optional.empty(); + + EnrollmentToken row = found.get(); + Instant now = Instant.now(); + if (row.getConsumedAt() != null) return Optional.empty(); + if (row.getExpiresAt().isBefore(now)) return Optional.empty(); + + row.setConsumedAt(now); + row.setConsumedByDeviceId(consumedByDeviceId); + return Optional.of(repository.save(row)); + } + + private int clampTtl(Integer requested) { + if (requested == null) return DEFAULT_TTL_MINUTES; + return Math.min(MAX_TTL_MINUTES, Math.max(1, requested)); + } + + private String generatePlaintext() { + byte[] raw = new byte[TOKEN_RANDOM_BYTES]; + random.nextBytes(raw); + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + } + + byte[] hash(String plaintext) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + if (pepper != null && !pepper.isEmpty()) { + digest.update(pepper.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + return digest.digest(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java new file mode 100644 index 0000000..8bc6938 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java @@ -0,0 +1,75 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceTelemetryRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.UUID; + +/** + * Persists incoming heartbeat frames: bumps {@link Device#getLastHeartbeat()} and + * appends a {@link DeviceTelemetry} row. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class HeartbeatService { + + private final DeviceRepository deviceRepository; + private final DeviceTelemetryRepository telemetryRepository; + private final DeviceEventPublisher events; + + @Transactional + public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { + Instant now = Instant.now(); + + HeartbeatFrame.Telemetry t = frame.getTelemetry(); + + deviceRepository.findById(deviceId).ifPresent(device -> { + device.setLastHeartbeat(now); + if (remoteIp != null && !remoteIp.isBlank()) { + device.setLastSeenIp(remoteIp); + } + // Enrollment set this once; a binary push changes it without re-enrolling. + String reported = t == null ? null : t.getAgentVersion(); + if (reported != null && !reported.isBlank() && !reported.equals(device.getAgentVersion())) { + log.info("Device {} agent version {} → {}", deviceId, device.getAgentVersion(), reported); + device.setAgentVersion(reported); + } + deviceRepository.save(device); + }); + + if (t == null) return; + + DeviceTelemetry row = DeviceTelemetry.builder() + .deviceId(deviceId) + .recordedAt(now) + .uptimeSec(t.getUptimeSec()) + .cpuTempC(t.getCpuTempC()) + .throttleFlags(t.getThrottleFlags()) + .memUsedMb(t.getMemUsedMb()) + .memTotalMb(t.getMemTotalMb()) + .diskUsedPct(t.getDiskUsedPct()) + .kioskAlive(t.getKioskAlive()) + .ipv4(t.getIpv4() == null ? null : String.join(",", t.getIpv4())) + .wifiSsid(t.getWifiSsid()) + .displayedFrameKey(truncate(t.getDisplayedFrameKey(), DeviceTelemetry.MAX_FRAME_KEY_LENGTH)) + .build(); + telemetryRepository.save(row); + + events.heartbeat(deviceId, frame); + } + + private static String truncate(String value, int max) { + if (value == null || value.length() <= max) return value; + return value.substring(0, max); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java new file mode 100644 index 0000000..f1dd7a5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java @@ -0,0 +1,126 @@ +package com.ibrasoft.lensbridge.service.agent.events; + +import com.ibrasoft.lensbridge.dto.board.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Thin wrapper over {@link SimpMessagingTemplate} that publishes structured events to + * the dashboard's STOMP topics. All payloads are plain {@link Map}s so the dashboard + * never needs to share schema with this module. + *

+ * If the STOMP broker is not enabled (e.g. in tests that don't load {@code StompConfig}), + * this component degrades to no-ops. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DeviceEventPublisher { + + private static final String TOPIC_DEVICES = "/topic/devices"; + + private final SimpMessagingTemplate messaging; + + public void deviceOnline(UUID deviceId) { + send(TOPIC_DEVICES, Map.of("event", "online", "deviceId", deviceId, "at", Instant.now())); + send(deviceTopic(deviceId), Map.of("event", "online", "deviceId", deviceId, "at", Instant.now())); + } + + public void deviceOffline(UUID deviceId) { + send(TOPIC_DEVICES, Map.of("event", "offline", "deviceId", deviceId, "at", Instant.now())); + send(deviceTopic(deviceId), Map.of("event", "offline", "deviceId", deviceId, "at", Instant.now())); + } + + public void heartbeat(UUID deviceId, HeartbeatFrame frame) { + Map body = new LinkedHashMap<>(); + body.put("event", "heartbeat"); + body.put("deviceId", deviceId); + body.put("at", Instant.now()); + body.put("telemetry", frame.getTelemetry()); + send(deviceTopic(deviceId), body); + } + + public void commandIssued(DeviceCommand cmd) { + publishCommandEvent(cmd, "issued", null); + } + + public void commandDelivered(DeviceCommand cmd) { + publishCommandEvent(cmd, "delivered", null); + } + + public void commandAcked(DeviceCommand cmd) { + publishCommandEvent(cmd, "acked", null); + } + + public void commandProgress(DeviceCommand cmd, CommandProgressFrame frame) { + Map extra = new LinkedHashMap<>(); + extra.put("stage", frame.getStage()); + extra.put("message", frame.getMessage()); + extra.put("percent", frame.getPercent()); + publishCommandEvent(cmd, "progress", extra); + } + + public void commandResult(DeviceCommand cmd, CommandResultFrame frame) { + Map extra = new LinkedHashMap<>(); + extra.put("status", frame.getStatus()); + extra.put("output", frame.getOutput()); + extra.put("durationMs", frame.getDurationMs()); + extra.put("errorMessage", frame.getErrorMessage()); + publishCommandEvent(cmd, "result", extra); + } + + /** + * Terminal transition the backend decided on its own (expiry, timeout) rather than one + * the agent reported. Emitted as {@code result} so dashboards need only one handler. + */ + public void commandTerminated(DeviceCommand cmd) { + Map extra = new LinkedHashMap<>(); + extra.put("errorMessage", cmd.getErrorMessage()); + publishCommandEvent(cmd, "result", extra); + } + + private void publishCommandEvent(DeviceCommand cmd, String event, Map extra) { + Map body = new LinkedHashMap<>(); + body.put("event", event); + body.put("commandId", cmd.getId()); + body.put("deviceId", cmd.getDeviceId()); + body.put("kind", cmd.getKind()); + body.put("status", cmd.getStatus()); + body.put("issuedAt", cmd.getIssuedAt()); + body.put("at", Instant.now()); + if (extra != null) body.putAll(extra); + + send(commandTopic(cmd.getDeviceId(), cmd.getId()), body); + send(deviceCommandsTopic(cmd.getDeviceId()), body); + } + + private void send(String destination, Object payload) { + try { + messaging.convertAndSend(destination, payload); + } catch (Exception e) { + log.warn("STOMP publish to {} failed: {}", destination, e.getMessage()); + } + } + + private static String deviceTopic(UUID deviceId) { + return TOPIC_DEVICES + "/" + deviceId; + } + + private static String deviceCommandsTopic(UUID deviceId) { + return TOPIC_DEVICES + "/" + deviceId + "/commands"; + } + + private static String commandTopic(UUID deviceId, UUID commandId) { + return TOPIC_DEVICES + "/" + deviceId + "/commands/" + commandId; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java new file mode 100644 index 0000000..1f7792e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java @@ -0,0 +1,35 @@ +package com.ibrasoft.lensbridge.service.agent.handshake; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * Canonical byte sequence the agent signs at handshake time. + *

+ * Both sides MUST construct it identically — the protocol version prefix lets us + * change the format in the future without breaking older agents (they will simply + * fail verification, which is the correct behaviour). + * + *

+ * musallahboard-auth-v1\n
+ * <sessionId>\n
+ * <challenge-base64>\n
+ * <deviceId>\n
+ * <timestamp-millis>
+ * 
+ */ +public final class AuthSignaturePayload { + + public static final String VERSION_PREFIX = "musallahboard-auth-v1"; + + private AuthSignaturePayload() {} + + public static byte[] build(UUID sessionId, String challengeBase64, UUID deviceId, long timestampMillis) { + String s = VERSION_PREFIX + "\n" + + sessionId + "\n" + + challengeBase64 + "\n" + + deviceId + "\n" + + timestampMillis; + return s.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java new file mode 100644 index 0000000..b1d7e87 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java @@ -0,0 +1,60 @@ +package com.ibrasoft.lensbridge.service.agent.handshake; + +import org.springframework.stereotype.Component; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; + +/** + * Verifies Ed25519 signatures using the JDK 17+ native provider. + *

+ * Public keys are persisted as the raw 32-byte Ed25519 point. We wrap the raw key + * into an X.509 SubjectPublicKeyInfo envelope at verification time so JCE can + * decode it. This keeps the on-the-wire and at-rest formats minimal. + */ +@Component +public class Ed25519Verifier { + + /** ASN.1 DER prefix for an Ed25519 SubjectPublicKeyInfo: AlgorithmIdentifier + BIT STRING header. */ + private static final byte[] X509_PREFIX = new byte[]{ + 0x30, 0x2a, // SEQUENCE, length 42 + 0x30, 0x05, // SEQUENCE, length 5 + 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519) + 0x03, 0x21, 0x00 // BIT STRING, length 33, 0 unused bits + }; + + private static final int RAW_PUBLIC_KEY_LEN = 32; + private static final int RAW_SIGNATURE_LEN = 64; + + /** + * @param rawPublicKey 32-byte Ed25519 public key + * @param message bytes that were signed + * @param signature 64-byte detached signature + * @return true iff the signature verifies + */ + public boolean verify(byte[] rawPublicKey, byte[] message, byte[] signature) { + if (rawPublicKey == null || rawPublicKey.length != RAW_PUBLIC_KEY_LEN) return false; + if (signature == null || signature.length != RAW_SIGNATURE_LEN) return false; + if (message == null) return false; + + try { + PublicKey publicKey = decodeRawPublicKey(rawPublicKey); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(message); + return verifier.verify(signature); + } catch (GeneralSecurityException e) { + return false; + } + } + + private static PublicKey decodeRawPublicKey(byte[] rawPublicKey) throws GeneralSecurityException { + byte[] x509 = new byte[X509_PREFIX.length + rawPublicKey.length]; + System.arraycopy(X509_PREFIX, 0, x509, 0, X509_PREFIX.length); + System.arraycopy(rawPublicKey, 0, x509, X509_PREFIX.length, rawPublicKey.length); + return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(x509)); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java index 3cbae53..a0f0df8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java @@ -1,46 +1,66 @@ package com.ibrasoft.lensbridge.service.board; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import lombok.Builder; import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import java.time.DateTimeException; import java.time.DayOfWeek; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.TemporalAdjusters; +/** + * Everything a payload assembly needs to know about "when" and "where" for one board. + *

+ * All boundaries are computed in the device's own timezone, never the server's. A board in + * Toronto and a backend container running UTC must agree on which events are "today". + *

+ * Weeks run Monday to Sunday, matching {@link com.ibrasoft.lensbridge.model.board.WeekId}, + * which keys the {@code weekly_content} table. The two used to disagree — events were + * bucketed Sunday–Saturday — so on Sundays a board showed next week's events next to last + * week's jummah times. + */ @Value @Builder +@Slf4j public class BoardContext { - BoardLocation location; - BoardConfig config; + + Device device; + DeviceConfig config; ZonedDateTime now; + /** The device's timezone, already resolved into {@link #now}. */ public ZoneId zone() { - // Prefer the board's configured timezone; fall back to system default. - if (config != null && config.getLocation() != null && config.getLocation().getTimezone() != null) { - try { - return ZoneId.of(config.getLocation().getTimezone()); - } catch (Exception ignored) { /* fallthrough */ } - } - return ZoneId.systemDefault(); + return now.getZone(); + } + + /** Local date in the device's timezone — the key for "which week's content". */ + public LocalDate today() { + return now.toLocalDate(); } public Instant currentWeekStart() { - return now.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) + return now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) .with(LocalTime.MIN) .toInstant(); } public Instant currentWeekEnd() { - return now.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + return now.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) .with(LocalTime.MAX) .toInstant(); } + public Instant rollingWindowEnd() { + return now.plusDays(6).with(LocalTime.MAX).toInstant(); + } + public Instant currentDayStart() { return now.with(LocalTime.MIN).toInstant(); } @@ -49,16 +69,34 @@ public Instant currentDayEnd() { return now.with(LocalTime.MAX).toInstant(); } - public static BoardContext of(BoardLocation location, BoardConfig config) { - ZoneId zone = ZoneId.systemDefault(); - if (config != null && config.getLocation() != null && config.getLocation().getTimezone() != null) { - try { zone = ZoneId.of(config.getLocation().getTimezone()); } - catch (Exception ignored) { /* keep default */ } - } + /** + * @param defaultZone used when the device has no timezone configured or the configured + * one is unparseable. Pass the deployment's default, not + * {@code ZoneId.systemDefault()} — the JVM's zone is an accident of + * the container image. + */ + public static BoardContext of(Device device, ZoneId defaultZone) { + DeviceConfig config = device.getConfig(); + ZoneId zone = resolveZone(device, config, defaultZone); return BoardContext.builder() - .location(location) + .device(device) .config(config) .now(ZonedDateTime.now(zone)) .build(); } + + private static ZoneId resolveZone(Device device, DeviceConfig config, ZoneId defaultZone) { + String configured = config == null || config.getLocation() == null + ? null + : config.getLocation().getTimezone(); + if (configured == null || configured.isBlank()) return defaultZone; + + try { + return ZoneId.of(configured); + } catch (DateTimeException e) { + log.warn("Device {} has an unparseable timezone '{}' — falling back to {}", + device.getId(), configured, defaultZone); + return defaultZone; + } + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index 773d0c3..ffec8bd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -1,67 +1,74 @@ package com.ibrasoft.lensbridge.service.board; -import com.ibrasoft.lensbridge.dto.response.MusallahBoardPayload; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import com.ibrasoft.lensbridge.model.board.Event; -import com.ibrasoft.lensbridge.model.board.Poster; -import com.ibrasoft.lensbridge.model.board.WeeklyContent; +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.service.BoardService; -import com.ibrasoft.lensbridge.service.PosterService; -import com.ibrasoft.lensbridge.service.board.transformer.EventListFrameTransformer; -import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; -import com.ibrasoft.lensbridge.service.board.transformer.WeeklyContentFrameTransformer; -import lombok.RequiredArgsConstructor; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.OpenWeatherService; +import com.ibrasoft.lensbridge.service.board.producer.FrameProducer; + +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import java.util.ArrayList; +import java.time.ZoneId; import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Slf4j +@AllArgsConstructor public class BoardPayloadAssembler { - private final BoardService boardService; - private final PosterService posterService; - private final PosterFrameTransformer posterTransformer; - private final EventListFrameTransformer eventListTransformer; - private final WeeklyContentFrameTransformer weeklyContentTransformer; + private final DeviceRepository deviceRepository; + private final OpenWeatherService openWeatherService; + + /** + * Every registered {@link FrameProducer}, Spring-ordered via {@code @Order} on each + * implementation. + */ + private final List frameProducers; + + private final ZoneId defaultZone; + + public MusallahBoardPayload assemble(UUID deviceId) { + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Device not found: " + deviceId))); - public MusallahBoardPayload assemble(BoardLocation location) { - BoardConfig config = boardService.getBoardConfig(location).orElse(null); - BoardContext ctx = BoardContext.of(location, config); + BoardContext ctx = BoardContext.of(device, defaultZone); - List frames = new ArrayList<>(); - frames.addAll(posterFrames(ctx)); - frames.addAll(eventFrames(ctx)); - frames.addAll(weeklyContentFrames(ctx)); + List frames = frameProducers.stream() + .flatMap(producer -> produceSafely(producer, ctx).stream()) + .collect(Collectors.toList()); return MusallahBoardPayload.builder() - .boardConfig(config) + .deviceConfig(ctx.getConfig()) .frames(frames) + .weather(openWeatherService.getCurrentWeather()) .build(); } - private List posterFrames(BoardContext ctx) { - List posters = posterService.getActivePosterFramesForBoard(ctx.getLocation()); - List out = new ArrayList<>(posters.size()); - for (Poster p : posters) out.add(posterTransformer.transform(p, ctx)); - return out; - } - - private List eventFrames(BoardContext ctx) { - long weekStart = ctx.currentWeekStart().toEpochMilli(); - long weekEnd = ctx.currentWeekEnd().toEpochMilli(); - List events = boardService.getEventsForBoardInRange(ctx.getLocation(), weekStart, weekEnd); - if (events.isEmpty()) return List.of(); - return List.of(eventListTransformer.transform(events, ctx)); - } - - private List weeklyContentFrames(BoardContext ctx) { - WeeklyContent content = boardService.getCurrentWeeklyContent().orElse(null); - return weeklyContentTransformer.transform(content, ctx); + /** + * Runs one producer, absorbing anything it throws so the rest of the payload still ships. + *

+ * These boards are unattended wall displays. A producer whose query fails should cost the + * board that producer's slides. A single producer failing should not make the entire API + * 500 + */ + private List produceSafely(FrameProducer producer, BoardContext ctx) { + try { + List frames = producer.produce(ctx); + return frames == null ? List.of() : frames; + } catch (RuntimeException e) { + log.error("Frame producer {} failed for device {}; omitting its frames from this payload", + producer.getClass().getSimpleName(), ctx.getDevice().getId(), e); + return List.of(); + } } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java new file mode 100644 index 0000000..50e89d9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java @@ -0,0 +1,113 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.dto.board.response.frames.DayBucket; +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.frames.AgendaFrameConfig; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Emits the single AGENDA frame: every day from today to the end of the rolling window, each + * carrying the events that fall on it in the device's timezone. + *

+ * This replaced the {@code event_list} and {@code daily_schedule} producers. Both ran the same + * query over different windows and differed only in how the frontend drew the result, so they + * were one frame with two layouts rather than two frames. Today's events are the first bucket. + */ +@Component +@RequiredArgsConstructor +@Order(2) +public class AgendaFrameProducer implements FrameProducer { + + private final BoardService boardService; + + @Override + public List produce(BoardContext ctx) { + List events = boardService.getEventsForAudienceInRange( + ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.rollingWindowEnd()); + + // Emitted even when the window is empty. The frames this replaced skipped themselves in + // that case, which left the board with a gap and no explanation; a frame reading "nothing + // scheduled" is the more honest slide. The board decides how to render an empty agenda. + return List.of(transform(events, ctx)); + } + + public FrameDefinition transform(List events, BoardContext ctx) { + AgendaFrameConfig config = AgendaFrameConfig.builder() + .heading("Next 7 Days") + .days(bucketByDay(events, ctx)) + .build(); + + return FrameDefinition.builder() + .frameId("agenda") + .frameType(FrameType.AGENDA) + .durationInSeconds(configuredDuration(ctx)) + .frameConfig(config) + .build(); + } + + /** + * Null means "auto": the board grows the dwell time with the number of events it ends up + * rendering. Only the board can decide that — it knows which day is current and how many + * rows survived the queue limit, and both change while a single payload is on screen. + */ + private Integer configuredDuration(BoardContext ctx) { + return ctx == null || ctx.getConfig() == null + ? null + : ctx.getConfig().getAgendaDurationSeconds(); + } + + /** + * Buckets by local date, emitting every day in the window whether or not it has events. + * The last day is derived from {@link BoardContext#rollingWindowEnd()} rather than hardcoded, + * so widening the window here needs no change. + *

+ * A multi-day event appears in every bucket it overlaps, not only the one it starts in — a + * conference running Monday to Wednesday should show up on Tuesday's agenda. + */ + private List bucketByDay(List events, BoardContext ctx) { + LocalDate firstDay = ctx.today(); + LocalDate lastDay = ctx.rollingWindowEnd().atZone(ctx.zone()).toLocalDate(); + + List buckets = new ArrayList<>(); + for (LocalDate day = firstDay; !day.isAfter(lastDay); day = day.plusDays(1)) { + Instant dayStart = day.atStartOfDay(ctx.zone()).toInstant(); + Instant dayEnd = day.plusDays(1).atStartOfDay(ctx.zone()).toInstant(); + + List onThisDay = events.stream() + .filter(e -> overlaps(e, dayStart, dayEnd)) + .sorted(Comparator.comparing(BoardEvent::getStartTime)) + .map(EventView::of) + .toList(); + + buckets.add(DayBucket.builder().date(day).events(onThisDay).build()); + } + return buckets; + } + + /** + * Half-open overlap: an event occupies {@code [start, end)} and the day is + * {@code [dayStart, dayEnd)}, so an event ending exactly at midnight belongs to the day it + * ended, not the next one. + *

+ * A zero-length event would overlap nothing under that rule, so it is treated as an instant + * on its start day. Same for the malformed {@code end < start} case. + */ + private boolean overlaps(BoardEvent event, Instant dayStart, Instant dayEnd) { + Instant start = event.getStartTime(); + Instant end = event.getEndTime().isAfter(start) ? event.getEndTime() : start.plusNanos(1); + return start.isBefore(dayEnd) && end.isAfter(dayStart); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java new file mode 100644 index 0000000..3d79b88 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java @@ -0,0 +1,17 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.service.board.BoardContext; + +import java.util.List; + +/** + * Fetches its own source data for one board and turns it into zero or more frames. + *

+ * {@link com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler} only knows this + * interface — it never references a concrete frame type. Adding a new frame type means + * adding one new {@code @Component} that implements this; the assembler does not change. + */ +public interface FrameProducer { + List produce(BoardContext ctx); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java new file mode 100644 index 0000000..438e6c9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java @@ -0,0 +1,45 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.NextPrayerFrameConfig; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Emits the NEXT_PRAYER frame. It carries no data — the countdown is computed client-side + * from {@code deviceConfig.location} — but declaring it here gives it a real + * {@code durationInSeconds} and a place in the ordered sequence instead of being synthesized + * and hardcoded in the frontend. + */ +@Component +@Order(1) +public class NextPrayerFrameProducer implements FrameProducer { + + public static final String FRAME_ID = "next_prayer"; + + @Override + public List produce(BoardContext ctx) { + return List.of(FrameDefinition.builder() + .frameId(FRAME_ID) + .frameType(FrameType.NEXT_PRAYER) + .durationInSeconds(configuredDuration(ctx)) + .frameConfig(new NextPrayerFrameConfig()) + .build()); + } + + /** + * Never null, unlike the agenda's: this slide renders a countdown whose size does not + * change with the data, so there is nothing for an "auto" mode to measure. A device with + * no config at all still gets the default rather than handing the board a null to guess at. + */ + private int configuredDuration(BoardContext ctx) { + return ctx == null || ctx.getConfig() == null + ? DeviceConfig.DEFAULT_NEXT_PRAYER_DURATION_SECONDS + : ctx.getConfig().getNextPrayerDurationSeconds(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java similarity index 50% rename from src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java rename to src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java index c718d54..1d9bcad 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java @@ -1,34 +1,46 @@ -package com.ibrasoft.lensbridge.service.board.transformer; +package com.ibrasoft.lensbridge.service.board.producer; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.model.board.frames.PosterFrameConfig; +import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.stream.Collectors; + @Component -public class PosterFrameTransformer implements FrameTransformer { +@RequiredArgsConstructor +@Order(3) +public class PosterFrameProducer implements FrameProducer { + + private static final String POSTER_FRAME_ID = "poster:"; + + private final PosterService posterService; @Override - public FrameType supports() { - return FrameType.POSTER; + public List produce(BoardContext ctx) { + List posters = + posterService.getActivePosterFramesForAudience(ctx.getDevice().getAudience()); + return posters.stream().map(p -> transform(p, ctx)).collect(Collectors.toList()); } - @Override public FrameDefinition transform(Poster poster, BoardContext ctx) { PosterFrameConfig config = PosterFrameConfig.builder() .posterUrl(poster.getImage()) .title(poster.getTitle()) + .signupUrl(poster.getSignupUrl()) .build(); return FrameDefinition.builder() + .frameId(POSTER_FRAME_ID + poster.getId()) .frameType(FrameType.POSTER) .durationInSeconds(poster.getDuration()) .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java new file mode 100644 index 0000000..fdb3af5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java @@ -0,0 +1,58 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.PromotableSocialMediaFrameConfig; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Emits one SOCIALS frame per social account promoted to this board's audience. + *

+ * This replaced a single hardcoded Instagram frame whose QR destination was read client-side + * from a since-removed {@code socialUrl} board-config field. Every field a social frame renders — platform, URL, + * copy, duration — is now backend data, so a board can promote several accounts and an + * operator can change any of them without a frontend deploy. + */ +@Component +@RequiredArgsConstructor +@Order(7) +public class SocialsFrameProducer implements FrameProducer { + + private final PromotableSocialMediaService socialMediaService; + + @Override + public List produce(BoardContext ctx) { + List socials = + socialMediaService.getForAudience(ctx.getDevice().getAudience()); + + List out = new ArrayList<>(); + + for (PromotableSocialMedia social : socials) { + PromotableSocialMediaFrameConfig config = PromotableSocialMediaFrameConfig.builder() + .url(social.getUrl()) + .socialType(social.getType()) + .headerText(social.getHeaderText()) + .heroText(social.getHeroText()) + .handle(social.getHandle()) + .footerText(social.getFooterText()) + .build(); + + out.add(FrameDefinition.builder() + .frameId("social:" + social.getId()) + .frameConfig(config) + .durationInSeconds(social.getDuration()) + .frameType(FrameType.SOCIALS) + .build()); + } + + return out; + } +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java similarity index 57% rename from src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java rename to src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java index f3a3966..f05fd2f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java @@ -1,14 +1,16 @@ -package com.ibrasoft.lensbridge.service.board.transformer; +package com.ibrasoft.lensbridge.service.board.producer; import com.ibrasoft.lensbridge.model.board.IslamicQuote; import com.ibrasoft.lensbridge.model.board.JummahPrayer; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.model.board.frames.IslamicQuoteFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.JummahFrameConfig; +import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.util.ArrayList; @@ -16,29 +18,44 @@ import java.util.stream.Collectors; /** - * Transforms a WeeklyContent document into up to three FrameDefinitions: - * one for the verse, one for the hadith, and one for jummah prayer info. + * Transforms a WeeklyContent entity into FrameDefinitions: + * one per quote (VERSE/HADITH kind) and one for jummah prayer info. * - * This transformer intentionally does NOT implement FrameTransformer because - * one source expands to multiple frames. The assembler (Phase 4) will inject it - * by concrete class. + * This intentionally does NOT implement FrameTransformer because one source expands + * to multiple frames — but it does implement {@link FrameProducer}, since that interface + * already returns a List. */ @Component -public class WeeklyContentFrameTransformer { +@RequiredArgsConstructor +@Order(6) +public class WeeklyContentFrameProducer implements FrameProducer { + + private final BoardService boardService; + + private final static String JUMMAH_FRAME_ID = "jummah"; + private final static String QUOTE_FRAME_ID = "quote:"; + + @Override + public List produce(BoardContext ctx) { + WeeklyContent content = boardService.getWeeklyContentFor(ctx.today()).orElse(null); + return transform(content, ctx); + } public List transform(WeeklyContent content, BoardContext ctx) { List out = new ArrayList<>(); if (content == null) return out; - if (content.getVerse() != null) { - out.add(quoteFrame(content.getVerse(), IslamicQuoteFrameConfig.Kind.VERSE)); + for (IslamicQuote quote : content.getQuotes()) { + IslamicQuoteFrameConfig.Kind kind = quote.getKind() == IslamicQuote.Kind.VERSE + ? IslamicQuoteFrameConfig.Kind.VERSE + : IslamicQuoteFrameConfig.Kind.HADITH; + out.add(quoteFrame(quote, kind)); } - if (content.getHadith() != null) { - out.add(quoteFrame(content.getHadith(), IslamicQuoteFrameConfig.Kind.HADITH)); - } - if (content.getJummahPrayer() != null && !content.getJummahPrayer().isEmpty()) { - out.add(jummahFrame(content.getJummahPrayer())); + + if (!content.getJummahPrayers().isEmpty()) { + out.add(jummahFrame(content.getJummahPrayers())); } + return out; } @@ -52,11 +69,10 @@ private FrameDefinition quoteFrame(IslamicQuote quote, IslamicQuoteFrameConfig.K .build(); return FrameDefinition.builder() + .frameId(QUOTE_FRAME_ID + quote.getId()) .frameType(FrameType.ISLAMIC_QUOTE) - .durationInSeconds(null) + .durationInSeconds(quote.getDurationSeconds()) .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) .build(); } @@ -65,7 +81,7 @@ private FrameDefinition jummahFrame(List prayers) { .map(p -> JummahFrameConfig.JummahSlot.builder() .prayerTime(p.getPrayerTime() != null ? p.getPrayerTime().toString() : null) .khatib(p.getKhatib()) - .location(p.getLocation()) + .room(p.getRoom()) .build()) .collect(Collectors.toList()); @@ -74,11 +90,10 @@ private FrameDefinition jummahFrame(List prayers) { .build(); return FrameDefinition.builder() + .frameId(JUMMAH_FRAME_ID) .frameType(FrameType.JUMMAH) .durationInSeconds(null) .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java deleted file mode 100644 index 8b74763..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.model.board.Event; -import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * Transforms a list of Events into a single DAILY_SCHEDULE FrameDefinition - * representing events for the current day. - */ -@Component -public class DailyScheduleFrameTransformer implements FrameTransformer> { - - @Override - public FrameType supports() { - return FrameType.DAILY_SCHEDULE; - } - - @Override - public FrameDefinition transform(List events, BoardContext ctx) { - List eventViews = events.stream() - .map(this::toEventView) - .collect(Collectors.toList()); - - DailyScheduleFrameConfig config = DailyScheduleFrameConfig.builder() - .heading("Today") - .events(eventViews) - .build(); - - return FrameDefinition.builder() - .frameType(FrameType.DAILY_SCHEDULE) - .durationInSeconds(null) - .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) - .build(); - } - - private EventListFrameConfig.EventView toEventView(Event event) { - return EventListFrameConfig.EventView.builder() - .name(event.getName()) - .description(event.getDescription()) - .location(event.getLocation()) - .startTimestamp(event.getStartTimestamp()) - .endTimestamp(event.getEndTimestamp()) - .allDay(event.getAllDay()) - .build(); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java deleted file mode 100644 index d7d2aa4..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.model.board.Event; -import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * Transforms a list of Events into a single EVENT_LIST FrameDefinition - * representing events for the current week. - */ -@Component -public class EventListFrameTransformer implements FrameTransformer> { - - @Override - public FrameType supports() { - return FrameType.EVENT_LIST; - } - - @Override - public FrameDefinition transform(List events, BoardContext ctx) { - List eventViews = events.stream() - .map(this::toEventView) - .collect(Collectors.toList()); - - EventListFrameConfig config = EventListFrameConfig.builder() - .heading("This Week") - .events(eventViews) - .build(); - - return FrameDefinition.builder() - .frameType(FrameType.EVENT_LIST) - .durationInSeconds(null) - .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) - .build(); - } - - private EventListFrameConfig.EventView toEventView(Event event) { - return EventListFrameConfig.EventView.builder() - .name(event.getName()) - .description(event.getDescription()) - .location(event.getLocation()) - .startTimestamp(event.getStartTimestamp()) - .endTimestamp(event.getEndTimestamp()) - .allDay(event.getAllDay()) - .build(); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java deleted file mode 100644 index a7abd50..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.service.board.BoardContext; - -public interface FrameTransformer { - FrameType supports(); - FrameDefinition transform(T source, BoardContext ctx); -} diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example new file mode 100644 index 0000000..ce443ec --- /dev/null +++ b/src/main/resources/application.properties.example @@ -0,0 +1,129 @@ +# Every property the app reads. Copy/split into application.properties, +# application-dev.properties, application-prod.properties as you see fit. +# Generate secrets with: openssl rand -base64 32 + +server.port=8080 +spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev} + +# Datasource (SQLite for dev, Postgres for prod) +spring.datasource.url=jdbc:sqlite:lensbridge-dev.db +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.datasource.username= +spring.datasource.password= +spring.jpa.properties.hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect +spring.jpa.properties.hibernate.connection.foreign_keys=true +spring.jpa.open-in-view=false + +# Schema is owned by Flyway; Hibernate only validates against it. Do not set ddl-auto=update +# here -- it would let Hibernate patch a schema Flyway believes it controls, and the two would +# disagree about what has been applied. Migrations are per-vendor because dev is SQLite and +# prod is Postgres; see docs/MIGRATIONS.md. +spring.jpa.hibernate.ddl-auto=validate +spring.flyway.enabled=true +# Set explicitly per profile below -- classpath:db/migration/postgresql for prod, +# classpath:db/migration/sqlite for dev. Spring Boot's {vendor} placeholder +# (classpath:db/migration/{vendor}) looks like it should pick this automatically from the +# JDBC URL, but inside this project's repackaged jar (Spring Boot 3.2+ nested-jar layout) +# Flyway's classpath scan doesn't restrict to the resolved subdirectory -- it scans both +# db/migration/sqlite and db/migration/postgresql and fails startup with "Found more than +# one migration with version 1". Confirmed against prod on 2026-08-10; see docs/MIGRATIONS.md. +# spring.flyway.locations=classpath:db/migration/postgresql + +# Allowed CORS origins +frontend.baseurl=http://localhost:5173 +musallahboard.baseurl=http://localhost:3000 + +# MusallahBoard device agents. websocketUrl is returned by /api/agent/enroll and +# persisted verbatim on the device, so point it at the address agents can reach +# from where they are deployed — not at whatever host happens to serve this app. +musallahboard.agent.websocketUrl=ws://localhost:8080/api/agent/ws +# Optional extra input to the enrollment-token hash. Leave empty to disable. +musallahboard.enrollment.tokenPepper= +# Zone used for a board whose device config has no timezone, or an unparseable one. +# Deliberately not the JVM default — the container's zone is an accident of the base image, +# and getting it wrong silently shifts every day and week boundary on the screen. +musallahboard.defaultTimezone=America/Toronto +# How often undelivered device commands are expired and in-flight ones timed out. +musallahboard.command.reaperIntervalMs=60000 + +# Cloudflare R2 +cloudflare.r2.endpoint=https://.r2.cloudflarestorage.com +cloudflare.r2.access-key-id=replace-with-r2-access-key +cloudflare.r2.secret-access-key=replace-with-r2-secret +cloudflare.r2.bucket-name=lensbridge-media +cloudflare.r2.public-url=https://media.lensbridge.tech +cloudflare.r2.url-expiration-minutes=15 + +# Uploads +uploads.allowed-file-types=image/jpeg,image/png,image/heic,image/webp,image/gif +uploads.global-max-size=1GB +uploads.global-daily-limit=10 +uploads.max-size.user=50MB +uploads.max-size.admin=2GB +uploads.max-size.root=5GB +uploads.daily-limit.user=10 +uploads.daily-limit.admin=500 +uploads.daily-limit.root=1000 + +# Image processing +image-processing.thumbnail-width=400 +image-processing.thumbnail-height=400 +image-processing.thumbnail-quality=0.7 +image-processing.thumbnail-folder=thumbnails/ + +# Rate limiting +ratelimit.requests=100 +ratelimit.duration.minutes=1 +ratelimit.exempt-roles=ROLE_ROOT,ROLE_ADMIN + +# Spring multipart cap +spring.servlet.multipart.max-file-size=250MB +spring.servlet.multipart.max-request-size=5000MB + +# Logging +logging.level.com.ibrasoft.lensbridge=INFO +logging.level.org.springframework.security=INFO +logging.level.org.springframework.mail=INFO + +# Mail (Purelymail SMTP) +spring.mail.host=smtp.purelymail.com +spring.mail.port=465 +spring.mail.protocol=smtp +spring.mail.username=noreply@lensbridge.tech +spring.mail.password=replace-with-smtp-password +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.ssl.enable=true +spring.mail.properties.mail.smtp.ssl.trust=smtp.purelymail.com +spring.mail.properties.mail.smtp.connectiontimeout=10000 +spring.mail.properties.mail.smtp.timeout=10000 +spring.mail.properties.mail.smtp.writetimeout=10000 +spring.mail.from=noreply@lensbridge.tech +spring.mail.from.name=LensBridge Mailer Service + +# JWT +lensbridge.app.jwtSecret=replace-with-base64-32-bytes +lensbridge.app.jwtExpirationMs=900000 +lensbridge.app.refreshTokenExpirationMs=604800000 +lensbridge.app.maxRefreshTokensPerUser=5 + +# Auth token TTLs +auth.token.verification-expiration-ms=86400000 +auth.token.password-reset-expiration-ms=3600000 + +# Onboarding +lensbridge.onboarding.secret=replace-with-base64-32-bytes +lensbridge.onboarding.expiration-ms=86400000 + +# Behind a reverse proxy (nginx): honor X-Forwarded-* headers +server.forward-headers-strategy=framework + +# Actuator (prod): only health, no details leak +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=never +management.endpoint.health.probes.enabled=true + +spring.jackson.serialization.write-dates-as-timestamps=false + +# API docs (disable in prod) +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true diff --git a/src/main/resources/db/migration/postgresql/V1__initial_schema.sql b/src/main/resources/db/migration/postgresql/V1__initial_schema.sql new file mode 100644 index 0000000..5740a88 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V1__initial_schema.sql @@ -0,0 +1,50 @@ +-- +-- V1: the whole schema, as of the move from Hibernate ddl-auto to Flyway. +-- +-- Generated from the JPA entity model rather than written by hand, so it is exactly what +-- `spring.jpa.hibernate.ddl-auto=validate` expects on startup. docs/MIGRATIONS.md has the +-- regeneration command -- though regenerating only makes sense while V1 is the only +-- migration. Once this has run anywhere, editing it fails Flyway's checksum: add a V2. +-- +-- Postgres specifics: native uuid, `timestamp(6) with time zone`, bytea, and the 12 foreign +-- keys SQLite cannot express. The FK constraint names are deliberate rather than Hibernate's +-- generated FK<32 hex chars> -- those are a hash of the mapping, so they change when the +-- mapping does, and a migration that has already run cannot go back and rename them. +-- Hibernate's validate does not check constraint names, so naming them costs nothing. + +create table audit_events (timestamp timestamp(6) with time zone not null, admin_id uuid not null, id uuid not null, target_entity_id uuid, details varchar(512), action varchar(255) not null check (action in ('APPROVE_UPLOAD','UNAPPROVE_UPLOAD','DELETE_UPLOAD','FEATURE_UPLOAD','UNFEATURE_UPLOAD','CREATE_EVENT','UPDATE_EVENT','DELETE_EVENT','CREATE_CALENDAR_EVENT','UPDATE_CALENDAR_EVENT','DELETE_CALENDAR_EVENT','CREATE_POSTER','UPDATE_POSTER','DELETE_POSTER','CREATE_SOCIAL','UPDATE_SOCIAL','DELETE_SOCIAL','SAVE_WEEKLY_CONTENT','DELETE_WEEKLY_CONTENT','UPDATE_BOARD_CONFIG','UPDATE_BOARD_TICKER','REFRESH_BOARDS','ISSUE_ENROLLMENT_TOKEN','REVOKE_DEVICE','ISSUE_DEVICE_COMMAND','PROMOTE_USER','DEMOTE_USER','DISABLE_USER','ENABLE_USER','VIEW_AUDIT_LOGS','EXPORT_DATA','SYSTEM_MAINTENANCE','VERIFY_USER','UNVERIFY_USER','RESET_USER_PASSWORD','TRIGGER_PASSWORD_RESET_EMAIL','ADD_USER_ROLE','REMOVE_USER_ROLE','GRANT_PERMISSION','REVOKE_PERMISSION','ADD_USER','REMOVE_USER','UPDATE_USER')), ip_address varchar(255), target_entity_type varchar(255) check (target_entity_type in ('USER','UPLOAD','MUSALLAH_BOARD','DEVICE','EVENT')), user_agent varchar(255), primary key (id)); +create table board_config_messages (device_id uuid not null, message varchar(255)); +create table board_configs (agenda_duration_seconds integer, dark_mode_after_isha boolean not null, enable_scrolling_message boolean not null, latitude float(53), longitude float(53), next_prayer_duration_seconds integer, device_id uuid not null, city varchar(255), country varchar(255), method varchar(255) check (method in ('KARACHI','ISNA','MWL','MAKKAH','EGYPT','TEHRAN','GULF','KUWAIT','QATAR','SINGAPORE','FRANCE','TURKEY','RUSSIA','DUBAI')), timezone varchar(255), primary key (device_id)); +create table board_events (all_day boolean not null, end_time timestamp(6) with time zone not null, start_time timestamp(6) with time zone not null, id uuid not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), description TEXT, location varchar(255), name varchar(255) not null, primary key (id)); +create table device_commands (deadline_ms integer, acked_at timestamp(6) with time zone, delivered_at timestamp(6) with time zone, expires_at timestamp(6) with time zone, finished_at timestamp(6) with time zone, issued_at timestamp(6) with time zone not null, started_at timestamp(6) with time zone, device_id uuid not null, id uuid not null, error_message TEXT, issued_by varchar(255) not null, kind varchar(255) not null, output_json TEXT, payload_json TEXT, status varchar(255) not null check (status in ('PENDING','DELIVERED','ACKED','RUNNING','SUCCEEDED','FAILED','TIMEOUT','REJECTED','EXPIRED')), primary key (id)); +create table device_telemetry (cpu_tempc float(53), disk_used_pct integer, kiosk_alive boolean, mem_total_mb integer, mem_used_mb integer, recorded_at timestamp(6) with time zone not null, uptime_sec bigint, device_id uuid not null, id uuid not null, displayed_frame_key varchar(64), ipv4 varchar(255), throttle_flags varchar(255), wifi_ssid varchar(255), primary key (id)); +create table devices (enrolled_at timestamp(6) with time zone, last_heartbeat timestamp(6) with time zone, revoked_at timestamp(6) with time zone, id uuid not null, public_key bytea, agent_version varchar(255), audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), display_name varchar(255) not null, hardware_model varchar(255), last_seen_ip varchar(255), organization_id varchar(255) not null, primary key (id)); +create table enrollment_tokens (consumed_at timestamp(6) with time zone, created_at timestamp(6) with time zone not null, expires_at timestamp(6) with time zone not null, consumed_by_device_id uuid, id uuid not null, token_hash bytea not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), created_by varchar(255) not null, display_name varchar(255) not null, primary key (id), constraint idx_enrollment_tokens_hash unique (token_hash)); +create table events (date timestamp(6) with time zone not null, id uuid not null, name varchar(255) not null, status varchar(255) not null check (status in ('UPCOMING','ONGOING','PAST')), primary key (id)); +create table islamic_quotes (id uuid not null, weekly_content_id uuid not null, arabic varchar(255), kind varchar(255) check (kind in ('VERSE','HADITH')), reference varchar(255), translation varchar(255), transliteration varchar(255), primary key (id)); +create table jummah_prayers (prayer_time time(6), slot_order integer, id uuid not null, weekly_content_id uuid not null, khatib varchar(255), room varchar(255), primary key (id)); +create table posters (duration integer not null, end_time timestamp(6) with time zone not null, start_time timestamp(6) with time zone not null, id uuid not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), image varchar(255) not null, signup_url varchar(255), title varchar(255) not null, primary key (id)); +create table promotable_social_media (duration integer not null, id uuid not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), footer_text varchar(255) not null, handle varchar(255), header_text varchar(255) not null, hero_text varchar(255) not null, name varchar(255) not null, type varchar(255) not null check (type in ('INSTAGRAM','YOUTUBE','TIKTOK','WHATSAPP','OTHER')), url varchar(255) not null, primary key (id)); +create table refresh_tokens (revoked boolean not null, created_date timestamp(6) with time zone not null, expiry_date timestamp(6) with time zone not null, last_used_date timestamp(6) with time zone not null, id uuid not null, user_id uuid not null, token_hash varchar(255) not null unique, primary key (id)); +create table uploads (approved boolean not null, featured boolean not null, is_anon boolean not null, created_date timestamp(6) with time zone, deleted_at timestamp(6) with time zone, deleted_by_id uuid, event_id uuid, uploaded_by uuid not null, uuid uuid not null, content_type varchar(255) check (content_type in ('IMAGE','VIDEO','AUDIO','DOCUMENT')), file_name varchar(255), file_url varchar(255), instagram_handle varchar(255), thumbnail_url varchar(255), upload_description varchar(255), primary key (uuid)); +create table user_permissions (user_id uuid not null, permission varchar(255) not null check (permission in ('MEDIA_UPLOAD_SELF','MEDIA_UPLOAD_MODERATE','MEDIA_UPLOAD_READ','MEDIA_EVENT_WRITE','BOARD_CONTENT_READ','BOARD_POSTER_WRITE','BOARD_EVENT_WRITE','BOARD_SOCIAL_WRITE','BOARD_WEEKLY_WRITE','BOARD_TICKER_WRITE','BOARD_CONFIG_READ','BOARD_CONFIG_WRITE','BOARD_REFRESH','BOARD_DEVICE_READ','BOARD_DEVICE_ENROLL','BOARD_DEVICE_REVOKE','BOARD_TELEMETRY_SUBSCRIBE','BOARD_COMMAND_BENIGN','BOARD_COMMAND_DISRUPTIVE','BOARD_COMMAND_INSPECT','IAM_USER_READ','IAM_USER_WRITE','IAM_ROLE_GRANT','AUDIT_READ')), primary key (user_id, permission)); +create table user_roles (user_id uuid not null, role varchar(255) not null check (role in ('USER','ADMIN','BOARD_VIEWER','BOARD_EDITOR','BOARD_ADMIN','ROOT')), primary key (user_id, role)); +create table users (verified_at timestamp(6) with time zone, student_number varchar(10) not null unique, id uuid not null, email varchar(254) not null unique, first_name varchar(255) not null, last_name varchar(255) not null, password_hash varchar(255) not null, primary key (id)); +create table verification_tokens (created_at timestamp(6) with time zone, expires_at timestamp(6) with time zone, used_at timestamp(6) with time zone, id uuid not null, user_id uuid not null, token_hash varchar(255) not null unique, type varchar(255) not null check (type in ('EMAIL_VERIFICATION','PASSWORD_RESET')), primary key (id)); +create table weekly_content (week_number integer not null, year integer not null, id uuid not null, primary key (id), unique (year, week_number)); +create index idx_device_commands_device_status on device_commands (device_id, status); +create index idx_device_commands_pending on device_commands (device_id, issued_at); +create index idx_device_telemetry_device_recorded on device_telemetry (device_id, recorded_at); +create index idx_users_email on users (email); +alter table audit_events add constraint fk_audit_events_admin_id foreign key (admin_id) references users; +alter table board_config_messages add constraint fk_board_config_messages_device_id foreign key (device_id) references board_configs; +alter table board_configs add constraint fk_board_configs_device_id foreign key (device_id) references devices; +alter table islamic_quotes add constraint fk_islamic_quotes_weekly_content_id foreign key (weekly_content_id) references weekly_content; +alter table jummah_prayers add constraint fk_jummah_prayers_weekly_content_id foreign key (weekly_content_id) references weekly_content; +alter table refresh_tokens add constraint fk_refresh_tokens_user_id foreign key (user_id) references users; +alter table uploads add constraint fk_uploads_deleted_by_id foreign key (deleted_by_id) references users; +alter table uploads add constraint fk_uploads_event_id foreign key (event_id) references events; +alter table uploads add constraint fk_uploads_uploaded_by foreign key (uploaded_by) references users; +alter table user_permissions add constraint fk_user_permissions_user_id foreign key (user_id) references users; +alter table user_roles add constraint fk_user_roles_user_id foreign key (user_id) references users; +alter table verification_tokens add constraint fk_verification_tokens_user_id foreign key (user_id) references users; diff --git a/src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql b/src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql new file mode 100644 index 0000000..a823f81 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql @@ -0,0 +1,10 @@ +-- +-- V2: per-quote slide duration. +-- +-- Nullable with no default on purpose: null is the frame contract's "auto", so every existing +-- quote keeps the behaviour it had before this column existed rather than being backfilled to +-- a duration nobody chose. +-- +-- The SQLite twin of this file must stay at the same version -- see docs/MIGRATIONS.md. + +alter table islamic_quotes add column duration_seconds integer; diff --git a/src/main/resources/db/migration/sqlite/V1__initial_schema.sql b/src/main/resources/db/migration/sqlite/V1__initial_schema.sql new file mode 100644 index 0000000..7ca03e3 --- /dev/null +++ b/src/main/resources/db/migration/sqlite/V1__initial_schema.sql @@ -0,0 +1,47 @@ +-- +-- V1: the whole schema, as of the move from Hibernate ddl-auto to Flyway. +-- +-- Generated from the JPA entity model rather than written by hand, so it is exactly what +-- `spring.jpa.hibernate.ddl-auto=validate` expects on startup. docs/MIGRATIONS.md has the +-- regeneration command -- though regenerating only makes sense while V1 is the only +-- migration. Once this has run anywhere, editing it fails Flyway's checksum: add a V2. +-- +-- SQLite specifics: +-- * UUIDs are blob, timestamps are naive `timestamp` -- this is what the SQLite dialect +-- emits, and validate is checked against it. +-- * No foreign keys. SQLite cannot ALTER TABLE ADD CONSTRAINT, so Hibernate never emitted +-- them here either; prod (Postgres) does have all 12. Dev will therefore accept an +-- orphaned row that prod rejects. +-- * The two unique indexes at the bottom are added by hand. The SQLite dialect drops the +-- table-level unique constraints that Postgres gets, which would let dev accept a +-- duplicate enrollment-token hash or two rows for the same week -- both of which the +-- application assumes cannot exist. + +create table audit_events (timestamp timestamp not null, admin_id blob not null, id blob not null, target_entity_id blob, details varchar(512), action varchar(255) not null check (action in ('APPROVE_UPLOAD','UNAPPROVE_UPLOAD','DELETE_UPLOAD','FEATURE_UPLOAD','UNFEATURE_UPLOAD','CREATE_EVENT','UPDATE_EVENT','DELETE_EVENT','CREATE_CALENDAR_EVENT','UPDATE_CALENDAR_EVENT','DELETE_CALENDAR_EVENT','CREATE_POSTER','UPDATE_POSTER','DELETE_POSTER','CREATE_SOCIAL','UPDATE_SOCIAL','DELETE_SOCIAL','SAVE_WEEKLY_CONTENT','DELETE_WEEKLY_CONTENT','UPDATE_BOARD_CONFIG','UPDATE_BOARD_TICKER','REFRESH_BOARDS','ISSUE_ENROLLMENT_TOKEN','REVOKE_DEVICE','ISSUE_DEVICE_COMMAND','PROMOTE_USER','DEMOTE_USER','DISABLE_USER','ENABLE_USER','VIEW_AUDIT_LOGS','EXPORT_DATA','SYSTEM_MAINTENANCE','VERIFY_USER','UNVERIFY_USER','RESET_USER_PASSWORD','TRIGGER_PASSWORD_RESET_EMAIL','ADD_USER_ROLE','REMOVE_USER_ROLE','GRANT_PERMISSION','REVOKE_PERMISSION','ADD_USER','REMOVE_USER','UPDATE_USER')), ip_address varchar(255), target_entity_type varchar(255) check (target_entity_type in ('USER','UPLOAD','MUSALLAH_BOARD','DEVICE','EVENT')), user_agent varchar(255), primary key (id)); +create table board_config_messages (device_id blob not null, message varchar(255)); +create table board_configs (agenda_duration_seconds integer, dark_mode_after_isha boolean not null, enable_scrolling_message boolean not null, latitude float, longitude float, next_prayer_duration_seconds integer, device_id blob not null, city varchar(255), country varchar(255), method varchar(255) check (method in ('KARACHI','ISNA','MWL','MAKKAH','EGYPT','TEHRAN','GULF','KUWAIT','QATAR','SINGAPORE','FRANCE','TURKEY','RUSSIA','DUBAI')), timezone varchar(255), primary key (device_id)); +create table board_events (all_day boolean not null, end_time timestamp not null, start_time timestamp not null, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), description TEXT, location varchar(255), name varchar(255) not null, primary key (id)); +create table device_commands (deadline_ms integer, acked_at timestamp, delivered_at timestamp, expires_at timestamp, finished_at timestamp, issued_at timestamp not null, started_at timestamp, id blob not null, device_id blob not null, error_message TEXT, issued_by varchar(255) not null, kind varchar(255) not null, output_json TEXT, payload_json TEXT, status varchar(255) not null check (status in ('PENDING','DELIVERED','ACKED','RUNNING','SUCCEEDED','FAILED','TIMEOUT','REJECTED','EXPIRED')), primary key (id)); +create table device_telemetry (cpu_tempc float, disk_used_pct integer, kiosk_alive boolean, mem_total_mb integer, mem_used_mb integer, recorded_at timestamp not null, uptime_sec bigint, id blob not null, displayed_frame_key varchar(64), device_id blob not null, ipv4 varchar(255), throttle_flags varchar(255), wifi_ssid varchar(255), primary key (id)); +create table devices (enrolled_at timestamp, last_heartbeat timestamp, revoked_at timestamp, id blob not null, agent_version varchar(255), audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), display_name varchar(255) not null, hardware_model varchar(255), last_seen_ip varchar(255), organization_id varchar(255) not null, public_key blob, primary key (id)); +create table enrollment_tokens (consumed_at timestamp, created_at timestamp not null, expires_at timestamp not null, consumed_by_device_id blob, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), created_by varchar(255) not null, display_name varchar(255) not null, token_hash blob not null, primary key (id)); +create table events (date timestamp not null, id blob not null, name varchar(255) not null, status varchar(255) not null check (status in ('UPCOMING','ONGOING','PAST')), primary key (id)); +create table islamic_quotes (id blob not null, weekly_content_id blob not null, arabic varchar(255), kind varchar(255) check (kind in ('VERSE','HADITH')), reference varchar(255), translation varchar(255), transliteration varchar(255), primary key (id)); +create table jummah_prayers (prayer_time time(6), slot_order integer, id blob not null, weekly_content_id blob not null, khatib varchar(255), room varchar(255), primary key (id)); +create table posters (duration integer not null, end_time timestamp not null, start_time timestamp not null, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), image varchar(255) not null, signup_url varchar(255), title varchar(255) not null, primary key (id)); +create table promotable_social_media (duration integer not null, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), footer_text varchar(255) not null, handle varchar(255), header_text varchar(255) not null, hero_text varchar(255) not null, name varchar(255) not null, type varchar(255) not null check (type in ('INSTAGRAM','YOUTUBE','TIKTOK','WHATSAPP','OTHER')), url varchar(255) not null, primary key (id)); +create table refresh_tokens (revoked boolean not null, created_date timestamp not null, expiry_date timestamp not null, last_used_date timestamp not null, id blob not null, user_id blob not null, token_hash varchar(255) not null unique, primary key (id)); +create table uploads (approved boolean not null, featured boolean not null, is_anon boolean not null, created_date timestamp, deleted_at timestamp, deleted_by_id blob, event_id blob, uploaded_by blob not null, uuid blob not null, content_type varchar(255) check (content_type in ('IMAGE','VIDEO','AUDIO','DOCUMENT')), file_name varchar(255), file_url varchar(255), instagram_handle varchar(255), thumbnail_url varchar(255), upload_description varchar(255), primary key (uuid)); +create table user_permissions (user_id blob not null, permission varchar(255) not null check (permission in ('MEDIA_UPLOAD_SELF','MEDIA_UPLOAD_MODERATE','MEDIA_UPLOAD_READ','MEDIA_EVENT_WRITE','BOARD_CONTENT_READ','BOARD_POSTER_WRITE','BOARD_EVENT_WRITE','BOARD_SOCIAL_WRITE','BOARD_WEEKLY_WRITE','BOARD_TICKER_WRITE','BOARD_CONFIG_READ','BOARD_CONFIG_WRITE','BOARD_REFRESH','BOARD_DEVICE_READ','BOARD_DEVICE_ENROLL','BOARD_DEVICE_REVOKE','BOARD_TELEMETRY_SUBSCRIBE','BOARD_COMMAND_BENIGN','BOARD_COMMAND_DISRUPTIVE','BOARD_COMMAND_INSPECT','IAM_USER_READ','IAM_USER_WRITE','IAM_ROLE_GRANT','AUDIT_READ')), primary key (user_id, permission)); +create table user_roles (user_id blob not null, role varchar(255) not null check (role in ('USER','ADMIN','BOARD_VIEWER','BOARD_EDITOR','BOARD_ADMIN','ROOT')), primary key (user_id, role)); +create table users (verified_at timestamp, student_number varchar(10) not null unique, id blob not null, email varchar(254) not null unique, first_name varchar(255) not null, last_name varchar(255) not null, password_hash varchar(255) not null, primary key (id)); +create table verification_tokens (created_at timestamp, expires_at timestamp, used_at timestamp, id blob not null, user_id blob not null, token_hash varchar(255) not null unique, type varchar(255) not null check (type in ('EMAIL_VERIFICATION','PASSWORD_RESET')), primary key (id)); +create table weekly_content (week_number integer not null, year integer not null, id blob not null, primary key (id)); +create index idx_device_commands_device_status on device_commands (device_id, status); +create index idx_device_commands_pending on device_commands (device_id, issued_at); +create index idx_device_telemetry_device_recorded on device_telemetry (device_id, recorded_at); +create index idx_users_email on users (email); + +-- Uniqueness Postgres enforces via table constraints; see the note above. +create unique index idx_enrollment_tokens_hash on enrollment_tokens (token_hash); +create unique index idx_weekly_content_year_week on weekly_content (year, week_number); diff --git a/src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql b/src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql new file mode 100644 index 0000000..e596cfe --- /dev/null +++ b/src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql @@ -0,0 +1,10 @@ +-- +-- V2: per-quote slide duration. +-- +-- Nullable with no default on purpose: null is the frame contract's "auto", so every existing +-- quote keeps the behaviour it had before this column existed rather than being backfilled to +-- a duration nobody chose. +-- +-- The Postgres twin of this file must stay at the same version -- see docs/MIGRATIONS.md. + +alter table islamic_quotes add column duration_seconds integer; diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html index b540c3f..8480653 100644 --- a/src/main/resources/templates/email-verification.html +++ b/src/main/resources/templates/email-verification.html @@ -1,339 +1,134 @@ - - - - - Activate your LensBridge Account! - - - - - - - + +
-
+ + diff --git a/src/main/resources/templates/password-reset.html b/src/main/resources/templates/password-reset.html index b8313e8..949a27e 100644 --- a/src/main/resources/templates/password-reset.html +++ b/src/main/resources/templates/password-reset.html @@ -1,337 +1,145 @@ - - - - - Reset your LensBridge Account Password - - - - - - - + +
-
+ + diff --git a/src/main/resources/templates/welcome.html b/src/main/resources/templates/welcome.html index ab42a7b..b476a78 100644 --- a/src/main/resources/templates/welcome.html +++ b/src/main/resources/templates/welcome.html @@ -1,463 +1,182 @@ - - - - - Welcome to LensBridge! - - - - - - -
-