78f6cf358d
- 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>
156 lines
6.1 KiB
TypeScript
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),
|
|
});
|
|
}
|
|
}
|