feat(guild): file upload/retention + channel canvas

Files:
- StoredFile entity + FilesModule: multipart upload (configurable
  FABRIC_BACKEND_GUILD_FILE_MAX_BYTES, default 100MB; no type limit),
  authenticated download (Bearer or ?access_token=), hourly + on-boot
  retention sweep (FABRIC_BACKEND_GUILD_FILE_TTL_DAYS, default 7).
- ApiKeyGuard also accepts ?access_token= (browser <img>/<a>).

Canvas:
- ChannelCanvas entity (one active per channel) + CanvasModule:
  GET / PUT|POST (share-replace, caller becomes sharer) /
  PATCH (sharer-only in-place update, version++) / DELETE (sharer-only).
  Emits canvas.updated / canvas.removed to the channel room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 20:17:02 +01:00
parent b3fcefb5ec
commit 58badf328c
11 changed files with 523 additions and 2 deletions

View File

@@ -9,7 +9,11 @@ import { introspectGuildToken } from './center-auth.js';
@Injectable()
export class ApiKeyGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
const req = context.switchToHttp().getRequest<{
path?: string;
headers: Record<string, string | string[] | undefined>;
query?: Record<string, string | string[] | undefined>;
}>();
const path = req.path ?? '';
// allow health check without auth
@@ -19,7 +23,13 @@ export class ApiKeyGuard implements CanActivate {
const auth = req.headers['authorization'];
const authValue = Array.isArray(auth) ? auth[0] : auth;
const token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
let token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
// Browsers can't set Authorization on <img>/<a> (file downloads); accept
// the guild token via ?access_token= as a fallback. Still introspected.
if (!token) {
const qt = req.query?.['access_token'];
token = (Array.isArray(qt) ? qt[0] : qt) ?? '';
}
if (!token) throw new UnauthorizedException('missing bearer token');
const result = await introspectGuildToken(token);