feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)

Full NestJS backend for the KC-App platform:
- auth: Authentik OIDC resource-server strategy + guest invite-code JWT
  login, plus TokenVerificationService for the WS handshake path
- kc: Leitungsteam-only KC (event) creation/listing
- wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService
  (port of the WP plugin's kc_run_zuteilung), CSV export
- files: LT-only upload with visibility tiers; list/download filtered by
  caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3)
- chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws
  gateway sharing ChatService access rules
- sync: append-only SyncLogEntry replication log + local<->cloud
  push/pull scheduler, shared-secret guarded
- common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global)
- serves client/web/ interim static web client under / (API under /api)

Typecheck, nest build and boot test pass; needs real Postgres/Authentik/
Nextcloud to run end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:23:45 +02:00
co-authored by Claude Sonnet 5
parent 327ce43404
commit 8ec127c0fb
42 changed files with 2648 additions and 78 deletions
+7
View File
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { FileVisibility } from '@prisma/client';
export class UploadFileDto {
@IsEnum(FileVisibility)
visibility!: FileVisibility;
}
+73
View File
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { FilesService } from './files.service';
import { UploadFileDto } from './dto/upload-file.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util';
type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
function isGuest(user: unknown): user is GuestJwtPayload {
return !!user && typeof user === 'object' && 'guestId' in user;
}
@Controller('files')
export class FilesController {
constructor(private readonly files: FilesService) {}
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
@Post(':kcId')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
@UseInterceptors(FileInterceptor('file'))
upload(
@Param('kcId') kcId: string,
@Body() dto: UploadFileDto,
@UploadedFile() file: Express.Multer.File,
@Req() req: AuthenticatedRequest,
) {
return this.files.upload(kcId, dto.visibility, file.originalname, file.buffer, req.user!.userId);
}
@Get(':kcId')
@UseGuards(AuthGuard(['authentik', 'guest']))
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES
: allowedVisibilitiesForUser(req.user!, kcId);
return this.files.listForCaller(kcId, allowed);
}
@Get('download/:fileId')
@UseGuards(AuthGuard(['authentik', 'guest']))
async download(
@Param('fileId') fileId: string,
@Req() req: FileCallerRequest,
@Res() res: Response,
) {
const meta = await this.files.getFileOrThrow(fileId);
const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES
: allowedVisibilitiesForUser(req.user!, meta.kcId);
const { file, data } = await this.files.downloadForCaller(fileId, allowed);
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
res.send(data);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesService } from './files.service';
import { FilesController } from './files.controller';
import { STORAGE_PROVIDER } from './storage/storage-provider';
import { WebDavStorageProvider } from './storage/webdav-storage.provider';
import { S3StorageProvider } from './storage/s3-storage.provider';
@Module({
controllers: [FilesController],
providers: [
FilesService,
{
// Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage.
provide: STORAGE_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('STORAGE_PROVIDER') === 's3'
? new S3StorageProvider(config)
: new WebDavStorageProvider(config),
},
],
})
export class FilesModule {}
+53
View File
@@ -0,0 +1,53 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FileVisibility, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider';
import { SyncService } from '../sync/sync.service';
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaClient,
@Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider,
private readonly sync: SyncService,
) {}
async upload(
kcId: string,
visibility: FileVisibility,
filename: string,
data: Buffer,
uploadedById: string,
) {
const storageKey = await this.storage.upload(kcId, filename, data);
const file = await this.prisma.file.create({
data: { kcId, storageKey, filename, visibility, uploadedById },
});
// Note: only metadata is replicated here; storageKey only resolves if
// local and cloud share the same Nextcloud/S3 backend (see sync docs).
await this.sync.capture('File', SyncOperation.CREATE, file.id, file);
return file;
}
listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) {
return this.prisma.file.findMany({
where: { kcId, visibility: { in: allowedVisibilities } },
orderBy: { createdAt: 'desc' },
});
}
async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) {
const file = await this.getFileOrThrow(fileId);
if (!allowedVisibilities.includes(file.visibility)) {
throw new ForbiddenException('Not permitted to access this file');
}
const data = await this.storage.download(file.storageKey);
return { file, data };
}
getFileOrThrow(fileId: string) {
return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => {
throw new NotFoundException('File not found');
});
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { StorageProvider } from './storage-provider';
/// S3-compatible object storage (AWS S3, MinIO, etc.).
@Injectable()
export class S3StorageProvider implements StorageProvider {
private readonly client: S3Client;
private readonly bucket: string;
constructor(config: ConfigService) {
this.bucket = config.getOrThrow<string>('S3_BUCKET');
this.client = new S3Client({
region: config.get<string>('S3_REGION') ?? 'auto',
endpoint: config.get<string>('S3_ENDPOINT'),
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE') === 'true',
credentials: {
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
},
});
}
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
const storageKey = `${kcId}/${randomUUID()}-${filename}`;
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: storageKey, Body: data }),
);
return storageKey;
}
async download(storageKey: string): Promise<Buffer> {
const result = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: storageKey }),
);
const chunks: Uint8Array[] = [];
for await (const chunk of result.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async delete(storageKey: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey }));
}
}
+10
View File
@@ -0,0 +1,10 @@
/// Abstraction over the external file storage backend (Nextcloud via WebDAV,
/// or S3-compatible object storage). Implementations only need to move raw
/// bytes; visibility/ownership metadata lives in the `File` Prisma model.
export interface StorageProvider {
upload(kcId: string, filename: string, data: Buffer): Promise<string>;
download(storageKey: string): Promise<Buffer>;
delete(storageKey: string): Promise<void>;
}
export const STORAGE_PROVIDER = Symbol('STORAGE_PROVIDER');
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { createClient, WebDAVClient } from 'webdav';
import { StorageProvider } from './storage-provider';
/// Nextcloud (or any WebDAV server) as file storage backend.
@Injectable()
export class WebDavStorageProvider implements StorageProvider {
private readonly client: WebDAVClient;
constructor(config: ConfigService) {
this.client = createClient(config.getOrThrow<string>('WEBDAV_URL'), {
username: config.getOrThrow<string>('WEBDAV_USERNAME'),
password: config.getOrThrow<string>('WEBDAV_PASSWORD'),
});
}
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
const dir = `/${kcId}`;
if (!(await this.client.exists(dir))) {
await this.client.createDirectory(dir, { recursive: true });
}
const storageKey = `${dir}/${randomUUID()}-${filename}`;
await this.client.putFileContents(storageKey, data, { overwrite: false });
return storageKey;
}
async download(storageKey: string): Promise<Buffer> {
const content = await this.client.getFileContents(storageKey);
return Buffer.isBuffer(content) ? content : Buffer.from(content as ArrayBuffer);
}
async delete(storageKey: string): Promise<void> {
await this.client.deleteFile(storageKey);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { FileVisibility, Role } from '@prisma/client';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Maps the caller's role for a given KC to the file visibility tiers they may see.
/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything.
export function allowedVisibilitiesForUser(
user: AuthenticatedUser,
kcId: string,
): FileVisibility[] {
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT];
}
const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId);
if (isTeamMemberForKc) {
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS];
}
return [];
}
export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE];