Fichiers et pièces jointes
Envoyer des fichiers à l'API REST de Spunto CMS, les relire, et pourquoi une pièce jointe n'a pas d'URL partageable.
Les fichiers sont des pièces jointes sur les cartes et des images dans le corps des cartes. Un fichier appartient à un seul espace de travail, comme tout le reste.
La règle dont tout découle
Une pièce jointe n'a pas d'URL à elle.
Les métadonnées sont une ligne dans les tables de l'espace de travail. Les octets vivent dans un stockage objet, et l'API les sert elle-même, depuis une route scopée, derrière la même vérification d'appartenance que toute autre lecture.
Il n'y a ni bucket public, ni URL signée. Une URL signée serait un second chemin d'autorisation, plus faible que le premier, et toute la posture du 404 plutôt que 403 ne vaudrait plus rien s'il suffisait de partager un lien pour la contourner.
Un bloc image et une propriété file portent donc un identifiant, jamais
une adresse. Chaque consommateur compose l'URL qu'il sait servir.
Envoyer un fichier
POST /workspaces/{workspaceId}/files
Content-Type: multipart/form-dataLe champ s'appelle file. Les champs facultatifs externalSource et
externalId rendent l'envoi idempotent : si l'espace de travail détient
déjà ces octets sous cette origine, l'API renvoie le fichier existant avec un
200 au lieu d'en stocker une copie. C'est ce qui permet de rejouer un import
sans dupliquer ses images.
Avec un client généré pour openapi-fetch, envoyez un FormData et laissez le
navigateur ou Node poser la frontière multipart :
const form = new FormData()
form.append("file", blob, "screenshot.png")
await api.POST("/workspaces/{workspaceId}/files", {
params: { path: { workspaceId } },
body: form as never, // le type généré décrit les champs, pas FormData
})Lire les octets
GET /workspaces/{workspaceId}/files/{fileId}/contentLe même jeton bearer que pour tout le reste. Les images et les PDF sont servis
en inline ; tout le reste revient en téléchargement attachment, toujours avec
X-Content-Type-Options: nosniff et une Content-Security-Policy qui n'autorise
rien.
Le type servi n'est pas le type déclaré
Hors d'une liste blanche, l'API sert application/octet-stream en
téléchargement — quoi que l'envoi ait prétendu.
Ce n'est pas du zèle. En production, le front et l'API partagent une même
origine, et un SVG est un document scriptable : en servir un en ligne comme
image reviendrait à servir le script d'un attaquant depuis votre propre origine.
L'objet File indique donc comme contentType ce que l'API servira, et
image: false pour un SVG. Fiez-vous à ces deux champs plutôt qu'au nom du
fichier.
Couvertures de carte
L'image de couverture d'une carte est un identifiant de fichier porté par la
carte elle-même, coverFileId. Envoyez le fichier, puis écrivez l'identifiant :
const form = new FormData()
form.append("file", blob, "cover.png")
const { data: file } = await api.POST("/workspaces/{workspaceId}/files", {
params: { path: { workspaceId } },
body: form as never,
})
await api.PATCH("/workspaces/{workspaceId}/items/{itemId}", {
params: { path: { workspaceId, itemId } },
body: { coverFileId: file!.id },
})Seul un fichier que l'API sert comme image est accepté (image: true sur
l'objet File) — tout le reste vaut un 400. coverFileId voyage sur Item,
donc une page de vue le porte aussi ; lisez les octets depuis la route
ci-dessus. Supprimer le fichier laisse l'identifiant en place : traitez un 404
sur les octets comme « pas de couverture ».
Avatars
La photo d'un membre est une pièce jointe de l'espace de travail, référencée depuis son appartenance — pas depuis son compte utilisateur.
L'endroit paraît étrange jusqu'à ce qu'on essaie l'alternative : un avatar porté par le compte exigerait une règle du genre « on voit le visage de quiconque partage un espace de travail avec soi », ce qui serait le premier chemin de lecture du système à franchir la frontière d'un tenant. Pour une photo de profil.
Il n'y a donc rien de nouveau dans la couche de stockage ni dans le chemin de lecture, et le pire cas se dégrade en initiales.
Une photo de profil Google, quand il y en a une, est recopiée plutôt que servie : les octets sont récupérés une fois, là où naît une appartenance, et deviennent une pièce jointe ordinaire. Servir l'adresse de Google aurait rouvert d'un coup l'URL publique et le visage porté par le compte.
Brouillons et publication
Publier une carte, ne lire que ce qui a été publié, savoir ce qui a changé depuis — et les webhooks qu'un site écoute.
Événements, webhooks et apps
Suivre les changements d'un CMS headless depuis un autre logiciel — un journal d'événements rejouable, des webhooks vérifiables, des apps à scopes.