toeverything/AFFiNE · warning · ActionForbidden
action_forbidden
action_forbidden
Error message
Only available when avatar storage provider is fs or assetpack.
What it means
GET /api/avatars/:id can only stream bytes when avatars live on the local filesystem or in the bundled asset pack (storage provider 'fs' or 'assetpack'). With remote providers, avatars are served from the provider's own URL, so this endpoint is intentionally disabled and answers action_forbidden before touching storage.
Solutions
- Use the avatarUrl field on the user object - with remote providers it already points at the provider-served URL
- If API-served avatars are required, switch the storage provider back to 'fs' or 'assetpack'
- Stop hand-building /api/avatars/... URLs in clients and integrations
Example fix
// before
const url = `/api/avatars/${avatarKey}`; // 403 when provider is s3/r2
// after
const url = user.avatarUrl ?? `/api/avatars/${avatarKey}`; Defensive patterns
Strategy: fallback
Validate before calling
// prefer the stored avatar URL; only call the API for local providers
function avatarSrc(user: { avatarUrl: string | null }): string {
return user.avatarUrl ?? defaultAvatar;
} Type guard
function isApiServedAvatar(avatarUrl: string): boolean {
return avatarUrl.startsWith('/api/avatars/');
} Try / catch
try {
return await fetch(`/api/avatars/${id}`);
} catch (e) {
if (e?.code === 'action_forbidden') return fetch(user.avatarUrl); // provider-served URL
throw e;
} Prevention
- Never construct /api/avatars URLs yourself - always read user.avatarUrl
- Know your deployment's storage provider: remote providers serve avatars directly from object storage
- Update integrations and bookmarks after migrating storage providers
When it happens
Trigger: Calling /api/avatars/:id while the storage provider is configured to anything other than 'fs' or 'assetpack' (e.g. s3/r2/azure); integrations hardcoding /api/avatars URLs after the deployment switched providers.
Common situations: Self-hosted instance migrated to S3-compatible storage while clients or bookmarks still hit the avatar API; scripts assuming the route exists on every deployment type.
Related errors
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/225243cc59ee9496.
Report an issue: GitHub.
Appendix: source
Thrown at packages/backend/server/src/core/user/controller.ts:21
import {
ActionForbidden,
applyAttachHeaders,
UserAvatarNotFound,
} from '../../base';
import { Public } from '../auth/guard';
import { AvatarStorage } from '../storage';
@Public()
@Controller('/api/avatars')
export class UserAvatarController {
constructor(private readonly storage: AvatarStorage) {}
@Get('/:id')
async getAvatar(@Res() res: Response, @Param('id') id: string) {
const provider = this.storage.config.storage.provider;
if (!['assetpack', 'fs'].includes(provider)) {
throw new ActionForbidden(
'Only available when avatar storage provider is fs or assetpack.'
);
}
const { body, metadata } = await this.storage.get(id);
if (!body) {
throw new UserAvatarNotFound();
}
// metadata should always exists if body is not null
if (metadata) {
res.setHeader('content-type', metadata.contentType);
res.setHeader('last-modified', metadata.lastModified.toISOString());
res.setHeader('content-length', metadata.contentLength);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,View on GitHub (pinned to b4c8548c09)