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,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());
}
}