From 02a5172c7c637c7485986aa973985f11d69573df Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:43:16 +0100 Subject: [PATCH] feat(admin): add GDPR user-data endpoints to photos, clock, storage backends - Add admin modules with GET/DELETE /api/v1/admin/user-data/:userId - Photos: albums, favorites, tags counting and deletion - Clock: alarms, timers, world clocks, presets counting and deletion - Storage: files, folders, shares, tags counting and deletion - Update UserDataService to include photos, clock, storage backends - Add ADMIN_SERVICE_KEY env var to all backends in docker-compose - Build storage-backend locally instead of using GHCR image Co-Authored-By: Claude Opus 4.5 --- .../backend/src/admin/admin.controller.ts | 47 ++++++ .../apps/backend/src/admin/admin.module.ts | 12 ++ .../apps/backend/src/admin/admin.service.ts | 145 +++++++++++++++++ .../src/admin/dto/user-data-response.dto.ts | 17 ++ .../src/admin/guards/service-auth.guard.ts | 40 +++++ apps/clock/apps/backend/src/app.module.ts | 2 + .../backend/src/admin/admin.controller.ts | 47 ++++++ .../apps/backend/src/admin/admin.module.ts | 12 ++ .../apps/backend/src/admin/admin.service.ts | 143 ++++++++++++++++ .../src/admin/dto/user-data-response.dto.ts | 17 ++ .../src/admin/guards/service-auth.guard.ts | 40 +++++ apps/photos/apps/backend/src/app.module.ts | 2 + .../backend/src/admin/admin.controller.ts | 47 ++++++ .../apps/backend/src/admin/admin.module.ts | 12 ++ .../apps/backend/src/admin/admin.service.ts | 154 ++++++++++++++++++ .../src/admin/dto/user-data-response.dto.ts | 17 ++ .../src/admin/guards/service-auth.guard.ts | 40 +++++ apps/storage/apps/backend/src/app.module.ts | 2 + docker-compose.macmini.yml | 17 +- .../src/admin/user-data.service.ts | 18 ++ 20 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 apps/clock/apps/backend/src/admin/admin.controller.ts create mode 100644 apps/clock/apps/backend/src/admin/admin.module.ts create mode 100644 apps/clock/apps/backend/src/admin/admin.service.ts create mode 100644 apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts create mode 100644 apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts create mode 100644 apps/photos/apps/backend/src/admin/admin.controller.ts create mode 100644 apps/photos/apps/backend/src/admin/admin.module.ts create mode 100644 apps/photos/apps/backend/src/admin/admin.service.ts create mode 100644 apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts create mode 100644 apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts create mode 100644 apps/storage/apps/backend/src/admin/admin.controller.ts create mode 100644 apps/storage/apps/backend/src/admin/admin.module.ts create mode 100644 apps/storage/apps/backend/src/admin/admin.service.ts create mode 100644 apps/storage/apps/backend/src/admin/dto/user-data-response.dto.ts create mode 100644 apps/storage/apps/backend/src/admin/guards/service-auth.guard.ts diff --git a/apps/clock/apps/backend/src/admin/admin.controller.ts b/apps/clock/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..57d434424 --- /dev/null +++ b/apps/clock/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + Delete, + Param, + UseGuards, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AdminService } from './admin.service'; +import { ServiceAuthGuard } from './guards/service-auth.guard'; +import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto'; + +/** + * Admin controller for user data queries + * Used by mana-core-auth aggregation service + * Protected by X-Service-Key authentication + */ +@Controller('api/v1/admin') +@UseGuards(ServiceAuthGuard) +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor(private readonly adminService: AdminService) {} + + /** + * Get user data counts for a specific user + * GET /api/v1/admin/user-data/:userId + */ + @Get('user-data/:userId') + async getUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: getUserData for userId=${userId}`); + return this.adminService.getUserData(userId); + } + + /** + * Delete all user data (GDPR right to be forgotten) + * DELETE /api/v1/admin/user-data/:userId + */ + @Delete('user-data/:userId') + @HttpCode(HttpStatus.OK) + async deleteUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/clock/apps/backend/src/admin/admin.module.ts b/apps/clock/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/clock/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [ConfigModule, DatabaseModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/apps/clock/apps/backend/src/admin/admin.service.ts b/apps/clock/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..fe87d0ae4 --- /dev/null +++ b/apps/clock/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { eq, sql, desc } from 'drizzle-orm'; +import * as schema from '../db/schema'; +import { + UserDataResponse, + DeleteUserDataResponse, + EntityCount, +} from './dto/user-data-response.dto'; + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: PostgresJsDatabase + ) {} + + /** + * Get user data counts for a specific user + */ + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count alarms + const alarmsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.alarms) + .where(eq(schema.alarms.userId, userId)); + const alarmsCount = alarmsResult[0]?.count ?? 0; + + // Count timers + const timersResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.timers) + .where(eq(schema.timers.userId, userId)); + const timersCount = timersResult[0]?.count ?? 0; + + // Count world clocks + const worldClocksResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.worldClocks) + .where(eq(schema.worldClocks.userId, userId)); + const worldClocksCount = worldClocksResult[0]?.count ?? 0; + + // Count presets + const presetsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.presets) + .where(eq(schema.presets.userId, userId)); + const presetsCount = presetsResult[0]?.count ?? 0; + + // Get last activity (most recent alarm update) + const lastAlarm = await this.db + .select({ updatedAt: schema.alarms.updatedAt }) + .from(schema.alarms) + .where(eq(schema.alarms.userId, userId)) + .orderBy(desc(schema.alarms.updatedAt)) + .limit(1); + const lastActivityAt = lastAlarm[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'alarms', count: alarmsCount, label: 'Wecker' }, + { entity: 'timers', count: timersCount, label: 'Timer' }, + { entity: 'world_clocks', count: worldClocksCount, label: 'Weltuhren' }, + { entity: 'presets', count: presetsCount, label: 'Vorlagen' }, + ]; + + const totalCount = alarmsCount + timersCount + worldClocksCount + presetsCount; + + return { + entities, + totalCount, + lastActivityAt, + }; + } + + /** + * Delete all user data (GDPR right to be forgotten) + */ + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete alarms + const deletedAlarms = await this.db + .delete(schema.alarms) + .where(eq(schema.alarms.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'alarms', + count: deletedAlarms.length, + label: 'Wecker', + }); + totalDeleted += deletedAlarms.length; + + // Delete timers + const deletedTimers = await this.db + .delete(schema.timers) + .where(eq(schema.timers.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'timers', + count: deletedTimers.length, + label: 'Timer', + }); + totalDeleted += deletedTimers.length; + + // Delete world clocks + const deletedWorldClocks = await this.db + .delete(schema.worldClocks) + .where(eq(schema.worldClocks.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'world_clocks', + count: deletedWorldClocks.length, + label: 'Weltuhren', + }); + totalDeleted += deletedWorldClocks.length; + + // Delete presets + const deletedPresets = await this.db + .delete(schema.presets) + .where(eq(schema.presets.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'presets', + count: deletedPresets.length, + label: 'Vorlagen', + }); + totalDeleted += deletedPresets.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { + success: true, + deletedCounts, + totalDeleted, + }; + } +} diff --git a/apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/clock/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +export interface EntityCount { + entity: string; + count: number; + label: string; +} + +export interface UserDataResponse { + entities: EntityCount[]; + totalCount: number; + lastActivityAt?: string; +} + +export interface DeleteUserDataResponse { + success: boolean; + deletedCounts: EntityCount[]; + totalDeleted: number; +} diff --git a/apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..81b60d0a4 --- /dev/null +++ b/apps/clock/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,40 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +/** + * Guard for internal service-to-service authentication using X-Service-Key header + * Used by mana-core-auth to query user data across backends + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly serviceKey: string; + + constructor(private readonly configService: ConfigService) { + this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const providedKey = request.headers['x-service-key'] as string; + + if (!providedKey) { + this.logger.warn('Missing X-Service-Key header'); + throw new UnauthorizedException('Missing service key'); + } + + if (providedKey !== this.serviceKey) { + this.logger.warn('Invalid service key provided'); + throw new UnauthorizedException('Invalid service key'); + } + + return true; + } +} diff --git a/apps/clock/apps/backend/src/app.module.ts b/apps/clock/apps/backend/src/app.module.ts index 545bb9b7c..0e9517293 100644 --- a/apps/clock/apps/backend/src/app.module.ts +++ b/apps/clock/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { AlarmModule } from './alarm/alarm.module'; import { TimerModule } from './timer/timer.module'; import { WorldClockModule } from './world-clock/world-clock.module'; import { PresetModule } from './preset/preset.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { PresetModule } from './preset/preset.module'; TimerModule, WorldClockModule, PresetModule, + AdminModule, ], }) export class AppModule {} diff --git a/apps/photos/apps/backend/src/admin/admin.controller.ts b/apps/photos/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..57d434424 --- /dev/null +++ b/apps/photos/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + Delete, + Param, + UseGuards, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AdminService } from './admin.service'; +import { ServiceAuthGuard } from './guards/service-auth.guard'; +import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto'; + +/** + * Admin controller for user data queries + * Used by mana-core-auth aggregation service + * Protected by X-Service-Key authentication + */ +@Controller('api/v1/admin') +@UseGuards(ServiceAuthGuard) +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor(private readonly adminService: AdminService) {} + + /** + * Get user data counts for a specific user + * GET /api/v1/admin/user-data/:userId + */ + @Get('user-data/:userId') + async getUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: getUserData for userId=${userId}`); + return this.adminService.getUserData(userId); + } + + /** + * Delete all user data (GDPR right to be forgotten) + * DELETE /api/v1/admin/user-data/:userId + */ + @Delete('user-data/:userId') + @HttpCode(HttpStatus.OK) + async deleteUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/photos/apps/backend/src/admin/admin.module.ts b/apps/photos/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/photos/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [ConfigModule, DatabaseModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/apps/photos/apps/backend/src/admin/admin.service.ts b/apps/photos/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..86e73be42 --- /dev/null +++ b/apps/photos/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,143 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { eq, sql, desc } from 'drizzle-orm'; +import * as schema from '../db/schema'; +import { + UserDataResponse, + DeleteUserDataResponse, + EntityCount, +} from './dto/user-data-response.dto'; + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: PostgresJsDatabase + ) {} + + /** + * Get user data counts for a specific user + */ + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count albums + const albumsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.albums) + .where(eq(schema.albums.userId, userId)); + const albumsCount = albumsResult[0]?.count ?? 0; + + // Count album items (through albums) + const albumItemsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.albumItems) + .innerJoin(schema.albums, eq(schema.albumItems.albumId, schema.albums.id)) + .where(eq(schema.albums.userId, userId)); + const albumItemsCount = albumItemsResult[0]?.count ?? 0; + + // Count favorites + const favoritesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.favorites) + .where(eq(schema.favorites.userId, userId)); + const favoritesCount = favoritesResult[0]?.count ?? 0; + + // Count tags + const tagsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.tags) + .where(eq(schema.tags.userId, userId)); + const tagsCount = tagsResult[0]?.count ?? 0; + + // Count photo tags (through tags) + const photoTagsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.photoTags) + .innerJoin(schema.tags, eq(schema.photoTags.tagId, schema.tags.id)) + .where(eq(schema.tags.userId, userId)); + const photoTagsCount = photoTagsResult[0]?.count ?? 0; + + // Get last activity (most recent album update) + const lastAlbum = await this.db + .select({ updatedAt: schema.albums.updatedAt }) + .from(schema.albums) + .where(eq(schema.albums.userId, userId)) + .orderBy(desc(schema.albums.updatedAt)) + .limit(1); + const lastActivityAt = lastAlbum[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'albums', count: albumsCount, label: 'Alben' }, + { entity: 'album_items', count: albumItemsCount, label: 'Album-Einträge' }, + { entity: 'favorites', count: favoritesCount, label: 'Favoriten' }, + { entity: 'tags', count: tagsCount, label: 'Tags' }, + { entity: 'photo_tags', count: photoTagsCount, label: 'Foto-Tags' }, + ]; + + const totalCount = albumsCount + albumItemsCount + favoritesCount + tagsCount + photoTagsCount; + + return { + entities, + totalCount, + lastActivityAt, + }; + } + + /** + * Delete all user data (GDPR right to be forgotten) + */ + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete favorites + const deletedFavorites = await this.db + .delete(schema.favorites) + .where(eq(schema.favorites.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'favorites', + count: deletedFavorites.length, + label: 'Favoriten', + }); + totalDeleted += deletedFavorites.length; + + // Delete tags (cascades to photo_tags) + const deletedTags = await this.db + .delete(schema.tags) + .where(eq(schema.tags.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'tags', + count: deletedTags.length, + label: 'Tags', + }); + totalDeleted += deletedTags.length; + + // Delete albums (cascades to album_items) + const deletedAlbums = await this.db + .delete(schema.albums) + .where(eq(schema.albums.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'albums', + count: deletedAlbums.length, + label: 'Alben', + }); + totalDeleted += deletedAlbums.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { + success: true, + deletedCounts, + totalDeleted, + }; + } +} diff --git a/apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/photos/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +export interface EntityCount { + entity: string; + count: number; + label: string; +} + +export interface UserDataResponse { + entities: EntityCount[]; + totalCount: number; + lastActivityAt?: string; +} + +export interface DeleteUserDataResponse { + success: boolean; + deletedCounts: EntityCount[]; + totalDeleted: number; +} diff --git a/apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..81b60d0a4 --- /dev/null +++ b/apps/photos/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,40 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +/** + * Guard for internal service-to-service authentication using X-Service-Key header + * Used by mana-core-auth to query user data across backends + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly serviceKey: string; + + constructor(private readonly configService: ConfigService) { + this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const providedKey = request.headers['x-service-key'] as string; + + if (!providedKey) { + this.logger.warn('Missing X-Service-Key header'); + throw new UnauthorizedException('Missing service key'); + } + + if (providedKey !== this.serviceKey) { + this.logger.warn('Invalid service key provided'); + throw new UnauthorizedException('Invalid service key'); + } + + return true; + } +} diff --git a/apps/photos/apps/backend/src/app.module.ts b/apps/photos/apps/backend/src/app.module.ts index bcd19fde0..6c4418c70 100644 --- a/apps/photos/apps/backend/src/app.module.ts +++ b/apps/photos/apps/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { AlbumModule } from './album/album.module'; import { FavoriteModule } from './favorite/favorite.module'; import { TagModule } from './tag/tag.module'; import { PhotoModule } from './photo/photo.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { PhotoModule } from './photo/photo.module'; FavoriteModule, TagModule, PhotoModule, + AdminModule, ], }) export class AppModule {} diff --git a/apps/storage/apps/backend/src/admin/admin.controller.ts b/apps/storage/apps/backend/src/admin/admin.controller.ts new file mode 100644 index 000000000..57d434424 --- /dev/null +++ b/apps/storage/apps/backend/src/admin/admin.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + Delete, + Param, + UseGuards, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { AdminService } from './admin.service'; +import { ServiceAuthGuard } from './guards/service-auth.guard'; +import { UserDataResponse, DeleteUserDataResponse } from './dto/user-data-response.dto'; + +/** + * Admin controller for user data queries + * Used by mana-core-auth aggregation service + * Protected by X-Service-Key authentication + */ +@Controller('api/v1/admin') +@UseGuards(ServiceAuthGuard) +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor(private readonly adminService: AdminService) {} + + /** + * Get user data counts for a specific user + * GET /api/v1/admin/user-data/:userId + */ + @Get('user-data/:userId') + async getUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: getUserData for userId=${userId}`); + return this.adminService.getUserData(userId); + } + + /** + * Delete all user data (GDPR right to be forgotten) + * DELETE /api/v1/admin/user-data/:userId + */ + @Delete('user-data/:userId') + @HttpCode(HttpStatus.OK) + async deleteUserData(@Param('userId') userId: string): Promise { + this.logger.log(`Admin request: deleteUserData for userId=${userId}`); + return this.adminService.deleteUserData(userId); + } +} diff --git a/apps/storage/apps/backend/src/admin/admin.module.ts b/apps/storage/apps/backend/src/admin/admin.module.ts new file mode 100644 index 000000000..a8f6ed50c --- /dev/null +++ b/apps/storage/apps/backend/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [ConfigModule, DatabaseModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/apps/storage/apps/backend/src/admin/admin.service.ts b/apps/storage/apps/backend/src/admin/admin.service.ts new file mode 100644 index 000000000..4736d5859 --- /dev/null +++ b/apps/storage/apps/backend/src/admin/admin.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { eq, sql, desc } from 'drizzle-orm'; +import * as schema from '../db/schema'; +import { + UserDataResponse, + DeleteUserDataResponse, + EntityCount, +} from './dto/user-data-response.dto'; + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: PostgresJsDatabase + ) {} + + /** + * Get user data counts for a specific user + */ + async getUserData(userId: string): Promise { + this.logger.log(`Getting user data for userId: ${userId}`); + + // Count files + const filesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.files) + .where(eq(schema.files.userId, userId)); + const filesCount = filesResult[0]?.count ?? 0; + + // Count folders + const foldersResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.folders) + .where(eq(schema.folders.userId, userId)); + const foldersCount = foldersResult[0]?.count ?? 0; + + // Count shares + const sharesResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.shares) + .where(eq(schema.shares.userId, userId)); + const sharesCount = sharesResult[0]?.count ?? 0; + + // Count tags + const tagsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.tags) + .where(eq(schema.tags.userId, userId)); + const tagsCount = tagsResult[0]?.count ?? 0; + + // Count file tags (through files) + const fileTagsResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(schema.fileTags) + .innerJoin(schema.files, eq(schema.fileTags.fileId, schema.files.id)) + .where(eq(schema.files.userId, userId)); + const fileTagsCount = fileTagsResult[0]?.count ?? 0; + + // Get last activity (most recent file update) + const lastFile = await this.db + .select({ updatedAt: schema.files.updatedAt }) + .from(schema.files) + .where(eq(schema.files.userId, userId)) + .orderBy(desc(schema.files.updatedAt)) + .limit(1); + const lastActivityAt = lastFile[0]?.updatedAt?.toISOString(); + + const entities: EntityCount[] = [ + { entity: 'files', count: filesCount, label: 'Dateien' }, + { entity: 'folders', count: foldersCount, label: 'Ordner' }, + { entity: 'shares', count: sharesCount, label: 'Freigaben' }, + { entity: 'tags', count: tagsCount, label: 'Tags' }, + { entity: 'file_tags', count: fileTagsCount, label: 'Datei-Tags' }, + ]; + + const totalCount = filesCount + foldersCount + sharesCount + tagsCount + fileTagsCount; + + return { + entities, + totalCount, + lastActivityAt, + }; + } + + /** + * Delete all user data (GDPR right to be forgotten) + */ + async deleteUserData(userId: string): Promise { + this.logger.log(`Deleting user data for userId: ${userId}`); + + const deletedCounts: EntityCount[] = []; + let totalDeleted = 0; + + // Delete shares first (references files/folders) + const deletedShares = await this.db + .delete(schema.shares) + .where(eq(schema.shares.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'shares', + count: deletedShares.length, + label: 'Freigaben', + }); + totalDeleted += deletedShares.length; + + // Delete tags (cascades to file_tags) + const deletedTags = await this.db + .delete(schema.tags) + .where(eq(schema.tags.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'tags', + count: deletedTags.length, + label: 'Tags', + }); + totalDeleted += deletedTags.length; + + // Delete files (cascades to file_versions) + const deletedFiles = await this.db + .delete(schema.files) + .where(eq(schema.files.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'files', + count: deletedFiles.length, + label: 'Dateien', + }); + totalDeleted += deletedFiles.length; + + // Delete folders + const deletedFolders = await this.db + .delete(schema.folders) + .where(eq(schema.folders.userId, userId)) + .returning(); + deletedCounts.push({ + entity: 'folders', + count: deletedFolders.length, + label: 'Ordner', + }); + totalDeleted += deletedFolders.length; + + this.logger.log(`Deleted ${totalDeleted} records for userId: ${userId}`); + + return { + success: true, + deletedCounts, + totalDeleted, + }; + } +} diff --git a/apps/storage/apps/backend/src/admin/dto/user-data-response.dto.ts b/apps/storage/apps/backend/src/admin/dto/user-data-response.dto.ts new file mode 100644 index 000000000..562a2eb6d --- /dev/null +++ b/apps/storage/apps/backend/src/admin/dto/user-data-response.dto.ts @@ -0,0 +1,17 @@ +export interface EntityCount { + entity: string; + count: number; + label: string; +} + +export interface UserDataResponse { + entities: EntityCount[]; + totalCount: number; + lastActivityAt?: string; +} + +export interface DeleteUserDataResponse { + success: boolean; + deletedCounts: EntityCount[]; + totalDeleted: number; +} diff --git a/apps/storage/apps/backend/src/admin/guards/service-auth.guard.ts b/apps/storage/apps/backend/src/admin/guards/service-auth.guard.ts new file mode 100644 index 000000000..81b60d0a4 --- /dev/null +++ b/apps/storage/apps/backend/src/admin/guards/service-auth.guard.ts @@ -0,0 +1,40 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +/** + * Guard for internal service-to-service authentication using X-Service-Key header + * Used by mana-core-auth to query user data across backends + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly serviceKey: string; + + constructor(private readonly configService: ConfigService) { + this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const providedKey = request.headers['x-service-key'] as string; + + if (!providedKey) { + this.logger.warn('Missing X-Service-Key header'); + throw new UnauthorizedException('Missing service key'); + } + + if (providedKey !== this.serviceKey) { + this.logger.warn('Invalid service key provided'); + throw new UnauthorizedException('Invalid service key'); + } + + return true; + } +} diff --git a/apps/storage/apps/backend/src/app.module.ts b/apps/storage/apps/backend/src/app.module.ts index 0ea122781..3e60a551a 100644 --- a/apps/storage/apps/backend/src/app.module.ts +++ b/apps/storage/apps/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { TagModule } from './tag/tag.module'; import { TrashModule } from './trash/trash.module'; import { SearchModule } from './search/search.module'; import { StorageModule } from './storage/storage.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { StorageModule } from './storage/storage.module'; TagModule, TrashModule, SearchModule, + AdminModule, ], }) export class AppModule {} diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index bbb8dec3d..0a4044e11 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -115,6 +115,10 @@ services: CONTACTS_BACKEND_URL: http://contacts-backend:3034 PICTURE_BACKEND_URL: http://photos-backend:3039 PRESI_BACKEND_URL: http://presi-backend:3036 + ZITARE_BACKEND_URL: http://zitare-backend:3007 + PHOTOS_BACKEND_URL: http://photos-backend:3039 + CLOCK_BACKEND_URL: http://clock-backend:3033 + STORAGE_BACKEND_URL: http://storage-backend:3035 ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} volumes: - analytics_data:/data/analytics @@ -287,6 +291,7 @@ services: AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} AZURE_OPENAI_API_VERSION: 2024-12-01-preview CORS_ORIGINS: https://chat.mana.how,https://mana.how + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3030:3030" healthcheck: @@ -312,6 +317,7 @@ services: DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/todo MANA_CORE_AUTH_URL: http://mana-auth:3001 CORS_ORIGINS: https://todo.mana.how,https://mana.how + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3031:3031" healthcheck: @@ -340,6 +346,7 @@ services: DB_USER: postgres MANA_CORE_AUTH_URL: http://mana-auth:3001 CORS_ORIGINS: https://calendar.mana.how,https://mana.how + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3032:3032" healthcheck: @@ -368,6 +375,7 @@ services: DB_USER: postgres MANA_CORE_AUTH_URL: http://mana-auth:3001 CORS_ORIGINS: https://clock.mana.how,https://mana.how + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3033:3033" healthcheck: @@ -402,6 +410,7 @@ services: S3_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} S3_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} S3_BUCKET: contacts-photos + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3034:3034" healthcheck: @@ -412,7 +421,10 @@ services: start_period: 40s storage-backend: - image: ghcr.io/memo-2023/storage-backend:latest + build: + context: . + dockerfile: apps/storage/apps/backend/Dockerfile + image: storage-backend:local container_name: mana-app-storage-backend restart: always depends_on: @@ -432,6 +444,7 @@ services: STORAGE_S3_PUBLIC_URL: https://storage-files.mana.how MAX_FILE_SIZE: 104857600 MAX_FILES_PER_UPLOAD: 10 + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3035:3035" healthcheck: @@ -454,6 +467,7 @@ services: DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/presi MANA_CORE_AUTH_URL: http://mana-auth:3001 CORS_ORIGINS: https://presi.mana.how,https://mana.how + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3036:3036" healthcheck: @@ -536,6 +550,7 @@ services: MANA_CORE_AUTH_URL: http://mana-auth:3001 MANA_MEDIA_URL: http://mana-media:3015 CORS_ORIGINS: https://photos.mana.how,https://mana.how + ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} ports: - "3039:3039" healthcheck: diff --git a/services/mana-core-auth/src/admin/user-data.service.ts b/services/mana-core-auth/src/admin/user-data.service.ts index a5db44d9c..4fdbae95f 100644 --- a/services/mana-core-auth/src/admin/user-data.service.ts +++ b/services/mana-core-auth/src/admin/user-data.service.ts @@ -85,6 +85,24 @@ export class UserDataService { icon: '📊', url: this.configService.get('PRESI_BACKEND_URL', 'http://localhost:3008'), }, + { + id: 'photos', + name: 'Photos', + icon: '📷', + url: this.configService.get('PHOTOS_BACKEND_URL', 'http://localhost:3019'), + }, + { + id: 'clock', + name: 'Clock', + icon: '⏰', + url: this.configService.get('CLOCK_BACKEND_URL', 'http://localhost:3017'), + }, + { + id: 'storage', + name: 'Storage', + icon: '💾', + url: this.configService.get('STORAGE_BACKEND_URL', 'http://localhost:3016'), + }, ]; }