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
@@ -17,6 +17,7 @@ e2e_set_address "$JAR"
# Sauberer Start: Verbindung trennen (200, oder 409 wenn nicht verbunden).
code=$(e2e_status "$JAR" DELETE "$C/connect")
case "$code" in 200) ;; 409) e2e_contains "$E2E_TMP/body.out" 'notConnected' "Start: notConnected" ;; *) e2e_fail "DELETE connect (Start) -> $code" ;; esac
e2e_nc_purge_tokens anna
e2e_expect 0 "$(e2e_nc_tokens anna)" "Start: keine Tessera-Zugaenge fuer anna"
# 1. Verbinden mit Passwort
@@ -100,6 +100,18 @@ d=json.load(sys.stdin)
print(len([t for t in d if "Tessera" in str(t.get("name",""))]))'
}
# e2e_nc_purge_tokens <nc-benutzer> — entfernt uebrig gebliebene Tessera-Zugaenge in der Nextcloud
# (ein Adresswechsel laesst Konten ablaufen, ohne den Zugang zu widerrufen; bekannte Restlage).
e2e_nc_purge_tokens() {
local id
for id in $(NC_OCC user:auth-tokens:list "$1" --output=json 2>/dev/null \
| python3 -I -c 'import json,sys
for t in json.load(sys.stdin):
if "Tessera" in str(t.get("name","")): print(t["id"])'); do
NC_OCC user:auth-tokens:delete "$1" "$id" >/dev/null 2>&1 || true
done
}
# e2e_connect_anna <jar> — verbindet anna (Passwort-Weg) im Tessera-Benutzer des <jar>.
# Gibt den HTTP-Status aus; der Antwortkoerper landet in $E2E_TMP/body.out.
e2e_connect_anna() {
@@ -11,6 +11,11 @@ S="$API/modules/nextcloud-files"
e2e_login "$JAR"
e2e_activate "$JAR"
# Das Skript ist wiederholbar: ein unveraenderter Wert antwortet bewusst ohne Pruefung, deshalb
# erst auf eine andere Adresse stellen (laesst bestehende Verbindungen ablaufen), dann auf die echte.
code=$(e2e_status "$JAR" PUT "$S/settings" '{"baseUrl":"http://172.17.0.1:18081","confirmReconnect":true}')
e2e_expect 200 "$code" "PUT settings (andere Adresse)"
# Speichern mit Pruefung
code=$(e2e_status "$JAR" PUT "$S/settings" "{\"baseUrl\":\"$NC_BASE\",\"confirmReconnect\":true}")
e2e_expect 200 "$code" "PUT settings"
@@ -22,6 +27,20 @@ e2e_expect 200 "$code" "POST settings/test"
e2e_contains "$E2E_TMP/body.out" '"ok":true' "settings/test ok"
e2e_contains "$E2E_TMP/body.out" '"version":"34\.' "settings/test Version 34"
# Kennung der Nextcloud fuer den Anmeldebildschirm: Name, Rechnername, Farbe, Logo (Aufgabe 6)
code=$(e2e_status "$JAR" GET "$S/server")
e2e_expect 200 "$code" "GET server"
e2e_contains "$E2E_TMP/body.out" '"host":"172.17.0.1:18080"' "server host"
e2e_contains "$E2E_TMP/body.out" '"name":"' "server name"
e2e_contains "$E2E_TMP/body.out" '"version":"34\.' "server version"
if grep -q '"hasLogo":true' "$E2E_TMP/body.out"; then
curl -s -D "$E2E_TMP/logo.head" -o "$E2E_TMP/logo.bin" -b "$JAR" "$S/server/logo"
grep -qi '^HTTP/[0-9.]* 200' "$E2E_TMP/logo.head" || e2e_fail "server/logo: kein 200"
grep -qi "^content-security-policy: default-src 'none'" "$E2E_TMP/logo.head" || e2e_fail "server/logo: CSP fehlt"
grep -qi '^x-content-type-options: nosniff' "$E2E_TMP/logo.head" || e2e_fail "server/logo: nosniff fehlt"
grep -qiE '^content-type: image/(png|jpeg|gif|webp|svg\+xml)' "$E2E_TMP/logo.head" || e2e_fail "server/logo: Bildtyp"
fi
# Nicht erreichbare Adresse: 200 mit ok:false
code=$(e2e_status "$JAR" POST "$S/settings/test" '{"baseUrl":"http://172.17.0.1:1"}')
e2e_expect 200 "$code" "settings/test unerreichbar"
+4
View File
@@ -11,6 +11,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu; das dürfen Benutzer mit der Freigabe „Verwalten“). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren.
- Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar.
- Domains, Registrieren: Verfügbarkeit prüfen, Kontakte wählen (die Nameserver übernimmt Tessera unverändert und in derselben Reihenfolge aus den Standardwerten in AutoDNS; sind dort keine hinterlegt, ist die Registrierung gesperrt), Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen.
- Neues Modul „Dateien“ in der Gruppe „Infrastruktur“: Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera ansehen, hochladen, herunterladen und ordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Administratoren und Benutzer mit der Freigabe „Verwalten“ tragen im Reiter „Einstellungen“ die Adresse der Nextcloud ein und können sie mit „Verbindung prüfen“ testen; wird die Adresse später geändert, müssen sich alle Benutzer neu anmelden.
- Dateien, Anmeldung: Jeder meldet sich mit seinem eigenen Nextcloud-Konto an. Tessera speichert dabei nicht Ihr Passwort, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit in Nextcloud widerrufen können. Konten mit Zwei-Faktor-Anmeldung melden sich über „Im Browser anmelden“: Sie bestätigen den Zugriff auf der Nextcloud-Seite, Tessera wartet so lange. Der Anmeldebildschirm zeigt Name, Logo und Farbe Ihrer Nextcloud, damit Sie sehen, wo Sie sich anmelden. „Abmelden“ entfernt den Zugang auch in Nextcloud; Ihre Dateien bleiben unverändert. Damit nicht ein paar Fehlversuche die Nextcloud für alle sperren, begrenzt Tessera Fehlversuche selbst und hält alle Anfragen an, sobald die Nextcloud „zu viele Anfragen“ meldet.
- Dateien, Arbeiten mit Dateien: Ordner durchsuchen mit Pfadleiste, Liste oder Raster (Tessera merkt sich Ihre Wahl), Farbkacheln je Dateityp, echte Vorschaubilder und Anzeige des belegten Speicherplatzes. Neuer Ordner, Umbenennen, Verschieben (mit Ordnerauswahl) und Löschen – gelöscht wird in den Papierkorb Ihrer Nextcloud, nach einer Rückfrage; alles auch für mehrere Einträge gleichzeitig, per Maus, Zeilenmenü, Rechtsklick oder Tastatur (Enter, Rücktaste, F2, Entf, Strg+A).
- Dateien, Hochladen und Herunterladen: Dateien und ganze Ordner per Ziehen und Ablegen (auch direkt auf einen Ordner) oder über „Hochladen“ hochladen; große Dateien gehen in Stücken hoch. Die Übertragungsleiste am unteren Rand zeigt den Fortschritt jeder Datei, lässt Abbrechen und Wiederholen zu und nennt Fehler in einfachen Worten. Tessera überschreibt nie stillschweigend: Gibt es den Namen schon, wählen Sie „Ersetzen“, „Beide behalten“ oder „Überspringen“. Dateien werden direkt heruntergeladen, Ordner und Mehrfachauswahl als ZIP-Datei.
### Geändert
@@ -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,12 +219,22 @@ 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 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>
<div className="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>
<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>
{expired && (
@@ -241,10 +257,13 @@ export function ConnectPanel({
>
{t('waiting.open')}
</a>
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<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 rounded-full bg-primary-strong motion-safe:animate-pulse"
className="inline-block h-2 w-2 shrink-0 rounded-full bg-primary-strong motion-safe:animate-pulse"
/>
{t('waiting.status')}
</p>
@@ -254,7 +273,6 @@ export function ConnectPanel({
</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
@@ -327,6 +345,12 @@ export function ConnectPanel({
</>
)}
</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}
/>
+10
View File
@@ -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. */
+23
View File
@@ -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.
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+17
View File
@@ -356,6 +356,23 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes
- **Benachrichtigungen:** Jeder Benutzer mit Zugriff auf das Modul kann über die Glocke auf einer Kachel für sich Benachrichtigungen einschalten. Tessera schickt dann eine E-Mail und, solange Tessera geöffnet ist, eine Meldung auf dem Bildschirm, wenn die Cloud rot wird und wenn sie wieder in Ordnung ist (eine Nachricht je Änderung). Für die E-Mails muss der Mailversand eingerichtet sein (Kapitel 6) und der Empfänger eine Adresse im Profil haben. Ob das Konto aktiv ist und das Modul noch genutzt werden darf, prüft Tessera erst beim Senden; sonst wird nichts verschickt. Scheitert der Versand, versucht Tessera es bis zu dreimal. Ändern Sie die Adresse einer roten Cloud und die neue Adresse antwortet in Ordnung, geht an die Abonnenten die Nachricht „wieder in Ordnung“.
- **Logo:** Entweder ein hochgeladenes Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB; SVG wird bewusst nicht angenommen) oder die Adresse eines Bildes. Eine https-Adresse lädt der Browser des Betrachters direkt – der Server ruft sie nie ab. Eine http-Adresse holt der Tessera-Server beim Speichern genau einmal ab, und zwar nur aus dem Internet: interne und private Adressen (auch über Weiterleitungen) lehnt er ab, bevor überhaupt eine Anfrage gesendet wird; der Abruf hat ein Zeitlimit, nimmt höchstens 1 MB und nur PNG, JPEG, GIF oder WebP an (geprüft an den Bytes, nicht am Dateityp des Servers). Das Ergebnis wird wie ein Upload gespeichert, die Adresse selbst nicht. Beides zugleich gibt es nicht: ein Upload ersetzt die Adresse und umgekehrt.
### Dateien: Nextcloud anbinden
Das Modul „Dateien“ (Gruppe Infrastruktur) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Adresse der Nextcloud eintragen und prüfen** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ (Reiter „Einstellungen“ im Modul); vergeben Sie diese Stufe nur an Personen, denen Sie das zutrauen, denn die Adresse ruft der Tessera-Server selbst auf. Wer nur „Benutzen“ hat, sieht den Reiter nicht.
- **Adresse:** Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, möglichst mit `https://` (zum Beispiel `https://cloud.ihre-firma.de`). Es gibt genau eine Nextcloud für die ganze Installation. Interne Adressen sind erlaubt. „Verbindung prüfen“ ruft nur die öffentliche Statusseite ab und nennt Version oder den Grund, warum es nicht klappt. Tessera folgt nie einer Weiterleitung: Leitet die Adresse um (zum Beispiel von `http` auf `https`), tragen Sie die endgültige Adresse ein.
- **Vertrauenswürdige Domains:** Meldet die Prüfung „Nextcloud lehnt diese Adresse ab“, muss der Rechnername der Adresse in der Nextcloud unter `trusted_domains` stehen (`config.php`, oder `occ config:system:set trusted_domains 1 --value=<Rechnername>`).
- **Zugang der Benutzer:** Jeder Benutzer meldet sich mit seinem eigenen Nextcloud-Konto an. Tessera speichert nur ein von Nextcloud ausgestelltes App-Passwort (verschlüsselt), nie das Passwort. Konten mit Zwei-Faktor-Anmeldung melden sich über den Browser an; das Passwortfenster genügt für sie nicht.
- **Ausnahme für den Tessera-Server in der Nextcloud (wichtig):** Nextcloud sperrt nach mehreren fehlgeschlagenen Anmeldungen die Absender-Adresse (Brute-Force-Schutz). Alle Tessera-Benutzer kommen von **derselben Adresse**, nämlich der des Tessera-Servers; eine Sperre träfe also alle gleichzeitig. Tessera begrenzt Fehlversuche deshalb selbst (höchstens drei je Benutzer in 15 Minuten und acht für den ganzen Server in 30 Minuten) und **hält alle Anfragen an die Nextcloud für bis zu 15 Minuten an**, sobald Nextcloud „zu viele Anfragen“ meldet; Benutzer sehen dann eine Wartezeit. Tragen Sie die Adresse des Tessera-Servers in die Ausnahmeliste der Nextcloud ein, dann kommt es dazu gar nicht erst. Auf dem Nextcloud-Server:
`occ config:app:set bruteForce whitelist_0 --value=<IP-Adresse des Tessera-Servers>`
Nehmen Sie bei weiteren Adressen `whitelist_1`, `whitelist_2` und so weiter; auch ein Netzbereich wie `192.168.13.0/24` ist möglich. Von welcher Adresse die Anfragen in der Nextcloud ankommen, sehen Sie im Zugriffsprotokoll des Webservers der Nextcloud; läuft die Nextcloud hinter einem Proxy, ist das oft die Adresse des Proxys. Eine bereits bestehende Sperre heben Sie mit `occ security:bruteforce:reset <IP-Adresse>` auf.
- **Adresse ändern:** Ändern Sie die Adresse, während Benutzer verbunden sind, fragt Tessera ausdrücklich nach. Nach dem Wechsel gelten alle bestehenden Verbindungen als abgelaufen, jeder meldet sich neu an (die alten App-Passwörter gelten nur für die alte Nextcloud und werden nie an die neue geschickt). Offene Browser-Anmeldungen werden verworfen.
- **Benutzer löschen:** Löschen Sie einen Tessera-Benutzer, bleibt dessen App-Passwort in der Geräteliste seines Nextcloud-Kontos stehen. Entfernen Sie es dort (Einstellungen → Sicherheit), oder lassen Sie es vorher über „Abmelden“ in Tessera widerrufen. Ohne Tessera-Konto kann niemand den Zugang nutzen, aber er bleibt in Nextcloud eingetragen.
- **Kein Dashboard-Widget:** Das Modul bringt keine Dashboard-Kachel mit.
- **Betrieb:** Netzwerkfreigaben, eigene Zertifizierungsstellen, die Größenbegrenzung des Proxys für das Hochladen und Fehlerbilder stehen im Betriebshandbuch, Abschnitt „Dateien (Nextcloud)“.
### Domains: AutoDNS anbinden
Das Modul „Domains“ (Gruppe Domains) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Anbindung einrichten, Kunden und Kontakte anlegen und Domains registrieren** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ – vergeben Sie diese Stufe nur an Personen, denen Sie Bestellungen auf Ihre Kosten zutrauen, denn eine Registrierung im Live-System ist verbindlich und kostenpflichtig.
+42
View File
@@ -18,6 +18,7 @@ Diese Anleitung richtet sich an alle Kolleginnen und Kollegen, die Tessera im Ar
- [Domains](#domains)
- [Proxmox](#proxmox)
- [Nextcloud-Status](#nextcloud-status)
- [Dateien (Nextcloud)](#dateien-nextcloud)
- [Kantinenabrechnung](#kantinenabrechnung)
- [Handelsware](#handelsware)
7. [Persönliche Einstellungen](#persönliche-einstellungen)
@@ -241,6 +242,47 @@ Pro Änderung kommt genau eine Nachricht; solange die Störung anhält, wiederho
**Die Dashboard-Kachel „Nextcloud-Status“:** Wer das Modul nutzen darf, kann sich eine Übersicht auch auf das Dashboard legen („Widget hinzufügen“). Sie zeigt drei Zähler (Grün, Gelb, Rot, bei Bedarf „Ohne Bewertung“) und darunter die roten und gelben Clouds mit Kundenname und Grund; sind alle Clouds in Ordnung, steht dort eine kurze Bestätigung. Ein Klick öffnet das Modul. Die Kachel liest nur den gespeicherten Stand und löst selbst keine Prüfung aus.
### Dateien (Nextcloud)
Das Modul zeigt Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera: ansehen, hochladen, herunterladen, ordnen. Es steht in der Seitenleiste in der Gruppe **Infrastruktur**. Jeder arbeitet nur in seinem eigenen Nextcloud-Konto; niemand sieht die Dateien eines Kollegen. Die Adresse der Nextcloud stellt ein Administrator (oder jemand mit der Freigabe „Verwalten“) einmal im Reiter **Einstellungen** ein. Steht dort noch nichts, sagt Ihnen die Seite das.
**Anmelden:** Beim ersten Öffnen sehen Sie den Anmeldebildschirm mit Name, Logo und Farbe Ihrer Nextcloud, damit Sie wissen, wo Sie sich anmelden. Es gibt zwei Wege:
- **Mit Benutzername und Passwort:** Tragen Sie Ihren Nextcloud-Benutzernamen (oder Ihre E-Mail-Adresse) und Ihr Nextcloud-Passwort ein und klicken Sie auf „Anmelden“.
- **Im Browser anmelden:** für Konten mit Zwei-Faktor-Anmeldung. Klicken Sie auf „Im Browser anmelden“, dann auf „Anmeldung bei Nextcloud öffnen“. Es öffnet sich die Nextcloud in einem neuen Tab. Melden Sie sich dort an und bestätigen Sie mit „Zugriff gewähren“. Tessera wartet so lange („Warten auf Bestätigung in Nextcloud …“) und zeigt danach Ihre Dateien. „Abbrechen“ bricht den Vorgang ab. Hat Ihr Konto Zwei-Faktor-Anmeldung und Sie versuchen es mit dem Passwort, weist Tessera Sie darauf hin und macht den Weg über den Browser zum Hauptknopf.
**Was Tessera speichert:** Ihr Passwort speichert Tessera nicht. Es schickt es einmal an Nextcloud und bekommt einen eigenen Zugang für Tessera zurück („App-Passwort“); nur dieser wird verschlüsselt aufbewahrt. In Ihren Nextcloud-Einstellungen unter „Sicherheit“ steht er als Gerät und lässt sich dort jederzeit widerrufen. Wenn Sie in Tessera auf **Abmelden** klicken (oben rechts, mit Rückfrage), entfernt Tessera den Zugang auch in Nextcloud. Ihre Dateien bleiben dabei unverändert.
**Dateien ansehen:** Oben zeigt eine Pfadleiste, wo Sie sind; ein Klick auf einen Teil des Pfads springt dorthin. Über „Liste“ und „Raster“ wählen Sie die Ansicht (Tessera merkt sich Ihre Wahl), über „Sortieren nach“ die Reihenfolge nach Name, Größe oder Änderungsdatum. Jede Datei hat eine Kachel in der Farbe ihres Typs (zum Beispiel PDF, XLSX, ZIP); bei Bildern erscheint das Vorschaubild. Ein Doppelklick auf einen Ordner öffnet ihn. Ganz unten steht der belegte Speicherplatz. Ist Ihr Speicher in Nextcloud unbegrenzt, steht dort nur, wie viel belegt ist.
**Arbeiten mit Dateien:** Über „Neuer Ordner“ legen Sie einen Ordner an. Über das Menü an jeder Zeile (oder mit der rechten Maustaste) finden Sie Öffnen, Herunterladen, Umbenennen, Verschieben, „In Nextcloud öffnen“ und Löschen. Beim Verschieben wählen Sie den Zielordner in einer Ordnerauswahl. **Löschen** verschiebt in den Papierkorb Ihrer Nextcloud, nach einer Rückfrage; dort holen Sie die Dateien bei Bedarf wieder heraus. Alles gilt auch für mehrere Einträge: Setzen Sie Häkchen oder halten Sie Strg (Umschalt für einen Bereich) gedrückt; eine Leiste oben nennt dann die Anzahl und bietet Herunterladen, Verschieben und Löschen für die ganze Auswahl.
**Hochladen:** Ziehen Sie Dateien oder ganze Ordner aus dem Dateimanager auf die Seite. Eine gestreifte Fläche nennt das Ziel („In „…“ ablegen“); legen Sie die Dateien auf eine Ordnerzeile, landen sie in diesem Ordner. Alternativ klicken Sie auf „Hochladen“ und wählen Dateien aus. Große Dateien gehen automatisch in Stücken hoch. Am unteren Rand erscheint die **Übertragungsleiste**: Jede Datei hat einen Fortschrittsbalken, „Abbrechen“ stoppt sie, „Erneut versuchen“ wiederholt eine fehlgeschlagene, „Erledigte entfernen“ räumt auf. Die Leiste lässt sich ein- und ausklappen.
**Gibt es den Namen schon?** Tessera überschreibt nie stillschweigend. Es fragt in der Übertragungsleiste: „Ersetzen“ (die vorhandene Datei wird durch die neue ersetzt), „Beide behalten“ (die neue bekommt einen eigenen Namen) oder „Überspringen“.
**Herunterladen:** Eine einzelne Datei lädt Ihr Browser direkt herunter. Ordner und Mehrfachauswahl kommen als ZIP-Datei.
**Tastatur:**
| Taste | Wirkung |
|---|---|
| Pfeiltasten | Eintrag wählen (mit Umschalt: Auswahl erweitern) |
| Enter | Ordner oder Datei öffnen |
| Rücktaste oder Alt + Pfeil nach oben | Einen Ordner nach oben |
| Leertaste | Eintrag zur Auswahl hinzufügen oder entfernen |
| Strg + A | Alles auswählen |
| F2 | Umbenennen |
| Entf | Löschen (mit Rückfrage) |
| Esc | Auswahl aufheben |
| Kontextmenütaste oder Umschalt + F10 | Menü des Eintrags |
**„Die Verbindung ist abgelaufen“:** Das passiert, wenn der Zugang in Nextcloud widerrufen wurde, Nextcloud ihn nicht mehr annimmt oder der Administrator die Adresse der Nextcloud geändert hat. Tessera zeigt dann wieder den Anmeldebildschirm mit einem Hinweis; melden Sie sich einfach neu an.
**„Nextcloud sperrt Anfragen vom Tessera-Server“:** Nextcloud schützt sich gegen Ausprobieren von Passwörtern und sperrt dafür eine Absender-Adresse zeitweise. Weil alle Tessera-Benutzer von derselben Adresse kommen, schickt Tessera dann bis zum Ende der Sperre (meist höchstens 15 Minuten) keine Anfragen mehr und nennt die Wartezeit. Tippen Sie das Passwort in der Zwischenzeit nicht wiederholt ein; warten Sie die Zeit ab. Bleibt die Meldung häufiger, sagen Sie Ihrem Administrator Bescheid: Er kann die Adresse des Tessera-Servers in der Nextcloud von dieser Sperre ausnehmen.
**Wenn etwas nicht klappt:** „Nextcloud ist gerade nicht erreichbar“ heißt, dass die Nextcloud nicht antwortet; prüfen Sie dort, ob sie läuft, und klicken Sie auf „Erneut versuchen“. Fehlt der Speicherplatz, nennt die Meldung beim Hochladen, wie viel noch frei ist.
### Kantinenabrechnung
Das Modul bereitet die Kantinenabrechnung für die Gehaltsabrechnung vor. Es steht in der Seitenleiste in der Gruppe **Finanzbuchhaltung**. Sie laden die CSV-Datei der Kantine hoch, Tessera prüft sie und erstellt daraus die Lohndatei für DATEV.
+13
View File
@@ -183,6 +183,18 @@ Images, die Compose-Datei selbst muss bei Bedarf separat auf dem Server aktualis
werden. Vor grösseren Änderungen an der Compose-Struktur die auf dem Server
tatsächlich liegende Datei prüfen, nicht blind von der Repo-Version ausgehen.
### Dateien (Nextcloud)
Das Modul „Dateien“ spricht aus dem `api`-Container mit der Nextcloud der Installation (Adresse im Modul unter „Einstellungen“). Dafür gilt:
- **Ausgehender Zugriff:** Der `api`-Container braucht Netzwerkzugriff auf die eingetragene Nextcloud (http oder https, auch interne Adressen). Tessera ruft nur feste Pfade dieser einen Adresse auf, folgt keiner Weiterleitung und schickt nie Cookies.
- **Zertifikate:** Das Zertifikat der Nextcloud wird immer geprüft, es gibt keinen Schalter, das abzustellen. Nutzt die Nextcloud ein Zertifikat einer eigenen (internen) Zertifizierungsstelle, legen Sie deren Zertifikat in den `api`-Container und setzen Sie `NODE_EXTRA_CA_CERTS=/pfad/zur/ca.pem` in dessen Umgebung (Compose-Datei des Servers, danach `up -d --force-recreate api`). Ohne das meldet „Verbindung prüfen“ im Modul, das Zertifikat ließe sich nicht prüfen.
- **Nginx Proxy Manager (Tessera-Adresse):** Beim Hochladen schickt der Browser große Dateien in Stücken von 8 MiB durch `/api-proxy`. Der Proxy vor Tessera muss das durchlassen: `client_max_body_size` mindestens `10m` (besser `64m`) und Lese- und Sendezeitlimits (`proxy_read_timeout`, `proxy_send_timeout`) von mindestens 120 Sekunden. Bei der Nginx-Voreinstellung von 1 MiB bricht jeder größere Upload schon beim ersten Stück ab.
- **Zusammenbau großer Dateien:** Nextcloud setzt die Stücke am Ende zu einer Datei zusammen. Dauert das länger als etwa 20 Sekunden, antwortet Tessera sofort und die Oberfläche fragt den Fortschritt ab; die Verbindung bleibt also nie lange offen. Unvollständige Uploads (Stücke ohne Abschluss, etwa nach einem Seitenwechsel) räumt Nextcloud nach 24 Stunden selbst auf.
- **Zustand im Arbeitsspeicher:** Offene Browser-Anmeldungen (Zwei-Faktor), laufende Zusammenbauten, die Aufrufsperre gegen die Brute-Force-Sperre der Nextcloud und die zwischengespeicherte Kennung der Nextcloud (10 Minuten) liegen im Prozess des `api`-Containers. Ein Neustart verwirft sie: Wer gerade eine Browser-Anmeldung offen hatte, startet sie neu; angemeldete Benutzer bleiben angemeldet (ihre Zugänge liegen verschlüsselt in der Datenbank).
- **Brute-Force-Ausnahme in der Nextcloud:** Tragen Sie die Adresse des Tessera-Servers dort in die Ausnahmeliste ein (Administrationshandbuch, Abschnitt „Dateien: Nextcloud anbinden“). Sonst kann eine Reihe falscher Anmeldungen die Nextcloud für alle Benutzer gleichzeitig sperren.
- **Verschlüsselung:** Die gespeicherten App-Passwörter sind mit `TESSERA_ENCRYPTION_KEY` verschlüsselt (Kapitel 2). Ein anderer Schlüssel macht sie unlesbar; die Benutzer müssen sich dann neu verbinden.
## 4. Neue Fassung einspielen
Das ist der wichtigste Ablauf im Tagesgeschäft. Zwei Befehle:
@@ -747,3 +759,4 @@ Für die Desktop-Auslieferung ist keine neue Pflichtvariable nötig.
| Client meldet „Update fehlgeschlagen" | Download über den Proxy abgebrochen (Größengrenze/Zeitlimit, siehe zweite Zeile dieser Tabelle), oder die Signatur passt nicht – die Pakete stammen nicht aus dem CI-Bau mit dem aktuellen Schlüssel | Kontrollzeile `/api-proxy/desktop/update` (Abschnitt „Wo die Pakete im Abbild liegen"), den Pipeline-Lauf und die Proxy-Einstellungen prüfen. Der Anwender kommt über den Browser-Weg weiter. |
| `/api-proxy/desktop/update` antwortet dauerhaft `204`, obwohl Pakete da sind | Manifest ohne `signature`/`updateVersion`: Pakete aus einem Bau vor der Update-Funktion oder mit `--no-sign` | Eine Änderung unter `apps/desktop/` pushen bzw. den Tag neu bauen lassen; im CI prüfen, dass die Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD` gesetzt sind (Abschnitt „Updates in der App und der Signierschlüssel"). |
| Eine Fehlermeldung aus der Desktop-App nennt als Herkunft „Desktop-App (unbekannt)“ ohne Version, Betreff-Kürzel `[Desktop]` | Der Client ist älter als diese Fassung: er meldet dem Server beim Start nur `desktop=1`, nicht Version, Stand und Betriebssystem (Parameter `dv`, `dc`, `dos`, aus denen `web` das Cookie `tessera_desktop_client` bildet) | Kein Fehler, die Meldung ist trotzdem als Desktop-App erkennbar. Client über „Auf Version … aktualisieren“ im Infobereich oder den Browser-Installer aktualisieren; danach stehen Betriebssystem, Version und Stand in der Meldung. |
| Beim Hochladen in „Dateien“ bricht jede größere Datei beim ersten 8-MB-Stück ab (Fehler „Die Verbindung wurde unterbrochen“, im Proxy-Protokoll `413`) | Der Proxy vor Tessera (Nginx Proxy Manager) lässt keine Anfragen über seiner Größengrenze durch (`client_max_body_size`), oder sein Zeitlimit ist zu kurz | Für die Tessera-Adresse `client_max_body_size` auf mindestens `10m` und die Lese-/Sendezeitlimits auf mindestens 120 Sekunden stellen (Kapitel 3, Abschnitt „Dateien (Nextcloud)“). |