Files
tessera-ctl/apps/api/src/nextcloud-status/nextcloud-status.service.ts
T
2026-10-02 23:42:10 +02:00

460 lines
16 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { NextcloudAlertService } from './nextcloud-alert.service';
import { planStatusWrite } from './nextcloud-alert-rules';
import { checkLogoUpload } from './nextcloud-logo-rules';
import {
type NextcloudRating,
type NextcloudReference,
newestVersion,
rateNextcloud,
} from './nextcloud-rating';
import { NextcloudReleaseService } from './nextcloud-release.service';
import { fetchNextcloudStatus, normalizeCloudUrl } from './nextcloud-status-fetch';
/**
* Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und
* Pruefabfragen holen `logoData` nie aus der Datenbank, nur der
* Logo-Abruf. `logoMime` ist genau dann gesetzt, wenn ein Upload vorliegt.
*/
export const PUBLIC_SELECT = {
id: true,
tenantId: true,
customerName: true,
baseUrl: true,
logoUrl: true,
logoMime: true,
logoVersion: true,
lastCheckedAt: true,
reachable: true,
maintenance: true,
needsDbUpgrade: true,
versionString: true,
edition: true,
errorKind: true,
errorDetail: true,
consecutiveFailures: true,
} as const;
type PublicRow = {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
logoMime: string | null;
logoVersion: number;
lastCheckedAt: Date | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
consecutiveFailures: number;
};
export interface NextcloudInstanceView {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
hasUploadedLogo: boolean;
logoVersion: number;
status: {
checkedAt: string | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
/**
* Genau ein Fehlschlag in Folge: die Kachel zeigt den letzten guten Stand
* mit Hinweis, die Wiederholung folgt in wenigen Minuten (L-03).
*/
pendingRetry: boolean;
};
rating: NextcloudRating;
/**
* Glocke des anfragenden Benutzers (quick-261002-kxc, D-K10). Nur in den
* Listenantworten gesetzt; Einzelantworten lassen das Feld weg — die Seite
* behaelt dann den Stand der Kachel.
*/
subscribed?: boolean;
}
export interface NextcloudListView {
instances: NextcloudInstanceView[];
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,
private readonly alerts: NextcloudAlertService,
) {}
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
const status = {
pendingRetry: row.consecutiveFailures === 1,
checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
reachable: row.reachable,
maintenance: row.maintenance,
needsDbUpgrade: row.needsDbUpgrade,
versionString: row.versionString,
edition: row.edition,
errorKind: row.errorKind,
errorDetail: row.errorDetail,
};
return {
id: row.id,
customerName: row.customerName,
baseUrl: row.baseUrl,
logoUrl: row.logoUrl,
hasUploadedLogo: row.logoMime !== null,
logoVersion: row.logoVersion,
status,
rating: rateNextcloud(status, reference, new Date()),
};
}
/**
* Alle Clouds des Mandanten samt Bewertung zum Lesezeitpunkt (D-B) und dem
* Glockenstand des anfragenden Benutzers (D-K10).
*/
async listForTenant(tenantId: string, userId: string): Promise<NextcloudListView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const [rows, reference, subscribed] = await Promise.all([
tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
orderBy: { customerName: 'asc' },
select: PUBLIC_SELECT,
}),
this.release.getReference(),
this.alerts.subscribedInstanceIds(tenantId, userId),
]);
return {
instances: rows.map((row) => ({
...this.toView(row as PublicRow, reference),
subscribed: subscribed.has((row as PublicRow).id),
})),
reference: {
newestVersion: newestVersion(reference),
fetchedAt: reference?.fetchedAt ?? null,
},
};
}
/** Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. */
async createInstance(
tenantId: string,
dto: CreateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
const tenantPrisma = forTenant(this.prisma, tenantId);
const created = await tenantPrisma.nextcloudInstance.create({
data: {
tenantId,
customerName: dto.customerName.trim(),
baseUrl,
logoUrl: dto.logoUrl ? dto.logoUrl : null,
},
select: { id: true },
});
return this.checkInstance(tenantId, created.id);
}
/**
* Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und
* schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404.
*
* Einziger Schreibweg fuer jede Pruefung (stuendlich, Wiederholung, "Jetzt
* pruefen", Anlegen, Adressaenderung). `planStatusWrite` setzt die
* Zwei-Fehlschlaege-Regel um (ein erster Fehlschlag laesst den gespeicherten
* Zustand unveraendert), danach entscheidet `evaluateAfterCheck` ueber eine
* Meldung — es wartet nur auf den Anspruch, nie auf den Mailversand.
*/
async checkInstance(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true, baseUrl: true, consecutiveFailures: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const startedAt = Date.now();
const result = await fetchNextcloudStatus(existing.baseUrl);
const now = new Date();
const plan = planStatusWrite(existing.consecutiveFailures, result, now);
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: plan.data,
select: { ...PUBLIC_SELECT, alertState: true },
});
const view = this.toView(updated as PublicRow, await this.release.getReference());
const outcome = result.reachable
? `Version ${result.versionString ?? '?'}${result.maintenance ? ', Wartungsmodus' : ''}`
: `Fehler ${result.errorKind}${result.errorDetail ? ` (${result.errorDetail})` : ''}`;
this.logger.log(
`Pruefung "${updated.customerName}" ${existing.baseUrl}: ${outcome}, Ampel ${view.rating.level}, ${Date.now() - startedAt} ms`,
);
try {
await this.alerts.evaluateAfterCheck(
tenantId,
{
id,
customerName: updated.customerName,
baseUrl: updated.baseUrl,
errorKind: updated.errorKind,
errorDetail: updated.errorDetail,
alertState: updated.alertState,
},
view.rating,
now,
);
} catch (err) {
// Eine Stoerung der Meldung darf das Pruefergebnis nicht verwerfen.
this.logger.error(
`Nextcloud-Meldung nach Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
);
}
return view;
}
/**
* 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;
// Neue Adresse: der alte Pruefstand gilt nicht mehr (D-K8). `alertState`
// bleibt bewusst unberuehrt — wird eine kaputte Adresse korrigiert und
// antwortet die neue, geht "wieder in Ordnung" an die Abonnenten.
Object.assign(data, {
reachable: null,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
lastCheckedAt: null,
consecutiveFailures: 0,
firstFailureAt: null,
});
}
}
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, userId: 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, userId);
}
/**
* 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`). Mit `retryDueBefore` (Wiederholung
* nach dem ersten Fehlschlag, quick-261002-kxc) filtert dieselbe Abfrage auf
* Clouds mit genau einem Fehlschlag, der vor diesem Zeitpunkt lag.
*/
async loadAllInstancesForScheduler(filter?: {
retryDueBefore: Date;
}): Promise<{ id: string; tenantId: string }[]> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.nextcloudInstance.findMany({
// Wiederholungsauftrag (D-K3): nur Clouds mit genau einem Fehlschlag, dessen
// Zeitpunkt lange genug zurueckliegt — derselbe Systemlesezugriff, kein neuer.
...(filter
? {
where: {
consecutiveFailures: 1,
firstFailureAt: { lte: filter.retryDueBefore },
},
}
: {}),
select: { id: true, tenantId: true },
});
}
}