Second Brain documentation

ADR 0012 – Fichiers : un fichier par nœud, contenu adressé par SHA-256 sur disque, service en même origine durci

Accepté (2026-10-15, mode autonome) ; « un fichier au plus par nœud » remplacé par l’ADR 0032 (plusieurs fichiers ordonnés)

← Toutes les décisions

ADR 0012 – Fichiers : un fichier par nœud, contenu adressé par SHA-256 sur disque, service en même origine durci

Statut : Accepté (2026-10-15, mode autonome) ; « un fichier au plus par nœud » remplacé par l’ADR 0032 (plusieurs fichiers ordonnés)

Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles) : un fichier au plus par nœud ; contenu adressé par SHA-256 (table file_blob, colonne node.file_ref déjà prévue par V1, nom d’origine et type détecté sur node) ; stockage disque sous app.files.path (/data/files/ab/cd/<sha256>, sans extension), écriture dans un fichier temporaire puis renommage atomique, déduplication ; taille maximale 50 Mo (multipart Spring, inchangée), fichier vide refusé ; type détecté par le contenu avec Apache Tika tika-core 4.1.0 (le nom ne sert que d’indice), aucun type refusé à l’upload ; un nœud par fichier (POST /api/nodes/files, sans type ni aspect), rattachement (PUT /api/nodes/{id}/file), retrait, lecture en flux par le nœud (GET /api/nodes/{id}/file) ; liste blanche des types servis inline, tout le reste en attachment, nosniff, CSP sandbox sauf PDF ; événements file.attached / file.replaced / file.removed ; aucune suppression physique avant la purge de la corbeille (jalon 3) ; aperçu PDF par le lecteur natif du navigateur (<iframe>), images par <img>, texte par <pre> ; Office, miniatures, pdf.js et ![[fichier]] reportés ; aucune dépendance npm.

Contexte

Le cahier (jalon 1) demande un « fichier optionnel » par nœud (« Nœud générique »), le dépôt par glisser-déposer avec « création d’un nœud par fichier », l’aperçu des PDF, images, documents Office et textes, le stockage sur disque avec déduplication par hash, l’original jamais modifié, l’export des fichiers d’origine et leur sauvegarde. Le backlog réduit la tranche à : stockage disque (/data), déduplication par hash, file_ref, aperçu PDF et images.

État du code :

  • node.file_ref text existe depuis V1 (« référence du fichier stocké (jalon 1 : upload) ») mais n’est ni lu ni écrit (absent de Db).
  • spring.servlet.multipart est déjà réglé à 50 Mo par fichier et 60 Mo par requête.
  • L’image Docker crée /data (propriétaire app) et docker-compose.yml y monte le volume app-data ; rien ne l’utilise encore.
  • Sécurité (ADR 0004) : même origine pour le front et l’API, cookie de session, CSRF par en-tête X-XSRF-TOKEN. Spring Security envoie par défaut X-Frame-Options: DENY et X-Content-Type-Options: nosniff, sur les deux chaînes (dev et oidc).
  • Rendu (ADR 0008) : les images du Markdown n’acceptent que https: et les URL relatives ; le seul bypassSecurityTrust* autorisé est celui de app-markdown-view.
  • ![[…]] est préservé mais n’est ni lien ni transclusion (ADR 0009, V2).
  • Le client HTTP Angular utilise withFetch().

Le risque principal est la sécurité. Servir depuis l’origine de l’application un fichier fourni par l’utilisateur (HTML, SVG, XHTML) revient à lui faire exécuter du script avec la session : c’est un XSS stocké. Les autres choix difficiles à défaire sont la forme des données (un ou plusieurs fichiers par nœud, nom d’origine porté par le nœud ou par le contenu) et la disposition sur disque, que reprendront sauvegardes, export, purge et versions (V2).

