630398fe93
- 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>
296 lines
9.9 KiB
TypeScript
296 lines
9.9 KiB
TypeScript
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;
|
||
}
|
||
}
|