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 `]/.test(bytes.subarray(0, 4096).toString('utf8'))) { return 'image/svg+xml'; } } return null; } async function readCappedBytes( body: AsyncIterable & { destroy?: (err?: Error) => unknown }, maxBytes: number, ): Promise { 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(); 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 { return (await this.load(tenantId)).info; } /** Schreibt das Logo als Antwort (404 `notFound`, wenn keines da ist). */ async sendLogo(res: Response, tenantId: string): Promise { 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 { 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 { 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 { 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; } }