Spunto CMS
L'API

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-data

Le 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}/content

Le 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.

Sur cette page