# Avatar Upload And Serving

## Goal

Give AGS accounts and organizations first-party profile pictures without
breaking the public GitHub-compatible REST surface. This replaces the legacy
`/avatars/{login}` implementation which redirected every account to the same
external GitHub image.

## GitHub Compatibility Boundary

GitHub's documented REST `PATCH /user` and `PATCH /orgs/{org}` requests do not
accept avatar image data. Their responses expose `avatar_url`; image changes
are a settings-page capability rather than a public GitHub upload API.

Accordingly, AGS keeps profile metadata on GitHub-compatible routes and exposes
avatar writes only through AGS extensions:

```text
PUT    /api/ext/v1/user/avatar
DELETE /api/ext/v1/user/avatar
PUT    /api/ext/v1/orgs/{org}/avatar
DELETE /api/ext/v1/orgs/{org}/avatar
```

Every GitHub-shaped REST and GraphQL user representation returns the local
versioned `avatar_url` that AGS serves at `/avatars/{login}?v={version}`.

## Behavior

- An authenticated account may change only its own picture.
- Organization owners (and site admins) may change an organization picture.
- Source uploads accept PNG, JPEG, and GIF up to 1 MiB and 3000px on either
  edge, matching GitHub's documented profile image envelope.
- AGS decodes the file instead of trusting multipart MIME metadata, rejects
  invalid images, strips source metadata by re-encoding, and caps the stored
  long edge at 512px. GIF inputs are normalized to their first frame PNG to
  retain a bounded, metadata-free serving format.
- A custom image is served as `image/png`; it never redirects to GitHub or an
  identity provider.
- Deleting a custom image increments the version and serves a deterministic
  local identicon. It cannot silently restore a connected-login image.
- A versioned current URL is immutable-cacheable for one year. An unversioned
  direct URL is revalidated and a stale version returns 404 rather than serving
  a newer account image under an old cache key.

## Storage And Data Model

`users.avatar_key` contains a server-generated opaque PNG object key and
`users.avatar_version` drives cache invalidation. Image bytes are stored below
the tenant's durable AGS storage root under `.avatars/`, beside the existing
tenant-scoped Git and wiki object stores, not in TiDB rows.

The storage adapter has a narrow `Put/Get/Delete` interface. The initial
adapter is a durable filesystem implementation because AGS already requires
the tenant storage root for Git repositories and wiki blobs. A future object
store adapter can replace it without changing the service or HTTP contract.

Upload writes a new opaque object before a row-locked database transaction.
If the transaction fails the new object is deleted; after a successful commit,
the replaced object is deleted. A missing object falls back to a generated
identicon and emits an operator error log instead of returning a broken avatar.

## Errors

| Status | Meaning |
| --- | --- |
| `400` | Missing or malformed multipart `file` field |
| `401` | No authenticated AGS identity |
| `403` | Caller is not an organization owner/site admin |
| `413` | Source file exceeds 1 MiB |
| `415` | Unsupported, malformed, or dimension-invalid image |

## Verification

- Unit tests cover filesystem object storage and accepted/invalid image
  normalization.
- HTTP tests cover personal upload/delete, versioned serving, stale URLs,
  request errors, organization authorization, and no external redirect.
- The staging acceptance check uploads through gh-web, refreshes the header
  and profile page, then removes the image and verifies the generated fallback.
