Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-server-info.ts
T
schalli 630398fe93 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>
2026-10-08 22:08:57 +02:00

296 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}