Athena — auditPic/api.md

AuditPic — API Reference

Base URL: http://localhost:8093 (local) / https://api.auditpic.com (production)

All authenticated endpoints require Authorization: Bearer <jwt_token> header.

Rate limits:

  • POST /api/v1/auth/login — 5 requests/minute/IP
  • POST /api/v1/auth/register — 3 requests/minute/IP
  • GET /public/photos/** — 30 requests/minute/IP

Authentication

POST /api/v1/auth/register

Register a new account. Rate-limited: 3 req/min/IP.

Request:

{
  "email": "user@example.com",
  "password": "yourpassword"
}

Response 200:

{
  "token": "<jwt>",
  "refreshToken": "<refresh_jwt>",
  "email": "user@example.com"
}

Errors:

  • 400 — Email already registered or validation failed
  • 429 — Rate limit exceeded

POST /api/v1/auth/login

Request:

{
  "email": "user@example.com",
  "password": "yourpassword"
}

Response 200:

{
  "token": "<jwt>",
  "refreshToken": "<refresh_jwt>",
  "email": "user@example.com"
}

Errors:

  • 401 — Invalid credentials
  • 429 — Rate limit exceeded

POST /api/v1/auth/refresh

Exchange a refresh token for a new access token.

Request:

{
  "refreshToken": "<refresh_jwt>"
}

Response 200:

{
  "token": "<new_jwt>",
  "refreshToken": "<new_refresh_jwt>",
  "email": "user@example.com"
}

Errors:

  • 401 — Invalid or expired refresh token

DELETE /api/v1/auth/me

Delete the authenticated user's account. Authenticated.

Permanently deletes:

  • All photos from object storage (MinIO / Firebase Storage)
  • All photo metadata rows from the database
  • All refresh tokens for the user
  • The user account record

MinIO failures for individual photos are logged and skipped — the rest of the deletion still proceeds.

Response 204 — No content

Errors:

  • 401 — Not authenticated

Photos

POST /api/v1/photos

Upload and sign a photo. Authenticated.

The server recomputes SHA-256 from the uploaded bytes and rejects the upload if it does not match the client-supplied hash. This prevents hash forgery and guarantees the provenance chain.

Request: multipart/form-data

| Field | Type | Description | |---|---|---| | file | binary | Image bytes (JPEG / PNG / WebP, max 50 MB) | | sha256 | string | SHA-256 hex hash of file bytes, computed on device | | capturedAt | string | ISO-8601 UTC timestamp of capture |

Response 200:

{
  "verificationId": "550e8400-e29b-41d4-a716-446655440000",
  "capturedAt": "2026-03-27T14:30:00Z",
  "signedAt": "2026-03-27T14:30:01.123Z",
  "sha256": "e3b0c44298fc1c149afbf4c8996fb924...",
  "isAiGenerated": null,
  "aiConfidence": null,
  "aiProvider": null,
  "aiDetectionStatus": "PENDING",
  "createdAt": "2026-03-27T14:30:01.456Z",
  "isPublic": true
}

isAiGenerated, aiConfidence, aiProvider are null immediately after upload and populated asynchronously once AI detection completes (aiDetectionStatus transitions to SUCCESS or FAILED).

Errors:

  • 400 — SHA-256 mismatch, file too large, unsupported file type
  • 401 — Not authenticated

GET /api/v1/photos

List photos for the authenticated user. Authenticated.

Query parameters:

| Param | Default | Description | |---|---|---| | page | 0 | Page number (0-indexed) | | size | 20 | Page size |

Response 200:

{
  "content": [
    {
      "verificationId": "...",
      "capturedAt": "2026-03-27T14:30:00Z",
      "signedAt": "2026-03-27T14:30:01Z",
      "sha256": "e3b0c44...",
      "isAiGenerated": false,
      "aiConfidence": 0.03,
      "aiProvider": "hive",
      "aiDetectionStatus": "SUCCESS",
      "createdAt": "2026-03-27T14:30:01Z",
      "isPublic": true
    }
  ],
  "totalElements": 42,
  "totalPages": 3,
  "number": 0,
  "size": 20
}

GET /api/v1/photos/{verificationId}

Get metadata for a photo. Public for public photos; private photos return 404 for unauthenticated callers.

Response 200: Same shape as photo object in list above.

Errors:

  • 404 — Photo not found or private

GET /api/v1/photos/{verificationId}/verify-signature

Verify the HMAC signature of a photo. Public for public photos.

Response 200:

{ "valid": true }

Errors:

  • 404 — Photo not found or private

GET /api/v1/photos/{verificationId}/download

Backend-streamed download of the original signed image. Authenticated. Owner only.

The bytes are streamed straight from object storage server-side as a file attachment — this is not a presigned/MinIO URL, so the client never receives a direct storage link. It serves the signed original (not the watermarked render), so a downloaded file still hashes to the stored SHA-256 and tamper-evidence is preserved. The mobile "Download original" action hands these bytes to the OS share sheet.

Response 200: the raw image bytes.

  • Content-Type: the stored image media type
  • Content-Disposition: attachment; filename="<verificationId>.<ext>"
  • Cache-Control: no-store

Errors:

  • 401 — Not authenticated
  • 403 — Not the owner
  • 404 — Photo not found

PATCH /api/v1/photos/{verificationId}/visibility

Toggle public/private visibility. Authenticated. Owner only. Making a photo private requires a premium subscription.

Request:

{ "isPublic": false }

Response 200: Updated photo object.

Errors:

  • 401 — Not authenticated
  • 403 — Not the owner, or non-premium user trying to set private
  • 404 — Photo not found

PATCH /api/v1/photos/{verificationId}/view-password

Set or clear an optional view-password that gates the public viewer. Authenticated. Owner only. The plaintext is never persisted — only a BCrypt hash is stored (photos.view_password_hash, migration V18). A null/blank password clears the gate.

Request:

{ "password": "letmein" }

Response 200: Updated photo object.

Errors:

  • 401 — Not authenticated
  • 403 — Not the owner
  • 404 — Photo not found

DELETE /api/v1/photos/{verificationId}

Delete a photo and its object storage entry. Authenticated. Owner only.

Response 204 — No content

Errors:

  • 401 — Not authenticated
  • 403 — Not the owner
  • 404 — Photo not found

Public Viewer (anonymous)

These endpoints serve the public, anonymous verification portal under /public/photos/** (all @PermitAll). Only photos with isPublic: true are accessible — private photos return 404 so their existence is not leaked. Rate-limited: 30 req/min/IP via RateLimitInterceptor.

The shareable link points at the web viewer route /v/{verificationId} (the Flutter app's Environment.viewerBaseUrl), never at a raw MinIO/presigned URL. That page calls the endpoints below; MinIO is never exposed to the client.

Short verification id (/v/{shortId})

verificationId is the public handle in every link, displayed id, and path. New photos get a short code from ShortIdGenerator: exactly 6 chars, no prefix, from a 31-symbol unambiguous lowercase alphabet (no 0/1/l/i/o), e.g. 7k4m9q (≈ 31⁶ ≈ 890M combinations). PhotoService re-rolls on the rare DB collision, so the unique index makes uniqueness guaranteed. The column is a plain string, so legacy full-UUID ids and earlier ap_+8 ids still resolve — no migration was needed. The web viewer route /v/{shortId} and the API paths both take this same id verbatim.

View-password gate (V18)

When the owner has set a view-password (see PATCH …/view-password), all three /public/photos/{id}* endpoints require it as the query param ?pw=<password>. Until it matches:

  • The JSON/image endpoints return 401 VIEW_PASSWORD_REQUIRED with a passwordProtected: true flag (body never carries the photo or verdict).
  • The HTML viewer page renders a password prompt form instead (a person opening the link in a browser gets a usable page, not a JSON 401).

The gate covers viewing only — the HMAC signature and tamper-evidence are unaffected.

Retention / expiry (14-day, V19)

Photos are hard-deleted by a daily retention sweep 14 days after creation (RetentionService, GDPR storage limitation). A public-viewer link to an expired photo returns 410 Gone PHOTO_EXPIRED (with expired: true) — a friendly "this photo has expired" HTML page for the viewer route, never a 404/500. Only a PII-free deleted_photos tombstone remains.

GET /public/photos/{verificationId}

HTML viewer page for a verified photo. Returns a full-page HTML document with the watermarked image, verification badges (signature validity, AI detection result), and metadata table.

Query params: pw (optional) — view-password if the photo is gated.

Response: text/html with Content-Security-Policy, X-Frame-Options: DENY, Cache-Control: no-store.

Other states (always HTML, never JSON, for this route):

  • Password-protected & missing/wrong pw200 with a password prompt form
  • Expired (past retention) → 410 Gone with the "photo has expired" page

Errors:

  • 404 — Photo not found or private

GET /public/photos/{verificationId}/verification

Consolidated, public-safe verdict JSON for the web viewer — the verdict is computed server-side (single source of truth) so the viewer renders one authoritative result instead of recombining separate calls client-side.

Query params: pw (optional) — view-password if the photo is gated.

Response 200: application/json, Cache-Control: no-store (PublicVerificationResponse):

{
  "verificationId": "7k4m9q",
  "verdict": "verified",
  "signatureValid": true,
  "sha256": "e3b0c44298fc1c14...",
  "signature": "base64-hmac-signature",
  "hmacKeyVersion": 1,
  "capturedAt": "2026-06-01T10:15:00Z",
  "signedAt": "2026-06-01T10:15:01Z",
  "aiDetectionStatus": "COMPLETED",
  "isAiGenerated": false,
  "aiConfidence": 0.02,
  "aiProvider": "sightengine",
  "imageUrl": "/public/photos/7k4m9q/image",
  "passwordProtected": false
}

verdict is "verified" when the HMAC signature checks out, otherwise "tampered".

Errors:

  • 401VIEW_PASSWORD_REQUIRED (gated photo, missing/wrong pw)
  • 404 — Photo not found or private
  • 410PHOTO_EXPIRED (past retention window)

GET /public/photos/{verificationId}/image

Streams the watermarked photo (PNG) directly from the server — the image bytes served here are what is stored on-server, proof of no tampering. An invisible LSB watermark embedding the verificationId does not affect the visible image but can be extracted to prove the image was served by AuditPic.

Query params: pw (optional) — view-password if the photo is gated (the viewer carries the same pw through so a protected image does not 401).

Response: image/png, Content-Disposition: inline, Cache-Control: no-store.

Errors:

  • 401VIEW_PASSWORD_REQUIRED (gated photo, missing/wrong pw)
  • 404 — Photo not found or private
  • 410PHOTO_EXPIRED (past retention window)
  • 500 — Watermarking failure

Original-bytes download is the owner-authenticated GET /api/v1/photos/{id}/download (above) — backend-streamed, not a public endpoint and not a presigned URL.


Error Format

All errors return:

{
  "timestamp": "2026-03-27T14:30:00Z",
  "status": 400,
  "error": "Bad Request",
  "message": "SHA-256 mismatch: supplied hash does not match uploaded file"
}

Database Schema

Managed by Flyway. Current version: V8.

CREATE TABLE users (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email       VARCHAR(512) NOT NULL,          -- AES-256-GCM encrypted
    email_hash  VARCHAR(64)  UNIQUE NOT NULL,   -- HMAC-SHA256 for lookup
    password    VARCHAR(255) NOT NULL,
    is_premium  BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMP NOT NULL
);

CREATE TABLE photos (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id             UUID NOT NULL REFERENCES users(id),
    verification_id     VARCHAR(36) UNIQUE NOT NULL,
    captured_at         TIMESTAMP NOT NULL,
    signed_at           TIMESTAMP NOT NULL,
    sha256              VARCHAR(64) NOT NULL,
    signature           VARCHAR(256) NOT NULL,
    hmac_key_version    INTEGER NOT NULL DEFAULT 1,
    storage_key         TEXT NOT NULL,
    is_ai_generated     BOOLEAN,
    ai_confidence       DOUBLE PRECISION,
    ai_provider         VARCHAR(50),
    ai_detection_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    retention_days      INTEGER NOT NULL DEFAULT 365,
    is_public           BOOLEAN NOT NULL DEFAULT TRUE,
    created_at          TIMESTAMP NOT NULL
);

Schema migrations are in audit-pic-backend/src/main/resources/db/migration/.

Reacties

Nog geen reacties