feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht
- NextcloudInstance mit Zeilenschutz, Migration 20261002150000 - Bewertung (Ampel) als reine Funktion, status.php-Abruf mit Grenzen, endoflife.date-Zwischenspeicher - API: Liste mit Bewertung, Anlegen und Einzelprüfung nur mit Verwalten - Modulseite mit Kacheln, Registrierung in Seitenleiste, Marktplatz und Symbolen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
-- quick-261002-k67 — Modul Nextcloud-Status.
|
||||
--
|
||||
-- Zweck: neue Tabelle "NextcloudInstance" fuer die vom Verwalter
|
||||
-- eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales
|
||||
-- Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Wartungsmodus,
|
||||
-- Versionstext, Fehlerart). Der Zustand liegt direkt auf der Zeile, es gibt
|
||||
-- kein Zwischenlager. Logo-Bytes liegen als BYTEA an der Zeile (hoechstens
|
||||
-- 1 MiB, Pruefung im Dienst); Abfragen ausser dem Logo-Abruf waehlen sie
|
||||
-- nie mit aus.
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||
-- Clouds sind gemeinsame Daten der Organisation, nicht persoenliche Daten
|
||||
-- eines einzelnen Benutzers.
|
||||
--
|
||||
-- Zusaetzlich eine `system_read_policy` (Form aus
|
||||
-- 20260914120000_rls_system_context_read): der stuendliche Hintergrunddienst
|
||||
-- liest ueber `forSystem()` genau einmal je Durchlauf Kennung und Mandant
|
||||
-- aller Clouds und prueft danach jede Cloud an ihren eigenen Mandanten
|
||||
-- gebunden. Geschrieben wird nie im Systemkontext.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "NextcloudInstance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"baseUrl" TEXT NOT NULL,
|
||||
"logoUrl" TEXT,
|
||||
"logoData" BYTEA,
|
||||
"logoMime" TEXT,
|
||||
"logoVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastCheckedAt" TIMESTAMP(3),
|
||||
"reachable" BOOLEAN,
|
||||
"maintenance" BOOLEAN,
|
||||
"needsDbUpgrade" BOOLEAN,
|
||||
"versionString" TEXT,
|
||||
"edition" TEXT,
|
||||
"productName" TEXT,
|
||||
"errorKind" TEXT,
|
||||
"errorDetail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NextcloudInstance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "NextcloudInstance_tenantId_idx" ON "NextcloudInstance"("tenantId");
|
||||
|
||||
ALTER TABLE "NextcloudInstance" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "NextcloudInstance" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "NextcloudInstance"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
CREATE POLICY system_read_policy ON "NextcloudInstance"
|
||||
FOR SELECT USING (is_system_context());
|
||||
@@ -784,6 +784,35 @@ model ProxmoxServerStatus {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Nextcloud-Status (quick-261002-k67): vom Verwalter eingetragene Clouds der
|
||||
// Kunden. Der zuletzt ermittelte Zustand liegt direkt auf der Zeile (L-03),
|
||||
// es gibt kein Zwischenlager. Logo-Bytes liegen als bytea an der Zeile (D-A,
|
||||
// hoechstens 1 MiB); Listen- und Planerabfragen waehlen sie nie mit aus.
|
||||
// Zeilenschutz nach Muster ProxmoxServer (tenantId, keine Relation zu Tenant).
|
||||
model NextcloudInstance {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
customerName String
|
||||
baseUrl String
|
||||
logoUrl String?
|
||||
logoData Bytes?
|
||||
logoMime String?
|
||||
logoVersion Int @default(0)
|
||||
lastCheckedAt DateTime?
|
||||
reachable Boolean? // null = noch nie geprueft
|
||||
maintenance Boolean?
|
||||
needsDbUpgrade Boolean?
|
||||
versionString String?
|
||||
edition String?
|
||||
productName String?
|
||||
errorKind String?
|
||||
errorDetail String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
||||
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
||||
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
|
||||
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
|
||||
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
@@ -57,6 +58,7 @@ import { RemindersModule } from './reminders/reminders.module';
|
||||
TendersModule,
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
NextcloudStatusModule,
|
||||
KantineDatevModule,
|
||||
HandelswareDatevModule,
|
||||
CustomModulesModule,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Eingaben fuer das Anlegen und Aendern einer Nextcloud-Cloud
|
||||
* (quick-261002-k67). Die Adresse wird im Dienst zusaetzlich normalisiert
|
||||
* (`normalizeCloudUrl`); hier gilt nur die Formpruefung. Eine Logo-Adresse
|
||||
* ist nur mit https erlaubt — sie wird vom Browser geladen, nie vom Server.
|
||||
*/
|
||||
export class CreateNextcloudInstanceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
customerName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2048)
|
||||
baseUrl!: string;
|
||||
|
||||
// Leere Zeichenkette = kein Logo; sonst nur eine https-Adresse.
|
||||
@IsOptional()
|
||||
@ValidateIf((_o, value) => typeof value === 'string' && value !== '')
|
||||
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
|
||||
@MaxLength(2048)
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateNextcloudInstanceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
customerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2048)
|
||||
baseUrl?: string;
|
||||
|
||||
// Leere Zeichenkette = Logo-Adresse entfernen.
|
||||
@IsOptional()
|
||||
@ValidateIf((_o, value) => typeof value === 'string' && value !== '')
|
||||
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
|
||||
@MaxLength(2048)
|
||||
logoUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type NextcloudReference,
|
||||
type NextcloudStatusInput,
|
||||
newestVersion,
|
||||
parseVersionTriple,
|
||||
rateNextcloud,
|
||||
} from './nextcloud-rating';
|
||||
|
||||
const REFERENCE: NextcloudReference = {
|
||||
fetchedAt: '2026-10-02T10:00:00.000Z',
|
||||
cycles: [
|
||||
{ cycle: 35, eol: '2027-09-30', latest: '35.0.1' },
|
||||
{ cycle: 34, eol: '2027-06-30', latest: '34.0.4' },
|
||||
{ cycle: 33, eol: '2027-02-28', latest: '33.0.9' },
|
||||
{ cycle: 32, eol: '2026-09-30', latest: '32.0.15' },
|
||||
{ cycle: 31, eol: '2026-02-28', latest: '31.0.14' },
|
||||
],
|
||||
};
|
||||
|
||||
const NOW = new Date('2026-10-02T12:00:00Z');
|
||||
|
||||
function status(
|
||||
versionString: string | null,
|
||||
over: Partial<NextcloudStatusInput> = {},
|
||||
): NextcloudStatusInput {
|
||||
return {
|
||||
checkedAt: '2026-10-02T11:00:00.000Z',
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString,
|
||||
errorKind: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('rateNextcloud', () => {
|
||||
it('neuester Patchstand eines unterstuetzten Zweigs ist gruen', () => {
|
||||
expect(rateNextcloud(status('35.0.1'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
updateTo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('neuer als der gelistete Patchstand ist ebenfalls gruen', () => {
|
||||
expect(rateNextcloud(status('35.0.2'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
});
|
||||
|
||||
it('Update im eigenen Zweig ist gelb', () => {
|
||||
expect(rateNextcloud(status('34.0.3'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'update-available',
|
||||
updateTo: '34.0.4',
|
||||
});
|
||||
});
|
||||
|
||||
it('Support-Ende ueberschritten ist rot mit Datum', () => {
|
||||
expect(rateNextcloud(status('32.0.15'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
eolDate: '2026-09-30',
|
||||
});
|
||||
});
|
||||
|
||||
it('Support endet bald ist gelb', () => {
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-08-01T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
eolDate: '2026-09-30',
|
||||
});
|
||||
expect(
|
||||
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-12-15T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
eolDate: '2027-02-28',
|
||||
});
|
||||
});
|
||||
|
||||
it('Grenze: genau 90 Tage bis zum Ende ist gelb, 91 Tage sind gruen', () => {
|
||||
// 2027-02-28 minus 90 Tage = 2026-11-30
|
||||
expect(
|
||||
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-11-30T08:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
});
|
||||
expect(
|
||||
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-11-29T08:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
});
|
||||
|
||||
it('Grenze: am Tag des Support-Endes gelb, am Tag danach rot', () => {
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-09-30T23:59:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
});
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-10-01T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
});
|
||||
});
|
||||
|
||||
it('Support-Ende bald hat Vorrang und nennt zusaetzlich das Update', () => {
|
||||
expect(
|
||||
rateNextcloud(status('33.0.5'), REFERENCE, new Date('2026-12-15T00:00:00Z')),
|
||||
).toMatchObject({
|
||||
level: 'yellow',
|
||||
reason: 'eol-soon',
|
||||
updateTo: '33.0.9',
|
||||
});
|
||||
});
|
||||
|
||||
it('Hauptversion aelter als jeder gelistete Zweig ist rot ohne Datum', () => {
|
||||
expect(rateNextcloud(status('20.0.1'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
eolDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('Hauptversion neuer als jeder gelistete Zweig ist gruen', () => {
|
||||
expect(rateNextcloud(status('36.0.0'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
});
|
||||
|
||||
it('Hauptversion innerhalb der Spanne, aber nicht gelistet, ist grau', () => {
|
||||
const lueckenhaft: NextcloudReference = {
|
||||
...REFERENCE,
|
||||
cycles: REFERENCE.cycles.filter((c) => c.cycle !== 33),
|
||||
};
|
||||
expect(rateNextcloud(status('33.0.1'), lueckenhaft, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'no-reference',
|
||||
});
|
||||
});
|
||||
|
||||
it('Zweig mit eol false gilt als unterstuetzt, eol true als abgelaufen', () => {
|
||||
const ohneEnde: NextcloudReference = {
|
||||
...REFERENCE,
|
||||
cycles: [
|
||||
{ cycle: 35, eol: false, latest: '35.0.1' },
|
||||
{ cycle: 30, eol: true, latest: '30.0.9' },
|
||||
],
|
||||
};
|
||||
expect(rateNextcloud(status('35.0.1'), ohneEnde, NOW)).toMatchObject({
|
||||
level: 'green',
|
||||
reason: 'current',
|
||||
});
|
||||
expect(rateNextcloud(status('30.0.9'), ohneEnde, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'eol-passed',
|
||||
eolDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('nicht erreichbar ist rot, auch ohne Vergleichsdaten', () => {
|
||||
expect(
|
||||
rateNextcloud(status(null, { reachable: false, errorKind: 'timeout' }), null, NOW),
|
||||
).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
});
|
||||
|
||||
it('keine gueltige Nextcloud-Antwort ist rot mit eigenem Grund', () => {
|
||||
expect(
|
||||
rateNextcloud(status(null, { reachable: false, errorKind: 'not-nextcloud' }), REFERENCE, NOW),
|
||||
).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'invalid-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('Wartungsmodus und ausstehende Datenbankaktualisierung sind rot', () => {
|
||||
expect(rateNextcloud(status('35.0.1', { maintenance: true }), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'red',
|
||||
reason: 'maintenance',
|
||||
});
|
||||
expect(rateNextcloud(status('35.0.1', { needsDbUpgrade: true }), REFERENCE, NOW)).toMatchObject(
|
||||
{
|
||||
level: 'red',
|
||||
reason: 'needs-db-upgrade',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('Prioritaet der roten Gruende', () => {
|
||||
expect(
|
||||
rateNextcloud(status('32.0.15', { maintenance: true, needsDbUpgrade: true }), REFERENCE, NOW)
|
||||
.reason,
|
||||
).toBe('maintenance');
|
||||
expect(rateNextcloud(status('32.0.15', { needsDbUpgrade: true }), REFERENCE, NOW).reason).toBe(
|
||||
'needs-db-upgrade',
|
||||
);
|
||||
expect(
|
||||
rateNextcloud(
|
||||
status('32.0.15', { reachable: false, errorKind: 'not-nextcloud', maintenance: true }),
|
||||
REFERENCE,
|
||||
NOW,
|
||||
).reason,
|
||||
).toBe('invalid-response');
|
||||
expect(
|
||||
rateNextcloud(
|
||||
status('32.0.15', { reachable: false, errorKind: 'timeout', maintenance: true }),
|
||||
REFERENCE,
|
||||
NOW,
|
||||
).reason,
|
||||
).toBe('unreachable');
|
||||
});
|
||||
|
||||
it('ohne Vergleichsdaten ist die Bewertung grau', () => {
|
||||
expect(rateNextcloud(status('35.0.1'), null, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'no-reference',
|
||||
});
|
||||
});
|
||||
|
||||
it('erreichbar ohne lesbare Version ist grau', () => {
|
||||
expect(rateNextcloud(status(null), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'version-unknown',
|
||||
});
|
||||
expect(rateNextcloud(status('abc'), REFERENCE, NOW)).toMatchObject({
|
||||
level: 'unknown',
|
||||
reason: 'version-unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('noch nie geprueft ist grau', () => {
|
||||
expect(
|
||||
rateNextcloud(
|
||||
{
|
||||
checkedAt: null,
|
||||
reachable: null,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
errorKind: null,
|
||||
},
|
||||
REFERENCE,
|
||||
NOW,
|
||||
),
|
||||
).toMatchObject({ level: 'unknown', reason: 'not-checked' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('newestVersion', () => {
|
||||
it('liefert den Patchstand des hoechsten Zweigs', () => {
|
||||
expect(newestVersion(REFERENCE)).toBe('35.0.1');
|
||||
});
|
||||
it('ohne Daten null', () => {
|
||||
expect(newestVersion(null)).toBeNull();
|
||||
expect(newestVersion({ cycles: [], fetchedAt: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVersionTriple', () => {
|
||||
it('liest drei Teile und ignoriert weitere', () => {
|
||||
expect(parseVersionTriple('31.0.5')).toEqual([31, 0, 5]);
|
||||
expect(parseVersionTriple('31.0.5.1')).toEqual([31, 0, 5]);
|
||||
expect(parseVersionTriple('31.0')).toBeNull();
|
||||
expect(parseVersionTriple(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* nextcloud-rating — reine Bewertung einer Nextcloud-Cloud (quick-261002-k67,
|
||||
* L-05). Kein Nest, kein Prisma, kein Zugriff auf die Uhr: das Datum wird
|
||||
* hereingereicht, damit jede Regel an festen Tagen testbar ist.
|
||||
*
|
||||
* Die Ampel (festgelegt):
|
||||
* - GRUEN: neuester Patchstand seines Hauptzweigs UND der Zweig wird noch
|
||||
* unterstuetzt (Support-Ende liegt mehr als 90 Tage entfernt).
|
||||
* - GELB: im eigenen Zweig gibt es ein Update ODER das Support-Ende des
|
||||
* Zweigs liegt innerhalb der naechsten 90 Tage (inklusive).
|
||||
* - ROT: Support-Ende erreicht/ueberschritten (oder Hauptzweig aelter als
|
||||
* jeder gelistete), nicht erreichbar, keine gueltige Nextcloud-Antwort,
|
||||
* Wartungsmodus oder ausstehende Datenbankaktualisierung.
|
||||
* - GRAU ("unknown"): ohne Vergleichsdaten, ohne lesbare Version oder noch
|
||||
* nie geprueft — die roten Zustandsmerkmale gelten trotzdem.
|
||||
*
|
||||
* Reihenfolge der roten Gruende: nicht erreichbar > ungueltige Antwort >
|
||||
* Wartungsmodus > Datenbankaktualisierung > Support abgelaufen.
|
||||
*/
|
||||
|
||||
export interface NextcloudCycle {
|
||||
/** Hauptversion, z. B. 34. */
|
||||
cycle: number;
|
||||
/** Support-Ende als Tag 'YYYY-MM-DD', `true` = beendet, `false` = ohne Ende. */
|
||||
eol: string | boolean;
|
||||
/** Neuester Patchstand des Zweigs, z. B. '34.0.4'. */
|
||||
latest: string;
|
||||
}
|
||||
|
||||
export interface NextcloudReference {
|
||||
cycles: NextcloudCycle[];
|
||||
/** ISO-Zeitpunkt des letzten erfolgreichen Abrufs. */
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export type RatingLevel = 'green' | 'yellow' | 'red' | 'unknown';
|
||||
|
||||
export type RatingReason =
|
||||
| 'current'
|
||||
| 'update-available'
|
||||
| 'eol-soon'
|
||||
| 'eol-passed'
|
||||
| 'unreachable'
|
||||
| 'invalid-response'
|
||||
| 'maintenance'
|
||||
| 'needs-db-upgrade'
|
||||
| 'no-reference'
|
||||
| 'version-unknown'
|
||||
| 'not-checked';
|
||||
|
||||
export interface NextcloudRating {
|
||||
level: RatingLevel;
|
||||
reason: RatingReason;
|
||||
/** Neuester Patchstand im eigenen Zweig, nur wenn ein Update ansteht. */
|
||||
updateTo: string | null;
|
||||
/** Support-Ende des Zweigs als 'YYYY-MM-DD', wenn bekannt. */
|
||||
eolDate: string | null;
|
||||
/** Hauptversion der Cloud, wenn lesbar. */
|
||||
cycle: number | null;
|
||||
}
|
||||
|
||||
/** Eingabe der Bewertung: der zuletzt gespeicherte Zustand einer Cloud. */
|
||||
export interface NextcloudStatusInput {
|
||||
/** null = noch nie geprueft. */
|
||||
checkedAt: Date | string | null;
|
||||
reachable: boolean | null;
|
||||
maintenance: boolean | null;
|
||||
needsDbUpgrade: boolean | null;
|
||||
versionString: string | null;
|
||||
errorKind: string | null;
|
||||
}
|
||||
|
||||
/** Warnfenster vor dem Support-Ende in Tagen (einschliesslich). */
|
||||
export const EOL_WARNING_DAYS = 90;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Versionsangabe `major.minor.patch` als Zahlentripel, sonst null. */
|
||||
export function parseVersionTriple(
|
||||
raw: string | null | undefined,
|
||||
): [number, number, number] | null {
|
||||
if (!raw) return null;
|
||||
const match = /^(\d{1,4})\.(\d{1,4})\.(\d{1,4})(?:\D.*)?$/.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareTriples(a: [number, number, number], b: [number, number, number]): number {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Tage zwischen zwei Kalendertagen 'YYYY-MM-DD' (UTC), `to - from`. */
|
||||
function dayDiff(from: string, to: string): number {
|
||||
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS);
|
||||
}
|
||||
|
||||
/** Neueste Gesamtversion laut Vergleichsdaten: Patchstand des hoechsten Zweigs. */
|
||||
export function newestVersion(reference: NextcloudReference | null): string | null {
|
||||
if (!reference || reference.cycles.length === 0) return null;
|
||||
let best: NextcloudCycle | null = null;
|
||||
for (const cycle of reference.cycles) {
|
||||
if (!best || cycle.cycle > best.cycle) best = cycle;
|
||||
}
|
||||
return best?.latest ?? null;
|
||||
}
|
||||
|
||||
function result(
|
||||
level: RatingLevel,
|
||||
reason: RatingReason,
|
||||
extra: Partial<Omit<NextcloudRating, 'level' | 'reason'>> = {},
|
||||
): NextcloudRating {
|
||||
return { level, reason, updateTo: null, eolDate: null, cycle: null, ...extra };
|
||||
}
|
||||
|
||||
export function rateNextcloud(
|
||||
status: NextcloudStatusInput,
|
||||
reference: NextcloudReference | null,
|
||||
now: Date,
|
||||
): NextcloudRating {
|
||||
const triple = parseVersionTriple(status.versionString);
|
||||
const cycleNo = triple ? triple[0] : null;
|
||||
|
||||
// Rote Zustandsmerkmale gehen vor jeder Versionsbewertung.
|
||||
if (status.checkedAt === null && status.reachable === null) {
|
||||
return result('unknown', 'not-checked');
|
||||
}
|
||||
if (status.reachable === false) {
|
||||
if (status.errorKind === 'not-nextcloud') {
|
||||
return result('red', 'invalid-response', { cycle: cycleNo });
|
||||
}
|
||||
return result('red', 'unreachable', { cycle: cycleNo });
|
||||
}
|
||||
if (status.maintenance === true) return result('red', 'maintenance', { cycle: cycleNo });
|
||||
if (status.needsDbUpgrade === true) return result('red', 'needs-db-upgrade', { cycle: cycleNo });
|
||||
|
||||
if (!triple) return result('unknown', 'version-unknown');
|
||||
if (!reference || reference.cycles.length === 0) {
|
||||
return result('unknown', 'no-reference', { cycle: cycleNo });
|
||||
}
|
||||
|
||||
const cycle = reference.cycles.find((c) => c.cycle === triple[0]);
|
||||
if (!cycle) {
|
||||
const numbers = reference.cycles.map((c) => c.cycle);
|
||||
const highest = Math.max(...numbers);
|
||||
const lowest = Math.min(...numbers);
|
||||
// D-E: neuer als jeder gelistete Zweig = frisch erschienen, noch nicht
|
||||
// bei endoflife.date; aelter als jeder gelistete = lange abgelaufen.
|
||||
if (triple[0] > highest) return result('green', 'current', { cycle: cycleNo });
|
||||
if (triple[0] < lowest) return result('red', 'eol-passed', { cycle: cycleNo });
|
||||
return result('unknown', 'no-reference', { cycle: cycleNo });
|
||||
}
|
||||
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const latest = parseVersionTriple(cycle.latest);
|
||||
const updateTo = latest && compareTriples(triple, latest) < 0 ? cycle.latest : null;
|
||||
|
||||
let eolDate: string | null = null;
|
||||
let daysToEol: number | null = null;
|
||||
if (cycle.eol === true) {
|
||||
return result('red', 'eol-passed', { cycle: cycleNo });
|
||||
}
|
||||
if (typeof cycle.eol === 'string') {
|
||||
eolDate = cycle.eol;
|
||||
daysToEol = dayDiff(today, cycle.eol);
|
||||
// Am Tag des Support-Endes ist die Version noch unterstuetzt (gelb),
|
||||
// erst der Tag danach ist rot.
|
||||
if (daysToEol < 0) return result('red', 'eol-passed', { cycle: cycleNo, eolDate });
|
||||
}
|
||||
|
||||
if (daysToEol !== null && daysToEol <= EOL_WARNING_DAYS) {
|
||||
return result('yellow', 'eol-soon', { cycle: cycleNo, eolDate, updateTo });
|
||||
}
|
||||
if (updateTo) {
|
||||
return result('yellow', 'update-available', { cycle: cycleNo, eolDate, updateTo });
|
||||
}
|
||||
return result('green', 'current', { cycle: cycleNo, eolDate });
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CACHE_TTL_MS,
|
||||
NextcloudReleaseService,
|
||||
parseEndOfLife,
|
||||
RETRY_BACKOFF_MS,
|
||||
} from './nextcloud-release.service';
|
||||
|
||||
const SAMPLE = [
|
||||
{ cycle: '35', eol: '2027-09-30', latest: '35.0.1' },
|
||||
{ cycle: '34', eol: '2027-06-30', latest: '34.0.4' },
|
||||
{ cycle: '10', eol: false, latest: '10.0.1' },
|
||||
];
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}
|
||||
|
||||
describe('parseEndOfLife', () => {
|
||||
it('liest gueltige Eintraege', () => {
|
||||
expect(parseEndOfLife(SAMPLE)).toEqual([
|
||||
{ cycle: 35, eol: '2027-09-30', latest: '35.0.1' },
|
||||
{ cycle: 34, eol: '2027-06-30', latest: '34.0.4' },
|
||||
{ cycle: 10, eol: false, latest: '10.0.1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ueberspringt Eintraege ohne numerischen Zweig oder gueltigen Patchstand', () => {
|
||||
expect(
|
||||
parseEndOfLife([
|
||||
{ cycle: 'x', eol: false, latest: '1.0.0' },
|
||||
{ cycle: '5', eol: false, latest: 'neu' },
|
||||
{ cycle: '6', eol: 'irgendwann', latest: '6.0.0' },
|
||||
{ cycle: '7', eol: true, latest: '7.0.0' },
|
||||
null,
|
||||
'text',
|
||||
]),
|
||||
).toEqual([{ cycle: 7, eol: true, latest: '7.0.0' }]);
|
||||
});
|
||||
|
||||
it('kein Array -> leer', () => {
|
||||
expect(parseEndOfLife({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudReleaseService', () => {
|
||||
let service: NextcloudReleaseService;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let nowMs: number;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new NextcloudReleaseService();
|
||||
fetchMock = vi.fn();
|
||||
nowMs = 1_000_000;
|
||||
service.fetchImpl = fetchMock as never;
|
||||
service.now = () => nowMs;
|
||||
});
|
||||
|
||||
it('erster Aufruf holt und wertet aus, zweiter innerhalb 12 h nicht', async () => {
|
||||
fetchMock.mockResolvedValue(ok(SAMPLE));
|
||||
const first = await service.getReference();
|
||||
expect(first?.cycles).toHaveLength(3);
|
||||
expect(first?.fetchedAt).toBe(new Date(nowMs).toISOString());
|
||||
nowMs += CACHE_TTL_MS - 1;
|
||||
await service.getReference();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nach 12 h kommen die alten Daten sofort, im Hintergrund wird erneuert', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
||||
await service.getReference();
|
||||
nowMs += CACHE_TTL_MS;
|
||||
fetchMock.mockResolvedValueOnce(ok([{ cycle: '36', eol: false, latest: '36.0.0' }]));
|
||||
const stale = await service.getReference();
|
||||
expect(stale?.cycles).toHaveLength(3);
|
||||
await service.refresh();
|
||||
const fresh = await service.getReference();
|
||||
expect(fresh?.cycles).toEqual([{ cycle: 36, eol: false, latest: '36.0.0' }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Fehlschlag mit vorhandenen Daten behaelt sie', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
||||
await service.getReference();
|
||||
nowMs += CACHE_TTL_MS;
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
await service.refresh();
|
||||
expect((await service.getReference())?.cycles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Fehlschlag ohne Daten -> null; keine neue Anfrage innerhalb von 15 Minuten', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
expect(await service.getReference()).toBeNull();
|
||||
nowMs += RETRY_BACKOFF_MS - 1;
|
||||
expect(await service.getReference()).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
nowMs += 2;
|
||||
expect(await service.getReference()).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('HTTP-Fehler zaehlt als Fehlschlag', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('x', { status: 500 }));
|
||||
expect(await service.getReference()).toBeNull();
|
||||
});
|
||||
|
||||
it('Muell oder leeres Array behaelt die letzten guten Daten', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
||||
await service.getReference();
|
||||
nowMs += CACHE_TTL_MS;
|
||||
fetchMock.mockResolvedValueOnce(ok([]));
|
||||
await service.refresh();
|
||||
nowMs += RETRY_BACKOFF_MS;
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 200 }));
|
||||
await service.refresh();
|
||||
expect((await service.getReference())?.cycles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('parallele Aufrufe teilen sich eine Anfrage', async () => {
|
||||
fetchMock.mockResolvedValue(ok(SAMPLE));
|
||||
const [a, b, c] = await Promise.all([
|
||||
service.getReference(),
|
||||
service.getReference(),
|
||||
service.getReference(),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
import type { NextcloudCycle, NextcloudReference } from './nextcloud-rating';
|
||||
|
||||
/** Quelle der Vergleichsdaten (L-04). */
|
||||
export const ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json';
|
||||
/** So lange gelten abgerufene Daten als frisch (12 Stunden). */
|
||||
export const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
/** Nach einem Fehlschlag wird fruehestens nach 15 Minuten neu versucht. */
|
||||
export const RETRY_BACKOFF_MS = 15 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* Liest die Antwort von endoflife.date: nur Eintraege mit rein numerischem
|
||||
* Zweig, gepunktetem Patchstand und Support-Ende als Tag oder Wahrheitswert
|
||||
* kommen durch.
|
||||
*/
|
||||
export function parseEndOfLife(json: unknown): NextcloudCycle[] {
|
||||
if (!Array.isArray(json)) return [];
|
||||
const cycles: NextcloudCycle[] = [];
|
||||
for (const entry of json) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const cycleRaw = typeof e.cycle === 'number' ? String(e.cycle) : e.cycle;
|
||||
if (typeof cycleRaw !== 'string' || !/^\d+$/.test(cycleRaw)) continue;
|
||||
if (typeof e.latest !== 'string' || !/^\d+\.\d+\.\d+(\.\d+)?$/.test(e.latest)) continue;
|
||||
let eol: string | boolean;
|
||||
if (typeof e.eol === 'boolean') eol = e.eol;
|
||||
else if (typeof e.eol === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.eol)) eol = e.eol;
|
||||
else continue;
|
||||
cycles.push({ cycle: Number(cycleRaw), eol, latest: e.latest });
|
||||
}
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwischenspeicher fuer die Versions-Vergleichsdaten (D-D): im Speicher,
|
||||
* 12 Stunden frisch. Veraltete Daten werden sofort geliefert und im
|
||||
* Hintergrund erneuert; nur ein leerer Speicher wird abgewartet. Nach einem
|
||||
* Fehlschlag gibt es 15 Minuten lang keinen neuen Versuch, gleichzeitige
|
||||
* Aufrufe teilen sich eine Anfrage. Ein Ausfall des Dienstes behaelt die
|
||||
* letzten guten Daten und stoert nie die Seite.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextcloudReleaseService {
|
||||
private readonly logger = new Logger(NextcloudReleaseService.name);
|
||||
private data: NextcloudReference | null = null;
|
||||
private fetchedAtMs = 0;
|
||||
private lastFailureMs: number | null = null;
|
||||
private inflight: Promise<void> | null = null;
|
||||
|
||||
/** Pruefstellen: Tests ueberschreiben diese beiden Felder. */
|
||||
fetchImpl: typeof undiciFetch = undiciFetch;
|
||||
now: () => number = () => Date.now();
|
||||
|
||||
async getReference(): Promise<NextcloudReference | null> {
|
||||
if (!this.data) {
|
||||
await this.refresh();
|
||||
return this.data;
|
||||
}
|
||||
if (this.now() - this.fetchedAtMs >= CACHE_TTL_MS) {
|
||||
void this.refresh();
|
||||
}
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/** Ruft die Daten ab (geteilt, mit Fehlschlag-Pause); wirft nie. */
|
||||
refresh(): Promise<void> {
|
||||
if (this.inflight) return this.inflight;
|
||||
if (this.lastFailureMs !== null && this.now() - this.lastFailureMs < RETRY_BACKOFF_MS) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.inflight = this.load().finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await this.fetchImpl(ENDOFLIFE_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'Tessera-Nextcloud-Status' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const text = await readCapped(response);
|
||||
const cycles = parseEndOfLife(JSON.parse(text));
|
||||
if (cycles.length === 0) throw new Error('keine verwertbaren Eintraege');
|
||||
this.fetchedAtMs = this.now();
|
||||
this.data = { cycles, fetchedAt: new Date(this.fetchedAtMs).toISOString() };
|
||||
this.lastFailureMs = null;
|
||||
} catch (err) {
|
||||
this.lastFailureMs = this.now();
|
||||
this.logger.warn(
|
||||
`Nextcloud-Vergleichsdaten nicht abrufbar: ${(err as Error).message}${this.data ? ' — letzten guten Daten bleiben erhalten' : ''}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCapped(response: Awaited<ReturnType<typeof undiciFetch>>): Promise<string> {
|
||||
if (!response.body) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('Antwort zu gross');
|
||||
return text;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let text = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_BYTES) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error('Antwort zu gross');
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchNextcloudStatus,
|
||||
MAX_STATUS_BYTES,
|
||||
normalizeCloudUrl,
|
||||
parseNextcloudStatus,
|
||||
} from './nextcloud-status-fetch';
|
||||
|
||||
const VALID = JSON.stringify({
|
||||
installed: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
version: '31.0.5.1',
|
||||
versionstring: '31.0.5',
|
||||
edition: '',
|
||||
productname: 'Nextcloud',
|
||||
extendedSupport: false,
|
||||
});
|
||||
|
||||
function res(body: string | null, status = 200, headers: Record<string, string> = {}) {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
|
||||
function fetchOf(...responses: Array<Response | (() => Promise<Response>)>) {
|
||||
const fn = vi.fn();
|
||||
for (const r of responses) {
|
||||
if (typeof r === 'function') fn.mockImplementationOnce(r);
|
||||
else fn.mockResolvedValueOnce(r);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('normalizeCloudUrl', () => {
|
||||
it.each([
|
||||
[' https://cloud.kunde.de/ ', 'https://cloud.kunde.de'],
|
||||
['https://cloud.kunde.de/status.php', 'https://cloud.kunde.de'],
|
||||
['https://cloud.kunde.de/index.php', 'https://cloud.kunde.de'],
|
||||
['https://host/nextcloud/', 'https://host/nextcloud'],
|
||||
['https://host/nextcloud/index.php/?a=1#x', 'https://host/nextcloud'],
|
||||
['http://intern:8080', 'http://intern:8080'],
|
||||
])('%s -> %s', (input, expected) => {
|
||||
expect(normalizeCloudUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'ftp://host/x',
|
||||
'javascript:alert(1)',
|
||||
'https://user:pass@cloud.kunde.de',
|
||||
'',
|
||||
' ',
|
||||
'kein url',
|
||||
`https://host/${'a'.repeat(2100)}`,
|
||||
])('lehnt %s ab', (input) => {
|
||||
expect(normalizeCloudUrl(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNextcloudStatus', () => {
|
||||
it('liest ein gueltiges status.php', () => {
|
||||
expect(parseNextcloudStatus(VALID)).toEqual({
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '31.0.5',
|
||||
edition: null,
|
||||
productName: 'Nextcloud',
|
||||
});
|
||||
});
|
||||
|
||||
it('behaelt Wartungsmodus', () => {
|
||||
expect(
|
||||
parseNextcloudStatus(
|
||||
JSON.stringify({ installed: true, maintenance: true, versionstring: '31.0.5' }),
|
||||
)?.maintenance,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fehlendes needsDbUpgrade ist false, fehlender versionstring faellt auf version zurueck', () => {
|
||||
const parsed = parseNextcloudStatus(JSON.stringify({ installed: true, version: '31.0.5.1' }));
|
||||
expect(parsed?.needsDbUpgrade).toBe(false);
|
||||
expect(parsed?.versionString).toBe('31.0.5');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<html>Login</html>',
|
||||
'',
|
||||
'[]',
|
||||
'"text"',
|
||||
'42',
|
||||
JSON.stringify({ installed: false, version: '31.0.5.1' }),
|
||||
JSON.stringify({ version: '31.0.5.1' }),
|
||||
])('kein Nextcloud: %s', (input) => {
|
||||
expect(parseNextcloudStatus(input)).toBeNull();
|
||||
});
|
||||
|
||||
it('kuerzt lange Textfelder auf 64 Zeichen und verwirft ungueltige Versionstexte', () => {
|
||||
const parsed = parseNextcloudStatus(
|
||||
JSON.stringify({
|
||||
installed: true,
|
||||
versionstring: '<script>',
|
||||
edition: 'e'.repeat(200),
|
||||
productname: 'p'.repeat(200),
|
||||
}),
|
||||
);
|
||||
expect(parsed?.edition).toHaveLength(64);
|
||||
expect(parsed?.productName).toHaveLength(64);
|
||||
expect(parsed?.versionString).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNextcloudStatus', () => {
|
||||
const BASE = 'https://cloud.kunde.de';
|
||||
|
||||
it('200 gueltig -> erreichbar mit Feldern, Anfrage ohne Zugangsdaten an <base>/status.php', async () => {
|
||||
const fetchImpl = fetchOf(res(VALID));
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
|
||||
expect(result).toMatchObject({
|
||||
reachable: true,
|
||||
versionString: '31.0.5',
|
||||
maintenance: false,
|
||||
errorKind: null,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://cloud.kunde.de/status.php');
|
||||
const names = Object.keys(init.headers).map((h) => h.toLowerCase());
|
||||
expect(names).not.toContain('authorization');
|
||||
expect(names).not.toContain('cookie');
|
||||
expect(init.redirect).toBe('manual');
|
||||
});
|
||||
|
||||
it('200 Wartungsmodus -> erreichbar, maintenance true', async () => {
|
||||
const body = JSON.stringify({ installed: true, maintenance: true, versionstring: '31.0.5' });
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res(body)) as never });
|
||||
expect(result).toMatchObject({ reachable: true, maintenance: true });
|
||||
});
|
||||
|
||||
it('200 Muell -> not-nextcloud', async () => {
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res('<html>')) as never });
|
||||
expect(result).toMatchObject({ reachable: false, errorKind: 'not-nextcloud' });
|
||||
});
|
||||
|
||||
it('503 -> http-status mit Detail', async () => {
|
||||
const result = await fetchNextcloudStatus(BASE, {
|
||||
fetchImpl: fetchOf(res('down', 503)) as never,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
reachable: false,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
});
|
||||
});
|
||||
|
||||
it('haengende Anfrage -> timeout', async () => {
|
||||
const hang = () => new Promise<Response>(() => {});
|
||||
const result = await fetchNextcloudStatus(BASE, {
|
||||
fetchImpl: fetchOf(hang) as never,
|
||||
timeoutMs: 20,
|
||||
});
|
||||
expect(result).toMatchObject({ reachable: false, errorKind: 'timeout' });
|
||||
});
|
||||
|
||||
it('abgelehnte Anfrage mit cause.code -> network', async () => {
|
||||
const reject = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }),
|
||||
);
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(reject) as never });
|
||||
expect(result).toMatchObject({ errorKind: 'network', errorDetail: 'ENOTFOUND' });
|
||||
});
|
||||
|
||||
it('Zertifikatsfehler -> tls', async () => {
|
||||
const reject = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new TypeError('fetch failed'), { cause: { code: 'CERT_HAS_EXPIRED' } }),
|
||||
);
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(reject) as never });
|
||||
expect(result).toMatchObject({ errorKind: 'tls', errorDetail: 'CERT_HAS_EXPIRED' });
|
||||
});
|
||||
|
||||
it('301 mit relativer Adresse wird einmal gefolgt', async () => {
|
||||
const fetchImpl = fetchOf(res(null, 301, { location: '/nextcloud/status.php' }), res(VALID));
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(fetchImpl.mock.calls[1][0]).toBe('https://cloud.kunde.de/nextcloud/status.php');
|
||||
});
|
||||
|
||||
it('Weiterleitung auf ftp: -> redirect', async () => {
|
||||
const result = await fetchNextcloudStatus(BASE, {
|
||||
fetchImpl: fetchOf(res(null, 302, { location: 'ftp://x/status.php' })) as never,
|
||||
});
|
||||
expect(result).toMatchObject({ errorKind: 'redirect' });
|
||||
});
|
||||
|
||||
it('vier Weiterleitungen in Folge -> redirect', async () => {
|
||||
const hop = () => res(null, 302, { location: '/status.php' });
|
||||
const fetchImpl = fetchOf(hop(), hop(), hop(), hop(), res(VALID));
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
|
||||
expect(result).toMatchObject({ errorKind: 'redirect' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('Antwort ueber 64 KiB -> too-large', async () => {
|
||||
const big = 'x'.repeat(MAX_STATUS_BYTES + 10);
|
||||
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res(big)) as never });
|
||||
expect(result).toMatchObject({ errorKind: 'too-large' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
|
||||
/**
|
||||
* nextcloud-status-fetch — Abruf und Auswertung von `<Adresse>/status.php`
|
||||
* (quick-261002-k67, L-03). Reine Funktionen plus ein Abruf mit einsetzbarem
|
||||
* `fetch`, damit Tests ohne Netz auskommen.
|
||||
*/
|
||||
|
||||
/** Zeitgrenze fuer die gesamte Anfrage (inkl. Weiterleitungen und Lesen). */
|
||||
export const STATUS_TIMEOUT_MS = 10_000;
|
||||
/** Hoechstzahl gefolgter Weiterleitungen. */
|
||||
export const MAX_REDIRECTS = 3;
|
||||
/** Groessendeckel der Antwort. */
|
||||
export const MAX_STATUS_BYTES = 64 * 1024;
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const MAX_TEXT_FIELD = 64;
|
||||
const MAX_VERSION_FIELD = 32;
|
||||
const MAX_DETAIL = 120;
|
||||
|
||||
export type NextcloudErrorKind =
|
||||
| 'timeout'
|
||||
| 'network'
|
||||
| 'tls'
|
||||
| 'http-status'
|
||||
| 'not-nextcloud'
|
||||
| 'too-large'
|
||||
| 'redirect';
|
||||
|
||||
export interface ParsedNextcloudStatus {
|
||||
maintenance: boolean;
|
||||
needsDbUpgrade: boolean;
|
||||
versionString: string | null;
|
||||
edition: string | null;
|
||||
productName: string | null;
|
||||
}
|
||||
|
||||
export interface NextcloudCheckResult {
|
||||
reachable: boolean;
|
||||
maintenance: boolean | null;
|
||||
needsDbUpgrade: boolean | null;
|
||||
versionString: string | null;
|
||||
edition: string | null;
|
||||
productName: string | null;
|
||||
errorKind: NextcloudErrorKind | null;
|
||||
errorDetail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt eine eingegebene Cloud-Adresse in Normalform: nur http/https, ohne
|
||||
* Zugangsdaten, ohne Abfrageteil/Anker, ohne `/status.php`, `/index.php` und
|
||||
* ohne abschliessenden Schrägstrich (Unterpfade wie `/nextcloud` bleiben).
|
||||
* Ungueltiges ergibt `null`.
|
||||
*/
|
||||
export function normalizeCloudUrl(raw: string): string | null {
|
||||
const trimmed = (raw ?? '').trim();
|
||||
if (!trimmed || trimmed.length > MAX_URL_LENGTH) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username || url.password) return null;
|
||||
if (!url.hostname) return null;
|
||||
let path = url.pathname.replace(/\/+$/, '');
|
||||
path = path.replace(/\/(status|index)\.php$/i, '').replace(/\/+$/, '');
|
||||
const normalized = `${url.protocol}//${url.host}${path}`;
|
||||
return normalized.length > MAX_URL_LENGTH ? null : normalized;
|
||||
}
|
||||
|
||||
function cleanVersion(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_VERSION_FIELD || !/^[0-9.]+$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function cleanText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, MAX_TEXT_FIELD) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Antwort von `status.php`. Nur Felder aus einer festen Liste
|
||||
* werden uebernommen; alles, was kein JSON-Objekt mit `installed: true` ist,
|
||||
* ergibt `null` (nicht Nextcloud).
|
||||
*/
|
||||
export function parseNextcloudStatus(text: string): ParsedNextcloudStatus | null {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
if (obj.installed !== true) return null;
|
||||
|
||||
let versionString = cleanVersion(obj.versionstring);
|
||||
if (!versionString) {
|
||||
const full = cleanVersion(obj.version);
|
||||
versionString = full ? full.split('.').slice(0, 3).join('.') : null;
|
||||
}
|
||||
|
||||
return {
|
||||
maintenance: obj.maintenance === true,
|
||||
needsDbUpgrade: obj.needsDbUpgrade === true,
|
||||
versionString,
|
||||
edition: cleanText(obj.edition),
|
||||
productName: cleanText(obj.productname),
|
||||
};
|
||||
}
|
||||
|
||||
const TLS_CODES = new Set([
|
||||
'CERT_HAS_EXPIRED',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'CERT_NOT_YET_VALID',
|
||||
'CERT_UNTRUSTED',
|
||||
'CERT_REVOKED',
|
||||
'HOSTNAME_MISMATCH',
|
||||
]);
|
||||
|
||||
function failure(errorKind: NextcloudErrorKind, errorDetail: string | null): NextcloudCheckResult {
|
||||
return {
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind,
|
||||
errorDetail: errorDetail ? errorDetail.slice(0, MAX_DETAIL) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(err: unknown): string | null {
|
||||
const e = err as { code?: unknown; cause?: { code?: unknown } } | null;
|
||||
const code = e?.cause?.code ?? e?.code;
|
||||
return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
class TooLargeError extends Error {}
|
||||
|
||||
async function readCapped(
|
||||
response: { body: ReadableStream<Uint8Array> | null; text(): Promise<string> },
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
if (!response.body) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text) > maxBytes) throw new TooLargeError();
|
||||
return text;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
let text = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new TooLargeError();
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
|
||||
function discard(response: { body?: { cancel(): Promise<void> } | null }): void {
|
||||
try {
|
||||
response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// schon verbraucht — nichts zu tun
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchStatusOptions {
|
||||
fetchImpl?: typeof undiciFetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt `<baseUrl>/status.php` ab und fasst das Ergebnis zusammen.
|
||||
*
|
||||
* SSRF-Hinweis (L-03, wie bei Proxmox T-DHH-02): die Adresse wird vom
|
||||
* Verwalter eingegeben, interne Adressen sind mit Absicht erlaubt (Clouds
|
||||
* stehen oft im Haus). Begrenzungen: nur GET auf `<Adresse>/status.php`,
|
||||
* keine Zugangsdaten oder Cookies, hoechstens 3 Weiterleitungen (nur
|
||||
* http/https), 10 Sekunden fuer die gesamte Anfrage, 64 KiB Antwort. Zum
|
||||
* Browser gelangen nur die ausgewerteten, auf eine feste Liste beschraenkten
|
||||
* Felder — nie ein Antworttext, und als Fehlerdetail nur eigene Kurzkennungen
|
||||
* (z. B. 'HTTP 503', 'ENOTFOUND'). Wer Verwalten darf, kann dadurch
|
||||
* erfahren, ob eine interne Adresse antwortet — bewusst akzeptiert.
|
||||
* Zertifikate werden geprueft (kein Abschalten, D-H).
|
||||
*/
|
||||
export async function fetchNextcloudStatus(
|
||||
baseUrl: string,
|
||||
opts: FetchStatusOptions = {},
|
||||
): Promise<NextcloudCheckResult> {
|
||||
const fetchImpl = opts.fetchImpl ?? undiciFetch;
|
||||
const timeoutMs = opts.timeoutMs ?? STATUS_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// Fuer haengende Server, die ein abgebrochenes fetch/read nicht beenden.
|
||||
const aborted = new Promise<'timeout'>((resolve) => {
|
||||
controller.signal.addEventListener('abort', () => resolve('timeout'));
|
||||
});
|
||||
|
||||
const run = async (): Promise<NextcloudCheckResult> => {
|
||||
let current = `${baseUrl}/status.php`;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||
try {
|
||||
response = await fetchImpl(current, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'Tessera-Nextcloud-Status' },
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return failure('timeout', null);
|
||||
const code = errorCode(err);
|
||||
if (code && TLS_CODES.has(code)) return failure('tls', code);
|
||||
return failure('network', code);
|
||||
}
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
discard(response);
|
||||
if (!location) return failure('redirect', 'Keine Weiterleitungsadresse');
|
||||
let next: URL;
|
||||
try {
|
||||
next = new URL(location, current);
|
||||
} catch {
|
||||
return failure('redirect', 'Ungueltige Weiterleitung');
|
||||
}
|
||||
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
||||
return failure('redirect', 'Weiterleitung nicht http/https');
|
||||
}
|
||||
if (hop === MAX_REDIRECTS) return failure('redirect', 'Zu viele Weiterleitungen');
|
||||
current = next.toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
discard(response);
|
||||
return failure('http-status', `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await readCapped(response as never, MAX_STATUS_BYTES);
|
||||
} catch (err) {
|
||||
if (err instanceof TooLargeError) return failure('too-large', null);
|
||||
if (controller.signal.aborted) return failure('timeout', null);
|
||||
return failure('network', errorCode(err));
|
||||
}
|
||||
|
||||
const parsed = parseNextcloudStatus(text);
|
||||
if (!parsed) return failure('not-nextcloud', null);
|
||||
return {
|
||||
reachable: true,
|
||||
maintenance: parsed.maintenance,
|
||||
needsDbUpgrade: parsed.needsDbUpgrade,
|
||||
versionString: parsed.versionString,
|
||||
edition: parsed.edition,
|
||||
productName: parsed.productName,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
};
|
||||
}
|
||||
return failure('redirect', 'Zu viele Weiterleitungen');
|
||||
};
|
||||
|
||||
try {
|
||||
const outcome = await Promise.race([run(), aborted]);
|
||||
return outcome === 'timeout' ? failure('timeout', null) : outcome;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'reflect-metadata';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard';
|
||||
import { NextcloudStatusController } from './nextcloud-status.controller';
|
||||
|
||||
const proto = NextcloudStatusController.prototype as unknown as Record<string, unknown>;
|
||||
|
||||
describe('NextcloudStatusController Metadaten', () => {
|
||||
it('Klasse traegt Modul-Slug und ModuleGuard', () => {
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudStatusController)).toBe(
|
||||
'nextcloud-status',
|
||||
);
|
||||
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(['create', 'checkOne'])('%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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
* `@UseModule('nextcloud-status')` auf Klassenebene — Aktivierung UND
|
||||
* 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
|
||||
* `@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).
|
||||
*/
|
||||
@Controller('modules/nextcloud-status')
|
||||
@UseModule('nextcloud-status')
|
||||
export class NextcloudStatusController {
|
||||
constructor(private readonly service: NextcloudStatusService) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Kein Mandantenkontext');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@Get('instances')
|
||||
async list(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.listForTenant(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('instances')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) {
|
||||
return this.service.createInstance(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('instances/:id/check')
|
||||
@ModuleManage('nextcloud-status')
|
||||
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.checkInstance(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import { NextcloudStatusController } from './nextcloud-status.controller';
|
||||
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
|
||||
import { NextcloudStatusService } from './nextcloud-status.service';
|
||||
|
||||
/**
|
||||
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
|
||||
* Vorbild `ProxmoxModule`: seeds itself into the module registry on startup.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [NextcloudStatusController],
|
||||
providers: [NextcloudStatusService, NextcloudReleaseService],
|
||||
})
|
||||
export class NextcloudStatusModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(NextcloudStatusModule.name);
|
||||
|
||||
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedNextcloudStatusModule(this.moduleRegistryService);
|
||||
this.logger.log('Nextcloud-Status module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed nextcloud-status module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Seeds the nextcloud-status module into the module registry
|
||||
* (quick-261002-k67). Vorbild `proxmox.seed.ts`; Kategorie
|
||||
* `infrastructure`. Der Slug ist zugleich der Wert in `@UseModule` und in
|
||||
* `WIDGET_MODULE_SLUGS` (@tessera/shared).
|
||||
*/
|
||||
export async function seedNextcloudStatusModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'nextcloud-status',
|
||||
name: 'Nextcloud-Status',
|
||||
version: '1.0.0',
|
||||
category: 'infrastructure',
|
||||
description: {
|
||||
de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick',
|
||||
en: 'Keep track of versions and availability of your Nextcloud clouds',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
forSystem: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./nextcloud-status-fetch')>();
|
||||
return { ...actual, fetchNextcloudStatus: vi.fn() };
|
||||
});
|
||||
|
||||
import { 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';
|
||||
|
||||
const REFERENCE: NextcloudReference = {
|
||||
fetchedAt: '2026-10-02T10:00:00.000Z',
|
||||
cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }],
|
||||
};
|
||||
|
||||
function makeRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'i1',
|
||||
tenantId: 't1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
logoUrl: null,
|
||||
logoMime: null,
|
||||
logoVersion: 0,
|
||||
lastCheckedAt: new Date('2026-10-02T11:00:00Z'),
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NextcloudStatusService', () => {
|
||||
let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> };
|
||||
let release: { getReference: ReturnType<typeof vi.fn> };
|
||||
let service: NextcloudStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchNextcloudStatus).mockReset();
|
||||
vi.mocked(forTenant).mockClear();
|
||||
prisma = {
|
||||
nextcloudInstance: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
release = { getReference: vi.fn().mockResolvedValue(REFERENCE) };
|
||||
service = new NextcloudStatusService(prisma as never, release as never);
|
||||
});
|
||||
|
||||
it('listForTenant waehlt keine Logo-Bytes und liefert Bewertung und neueste Version', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([
|
||||
makeRow(),
|
||||
makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }),
|
||||
]);
|
||||
const result = await service.listForTenant('t1');
|
||||
const args = prisma.nextcloudInstance.findMany.mock.calls[0][0];
|
||||
expect(args.select).toBe(PUBLIC_SELECT);
|
||||
expect(args.select).not.toHaveProperty('logoData');
|
||||
expect(args.where).toEqual({ tenantId: 't1' });
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
expect(result.reference).toEqual({ newestVersion: '35.0.1', fetchedAt: REFERENCE.fetchedAt });
|
||||
expect(result.instances).toHaveLength(2);
|
||||
expect(result.instances[0].rating).toMatchObject({ level: 'green', reason: 'current' });
|
||||
expect(result.instances[0].hasUploadedLogo).toBe(false);
|
||||
expect(result.instances[1]).toMatchObject({ hasUploadedLogo: true, logoVersion: 3 });
|
||||
expect(JSON.stringify(result)).not.toContain('logoData');
|
||||
});
|
||||
|
||||
it('listForTenant ohne Vergleichsdaten bewertet grau', async () => {
|
||||
release.getReference.mockResolvedValue(null);
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]);
|
||||
const result = await service.listForTenant('t1');
|
||||
expect(result.reference).toEqual({ newestVersion: null, fetchedAt: null });
|
||||
expect(result.instances[0].rating.level).toBe('unknown');
|
||||
});
|
||||
|
||||
it('createInstance normalisiert die Adresse, legt an und prueft sofort', async () => {
|
||||
prisma.nextcloudInstance.create.mockResolvedValue({ id: 'i1' });
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
});
|
||||
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
|
||||
reachable: true,
|
||||
maintenance: false,
|
||||
needsDbUpgrade: false,
|
||||
versionString: '35.0.1',
|
||||
edition: null,
|
||||
productName: 'Nextcloud',
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
|
||||
const view = await service.createInstance('t1', {
|
||||
customerName: ' Kunde A ',
|
||||
baseUrl: 'https://cloud.a.de/status.php/',
|
||||
});
|
||||
expect(prisma.nextcloudInstance.create.mock.calls[0][0].data).toEqual({
|
||||
tenantId: 't1',
|
||||
customerName: 'Kunde A',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
logoUrl: null,
|
||||
});
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledWith('https://cloud.a.de');
|
||||
expect(view.id).toBe('i1');
|
||||
});
|
||||
|
||||
it('createInstance mit ungueltiger Adresse -> BadRequest mit deutscher Meldung', async () => {
|
||||
await expect(
|
||||
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(
|
||||
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
|
||||
).rejects.toThrow('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.');
|
||||
expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkInstance schreibt alle Statusspalten', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://cloud.a.de',
|
||||
});
|
||||
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(
|
||||
makeRow({
|
||||
reachable: false,
|
||||
versionString: null,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
}),
|
||||
);
|
||||
const view = await service.checkInstance('t1', 'i1');
|
||||
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
|
||||
id: 'i1',
|
||||
tenantId: 't1',
|
||||
});
|
||||
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({
|
||||
reachable: false,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
errorKind: 'http-status',
|
||||
errorDetail: 'HTTP 503',
|
||||
});
|
||||
expect(data.lastCheckedAt).toBeInstanceOf(Date);
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
|
||||
expect(view.rating).toMatchObject({ level: 'red', reason: 'unreachable' });
|
||||
});
|
||||
|
||||
it('checkInstance fuer unbekannte Kennung -> NotFound, kein Abruf', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
|
||||
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
|
||||
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,
|
||||
} 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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
rating: NextcloudRating;
|
||||
}
|
||||
|
||||
export interface NextcloudListView {
|
||||
instances: NextcloudInstanceView[];
|
||||
reference: { newestVersion: string | null; fetchedAt: string | null };
|
||||
}
|
||||
|
||||
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
|
||||
|
||||
@Injectable()
|
||||
export class NextcloudStatusService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly release: NextcloudReleaseService,
|
||||
) {}
|
||||
|
||||
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
|
||||
const status = {
|
||||
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). */
|
||||
async listForTenant(tenantId: string): Promise<NextcloudListView> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const [rows, reference] = await Promise.all([
|
||||
tenantPrisma.nextcloudInstance.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { customerName: 'asc' },
|
||||
select: PUBLIC_SELECT,
|
||||
}),
|
||||
this.release.getReference(),
|
||||
]);
|
||||
return {
|
||||
instances: rows.map((row) => this.toView(row as PublicRow, reference)),
|
||||
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.
|
||||
*/
|
||||
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 },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
|
||||
|
||||
const result = await fetchNextcloudStatus(existing.baseUrl);
|
||||
const updated = await tenantPrisma.nextcloudInstance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
reachable: result.reachable,
|
||||
maintenance: result.maintenance,
|
||||
needsDbUpgrade: result.needsDbUpgrade,
|
||||
versionString: result.versionString,
|
||||
edition: result.edition,
|
||||
productName: result.productName,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
lastCheckedAt: new Date(),
|
||||
},
|
||||
select: PUBLIC_SELECT,
|
||||
});
|
||||
return this.toView(updated as PublicRow, await this.release.getReference());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user