feat(nextcloud-files): Dateien auflisten, Vorschau, Ordner anlegen, umbenennen, verschieben und löschen
- PROPFIND-Auswertung (mehrere propstat, Kleinbuchstaben-Hex, Speicher -3 = unbegrenzt, nur Zeichenketten) - WebDAV-Schicht mit Zugangsschluessel und Aufrufsperre, Destination immer aus der Basis, Overwrite: F - mapNcFailure als einzige Fehlerabbildung (nie 401/403 an den Browser), sendUpstreamStream prueft vor dem ersten Byte - Dateidienst und Routen files, folders, move, preview im eigenen Konto; zweiter Benutzer bekommt notConnected - Web-API-Funktionen listFolder, createFolder, moveEntry, deleteEntry, previewUrl - e2e-files.sh gegen die Test-Nextcloud Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 } = result.listing;
|
||||
return { path: pathOf(segments), entries, quota, truncated };
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
accept: (contentType) => contentType.toLowerCase().startsWith('image/'),
|
||||
maxBytes: PREVIEW_MAX_BYTES,
|
||||
onExpired: () => this.account.markExpired(tenantId, userId),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user