Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-files.service.ts
T
schalli 78f6cf358d fix(quick-261009-dkv): Pfade woertlich, Zaehler und Serverdatum, Rechte des Ordners (Schnittstelle)
- Pfade, Ziele und Empfaengerkennungen der Freigaben bleiben woertlich (nur Anzeigetexte werden bereinigt)
- Weitergaben als eigene Freigaben mit eigenem Pfad; accessOf nach Eintragsart
- Begrenzung: 10 neue Freigaben je 10 Minuten, 40 Versuche je 10 Minuten vor den Abfragen an die Nextcloud, leere Zaehler werden entfernt
- Freigaberegeln nennen das Serverdatum, die Ordnerliste die Berechtigungsbuchstaben des Ordners selbst
- Live-Test: Ablehnen offener Freigaben, Weitergabe, Buchstaben des Ordners

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:21:23 +02:00

156 lines
6.1 KiB
TypeScript

import { Inject, Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { NextcloudCallGate } from './nextcloud-call-gate';
import * as dav from './nextcloud-dav';
import { type NcSession, ncErrorDefault } from './nextcloud-files.types';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import {
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
parseUserPath,
validateNewName,
} from './nextcloud-http';
import type { NcEntry, NcQuota } from './nextcloud-propfind';
import { mapNcFailure, type NcOutcome, sendUpstreamStream } from './nextcloud-upstream';
export interface ListingView {
path: string;
entries: NcEntry[];
quota: NcQuota;
truncated: boolean;
/** Berechtigungsbuchstaben des Ordners selbst (z. B. `RGDNVCK`); null, wenn Nextcloud sie nicht nennt. */
permissions: string | null;
}
/** Vorschaubilder: hoechstens 5 MiB, nur Bilder (D-K). */
const PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const FILE_ID_RE = /^\d{1,20}$/;
function pathOf(segments: readonly string[]): string {
return `/${segments.join('/')}`;
}
/**
* Dateiaktionen im Konto des angemeldeten Benutzers (quick-261008-mzu, D-I):
* auflisten, Ordner anlegen, verschieben/umbenennen, loeschen, Vorschau. Jede
* Methode beginnt mit `getSession(tenantId, userId)` — die Benutzerkennung
* kommt aus dem Token, nie aus der Eingabe; ein Benutzer ohne Konto bekommt
* `notConnected` und kann nie ueber das Konto eines anderen arbeiten. Dieser
* Dienst greift nicht auf die Datenbank zu. Jeder Fehler laeuft durch
* `mapNcFailure` (nie 401/403 an den Browser; ein 401 von Nextcloud markiert die
* Verbindung als abgelaufen).
*/
@Injectable()
export class NextcloudFilesService {
constructor(
private readonly account: NextcloudFilesAccountService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {}
private async fail(tenantId: string, userId: string, result: NcOutcome): Promise<never> {
throw await mapNcFailure(result, {
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
private session(tenantId: string, userId: string): Promise<NcSession> {
return this.account.getSession(tenantId, userId);
}
async list(tenantId: string, userId: string, rawPath?: string): Promise<ListingView> {
const segments = parseUserPath(rawPath);
const session = await this.session(tenantId, userId);
const result = await dav.list(this.transport, this.gate, session, segments);
if (!result.ok || result.listing === null) return this.fail(tenantId, userId, result);
const { entries, quota, truncated, folderPermissions } = result.listing;
return {
path: pathOf(segments),
entries,
quota,
truncated,
permissions: folderPermissions,
};
}
async createFolder(tenantId: string, userId: string, rawPath: string): Promise<{ path: string }> {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
validateNewName(segments[segments.length - 1]);
const session = await this.session(tenantId, userId);
const result = await dav.mkdir(this.transport, this.gate, session, segments);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.fail(tenantId, userId, result);
}
return { path: pathOf(segments) };
}
async move(
tenantId: string,
userId: string,
rawFrom: string,
rawTo: string,
): Promise<{ from: string; to: string }> {
const from = parseUserPath(rawFrom);
const to = parseUserPath(rawTo);
if (from.length === 0 || to.length === 0) throw ncErrorDefault('invalidPath');
validateNewName(to[to.length - 1]);
// Ziel gleich oder innerhalb der Quelle: vor jedem Aufruf ablehnen.
if (to.length >= from.length && from.every((s, i) => to[i] === s)) {
throw ncErrorDefault('moveIntoItself');
}
const session = await this.session(tenantId, userId);
const result = await dav.move(this.transport, this.gate, session, from, to);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.fail(tenantId, userId, result);
}
return { from: pathOf(from), to: pathOf(to) };
}
async remove(tenantId: string, userId: string, rawPath: string): Promise<{ deleted: true }> {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const result = await dav.remove(this.transport, this.gate, session, segments);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.fail(tenantId, userId, result);
}
return { deleted: true };
}
/** Vorschaubild streamen; `version` (Entity-Tag) erlaubt einen Tag Browser-Cache. */
async preview(
res: Response,
tenantId: string,
userId: string,
fileId: string,
version?: string,
): Promise<void> {
if (!FILE_ID_RE.test(fileId)) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
// Bricht der Browser ab, wird auch der Aufruf an Nextcloud abgebrochen.
const abort = new AbortController();
res.on('close', () => {
if (!res.writableFinished) abort.abort();
});
const upstream = await dav.preview(this.transport, this.gate, session, fileId, abort.signal);
await sendUpstreamStream(res, upstream, {
extraHeaders: {
'cache-control': version ? 'private, max-age=86400' : 'private, max-age=3600',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
},
// Nur Rasterbilder (IN-02): SVG kann Skript enthalten; CSP-Sandbox und nosniff entschaerfen
// das zwar, aber eine Vorschau braucht kein SVG (Nextcloud rendert Vorschauen als PNG/JPEG).
accept: (contentType) => {
const type = contentType.toLowerCase();
return type.startsWith('image/') && !type.startsWith('image/svg');
},
maxBytes: PREVIEW_MAX_BYTES,
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
}