Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
@@ -0,0 +1,37 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ApiKeysService } from './api-keys.service';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly apiKeysService: ApiKeysService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// Check header (X-API-Key)
let apiKey = request.headers['x-api-key'] || request.headers['X-API-Key'];
// Fallback to query parameter (apiKey)
if (!apiKey) {
apiKey = request.query['apiKey'];
}
if (!apiKey) {
throw new UnauthorizedException('API Key missing');
}
try {
const keyEntry = await this.apiKeysService.validateKey(apiKey as string);
// Attach metadata to request if needed later
request.apiKeyMetadata = {
id: keyEntry.id,
name: keyEntry.name,
};
return true;
} catch (err) {
throw new UnauthorizedException(err.message || 'Invalid API Key');
}
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiKeysService } from './api-keys.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Controller('api/api-keys')
@UseGuards(JwtAuthGuard)
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@Post()
async create(@Body() body: { name: string; expiresDays?: number }) {
// Note: The plainKey is only returned here once.
return this.apiKeysService.createApiKey(body.name, body.expiresDays);
}
@Get()
async findAll() {
// Note: This returns hashed keys and prefixes, not the plain keys.
return this.apiKeysService.listKeys();
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.apiKeysService.deleteKey(id);
}
}
@@ -0,0 +1,71 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiKey } from '../database/entities/api-key.entity';
import * as crypto from 'crypto';
@Injectable()
export class ApiKeysService {
constructor(
@InjectRepository(ApiKey)
private readonly apiKeyRepo: Repository<ApiKey>,
) {}
async createApiKey(name: string, expiresDays?: number): Promise<{ plainKey: string; entity: ApiKey }> {
const prefix = 'pm_';
const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex
const plainKey = `${prefix}${randomPart}`;
const keyHash = this.hashKey(plainKey);
const apiKey = this.apiKeyRepo.create({
name,
keyPrefix: prefix,
keyHash,
expiresAt: expiresDays ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null,
});
const savedKey = await this.apiKeyRepo.save(apiKey);
return {
plainKey,
entity: savedKey,
};
}
async validateKey(plainKey: string): Promise<ApiKey> {
const keyHash = this.hashKey(plainKey);
const apiKey = await this.apiKeyRepo.findOne({
where: { keyHash },
});
if (!apiKey) {
throw new UnauthorizedException('Invalid API Key');
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
throw new UnauthorizedException('API Key has expired');
}
// Update last used timestamp (async, don't wait for it to return response faster)
apiKey.lastUsedAt = new Date();
this.apiKeyRepo.save(apiKey).catch(err => console.error('Error updating lastUsedAt:', err));
return apiKey;
}
async listKeys(): Promise<ApiKey[]> {
return this.apiKeyRepo.find({
order: { createdAt: 'DESC' },
});
}
async deleteKey(id: string): Promise<void> {
await this.apiKeyRepo.delete(id);
}
private hashKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex');
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiKeysService } from './api-keys.service';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeyGuard } from './api-key.guard';
import { JwtOrApiKeyGuard } from './jwt-or-apikey.guard';
import { ApiKey } from '../database/entities/api-key.entity';
import { PermissionsGuard } from './permissions.guard';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
TypeOrmModule.forFeature([ApiKey]),
],
controllers: [ApiKeysController],
providers: [
JwtStrategy,
JwtAuthGuard,
ApiKeysService,
ApiKeyGuard,
JwtOrApiKeyGuard,
PermissionsGuard,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: PermissionsGuard,
},
],
exports: [PassportModule, ApiKeysService, ApiKeyGuard, JwtAuthGuard, JwtOrApiKeyGuard, PermissionsGuard, TypeOrmModule],
})
export class AuthModule {}
@@ -0,0 +1,21 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@@ -0,0 +1,31 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiKeyGuard } from './api-key.guard';
import { lastValueFrom, isObservable } from 'rxjs';
/**
* Combined guard that accepts either a valid JWT Bearer token
* or a valid API key (X-API-Key header / apiKey query param).
* Tries JWT first, falls back to API key.
*/
@Injectable()
export class JwtOrApiKeyGuard implements CanActivate {
constructor(
private readonly jwtGuard: JwtAuthGuard,
private readonly apiKeyGuard: ApiKeyGuard,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Try JWT first
try {
const result = this.jwtGuard.canActivate(context);
const jwtOk = isObservable(result) ? await lastValueFrom(result) : await result;
if (jwtOk) return true;
} catch {
// JWT failed, try API key
}
// Fall back to API key
return this.apiKeyGuard.canActivate(context);
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { passportJwtSecret } from 'jwks-rsa';
import { mapGroupsToPermissions } from './permissions.enum';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
const issuer = configService.get<string>('OIDC_ISSUER', '');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
issuer,
algorithms: ['RS256'],
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${issuer.endsWith('/') ? issuer.slice(0, -1) : issuer}/jwks/`,
}),
});
}
validate(payload: any): { userId: string; email: string; name: string; preferredUsername: string | null; groups: string[]; permissions: any[] } {
const groups = payload.groups || [];
return {
userId: payload.sub,
email: payload.email,
name: payload.name || payload.preferred_username,
preferredUsername: payload.preferred_username ?? null,
groups: groups,
permissions: mapGroupsToPermissions(groups),
};
}
}
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Permission } from './permissions.enum';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions);
@@ -0,0 +1,35 @@
export const Permission = {
MANAGE_ALL: 'MANAGE_ALL',
PROCESS_MANUALLY: 'PROCESS_MANUALLY',
VIEW_MAIL: 'VIEW_MAIL',
VIEW_INBOX: 'VIEW_INBOX',
VIEW_SCANNER: 'VIEW_SCANNER',
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
} as const;
export type Permission = typeof Permission[keyof typeof Permission];
export function mapGroupsToPermissions(groups: string[] | undefined | null): Permission[] {
const permissions = new Set<Permission>();
if (!groups || !Array.isArray(groups)) {
return [];
}
if (groups.includes('PM_Admin')) {
permissions.add(Permission.MANAGE_ALL);
permissions.add(Permission.PROCESS_MANUALLY);
permissions.add(Permission.VIEW_MAIL);
permissions.add(Permission.VIEW_INBOX);
permissions.add(Permission.VIEW_SCANNER);
permissions.add(Permission.MANAGE_SETTINGS);
return Array.from(permissions);
}
if (groups.includes('PM_Belege')) permissions.add(Permission.PROCESS_MANUALLY);
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
return Array.from(permissions);
}
@@ -0,0 +1,40 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from './permissions.decorator';
import { Permission } from './permissions.enum';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredPermissions) {
return true;
}
const { user } = context.switchToHttp().getRequest();
// Let API Key requests bypass the permissions check for now, unless explicitly denied.
// Usually API keys have different scopes, but assuming they act as Admins for automated uploads.
if (user && user.apiKey) {
return true;
}
if (!user || !user.permissions) {
return false;
}
const userPermissions = user.permissions as Permission[];
if (userPermissions.includes(Permission.MANAGE_ALL)) {
return true;
}
return requiredPermissions.some((permission) => userPermissions.includes(permission));
}
}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);