feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung
- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten - Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden - Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt - Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { DkvController } from '../dkv/dkv.controller';
|
||||
import { ModuleGrantsController } from '../groups/module-grants.controller';
|
||||
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
|
||||
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
|
||||
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
|
||||
import { ProxmoxController } from '../proxmox/proxmox.controller';
|
||||
import { TendersController } from '../tenders/tenders.controller';
|
||||
import { ModuleRegistryController } from './module-registry.controller';
|
||||
@@ -70,6 +71,19 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
|
||||
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
|
||||
(name) => {
|
||||
expectManage(NextcloudStatusController, name, 'nextcloud-status');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['list', 'logo'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
const fn = handler(NextcloudStatusController, name);
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
|
||||
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
|
||||
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
|
||||
|
||||
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
|
||||
const GIF = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
const WEBP = [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50];
|
||||
|
||||
describe('checkLogoUpload', () => {
|
||||
it.each([
|
||||
['PNG', PNG, 'image/png'],
|
||||
['JPEG', JPEG, 'image/jpeg'],
|
||||
['GIF', GIF, 'image/gif'],
|
||||
['WebP', WEBP, 'image/webp'],
|
||||
])('erkennt %s', (_name, bytes, mime) => {
|
||||
expect(checkLogoUpload(Buffer.from([...bytes, 1, 2, 3]))).toBe(mime);
|
||||
});
|
||||
|
||||
it('lehnt SVG-Text, PDF, leere Dateien und umbenanntes HTML ab', () => {
|
||||
expect(
|
||||
checkLogoUpload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||
).toBeNull();
|
||||
expect(checkLogoUpload(Buffer.from('%PDF-1.7 ...'))).toBeNull();
|
||||
expect(checkLogoUpload(Buffer.alloc(0))).toBeNull();
|
||||
expect(checkLogoUpload(Buffer.from('<html><script>alert(1)</script></html>'))).toBeNull();
|
||||
});
|
||||
|
||||
it('genau 1 MiB ist erlaubt, ein Byte mehr nicht', () => {
|
||||
const ok = Buffer.concat([
|
||||
Buffer.from(PNG),
|
||||
Buffer.alloc(NEXTCLOUD_LOGO_MAX_BYTES - PNG.length),
|
||||
]);
|
||||
expect(ok.length).toBe(NEXTCLOUD_LOGO_MAX_BYTES);
|
||||
expect(checkLogoUpload(ok)).toBe('image/png');
|
||||
expect(checkLogoUpload(Buffer.concat([ok, Buffer.from([0])]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type DashboardImageMime, detectImageMime } from '../dashboard/dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* Regeln fuer hochgeladene Cloud-Logos (quick-261002-k67, L-02, D-A).
|
||||
* Kein Nest, kein Prisma — direkt an den Bytes testbar.
|
||||
*
|
||||
* Erlaubt sind PNG, JPEG, GIF und WebP bis 1 MiB. KEIN SVG: ein SVG kann
|
||||
* Skript tragen und wuerde, direkt im Tab geoeffnet, unter der Adresse von
|
||||
* Tessera laufen. Der Typ kommt aus den Magic Bytes, nie aus dem vom Browser
|
||||
* behaupteten `mimetype` oder der Dateiendung (Muster T-PI9-01); der erkannte
|
||||
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-k67-02).
|
||||
*/
|
||||
|
||||
/** Hoechstgroesse: 1 MiB (multer `limits.fileSize` an der Route + zweites Netz im Dienst). */
|
||||
export const NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/** Erkannter Bildtyp oder `null`, wenn leer, zu gross oder kein erlaubtes Bildformat. */
|
||||
export function checkLogoUpload(buffer: Uint8Array): DashboardImageMime | null {
|
||||
if (buffer.length === 0 || buffer.length > NEXTCLOUD_LOGO_MAX_BYTES) return null;
|
||||
return detectImageMime(buffer);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NEXTCLOUD_CRON,
|
||||
NEXTCLOUD_JOB_NAME,
|
||||
NextcloudStatusSchedulerService,
|
||||
} from './nextcloud-status-scheduler.service';
|
||||
|
||||
function makeFakeRegistry() {
|
||||
const jobs = new Map<string, any>();
|
||||
return {
|
||||
__jobs: jobs,
|
||||
addCronJob: vi.fn((name: string, job: any) => {
|
||||
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
|
||||
jobs.set(name, job);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: string[] = []) {
|
||||
const registry = makeFakeRegistry();
|
||||
const calls: Array<[string, string]> = [];
|
||||
const service = {
|
||||
loadAllInstancesForScheduler: vi.fn(async () => rows),
|
||||
checkInstance: vi.fn(async (tenantId: string, id: string) => {
|
||||
calls.push([tenantId, id]);
|
||||
if (failFor.includes(id)) throw new Error(`boom ${id}`);
|
||||
}),
|
||||
};
|
||||
const release = { refresh: vi.fn(async () => undefined) };
|
||||
const scheduler = new NextcloudStatusSchedulerService(
|
||||
registry as any,
|
||||
service as any,
|
||||
release as any,
|
||||
);
|
||||
const logger = (scheduler as any).logger;
|
||||
const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||
return { registry, service, release, scheduler, calls, logSpy, errorSpy, warnSpy };
|
||||
}
|
||||
|
||||
describe('NextcloudStatusSchedulerService', () => {
|
||||
it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => {
|
||||
const { registry, service, release, scheduler } = makeScheduler();
|
||||
await scheduler.onApplicationBootstrap();
|
||||
expect(registry.addCronJob).toHaveBeenCalledTimes(1);
|
||||
expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
|
||||
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
|
||||
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
|
||||
expect(NEXTCLOUD_CRON).toBe('0 * * * *');
|
||||
expect(job.isActive ?? job.running).toBeTruthy();
|
||||
expect(service.loadAllInstancesForScheduler).not.toHaveBeenCalled();
|
||||
expect(release.refresh).toHaveBeenCalledTimes(1);
|
||||
job.stop();
|
||||
});
|
||||
|
||||
it('ein Fehler beim Start wird protokolliert und nicht weitergeworfen', async () => {
|
||||
const { registry, scheduler, errorSpy } = makeScheduler();
|
||||
registry.addCronJob.mockImplementation(() => {
|
||||
throw new Error('registry kaputt');
|
||||
});
|
||||
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tick prueft jede Cloud an ihren eigenen Mandanten gebunden', async () => {
|
||||
const rows = [
|
||||
{ id: 'a1', tenantId: 'tA' },
|
||||
{ id: 'b1', tenantId: 'tB' },
|
||||
{ id: 'a2', tenantId: 'tA' },
|
||||
];
|
||||
const { scheduler, calls } = makeScheduler(rows);
|
||||
await scheduler.tick();
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toContainEqual(['tA', 'a1']);
|
||||
expect(calls).toContainEqual(['tA', 'a2']);
|
||||
expect(calls).toContainEqual(['tB', 'b1']);
|
||||
});
|
||||
|
||||
it('eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
|
||||
const rows = [
|
||||
{ id: 'x', tenantId: 't' },
|
||||
{ id: 'y', tenantId: 't' },
|
||||
{ id: 'z', tenantId: 't' },
|
||||
];
|
||||
const { scheduler, calls, errorSpy } = makeScheduler(rows, ['x']);
|
||||
await scheduler.tick();
|
||||
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y', 'z']);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ein Durchlauf, waehrend der vorige noch laeuft, wird uebersprungen', async () => {
|
||||
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
|
||||
let release!: () => void;
|
||||
service.checkInstance.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const first = scheduler.tick();
|
||||
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
|
||||
await scheduler.tick();
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
release();
|
||||
await first;
|
||||
await scheduler.tick();
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import {
|
||||
CHECK_CONCURRENCY,
|
||||
NextcloudStatusService,
|
||||
runWithConcurrency,
|
||||
} from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
* CronJob constructor — resolved at runtime via require() because `cron` is
|
||||
* a transitive dependency of @nestjs/schedule (not a direct api dep under
|
||||
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
|
||||
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
|
||||
* it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution
|
||||
* workaround verbatim.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
require('cron').CronJob as new (
|
||||
cronTime: string,
|
||||
onTick: () => void,
|
||||
) => { start(): void };
|
||||
|
||||
/** Name des Auftrags in der Registry. */
|
||||
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
|
||||
/** Jede volle Stunde (L-08). */
|
||||
export const NEXTCLOUD_CRON = '0 * * * *';
|
||||
|
||||
/**
|
||||
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
|
||||
* (quick-261002-k67, L-08, D-C).
|
||||
*
|
||||
* Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die
|
||||
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
|
||||
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum
|
||||
* zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
|
||||
*
|
||||
* Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist
|
||||
* fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird
|
||||
* beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann
|
||||
* daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud
|
||||
* eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds
|
||||
* (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren
|
||||
* eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein
|
||||
* Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly service: NextcloudStatusService,
|
||||
private readonly release: NextcloudReleaseService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registriert und startet den Auftrag und stoesst das Aufwaermen der
|
||||
* Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und
|
||||
* protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
const job = new CronJobClass(NEXTCLOUD_CRON, () => {
|
||||
this.tick().catch((err) =>
|
||||
this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`),
|
||||
);
|
||||
});
|
||||
// Cast noetig — dasselbe Muster wie ProxmoxSchedulerService.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
|
||||
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
|
||||
job.start();
|
||||
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
|
||||
void this.release.refresh().catch(() => undefined);
|
||||
} catch (err) {
|
||||
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Durchlauf ueber alle Clouds aller Mandanten. */
|
||||
async tick(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.logger.warn('Nextcloud poll tick skipped — previous run still active');
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const rows = await this.service.loadAllInstancesForScheduler();
|
||||
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
|
||||
const byTenant = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const ids = byTenant.get(row.tenantId) ?? [];
|
||||
ids.push(row.id);
|
||||
byTenant.set(row.tenantId, ids);
|
||||
}
|
||||
const work: { tenantId: string; id: string }[] = [];
|
||||
for (const [tenantId, ids] of byTenant) {
|
||||
for (const id of ids) work.push({ tenantId, id });
|
||||
}
|
||||
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
|
||||
try {
|
||||
await this.service.checkInstance(tenantId, id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,37 @@ describe('NextcloudStatusController Metadaten', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
|
||||
});
|
||||
|
||||
it('list bleibt auf Benutzen-Ebene', () => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.list as object)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.list as object)).toBeUndefined();
|
||||
it.each(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['create', 'checkOne'])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
|
||||
it.each([
|
||||
'create',
|
||||
'update',
|
||||
'remove',
|
||||
'checkAll',
|
||||
'checkOne',
|
||||
'uploadLogo',
|
||||
'removeLogo',
|
||||
])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
|
||||
const fn = proto[name] as object;
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('die statische Route POST instances/check steht vor jedem Handler mit :id (kein 404-Shadowing)', () => {
|
||||
const names = Object.getOwnPropertyNames(NextcloudStatusController.prototype).filter(
|
||||
(n) => n !== 'constructor' && typeof proto[n] === 'function',
|
||||
);
|
||||
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
|
||||
expect(pathOf('checkAll')).toBe('instances/check');
|
||||
const checkIndex = names.indexOf('checkAll');
|
||||
const idHandlers = names.filter((n) => (pathOf(n) ?? '').includes(':id'));
|
||||
expect(idHandlers.length).toBeGreaterThan(0);
|
||||
for (const name of idHandlers) {
|
||||
expect(checkIndex, `checkAll vor ${name}`).toBeLessThan(names.indexOf(name));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import {
|
||||
CreateNextcloudInstanceDto,
|
||||
UpdateNextcloudInstanceDto,
|
||||
} from './dto/nextcloud-instance.dto';
|
||||
import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
@@ -9,14 +28,15 @@ import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
|
||||
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
|
||||
*
|
||||
* Rechte (L-09): Lesen (`GET instances`, Logo-Abruf) steht jedem Benutzer mit
|
||||
* Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
|
||||
* Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) steht jedem
|
||||
* Benutzer mit Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
|
||||
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
|
||||
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
|
||||
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
|
||||
*
|
||||
* Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen
|
||||
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing).
|
||||
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing,
|
||||
* T-k67-08; die Reihenfolge ist im Controller-Spec festgeschrieben).
|
||||
*/
|
||||
@Controller('modules/nextcloud-status')
|
||||
@UseModule('nextcloud-status')
|
||||
@@ -42,9 +62,73 @@ export class NextcloudStatusController {
|
||||
return this.service.createInstance(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
/** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */
|
||||
@Post('instances/check')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async checkAll(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.checkAllForTenant(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Put('instances/:id')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateNextcloudInstanceDto,
|
||||
) {
|
||||
return this.service.updateInstance(this.requireTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
@Delete('instances/:id')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const deleted = await this.service.deleteInstance(this.requireTenantId(req), id);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
@Post('instances/:id/check')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.checkInstance(this.requireTenantId(req), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per
|
||||
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
|
||||
* Zwischenspeicherung (Adresse traegt clientseitig `?v=<logoVersion>`),
|
||||
* `nosniff` und eine Sandbox-CSP — Muster `favorites.controller.ts` `getIcon`
|
||||
* (T-k67-02).
|
||||
*/
|
||||
@Get('instances/:id/logo')
|
||||
async logo(@Req() req: AuthenticatedRequest, @Param('id') id: string, @Res() res: Response) {
|
||||
const { data, mime } = await this.service.getLogo(this.requireTenantId(req), id);
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
res.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo hochladen: Groessengrenze JE ROUTE (multers `LIMIT_FILE_SIZE` wird von
|
||||
* Nest auf 413 abgebildet); Typ und Besitz pruefen im Dienst.
|
||||
*/
|
||||
@Post('instances/:id/logo')
|
||||
@ModuleManage('nextcloud-status')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }),
|
||||
)
|
||||
async uploadLogo(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file?: UploadedFileLike,
|
||||
) {
|
||||
return this.service.uploadLogo(this.requireTenantId(req), id, file);
|
||||
}
|
||||
|
||||
@Delete('instances/:id/logo')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async removeLogo(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.removeLogo(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import { NextcloudStatusController } from './nextcloud-status.controller';
|
||||
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service';
|
||||
|
||||
/**
|
||||
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
|
||||
@@ -13,7 +14,7 @@ import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [NextcloudStatusController],
|
||||
providers: [NextcloudStatusService, NextcloudReleaseService],
|
||||
providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService],
|
||||
})
|
||||
export class NextcloudStatusModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(NextcloudStatusModule.name);
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
|
||||
return { ...actual, fetchNextcloudStatus: vi.fn() };
|
||||
});
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { NextcloudReference } from './nextcloud-rating';
|
||||
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
|
||||
import { fetchNextcloudStatus } from './nextcloud-status-fetch';
|
||||
@@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => {
|
||||
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
describe('Schreibwege (Aufgabe 2)', () => {
|
||||
const OK_RESULT = {
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.nextcloudInstance.delete = vi.fn();
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
|
||||
vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT);
|
||||
});
|
||||
|
||||
it('updateInstance: nur Name -> kein Neuabruf', async () => {
|
||||
await service.updateInstance('t1', 'i1', { customerName: ' Neu ' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
customerName: 'Neu',
|
||||
});
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => {
|
||||
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
baseUrl: 'https://neu.example.de',
|
||||
});
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(fetchNextcloudStatus).mockClear();
|
||||
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' });
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
|
||||
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => {
|
||||
await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
logoUrl: 'https://logo.example.de/a.png',
|
||||
logoData: null,
|
||||
logoMime: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
await service.updateInstance('t1', 'i1', { logoUrl: '' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({
|
||||
logoUrl: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
|
||||
await service.uploadLogo('t1', 'i1', {
|
||||
buffer: png,
|
||||
originalname: 'x.exe',
|
||||
mimetype: 'text/html',
|
||||
size: png.length,
|
||||
});
|
||||
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
|
||||
expect(Buffer.from(data.logoData)).toEqual(png);
|
||||
expect(data).toMatchObject({
|
||||
logoMime: 'image/png',
|
||||
logoUrl: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
|
||||
});
|
||||
|
||||
it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => {
|
||||
const html = Buffer.from('<html></html>');
|
||||
await expect(
|
||||
service.uploadLogo('t1', 'i1', {
|
||||
buffer: html,
|
||||
originalname: 'a.png',
|
||||
mimetype: 'image/png',
|
||||
size: html.length,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
|
||||
);
|
||||
await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException);
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
await expect(
|
||||
service.uploadLogo('t1', 'fremd', {
|
||||
buffer: png,
|
||||
originalname: 'a.png',
|
||||
mimetype: 'image/png',
|
||||
size: png.length,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => {
|
||||
await service.removeLogo('t1', 'i1');
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
logoData: null,
|
||||
logoMime: null,
|
||||
logoVersion: { increment: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
logoData: new Uint8Array([1, 2, 3]),
|
||||
logoMime: 'image/png',
|
||||
});
|
||||
const logo = await service.getLogo('t1', 'i1');
|
||||
expect(logo.mime).toBe('image/png');
|
||||
expect([...logo.data]).toEqual([1, 2, 3]);
|
||||
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
|
||||
id: 'i1',
|
||||
tenantId: 't1',
|
||||
});
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null });
|
||||
await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException);
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => {
|
||||
expect(await service.deleteInstance('t1', 'i1')).toBe(true);
|
||||
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } });
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => {
|
||||
const ids = Array.from({ length: 10 }, (_, i) => `i${i}`);
|
||||
prisma.nextcloudInstance.findMany.mockImplementation(
|
||||
async (args: { select: Record<string, boolean> }) =>
|
||||
args.select.id && Object.keys(args.select).length === 1
|
||||
? ids.map((id) => ({ id }))
|
||||
: [makeRow()],
|
||||
);
|
||||
prisma.nextcloudInstance.findFirst.mockImplementation(
|
||||
async ({ where }: { where: { id: string } }) => ({
|
||||
id: where.id,
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
}),
|
||||
);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
vi.mocked(fetchNextcloudStatus).mockImplementation(async () => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
inFlight--;
|
||||
return OK_RESULT;
|
||||
});
|
||||
const result = await service.checkAllForTenant('t1');
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(peak).toBeGreaterThan(1);
|
||||
expect(result.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockImplementation(
|
||||
async (args: { select: Record<string, boolean> }) =>
|
||||
Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [],
|
||||
);
|
||||
prisma.nextcloudInstance.findFirst.mockImplementation(
|
||||
async ({ where }: { where: { id: string } }) =>
|
||||
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' },
|
||||
);
|
||||
await service.checkAllForTenant('t1');
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
|
||||
const rows = await service.loadAllInstancesForScheduler();
|
||||
expect(forSystem).toHaveBeenCalledWith(prisma);
|
||||
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type {
|
||||
CreateNextcloudInstanceDto,
|
||||
UpdateNextcloudInstanceDto,
|
||||
} from './dto/nextcloud-instance.dto';
|
||||
import { checkLogoUpload } from './nextcloud-logo-rules';
|
||||
import {
|
||||
type NextcloudRating,
|
||||
type NextcloudReference,
|
||||
@@ -76,10 +81,34 @@ export interface NextcloudListView {
|
||||
reference: { newestVersion: string | null; fetchedAt: string | null };
|
||||
}
|
||||
|
||||
/** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */
|
||||
export const CHECK_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen —
|
||||
* der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht.
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const item = items[next++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
|
||||
|
||||
@Injectable()
|
||||
export class NextcloudStatusService {
|
||||
private readonly logger = new Logger(NextcloudStatusService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly release: NextcloudReleaseService,
|
||||
@@ -178,4 +207,172 @@ export class NextcloudStatusService {
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
/**
|
||||
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
|
||||
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
|
||||
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere
|
||||
* entfernt nur die Adresse (D-A).
|
||||
*/
|
||||
async updateInstance(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateNextcloudInstanceDto,
|
||||
): Promise<NextcloudInstanceView> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, baseUrl: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim();
|
||||
|
||||
let urlChanged = false;
|
||||
if (dto.baseUrl !== undefined) {
|
||||
const baseUrl = normalizeCloudUrl(dto.baseUrl);
|
||||
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
|
||||
if (baseUrl !== existing.baseUrl) {
|
||||
data.baseUrl = baseUrl;
|
||||
urlChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.logoUrl !== undefined) {
|
||||
if (dto.logoUrl) {
|
||||
data.logoUrl = dto.logoUrl;
|
||||
data.logoData = null;
|
||||
data.logoMime = null;
|
||||
} else {
|
||||
data.logoUrl = null;
|
||||
}
|
||||
data.logoVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
if (urlChanged) return this.checkInstance(tenantId, id);
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
|
||||
/** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */
|
||||
async deleteInstance(tenantId: string, id: string): Promise<boolean> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
await tenantPrisma.nextcloudInstance.delete({ where: { id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes
|
||||
* (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene
|
||||
* Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus).
|
||||
*/
|
||||
async uploadLogo(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
file: UploadedFileLike | undefined,
|
||||
): Promise<NextcloudInstanceView> {
|
||||
const mime = file ? checkLogoUpload(file.buffer) : null;
|
||||
if (!file || !mime) {
|
||||
throw new BadRequestException(
|
||||
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
|
||||
);
|
||||
}
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
logoData: new Uint8Array(file.buffer),
|
||||
logoMime: mime,
|
||||
logoUrl: null,
|
||||
logoVersion: { increment: 1 },
|
||||
},
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
|
||||
/** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */
|
||||
async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { logoData: true, logoMime: true },
|
||||
});
|
||||
if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden');
|
||||
return { data: Buffer.from(row.logoData), mime: row.logoMime };
|
||||
}
|
||||
|
||||
/** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */
|
||||
async removeLogo(tenantId: string, id: string): Promise<NextcloudInstanceView> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.nextcloudInstance.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
|
||||
/** Kennungen aller Clouds des Mandanten. */
|
||||
async listInstanceIdsForTenant(tenantId: string): Promise<string[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const rows = await tenantPrisma.nextcloudInstance.findMany({
|
||||
where: { tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((r: { id: string }) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit
|
||||
* hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
|
||||
* Cloud stoppt die anderen nicht.
|
||||
*/
|
||||
async checkAllForTenant(tenantId: string): Promise<NextcloudListView> {
|
||||
const ids = await this.listInstanceIdsForTenant(tenantId);
|
||||
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
|
||||
try {
|
||||
await this.checkInstance(tenantId, id);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.listForTenant(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf
|
||||
* dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`;
|
||||
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
|
||||
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
|
||||
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
|
||||
* Mandanten gebunden (`checkInstance`).
|
||||
*/
|
||||
async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
return systemPrisma.nextcloudInstance.findMany({
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +189,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
|
||||
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
|
||||
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
|
||||
*
|
||||
* quick-261002-k67 (Aufgabe 2): eine siebte Datei kommt hinzu —
|
||||
* `nextcloud-status/nextcloud-status.service.ts`, EIN Aufruf:
|
||||
* `loadAllInstancesForScheduler()` liest fuer den stuendlichen Planer nur
|
||||
* Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Logo-Bytes).
|
||||
* Geprueft und geschrieben wird danach je Cloud gebunden ueber
|
||||
* `forTenant(prisma, tenantId)`. Die passende Regel ist
|
||||
* `system_read_policy ... FOR SELECT` auf "NextcloudInstance" (Migration
|
||||
* 20261002150000). Summe neu: 7 Dateien, 8 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1],
|
||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
|
||||
Reference in New Issue
Block a user