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:
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { FileVisibility } from '@prisma/client';
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsEnum(FileVisibility)
|
||||
visibility!: FileVisibility;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
Reference in New Issue
Block a user