feat(nextcloud-files): Nextcloud-Kennung auf der Anmeldeseite, Anleitungen und Changelog

- Anmeldebildschirm zeigt Name, Logo und Themenfarbe der Nextcloud (GET server, server/logo)
- Kennung wird 10 Minuten zwischengespeichert, Adresswechsel leert sie, Logo nur nach Bytes erkannt und mit CSP-Sandbox ausgeliefert
- Anmeldekarte neu gestaltet (Kennungskopf, Hinweis zum Passwort am Fuss), Kontoleiste mit kleiner Kachel
- Fokusfang in leeren Ordnern, damit die Rücktaste dort funktioniert
- Changelog, Anwender-, Administrations- und Betriebshandbuch (Brute-Force-Ausnahme, Proxy-Grenzen)
- e2e-Skripte wiederholbar gemacht

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:08:57 +02:00
parent 86fbe8f396
commit 630398fe93
24 changed files with 1118 additions and 107 deletions
@@ -75,6 +75,8 @@ describe('NextcloudFilesController — Metadaten', () => {
];
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
expect(route('getStatus')).toEqual([0, 'status']);
expect(route('getServer')).toEqual([0, 'server']);
expect(route('getServerLogo')).toEqual([0, 'server/logo']);
expect(route('getSettings')).toEqual([0, 'settings']);
expect(route('saveSettings')).toEqual([2, 'settings']);
expect(route('testSettings')).toEqual([1, 'settings/test']);
@@ -220,6 +222,13 @@ describe('NextcloudFilesController — Delegation', () => {
};
}
function makeServerInfo() {
return {
getServerInfo: vi.fn(async (..._a: unknown[]) => ({ host: 'cloud.example', name: 'Nextcloud' })),
sendLogo: vi.fn(async (..._a: unknown[]) => undefined),
};
}
function makeFiles() {
return {
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
@@ -237,6 +246,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeAccount() as any,
files as any,
makeTransfer() as any,
makeServerInfo() as any,
);
const r = userReq('t1', 'u1');
const res = { end: vi.fn() } as any;
@@ -261,6 +271,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeAccount() as any,
makeFiles() as any,
transfer as any,
makeServerInfo() as any,
);
const r = userReq('t1', 'u1');
(r as any).headers = { range: 'bytes=0-99' };
@@ -312,6 +323,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeAccount() as any,
makeFiles() as any,
transfer as any,
makeServerInfo() as any,
);
const r = userReq('t1', undefined);
const res = {} as any;
@@ -334,6 +346,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeAccount() as any,
files as any,
makeTransfer() as any,
makeServerInfo() as any,
);
const r = userReq('t1', undefined);
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
@@ -352,6 +365,7 @@ describe('NextcloudFilesController — Delegation', () => {
account as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
);
await controller.getStatus(userReq('t1', 'u1'));
await controller.getSettings(req('t1'));
@@ -383,6 +397,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
);
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
ForbiddenException,
@@ -393,6 +408,25 @@ describe('NextcloudFilesController — Delegation', () => {
).rejects.toBeInstanceOf(ForbiddenException);
});
it('Server-Kennung: nur der Mandant aus dem Token, Logo schreibt in die Antwort', async () => {
const serverInfo = makeServerInfo();
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
serverInfo as any,
);
const res = { end: vi.fn() } as any;
await controller.getServer(userReq('t1', 'u1'));
await controller.getServerLogo(userReq('t1', 'u1'), res);
expect(serverInfo.getServerInfo).toHaveBeenCalledWith('t1');
expect(serverInfo.sendLogo).toHaveBeenCalledWith(res, 't1');
await expect(controller.getServer(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => {
const account = makeAccount();
const controller = new NextcloudFilesController(
@@ -400,6 +434,7 @@ describe('NextcloudFilesController — Delegation', () => {
account as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
);
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
ForbiddenException,
@@ -41,6 +41,7 @@ import {
NextcloudFilesTransferService,
type RawUploadRequest,
} from './nextcloud-files-transfer.service';
import { NextcloudServerInfoService } from './nextcloud-server-info';
/**
* `@UseModule('nextcloud-files')` auf Klassenebene — Aktivierung UND Freigabe.
@@ -85,6 +86,7 @@ export class NextcloudFilesController {
private readonly account: NextcloudFilesAccountService,
private readonly files: NextcloudFilesService,
private readonly transfer: NextcloudFilesTransferService,
private readonly serverInfo: NextcloudServerInfoService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
@@ -109,6 +111,18 @@ export class NextcloudFilesController {
return this.account.getStatus(this.requireTenantId(req), this.requireUserId(req));
}
/** Name, Themenfarbe und Logo der Nextcloud fuer den Anmeldebildschirm (ohne Zugangsdaten). */
@Get('server')
async getServer(@Req() req: AuthenticatedRequest) {
return this.serverInfo.getServerInfo(this.requireTenantId(req));
}
/** Das Nextcloud-Logo als Bild (Bildtyp aus den Bytes, CSP-Sandbox); `<img>` laedt mit dem Cookie. */
@Get('server/logo')
async getServerLogo(@Req() req: AuthenticatedRequest, @Res() res: Response): Promise<void> {
await this.serverInfo.sendLogo(res, this.requireTenantId(req));
}
@Get('settings')
@ModuleManage('nextcloud-files')
async getSettings(@Req() req: AuthenticatedRequest) {
@@ -14,6 +14,7 @@ import {
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
import { NextcloudServerInfoService } from './nextcloud-server-info';
/**
* Modul "Dateien" (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in
@@ -30,6 +31,7 @@ import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
NextcloudFilesAccountService,
NextcloudFilesService,
NextcloudFilesTransferService,
NextcloudServerInfoService,
NextcloudLoginGuard,
LoginFlowStore,
NextcloudCallGate,
@@ -0,0 +1,281 @@
import { Readable } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
import {
LOGO_MAX_BYTES,
NextcloudServerInfoService,
SERVER_INFO_TTL_MS,
sniffLogoType,
} from './nextcloud-server-info';
const BASE = 'https://cloud.example/nc';
const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.from('rest')]);
const SVG = Buffer.from('<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>');
interface Reply {
status?: number;
body?: Buffer | string;
headers?: Record<string, string>;
}
/** Attrappe: Antworten je Pfadende; unbekannte Pfade antworten 404. */
function setup(replies: Record<string, Reply | 'fail'>, baseUrl: string | null = BASE) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
const path = new URL(req.url).pathname.replace('/nc', '');
const reply = replies[path];
if (reply === 'fail') throw Object.assign(new Error('x'), { code: 'ECONNREFUSED' });
const r = reply ?? { status: 404 };
return {
statusCode: r.status ?? 200,
headers: r.headers ?? {},
body: Readable.from(r.body === undefined ? [] : [Buffer.from(r.body)]),
};
};
const listeners: ((t: string) => void)[] = [];
const settings = {
getBaseUrl: vi.fn(async (_t: string) => baseUrl),
onAddressChange: (l: (t: string) => void) => listeners.push(l),
};
const gate = new NextcloudCallGate();
const service = new NextcloudServerInfoService(settings as any, gate, transport);
let clock = 1_000_000;
service.now = () => clock;
return {
service,
gate,
calls,
listeners,
settings,
advance: (ms: number) => {
clock += ms;
},
};
}
const STATUS = {
'/status.php': {
body: JSON.stringify({
installed: true,
productname: 'Firmen-Cloud',
versionstring: '34.0.4',
}),
},
};
const CAPS = (color: unknown) => ({
'/ocs/v2.php/cloud/capabilities': {
body: JSON.stringify({ ocs: { data: { capabilities: { theming: { color } } } } }),
},
});
function fakeRes() {
const headers: Record<string, string> = {};
const res: any = {
statusCode: 0,
status(code: number) {
res.statusCode = code;
return res;
},
setHeader(name: string, value: string) {
headers[name.toLowerCase()] = value;
},
end: vi.fn(),
};
return { res, headers };
}
describe('NextcloudServerInfoService', () => {
it('liefert Name aus status.php, Farbe, Version und Logo-Vorhandensein', async () => {
const { service } = setup({
...STATUS,
...CAPS('#0082C9'),
'/index.php/apps/theming/image/logo': { body: PNG },
});
expect(await service.getServerInfo('t1')).toEqual({
host: 'cloud.example',
name: 'Firmen-Cloud',
color: '#0082c9',
version: '34.0.4',
hasLogo: true,
});
});
it('ruft nur feste Pfade der Basisadresse auf (kein Cookie, keine Zugangsdaten)', async () => {
const { service, calls } = setup({
...STATUS,
...CAPS('#0082c9'),
'/index.php/apps/theming/image/logo': { body: PNG },
});
await service.getServerInfo('t1');
expect(calls.map((c) => c.url)).toEqual([
'https://cloud.example/nc/status.php',
'https://cloud.example/nc/ocs/v2.php/cloud/capabilities?format=json',
'https://cloud.example/nc/index.php/apps/theming/image/logo',
]);
for (const c of calls) {
expect(c.headers.authorization).toBeUndefined();
expect(c.headers.cookie).toBeUndefined();
}
});
it('ohne productname faellt der Name auf den Rechnernamen zurueck', async () => {
const { service } = setup({
'/status.php': { body: JSON.stringify({ installed: true, versionstring: '34.0.4' }) },
});
const info = await service.getServerInfo('t1');
expect(info.name).toBe('cloud.example');
expect(info.version).toBe('34.0.4');
});
it.each([
['rot', 'red'],
['kurz', '#fff'],
['mit Skript', '#00000;"><script>'],
['Zahl', 12],
['leer', ''],
])('Farbe %s wird nicht uebernommen', async (_n, color) => {
const { service } = setup({ ...STATUS, ...CAPS(color) });
expect((await service.getServerInfo('t1')).color).toBeNull();
});
it('capabilities scheitert: Farbe null, kein Fehler, Name bleibt', async () => {
const { service } = setup({ ...STATUS, '/ocs/v2.php/cloud/capabilities': 'fail' });
const info = await service.getServerInfo('t1');
expect(info.color).toBeNull();
expect(info.name).toBe('Firmen-Cloud');
});
it('Logo: erst Theming, dann das Standardlogo, sonst hasLogo false', async () => {
const both = setup({
...STATUS,
'/index.php/apps/theming/image/logo': { status: 404 },
'/core/img/logo/logo.svg': { body: SVG },
});
expect((await both.service.getServerInfo('t1')).hasLogo).toBe(true);
expect(both.calls.map((c) => new URL(c.url).pathname)).toEqual(
expect.arrayContaining(['/nc/index.php/apps/theming/image/logo', '/nc/core/img/logo/logo.svg']),
);
const none = setup({ ...STATUS });
expect((await none.service.getServerInfo('t1')).hasLogo).toBe(false);
});
it('Logo wird nur nach den ersten Bytes erkannt, nicht nach dem Antwortkopf', async () => {
const { service } = setup({
...STATUS,
'/index.php/apps/theming/image/logo': {
body: '<html><script>alert(1)</script></html>',
headers: { 'content-type': 'image/png' },
},
});
expect((await service.getServerInfo('t1')).hasLogo).toBe(false);
});
it('Logo ueber 512 KiB wird verworfen', async () => {
const big = Buffer.concat([PNG, Buffer.alloc(LOGO_MAX_BYTES)]);
const { service } = setup({
...STATUS,
'/index.php/apps/theming/image/logo': { body: big },
});
expect((await service.getServerInfo('t1')).hasLogo).toBe(false);
});
it('sniffLogoType erkennt PNG, JPEG, GIF, WebP und SVG, sonst null', () => {
expect(sniffLogoType(PNG)).toBe('image/png');
expect(sniffLogoType(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0]))).toBe('image/jpeg');
expect(sniffLogoType(Buffer.from('GIF89a....'))).toBe('image/gif');
expect(sniffLogoType(Buffer.from('RIFF\0\0\0\0WEBPVP8 '))).toBe('image/webp');
expect(sniffLogoType(SVG)).toBe('image/svg+xml');
expect(sniffLogoType(Buffer.from(' \n<svg viewBox="0 0 1 1"/>'))).toBe('image/svg+xml');
expect(sniffLogoType(Buffer.from('<?xml version="1.0"?><note/>'))).toBeNull();
expect(sniffLogoType(Buffer.from('<html></html>'))).toBeNull();
expect(sniffLogoType(Buffer.alloc(0))).toBeNull();
});
it('merkt sich das Ergebnis 10 Minuten, danach wird neu gefragt', async () => {
const { service, calls, advance } = setup({ ...STATUS, ...CAPS('#0082c9') });
await service.getServerInfo('t1');
const first = calls.length;
await service.getServerInfo('t1');
expect(calls.length).toBe(first);
advance(SERVER_INFO_TTL_MS + 1);
await service.getServerInfo('t1');
expect(calls.length).toBeGreaterThan(first);
});
it('ein Adresswechsel leert die gemerkte Kennung', async () => {
const { service, calls, listeners } = setup({ ...STATUS });
await service.getServerInfo('t1');
const first = calls.length;
for (const l of listeners) l('t1');
await service.getServerInfo('t1');
expect(calls.length).toBeGreaterThan(first);
});
it('ein Adresswechsel einer anderen Organisation laesst die gemerkte Kennung stehen', async () => {
const { service, calls, listeners } = setup({ ...STATUS });
await service.getServerInfo('t1');
const first = calls.length;
for (const l of listeners) l('t2');
await service.getServerInfo('t1');
expect(calls.length).toBe(first);
});
it('gesperrter Ursprung: nur der Rechnername, keine einzige Anfrage', async () => {
const { service, gate, calls } = setup({ ...STATUS });
gate.pause('https://cloud.example');
expect(await service.getServerInfo('t1')).toEqual({
host: 'cloud.example',
name: 'cloud.example',
color: null,
version: null,
hasLogo: false,
});
expect(calls).toHaveLength(0);
});
it('ein 429 mitten in der Abfrage wird nicht gemerkt', async () => {
const { service, gate, calls } = setup({
'/status.php': { status: 429, headers: { 'retry-after': '60' } },
});
const info = await service.getServerInfo('t1');
expect(info.name).toBe('cloud.example');
expect(gate.isPaused('https://cloud.example').paused).toBe(true);
// nach der Sperre wird neu gefragt (nichts gemerkt)
expect(calls).toHaveLength(1);
});
it('ohne Adresse: 409 notConfigured', async () => {
const { service } = setup({}, null);
await expect(service.getServerInfo('t1')).rejects.toMatchObject({ status: 409 });
});
it('sendLogo: Bytes mit CSP-Sandbox, nosniff und privatem Cache, Typ aus den Bytes', async () => {
const { service } = setup({
...STATUS,
'/index.php/apps/theming/image/logo': {
body: SVG,
headers: { 'content-type': 'text/html', 'set-cookie': 'x=1' },
},
});
const { res, headers } = fakeRes();
await service.sendLogo(res, 't1');
expect(res.statusCode).toBe(200);
expect(headers['content-type']).toBe('image/svg+xml');
expect(headers['content-security-policy']).toBe(
"default-src 'none'; style-src 'unsafe-inline'; sandbox",
);
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['cache-control']).toBe('private, max-age=3600');
expect(headers['set-cookie']).toBeUndefined();
expect(res.end).toHaveBeenCalledWith(SVG);
});
it('sendLogo ohne Logo: 404', async () => {
const { service } = setup({ ...STATUS });
const { res } = fakeRes();
await expect(service.sendLogo(res, 't1')).rejects.toMatchObject({ status: 404 });
expect(res.end).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,295 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { parseNextcloudStatus } from '../nextcloud-status/nextcloud-status-fetch';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { ncErrorDefault } from './nextcloud-files.types';
import {
discardBody,
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
ncRequest,
readCappedText,
} from './nextcloud-http';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
/**
* Kennung der Nextcloud fuer den Anmeldebildschirm (quick-261008-mzu, L-09):
* Name, Themenfarbe und Logo der Nextcloud, damit der Benutzer sieht, WO er sich
* anmeldet. Alles ohne Zugangsdaten und nur ueber die feste Basisadresse:
* - Name: `status.php` (`productname`), sonst der Rechnername.
* - Farbe: anonyme `ocs/v2.php/cloud/capabilities`, `theming.color`, nur als
* `#rrggbb` uebernommen.
* - Logo: genau `/index.php/apps/theming/image/logo`, dann
* `/core/img/logo/logo.svg`. Nie eine Adresse aus einer Antwort. Der
* Bildtyp wird an den ersten Bytes erkannt (nie am Antwortkopf), hoechstens
* 512 KiB; SVG nur mit CSP-Sandbox ausgeliefert (kein Skript im Logo).
*
* Das Ergebnis liegt 10 Minuten im Arbeitsspeicher (je Organisation und
* Adresse); ein Adresswechsel leert es. Steht der Ursprung wegen eines 429 auf
* der Aufrufsperre, geht keine Anfrage raus, es gibt nur den Rechnernamen.
*/
export interface NextcloudServerInfo {
host: string;
name: string;
/** `#rrggbb` aus dem Nextcloud-Theming oder `null`. */
color: string | null;
version: string | null;
hasLogo: boolean;
}
interface LogoImage {
contentType: string;
bytes: Buffer;
}
interface CacheEntry {
info: NextcloudServerInfo;
logo: LogoImage | null;
expiresAt: number;
}
export const SERVER_INFO_TTL_MS = 10 * 60 * 1000;
/** Kurze Haltezeit, wenn schon der Name nicht ermittelt werden konnte (vorübergehende Stoerung). */
export const SERVER_INFO_DEGRADED_TTL_MS = 60 * 1000;
export const LOGO_MAX_BYTES = 512 * 1024;
const STATUS_MAX_BYTES = 64 * 1024;
const CAPABILITIES_MAX_BYTES = 1024 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_CACHE_ENTRIES = 500;
const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
/** Bildtyp an den ersten Bytes erkennen; `null`, wenn es kein erlaubtes Bild ist. */
export function sniffLogoType(bytes: Buffer): string | null {
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
return 'image/png';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('latin1'))) {
return 'image/gif';
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
bytes.subarray(8, 12).toString('latin1') === 'WEBP'
) {
return 'image/webp';
}
// SVG ist Text: nach BOM und Leerraum muss `<svg` oder `<?xml` kommen.
const head = bytes
.subarray(0, 256)
.toString('utf8')
.replace(/^/, '')
.trimStart();
if (head.startsWith('<svg') || head.startsWith('<?xml')) {
// `<?xml` allein genuegt nicht: irgendwo im Anfang muss ein svg-Element stehen.
if (head.startsWith('<svg') || /<svg[\s>]/.test(bytes.subarray(0, 4096).toString('utf8'))) {
return 'image/svg+xml';
}
}
return null;
}
async function readCappedBytes(
body: AsyncIterable<Uint8Array | string> & { destroy?: (err?: Error) => unknown },
maxBytes: number,
): Promise<Buffer | null> {
const chunks: Buffer[] = [];
let total = 0;
try {
for await (const chunk of body) {
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk);
total += buf.byteLength;
if (total > maxBytes) {
try {
body.destroy?.();
} catch {
// schon beendet
}
return null;
}
chunks.push(buf);
}
} catch {
return null;
}
return Buffer.concat(chunks);
}
function hostOf(baseUrl: string): string {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl;
}
}
@Injectable()
export class NextcloudServerInfoService {
/** Zeitquelle in Millisekunden; Tests ersetzen sie. */
now: () => number = () => Date.now();
private readonly cache = new Map<string, CacheEntry>();
constructor(
private readonly settings: NextcloudFilesSettingsService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {
// Ein Adresswechsel macht die zwischengespeicherte Kennung ungueltig.
this.settings.onAddressChange((tenantId) => this.clearTenant(tenantId));
}
clearTenant(tenantId: string): void {
const prefix = `${tenantId}:`;
for (const key of [...this.cache.keys()]) {
if (key.startsWith(prefix)) this.cache.delete(key);
}
}
/** Name, Farbe, Version und Logo-Vorhandensein der Nextcloud der Organisation. */
async getServerInfo(tenantId: string): Promise<NextcloudServerInfo> {
return (await this.load(tenantId)).info;
}
/** Schreibt das Logo als Antwort (404 `notFound`, wenn keines da ist). */
async sendLogo(res: Response, tenantId: string): Promise<void> {
const { logo } = await this.load(tenantId);
if (!logo) throw ncErrorDefault('notFound');
res.status(200);
res.setHeader('Content-Type', logo.contentType);
res.setHeader('Content-Length', String(logo.bytes.length));
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.end(logo.bytes);
}
private async load(tenantId: string): Promise<CacheEntry> {
const baseUrl = await this.settings.getBaseUrl(tenantId);
if (baseUrl === null) throw ncErrorDefault('notConfigured');
const key = `${tenantId}:${baseUrl}`;
const cached = this.cache.get(key);
if (cached && cached.expiresAt > this.now()) return cached;
const host = hostOf(baseUrl);
const hostOnly = (): CacheEntry => ({
info: { host, name: host, color: null, version: null, hasLogo: false },
logo: null,
expiresAt: 0,
});
// Steht der Ursprung auf der Aufrufsperre, geht keine Anfrage raus (und nichts wird gemerkt).
const origin = new URL(baseUrl).origin;
if (this.gate.isPaused(origin).paused) return hostOnly();
const status = await this.fetchStatus(baseUrl);
const color = await this.fetchColor(baseUrl);
const logo = await this.fetchLogo(baseUrl);
const entry: CacheEntry = {
info: {
host,
name: status?.productName ?? host,
color,
version: status?.versionString ?? null,
hasLogo: logo !== null,
},
logo,
expiresAt:
this.now() + (status ? SERVER_INFO_TTL_MS : SERVER_INFO_DEGRADED_TTL_MS),
};
// Ein 429 mitten in der Abfrage: nicht merken, damit nach der Sperre neu gefragt wird.
if (!this.gate.isPaused(origin).paused) this.remember(key, entry);
return entry;
}
private remember(key: string, entry: CacheEntry): void {
this.cache.delete(key);
this.cache.set(key, entry);
while (this.cache.size > MAX_CACHE_ENTRIES) {
const oldest = this.cache.keys().next().value;
if (oldest === undefined) break;
this.cache.delete(oldest);
}
}
private async fetchStatus(baseUrl: string) {
const res = await ncRequest(this.transport, this.gate, {
baseUrl,
prefix: '/status.php',
method: 'GET',
headers: { accept: 'application/json' },
headersTimeoutMs: REQUEST_TIMEOUT_MS,
bodyTimeoutMs: REQUEST_TIMEOUT_MS,
});
if (!res.ok) return null;
if (res.status !== 200) {
discardBody(res.body);
return null;
}
const text = await readCappedText(res.body, STATUS_MAX_BYTES);
return text.ok ? parseNextcloudStatus(text.text) : null;
}
private async fetchColor(baseUrl: string): Promise<string | null> {
const res = await ncRequest(this.transport, this.gate, {
baseUrl,
prefix: '/ocs/v2.php/',
segments: ['cloud', 'capabilities'],
query: { format: 'json' },
method: 'GET',
ocs: true,
headersTimeoutMs: REQUEST_TIMEOUT_MS,
bodyTimeoutMs: REQUEST_TIMEOUT_MS,
});
if (!res.ok) return null;
if (res.status !== 200) {
discardBody(res.body);
return null;
}
const text = await readCappedText(res.body, CAPABILITIES_MAX_BYTES);
if (!text.ok) return null;
try {
const json = JSON.parse(text.text) as {
ocs?: { data?: { capabilities?: { theming?: { color?: unknown } } } };
};
const color = json?.ocs?.data?.capabilities?.theming?.color;
return typeof color === 'string' && COLOR_PATTERN.test(color) ? color.toLowerCase() : null;
} catch {
return null;
}
}
private async fetchLogo(baseUrl: string): Promise<LogoImage | null> {
const candidates: { prefix: '/index.php/apps/theming/image/logo' | '/core/img/logo/logo.svg' }[] =
[{ prefix: '/index.php/apps/theming/image/logo' }, { prefix: '/core/img/logo/logo.svg' }];
for (const { prefix } of candidates) {
const res = await ncRequest(this.transport, this.gate, {
baseUrl,
prefix,
method: 'GET',
headersTimeoutMs: REQUEST_TIMEOUT_MS,
bodyTimeoutMs: REQUEST_TIMEOUT_MS,
});
if (!res.ok) {
// Pause, Netz- oder Zertifikatsproblem: der zweite Pfad wuerde dasselbe erleben.
if (res.kind === 'paused' || res.kind === 'network' || res.kind === 'tls') return null;
continue;
}
if (res.status !== 200) {
discardBody(res.body);
continue;
}
const bytes = await readCappedBytes(res.body, LOGO_MAX_BYTES);
if (!bytes || bytes.length === 0) continue;
const contentType = sniffLogoType(bytes);
if (contentType) return { contentType, bytes };
}
return null;
}
}