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:
2026-10-02 14:49:02 +02:00
parent 7188733f70
commit 5ef7b0c6cc
31 changed files with 2513 additions and 2 deletions
@@ -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());
+29
View File
@@ -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,
+2
View File
@@ -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());
}
}
@@ -7,6 +7,7 @@ import DkvFleetLayout from './dkv-fleet/layout';
import DomaincheckLayout from './domaincheck/layout';
import HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout';
import NextcloudStatusLayout from './nextcloud-status/layout';
import TenderRadarLayout from './tender-radar/layout';
/**
@@ -42,6 +43,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
['tender-radar', TenderRadarLayout],
['kantine-datev', KantineDatevLayout],
['handelsware-datev', HandelswareDatevLayout],
['nextcloud-status', NextcloudStatusLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild });
@@ -0,0 +1,118 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useState } from 'react';
import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display';
import { formatAge } from '@/components/proxmox/proxmox-status';
import { logoSrc, type NextcloudInstance } from '@/lib/nextcloud-status-api';
/** Ein bis zwei Anfangsbuchstaben als Ersatz, wenn kein Logo vorliegt oder es nicht laedt. */
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
const letters = parts.slice(0, 2).map((p) => Array.from(p)[0] ?? '');
return letters.join('').toUpperCase() || '?';
}
interface CloudTileProps {
instance: NextcloudInstance;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number;
}
/**
* Kachel EINER Cloud (quick-261002-k67, L-06): Leiste links und Pille in der
* Ampelfarbe, Logo (oder Initialen), Kundenname, Adresse als Verweis in
* neuem Tab, installierte Version, kurzer Klartext zur Bewertung und
* Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und
* wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05).
*/
export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
const t = useTranslations('nextcloudStatus');
const locale = useLocale();
const [logoFailed, setLogoFailed] = useState(false);
const style = RATING_STYLE[instance.rating.level];
const reason = ratingReasonText(t, instance.rating, locale);
const src = logoFailed ? null : logoSrc(instance);
const checkedAge = formatAge(instance.status.checkedAt, now, locale);
const showErrorDetail =
instance.rating.level === 'red' &&
instance.rating.reason === 'unreachable' &&
instance.status.errorDetail;
return (
<article
data-testid="cloud-tile"
data-level={instance.rating.level}
aria-label={`${instance.customerName}: ${reason}`}
className="relative flex h-full flex-col overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border"
>
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
<div className="flex flex-1 flex-col gap-3 py-4 pl-5 pr-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-well text-sm font-semibold text-muted-foreground">
{src ? (
// Dynamische Adresse (Upload ueber den Proxy oder externe https-Adresse) — next/image waere hier unpassend.
// biome-ignore lint/performance/noImgElement: externe, vom Verwalter gesetzte Adresse
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall, keine Bedienung
<img
src={src}
alt={instance.customerName}
referrerPolicy="no-referrer"
className="h-full w-full object-contain"
onError={() => setLogoFailed(true)}
/>
) : (
<span aria-hidden="true">{initialsOf(instance.customerName)}</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold leading-tight" title={instance.customerName}>
{instance.customerName}
</div>
<a
href={instance.baseUrl}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-xs text-muted-foreground underline-offset-2 hover:underline"
title={t('card.openCloud')}
>
{instance.baseUrl}
</a>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span
data-testid="rating-pill"
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
>
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
{reason}
</span>
<span className="text-xs text-muted-foreground">
{t('card.version')}:{' '}
<span className="font-medium tabular-nums text-foreground">
{instance.status.versionString ?? t('card.versionUnknown')}
</span>
</span>
</div>
{showErrorDetail && (
<p className="break-words text-xs text-muted-foreground">{instance.status.errorDetail}</p>
)}
<div
data-testid="last-check"
className="mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground"
title={
instance.status.checkedAt
? new Date(instance.status.checkedAt).toLocaleString(locale)
: undefined
}
>
{checkedAge ? t('card.lastCheck', { age: checkedAge }) : t('card.neverChecked')}
</div>
</div>
</article>
);
}
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function NextcloudStatusLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="nextcloud-status">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,170 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudInstance, NextcloudList } from '@/lib/nextcloud-status-api';
import de from '@/messages/de.json';
import NextcloudStatusPage from './page';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockListInstances = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) };
});
function makeInstance(
over: Partial<NextcloudInstance> & { id: string; customerName: string },
): NextcloudInstance {
return {
baseUrl: `https://${over.id}.example.de`,
logoUrl: null,
hasUploadedLogo: false,
logoVersion: 0,
status: {
checkedAt: new Date().toISOString(),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
...over,
};
}
const LIST: NextcloudList = {
reference: { newestVersion: '35.0.1', fetchedAt: '2026-10-02T10:00:00.000Z' },
instances: [
makeInstance({ id: 'a', customerName: 'Kunde Grün' }),
makeInstance({
id: 'b',
customerName: 'Kunde Gelb',
status: {
checkedAt: new Date().toISOString(),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '34.0.3',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: {
level: 'yellow',
reason: 'update-available',
updateTo: '34.0.4',
eolDate: '2027-06-30',
cycle: 34,
},
}),
makeInstance({
id: 'c',
customerName: 'Kunde Rot',
status: {
checkedAt: new Date().toISOString(),
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
errorKind: 'timeout',
errorDetail: null,
},
rating: { level: 'red', reason: 'unreachable', updateTo: null, eolDate: null, cycle: null },
}),
makeInstance({
id: 'd',
customerName: 'Kunde Grau',
rating: {
level: 'unknown',
reason: 'no-reference',
updateTo: null,
eolDate: null,
cycle: 35,
},
}),
],
};
describe('NextcloudStatusPage', () => {
beforeEach(() => {
mockListInstances.mockReset();
});
afterEach(() => cleanup());
it('zeigt je Cloud eine Kachel mit Name, Version, Klartext und Verweis', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
const tiles = await screen.findAllByTestId('cloud-tile');
expect(tiles).toHaveLength(4);
const green = tiles.find((t) => t.textContent?.includes('Kunde Grün'));
expect(green).toBeDefined();
expect(within(green as HTMLElement).getByText('Aktuell')).toBeInTheDocument();
expect(green).toHaveAttribute('data-level', 'green');
expect(screen.getByText('Update auf 34.0.4 verfügbar')).toBeInTheDocument();
expect(screen.getByText('Nicht erreichbar')).toBeInTheDocument();
expect(screen.getByText('Bewertung nicht möglich')).toBeInTheDocument();
expect(screen.getByText('34.0.3')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'https://a.example.de' });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveAttribute('href', 'https://a.example.de');
});
it('nennt die neueste Nextcloud-Version', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
'Neueste Nextcloud-Version: 35.0.1',
);
});
it('weist ohne Vergleichsdaten darauf hin', async () => {
mockListInstances.mockResolvedValue({
...LIST,
reference: { newestVersion: null, fetchedAt: null },
});
render(<NextcloudStatusPage />);
expect(await screen.findByTestId('newest-version')).toHaveTextContent(
'Versionsdaten derzeit nicht verfügbar',
);
});
it('zeigt den Leerzustand ohne Clouds', async () => {
mockListInstances.mockResolvedValue({ ...LIST, instances: [] });
render(<NextcloudStatusPage />);
expect(await screen.findByText('Es sind noch keine Clouds eingetragen.')).toBeInTheDocument();
expect(screen.queryByTestId('cloud-tile')).toBeNull();
});
it('zeigt einen Hinweis, wenn das Laden fehlschlägt', async () => {
mockListInstances.mockRejectedValue(new Error('boom'));
render(<NextcloudStatusPage />);
await waitFor(() =>
expect(screen.getByText(/konnten nicht geladen werden/)).toBeInTheDocument(),
);
});
it('verwendet ohne Logo Initialen', async () => {
mockListInstances.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
const tile = await screen.findByTestId('cloud-tile');
expect(within(tile).getByText('KG')).toBeInTheDocument();
});
});
@@ -0,0 +1,104 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/layout/page-header';
import { WELL } from '@/components/proxmox/status-styles';
import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api';
import { CloudTile } from './components/CloudTile';
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
const CLOCK_TICK_MS = 60_000;
function SkeletonTile() {
return (
<div className="rounded-lg bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-3.5 w-1/2 rounded bg-muted" />
<div className="h-3 w-2/3 rounded bg-muted" />
</div>
</div>
<div className="mt-4 h-6 w-1/2 rounded-full bg-muted" />
</div>
);
}
/**
* Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit
* Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die
* Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an.
*/
export default function NextcloudStatusPage() {
const t = useTranslations('nextcloudStatus');
const [data, setData] = useState<NextcloudList | null>(null);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const reload = useCallback(() => {
listInstances()
.then((list) => {
setData(list);
setNow(Date.now());
})
.catch(() => setError(t('loadError')));
}, [t]);
useEffect(() => {
reload();
}, [reload]);
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
return () => window.clearInterval(id);
}, []);
const newest = data?.reference.newestVersion ?? null;
return (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="nextcloud-status" title={t('title')} description={t('description')} />
{error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
{error}
</p>
)}
{!error && data === null && (
<div
aria-busy="true"
className="grid animate-pulse gap-4 motion-reduce:animate-none sm:grid-cols-2 xl:grid-cols-3"
>
<span className="sr-only">{t('loading')}</span>
<SkeletonTile />
<SkeletonTile />
<SkeletonTile />
</div>
)}
{!error && data !== null && (
<p className="text-sm text-muted-foreground" data-testid="newest-version">
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
</p>
)}
{!error && data !== null && data.instances.length === 0 && (
<div className={`${WELL} px-6 py-10 text-center`}>
<p className="text-sm text-muted-foreground">{t('empty')}</p>
</div>
)}
{!error && data !== null && data.instances.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.instances.map((instance) => (
<li key={instance.id} className="min-w-0">
<CloudTile instance={instance} now={now} />
</li>
))}
</ul>
)}
</div>
);
}
@@ -57,6 +57,7 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
<path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z" />
</>
),
cloud: <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
tile: (
<>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
@@ -0,0 +1,81 @@
import type { NextcloudRating, RatingLevel } from '@/lib/nextcloud-status-api';
/**
* Darstellung der Ampel (quick-261002-k67), gemeinsam fuer Modulseite und
* Dashboard-Kachel. Bewusst vollstaendige, statische Klassenzeichenketten —
* Tailwind findet Klassen nur, wenn sie woertlich im Quelltext stehen.
*
* `fill` = Flaeche (Leiste, Punkt), `pill` = Pille (12 % Flaeche + Textvariante),
* `text` = Schrift in Statusfarbe.
*/
export const RATING_STYLE: Record<RatingLevel, { fill: string; pill: string; text: string }> = {
green: {
fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg',
},
yellow: {
fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg',
},
red: {
fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg',
},
unknown: {
fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg',
},
};
type Translate = (key: string, values?: Record<string, string | number>) => string;
/** 'YYYY-MM-DD' als Datum nach Sprache, in UTC (kein Tagesversatz durch die Zeitzone). */
export function formatEolDate(isoDay: string, locale: string): string {
const date = new Date(`${isoDay}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return isoDay;
return new Intl.DateTimeFormat(locale, {
timeZone: 'UTC',
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
/**
* Kurzer Klartext zur Bewertung. `t` ist der Uebersetzer des Namensraums
* `nextcloudStatus`; die API liefert nur Grundkennung plus Parameter (D-B).
*/
export function ratingReasonText(t: Translate, rating: NextcloudRating, locale: string): string {
switch (rating.reason) {
case 'current':
return t('reason.current');
case 'update-available':
return t('reason.updateAvailable', { version: rating.updateTo ?? '' });
case 'eol-soon':
return t('reason.eolSoon', {
date: rating.eolDate ? formatEolDate(rating.eolDate, locale) : '',
});
case 'eol-passed':
return rating.eolDate
? t('reason.eolPassed', { date: formatEolDate(rating.eolDate, locale) })
: t('reason.eolPassedNoDate');
case 'unreachable':
return t('reason.unreachable');
case 'invalid-response':
return t('reason.invalidResponse');
case 'maintenance':
return t('reason.maintenance');
case 'needs-db-upgrade':
return t('reason.needsDbUpgrade');
case 'no-reference':
return t('reason.noReference');
case 'version-unknown':
return t('reason.versionUnknown');
case 'not-checked':
return t('reason.notChecked');
}
}
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'tile';
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'tile';
const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar',
@@ -15,6 +15,7 @@ const ICONS: Record<string, ModuleIconId> = {
'cert-manager': 'certificate',
domaincheck: 'globe',
proxmox: 'server',
'nextcloud-status': 'cloud',
'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
};
+6
View File
@@ -59,6 +59,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false },
),
},
'nextcloud-status': {
component: dynamic(
() => import('@/app/(portal)/modules/nextcloud-status/page'),
{ ssr: false },
),
},
'kantine-datev': {
component: dynamic(
() => import('@/app/(portal)/modules/kantine-datev/page'),
+86
View File
@@ -0,0 +1,86 @@
/**
* Nextcloud-Status Module API client (quick-261002-k67). Konsumiert
* `/modules/nextcloud-status/*`. Vorbild `proxmox-api.ts`:
* `credentials: 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*
* Die Antworten tragen nur ausgewertete Felder — nie einen rohen
* Antworttext einer Cloud und nie Logo-Bytes (die kommen ueber die
* eigene Logo-Adresse, siehe `logoSrc`).
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
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;
cycle: number | null;
}
export interface NextcloudInstanceStatus {
checkedAt: string | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
}
export interface NextcloudInstance {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
hasUploadedLogo: boolean;
logoVersion: number;
status: NextcloudInstanceStatus;
rating: NextcloudRating;
}
export interface NextcloudList {
instances: NextcloudInstance[];
reference: { newestVersion: string | null; fetchedAt: string | null };
}
/** GET /modules/nextcloud-status/instances — Clouds samt Bewertung und neuester Version. */
export async function listInstances(): Promise<NextcloudList> {
const res = await fetch(`${API_URL}/modules/nextcloud-status/instances`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch nextcloud instances');
return res.json();
}
/**
* Bildquelle fuer das Logo einer Cloud: ein hochgeladenes Logo kommt ueber den
* same-origin-Proxy (angemeldet, mit `?v=` gegen alte Zwischenspeicher), eine
* Bildadresse laedt der Browser direkt; ohne beides `null` (Initialen).
*/
export function logoSrc(
instance: Pick<NextcloudInstance, 'id' | 'hasUploadedLogo' | 'logoVersion' | 'logoUrl'>,
): string | null {
if (instance.hasUploadedLogo) {
return `/api-proxy/modules/nextcloud-status/instances/${instance.id}/logo?v=${instance.logoVersion}`;
}
return instance.logoUrl ?? null;
}
+1
View File
@@ -25,6 +25,7 @@ export const useNavStore = create<NavState>()((set) => ({
/** Ersatztitel, solange die Seitenleiste die Module noch nicht geladen hat. */
const MODULE_TITLE_KEYS: Record<string, string> = {
proxmox: 'proxmox.title',
'nextcloud-status': 'nextcloudStatus.title',
domaincheck: 'domaincheck.title',
'cert-manager': 'certManager.title',
'dkv-fleet': 'dkvFleet.pageTitle',
+38
View File
@@ -1814,5 +1814,43 @@
"procurement": "Beschaffung",
"accounting": "Finanzbuchhaltung",
"custom-modules": "Eigene Module"
},
"nextcloudStatus": {
"title": "Nextcloud-Status",
"description": "Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick",
"loading": "Clouds werden geladen …",
"loadError": "Die Clouds konnten nicht geladen werden. Bitte laden Sie die Seite neu.",
"empty": "Es sind noch keine Clouds eingetragen.",
"emptyManage": "Fügen Sie Ihre erste Cloud über „Cloud hinzufügen“ hinzu.",
"newestVersion": "Neueste Nextcloud-Version: {version}",
"referenceUnavailable": "Versionsdaten derzeit nicht verfügbar",
"card": {
"version": "Version",
"versionUnknown": "—",
"lastCheck": "Zuletzt geprüft {age}",
"neverChecked": "Noch nie geprüft",
"openCloud": "Cloud im neuen Tab öffnen",
"noLogo": "Kein Logo"
},
"level": {
"green": "Grün",
"yellow": "Gelb",
"red": "Rot",
"unknown": "Ohne Bewertung"
},
"reason": {
"current": "Aktuell",
"updateAvailable": "Update auf {version} verfügbar",
"eolSoon": "Support endet am {date}",
"eolPassed": "Support abgelaufen seit {date}",
"eolPassedNoDate": "Support abgelaufen",
"unreachable": "Nicht erreichbar",
"invalidResponse": "Keine gültige Nextcloud-Antwort",
"maintenance": "Wartungsmodus",
"needsDbUpgrade": "Datenbank-Aktualisierung ausstehend",
"noReference": "Bewertung nicht möglich",
"versionUnknown": "Version unbekannt",
"notChecked": "Noch nicht geprüft"
}
}
}
+38
View File
@@ -1814,5 +1814,43 @@
"procurement": "Procurement",
"accounting": "Financial accounting",
"custom-modules": "Custom modules"
},
"nextcloudStatus": {
"title": "Nextcloud status",
"description": "Keep track of versions and availability of your Nextcloud clouds",
"loading": "Loading clouds …",
"loadError": "The clouds could not be loaded. Please reload the page.",
"empty": "No clouds have been added yet.",
"emptyManage": "Add your first cloud with “Add cloud”.",
"newestVersion": "Newest Nextcloud version: {version}",
"referenceUnavailable": "Version data currently unavailable",
"card": {
"version": "Version",
"versionUnknown": "—",
"lastCheck": "Last checked {age}",
"neverChecked": "Never checked",
"openCloud": "Open cloud in a new tab",
"noLogo": "No logo"
},
"level": {
"green": "Green",
"yellow": "Yellow",
"red": "Red",
"unknown": "Not rated"
},
"reason": {
"current": "Up to date",
"updateAvailable": "Update to {version} available",
"eolSoon": "Support ends on {date}",
"eolPassed": "Support ended on {date}",
"eolPassedNoDate": "Support ended",
"unreachable": "Not reachable",
"invalidResponse": "Not a valid Nextcloud response",
"maintenance": "Maintenance mode",
"needsDbUpgrade": "Database upgrade pending",
"noReference": "Rating not possible",
"versionUnknown": "Version unknown",
"notChecked": "Not checked yet"
}
}
}
@@ -103,6 +103,9 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
* and must never be touched by the replacement or flagged by the guard.
*/
export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
'Neueste',
'ausstehend',
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
'Aussteller',
'Betriebssystemen',