diff --git a/apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql b/apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql new file mode 100644 index 0000000..022dd77 --- /dev/null +++ b/apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql @@ -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()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 58b067e..f8ff789 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4ff1a19..67d1913 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts b/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts new file mode 100644 index 0000000..19ed288 --- /dev/null +++ b/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts @@ -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; +} diff --git a/apps/api/src/nextcloud-status/nextcloud-rating.spec.ts b/apps/api/src/nextcloud-status/nextcloud-rating.spec.ts new file mode 100644 index 0000000..d370cca --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-rating.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-rating.ts b/apps/api/src/nextcloud-status/nextcloud-rating.ts new file mode 100644 index 0000000..5689a37 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-rating.ts @@ -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> = {}, +): 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 }); +} diff --git a/apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts new file mode 100644 index 0000000..f14d617 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts @@ -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; + 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('', { 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); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-release.service.ts b/apps/api/src/nextcloud-status/nextcloud-release.service.ts new file mode 100644 index 0000000..79cfc9e --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-release.service.ts @@ -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; + 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 | null = null; + + /** Pruefstellen: Tests ueberschreiben diese beiden Felder. */ + fetchImpl: typeof undiciFetch = undiciFetch; + now: () => number = () => Date.now(); + + async getReference(): Promise { + 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 { + 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 { + 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>): Promise { + 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(); +} diff --git a/apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts new file mode 100644 index 0000000..ec748a6 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts @@ -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 = {}) { + return new Response(body, { status, headers }); +} + +function fetchOf(...responses: Array Promise)>) { + 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([ + 'Login', + '', + '[]', + '"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: '