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:
@@ -145,6 +145,8 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
// Spätere Aufgaben von quick-261008-mzu ergänzen diese Liste um ihre Benutzen-Handler.
|
||||
it.each([
|
||||
'getStatus',
|
||||
'getServer',
|
||||
'getServerLogo',
|
||||
'connectPassword',
|
||||
'startFlow',
|
||||
'pollFlow',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
disconnectNextcloud,
|
||||
type NextcloudFilesAccount,
|
||||
NextcloudFilesRequestError,
|
||||
type NextcloudServerInfo,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import { ServerTile } from './ServerIdentity';
|
||||
|
||||
/**
|
||||
* Kontoleiste im Seitenkopf (quick-261008-mzu): zeigt, mit welchem Nextcloud-Konto
|
||||
@@ -16,10 +18,13 @@ import {
|
||||
export function AccountBar({
|
||||
account,
|
||||
host,
|
||||
info = null,
|
||||
onDisconnected,
|
||||
}: {
|
||||
account: NextcloudFilesAccount;
|
||||
host: string | null;
|
||||
/** Kennung der Nextcloud fuer die kleine Kachel; ohne sie zeigt die Kachel nur den Anfangsbuchstaben. */
|
||||
info?: NextcloudServerInfo | null;
|
||||
onDisconnected: () => void;
|
||||
}) {
|
||||
const t = useTranslations('nextcloudFiles.account');
|
||||
@@ -86,6 +91,9 @@ export function AccountBar({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span title={info?.name ?? undefined} className="inline-flex">
|
||||
<ServerTile info={info} size="sm" />
|
||||
</span>
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-sm font-medium text-foreground">{t('signedInAs', { name })}</p>
|
||||
{host && <p className="truncate text-xs text-muted-foreground">{host}</p>}
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
connectWithPassword,
|
||||
type NextcloudFilesFlowStart,
|
||||
NextcloudFilesRequestError,
|
||||
type NextcloudServerInfo,
|
||||
pollLoginFlow,
|
||||
startLoginFlow,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import { LockIcon } from './icons';
|
||||
import { ServerIdentity } from './ServerIdentity';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
@@ -36,10 +39,13 @@ interface ErrorState {
|
||||
*/
|
||||
export function ConnectPanel({
|
||||
host,
|
||||
info = null,
|
||||
expired,
|
||||
onConnected,
|
||||
}: {
|
||||
host: string | null;
|
||||
/** Name, Farbe und Logo der Nextcloud; solange sie fehlen, steht nur der Rechnername da. */
|
||||
info?: NextcloudServerInfo | null;
|
||||
expired: boolean;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
@@ -213,119 +219,137 @@ export function ConnectPanel({
|
||||
const primaryClass = 'btn btn-primary w-full justify-center';
|
||||
const secondaryClass = 'btn btn-secondary w-full justify-center';
|
||||
|
||||
const accent = info?.color ?? null;
|
||||
|
||||
return (
|
||||
<div data-testid="nextcloud-files-connect" className="mx-auto w-full max-w-lg">
|
||||
<div className="surface space-y-5 p-6">
|
||||
<div>
|
||||
{host && <p className="text-xs font-medium text-muted-foreground">{host}</p>}
|
||||
<h2 className="mt-0.5 text-lg font-semibold text-foreground">{t('title')}</h2>
|
||||
<div className="surface overflow-hidden">
|
||||
<div
|
||||
className="border-b border-border bg-well px-6 py-5"
|
||||
style={accent ? { boxShadow: `inset 0 3px 0 ${accent}` } : undefined}
|
||||
>
|
||||
<ServerIdentity info={info} host={host} />
|
||||
</div>
|
||||
|
||||
{expired && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-status-warn/40 bg-status-warn/12 px-3 py-2 text-sm text-status-warn-fg"
|
||||
>
|
||||
{t('expiredNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{flow ? (
|
||||
<div className="space-y-4" data-testid="nextcloud-files-flow">
|
||||
<p className="text-sm text-foreground">{t('waiting.instruction')}</p>
|
||||
<a
|
||||
href={flow.loginUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary w-full justify-center"
|
||||
>
|
||||
{t('waiting.open')}
|
||||
</a>
|
||||
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full bg-primary-strong motion-safe:animate-pulse"
|
||||
/>
|
||||
{t('waiting.status')}
|
||||
</p>
|
||||
<button type="button" className="btn btn-subtle" onClick={() => void cancel()}>
|
||||
{t('waiting.cancel')}
|
||||
</button>
|
||||
<div className="space-y-5 p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('title')}</h2>
|
||||
{!flow && <p className="mt-1 text-sm text-muted-foreground">{t('explanation')}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('explanation')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="nc-files-login-name"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t('loginName')}
|
||||
</label>
|
||||
<input
|
||||
id="nc-files-login-name"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={loginName}
|
||||
onChange={(e) => setLoginName(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="nc-files-password"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t('password')}
|
||||
</label>
|
||||
<input
|
||||
id="nc-files-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errorText(error)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className={preferBrowser ? secondaryClass : primaryClass}
|
||||
disabled={busy || loginName.trim() === '' || password === ''}
|
||||
>
|
||||
{busy ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
{expired && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-status-warn/40 bg-status-warn/12 px-3 py-2 text-sm text-status-warn-fg"
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t('or')}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{t('expiredNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={preferBrowser ? primaryClass : secondaryClass}
|
||||
disabled={starting || busy}
|
||||
onClick={() => void begin()}
|
||||
{flow ? (
|
||||
<div className="space-y-4" data-testid="nextcloud-files-flow">
|
||||
<p className="text-sm text-foreground">{t('waiting.instruction')}</p>
|
||||
<a
|
||||
href={flow.loginUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary w-full justify-center"
|
||||
>
|
||||
{starting ? t('starting') : t('browser')}
|
||||
{t('waiting.open')}
|
||||
</a>
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2.5 rounded-md bg-well px-3 py-2.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full bg-primary-strong motion-safe:animate-pulse"
|
||||
/>
|
||||
{t('waiting.status')}
|
||||
</p>
|
||||
<button type="button" className="btn btn-subtle" onClick={() => void cancel()}>
|
||||
{t('waiting.cancel')}
|
||||
</button>
|
||||
<p className="text-center text-xs text-muted-foreground">{t('browserHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={(e) => void submit(e)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="nc-files-login-name"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t('loginName')}
|
||||
</label>
|
||||
<input
|
||||
id="nc-files-login-name"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={loginName}
|
||||
onChange={(e) => setLoginName(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="nc-files-password"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t('password')}
|
||||
</label>
|
||||
<input
|
||||
id="nc-files-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errorText(error)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className={preferBrowser ? secondaryClass : primaryClass}
|
||||
disabled={busy || loginName.trim() === '' || password === ''}
|
||||
>
|
||||
{busy ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t('or')}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={preferBrowser ? primaryClass : secondaryClass}
|
||||
disabled={starting || busy}
|
||||
onClick={() => void begin()}
|
||||
>
|
||||
{starting ? t('starting') : t('browser')}
|
||||
</button>
|
||||
<p className="text-center text-xs text-muted-foreground">{t('browserHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="flex items-start gap-2.5 border-t border-border bg-well px-6 py-4 text-xs leading-relaxed text-muted-foreground">
|
||||
<LockIcon size={14} className="mt-0.5 shrink-0" />
|
||||
{t('privacy')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -836,7 +836,9 @@ export function FileBrowser({
|
||||
ref={rootRef}
|
||||
aria-label={t('regionLabel')}
|
||||
onKeyDown={onKeyDown}
|
||||
className="surface relative max-sm:-mx-3 max-sm:rounded-none"
|
||||
// Auffangpunkt fuer den Fokus, wenn es keine Zeile gibt (leerer Ordner): so geht die Rücktaste auch dort.
|
||||
tabIndex={-1}
|
||||
className="nc-files-root surface relative max-sm:-mx-3 max-sm:rounded-none"
|
||||
>
|
||||
<div
|
||||
ref={barRef}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { readableOnAccent } from '@/lib/color';
|
||||
import { type NextcloudServerInfo, serverLogoUrl } from '@/lib/nextcloud-files-api';
|
||||
|
||||
const TILE = {
|
||||
lg: { box: 'h-12 w-12 rounded-xl', pad: 'p-2', letter: 'text-xl' },
|
||||
sm: { box: 'h-8 w-8 rounded-lg', pad: 'p-1.5', letter: 'text-sm' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Kachel der Nextcloud (quick-261008-mzu, L-09): Themenfarbe der Nextcloud mit
|
||||
* deren Logo. Ohne Logo (oder wenn es nicht laedt) steht der erste Buchstabe des
|
||||
* Namens in einer lesbaren Schriftfarbe auf der Kachel; ohne Themenfarbe bleibt
|
||||
* die Kachel neutral (`bg-tile`).
|
||||
*/
|
||||
export function ServerTile({
|
||||
info,
|
||||
size = 'lg',
|
||||
}: {
|
||||
info: NextcloudServerInfo | null;
|
||||
size?: 'lg' | 'sm';
|
||||
}) {
|
||||
const name = info?.name?.trim() || 'Nextcloud';
|
||||
const color = info?.color ?? null;
|
||||
const logoToken = info ? `${info.host}-${info.version ?? ''}-${info.color ?? ''}` : undefined;
|
||||
const [logoFailed, setLogoFailed] = useState(false);
|
||||
// Neue Nextcloud (oder neu geladene Kennung): dem Logo eine neue Chance geben.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: nur das Logo-Merkmal setzt den Fehler zurueck
|
||||
useEffect(() => setLogoFailed(false), [logoToken]);
|
||||
|
||||
const t = TILE[size];
|
||||
const showLogo = info?.hasLogo === true && !logoFailed;
|
||||
const letter = [...name][0]?.toUpperCase() ?? 'N';
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="nc-server-tile"
|
||||
className={`relative inline-flex shrink-0 items-center justify-center overflow-hidden ${t.box} ${
|
||||
color ? '' : 'bg-tile text-tile-foreground'
|
||||
}`}
|
||||
style={color ? { backgroundColor: color, color: readableOnAccent(color) } : undefined}
|
||||
>
|
||||
{showLogo ? (
|
||||
// biome-ignore lint/performance/noImgElement: Logo kommt ueber den Tessera-Proxy, next/image passt nicht
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall
|
||||
<img
|
||||
src={serverLogoUrl(logoToken)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
className={`h-full w-full object-contain ${t.pad}`}
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true" className={`font-semibold leading-none ${t.letter}`}>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kennung der Nextcloud: Kachel, daneben Name und Rechnername (so sieht man, WO man sich anmeldet). */
|
||||
export function ServerIdentity({
|
||||
info,
|
||||
host,
|
||||
}: {
|
||||
info: NextcloudServerInfo | null;
|
||||
/** Rechnername aus dem Status, solange die Kennung noch nicht geladen ist. */
|
||||
host: string | null;
|
||||
}) {
|
||||
const name = info?.name?.trim() || 'Nextcloud';
|
||||
const shownHost = info?.host ?? host;
|
||||
return (
|
||||
<div data-testid="nc-server-identity" className="flex min-w-0 items-center gap-3">
|
||||
<ServerTile info={info} size="lg" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-base font-semibold leading-tight text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
{shownHost && (
|
||||
<span className="block truncate text-xs text-muted-foreground">{shownHost}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,6 +149,18 @@ export const RetryIcon = (p: P) => (
|
||||
<path d="M8 16H3v5" />
|
||||
</Icon>
|
||||
);
|
||||
export const LockIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</Icon>
|
||||
);
|
||||
export const CloudIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const FileGlyph = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
NextcloudFilesRequestError,
|
||||
type NextcloudFilesSettings,
|
||||
type NextcloudFilesStatus,
|
||||
type NextcloudServerInfo,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import de from '@/messages/de.json';
|
||||
import NextcloudFilesPage from './page';
|
||||
@@ -37,6 +38,7 @@ const mockPollFlow = vi.fn();
|
||||
const mockCancelFlow = vi.fn();
|
||||
const mockDisconnect = vi.fn();
|
||||
const mockListFolder = vi.fn();
|
||||
const mockGetServerInfo = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||
@@ -52,6 +54,7 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
cancelLoginFlow: (...args: unknown[]) => mockCancelFlow(...args),
|
||||
disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args),
|
||||
listFolder: (...args: unknown[]) => mockListFolder(...args),
|
||||
getServerInfo: (...args: unknown[]) => mockGetServerInfo(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -70,6 +73,17 @@ function status(over: Partial<NextcloudFilesStatus> = {}): NextcloudFilesStatus
|
||||
};
|
||||
}
|
||||
|
||||
function serverInfo(over: Partial<NextcloudServerInfo> = {}): NextcloudServerInfo {
|
||||
return {
|
||||
host: 'cloud.example',
|
||||
name: 'Firmen-Cloud',
|
||||
color: '#0082c9',
|
||||
version: '34.0.4',
|
||||
hasLogo: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function settings(over: Partial<NextcloudFilesSettings> = {}): NextcloudFilesSettings {
|
||||
return { baseUrl: 'https://cloud.example', connectedAccounts: 0, ...over };
|
||||
}
|
||||
@@ -85,6 +99,7 @@ beforeEach(() => {
|
||||
mockPollFlow.mockReset();
|
||||
mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true });
|
||||
mockDisconnect.mockReset();
|
||||
mockGetServerInfo.mockReset().mockResolvedValue(serverInfo());
|
||||
mockListFolder.mockReset().mockResolvedValue({
|
||||
path: '/',
|
||||
entries: [
|
||||
@@ -518,3 +533,58 @@ describe('Dateiansicht auf der Seite', () => {
|
||||
expect(mockGetStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kennung der Nextcloud (Aufgabe 6)', () => {
|
||||
it('Anmeldebildschirm zeigt Name und Rechnername, die Kachel in der Themenfarbe, ohne Logo den Anfangsbuchstaben', async () => {
|
||||
render(<NextcloudFilesPage />);
|
||||
const identity = await screen.findByTestId('nc-server-identity');
|
||||
await waitFor(() => expect(within(identity).getByText('Firmen-Cloud')).toBeTruthy());
|
||||
expect(within(identity).getByText('cloud.example')).toBeTruthy();
|
||||
const tile = within(identity).getByTestId('nc-server-tile') as HTMLElement;
|
||||
expect(tile.style.backgroundColor).toBe('rgb(0, 130, 201)');
|
||||
expect(within(tile).getByText('F')).toBeTruthy();
|
||||
expect(tile.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('mit Logo steht ein Bild von der Tessera-Adresse in der Kachel; ein Ladefehler faellt auf den Buchstaben zurueck', async () => {
|
||||
mockGetServerInfo.mockResolvedValue(serverInfo({ hasLogo: true }));
|
||||
render(<NextcloudFilesPage />);
|
||||
const tile = (await screen.findByTestId('nc-server-tile')) as HTMLElement;
|
||||
await waitFor(() => expect(tile.querySelector('img')).toBeTruthy());
|
||||
const img = tile.querySelector('img') as HTMLImageElement;
|
||||
expect(img.getAttribute('src')).toContain('/modules/nextcloud-files/server/logo');
|
||||
fireEvent.error(img);
|
||||
await waitFor(() => expect(tile.querySelector('img')).toBeNull());
|
||||
expect(within(tile).getByText('F')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ohne Themenfarbe bleibt die Kachel neutral', async () => {
|
||||
mockGetServerInfo.mockResolvedValue(serverInfo({ color: null }));
|
||||
render(<NextcloudFilesPage />);
|
||||
const tile = (await screen.findByTestId('nc-server-tile')) as HTMLElement;
|
||||
await waitFor(() => expect(within(tile).getByText('F')).toBeTruthy());
|
||||
expect(tile.className).toContain('bg-tile');
|
||||
expect(tile.style.backgroundColor).toBe('');
|
||||
});
|
||||
|
||||
it('scheitert die Kennung, steht nur der Rechnername da', async () => {
|
||||
mockGetServerInfo.mockRejectedValue(new NextcloudFilesRequestError(500, null, 'x'));
|
||||
render(<NextcloudFilesPage />);
|
||||
const identity = await screen.findByTestId('nc-server-identity');
|
||||
expect(within(identity).getByText('cloud.example')).toBeTruthy();
|
||||
expect(within(identity).getByText('Nextcloud')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('die Kontoleiste zeigt die kleine Kachel der Nextcloud', async () => {
|
||||
mockGetStatus.mockResolvedValue(status({ account: account() }));
|
||||
render(<NextcloudFilesPage />);
|
||||
expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy();
|
||||
const tile = (await screen.findByTestId('nc-server-tile')) as HTMLElement;
|
||||
await waitFor(() => expect(tile.style.backgroundColor).toBe('rgb(0, 130, 201)'));
|
||||
});
|
||||
|
||||
it('der Datenschutzhinweis steht am Fuss des Anmeldebildschirms', async () => {
|
||||
render(<NextcloudFilesPage />);
|
||||
expect(await screen.findByText(/Tessera speichert Ihr Passwort nicht/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
|
||||
import {
|
||||
getNextcloudFilesStatus,
|
||||
getServerInfo,
|
||||
type NextcloudFilesStatus,
|
||||
type NextcloudServerInfo,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { AccountBar } from './components/AccountBar';
|
||||
import { ConnectPanel } from './components/ConnectPanel';
|
||||
@@ -31,6 +36,7 @@ export default function NextcloudFilesPage() {
|
||||
const [status, setStatus] = useState<NextcloudFilesStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
const [tab, setTab] = useState<TabId>('files');
|
||||
const [serverInfo, setServerInfo] = useState<NextcloudServerInfo | null>(null);
|
||||
|
||||
const reloadStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -45,6 +51,29 @@ export default function NextcloudFilesPage() {
|
||||
void reloadStatus();
|
||||
}, [reloadStatus]);
|
||||
|
||||
// Name, Farbe und Logo der Nextcloud fuer Anmeldebildschirm und Kontoleiste. Ein Fehler ist
|
||||
// harmlos: ohne Kennung steht der Rechnername da.
|
||||
const configured = status?.configured === true;
|
||||
const serverUrl = status?.serverUrl ?? null;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: eine neue Adresse laedt die Kennung neu
|
||||
useEffect(() => {
|
||||
if (!configured) {
|
||||
setServerInfo(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
getServerInfo()
|
||||
.then((info) => {
|
||||
if (!cancelled) setServerInfo(info);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setServerInfo(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [configured, serverUrl]);
|
||||
|
||||
const account = status?.account ?? null;
|
||||
const connected = account?.connected === true;
|
||||
|
||||
@@ -60,7 +89,12 @@ export default function NextcloudFilesPage() {
|
||||
description={t('description')}
|
||||
actions={
|
||||
activeTab === 'files' && status?.configured && account && connected ? (
|
||||
<AccountBar account={account} host={status.host} onDisconnected={reloadStatus} />
|
||||
<AccountBar
|
||||
account={account}
|
||||
host={status.host}
|
||||
info={serverInfo}
|
||||
onDisconnected={reloadStatus}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
@@ -95,6 +129,7 @@ export default function NextcloudFilesPage() {
|
||||
) : (
|
||||
<ConnectPanel
|
||||
host={status.host}
|
||||
info={serverInfo}
|
||||
expired={account?.expired === true}
|
||||
onConnected={reloadStatus}
|
||||
/>
|
||||
|
||||
@@ -916,6 +916,16 @@ html[data-desktop-os="linux"] .animate-pulse {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Die Dateikarte faengt den Fokus auf, wenn es keine Zeile gibt (leerer Ordner); sie soll dabei
|
||||
keinen Rahmen zeigen, im dunklen Modus aber ihre Haarlinie behalten. */
|
||||
.nc-files-root:focus {
|
||||
outline: none;
|
||||
}
|
||||
.dark .nc-files-root:focus {
|
||||
outline: 1px solid var(--border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Bewegung der Dateiansicht nur als Antwort auf eine Handlung (Auswahl,
|
||||
Ablegen, erste Uebertragung); `motion-safe:` schaltet sie bei reduzierter
|
||||
Bewegung ganz ab. */
|
||||
|
||||
@@ -138,6 +138,29 @@ export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFi
|
||||
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
|
||||
}
|
||||
|
||||
/** Kennung der Nextcloud fuer den Anmeldebildschirm (ohne Zugangsdaten). */
|
||||
export interface NextcloudServerInfo {
|
||||
host: string;
|
||||
name: string;
|
||||
/** `#rrggbb` aus dem Nextcloud-Theming oder `null`. */
|
||||
color: string | null;
|
||||
version: string | null;
|
||||
hasLogo: boolean;
|
||||
}
|
||||
|
||||
export function getServerInfo(): Promise<NextcloudServerInfo> {
|
||||
return request<NextcloudServerInfo>('/server');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Nextcloud-Logos als Bild (`<img>` laedt mit dem Tessera-Cookie). `token`
|
||||
* wechselt, wenn sich die Nextcloud aendert, und umgeht so den Bildzwischenspeicher.
|
||||
*/
|
||||
export function serverLogoUrl(token?: string): string {
|
||||
const version = token ? `?v=${encodeURIComponent(token)}` : '';
|
||||
return `${API_URL}${BASE}/server/logo${version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbinden mit Benutzername und Passwort. Das Passwort geht genau einmal an die
|
||||
* API und wird nirgends zwischengespeichert; die Antwort enthaelt kein Geheimnis.
|
||||
|
||||
@@ -2337,7 +2337,8 @@
|
||||
"connect": {
|
||||
"title": "Mit Nextcloud verbinden",
|
||||
"expiredNotice": "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an.",
|
||||
"explanation": "Melden Sie sich mit Ihrem Nextcloud-Konto an. Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit widerrufen können.",
|
||||
"explanation": "Melden Sie sich mit Ihrem Nextcloud-Konto an.",
|
||||
"privacy": "Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit in Nextcloud widerrufen können.",
|
||||
"loginName": "Benutzername oder E-Mail",
|
||||
"password": "Passwort",
|
||||
"submit": "Anmelden",
|
||||
|
||||
@@ -2337,7 +2337,8 @@
|
||||
"connect": {
|
||||
"title": "Connect to Nextcloud",
|
||||
"expiredNotice": "Your connection to Nextcloud has expired or was revoked in Nextcloud. Please sign in again.",
|
||||
"explanation": "Sign in with your Nextcloud account. Tessera does not store your password; instead it has Nextcloud issue a separate access that you can revoke at any time.",
|
||||
"explanation": "Sign in with your Nextcloud account.",
|
||||
"privacy": "Tessera does not store your password. It asks Nextcloud for a separate access that you can revoke in Nextcloud at any time.",
|
||||
"loginName": "User name or email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
|
||||
Reference in New Issue
Block a user