Sonde de détection (Tika tika-core 4.1.0, Tika#detect(InputStream, nom), 2026-10-15) :

Contenu Nom fourni Type détecté
SVG avec <script> a.png image/svg+xml
HTML avec <script> a.png, a.txt, a.pdf text/html
XHTML a.txt application/xhtml+xml
texte avec <script> au milieu a.txt text/plain
PNG a.html image/png
octets aléatoires a.pdf / a.png / aucun application/pdf / image/png / application/octet-stream
texte a.pdf text/plain
ZIP a.docx / a.png type Word (OOXML) / application/zip
# Titre… note.md text/markdown

Les signatures (magic) l’emportent sur l’extension. L’extension n’est retenue que si elle précise le type reconnu par les signatures, ou si aucune signature ne correspond. Un contenu actif ne se fait donc pas passer pour une image ou un PDF. À l’inverse, un contenu illisible peut être étiqueté PDF ou PNG d’après son nom ; avec nosniff, le navigateur échoue simplement à l’afficher. La même sonde montre que tika-core 4.1.0 échoue au chargement avec commons-io 2.21 (NoClassDefFoundError: TaggedInputStream) : il exige la version 2.22.

Décision

Modèle : un fichier par nœud, contenu adressé par son SHA-256

  • Un fichier au plus par nœud au jalon 1, conformément au cahier (« fichier optionnel », « un nœud par fichier »). Pour plusieurs pièces jointes, on crée plusieurs nœuds, enfants (ADR 0007) ou liés. Les versions d’un même fichier relèvent de la V2.

  • Migration V6__fichiers.sql :

    -- Fichiers (ADR 0012) : contenus stockés sur disque sous app.files.path, adressés par leur SHA-256.
    create table file_blob (
        sha256     text        primary key check (sha256 ~ '^[0-9a-f]{64}$'),
        size       bigint      not null check (size >= 0),
        created_at timestamptz not null default now()
    );
    
    -- node.file_ref (V1, jamais écrit jusqu'ici) devient la référence au contenu ; restrict : un contenu
    -- référencé ne peut pas être supprimé (garde-fou de la purge du jalon 3).
    alter table node add constraint node_file_ref_fk foreign key (file_ref) references file_blob (sha256);
    alter table node add column file_name text;          -- nom d'origine, assaini
    alter table node add column file_media_type text;    -- type détecté par le contenu (Tika)
    alter table node add constraint node_file_chk check (
        (file_ref is null and file_name is null and file_media_type is null)
        or (file_ref is not null and file_name is not null and file_name <> ''
            and file_media_type is not null and file_media_type <> ''));
    create index node_file_ref_idx on node (file_ref) where file_ref is not null;

    file_blob décrit le contenu (identique pour tous les nœuds qui le partagent). Le nœud porte ce qui dépend du dépôt : le nom d’origine, et le type détecté, qui dépend aussi du nom (un ZIP nommé .docx est un document Word). La taille est lue par jointure.

  • Aucun lien avec le domaine. Le moteur ne connaît ni l’aspect document ni aucun type. Un nœud créé par dépôt n’a ni type ni aspect : c’est un élément « capturé, à classer », candidat naturel de l’inbox (tranche suivante). L’utilisateur lui ajoute ensuite document, facture… comme à tout nœud. Les métadonnées du fichier (nom, type, taille) ne sont pas un aspect : ce sont des colonnes du moteur, comme title. Le format YAML et les méta-schémas ne changent pas.

Stockage sur disque

  • Racine configurable : app.files.path, valeur par défaut ${APP_FILES_PATH:../data/files} (racine du dépôt en développement, comme ../config). Valeur /data/files dans l’image (ENV du Dockerfile et docker-compose.yml). /data/ est ajouté au .gitignore.
  • Chemin d’un contenu : <racine>/<2 premiers hex>/<2 suivants>/<sha256>, sans extension. Le chemin est calculé uniquement à partir d’un SHA-256 validé (^[0-9a-f]{64}$) : aucune donnée fournie par l’utilisateur, nom compris, n’entre dans un chemin.
  • Écriture en deux temps :
    1. mise en attente (hors transaction) : le flux du multipart est copié dans <racine>/.tmp/<uuid> à travers un DigestInputStream SHA-256, puis FileChannel.force(true). On obtient le hash et la taille en une seule lecture ;
    2. validation, dans la transaction de la base, après le verrou consultatif partagé file.gc : insert into file_blob … on conflict do nothing, puis Files.move(tmp, cible, ATOMIC_MOVE). Si la cible existe déjà avec la même taille, c’est un doublon : le fichier temporaire est supprimé. Si sa taille diffère (fichier abîmé), elle est remplacée. Sous Windows, un renommage refusé alors que la cible existe avec la bonne taille vaut aussi doublon.
    3. le fichier temporaire est supprimé dans un finally s’il existe encore.
  • Les répertoires sont créés à la première écriture, pas au démarrage. Les tests qui n’écrivent pas de fichier ne créent donc rien, et les autres pointent app.files.path vers un @TempDir. Au démarrage, les fichiers de .tmp de plus de 24 h sont supprimés et le chemin absolu est journalisé.
  • Une transaction annulée après le renommage laisse un contenu sans ligne file_blob. C’est sans danger : un orphelin de plus pour la purge. L’inverse, une ligne sans contenu, est impossible tant que la purge (jalon 3) prend le même verrou file.gc en mode exclusif.
  • Une erreur d’entrée-sortie du stockage produit une FileStorageException, traduite en 503 ProblemDetail « Stockage des fichiers indisponible ».

Taille, nom et type

  • Taille : spring.servlet.multipart.max-file-size reste à 50 Mo (surcharge par SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE). Un dépassement (MaxUploadSizeExceededException) donne 413 avec errors.file. Un fichier vide donne 400 errors.file, car un dossier déposé dans certains navigateurs arrive comme un fichier vide. Une partie file absente donne 400 errors.file. GET /api/files/limits renvoie { maxFileSize } (octets, lu dans MultipartProperties) pour que le front refuse avant l’envoi : au-delà de server.tomcat.max-swallow-size (2 Mo), Tomcat coupe la connexion et le navigateur ne voit qu’une erreur réseau, pas le 413.
  • Nom d’origine (FileNames.sanitize) : dernier segment après / ou \, NFC, suppression des caractères de contrôle C0 et C1, de U+2028/U+2029, des marques bidirectionnelles (U+200E/F, U+202A–U+202E, U+2066–U+2069) et de U+FEFF, strip(), 255 points de code au plus en conservant l’extension. Vide après assainissement : fichier.
  • Titre du nœud créé (FileNames.title) : le nom sans sa dernière extension (point en position > 0), 500 caractères au plus ; vide : le nom entier.
  • Type : MediaTypes.detect(Path, String nom) appelle une instance unique de org.apache.tika.Tika sur les premiers octets du fichier mis en attente, avec le nom assaini en indice. Le Content-Type envoyé par le client est ignoré. Aucun type n’est refusé à l’upload : un deuxième cerveau stocke aussi des ZIP, des mails ou des fichiers inconnus. La sécurité se joue à la lecture (ci-dessous).

API

Contrôleur NodeFileController, tag OpenAPI files :

Méthode Chemin Effet Réponse
POST /api/nodes/files (multipart : file ; requête : parentId?) crée un nœud (titre tiré du nom, sans type) et lui rattache le fichier 201 NodeView, Location: /api/nodes/{id}
PUT /api/nodes/{id}/file (multipart : file) rattache ou remplace ; même contenu et même nom : rien 200 NodeView
DELETE /api/nodes/{id}/file détache (le contenu reste sur disque) 200 NodeView, 404 sans fichier (comme removeAspect)
GET /api/nodes/{id}/file contenu en flux 200, 206 (Range), 304 (If-None-Match), 404 si nœud absent, en corbeille ou sans fichier
GET /api/files/limits { maxFileSize } 200
  • Un fichier par requête de création. Le front envoie les fichiers d’un dépôt en parallèle limité et rend compte de chacun.
  • POST /api/nodes/files passe par NodeService.create (même transaction). Ainsi la création rattrape les liens wiki non résolus de son titre (ADR 0009) et vérifie le parent. Un parentId introuvable donne 400 errors.parentId.
  • NodeView et NodeSummary gagnent file (FileView(String name, String mediaType, long size, String sha256)), absent du JSON quand le nœud n’a pas de fichier (@JsonInclude(NON_NULL), requiredMode = NOT_REQUIRED, comme BacklinkView.linkId).
  • GET …/file est documenté application/octet-stream, format: binary (le client généré renvoie un Blob). Il renvoie ResponseEntity<Resource> (FileSystemResource), ce qui donne à Spring MVC la prise en charge des requêtes Range, utile au lecteur PDF et aux médias. L’en-tête ETag: "<sha256>" est vérifié par WebRequest.checkNotModified.
  • Le multipart passe par PUT comme par POST (Tomcat lit les parties quelle que soit la méthode). Un test d’intégration le vérifie. Repli si ce n’est pas le cas : POST /api/nodes/{id}/file.

Lecture : en-têtes contre le XSS en même origine

Politique pure FileServingPolicy.of(String mediaType) → Served(MediaType contentType, boolean inline, String csp), testée unitairement :

  • inline seulement pour la liste blanche : application/pdf, image/png, image/jpeg, image/gif, image/webp, image/avif, text/plain. Tout le reste est servi en attachment (SVG, HTML, XML, Office, inconnus). Content-Disposition est construit par ContentDisposition de Spring (filename*=UTF-8''…) à partir du nom assaini.
  • Content-Type : le type détecté s’il appartient à image/*, audio/*, video/*, application/pdf ou text/plain, sinon application/octet-stream. On n’envoie donc jamais text/html, application/xhtml+xml, */xml ni */javascript. image/svg+xml est conservé pour que <img> l’affiche ; une balise <img> n’exécute aucun script et ignore Content-Disposition.
  • Sur toutes les réponses : X-Content-Type-Options: nosniff, Cross-Origin-Resource-Policy: same-origin et Cache-Control: private, no-cache (revalidation par ETag).
  • Content-Security-Policy :
    • PDF : default-src 'none'; object-src 'self'; frame-ancestors 'self'. Pas de sandbox, et object-src est nécessaire : Chromium n’affiche pas un PDF servi avec sandbox ni avec default-src 'none' seul ;
    • autres types : default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'self'; sandbox.
  • X-Frame-Options passe de DENY à SAMEORIGIN dans les deux chaînes de SecurityConfig (headers.frameOptions(sameOrigin)), sinon l’aperçu PDF en <iframe> est bloqué. Le clickjacking par une autre origine reste impossible. Amende l’ADR 0004.
  • CSRF : inchangé. Le jeton est lu dans l’en-tête X-XSRF-TOKEN, qu’Angular ajoute aux requêtes POST/PUT/DELETE relatives, multipart compris. Spring Security n’a donc pas à lire le corps : aucun MultipartFilter à placer avant la chaîne. Le GET n’a pas d’effet de bord.

Événements

Dans la transaction d’écriture, sur le modèle des aspects. Charge utile d’un fichier : {sha256, name, size, mediaType}.

  • file.attached {after} : premier fichier du nœud. Un nœud créé par dépôt produit node.created puis file.attached.
  • file.replaced {before, after} : contenu ou nom différent.
  • file.removed {before}.
  • Rattacher le même contenu sous le même nom : ni événement ni mise à jour. Toute autre écriture fait touch sur updated_at.

Le déclencheur « fichier ajouté » des règles (jalon 3) correspondra à file.attached et file.replaced. Comme les événements gardent le sha256 des contenus remplacés et que rien n’est supprimé physiquement, l’historique des versions (V2) pourra être reconstitué.

Corbeille, purge, sauvegarde, export

  • Mettre un nœud à la corbeille ne touche pas au fichier : la restauration le retrouve. GET …/file d’un nœud en corbeille donne 404, comme GET /api/nodes/{id}.
  • Aucune suppression physique au jalon 1. La purge (jalon 3, avec la purge de la corbeille) prendra le verrou file.gc en mode exclusif. Elle supprimera les lignes file_blob que plus aucun nœud ne référence (la clé étrangère restrict protège les autres), puis leurs fichiers, puis les contenus présents sur disque sans ligne. Elle devra garder ce que l’historique ou les versions (V2) référencent encore, selon la politique qui sera arrêtée.
  • Sauvegardes (tranche dédiée) : pg_dump d’abord, puis copie de <racine> (sans .tmp/). Les contenus sont immuables et jamais supprimés avant la purge : une copie prise après le dump contient tout ce que le dump référence. La purge devra être exclue de la plage de sauvegarde. La restauration testée vérifie sha256sum sur un échantillon.
  • Export (tranche dédiée) : le fichier d’origine sous son nom d’origine, à côté du Markdown du nœud. Le sha256 va dans le frontmatter. Un même contenu partagé est copié une fois par nœud.

Front

  • Pas de nouvelle dépendance npm. Rien n’est chargé dans le bundle initial en dehors de la petite directive de dépôt (aucune bibliothèque).
  • core/files/ : nodeFileUrl(id) (vérifie le format UUID, renvoie /api/nodes/${id}/file), previewKind(mediaType): 'pdf' | 'image' | 'text' | 'none', formatSize(octets) (fr-FR), et FileUploader (limites mises en cache, contrôle de taille et de fichier vide avant l’envoi, envois en parallèle limités à 3, résultat par fichier). withFetch() ne donne pas la progression de l’envoi : l’indicateur est indéterminé, par fichier.
  • Directive appFileDrop : compteur dragenter/dragleave, réagit seulement si dataTransfer.types contient Files, ignore les dossiers (webkitGetAsEntry()?.isDirectory). Elle s’accompagne d’un bouton « Importer des fichiers » (<input type="file" multiple>) : sur mobile, le glisser-déposer n’existe pas.
  • Points de dépôt, un nœud par fichier : accueil et liste des nœuds (à la racine ; la liste se recharge ensuite) ; panneau des enfants de l’éditeur (parentId = nœud courant). Au plus 100 fichiers par dépôt. Avec un seul fichier déposé depuis l’accueil, on ouvre le nœud créé.
  • Panneau « Fichier » de l’éditeur (app-node-file-panel), sur un nœud existant (masqué sur /nodes/new) :
    • sans fichier : zone de dépôt (un seul fichier) et bouton « Joindre un fichier » ;
    • avec fichier : nom, type, taille, boutons « Ouvrir » (nouvel onglet), « Télécharger » (<a download>), « Remplacer » et « Retirer ».
    • Comme les panneaux de liens, il écrit immédiatement. Il ne recharge pas le formulaire de l’éditeur, pour ne pas perdre la saisie en cours, et ses boutons sont désactivés pendant la requête.
  • Aperçu selon previewKind :
    • PDF : <iframe> si navigator.pdfViewerEnabled, sinon les seuls boutons (Chrome Android n’a pas de lecteur intégré). L’<iframe> n’a pas d’attribut sandbox, qui bloquerait le lecteur ;
    • image : <img loading="lazy">, y compris SVG ;
    • texte : text/*, application/json, au plus 512 Kio, lu par le client généré (Blob, puis TextDecoder('utf-8')) et affiché dans un <pre> par interpolation, jamais en innerHTML ;
    • autres types : rien.
  • Amende l’ADR 0008 : un second contournement est autorisé, bypassSecurityTrustResourceUrl, limité au src de l’<iframe> PDF. Il ne s’applique qu’à la valeur renvoyée par nodeFileUrl(id), construite à partir d’un UUID validé, jamais d’une chaîne libre.
  • Une image d’un nœud peut déjà s’insérer dans un corps par URL relative, ![légende](/api/nodes/<id>/file), que la politique de l’ADR 0008 accepte. Aucune syntaxe n’est ajoutée : ![[fichier]] reste la transclusion de la V2.

Dépendances

  • org.apache.tika:tika-core 4.1.0 (Apache-2.0, publiée le 2026-09-26, Java 17+, environ 770 Ko). Exclusion de org.commonmark:* : Tika 4 l’utilise pour la sortie Markdown, qui ne sert pas à la détection, et le projet a déjà commonmark 0.30.1. Dépendances restantes : slf4j-api (fourni par Spring Boot) et commons-io.
  • commons-io:commons-io 2.22.0 (Apache-2.0) fixé dans dependencyManagement : Tika 4.1.0 ne fonctionne pas avec 2.21 (voir la sonde), et une dépendance transitive plus proche pourrait imposer une version antérieure. Contrôle : ./mvnw -pl backend dependency:tree -Dincludes=commons-io,org.commonmark.
  • Choisir Tika aligne la détection sur l’extraction du texte des PDF (cahier, jalon 3), qui ajoutera les parseurs de la même version.

Alternatives écartées

  • Plusieurs fichiers par nœud dès le jalon 1 (table node_file, ordre, pièces jointes) : le cahier parle d’« un » fichier optionnel et d’« un nœud par fichier ». Les pièces multiples s’expriment par la hiérarchie et les liens, qui existent déjà. Ajouter une table plus tard coûte peu ; en retirer une, après usage, coûte cher.
  • Nom d’origine et type dans file_blob : deux dépôts du même contenu sous deux noms se marcheraient dessus, et le type dépend du nom (ZIP ou docx).
  • Pas de table file_blob (tout sur node) : plus de clé étrangère pour protéger un contenu référencé, et la purge devrait parcourir le disque pour savoir ce qui existe.
  • Identifiant UUID pour le contenu au lieu du hash : la déduplication demanderait un index unique de plus, et le chemin sur disque ne permettrait plus de vérifier l’intégrité.
  • Chemin avec extension ou nom d’origine : il faudrait se protéger de la traversée de répertoires et des noms Windows réservés, et le serveur de fichiers d’un reverse proxy pourrait se mettre à « deviner » le type.
  • Stockage en base (bytea, large objects) : dumps volumineux et lents, mémoire, et le cahier prévoit S3/MinIO. Le disque est l’option la plus simple ; une interface FileStore permettra d’ajouter S3 plus tard.
  • S3/MinIO dès le jalon 1 : un service de plus à déployer pour un usage personnel.
  • Liste blanche de types à l’upload : refuser un ZIP ou un .eml contredit la capture sans friction, et la sécurité se règle mieux à la lecture.
  • Détection maison (une dizaine de signatures) : plus légère, mais il faudrait maintenir à la main ce que Tika fait déjà et qu’il faudra de toute façon ajouter au jalon 3.
  • Tika 3.3.3 (dépendances plus légères, sans commonmark) : branche de maintenance ; le jalon 3 aurait dû migrer vers la version 4.
  • Confiance dans le Content-Type du client : ce n’est qu’une déclaration, choisie par l’expéditeur.
  • Origine séparée pour les fichiers (files.example.com, comme le usercontent de Google) : c’est la défense la plus robuste contre le XSS par fichier. Elle demande un second nom de domaine, des cookies ou jetons distincts et du CORS, contre le modèle même origine de l’ADR 0004. On la garde en réserve si un type servi inline pose un jour problème.
  • GET /api/files/{sha256} (lecture par contenu) : le nom d’origine appartient au nœud, et passer par le nœud règle l’accès (corbeille) et l’autorisation future (multi-utilisateur) en un seul endroit.
  • pdf.js (pdfjs-dist, Apache-2.0) : rendu identique partout, mobile compris. Mais plusieurs mégaoctets, avec le worker, qu’il faut charger et mettre à jour, alors que les lecteurs natifs couvrent les postes de travail. On le réévaluera avec la PWA (jalon 2) si l’aperçu sur mobile manque.
  • Miniatures et aperçus générés à l’upload (PDFBox, Thumbnailator) : inutiles tant qu’il n’y a ni galerie (« Plus tard ») ni vignette dans la liste. Les générer plus tard, en lot, ne coûte qu’un traitement.
  • Conversion Office (LibreOffice headless, Gotenberg) : un conteneur de plusieurs centaines de mégaoctets et une surface d’attaque (fichiers non fiables) importante, pour un besoin secondaire.
  • Taille de fichier ou nom ajoutés à node.search : changer l’expression de la colonne générée impose de la recréer avec son index. Le titre reprend déjà le nom ; le contenu des PDF viendra avec Tika au jalon 3.

Conséquences

  • Un fichier déposé devient un nœud complet : titre, liens wiki, tags, hiérarchie, corbeille, historique. Le moteur reste générique : aucun aspect ni type n’est implicite.
  • node.file_ref (V1) prend son sens. Une valeur non nulle déjà présente ferait échouer V6 (clé étrangère) ; aucune n’a jamais été écrite.
  • Le disque grossit sans jamais rétrécir avant le jalon 3. Rien ne se perd, mais il n’y a pas de quota : à surveiller dans la tranche déploiement (alerte d’espace libre).
  • Le reverse proxy (Caddy, tranche déploiement) ne doit pas limiter le corps des requêtes en dessous de 60 Mo. La future CSP de l’application devra autoriser frame-src 'self' et img-src 'self'.
  • Aperçus non uniformes : un PDF sur Chrome Android ou une photo HEIC d’iPhone (non affichable hors Safari) n’ont que les boutons Ouvrir et Télécharger.
  • Le texte s’affiche toujours en UTF-8 dans l’application. Ouvert dans un onglet, un text/plain sans charset est décodé selon la supposition du navigateur.
  • Un PDF est servi inline sans sandbox. Le JavaScript d’un PDF s’exécute dans le lecteur du navigateur, isolé de la page (PDFium, pdf.js), mais à la même origine. C’est un risque résiduel accepté pour un usage personnel ; l’origine séparée en est le remède.
  • Les métadonnées des fichiers (EXIF, géolocalisation des photos) sont conservées : l’original est intact. À traiter au partage public (« Plus tard »).
  • Les fichiers ne sont pas chiffrés au repos par l’application : chiffrer le disque de l’hôte (cahier : chiffrement au repos hors MVP).
  • Le contrat OpenAPI gagne du multipart et du binaire. Le client généré envoie un Blob : il faut passer le File lui-même, sans le recopier, pour garder son nom dans le FormData, et un test le vérifie.

Reporté

  • Miniatures et aperçus générés (cahier « Rendus », MVP sans jalon explicite) : avec la galerie ou une colonne vignette.
  • Aperçu Office par conversion : V2, ou au jalon 3 avec Tika si l’extraction de texte suffit.
  • pdf.js pour le mobile : avec la PWA (jalon 2), si le besoin est confirmé.
  • Extraction du texte des PDF pour la recherche (jalon 3, Tika), OCR (V2).
  • Purge des contenus orphelins (jalon 3, avec la corbeille), versions de fichier (V2), dossier surveillé et partage mobile (V2).
  • ![[fichier]] (transclusion, V2) ; collage d’une image dans l’éditeur qui crée un nœud et insère son URL relative.
  • Détection et signalement des doublons au dépôt (même sha256 déjà présent sur un autre nœud).
  • Stockage S3/MinIO.

Questions ouvertes tranchées (mode autonome)

  • Un ou plusieurs fichiers par nœud : un.
  • Rattachement automatique de l’aspect document : non, le moteur reste générique. Le classement viendra des règles (jalon 3) ou du classement automatique (V2).
  • Taille maximale : 50 Mo (inchangée), configurable par variable d’environnement.
  • Types refusés à l’upload : aucun ; liste blanche à la lecture.
  • Création en lot : un fichier par requête, en parallèle limité côté front.
  • Aperçu PDF : natif ; images : <img> ; texte : inclus (peu coûteux) ; Office : reporté.
  • Suppression physique : aucune avant le jalon 3.

Questions ouvertes tranchées (mode autonome)

  • Miniatures générées et aperçu Office par conversion : reportés après le jalon 1 (lignes ajoutées au backlog).
  • Aperçu PDF sur mobile (pdf.js) : non au jalon 1 ; boutons Ouvrir / Télécharger en repli.
  • Doublons au dépôt : pas de signalement au jalon 1 (le contenu est dédupliqué sur disque, un second nœud est créé).
  • Aucun type ni aspect posé par défaut sur un nœud déposé (pas de changement du méta-schéma).
  • Taille maximale : 50 Mo, surchargeable par variable d’environnement.