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 { throw await mapNcFailure(result, { onExpired: () => this.account.markExpired(tenantId, userId), }); } private session(tenantId: string, userId: string): Promise { return this.account.getSession(tenantId, userId); } async list(tenantId: string, userId: string, rawPath?: string): Promise { 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 { 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), }); } }