Files
Kazier/docs/api-v1.md
T
2026-09-16 14:47:10 +02:00

18 KiB

VaultDrop — Contrat API V1 (autoritatif)

Status : autoritatif. Le client mobile Kotlin est la source de vérité : data/remote/ApiService.kt + data/remote/dto/Dtos.kt (contrat Retrofit/Moshi) sont implémentés ; le serveur Go doit les matcher exactement (méthode + path + enveloppe), il ne re-négocie pas. Document consolidé depuis le client — toute divergence de ce doc doit être portée dans le client d'abord.

Références : V2.md (modèle cible), mobile/services/db/ (conventions sync), AGENTS.md (structure + commandes).


1. Base et transport

  • Base URL serveur : configurable à l'exécution dans les Réglages de l'appli (défaut http://10.0.2.2:8080/api/v1).
  • JSON partout, sauf POST /files/upload (multipart).
  • Enveloppe succès : { "data": T, "meta"?: { "page": int, "pageSize": int, "total": int } } (meta présent sur les listes paginées).
  • Erreur : { "error": { "code": string, "message": string } } + statut HTTP adéquat.
  • Côté client, toute réponse non-2xx est normalisée en ApiError : code du body si présent, sinon HTTP_<status> ; échec réseau → NETWORK_ERROR. Une réponse 2xx mais dont le body n'est pas du JSON d'enveloppe valide (HTML, corps vide, JSON mal formé, absence de la clé data) → INVALID_RESPONSE (client-only).

2. Identité et identifiants (invariants)

  • User-first (V1 finale) : le device s'enregistre d'abord (POST /devices) avec son identité générée localement (device_user_id 32-hex mobile) — réponse { "deviceId" } uniquement, sans token. Puis le client appelle POST /auth/login (username + password + device_id) pour obtenir son token paseto v4-local. Requêtes suivantes : Authorization: Bearer <token> (toutes les routes sauf /health, /devices, /auth/login).
  • Claims du token : le subject = user_id (AUTORISE, clé de scoping de toutes les ressources) ; device_id est un claim secondaire, porté mais NON autorisant seul (idempotence outbox + jobs OCR). Résolu par middleware RequireAuth qui vérifie aussi que le compte existe toujours (deleted_at IS NULL).
  • TTL : 7 jours, sans refresh. À expiration, le client re-logine (POST /auth/login). Le changement de mot de passe (PATCH /users/me/password) n'invalide pas les tokens déjà émis — limite V1 assumée (pas de liste de révocation) jusqu'à l'expiration.
  • Au login, devices.user_id mémorise le dernier user connecté (INFORMATIF uniquement, jamais autorisant — recommande POST /devicesPOST /auth/login pour un nouveau device, sans réutiliser le device d'un autre compte).
  • Identifiants : resource_id, device_user_id, user_id, is_admin… = TEXT opaque 32-hex minuscule, ^[0-9a-f]{32}$. Le mobile génère toujours lower(hex(randomblob(16))) ; le serveur stocke tel quel, sans conversion UUID (cf. note V2.md). Contrainte serveur : CHECK (col ~ '^[0-9a-f]{32}$') sur toutes les colonnes id + FK.
  • Usernames : username = forme affichée ; l'unicité et la résolution portent sur username_normalized (lowercase + trim). Résolution d'un destinataire : GET /users/resolve?username= (exact uniquement, jamais de listing ni de préfixe — pas d'énumération de comptes).
  • Bootstrap : au premier démarrage, si users est vide, ADMIN_USERNAME/ADMIN_PASSWORD (env) créent le premier admin ; absents → le serveur refuse de démarrer. L'env n'écrase jamais un compte existant.
  • Horodatages échangés en millisecondes epoch (le mobile utilise Date.now()).

3. Endpoints

Méthode Path Requête Réponse data Statut absence
GET /health { "status": "healthy" }
POST /devices { "deviceId": "…32-hex" } (client-generated) { "deviceId": "…32-hex" }aucun token (V1 finale) INVALID_DEVICE_ID
POST /auth/login { "username", "password", "device_id" } { "token", "expires_at" (ms), "user": { "id", "username", "is_admin" } } UNAUTHORIZED / INVALID_DEVICE_ID
GET /shares/links/:token — (public, sans auth) { "token", "resource_id", "resourceType", "name", "access", "expiresAt" } NOT_FOUND (inconnu/révoqué/expiré)
GET /users/resolve query username (obligatoire) { "id", "username" } NOT_FOUND
PATCH /users/me/password { "current_password", "new_password" } { "id" } INVALID_PASSWORD (403)
GET /files query folderId?, page?, pageSize?, sort? FileDto[] (+ meta)
GET /files/:id FileDto NOT_FOUND
DELETE /files/:id { "id": "…" } NOT_FOUND
GET /files/search query q (obligatoire), page?, pageSize? FileDto[] (+ meta)
GET /files/folders FolderDto[] (racines)
POST /files/upload multipart : file (uri/name/type), folderId? FileDto FILE_TOO_LARGE
POST /ocr/jobs { "fileId": "…" } OcrJob
GET /ocr/jobs/:id OcrJob NOT_FOUND
GET /files/:id/ocr FileOcr ({ text, updatedAt }) NOT_FOUND
POST /sync/ops voir §6 voir §6
GET /sync/permissions query after? (cached_at ms) ResourcePermission[]

Lecture partagée : GET /files, GET /files/:id, GET /files/search, GET /files/:id/ocr et GET /files/folders couvrent les ressources possédées ET partagées (accès viewer+, §6.2). Le rename d'une ressource partagée passe par l'outbox update_metadata (nécessite editor+, §6.1). DELETE /files/:id reste owner-only.

DTOs (copie conforme de mobile/api/types.ts)

type FileDto = {
  id: string;               // resource_id 32-hex
  name: string;
  size: number;
  mimeType?: string | null;
  folderId?: string | null; // resource_id 32-hex
  tags?: string[];
  createdAt?: string;
  updatedAt?: string;
};
type FolderDto = { id: string; name: string; parentId?: string | null };
type OcrJobStatus = 'queued' | 'processing' | 'done' | 'failed';
type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: string | null };
type FileOcr = { text: string; updatedAt?: string | null };

4. Upload

  • Multipart : champ file + folderId? optionnel + resourceId? optionnel. Le client ne fixe jamais Content-Type (le boundary doit être généré par la plateforme).
  • resourceId optionnel : cible un fichier déjà connu du user (créé metadata-only via l'outbox create_resource, ou précédemment uploadé) pour lui attacher ses octets physiques — cas d'usage : le client local-first doit fournir le bytes d'un fichier SAF pour l'OCR serveur (POST /ocr/jobs). Le resourceId fourni doit exister et appartenir au user (sinon NOT_FOUND, même réponses/format que GET /files/:id) ; la ligne n'est jamais re-créée ni renommée ni déplacée, seule la métadonnée physique (size/mimeType/extension) est rafraîchie et FileDto renvoyé. resourceId absent → comportement historique (nouvelle ressource).
  • Limite : MAX_FILE_SIZE_MB (défaut 50). Dépassement → 413 { "error": { "code": "FILE_TOO_LARGE", … } }.
  • Le fichier physique est stocké sous UPLOAD_DIR/<user_id>/<resource_id>.<ext> ; la métadonnée est persistée en base et renvoyée en FileDto. Si la persistance de la métadonnée échoue (ex. NAME_CONFLICT), le fichier physique est supprimé.

5. OCR

  • POST /ocr/jobs { fileId }OcrJob immédiat (status: queued), traitement asynchrone par une file serveur bornée (ocr_jobs + worker Ocr.Run, OCR_WORKERS défaut 2) — le handler n'enfile jamais de traitement, Tesseract tourne hors request handler (FIFO, récupération des processing orphelins au boot).
  • GET /ocr/jobs/:id → statut. Le mobile poll toutes les 3s jusqu'à done/failed (hooks/useUpload.ts). Cycle : queued → processing → done | failed ; done renvoie text, failed renvoie error.
  • Moteur : Tesseract en appel système (ocr/tesseract.go), langue OCR_LANG (défaut fra+eng). Les images sont passées directement à tesseract ; les PDF subissent une extraction du calque texte (ledongthuc/pdf, déjà en go.mod) — un PDF scanné produit un texte vide plutôt qu'un rendu/OCR (hors scope V1).
  • fileId inconnu/pas du user → NOT_FOUND. Fichier physique introuvable (ex. suppression manuelle sous UPLOAD_DIR) → job failed "file not readable". Le job est créé par le device courant (ocr_jobs.device_id) mais la validation de la ressource est scopée par le user.
  • Le texte atterrit sur la ressource : à la complétion (done avec texte non vide), le worker écrit l'extrait trimé dans resources.ocr_text (et bump updated_at, d'où une injection dans le delta outbox/snapshot). ocr_jobs.text reste l'historique du job.
  • GET /files/:id/ocrFileOcr { text, updatedAt } : l'extrait server-side du fichier, lisible par le possédant ET les grantees (viewer+, même portée que GET /files/:id ; text "" si pas encore extrait). Un second device rapatrie ainsi le texte d'un OCR fait par un autre device (ou par la ressource partagée) sans re-téléverser les octets ni re-soumettre de job — le client Kotlin le fait en tête de OcrRepository.syncFromServer/processAuto et du viewer (OcrViewModel).

6. Contrat de sync (outbox + snapshot)

6.1 Outbox — POST /sync/ops

{
  "operations": [
    {
      "operation_id": "…32-hex",      // généré par le client (GenerateId), jamais réutilisé
      "ref_type": "resource",      // "resource" | "share" | "share_link"
      "ref_id": null,              // id local de la ligne share/share_link (sinon null)
      "resource_id": "…32-hex",    // ressource cible
      "resource_type": "folder",   // "folder" | "file"
      "operation": "create_resource",
      "payload": {}
    }
  ]
}
  • operationcreate_resource | update_metadata | delete_resource | move_resource | share | revoke_share | update_share | create_link | revoke_link (cf. PendingOperationType mobile).
  • Identifiants : operation_id est un TEXT 32-hex généré par le client (^[0-9a-f]{32}$, CHECK-enforced depuis la migration 000008), distinct de resource_id. L'idempotence outbox reste par device : UNIQUE(device_id, operation_id) (la réinscription d'un device avec un login différent ne réutilise pas l'historique outbox d'un autre compte). Pour chaque op : si déjà traitée → no-op (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide.
  • Ordre : les opérations sont appliquées séquentiellement, dans l'ordre du batch. Le serveur s'arrête à la première erreur non-idempotente et renvoie l'index atteint — le client reprend à cet index.
  • Réponse : 2xx avec { "applied": int, "failed": { "operation_id": "…32-hex", "code": string, "message": string } | null } (applied = index de la prochaine op à envoyer).
  • Côté client, le pushStatus (pending/synced/failed) des shares/share_links est dérivé de l'état des opérations de l'outbox ; dead-letter immédiat sur erreur 4xx non-idempotente (failed, non resélectionné ; attempts reste un compteur diagnostic, pas un seuil) — seul le transitoire (NETWORK_ERROR/5xx) est rejoué avec backoff. Côté serveur, les ops partage créent un état réel (tables shares/share_links, migration 000009) — l'outbox est le seul chemin d'écriture des droits.
  • Sémantique d'application (côté serveur) :
    • create_resource : crée la ressource ; déjà présente → no-op (rejeu idempotent). payload.name obligatoire ; payload.parentResourceId (32-hex, optionnel) = dossier parent — absent → racine. Parent inexistant → NOT_FOUND (cohérent avec move_resource). Ordre garanti par construction client : le walk SAF émet les create des dossiers (ordre préfixe) avant ceux des fichiers, dans la même transaction Room → id ASC = parent avant enfant.
    • update_metadata : ressource absente → no-op (état terminal atteint) ; ressource partagée : nécessite editor+ (§6.2) — sinon NOT_FOUND (une ressource visible mais sans droit de mutation n'est pas renommée) ; une ressource sans relation applicable (ni owner, ni grant) reste un no-op (pas d'énumération). move_resource / delete_resource restent owner-only (scoping par user_id, les ops d'un non-owner sont des no-op).
    • delete_resource : idempotent — suppression d'une ressource absente = succès.
    • Partage (share / update_share, même sémantique d'upsert) : payload = { "granteeUserId": "…32-hex", "access": "viewer"|"commenter"|"editor", "inherit"?: bool, "expiresAt"?: ms }. La ressource doit appartenir au user appelant (sinon NOT_FOUND) ; le grantee doit exister (sinon GRANTEE_NOT_FOUND) ; accès déjà partagé → mis à jour (idempotent). Privilégier viewer < commenter < editor (< owner réservé à l'ownership, non partageable → INVALID_REQUEST).
    • revoke_share : payload = { "granteeUserId": "…32-hex" }. Idempotent (déjà révoqué/absent → no-op).
    • create_link : payload = { "token": "…32-hex", "access": "viewer"|"commenter"|"editor", "expiresAt"?: ms }. Le token est généré côté client (GenerateId), c'est l'identifiant du lien (colonne id de share_links).
    • revoke_link : payload = { "token": "…32-hex" }. Idempotent.
    • Validation (deuxième champ operation_id, hex32 pour resource_id et operation_id, enum operation) → échec INVALID_REQUEST avec arrêt du batch.
    • Nom déjà pris (même parent, ou à la racine) → échec NAME_CONFLICT.**
  • Lien public : GET /shares/links/:token est sans authentification (résolution publique d'un lien) et renvoie { "token", "resource_id", "resourceType", "name", "access", "expiresAt" }. NOT_FOUND si le lien est inconnu, révoqué ou expiré.

6.2 Snapshot — GET /sync/permissions?after=<cached_at_ms>

  • Renvoie le delta (ou l'ensemble) des permissions effectives pour le user appelant, chacune sous la forme exacte consommée par canAccess :
type ResourcePermission = {
  resource_id: string;            // 32-hex
  resourceType: 'folder' | 'file';
  effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
  name: string;                   // nom de la ressource (hydratation mobile)
  parentId: string | null;        // parent 32-hex, null = racine (hydratation mobile)
  inherit: boolean;
  ownerId: string | null;         // ownership USER si applicable
  sharedById: string | null;
  expiresAt: number | null;       // ms epoch ; null = jamais
  cachedAt: number;               // ms epoch — horodatage du snapshot (TTL 24h)
  updatedAt: number;
};
  • Calcul de effective_access (le serveur est la source de vérité) :
    1. Rang : viewer = 1 < commenter = 2 < editor = 3 < owner = 4.
    2. La permission exacte sur le nœud est autoritaire — elle n'est pas annulée par son propre inherit=false et n'est pas écrasée par un ancêtre de rang supérieur.
    3. Les ancêtres propagent uniquement si leur relation a inherit = true ; une relation expirée (expires_at passé) est ignorée et ne propage pas.
    4. owner_id == le user appelant → owner (fallback, quel que soit le niveau remonté).
    5. Le rang le plus élevé l'emporte pour les nœuds sans permission propre ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot. inherit = false sur un nœud intermédiaire stoppe uniquement la propagation de sa propre relation ; une relation inherit = true plus haut continue de traverser.
  • Delta : after filtre sur resource.updated_at et l'updated_at de la relation gagnante — une ressource nouvellement partagée apparaît dans le delta du grantee dès sa création. Révoquée/expirée, la ressource disparaît du snapshot : la convergence côté client passe par les pulls complets (after=0, à chaque login et quand le cache dépasse le TTL 24h).
  • TTL / stale : après PERMISSION_TTL_MS (= 24h) sans reseed, canAccess downgrade en lecture seule (viewer) vers le cache.

6.3 Placements

  • Le statut de placement (sync_status mobile : local | cloud | local-cloud) est le reflet du resource_placements serveur (statuts V2 : local_only, synced, cloud_only, pending_upload, pending_download). La matérialisation se fait via l'outbox (create_resourcesynced/pending_upload ; suppression physique locale ≠ suppression serveur).

7. Codes d'erreur courants

  • Authentification : UNAUTHORIZED (401 — token manquant/invalide/expiré, compte supprimé, OU identifiants de login erronés : indistinguables par design, même code+message), INVALID_DEVICE_ID (400 — device non enregistré au login).
  • Ressources : NOT_FOUND (404), NAME_CONFLICT (409 — même nom dans le même parent, cf. UNIQUE(parent_id, name), ou à la racine, index partiel (user_id, name) WHERE parent_id IS NULL), FILE_TOO_LARGE (413), INVALID_PASSWORD (403 sur PATCH /users/me/password).
  • Partage : GRANTEE_NOT_FOUND (404 — grantee inexistant sur une op share), NOT_FOUND (404 — ressource non possédée sur une op share : scoping, pas d'énumération).
  • Client-only : NETWORK_ERROR, INVALID_RESPONSE (2xx mais corps d'enveloppe invalide), HTTP_<status> (fallback). Statut SERVICE_UNAVAILABLE (503) si le backend n'est pas initialisé.
  • V1 finale : toutes les routes sont réelles (pas de 501 restant).

8. Moteur de login (règles de sécurité)

  • POST /auth/login : le device doit exister (POST /devices d'abord, sinon 400 INVALID_DEVICE_ID). La vérification du mot de passe est constant-time (argon2id, comparaison subtle) et le délai est égalisé entre « username inconnu » et « mauvais mot de passe » (vérification contre un dummy-hash) — les deux produisent exactement la même réponse 401.
  • GET /users/resolve : résolution exacte du username_normalized uniquement ; ne renvoie jamais email ni is_admin (ni listing, ni préfixe → pas d'énumération de comptes).
  • PATCH /users/me/password : exige current_password (mauvais curl → 403). Minimum 8 caractères. Limite V1 : tokens émis non révoqués (validité 7 j), et is_admin/ADMIN_* non modifiables par API.