
# Profile Avatars

A signed-in user manages their own profile picture. There is no user id in either path: the account is always the caller's, so one user can never write another's avatar.

## The Two Endpoints

| Endpoint                     | Behavior                                                                                 |
| ---------------------------- | ---------------------------------------------------------------------------------------- |
| `POST /api/account/avatar`   | Multipart upload under the field name `file`. Returns `201` with the stored `image` URL. |
| `DELETE /api/account/avatar` | Clears the picture and removes the stored object. Returns `200`, and is idempotent.      |

```bash
curl -X POST https://app.example.com/api/account/avatar \
  -H "Cookie: $SESSION" \
  -F "file=@portrait.png"
```

```json
{
  "success": true,
  "image": "/api/buckets/avatars/files/9f3c1e2a-7b44-4d10-9e21-8a6f0c1d2e3b-avatar.png"
}
```

The URL lands on the user's `image`, which is what the [user directory](/en/docs/user-management) projects and what `GET /api/account/export` reports.

## Declare an `avatars` Bucket

This is a hard requirement, not a convention:

```yaml
buckets:
  - name: avatars
    public: true
    maxFileSize: 2097152
```

:::callout
**Without a bucket named `avatars`, upload answers `404`.** The URL this endpoint mints names `avatars` literally, so there is deliberately no fallback to the implicit `default` bucket — a fallback would store the file, hand back a URL, and leave the user with an avatar that never loads. The name is checked before anything is read.

`public: true` is the usual choice. An avatar is rendered into other people's browsers by definition, so a private bucket makes every `<img>` tag `404` for everyone but its owner.
:::

`maxFileSize` on that bucket caps the upload; without it the limit is 2 MB. Over the cap answers `413`.

## What Is Accepted

Sovrium ignores the filename you send and ignores the `Content-Type` you declare. It decodes the bytes and reads the real container header, then derives the extension from that.

| Format | Stored as |
| ------ | --------- |
| PNG    | `.png`    |
| JPEG   | `.jpg`    |
| WebP   | `.webp`   |

Anything else — including a file that merely claims to be one of the three — answers `400` and leaves the existing avatar untouched.

:::callout
**Discarding the filename removes a whole class of problem rather than checking for it.** The filename is the one part of an upload entirely chosen by the uploader, so path traversal, null bytes and extension/content mismatch stop being checks that could be wrong and become shapes that cannot arrive. Reading the container header rather than the declared type means HTML calling itself `image/png` is refused here, instead of being stored and later served from your own origin.
:::

## The Image URL Is Not Writable

`image` cannot be set by hand. Sending one to `POST /api/auth/update-user` — or to sign-up, or to the admin user endpoints — answers `400`:

```json
{
  "message": "A profile image cannot be set directly. Upload one through the account avatar endpoint, or send `image: null` to clear it."
}
```

Every non-null value is refused, including a well-formed URL pointing at your own `avatars` bucket. Shape would prove the URL points at this instance; it would not prove the object belongs to the caller, and that is the thing worth proving. Sending `image: null` is permitted and clears the picture; omitting the field entirely leaves it alone, so an ordinary name change still works.

## Outcomes

| Outcome                           | Status                     |
| --------------------------------- | -------------------------- |
| Uploaded                          | `201` with the `image` URL |
| Deleted, or already absent        | `200`                      |
| No session                        | `401`                      |
| No `avatars` bucket declared      | `404`                      |
| No `file` field in the body       | `400`                      |
| Not a decodable PNG, JPEG or WebP | `400`                      |
| Over the bucket cap, or over 2 MB | `413`                      |

Deleting an account through [GDPR erasure](/en/docs/gdpr-privacy) removes the stored avatar object along with the row.

## Related Pages

- [User Management](/en/docs/user-management) — the directory that renders these images.
- [Buckets Overview](/en/docs/buckets-overview) — declaring the `avatars` bucket.
- [File Operations](/en/docs/file-operations) — the download route the minted URL points at.
- [GDPR & Privacy](/en/docs/gdpr-privacy) — what erasure removes.
