mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
3de2f25552
commit
02a5172c7c
20 changed files with 830 additions and 1 deletions
47
apps/clock/apps/backend/src/admin/admin.controller.ts
Normal file
47
apps/clock/apps/backend/src/admin/admin.controller.ts
Normal file
|
|
@ -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<UserDataResponse> {
|
||||
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<DeleteUserDataResponse> {
|
||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
||||
return this.adminService.deleteUserData(userId);
|
||||
}
|
||||
}
|
||||
12
apps/clock/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/clock/apps/backend/src/admin/admin.module.ts
Normal file
|
|
@ -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 {}
|
||||
145
apps/clock/apps/backend/src/admin/admin.service.ts
Normal file
145
apps/clock/apps/backend/src/admin/admin.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get user data counts for a specific user
|
||||
*/
|
||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
||||
|
||||
// Count alarms
|
||||
const alarmsResult = await this.db
|
||||
.select({ count: sql<number>`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<number>`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<number>`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<number>`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<DeleteUserDataResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
47
apps/photos/apps/backend/src/admin/admin.controller.ts
Normal file
47
apps/photos/apps/backend/src/admin/admin.controller.ts
Normal file
|
|
@ -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<UserDataResponse> {
|
||||
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<DeleteUserDataResponse> {
|
||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
||||
return this.adminService.deleteUserData(userId);
|
||||
}
|
||||
}
|
||||
12
apps/photos/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/photos/apps/backend/src/admin/admin.module.ts
Normal file
|
|
@ -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 {}
|
||||
143
apps/photos/apps/backend/src/admin/admin.service.ts
Normal file
143
apps/photos/apps/backend/src/admin/admin.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get user data counts for a specific user
|
||||
*/
|
||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
||||
|
||||
// Count albums
|
||||
const albumsResult = await this.db
|
||||
.select({ count: sql<number>`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<number>`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<number>`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<number>`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<number>`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<DeleteUserDataResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
47
apps/storage/apps/backend/src/admin/admin.controller.ts
Normal file
47
apps/storage/apps/backend/src/admin/admin.controller.ts
Normal file
|
|
@ -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<UserDataResponse> {
|
||||
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<DeleteUserDataResponse> {
|
||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
||||
return this.adminService.deleteUserData(userId);
|
||||
}
|
||||
}
|
||||
12
apps/storage/apps/backend/src/admin/admin.module.ts
Normal file
12
apps/storage/apps/backend/src/admin/admin.module.ts
Normal file
|
|
@ -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 {}
|
||||
154
apps/storage/apps/backend/src/admin/admin.service.ts
Normal file
154
apps/storage/apps/backend/src/admin/admin.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get user data counts for a specific user
|
||||
*/
|
||||
async getUserData(userId: string): Promise<UserDataResponse> {
|
||||
this.logger.log(`Getting user data for userId: ${userId}`);
|
||||
|
||||
// Count files
|
||||
const filesResult = await this.db
|
||||
.select({ count: sql<number>`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<number>`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<number>`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<number>`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<number>`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<DeleteUserDataResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue