0afcf237e8
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
460 lines
16 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|