fix(nextcloud-files): WR-01 Adresspruefung folgt keiner Weiterleitung und meldet nur grob
- testAddress fragt status.php ueber ncRequest ab (keine Weiterleitung, Aufrufsperre, keine Zugangsdaten) statt ueber fetchNextcloudStatus mit bis zu drei Weiterleitungen - Ergebnis nur noch ok / maintenance / paused / locked / redirect / not-nextcloud / unreachable, ohne HTTP-Statuscode, Fehlerkuerzel oder Weiterleitungsziel - NEXTCLOUD_STATUS_FETCHER entfaellt; Specs mit Attrappen-Transport, Anleitung angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,49 +4,37 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|||||||
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
|
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { NextcloudCheckResult } from '../nextcloud-status/nextcloud-status-fetch';
|
import { Readable } from 'node:stream';
|
||||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
|
import type { NcTransportRequest, NcTransportResponse } from './nextcloud-http';
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reachable(over: Partial<NextcloudCheckResult> = {}): NextcloudCheckResult {
|
const STATUS_OK = {
|
||||||
return {
|
installed: true,
|
||||||
reachable: true,
|
maintenance: false,
|
||||||
maintenance: false,
|
needsDbUpgrade: false,
|
||||||
needsDbUpgrade: false,
|
version: '34.0.4.1',
|
||||||
versionString: '34.0.4',
|
versionstring: '34.0.4',
|
||||||
edition: '',
|
edition: '',
|
||||||
productName: 'Nextcloud',
|
productname: 'Nextcloud',
|
||||||
errorKind: null,
|
};
|
||||||
errorDetail: null,
|
|
||||||
...over,
|
function reply(
|
||||||
};
|
statusCode: number,
|
||||||
|
body: unknown = '',
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): NcTransportResponse {
|
||||||
|
const text = typeof body === 'string' ? body : JSON.stringify(body);
|
||||||
|
return { statusCode, headers, body: Readable.from(text === '' ? [] : [Buffer.from(text)]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function unreachable(
|
type Handler = (req: NcTransportRequest) => NcTransportResponse;
|
||||||
errorKind: NextcloudCheckResult['errorKind'],
|
|
||||||
errorDetail: string | null = null,
|
|
||||||
): NextcloudCheckResult {
|
|
||||||
return {
|
|
||||||
reachable: false,
|
|
||||||
maintenance: null,
|
|
||||||
needsDbUpgrade: null,
|
|
||||||
versionString: null,
|
|
||||||
edition: null,
|
|
||||||
productName: null,
|
|
||||||
errorKind,
|
|
||||||
errorDetail,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeService(opts: {
|
function makeService(opts: { row?: Row | null; activeAccounts?: number; handler?: Handler }) {
|
||||||
row?: Row | null;
|
|
||||||
activeAccounts?: number;
|
|
||||||
check?: NextcloudCheckResult;
|
|
||||||
}) {
|
|
||||||
const state = { row: opts.row ?? null };
|
const state = { row: opts.row ?? null };
|
||||||
const upsert = vi.fn(async ({ create, update }: any) => {
|
const upsert = vi.fn(async ({ create, update }: any) => {
|
||||||
state.row = state.row ? { ...state.row, ...update } : { ...create };
|
state.row = state.row ? { ...state.row, ...update } : { ...create };
|
||||||
@@ -64,10 +52,14 @@ function makeService(opts: {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const fetcher = vi.fn(async (..._a: unknown[]) => opts.check ?? reachable());
|
const calls: NcTransportRequest[] = [];
|
||||||
|
const transport = vi.fn(async (req: NcTransportRequest) => {
|
||||||
|
calls.push(req);
|
||||||
|
return (opts.handler ?? (() => reply(200, STATUS_OK)))(req);
|
||||||
|
});
|
||||||
const gate = new NextcloudCallGate();
|
const gate = new NextcloudCallGate();
|
||||||
const service = new NextcloudFilesSettingsService(db as any, gate, fetcher as any);
|
const service = new NextcloudFilesSettingsService(db as any, gate, transport);
|
||||||
return { service, state, upsert, updateMany, count, fetcher, gate, tenants };
|
return { service, state, upsert, updateMany, count, calls, transport, gate, tenants };
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeOf = async (p: Promise<unknown>) => {
|
const codeOf = async (p: Promise<unknown>) => {
|
||||||
@@ -163,7 +155,7 @@ describe('NextcloudFilesSettingsService — Speichern', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('mit confirmReconnect: Adresse gespeichert, alle Konten der Organisation abgelaufen, Zuhoerer gerufen', async () => {
|
it('mit confirmReconnect: Adresse gespeichert, alle Konten der Organisation abgelaufen, Zuhoerer gerufen', async () => {
|
||||||
const { service, upsert, updateMany, fetcher } = makeService({
|
const { service, upsert, updateMany, calls } = makeService({
|
||||||
row: { baseUrl: 'https://alt.example' },
|
row: { baseUrl: 'https://alt.example' },
|
||||||
activeAccounts: 2,
|
activeAccounts: 2,
|
||||||
});
|
});
|
||||||
@@ -182,8 +174,8 @@ describe('NextcloudFilesSettingsService — Speichern', () => {
|
|||||||
});
|
});
|
||||||
expect(listener).toHaveBeenCalledWith('t1');
|
expect(listener).toHaveBeenCalledWith('t1');
|
||||||
expect(other).toHaveBeenCalledWith('t1');
|
expect(other).toHaveBeenCalledWith('t1');
|
||||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(fetcher.mock.calls[0][0]).toBe('https://neu.example');
|
expect(calls[0].url).toBe('https://neu.example/status.php');
|
||||||
expect(view.check?.ok).toBe(true);
|
expect(view.check?.ok).toBe(true);
|
||||||
expect(view.connectedAccounts).toBe(0);
|
expect(view.connectedAccounts).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -195,9 +187,9 @@ describe('NextcloudFilesSettingsService — Speichern', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
|
describe('NextcloudFilesSettingsService — Verbindungspruefung (WR-01: grob, ohne Weiterleitung)', () => {
|
||||||
it('erreichbar: ok mit Version', async () => {
|
it('erreichbar: ok mit Version, ein einziger GET auf status.php ohne Zugangsdaten', async () => {
|
||||||
const { service, fetcher } = makeService({ check: reachable() });
|
const { service, calls } = makeService({});
|
||||||
const res = await service.testAddress('https://cloud.example');
|
const res = await service.testAddress('https://cloud.example');
|
||||||
expect(res).toMatchObject({
|
expect(res).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -206,26 +198,57 @@ describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
|
|||||||
productName: 'Nextcloud',
|
productName: 'Nextcloud',
|
||||||
});
|
});
|
||||||
expect(res.message).toContain('34.0.4');
|
expect(res.message).toContain('34.0.4');
|
||||||
expect(fetcher.mock.calls[0][0]).toBe('https://cloud.example');
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].method).toBe('GET');
|
||||||
|
expect(calls[0].url).toBe('https://cloud.example/status.php');
|
||||||
|
expect(calls[0].headers.authorization).toBeUndefined();
|
||||||
|
expect(calls[0].headers.cookie).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('HTTP 400 ist der Hinweis auf die vertrauenswuerdigen Domains', async () => {
|
it('eine Weiterleitung wird NICHT befolgt; das Ergebnis nennt weder Ziel noch Status', async () => {
|
||||||
const { service } = makeService({ check: unreachable('http-status', 'HTTP 400') });
|
const { service, calls } = makeService({
|
||||||
|
handler: () => reply(302, '', { location: 'http://10.0.0.7:8080/secret' }),
|
||||||
|
});
|
||||||
const res = await service.testAddress('https://cloud.example');
|
const res = await service.testAddress('https://cloud.example');
|
||||||
expect(res.ok).toBe(false);
|
expect(res).toMatchObject({ ok: false, kind: 'redirect' });
|
||||||
expect(res.kind).toBe('trusted-domains');
|
expect(calls).toHaveLength(1);
|
||||||
expect(res.message).toContain('vertrauenswürdigen Domains');
|
const text = JSON.stringify(res);
|
||||||
|
expect(text).not.toContain('10.0.0.7');
|
||||||
|
expect(text).not.toContain('302');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Zeitueberschreitung hat einen deutschen Text', async () => {
|
it('jeder andere HTTP-Fehler heisst nur "keine Nextcloud", ohne Statuscode', async () => {
|
||||||
const { service } = makeService({ check: unreachable('timeout') });
|
for (const status of [400, 401, 403, 404, 500, 502]) {
|
||||||
const res = await service.testAddress('https://cloud.example');
|
const { service } = makeService({ handler: () => reply(status, 'interne Seite') });
|
||||||
expect(res).toMatchObject({ ok: false, kind: 'timeout' });
|
const res = await service.testAddress('https://cloud.example');
|
||||||
expect(res.message).toContain('nicht rechtzeitig');
|
expect(res.kind, String(status)).toBe('not-nextcloud');
|
||||||
|
expect(JSON.stringify(res)).not.toContain(String(status));
|
||||||
|
expect(JSON.stringify(res)).not.toContain('interne Seite');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keine Nextcloud-Antwort (200 ohne installed:true) ist not-nextcloud', async () => {
|
||||||
|
const { service } = makeService({ handler: () => reply(200, '<html>Router</html>') });
|
||||||
|
expect((await service.testAddress('https://cloud.example')).kind).toBe('not-nextcloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Netzfehler, Zeitueberschreitung und Zertifikat sind gleichermassen "nicht erreichbar"', async () => {
|
||||||
|
for (const code of ['ECONNREFUSED', 'UND_ERR_HEADERS_TIMEOUT', 'CERT_HAS_EXPIRED']) {
|
||||||
|
const { service } = makeService({
|
||||||
|
handler: () => {
|
||||||
|
throw Object.assign(new Error('x'), { code });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await service.testAddress('https://cloud.example');
|
||||||
|
expect(res, code).toMatchObject({ ok: false, kind: 'unreachable' });
|
||||||
|
expect(JSON.stringify(res)).not.toContain(code);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Wartungsmodus ist kein Erfolg', async () => {
|
it('Wartungsmodus ist kein Erfolg', async () => {
|
||||||
const { service } = makeService({ check: reachable({ maintenance: true }) });
|
const { service } = makeService({
|
||||||
|
handler: () => reply(200, { ...STATUS_OK, maintenance: true }),
|
||||||
|
});
|
||||||
expect(await service.testAddress('https://cloud.example')).toMatchObject({
|
expect(await service.testAddress('https://cloud.example')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
kind: 'maintenance',
|
kind: 'maintenance',
|
||||||
@@ -233,15 +256,15 @@ describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('eine gesperrte Adresse (Aufrufsperre) fragt Nextcloud gar nicht erst', async () => {
|
it('eine gesperrte Adresse (Aufrufsperre) fragt Nextcloud gar nicht erst', async () => {
|
||||||
const { service, fetcher, gate } = makeService({});
|
const { service, calls, gate } = makeService({});
|
||||||
gate.pause('https://cloud.example');
|
gate.pause('https://cloud.example');
|
||||||
const res = await service.testAddress('https://cloud.example');
|
const res = await service.testAddress('https://cloud.example');
|
||||||
expect(res).toMatchObject({ ok: false, kind: 'paused' });
|
expect(res).toMatchObject({ ok: false, kind: 'paused' });
|
||||||
expect(fetcher).not.toHaveBeenCalled();
|
expect(calls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein 429 aus der Pruefung sperrt den Ursprung fuer alle weiteren Aufrufe', async () => {
|
it('ein 429 aus der Pruefung sperrt den Ursprung fuer alle weiteren Aufrufe', async () => {
|
||||||
const { service, gate } = makeService({ check: unreachable('http-status', 'HTTP 429') });
|
const { service, gate } = makeService({ handler: () => reply(429) });
|
||||||
const res = await service.testAddress('https://cloud.example');
|
const res = await service.testAddress('https://cloud.example');
|
||||||
expect(res.kind).toBe('locked');
|
expect(res.kind).toBe('locked');
|
||||||
expect(gate.isPaused('https://cloud.example').paused).toBe(true);
|
expect(gate.isPaused('https://cloud.example').paused).toBe(true);
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { fetch as undiciFetch } from 'undici';
|
import {
|
||||||
|
normalizeCloudUrl,
|
||||||
|
parseNextcloudStatus,
|
||||||
|
} from '../nextcloud-status/nextcloud-status-fetch';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import {
|
|
||||||
type FetchStatusOptions,
|
|
||||||
fetchNextcloudStatus,
|
|
||||||
type NextcloudCheckResult,
|
|
||||||
normalizeCloudUrl,
|
|
||||||
} from '../nextcloud-status/nextcloud-status-fetch';
|
|
||||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||||
import {
|
import {
|
||||||
type NextcloudFilesCheckView,
|
type NextcloudFilesCheckView,
|
||||||
@@ -15,14 +12,17 @@ import {
|
|||||||
type NextcloudFilesStatusView,
|
type NextcloudFilesStatusView,
|
||||||
ncErrorDefault,
|
ncErrorDefault,
|
||||||
} from './nextcloud-files.types';
|
} from './nextcloud-files.types';
|
||||||
|
import {
|
||||||
|
discardBody,
|
||||||
|
NEXTCLOUD_TRANSPORT,
|
||||||
|
type NextcloudTransport,
|
||||||
|
ncRequest,
|
||||||
|
readCappedText,
|
||||||
|
} from './nextcloud-http';
|
||||||
|
|
||||||
/** Nest-Token fuer den Abruf von `status.php` (Tests setzen eine Attrappe ein). */
|
/** Zeitgrenze und Groessendeckel der Adresspruefung (`status.php`). */
|
||||||
export const NEXTCLOUD_STATUS_FETCHER = 'NEXTCLOUD_STATUS_FETCHER';
|
const CHECK_TIMEOUT_MS = 10_000;
|
||||||
export type NextcloudStatusFetcher = (
|
const CHECK_MAX_BYTES = 64 * 1024;
|
||||||
baseUrl: string,
|
|
||||||
opts?: FetchStatusOptions,
|
|
||||||
) => Promise<NextcloudCheckResult>;
|
|
||||||
export const defaultStatusFetcher: NextcloudStatusFetcher = fetchNextcloudStatus;
|
|
||||||
|
|
||||||
export type AddressChangeListener = (tenantId: string) => void;
|
export type AddressChangeListener = (tenantId: string) => void;
|
||||||
|
|
||||||
@@ -30,8 +30,12 @@ interface ConfigRow {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRUSTED_DOMAINS_TEXT =
|
const NOT_NEXTCLOUD_TEXT =
|
||||||
'Nextcloud lehnt diese Adresse ab. Bitte nehmen Sie den Rechnernamen in die vertrauenswürdigen Domains (trusted_domains) der Nextcloud auf.';
|
'Unter dieser Adresse antwortet keine Nextcloud. Steht dort doch eine Nextcloud, nehmen Sie bitte den Rechnernamen in ihre vertrauenswürdigen Domains (trusted_domains) auf.';
|
||||||
|
const UNREACHABLE_TEXT =
|
||||||
|
'Unter dieser Adresse ist keine Nextcloud erreichbar. Bitte prüfen Sie die Adresse. Bei https muss der Tessera-Server dem Zertifikat vertrauen (eigene Zertifizierungsstelle siehe Betriebshandbuch).';
|
||||||
|
const REDIRECT_TEXT =
|
||||||
|
'Die Adresse leitet auf eine andere Adresse weiter. Tessera folgt keiner Weiterleitung; tragen Sie bitte die endgültige Adresse ein (zum Beispiel mit https).';
|
||||||
|
|
||||||
function hostOf(baseUrl: string): string | null {
|
function hostOf(baseUrl: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -41,14 +45,6 @@ function hostOf(baseUrl: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function originOf(baseUrl: string): string | null {
|
|
||||||
try {
|
|
||||||
return new URL(baseUrl).origin;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einstellungen des Moduls "Nextcloud-Dateien" (quick-261008-mzu): die EINE
|
* Einstellungen des Moduls "Nextcloud-Dateien" (quick-261008-mzu): die EINE
|
||||||
* Nextcloud-Adresse der Organisation. Gesamter Zugriff auf `nextcloudFilesConfig`
|
* Nextcloud-Adresse der Organisation. Gesamter Zugriff auf `nextcloudFilesConfig`
|
||||||
@@ -67,7 +63,7 @@ export class NextcloudFilesSettingsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly gate: NextcloudCallGate,
|
private readonly gate: NextcloudCallGate,
|
||||||
@Inject(NEXTCLOUD_STATUS_FETCHER) private readonly statusFetcher: NextcloudStatusFetcher,
|
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Meldet einen Zuhoerer an, der nach einem Adresswechsel mit der Organisation aufgerufen wird. */
|
/** Meldet einen Zuhoerer an, der nach einem Adresswechsel mit der Organisation aufgerufen wird. */
|
||||||
@@ -149,48 +145,20 @@ export class NextcloudFilesSettingsService {
|
|||||||
// --- Verbindungspruefung -----------------------------------------------------
|
// --- Verbindungspruefung -----------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fragt `status.php` der Adresse ab (ohne Zugangsdaten). Antwortet immer mit
|
* Fragt `status.php` der Adresse ab (ohne Zugangsdaten) — ueber `ncRequest`, also
|
||||||
* `{ ok, kind, message, version, productName }`; Netz- und HTTP-Probleme sind
|
* mit denselben Regeln wie jeder andere Nextcloud-Aufruf: KEINE Weiterleitung wird
|
||||||
* keine Serverfehler. Steht der Ursprung wegen eines 429 auf der Aufrufsperre,
|
* befolgt, und steht der Ursprung wegen eines 429 auf der Aufrufsperre, geht gar
|
||||||
* geht gar keine Anfrage raus.
|
* keine Anfrage raus.
|
||||||
|
*
|
||||||
|
* Das Ergebnis ist bewusst GROB (WR-01): erreichbare Nextcloud (`ok`), Wartung,
|
||||||
|
* gesperrt, Weiterleitung auf eine andere Adresse (`redirect`), keine Nextcloud
|
||||||
|
* (`not-nextcloud`) oder nicht erreichbar (`unreachable`). Kein HTTP-Statuscode,
|
||||||
|
* kein Fehlerkuerzel, kein Ziel einer Weiterleitung verlaesst den Server — die
|
||||||
|
* Pruefung taugt so nicht zum Abtasten interner Adressen und Dienste.
|
||||||
*/
|
*/
|
||||||
async testAddress(rawBaseUrl: string): Promise<NextcloudFilesCheckView> {
|
async testAddress(rawBaseUrl: string): Promise<NextcloudFilesCheckView> {
|
||||||
const baseUrl = normalizeCloudUrl(rawBaseUrl);
|
const baseUrl = normalizeCloudUrl(rawBaseUrl);
|
||||||
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
|
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
|
||||||
const origin = originOf(baseUrl);
|
|
||||||
|
|
||||||
if (origin) {
|
|
||||||
const pause = this.gate.isPaused(origin);
|
|
||||||
if (pause.paused) {
|
|
||||||
const minutes = Math.max(1, Math.ceil(pause.retryAfterSeconds / 60));
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
kind: 'paused',
|
|
||||||
message: `Nextcloud sperrt Anmeldungen vom Tessera-Server zurzeit. Bitte warten Sie etwa ${minutes} Minuten.`,
|
|
||||||
version: null,
|
|
||||||
productName: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merkt, ob die Adresse nur ueber eine Weiterleitung antwortet: im Betrieb folgt Tessera
|
|
||||||
// keiner Weiterleitung, die Adresse muss dann direkt eingetragen werden.
|
|
||||||
let redirected = false;
|
|
||||||
const recordingFetch = (async (...args: Parameters<typeof undiciFetch>) => {
|
|
||||||
const response = await undiciFetch(...args);
|
|
||||||
if (response.status >= 300 && response.status < 400) redirected = true;
|
|
||||||
return response;
|
|
||||||
}) as typeof undiciFetch;
|
|
||||||
|
|
||||||
const result = await this.statusFetcher(baseUrl, { fetchImpl: recordingFetch });
|
|
||||||
return this.describeCheck(result, redirected, origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
private describeCheck(
|
|
||||||
result: NextcloudCheckResult,
|
|
||||||
redirected: boolean,
|
|
||||||
origin: string | null,
|
|
||||||
): NextcloudFilesCheckView {
|
|
||||||
const fail = (kind: string, message: string): NextcloudFilesCheckView => ({
|
const fail = (kind: string, message: string): NextcloudFilesCheckView => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
kind,
|
kind,
|
||||||
@@ -199,64 +167,53 @@ export class NextcloudFilesSettingsService {
|
|||||||
productName: null,
|
productName: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.reachable) {
|
const res = await ncRequest(this.transport, this.gate, {
|
||||||
if (result.maintenance) {
|
baseUrl,
|
||||||
return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
|
prefix: '/status.php',
|
||||||
|
method: 'GET',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
headersTimeoutMs: CHECK_TIMEOUT_MS,
|
||||||
|
bodyTimeoutMs: CHECK_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.kind === 'paused' || (res.kind === 'http' && res.status === 429)) {
|
||||||
|
const minutes = Math.max(1, Math.ceil((res.retryAfterSeconds ?? 900) / 60));
|
||||||
|
return fail(
|
||||||
|
res.kind === 'paused' ? 'paused' : 'locked',
|
||||||
|
`Nextcloud sperrt Anmeldungen vom Tessera-Server zurzeit. Bitte warten Sie etwa ${minutes} Minuten.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const name = result.productName ?? 'Nextcloud';
|
if (res.kind === 'redirect') return fail('redirect', REDIRECT_TEXT);
|
||||||
const version = result.versionString;
|
return fail('unreachable', UNREACHABLE_TEXT);
|
||||||
const base = version
|
|
||||||
? `Verbindung erfolgreich: ${name} ${version}.`
|
|
||||||
: 'Verbindung erfolgreich.';
|
|
||||||
const hint = redirected
|
|
||||||
? ' Die Adresse leitet allerdings um. Tessera folgt keiner Weiterleitung, tragen Sie bitte die endgültige Adresse ein (zum Beispiel mit https).'
|
|
||||||
: '';
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
kind: redirected ? 'redirected' : 'ok',
|
|
||||||
message: base + hint,
|
|
||||||
version,
|
|
||||||
productName: result.productName,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (result.errorKind) {
|
if (res.status === 503) {
|
||||||
case 'timeout':
|
discardBody(res.body);
|
||||||
return fail(
|
return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
|
||||||
'timeout',
|
|
||||||
'Nextcloud hat nicht rechtzeitig geantwortet. Bitte prüfen Sie die Adresse und versuchen Sie es erneut.',
|
|
||||||
);
|
|
||||||
case 'network':
|
|
||||||
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
|
|
||||||
case 'tls':
|
|
||||||
return fail(
|
|
||||||
'tls',
|
|
||||||
'Das Zertifikat der Nextcloud konnte nicht geprüft werden. Bitte prüfen Sie die Adresse oder hinterlegen Sie die Zertifizierungsstelle auf dem Tessera-Server.',
|
|
||||||
);
|
|
||||||
case 'http-status': {
|
|
||||||
if (result.errorDetail === 'HTTP 400') return fail('trusted-domains', TRUSTED_DOMAINS_TEXT);
|
|
||||||
if (result.errorDetail === 'HTTP 429') {
|
|
||||||
if (origin) this.gate.pause(origin);
|
|
||||||
return fail(
|
|
||||||
'locked',
|
|
||||||
'Nextcloud sperrt Anmeldungen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in einigen Minuten erneut.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fail(
|
|
||||||
'http-status',
|
|
||||||
`Nextcloud hat unter dieser Adresse mit einem Fehler geantwortet (${result.errorDetail ?? 'unbekannt'}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'redirect':
|
|
||||||
return fail(
|
|
||||||
'redirect',
|
|
||||||
'Die Adresse leitet mehrfach um oder führt nicht zu einer Nextcloud. Bitte prüfen Sie sie.',
|
|
||||||
);
|
|
||||||
case 'too-large':
|
|
||||||
case 'not-nextcloud':
|
|
||||||
return fail('not-nextcloud', 'Unter dieser Adresse antwortet keine Nextcloud.');
|
|
||||||
default:
|
|
||||||
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
|
|
||||||
}
|
}
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
discardBody(res.body);
|
||||||
|
return fail('not-nextcloud', NOT_NEXTCLOUD_TEXT);
|
||||||
|
}
|
||||||
|
const text = await readCappedText(res.body, CHECK_MAX_BYTES);
|
||||||
|
if (!text.ok) {
|
||||||
|
return text.kind === 'too-large'
|
||||||
|
? fail('not-nextcloud', NOT_NEXTCLOUD_TEXT)
|
||||||
|
: fail('unreachable', UNREACHABLE_TEXT);
|
||||||
|
}
|
||||||
|
const parsed = parseNextcloudStatus(text.text);
|
||||||
|
if (!parsed) return fail('not-nextcloud', NOT_NEXTCLOUD_TEXT);
|
||||||
|
if (parsed.maintenance) return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
|
||||||
|
|
||||||
|
const name = parsed.productName ?? 'Nextcloud';
|
||||||
|
const version = parsed.versionString;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: 'ok',
|
||||||
|
message: version ? `Verbindung erfolgreich: ${name} ${version}.` : 'Verbindung erfolgreich.',
|
||||||
|
version,
|
||||||
|
productName: parsed.productName,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import { NextcloudFilesController } from './nextcloud-files.controller';
|
|||||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import {
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
defaultStatusFetcher,
|
|
||||||
NEXTCLOUD_STATUS_FETCHER,
|
|
||||||
NextcloudFilesSettingsService,
|
|
||||||
} from './nextcloud-files-settings.service';
|
|
||||||
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
||||||
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
||||||
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||||
@@ -36,7 +32,6 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
|||||||
LoginFlowStore,
|
LoginFlowStore,
|
||||||
NextcloudCallGate,
|
NextcloudCallGate,
|
||||||
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
|
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
|
||||||
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
NextcloudCallGate,
|
NextcloudCallGate,
|
||||||
|
|||||||
@@ -360,8 +360,8 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes
|
|||||||
|
|
||||||
Das Modul „Dateien“ (Gruppe Infrastruktur) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Adresse der Nextcloud legt ausschließlich ein Administrator fest** (Reiter „Einstellungen“ im Modul). Der Grund: Alle Benutzer schicken ihr Nextcloud-Passwort an diese Adresse, und das ist oft zugleich ihr Windows-Passwort. Benutzer mit der Freigabestufe „Verwalten“ sehen den Reiter ebenfalls, können die eingetragene Adresse aber nur ansehen und mit „Verbindung prüfen“ testen, nicht ändern. Wer nur „Benutzen“ hat, sieht den Reiter nicht.
|
Das Modul „Dateien“ (Gruppe Infrastruktur) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Adresse der Nextcloud legt ausschließlich ein Administrator fest** (Reiter „Einstellungen“ im Modul). Der Grund: Alle Benutzer schicken ihr Nextcloud-Passwort an diese Adresse, und das ist oft zugleich ihr Windows-Passwort. Benutzer mit der Freigabestufe „Verwalten“ sehen den Reiter ebenfalls, können die eingetragene Adresse aber nur ansehen und mit „Verbindung prüfen“ testen, nicht ändern. 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.
|
- **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 meldet bewusst nur grob: Nextcloud erreichbar (mit Version), Wartungsmodus, „leitet weiter“, „keine Nextcloud“ oder „nicht erreichbar“. Fehlernummern oder Ziele einer Weiterleitung nennt Tessera nicht, damit sich die Prüfung nicht zum Abtasten interner Adressen eignet. 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>`).
|
- **Vertrauenswürdige Domains:** Meldet die Prüfung „keine Nextcloud“, obwohl unter der Adresse eine Nextcloud läuft, 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.
|
- **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:
|
- **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:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user