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

  1. Use the avatarUrl field on the user object - with remote providers it already points at the provider-served URL
  2. If API-served avatars are required, switch the storage provider back to 'fs' or 'assetpack'
  3. 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

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)