feat(quick-261009-dkv): Teilen mit Personen und Gruppen - Durchstich, Schnittstelle
- Freigaben-Schicht (nextcloud-shares.ts): eigener OCS-Aufruf, der Fehlertexte liest, Parser fuer Freigaben, Empfaenger und Freigaberegeln, Berechtigungsabbildung - Dienst mit Vorpruefung aus den Faehigkeiten, Dublettenpruefung und Fehlermatrix (nie 401/403 nach aussen), Begrenzung auf 15 neue Freigaben in 10 Minuten - Routen shares/policy, shares/by-path, sharees, POST/PUT/DELETE shares - oc:share-types als shareTypes an jedem Eintrag - Live-Test e2e-shares.sh (Abschnitt people) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,12 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
'completeUpload',
|
||||
'uploadState',
|
||||
'abortUpload',
|
||||
'getSharePolicy',
|
||||
'listSharesForPath',
|
||||
'searchSharees',
|
||||
'createShare',
|
||||
'updateShare',
|
||||
'deleteShare',
|
||||
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
const fn = handler(NextcloudFilesController, name);
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Eingaben der Freigaberouten (quick-261009-dkv, D-11). Der Browser schickt nur
|
||||
* die Aufzaehlungen `kind` und `access`, nie eine Freigabeart-Zahl oder eine
|
||||
* Berechtigungsmaske; die API bildet sie selbst ab. Die globale ValidationPipe
|
||||
* laeuft mit `whitelist: true` — unbekannte Felder (z. B. `permissions`) werden
|
||||
* entfernt. Pfade laufen im Dienst durch `parseUserPath`.
|
||||
*/
|
||||
|
||||
/** Kein Steuerzeichen (auch kein Zeilenumbruch) in Namen, die an Nextcloud gehen. */
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
|
||||
const NO_CONTROL = /^[^\u0000-\u001f\u007f]*$/;
|
||||
|
||||
export const SHARE_KINDS_PEOPLE = ['user', 'group'] as const;
|
||||
export const SHARE_ACCESS = ['view', 'edit', 'upload'] as const;
|
||||
export const ITEM_TYPES = ['file', 'folder'] as const;
|
||||
|
||||
export class ShareByPathQueryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(4096)
|
||||
path!: string;
|
||||
}
|
||||
|
||||
export class ShareeQueryDto {
|
||||
/** Leer ist erlaubt (der Dienst ruft dann Nextcloud nicht auf). */
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(NO_CONTROL)
|
||||
term!: string;
|
||||
|
||||
@IsIn(ITEM_TYPES)
|
||||
itemType!: (typeof ITEM_TYPES)[number];
|
||||
}
|
||||
|
||||
export class CreateShareDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(4096)
|
||||
path!: string;
|
||||
|
||||
@IsIn(SHARE_KINDS_PEOPLE)
|
||||
kind!: (typeof SHARE_KINDS_PEOPLE)[number];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
@Matches(NO_CONTROL)
|
||||
shareWith!: string;
|
||||
|
||||
@IsIn(SHARE_ACCESS)
|
||||
access!: (typeof SHARE_ACCESS)[number];
|
||||
}
|
||||
|
||||
export class UpdateShareDto {
|
||||
@IsOptional()
|
||||
@IsIn(SHARE_ACCESS)
|
||||
access?: (typeof SHARE_ACCESS)[number];
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import { type NcSession } from './nextcloud-files.types';
|
||||
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
|
||||
import { NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||
|
||||
const SESSION: NcSession = {
|
||||
baseUrl: 'https://cloud.example/nc',
|
||||
ncUserId: 'anna',
|
||||
authorization: 'Basic YW5uYTphcHAtcHctMTIz',
|
||||
credentialKey: 'k1',
|
||||
};
|
||||
const OCS = 'https://cloud.example/nc/ocs/v2.php';
|
||||
const SHARES = `${OCS}/apps/files_sharing/api/v1/shares`;
|
||||
const BY_PATH = `${SHARES}?path=%2FProjekte&reshares=true`;
|
||||
const CAPS_URL = `${OCS}/cloud/capabilities`;
|
||||
const DAV = 'https://cloud.example/nc/remote.php/dav/files/anna';
|
||||
|
||||
const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"';
|
||||
function statXml(name: string, folder: boolean, letters: string): string {
|
||||
return `<?xml version="1.0"?><d:multistatus ${NS}><d:response>
|
||||
<d:href>/nc/remote.php/dav/files/anna/${name}</d:href><d:propstat><d:prop>
|
||||
<d:resourcetype>${folder ? '<d:collection/>' : ''}</d:resourcetype>
|
||||
<oc:fileid>7</oc:fileid><oc:permissions>${letters}</oc:permissions>
|
||||
${folder ? '' : '<d:getcontentlength>5</d:getcontentlength><d:getcontenttype>text/plain</d:getcontenttype>'}
|
||||
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`;
|
||||
}
|
||||
|
||||
const ocs = (data: unknown, status = 200, message = 'OK') =>
|
||||
JSON.stringify({ ocs: { meta: { statuscode: status, message }, data } });
|
||||
|
||||
const CAPS = (over: Record<string, unknown> = {}) =>
|
||||
ocs({
|
||||
capabilities: {
|
||||
password_policy: { minLength: 10 },
|
||||
files_sharing: {
|
||||
api_enabled: true,
|
||||
group_sharing: true,
|
||||
public: { enabled: true, upload: true },
|
||||
sharee: { minSearchStringLength: 0 },
|
||||
...over,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function shareJson(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: '17',
|
||||
share_type: 0,
|
||||
uid_owner: 'anna',
|
||||
displayname_owner: 'Anna Müller',
|
||||
permissions: 15,
|
||||
can_edit: true,
|
||||
can_delete: true,
|
||||
stime: 1791532378,
|
||||
path: '/Projekte',
|
||||
item_type: 'folder',
|
||||
item_permissions: 31,
|
||||
mimetype: 'httpd/unix-directory',
|
||||
file_target: '/Projekte',
|
||||
share_with: 'ben',
|
||||
share_with_displayname: 'Ben Beispiel',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
type Reply = { status: number; text?: string; headers?: Record<string, string> };
|
||||
|
||||
function setup(replies: Reply[]) {
|
||||
const calls: NcTransportRequest[] = [];
|
||||
const queue = [...replies];
|
||||
const transport: NextcloudTransport = async (req) => {
|
||||
calls.push(req);
|
||||
const reply = queue.shift();
|
||||
if (!reply) throw new Error(`unerwarteter Aufruf ${req.method} ${req.url}`);
|
||||
return {
|
||||
statusCode: reply.status,
|
||||
headers: reply.headers ?? {},
|
||||
body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []),
|
||||
};
|
||||
};
|
||||
const account = {
|
||||
getSession: vi.fn(async (..._a: unknown[]) => SESSION),
|
||||
markExpired: vi.fn(async (..._a: unknown[]) => {}),
|
||||
};
|
||||
const guard = new NextcloudLoginGuard();
|
||||
const clock = { t: 1_000_000 };
|
||||
guard.now = () => clock.t;
|
||||
const service = new NextcloudFilesSharesService(
|
||||
account as never,
|
||||
new NextcloudCallGate(),
|
||||
transport,
|
||||
guard,
|
||||
);
|
||||
return { service, account, calls, queue, clock, guard };
|
||||
}
|
||||
|
||||
const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code;
|
||||
const bodyOf = (e: unknown) => (e as { response: Record<string, unknown> }).response;
|
||||
const statusOf = (e: unknown) => (e as { getStatus(): number }).getStatus();
|
||||
|
||||
async function failure(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
throw new Error('es wurde kein Fehler geworfen');
|
||||
}
|
||||
|
||||
/** Antworten fuer ein erfolgreiches create(): stat, Faehigkeiten, Liste des Pfads, POST. */
|
||||
function createReplies(
|
||||
over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {},
|
||||
): Reply[] {
|
||||
return [
|
||||
over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') },
|
||||
over.caps ?? { status: 200, text: CAPS() },
|
||||
over.list ?? { status: 200, text: ocs([]) },
|
||||
over.post ?? { status: 200, text: ocs(shareJson()) },
|
||||
];
|
||||
}
|
||||
|
||||
const INPUT = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as const;
|
||||
|
||||
describe('NextcloudFilesSharesService — create', () => {
|
||||
it('sendet PROPFIND, Faehigkeiten, Liste des Pfads, POST in dieser Reihenfolge und liefert die Freigabe', async () => {
|
||||
const { service, account, calls } = setup(createReplies());
|
||||
const share = await service.create('t1', 'u1', INPUT);
|
||||
expect(account.getSession).toHaveBeenCalledWith('t1', 'u1');
|
||||
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
||||
`PROPFIND ${DAV}/Projekte`,
|
||||
`GET ${CAPS_URL}`,
|
||||
`GET ${BY_PATH}`,
|
||||
`POST ${SHARES}`,
|
||||
]);
|
||||
expect(calls[0].headers.depth).toBe('0');
|
||||
expect(calls[3].headers['content-type']).toBe('application/json');
|
||||
expect(calls[3].body).toBe(
|
||||
'{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}',
|
||||
);
|
||||
expect(share).toMatchObject({ id: '17', kind: 'user', shareWith: 'ben', access: 'edit' });
|
||||
});
|
||||
|
||||
it('Gruppe: shareType 1', async () => {
|
||||
const { service, calls } = setup(
|
||||
createReplies({
|
||||
post: { status: 200, text: ocs(shareJson({ share_type: 1, share_with: 'team' })) },
|
||||
}),
|
||||
);
|
||||
const share = await service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' });
|
||||
expect(calls[3].body).toBe(
|
||||
'{"path":"/Projekte","shareType":1,"shareWith":"team","permissions":15}',
|
||||
);
|
||||
expect(share.kind).toBe('group');
|
||||
});
|
||||
|
||||
it('Datei: Bearbeiten sendet 3, Ansehen sendet 1', async () => {
|
||||
for (const [access, permissions] of [
|
||||
['edit', 3],
|
||||
['view', 1],
|
||||
] as const) {
|
||||
const { service, calls } = setup([
|
||||
{ status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') },
|
||||
{ status: 200, text: CAPS() },
|
||||
{ status: 200, text: ocs([]) },
|
||||
{
|
||||
status: 200,
|
||||
text: ocs(shareJson({ path: '/Bericht.txt', item_type: 'file', permissions })),
|
||||
},
|
||||
]);
|
||||
await service.create('t1', 'u1', { ...INPUT, path: '/Bericht.txt', access });
|
||||
expect(calls[2].url).toBe(`${SHARES}?path=%2FBericht.txt&reshares=true`);
|
||||
expect(calls[3].body).toBe(
|
||||
`{"path":"/Bericht.txt","shareType":0,"shareWith":"ben","permissions":${permissions}}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('Hochladen fuer eine Person, oder Bearbeiten bei Buchstaben RG: 400 shareAccessInvalid, kein POST', async () => {
|
||||
const upload = setup(createReplies());
|
||||
const e1 = await failure(upload.service.create('t1', 'u1', { ...INPUT, access: 'upload' }));
|
||||
expect(codeOf(e1)).toBe('shareAccessInvalid');
|
||||
expect(statusOf(e1)).toBe(400);
|
||||
expect(upload.calls.some((c) => c.method === 'POST')).toBe(false);
|
||||
|
||||
const readonly = setup(
|
||||
createReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }),
|
||||
);
|
||||
const e2 = await failure(readonly.service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e2)).toBe('shareAccessInvalid');
|
||||
expect(readonly.calls.some((c) => c.method === 'POST')).toBe(false);
|
||||
});
|
||||
|
||||
it('der Empfaenger hat schon eine Freigabe: 409 shareAlreadyExists, kein POST', async () => {
|
||||
const { service, calls } = setup(
|
||||
createReplies({ list: { status: 200, text: ocs([shareJson()]) } }),
|
||||
);
|
||||
const e = await failure(service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e)).toBe('shareAlreadyExists');
|
||||
expect(statusOf(e)).toBe(409);
|
||||
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||
});
|
||||
|
||||
it('gleiche Kennung als Gruppe zaehlt nicht als vorhandene Personenfreigabe', async () => {
|
||||
const { service } = setup(
|
||||
createReplies({
|
||||
list: { status: 200, text: ocs([shareJson({ share_type: 1, share_with: 'ben' })]) },
|
||||
}),
|
||||
);
|
||||
await expect(service.create('t1', 'u1', INPUT)).resolves.toMatchObject({ id: '17' });
|
||||
});
|
||||
|
||||
it('Gruppen ausgeschaltet: 409 sharingDisabled mit der Gruppenmeldung, kein POST', async () => {
|
||||
const { service, calls } = setup(
|
||||
createReplies({ caps: { status: 200, text: CAPS({ group_sharing: false }) } }),
|
||||
);
|
||||
const e = await failure(
|
||||
service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' }),
|
||||
);
|
||||
expect(codeOf(e)).toBe('sharingDisabled');
|
||||
expect(statusOf(e)).toBe(409);
|
||||
expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.');
|
||||
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||
});
|
||||
|
||||
it('Freigabe-Schnittstelle aus: 409 sharingDisabled', async () => {
|
||||
const { service, calls } = setup(
|
||||
createReplies({ caps: { status: 200, text: CAPS({ api_enabled: false }) } }),
|
||||
);
|
||||
const e = await failure(service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e)).toBe('sharingDisabled');
|
||||
expect(bodyOf(e).message).toBe('Teilen ist in Ihrer Nextcloud ausgeschaltet.');
|
||||
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||
});
|
||||
|
||||
it('leerer Pfad oder Wurzel: 400 invalidPath ohne jeden Aufruf', async () => {
|
||||
for (const path of ['', '/']) {
|
||||
const { service, account, calls } = setup([]);
|
||||
const e = await failure(service.create('t1', 'u1', { ...INPUT, path }));
|
||||
expect(codeOf(e)).toBe('invalidPath');
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(account.getSession).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('Eintrag nicht vorhanden: 404 notFound, danach kein Freigabe-Aufruf', async () => {
|
||||
const { service, calls } = setup([{ status: 404 }]);
|
||||
const e = await failure(service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e)).toBe('notFound');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[404, undefined, 422, 'shareRecipientInvalid'],
|
||||
[403, 'You cannot share a folder that contains other shares', 422, 'shareRejected'],
|
||||
[400, undefined, 422, 'shareRejected'],
|
||||
[500, undefined, 502, 'nextcloudError'],
|
||||
[503, undefined, 503, 'nextcloudMaintenance'],
|
||||
])('POST antwortet %i -> %i %s', async (nc, message, http, code) => {
|
||||
const { service } = setup(
|
||||
createReplies({
|
||||
post: {
|
||||
status: nc,
|
||||
text: JSON.stringify({
|
||||
ocs: { meta: { statuscode: nc, message: message ?? 'x' }, data: [] },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const e = await failure(service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e)).toBe(code);
|
||||
expect(statusOf(e)).toBe(http);
|
||||
expect([401, 403]).not.toContain(statusOf(e));
|
||||
if (code === 'shareRejected' && message) expect(bodyOf(e).ncMessage).toBe(message);
|
||||
});
|
||||
|
||||
it('POST antwortet 401: 409 connectionExpired und die Verbindung ist als abgelaufen markiert', async () => {
|
||||
const { service, account } = setup(createReplies({ post: { status: 401 } }));
|
||||
const e = await failure(service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e)).toBe('connectionExpired');
|
||||
expect(statusOf(e)).toBe(409);
|
||||
expect(account.markExpired).toHaveBeenCalledTimes(1);
|
||||
expect(account.markExpired).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
|
||||
it('POST antwortet 429: 503 nextcloudLocked', async () => {
|
||||
const { service } = setup(
|
||||
createReplies({ post: { status: 429, headers: { 'retry-after': '60' } } }),
|
||||
);
|
||||
const e = await failure(service.create('t1', 'u1', INPUT));
|
||||
expect(codeOf(e)).toBe('nextcloudLocked');
|
||||
expect(statusOf(e)).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => {
|
||||
it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares ohne Aufruf, nach 10 Minuten geht es wieder', async () => {
|
||||
const replies: Reply[] = [];
|
||||
for (let i = 0; i < 15; i++) replies.push(...createReplies());
|
||||
// der abgewiesene 16. Versuch verbraucht nur die Vorpruefungen, nie den POST
|
||||
replies.push(...createReplies().slice(0, 3));
|
||||
replies.push(...createReplies());
|
||||
const { service, calls, clock } = setup(replies);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await service.create('t1', 'u1', { ...INPUT, shareWith: `ben${i}` });
|
||||
}
|
||||
const before = calls.length;
|
||||
const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }));
|
||||
expect(codeOf(e)).toBe('tooManyShares');
|
||||
expect(statusOf(e)).toBe(429);
|
||||
expect(bodyOf(e).retryAfterSeconds).toBe(600);
|
||||
// Vorpruefungen laufen, aber der POST wird nicht gesendet.
|
||||
expect(calls.slice(before).some((c) => c.method === 'POST')).toBe(false);
|
||||
|
||||
clock.t += 10 * 60 * 1000;
|
||||
await expect(
|
||||
service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('eine durch Vorpruefung abgelehnte Eingabe zaehlt nicht mit', async () => {
|
||||
const replies: Reply[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
replies.push(
|
||||
...createReplies({
|
||||
list: { status: 200, text: ocs([shareJson({ share_with: 'x' })]) },
|
||||
}).slice(0, 3),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 15; i++) replies.push(...createReplies());
|
||||
const { service } = setup(replies);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' }));
|
||||
expect(codeOf(e)).toBe('shareAlreadyExists');
|
||||
}
|
||||
for (let i = 0; i < 15; i++) await service.create('t1', 'u1', { ...INPUT, shareWith: `b${i}` });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudFilesSharesService — update', () => {
|
||||
const GET17 = `GET ${SHARES}/17`;
|
||||
|
||||
it('Ansehen: GET der Freigabe, dann PUT nur mit der neuen Berechtigung', async () => {
|
||||
const { service, calls } = setup([
|
||||
{ status: 200, text: ocs([shareJson()]) },
|
||||
{ status: 200, text: ocs(shareJson({ permissions: 1 })) },
|
||||
]);
|
||||
const share = await service.update('t1', 'u1', '17', { access: 'view' });
|
||||
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([GET17, `PUT ${SHARES}/17`]);
|
||||
expect(calls[1].body).toBe('{"permissions":1}');
|
||||
expect(share.access).toBe('view');
|
||||
});
|
||||
|
||||
it('Freigabe nicht aenderbar (can_edit false): 422 shareRejected ohne PUT', async () => {
|
||||
const { service, calls } = setup([
|
||||
{ status: 200, text: ocs([shareJson({ can_edit: false })]) },
|
||||
]);
|
||||
const e = await failure(service.update('t1', 'u1', '17', { access: 'view' }));
|
||||
expect(codeOf(e)).toBe('shareRejected');
|
||||
expect(statusOf(e)).toBe(422);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('PUT 404 -> 404 shareNotFound', async () => {
|
||||
const { service } = setup([
|
||||
{ status: 200, text: ocs([shareJson()]) },
|
||||
{ status: 404, text: ocs([], 404, 'Wrong share ID, share does not exist') },
|
||||
]);
|
||||
const e = await failure(service.update('t1', 'u1', '17', { access: 'view' }));
|
||||
expect(codeOf(e)).toBe('shareNotFound');
|
||||
expect(statusOf(e)).toBe(404);
|
||||
});
|
||||
|
||||
it('PUT 400 und 403 -> 422 shareRejected mit Meldung der Nextcloud', async () => {
|
||||
for (const nc of [400, 403]) {
|
||||
const { service } = setup([
|
||||
{ status: 200, text: ocs([shareJson()]) },
|
||||
{ status: nc, text: ocs([], nc, 'Failed to update share.') },
|
||||
]);
|
||||
const e = await failure(service.update('t1', 'u1', '17', { access: 'view' }));
|
||||
expect(codeOf(e)).toBe('shareRejected');
|
||||
expect(statusOf(e)).toBe(422);
|
||||
expect(bodyOf(e).ncMessage).toBe('Failed to update share.');
|
||||
}
|
||||
});
|
||||
|
||||
it("Kennung 'abc' und eine 21-stellige Kennung: 404 shareNotFound ohne Aufruf", async () => {
|
||||
for (const id of ['abc', '1'.repeat(21), '../x', '']) {
|
||||
const { service, calls } = setup([]);
|
||||
const e = await failure(service.update('t1', 'u1', id, { access: 'view' }));
|
||||
expect(codeOf(e)).toBe('shareNotFound');
|
||||
expect(calls).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('ohne Aenderung kommt die aktuelle Freigabe zurueck, es gibt kein PUT', async () => {
|
||||
const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]);
|
||||
const share = await service.update('t1', 'u1', '17', {});
|
||||
expect(share.id).toBe('17');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gleiche Berechtigung wie bisher: kein PUT', async () => {
|
||||
const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]);
|
||||
await service.update('t1', 'u1', '17', { access: 'edit' });
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Bearbeiten bei einem Eintrag ohne Schreibrecht und Hochladen sind shareAccessInvalid', async () => {
|
||||
const ro = setup([
|
||||
{ status: 200, text: ocs([shareJson({ permissions: 1, item_permissions: 17 })]) },
|
||||
]);
|
||||
expect(codeOf(await failure(ro.service.update('t1', 'u1', '17', { access: 'edit' })))).toBe(
|
||||
'shareAccessInvalid',
|
||||
);
|
||||
const up = setup([{ status: 200, text: ocs([shareJson()]) }]);
|
||||
expect(codeOf(await failure(up.service.update('t1', 'u1', '17', { access: 'upload' })))).toBe(
|
||||
'shareAccessInvalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('GET der Freigabe 404 -> shareNotFound; 401 markiert die Verbindung', async () => {
|
||||
const gone = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]);
|
||||
expect(codeOf(await failure(gone.service.update('t1', 'u1', '17', { access: 'view' })))).toBe(
|
||||
'shareNotFound',
|
||||
);
|
||||
const dead = setup([{ status: 401 }]);
|
||||
const e = await failure(dead.service.update('t1', 'u1', '17', { access: 'view' }));
|
||||
expect(codeOf(e)).toBe('connectionExpired');
|
||||
expect(dead.account.markExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => {
|
||||
it('remove: DELETE auf die Kennung', async () => {
|
||||
const { service, calls } = setup([{ status: 200, text: ocs([]) }]);
|
||||
await expect(service.remove('t1', 'u1', '17')).resolves.toEqual({ deleted: true });
|
||||
expect(calls[0].method).toBe('DELETE');
|
||||
expect(calls[0].url).toBe(`${SHARES}/17`);
|
||||
expect(calls[0].body).toBeNull();
|
||||
});
|
||||
|
||||
it('remove: 404 -> shareNotFound, 403 -> 422 shareRejected, falsche Kennung ohne Aufruf', async () => {
|
||||
const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]);
|
||||
expect(codeOf(await failure(nf.service.remove('t1', 'u1', '17')))).toBe('shareNotFound');
|
||||
const forbidden = setup([{ status: 403, text: ocs([], 403, 'Could not delete share') }]);
|
||||
const e = await failure(forbidden.service.remove('t1', 'u1', '17'));
|
||||
expect(codeOf(e)).toBe('shareRejected');
|
||||
expect(statusOf(e)).toBe(422);
|
||||
expect(bodyOf(e).ncMessage).toBe('Could not delete share');
|
||||
const bad = setup([]);
|
||||
expect(codeOf(await failure(bad.service.remove('t1', 'u1', 'abc')))).toBe('shareNotFound');
|
||||
expect(bad.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Freigaben eines Pfads: Pfad normalisiert, Wurzel ist invalidPath', async () => {
|
||||
const { service, calls } = setup([
|
||||
{ status: 200, text: ocs([shareJson(), { share_type: 4, id: '9' }]) },
|
||||
]);
|
||||
const out = await service.sharesForPath('t1', 'u1', '/Projekte/');
|
||||
expect(calls[0].url).toBe(BY_PATH);
|
||||
expect(out.path).toBe('/Projekte');
|
||||
expect(out.shares.map((s) => s.id)).toEqual(['17']);
|
||||
expect(out.hidden).toBe(1);
|
||||
const root = setup([]);
|
||||
expect(codeOf(await failure(root.service.sharesForPath('t1', 'u1', '/')))).toBe('invalidPath');
|
||||
const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong path') }]);
|
||||
expect(codeOf(await failure(nf.service.sharesForPath('t1', 'u1', '/x')))).toBe('notFound');
|
||||
});
|
||||
|
||||
it('Empfaengersuche: genau die erwartete Adresse; leerer Suchbegriff ruft nichts auf', async () => {
|
||||
const { service, calls } = setup([
|
||||
{
|
||||
status: 200,
|
||||
text: ocs({
|
||||
users: [
|
||||
{
|
||||
label: 'Ben Beispiel',
|
||||
value: { shareType: 0, shareWith: 'ben' },
|
||||
shareWithDisplayNameUnique: 'ben',
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const out = await service.sharees('t1', 'u1', ' ben ', 'folder');
|
||||
expect(calls[0].url).toBe(
|
||||
`${OCS}/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1`,
|
||||
);
|
||||
expect(out.sharees).toEqual([
|
||||
{ kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' },
|
||||
]);
|
||||
|
||||
const empty = setup([]);
|
||||
await expect(empty.service.sharees('t1', 'u1', ' ', 'file')).resolves.toEqual({ sharees: [] });
|
||||
expect(empty.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Richtlinie: zwei Aufrufe -> zwei Anfragen an die Faehigkeiten (kein Zwischenspeicher)', async () => {
|
||||
const { service, calls } = setup([
|
||||
{ status: 200, text: CAPS() },
|
||||
{ status: 200, text: CAPS({ group_sharing: false }) },
|
||||
]);
|
||||
const first = await service.policy('t1', 'u1');
|
||||
const second = await service.policy('t1', 'u1');
|
||||
expect(calls.map((c) => c.url)).toEqual([CAPS_URL, CAPS_URL]);
|
||||
expect(first.groupsEnabled).toBe(true);
|
||||
expect(second.groupsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('nie 401 oder 403 nach aussen, auch nicht bei 403 aus der Suche', async () => {
|
||||
const { service } = setup([{ status: 403, text: ocs([], 403, 'nope') }]);
|
||||
const e = await failure(service.sharees('t1', 'u1', 'be', 'file'));
|
||||
expect([401, 403]).not.toContain(statusOf(e));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 } from './nextcloud-http';
|
||||
import { NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||
import type { NcEntry } from './nextcloud-propfind';
|
||||
import {
|
||||
CAPABILITIES_SEGMENTS,
|
||||
isShareId,
|
||||
type NcItemType,
|
||||
type NcShareKind,
|
||||
type NcShareView,
|
||||
type NcSharee,
|
||||
type NcSharePolicy,
|
||||
OCS_CAPABILITIES_MAX_BYTES,
|
||||
type OcsShareOptions,
|
||||
ocsShareRequest,
|
||||
parseShareList,
|
||||
parseSharePolicy,
|
||||
parseSharees,
|
||||
permissionsFor,
|
||||
SHAREE_SEGMENTS,
|
||||
SHARES_BASE_SEGMENTS,
|
||||
} from './nextcloud-shares';
|
||||
import { mapNcFailure } from './nextcloud-upstream';
|
||||
|
||||
/**
|
||||
* Freigaben im Konto des angemeldeten Benutzers (quick-261009-dkv, D-11 bis D-16).
|
||||
*
|
||||
* Rechte: jede Methode beginnt mit `getSession(tenantId, userId)` — Mandant und
|
||||
* Benutzer kommen aus dem Token, jeder Aufruf geht mit dem eigenen App-Passwort des
|
||||
* Aufrufers an Nextcloud. Nextcloud bleibt die entscheidende Stelle (Eigentum,
|
||||
* Teilen-Recht, Richtlinien); die API prueft vorher nur, was sie aus den
|
||||
* Faehigkeiten und dem Eintrag selbst wissen kann, damit nie eine unlesbare
|
||||
* Nextcloud-Meldung entsteht (Nextcloud antwortet beim Aendern auf fast alles mit
|
||||
* "Failed to update share."). Dieser Dienst greift nicht auf die Datenbank zu.
|
||||
*
|
||||
* Fehlervertrag wie in Etappe 1: nie 401/403 an den Browser; Nextcloud-Meldungen
|
||||
* (uebersetzt, nie auswerten!) hoechstens als `ncMessage` in einer zweiten Zeile.
|
||||
* Nie loggen: Koerper, Passwoerter, Kennungen, Link-Adressen.
|
||||
*/
|
||||
|
||||
/** Eingabe fuer eine neue Freigabe an Person oder Gruppe (Links folgen in Aufgabe 2). */
|
||||
export interface CreateShareInput {
|
||||
path: string;
|
||||
kind: 'user' | 'group';
|
||||
shareWith: string;
|
||||
access: 'view' | 'edit' | 'upload';
|
||||
}
|
||||
|
||||
export interface UpdateShareInput {
|
||||
access?: 'view' | 'edit' | 'upload';
|
||||
}
|
||||
|
||||
/** Welche Felder gesendet wurden: bestimmt, wie ein Fehler gedeutet wird (D-15). */
|
||||
interface SentFields {
|
||||
kind?: NcShareKind;
|
||||
passwordNonEmpty?: boolean;
|
||||
expireDateSent?: boolean;
|
||||
expireDateNonEmpty?: boolean;
|
||||
}
|
||||
|
||||
type Operation = 'create' | 'update' | 'remove' | 'accept' | 'read-path' | 'read-id' | 'other';
|
||||
|
||||
const GROUPS_OFF = 'Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.';
|
||||
|
||||
function pathOf(segments: readonly string[]): string {
|
||||
return `/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NextcloudFilesSharesService {
|
||||
constructor(
|
||||
private readonly account: NextcloudFilesAccountService,
|
||||
private readonly gate: NextcloudCallGate,
|
||||
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
|
||||
private readonly guard: NextcloudLoginGuard,
|
||||
) {}
|
||||
|
||||
private session(tenantId: string, userId: string): Promise<NcSession> {
|
||||
return this.account.getSession(tenantId, userId);
|
||||
}
|
||||
|
||||
private async transportFail(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
result: { ok: boolean; kind?: string; status?: number; retryAfterSeconds?: number },
|
||||
): Promise<never> {
|
||||
throw await mapNcFailure(result, {
|
||||
onExpired: () => this.account.markExpired(tenantId, userId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D-15: ein nicht erfolgreicher Aufruf wird zu genau einem deutschen Fehler. Transportfehler,
|
||||
* abgelaufene Zugaenge, 429 und jeder Status ab 500 gehen unveraendert durch `mapNcFailure`
|
||||
* (Etappe-1-Vertrag). Danach zaehlt nur (Aufruf, Status, gesendete Felder) — nie der Text.
|
||||
*/
|
||||
private async mapShareFailure(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
operation: Operation,
|
||||
result: {
|
||||
ok: boolean;
|
||||
kind?: string;
|
||||
status?: number;
|
||||
message?: string | null;
|
||||
retryAfterSeconds?: number;
|
||||
},
|
||||
sent: SentFields,
|
||||
): Promise<never> {
|
||||
const status = result.status;
|
||||
if (!result.ok || status === undefined || status >= 500 || status === 401 || status === 429) {
|
||||
return this.transportFail(tenantId, userId, result);
|
||||
}
|
||||
if (status < 400) return this.transportFail(tenantId, userId, { ok: true, status: 502 });
|
||||
const extra = result.message ? { ncMessage: result.message } : undefined;
|
||||
const rejected = () => ncErrorDefault('shareRejected', extra);
|
||||
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
if (status === 400 && sent.passwordNonEmpty) {
|
||||
throw ncErrorDefault('sharePasswordRejected', extra);
|
||||
}
|
||||
if (status === 404 && sent.expireDateNonEmpty) {
|
||||
throw ncErrorDefault('shareExpiryInvalid', extra);
|
||||
}
|
||||
if (status === 404) {
|
||||
throw sent.kind === 'link'
|
||||
? ncErrorDefault('notFound')
|
||||
: ncErrorDefault('shareRecipientInvalid', extra);
|
||||
}
|
||||
throw rejected();
|
||||
case 'update':
|
||||
if (status === 400 && sent.passwordNonEmpty) {
|
||||
throw ncErrorDefault('sharePasswordRejected', extra);
|
||||
}
|
||||
if ((status === 400 || status === 404) && sent.expireDateSent) {
|
||||
throw ncErrorDefault('shareExpiryInvalid', extra);
|
||||
}
|
||||
if (status === 404) throw ncErrorDefault('shareNotFound');
|
||||
throw rejected();
|
||||
case 'remove':
|
||||
case 'accept':
|
||||
case 'read-id':
|
||||
if (status === 404) throw ncErrorDefault('shareNotFound');
|
||||
throw rejected();
|
||||
case 'read-path':
|
||||
if (status === 404) throw ncErrorDefault('notFound');
|
||||
throw rejected();
|
||||
default:
|
||||
throw rejected();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ruft Nextcloud auf und liefert die Daten einer erfolgreichen (2xx) Antwort, sonst wirft es. */
|
||||
private async run(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
session: NcSession,
|
||||
opts: OcsShareOptions,
|
||||
operation: Operation,
|
||||
sent: SentFields = {},
|
||||
): Promise<unknown> {
|
||||
const result = await ocsShareRequest(this.transport, this.gate, session, opts);
|
||||
if (!result.ok || result.status < 200 || result.status >= 300) {
|
||||
return this.mapShareFailure(tenantId, userId, operation, result, sent);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** D-13: Richtlinie bei jeder Nutzung frisch lesen (je Benutzer verschieden, nie zwischengespeichert). */
|
||||
private async loadPolicy(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
session: NcSession,
|
||||
): Promise<NcSharePolicy> {
|
||||
const data = await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{ method: 'GET', segments: CAPABILITIES_SEGMENTS, maxBytes: OCS_CAPABILITIES_MAX_BYTES },
|
||||
'other',
|
||||
);
|
||||
return parseSharePolicy(data);
|
||||
}
|
||||
|
||||
private async listByPath(tenantId: string, userId: string, session: NcSession, path: string) {
|
||||
const data = await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
query: { path, reshares: 'true' },
|
||||
},
|
||||
'read-path',
|
||||
);
|
||||
return parseShareList(data, session.ncUserId);
|
||||
}
|
||||
|
||||
private async loadShare(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
session: NcSession,
|
||||
id: string,
|
||||
): Promise<NcShareView> {
|
||||
const data = await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{ method: 'GET', segments: [...SHARES_BASE_SEGMENTS, id] },
|
||||
'read-id',
|
||||
);
|
||||
const share = parseShareList(data, session.ncUserId).shares[0];
|
||||
// Eine Freigabe anderer Art (E-Mail, Server ...) gibt es fuer Tessera nicht.
|
||||
if (!share) throw ncErrorDefault('shareNotFound');
|
||||
return share;
|
||||
}
|
||||
|
||||
// --- Lesen ----------------------------------------------------------------------------------
|
||||
|
||||
async policy(tenantId: string, userId: string): Promise<NcSharePolicy> {
|
||||
const session = await this.session(tenantId, userId);
|
||||
return this.loadPolicy(tenantId, userId, session);
|
||||
}
|
||||
|
||||
async sharesForPath(tenantId: string, userId: string, rawPath: string) {
|
||||
const segments = parseUserPath(rawPath);
|
||||
if (segments.length === 0) throw ncErrorDefault('invalidPath');
|
||||
const session = await this.session(tenantId, userId);
|
||||
const path = pathOf(segments);
|
||||
const list = await this.listByPath(tenantId, userId, session, path);
|
||||
return { path, ...list };
|
||||
}
|
||||
|
||||
async sharees(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
term: string,
|
||||
itemType: NcItemType,
|
||||
): Promise<{ sharees: NcSharee[] }> {
|
||||
const search = term.trim();
|
||||
if (search.length < 1) return { sharees: [] };
|
||||
const session = await this.session(tenantId, userId);
|
||||
const data = await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{
|
||||
method: 'GET',
|
||||
segments: SHAREE_SEGMENTS,
|
||||
query: {
|
||||
search,
|
||||
itemType,
|
||||
perPage: '20',
|
||||
'shareType[0]': '0',
|
||||
'shareType[1]': '1',
|
||||
},
|
||||
},
|
||||
'other',
|
||||
);
|
||||
return { sharees: parseSharees(data) };
|
||||
}
|
||||
|
||||
// --- Schreiben ------------------------------------------------------------------------------
|
||||
|
||||
/** Darf dieser Eintrag laut Nextcloud-Buchstaben geaendert oder befuellt werden (W, C oder K)? */
|
||||
private entryWritable(entry: NcEntry): boolean {
|
||||
return /[WCK]/.test(entry.permissions);
|
||||
}
|
||||
|
||||
async create(tenantId: string, userId: string, input: CreateShareInput): Promise<NcShareView> {
|
||||
const segments = parseUserPath(input.path);
|
||||
if (segments.length === 0) throw ncErrorDefault('invalidPath');
|
||||
const session = await this.session(tenantId, userId);
|
||||
|
||||
// Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01).
|
||||
const stat = await dav.stat(this.transport, this.gate, session, segments);
|
||||
if (!stat.ok) return this.transportFail(tenantId, userId, stat);
|
||||
if (stat.entry === null) {
|
||||
if (stat.status === 404) throw ncErrorDefault('notFound');
|
||||
return this.transportFail(tenantId, userId, { ok: true, status: stat.status });
|
||||
}
|
||||
const entry = stat.entry;
|
||||
|
||||
const policy = await this.loadPolicy(tenantId, userId, session);
|
||||
if (!policy.enabled) throw ncErrorDefault('sharingDisabled');
|
||||
if (input.kind === 'group' && !policy.groupsEnabled) {
|
||||
throw ncErrorDefault('sharingDisabled', undefined, GROUPS_OFF);
|
||||
}
|
||||
if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected');
|
||||
if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) {
|
||||
throw ncErrorDefault('shareAccessInvalid');
|
||||
}
|
||||
const permissions = permissionsFor(input.access, entry.type);
|
||||
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
|
||||
|
||||
const path = pathOf(segments);
|
||||
const existing = await this.listByPath(tenantId, userId, session, path);
|
||||
if (existing.shares.some((s) => s.kind === input.kind && s.shareWith === input.shareWith)) {
|
||||
throw ncErrorDefault('shareAlreadyExists');
|
||||
}
|
||||
|
||||
// Erst nach jeder Vorpruefung zaehlen: abgelehnte Eingaben verbrauchen kein Kontingent.
|
||||
this.guard.checkShareCreate(userId);
|
||||
const data = await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{
|
||||
method: 'POST',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
json: {
|
||||
path,
|
||||
shareType: input.kind === 'user' ? 0 : 1,
|
||||
shareWith: input.shareWith,
|
||||
permissions,
|
||||
},
|
||||
},
|
||||
'create',
|
||||
{ kind: input.kind },
|
||||
);
|
||||
const created = parseShareList(data, session.ncUserId).shares[0];
|
||||
if (!created) throw ncErrorDefault('nextcloudError');
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
input: UpdateShareInput,
|
||||
): Promise<NcShareView> {
|
||||
if (!isShareId(id)) throw ncErrorDefault('shareNotFound');
|
||||
const session = await this.session(tenantId, userId);
|
||||
const current = await this.loadShare(tenantId, userId, session, id);
|
||||
if (!current.canEdit) throw ncErrorDefault('shareRejected');
|
||||
if (input.access === undefined) return current;
|
||||
|
||||
if (current.kind === 'link') throw ncErrorDefault('shareAccessInvalid');
|
||||
if (input.access === 'upload' || (input.access === 'edit' && !current.itemWritable)) {
|
||||
throw ncErrorDefault('shareAccessInvalid');
|
||||
}
|
||||
const permissions = permissionsFor(input.access, current.itemType);
|
||||
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
|
||||
if (current.access === input.access) return current;
|
||||
|
||||
const data = await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{ method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json: { permissions } },
|
||||
'update',
|
||||
{ kind: current.kind },
|
||||
);
|
||||
const updated = parseShareList(data, session.ncUserId).shares[0];
|
||||
if (!updated) throw ncErrorDefault('nextcloudError');
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, userId: string, id: string): Promise<{ deleted: true }> {
|
||||
if (!isShareId(id)) throw ncErrorDefault('shareNotFound');
|
||||
const session = await this.session(tenantId, userId);
|
||||
await this.run(
|
||||
tenantId,
|
||||
userId,
|
||||
session,
|
||||
{ method: 'DELETE', segments: [...SHARES_BASE_SEGMENTS, id] },
|
||||
'remove',
|
||||
);
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ const ADMIN_HANDLERS = ['saveSettings'];
|
||||
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
|
||||
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
|
||||
const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload'];
|
||||
/** Teilen (quick-261009-dkv): alle auf Benutzen-Ebene. */
|
||||
const SHARE_HANDLERS = [
|
||||
'getSharePolicy',
|
||||
'listSharesForPath',
|
||||
'searchSharees',
|
||||
'createShare',
|
||||
'updateShare',
|
||||
'deleteShare',
|
||||
];
|
||||
const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM];
|
||||
|
||||
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
|
||||
@@ -109,6 +118,34 @@ describe('NextcloudFilesController — Metadaten', () => {
|
||||
expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']);
|
||||
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
|
||||
expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']);
|
||||
// Teilen (quick-261009-dkv)
|
||||
expect(route('getSharePolicy')).toEqual([0, 'shares/policy']);
|
||||
expect(route('listSharesForPath')).toEqual([0, 'shares/by-path']);
|
||||
expect(route('searchSharees')).toEqual([0, 'sharees']);
|
||||
expect(route('createShare')).toEqual([1, 'shares']);
|
||||
expect(route('updateShare')).toEqual([2, 'shares/:id']);
|
||||
expect(route('deleteShare')).toEqual([3, 'shares/:id']);
|
||||
});
|
||||
|
||||
it('die Teilen-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => {
|
||||
for (const name of SHARE_HANDLERS) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('die beiden :id-Handler des Teilens stehen nach allen statischen Handlern', () => {
|
||||
const names = routeHandlers();
|
||||
for (const name of ['getSharePolicy', 'listSharesForPath', 'searchSharees', 'createShare']) {
|
||||
expect(names.indexOf(name), name).toBeLessThan(names.indexOf('pollFlow'));
|
||||
}
|
||||
const staticLast = Math.max(
|
||||
...names
|
||||
.filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':'))
|
||||
.map((n) => names.indexOf(n)),
|
||||
);
|
||||
expect(names.indexOf('updateShare')).toBeGreaterThan(staticLast);
|
||||
expect(names.indexOf('deleteShare')).toBeGreaterThan(staticLast);
|
||||
});
|
||||
|
||||
it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => {
|
||||
@@ -246,6 +283,17 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function makeShares() {
|
||||
return {
|
||||
policy: vi.fn(async (..._a: unknown[]) => ({ enabled: true })),
|
||||
sharesForPath: vi.fn(async (..._a: unknown[]) => ({ shares: [] })),
|
||||
sharees: vi.fn(async (..._a: unknown[]) => ({ sharees: [] })),
|
||||
create: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
|
||||
update: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
|
||||
remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFiles() {
|
||||
return {
|
||||
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
|
||||
@@ -264,6 +312,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
files as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
const r = userReq('t1', 'u1');
|
||||
const res = { end: vi.fn() } as any;
|
||||
@@ -289,6 +338,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
transfer as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
const r = userReq('t1', 'u1');
|
||||
(r as any).headers = { range: 'bytes=0-99' };
|
||||
@@ -363,6 +413,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
transfer as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
const r = userReq('t1', undefined);
|
||||
const res = {} as any;
|
||||
@@ -378,6 +429,51 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
expect(transfer.uploadState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Teilen-Handler: Mandant und Benutzer aus dem Token, Eingaben aus Query und Body, Kennung als Zeichenkette', async () => {
|
||||
const shares = makeShares();
|
||||
const controller = new NextcloudFilesController(
|
||||
makeSettings() as any,
|
||||
makeAccount() as any,
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
shares as any,
|
||||
);
|
||||
const r = userReq('t1', 'u1');
|
||||
await controller.getSharePolicy(r);
|
||||
await controller.listSharesForPath(r, { path: '/Projekte' } as any);
|
||||
await controller.searchSharees(r, { term: 'ben', itemType: 'folder' } as any);
|
||||
const dto = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as any;
|
||||
await controller.createShare(r, dto);
|
||||
await controller.updateShare(r, '17', { access: 'view' } as any);
|
||||
await controller.deleteShare(r, '17');
|
||||
expect(shares.policy).toHaveBeenCalledWith('t1', 'u1');
|
||||
expect(shares.sharesForPath).toHaveBeenCalledWith('t1', 'u1', '/Projekte');
|
||||
expect(shares.sharees).toHaveBeenCalledWith('t1', 'u1', 'ben', 'folder');
|
||||
expect(shares.create).toHaveBeenCalledWith('t1', 'u1', dto);
|
||||
expect(shares.update).toHaveBeenCalledWith('t1', 'u1', '17', { access: 'view' });
|
||||
expect(shares.remove).toHaveBeenCalledWith('t1', 'u1', '17');
|
||||
});
|
||||
|
||||
it('Teilen-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
|
||||
const shares = makeShares();
|
||||
const controller = new NextcloudFilesController(
|
||||
makeSettings() as any,
|
||||
makeAccount() as any,
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
shares as any,
|
||||
);
|
||||
const r = userReq('t1', undefined);
|
||||
await expect(controller.getSharePolicy(r)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.createShare(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.deleteShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
const noTenant = userReq(undefined, 'u1');
|
||||
await expect(controller.getSharePolicy(noTenant)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
for (const fn of Object.values(shares)) expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
|
||||
const files = makeFiles();
|
||||
const controller = new NextcloudFilesController(
|
||||
@@ -386,6 +482,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
files as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
const r = userReq('t1', undefined);
|
||||
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
@@ -405,6 +502,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
await controller.getStatus(userReq('t1', 'u1'));
|
||||
const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any;
|
||||
@@ -439,6 +537,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any;
|
||||
await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any);
|
||||
@@ -463,6 +562,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
@@ -481,6 +581,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
serverInfo as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
const res = { end: vi.fn() } as any;
|
||||
await controller.getServer(userReq('t1', 'u1'));
|
||||
@@ -500,6 +601,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
makeFiles() as any,
|
||||
makeTransfer() as any,
|
||||
makeServerInfo() as any,
|
||||
makeShares() as any,
|
||||
);
|
||||
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
PathQueryDto,
|
||||
PreviewQueryDto,
|
||||
} from './dto/nextcloud-files-ops.dto';
|
||||
import {
|
||||
CreateShareDto,
|
||||
ShareByPathQueryDto,
|
||||
ShareeQueryDto,
|
||||
UpdateShareDto,
|
||||
} from './dto/nextcloud-files-shares.dto';
|
||||
import {
|
||||
SaveNextcloudFilesSettingsDto,
|
||||
TestNextcloudFilesSettingsDto,
|
||||
@@ -39,6 +45,7 @@ import {
|
||||
} from './dto/nextcloud-files-transfer.dto';
|
||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||
import {
|
||||
NextcloudFilesTransferService,
|
||||
@@ -64,10 +71,12 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
||||
* GET server/logo, POST connect/password, POST connect/flow,
|
||||
* DELETE connect, GET files, DELETE files, POST folders, POST move,
|
||||
* GET preview, GET download, POST download/zip, POST uploads, PUT
|
||||
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE
|
||||
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
|
||||
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
|
||||
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto.
|
||||
* uploads/file, GET shares/policy, GET shares/by-path, GET sharees,
|
||||
* POST shares (quick-261009-dkv: Teilen); danach die Parameterrouten am
|
||||
* ENDE: GET/DELETE connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n,
|
||||
* POST uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
|
||||
* uploads/:uploadId, PUT/DELETE shares/:id. Jeder Benutzer arbeitet nur
|
||||
* im eigenen Konto.
|
||||
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
|
||||
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
|
||||
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
|
||||
@@ -104,6 +113,7 @@ export class NextcloudFilesController {
|
||||
private readonly files: NextcloudFilesService,
|
||||
private readonly transfer: NextcloudFilesTransferService,
|
||||
private readonly serverInfo: NextcloudServerInfoService,
|
||||
private readonly shares: NextcloudFilesSharesService,
|
||||
) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
@@ -290,6 +300,39 @@ export class NextcloudFilesController {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Teilen (statisch; Benutzen; Freigaben gibt es nur im eigenen Konto) ----------------
|
||||
|
||||
/** Freigaberegeln der Nextcloud des Aufrufers (frisch gelesen, nie zwischengespeichert). */
|
||||
@Get('shares/policy')
|
||||
async getSharePolicy(@Req() req: AuthenticatedRequest) {
|
||||
return this.shares.policy(this.requireTenantId(req), this.requireUserId(req));
|
||||
}
|
||||
|
||||
@Get('shares/by-path')
|
||||
async listSharesForPath(@Req() req: AuthenticatedRequest, @Query() query: ShareByPathQueryDto) {
|
||||
return this.shares.sharesForPath(
|
||||
this.requireTenantId(req),
|
||||
this.requireUserId(req),
|
||||
query.path,
|
||||
);
|
||||
}
|
||||
|
||||
/** Empfaengersuche (Personen und Gruppen) ueber die Nextcloud. */
|
||||
@Get('sharees')
|
||||
async searchSharees(@Req() req: AuthenticatedRequest, @Query() query: ShareeQueryDto) {
|
||||
return this.shares.sharees(
|
||||
this.requireTenantId(req),
|
||||
this.requireUserId(req),
|
||||
query.term,
|
||||
query.itemType,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('shares')
|
||||
async createShare(@Req() req: AuthenticatedRequest, @Body() dto: CreateShareDto) {
|
||||
return this.shares.create(this.requireTenantId(req), this.requireUserId(req), dto);
|
||||
}
|
||||
|
||||
// --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) ---------------
|
||||
|
||||
@Get('connect/flow/:flowId')
|
||||
@@ -353,4 +396,19 @@ export class NextcloudFilesController {
|
||||
async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) {
|
||||
return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId);
|
||||
}
|
||||
|
||||
/** Die Kennung prueft der Dienst (nur Ziffern); die Berechtigung kommt als Auswahl, nie als Maske. */
|
||||
@Put('shares/:id')
|
||||
async updateShare(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateShareDto,
|
||||
) {
|
||||
return this.shares.update(this.requireTenantId(req), this.requireUserId(req), id, dto);
|
||||
}
|
||||
|
||||
@Delete('shares/:id')
|
||||
async deleteShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.shares.remove(this.requireTenantId(req), this.requireUserId(req), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NextcloudFilesController } from './nextcloud-files.controller';
|
||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
||||
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
||||
@@ -27,6 +28,7 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
||||
NextcloudFilesAccountService,
|
||||
NextcloudFilesService,
|
||||
NextcloudFilesTransferService,
|
||||
NextcloudFilesSharesService,
|
||||
NextcloudServerInfoService,
|
||||
NextcloudLoginGuard,
|
||||
LoginFlowStore,
|
||||
|
||||
@@ -41,7 +41,19 @@ export type NcErrorCode =
|
||||
| 'flowExpired'
|
||||
| 'tooManyFlows'
|
||||
| 'invalidUrl'
|
||||
| 'confirmReconnect';
|
||||
| 'confirmReconnect'
|
||||
| 'sharingDisabled'
|
||||
| 'linkSharingDisabled'
|
||||
| 'shareAccessInvalid'
|
||||
| 'shareRecipientInvalid'
|
||||
| 'shareAlreadyExists'
|
||||
| 'sharePasswordRequired'
|
||||
| 'sharePasswordRejected'
|
||||
| 'shareExpiryRequired'
|
||||
| 'shareExpiryInvalid'
|
||||
| 'shareRejected'
|
||||
| 'shareNotFound'
|
||||
| 'tooManyShares';
|
||||
|
||||
interface ErrorDefault {
|
||||
status: number;
|
||||
@@ -179,6 +191,56 @@ export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
|
||||
message:
|
||||
'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.',
|
||||
},
|
||||
sharingDisabled: {
|
||||
status: 409,
|
||||
message: 'Teilen ist in Ihrer Nextcloud ausgeschaltet.',
|
||||
},
|
||||
linkSharingDisabled: {
|
||||
status: 409,
|
||||
message: 'Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.',
|
||||
},
|
||||
shareAccessInvalid: {
|
||||
status: 400,
|
||||
message: 'Diese Berechtigung ist für diesen Eintrag nicht möglich.',
|
||||
},
|
||||
shareRecipientInvalid: {
|
||||
status: 422,
|
||||
message: 'Diese Person oder Gruppe kennt Ihre Nextcloud nicht.',
|
||||
},
|
||||
shareAlreadyExists: {
|
||||
status: 409,
|
||||
message: 'Der Eintrag ist schon so geteilt. Ändern Sie die vorhandene Freigabe.',
|
||||
},
|
||||
sharePasswordRequired: {
|
||||
status: 400,
|
||||
message: 'Ihre Nextcloud verlangt für Links ein Passwort.',
|
||||
},
|
||||
sharePasswordRejected: {
|
||||
status: 400,
|
||||
message:
|
||||
'Nextcloud lehnt dieses Passwort ab. Bitte wählen Sie ein längeres oder weniger gebräuchliches Passwort.',
|
||||
},
|
||||
shareExpiryRequired: {
|
||||
status: 400,
|
||||
message: 'Ihre Nextcloud verlangt für Links ein Ablaufdatum.',
|
||||
},
|
||||
shareExpiryInvalid: {
|
||||
status: 400,
|
||||
message:
|
||||
'Dieses Ablaufdatum lässt Ihre Nextcloud nicht zu. Es darf nicht in der Vergangenheit und nicht nach dem erlaubten Höchstdatum liegen.',
|
||||
},
|
||||
shareRejected: {
|
||||
status: 422,
|
||||
message: 'Nextcloud hat diese Freigabe abgelehnt.',
|
||||
},
|
||||
shareNotFound: {
|
||||
status: 404,
|
||||
message: 'Diese Freigabe gibt es nicht mehr.',
|
||||
},
|
||||
tooManyShares: {
|
||||
status: 429,
|
||||
message: 'Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie einige Minuten.',
|
||||
},
|
||||
};
|
||||
|
||||
/** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */
|
||||
|
||||
@@ -151,6 +151,47 @@ describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudLoginGuard — neue Freigaben (quick-261009-dkv, D-09)', () => {
|
||||
it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares mit Wartezeit bis zum Fensterende', () => {
|
||||
const { guard, clock } = makeGuard();
|
||||
for (let i = 0; i < 15; i++) {
|
||||
guard.checkShareCreate('u1');
|
||||
clock.t += 1000;
|
||||
}
|
||||
// aelteste Freigabe liegt 15 s zurueck -> 600 - 15 = 585 s
|
||||
const blocked = codeOf(() => guard.checkShareCreate('u1'));
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.body.code).toBe('tooManyShares');
|
||||
expect(blocked.body.retryAfterSeconds).toBe(585);
|
||||
});
|
||||
|
||||
it('Benutzer B ist von Benutzer A unabhaengig', () => {
|
||||
const { guard } = makeGuard();
|
||||
for (let i = 0; i < 15; i++) guard.checkShareCreate('a');
|
||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
||||
expect(codeOf(() => guard.checkShareCreate('b')).status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('nach 10 Minuten darf der Benutzer wieder; eine abgewiesene Freigabe zaehlt nicht mit', () => {
|
||||
const { guard, clock } = makeGuard();
|
||||
for (let i = 0; i < 15; i++) guard.checkShareCreate('a');
|
||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
||||
clock.t += 10 * MIN;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBeUndefined();
|
||||
}
|
||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
||||
});
|
||||
|
||||
it('beruehrt weder Fehlerzaehler noch Flow-Starts', () => {
|
||||
const { guard } = makeGuard();
|
||||
for (let i = 0; i < 15; i++) guard.checkShareCreate('u1');
|
||||
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||
expect(codeOf(() => guard.checkFlowStart('u1')).status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginFlowStore', () => {
|
||||
function makeStore() {
|
||||
const store = new LoginFlowStore();
|
||||
|
||||
@@ -37,6 +37,14 @@ export const SERVER_FAILURE_LIMIT = 8;
|
||||
export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000;
|
||||
export const FLOW_START_LIMIT = 10;
|
||||
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* Neue Freigaben je Benutzer (quick-261009-dkv, D-09): Nextcloud erlaubt 20 in 10
|
||||
* Minuten (`UserRateLimit`); deren 429 haette keinen `Retry-After` und legte die
|
||||
* Aufrufsperre ueber den ganzen Ursprung (alle Benutzer, 15 Minuten). Tessera
|
||||
* bremst deshalb schon bei 15.
|
||||
*/
|
||||
export const SHARE_CREATE_LIMIT = 15;
|
||||
export const SHARE_CREATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */
|
||||
interface Attempt {
|
||||
@@ -66,6 +74,12 @@ function removeAttempt(list: Attempt[] | undefined, attempt: Attempt): void {
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
}
|
||||
|
||||
function tooManyShares(retryAfterMs: number) {
|
||||
return ncErrorDefault('tooManyShares', {
|
||||
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
||||
});
|
||||
}
|
||||
|
||||
function tooMany(retryAfterMs: number) {
|
||||
return ncErrorDefault('tooManyAttempts', {
|
||||
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
||||
@@ -80,6 +94,7 @@ export class NextcloudLoginGuard {
|
||||
private readonly userFailures = new Map<string, Attempt[]>();
|
||||
private readonly serverFailures = new Map<string, Attempt[]>();
|
||||
private readonly flowStarts = new Map<string, number[]>();
|
||||
private readonly shareCreates = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
|
||||
@@ -167,6 +182,22 @@ export class NextcloudLoginGuard {
|
||||
starts.push(now);
|
||||
this.flowStarts.set(userId, starts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt eine neue Freigabe (15 je Benutzer in 10 Minuten); die 16. wird mit 429
|
||||
* `tooManyShares` abgewiesen, OHNE Nextcloud anzusprechen. Der Aufrufer ruft das erst
|
||||
* unmittelbar vor dem POST auf, nach jeder Vorpruefung — abgelehnte Eingaben zaehlen nicht.
|
||||
*/
|
||||
checkShareCreate(userId: string): void {
|
||||
const now = this.now();
|
||||
const times = pruneTimes(this.shareCreates.get(userId) ?? [], now, SHARE_CREATE_WINDOW_MS);
|
||||
if (times.length >= SHARE_CREATE_LIMIT) {
|
||||
this.shareCreates.set(userId, times);
|
||||
throw tooManyShares(times[0] + SHARE_CREATE_WINDOW_MS - now);
|
||||
}
|
||||
times.push(now);
|
||||
this.shareCreates.set(userId, times);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Browser-Anmeldung (Login Flow v2) -----------------------------------------
|
||||
|
||||
@@ -41,6 +41,7 @@ const XML = `<?xml version="1.0"?>
|
||||
<oc:permissions>RGDNVCK</oc:permissions>
|
||||
<oc:size>5000</oc:size>
|
||||
<oc:favorite>0</oc:favorite>
|
||||
<oc:share-types/>
|
||||
<nc:has-preview>false</nc:has-preview>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
@@ -63,6 +64,7 @@ const XML = `<?xml version="1.0"?>
|
||||
<oc:permissions>RGDNVW</oc:permissions>
|
||||
<oc:size>42</oc:size>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
<oc:share-types><oc:share-type>3</oc:share-type><oc:share-type>0</oc:share-type><oc:share-type>3</oc:share-type><oc:share-type>abc</oc:share-type></oc:share-types>
|
||||
<nc:has-preview>true</nc:has-preview>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
@@ -113,6 +115,7 @@ describe('parsePropfind', () => {
|
||||
permissions: 'RGDNVCK',
|
||||
hasPreview: false,
|
||||
favorite: false,
|
||||
shareTypes: [],
|
||||
},
|
||||
{
|
||||
name: '12345',
|
||||
@@ -126,6 +129,7 @@ describe('parsePropfind', () => {
|
||||
permissions: 'RGDNVW',
|
||||
hasPreview: true,
|
||||
favorite: true,
|
||||
shareTypes: [0, 3],
|
||||
},
|
||||
{
|
||||
name: '50%25.txt',
|
||||
@@ -139,10 +143,18 @@ describe('parsePropfind', () => {
|
||||
permissions: 'RGDNVW',
|
||||
hasPreview: false,
|
||||
favorite: false,
|
||||
shareTypes: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Freigabearten (oc:share-types): doppelte und Nichtzahlen entfallen, leeres Element ist leer (quick-261009-dkv)', () => {
|
||||
const entries = parsePropfind(XML, CTX).entries;
|
||||
expect(entries.find((e) => e.name === '12345')?.shareTypes).toEqual([0, 3]);
|
||||
expect(entries.find((e) => e.name === 'Ärger & Co')?.shareTypes).toEqual([]);
|
||||
expect(entries.find((e) => e.name === '50%25.txt')?.shareTypes).toEqual([]);
|
||||
});
|
||||
|
||||
it('der Name bleibt eine Zeichenkette (12345), nie eine Zahl', () => {
|
||||
const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042');
|
||||
expect(typeof entry?.name).toBe('string');
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface NcEntry {
|
||||
permissions: string;
|
||||
hasPreview: boolean;
|
||||
favorite: boolean;
|
||||
/**
|
||||
* Freigabearten, in denen der Eintrag geteilt ist (`oc:share-types`: 0 Person, 1 Gruppe,
|
||||
* 3 Link ...), aufsteigend und ohne Doppelte; leer = nicht (selbst) geteilt (quick-261009-dkv).
|
||||
*/
|
||||
shareTypes: number[];
|
||||
}
|
||||
|
||||
export interface NcQuota {
|
||||
@@ -173,6 +178,17 @@ function isoOf(value: unknown): string | null {
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
||||
}
|
||||
|
||||
/** `<oc:share-types><oc:share-type>0</oc:share-type>...` -> ganze Zahlen 0..99, aufsteigend, ohne Doppelte. */
|
||||
function shareTypesOf(value: unknown): number[] {
|
||||
if (!isDict(value)) return [];
|
||||
const found = new Set<number>();
|
||||
for (const item of asArray(value['share-type'] as unknown)) {
|
||||
const n = numberOf(item);
|
||||
if (n !== null && Number.isInteger(n) && n >= 0 && n <= 99) found.add(n);
|
||||
}
|
||||
return [...found].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function buildEntry(rel: readonly string[], props: Dict): NcEntry {
|
||||
const folder = isFolder(props);
|
||||
const sizeRaw = folder
|
||||
@@ -191,6 +207,7 @@ function buildEntry(rel: readonly string[], props: Dict): NcEntry {
|
||||
permissions: text(props.permissions) ?? '',
|
||||
hasPreview: text(props['has-preview']) === 'true',
|
||||
favorite: text(props.favorite) === '1',
|
||||
shareTypes: shareTypesOf(props['share-types']),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import type { NcSession } from './nextcloud-files.types';
|
||||
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
|
||||
import {
|
||||
accessOf,
|
||||
OCS_OK_MAX_BYTES,
|
||||
ocsShareRequest,
|
||||
parseShare,
|
||||
parseShareList,
|
||||
parseSharePolicy,
|
||||
parseSharees,
|
||||
permissionsFor,
|
||||
SHARES_BASE_SEGMENTS,
|
||||
} from './nextcloud-shares';
|
||||
|
||||
const SESSION: NcSession = {
|
||||
baseUrl: 'https://cloud.example/nc',
|
||||
ncUserId: 'anna',
|
||||
authorization: 'Basic YW5uYTphcHAtcHctMTIz',
|
||||
credentialKey: 'k1',
|
||||
};
|
||||
const SHARES = 'https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares';
|
||||
|
||||
type Reply = { status: number; text?: string; headers?: Record<string, string> };
|
||||
|
||||
function setup(reply: Reply) {
|
||||
const calls: NcTransportRequest[] = [];
|
||||
const gate = new NextcloudCallGate();
|
||||
const transport: NextcloudTransport = async (req) => {
|
||||
calls.push(req);
|
||||
return {
|
||||
statusCode: reply.status,
|
||||
headers: reply.headers ?? {},
|
||||
body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []),
|
||||
};
|
||||
};
|
||||
return { calls, gate, transport };
|
||||
}
|
||||
|
||||
const ok = (data: unknown, message = 'OK') =>
|
||||
JSON.stringify({ ocs: { meta: { status: 'ok', statuscode: 200, message }, data } });
|
||||
|
||||
/** Lebende Antwort (Research, Nextcloud 34.0.4): Ordner /Projekte an zoe. */
|
||||
const USER_SHARE = {
|
||||
id: '1',
|
||||
share_type: 0,
|
||||
uid_owner: 'anna',
|
||||
displayname_owner: 'Anna Müller',
|
||||
permissions: 31,
|
||||
can_edit: true,
|
||||
can_delete: true,
|
||||
stime: 1791532378,
|
||||
parent: null,
|
||||
expiration: '2026-12-31 23:59:59',
|
||||
token: null,
|
||||
uid_file_owner: 'anna',
|
||||
note: '',
|
||||
label: '',
|
||||
displayname_file_owner: 'Anna Müller',
|
||||
path: '/Projekte',
|
||||
item_type: 'folder',
|
||||
item_permissions: 31,
|
||||
'is-mount-root': false,
|
||||
'mount-type': '',
|
||||
mimetype: 'httpd/unix-directory',
|
||||
has_preview: false,
|
||||
storage_id: 'home::anna',
|
||||
storage: 3,
|
||||
item_source: 294,
|
||||
file_source: 294,
|
||||
file_parent: 93,
|
||||
file_target: '/Projekte',
|
||||
item_size: 6810,
|
||||
item_mtime: 1791489905,
|
||||
share_with: 'zoe',
|
||||
share_with_displayname: 'Zwei Faktor',
|
||||
share_with_displayname_unique: 'zoe',
|
||||
mail_send: 1,
|
||||
hide_download: 0,
|
||||
attributes: null,
|
||||
};
|
||||
|
||||
const LINK_SHARE = {
|
||||
...USER_SHARE,
|
||||
id: '5',
|
||||
share_type: 3,
|
||||
path: '/Projekte/Angebot.pdf',
|
||||
file_target: '/Angebot.pdf',
|
||||
item_type: 'file',
|
||||
mimetype: 'application/pdf',
|
||||
item_permissions: 19,
|
||||
permissions: 1,
|
||||
expiration: null,
|
||||
token: 'AbC123',
|
||||
url: 'http://cloud.example/nc/index.php/s/AbC123',
|
||||
password: 'redacted',
|
||||
share_with: 'redacted',
|
||||
share_with_displayname: '(Geteilter Link)',
|
||||
label: 'Kunde',
|
||||
};
|
||||
|
||||
const SHAREES = {
|
||||
exact: {
|
||||
users: [],
|
||||
groups: [
|
||||
{
|
||||
label: 'twofa',
|
||||
value: { shareType: 1, shareWith: 'twofa' },
|
||||
},
|
||||
],
|
||||
remotes: [],
|
||||
emails: [],
|
||||
},
|
||||
users: [
|
||||
{
|
||||
label: 'Zwei Faktor',
|
||||
subline: '',
|
||||
icon: 'icon-user',
|
||||
value: { shareType: 0, shareWith: 'zoe' },
|
||||
shareWithDisplayNameUnique: 'zoe',
|
||||
status: [],
|
||||
},
|
||||
],
|
||||
groups: [{ label: 'twofa', value: { shareType: 1, shareWith: 'twofa' } }],
|
||||
remotes: [{ label: 'x', value: { shareType: 6, shareWith: 'x@y' } }],
|
||||
emails: [{ label: 'm', value: { shareType: 4, shareWith: 'm@y' } }],
|
||||
lookup: [],
|
||||
};
|
||||
|
||||
const CAPS = {
|
||||
version: { major: 34 },
|
||||
capabilities: {
|
||||
password_policy: { minLength: 10, enforceNonCommonPassword: true },
|
||||
files_sharing: {
|
||||
api_enabled: true,
|
||||
public: {
|
||||
enabled: true,
|
||||
password: { enforced: false, askForOptionalPassword: false },
|
||||
expire_date: { enabled: false },
|
||||
expire_date_internal: { enabled: false },
|
||||
upload: true,
|
||||
upload_files_drop: true,
|
||||
multiple_links: true,
|
||||
},
|
||||
resharing: true,
|
||||
group_sharing: true,
|
||||
default_permissions: 31,
|
||||
sharee: { minSearchStringLength: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ocsShareRequest — genaue Aufrufe', () => {
|
||||
it('Freigaben eines Pfads: GET mit codiertem Pfad, Auth- und OCS-Kopfzeilen, ohne Cookie', async () => {
|
||||
const { calls, gate, transport } = setup({ status: 200, text: ok([]) });
|
||||
await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
query: { path: '/Projekte/Ärger 100%', reshares: 'true' },
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].method).toBe('GET');
|
||||
expect(calls[0].url).toBe(`${SHARES}?path=%2FProjekte%2F%C3%84rger%20100%25&reshares=true`);
|
||||
expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz');
|
||||
expect(calls[0].headers['ocs-apirequest']).toBe('true');
|
||||
expect(calls[0].headers.accept).toBe('application/json');
|
||||
expect(calls[0].headers.cookie).toBeUndefined();
|
||||
expect(calls[0].body).toBeNull();
|
||||
});
|
||||
|
||||
it('Empfaengersuche: genau die erwartete Adresse mit durchnummerierten shareType-Schluesseln', async () => {
|
||||
const { calls, gate, transport } = setup({ status: 200, text: ok({}) });
|
||||
await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: ['apps', 'files_sharing', 'api', 'v1', 'sharees'],
|
||||
query: {
|
||||
search: 'ben',
|
||||
itemType: 'folder',
|
||||
perPage: '20',
|
||||
'shareType[0]': '0',
|
||||
'shareType[1]': '1',
|
||||
},
|
||||
});
|
||||
expect(calls[0].url).toBe(
|
||||
'https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('Anlegen: POST mit JSON-Kopfzeile und dem Koerper als Zeichenkette', async () => {
|
||||
const { calls, gate, transport } = setup({ status: 200, text: ok(USER_SHARE) });
|
||||
await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'POST',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
json: { path: '/Projekte', shareType: 0, shareWith: 'ben', permissions: 15 },
|
||||
});
|
||||
expect(calls[0].method).toBe('POST');
|
||||
expect(calls[0].url).toBe(SHARES);
|
||||
expect(calls[0].headers['content-type']).toBe('application/json');
|
||||
expect(calls[0].body).toBe(
|
||||
'{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}',
|
||||
);
|
||||
});
|
||||
|
||||
it('Aendern und Loeschen: Kennung als eigenes Segment, DELETE ohne Koerper', async () => {
|
||||
const put = setup({ status: 200, text: ok(USER_SHARE) });
|
||||
await ocsShareRequest(put.transport, put.gate, SESSION, {
|
||||
method: 'PUT',
|
||||
segments: [...SHARES_BASE_SEGMENTS, '17'],
|
||||
json: { permissions: 1 },
|
||||
});
|
||||
expect(put.calls[0].method).toBe('PUT');
|
||||
expect(put.calls[0].url).toBe(`${SHARES}/17`);
|
||||
expect(put.calls[0].body).toBe('{"permissions":1}');
|
||||
|
||||
const del = setup({ status: 200, text: ok([]) });
|
||||
await ocsShareRequest(del.transport, del.gate, SESSION, {
|
||||
method: 'DELETE',
|
||||
segments: [...SHARES_BASE_SEGMENTS, '17'],
|
||||
});
|
||||
expect(del.calls[0].method).toBe('DELETE');
|
||||
expect(del.calls[0].url).toBe(`${SHARES}/17`);
|
||||
expect(del.calls[0].body).toBeNull();
|
||||
expect(del.calls[0].headers['content-type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Faehigkeiten: GET cloud/capabilities', async () => {
|
||||
const { calls, gate, transport } = setup({ status: 200, text: ok(CAPS) });
|
||||
const res = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: ['cloud', 'capabilities'],
|
||||
});
|
||||
expect(calls[0].url).toBe('https://cloud.example/nc/ocs/v2.php/cloud/capabilities');
|
||||
expect(res.ok && res.data).toEqual(CAPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ocsShareRequest — Antworten', () => {
|
||||
const NOT_FOUND =
|
||||
'{"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Wrong share ID, share does not exist"},"data":[]}}';
|
||||
|
||||
it('404 mit OCS-Text kommt als ok:true mit Status und Text', async () => {
|
||||
const { gate, transport } = setup({ status: 404, text: NOT_FOUND });
|
||||
const res = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: [...SHARES_BASE_SEGMENTS, '9'],
|
||||
});
|
||||
expect(res).toMatchObject({
|
||||
ok: true,
|
||||
status: 404,
|
||||
message: 'Wrong share ID, share does not exist',
|
||||
});
|
||||
});
|
||||
|
||||
it('der Text wird von Steuerzeichen befreit und auf 300 Zeichen gekuerzt', async () => {
|
||||
const dirty = `a\u0000b\u001b[31m${'x'.repeat(500)}`;
|
||||
const { gate, transport } = setup({ status: 400, text: ok([], dirty) });
|
||||
const res = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
});
|
||||
expect(res.ok && res.message).not.toBeNull();
|
||||
const message = res.ok ? (res.message as string) : '';
|
||||
expect(message).toHaveLength(300);
|
||||
expect(message.startsWith('a b [31m')).toBe(true);
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Pruefung auf Steuerzeichen
|
||||
expect(/[\u0000-\u001f\u007f]/.test(message)).toBe(false);
|
||||
});
|
||||
|
||||
it('kein JSON bei 2xx -> invalid-response; bei 4xx nur ohne Text', async () => {
|
||||
const good = setup({ status: 200, text: '<html>' });
|
||||
expect(
|
||||
await ocsShareRequest(good.transport, good.gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
}),
|
||||
).toEqual({ ok: false, kind: 'invalid-response' });
|
||||
|
||||
const bad = setup({ status: 400, text: '<html>' });
|
||||
expect(
|
||||
await ocsShareRequest(bad.transport, bad.gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
}),
|
||||
).toEqual({ ok: true, status: 400, message: null, data: null });
|
||||
});
|
||||
|
||||
it('eine 2xx-Antwort ueber 8 MiB ist too-large', async () => {
|
||||
const big = `${ok('x')}${' '.repeat(OCS_OK_MAX_BYTES)}`;
|
||||
const { gate, transport } = setup({ status: 200, text: big });
|
||||
const res = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
});
|
||||
expect(res).toMatchObject({ ok: false, kind: 'too-large' });
|
||||
});
|
||||
|
||||
it('eine Fehlerantwort ueber 64 KiB bleibt ein Fehlerstatus ohne Text', async () => {
|
||||
const big = `${NOT_FOUND}${' '.repeat(70 * 1024)}`;
|
||||
const { gate, transport } = setup({ status: 404, text: big });
|
||||
const res = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
});
|
||||
expect(res).toEqual({ ok: true, status: 404, message: null, data: null });
|
||||
});
|
||||
|
||||
it('429 haelt den Ursprung an: der naechste Aufruf wird nicht gesendet', async () => {
|
||||
const { calls, gate, transport } = setup({ status: 429, headers: { 'retry-after': '30' } });
|
||||
const first = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'POST',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
json: { path: '/a' },
|
||||
});
|
||||
expect(first).toMatchObject({ ok: false, kind: 'http', status: 429 });
|
||||
const second = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
});
|
||||
expect(second).toMatchObject({ ok: false, kind: 'paused' });
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('401 -> credential-dead', async () => {
|
||||
const { gate, transport } = setup({ status: 401 });
|
||||
const res = await ocsShareRequest(transport, gate, SESSION, {
|
||||
method: 'GET',
|
||||
segments: SHARES_BASE_SEGMENTS,
|
||||
});
|
||||
expect(res).toMatchObject({ ok: false, kind: 'credential-dead' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseShare', () => {
|
||||
it('die lebende Personenfreigabe wird zu genau der kleinen Ansicht', () => {
|
||||
expect(parseShare(USER_SHARE, 'anna')).toEqual({
|
||||
id: '1',
|
||||
kind: 'user',
|
||||
path: '/Projekte',
|
||||
name: 'Projekte',
|
||||
itemType: 'folder',
|
||||
mime: null,
|
||||
itemWritable: true,
|
||||
permissions: 31,
|
||||
access: 'edit',
|
||||
shareWith: 'zoe',
|
||||
shareWithName: 'Zwei Faktor',
|
||||
ownerId: 'anna',
|
||||
ownerName: 'Anna Müller',
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
expiration: '2026-12-31',
|
||||
label: '',
|
||||
url: null,
|
||||
hasPassword: false,
|
||||
target: '/Projekte',
|
||||
sharedAt: '2026-10-09T07:52:58.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('kopiert keine unbekannten Felder', () => {
|
||||
const json = JSON.stringify(parseShare(USER_SHARE, 'anna'));
|
||||
for (const word of ['storage_id', 'attributes', 'mail_send', 'item_source', 'token']) {
|
||||
expect(json).not.toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
it('Link des Eigentuemers: Adresse bleibt, Passwort nur als hasPassword, nie "redacted"', () => {
|
||||
const own = parseShare(LINK_SHARE, 'anna');
|
||||
expect(own).toMatchObject({
|
||||
kind: 'link',
|
||||
hasPassword: true,
|
||||
url: 'http://cloud.example/nc/index.php/s/AbC123',
|
||||
shareWith: null,
|
||||
shareWithName: null,
|
||||
itemType: 'file',
|
||||
mime: 'application/pdf',
|
||||
label: 'Kunde',
|
||||
name: 'Angebot.pdf',
|
||||
});
|
||||
expect(JSON.stringify(own)).not.toContain('redacted');
|
||||
expect(JSON.stringify(own)).not.toContain('"token"');
|
||||
});
|
||||
|
||||
it('dieselbe Freigabe fuer einen anderen Betrachter hat keine Adresse', () => {
|
||||
expect(parseShare(LINK_SHARE, 'zoe')?.url).toBeNull();
|
||||
});
|
||||
|
||||
it('eine Adresse mit anderem Schema wird verworfen', () => {
|
||||
expect(parseShare({ ...LINK_SHARE, url: 'javascript:alert(1)' }, 'anna')?.url).toBeNull();
|
||||
expect(parseShare({ ...LINK_SHARE, url: 'ftp://x.example/s/1' }, 'anna')?.url).toBeNull();
|
||||
});
|
||||
|
||||
it('andere Arten und fehlerhafte Kennungen ergeben null', () => {
|
||||
expect(parseShare({ ...USER_SHARE, share_type: 4 }, 'anna')).toBeNull();
|
||||
expect(parseShare({ ...USER_SHARE, share_type: 6 }, 'anna')).toBeNull();
|
||||
expect(parseShare({ ...USER_SHARE, id: 'abc' }, 'anna')).toBeNull();
|
||||
expect(parseShare('x', 'anna')).toBeNull();
|
||||
});
|
||||
|
||||
it('eingehende Freigabe: Name aus dem Ziel im eigenen Baum, Eigentuemer fremd', () => {
|
||||
const incoming = parseShare(
|
||||
{
|
||||
...USER_SHARE,
|
||||
uid_owner: 'ben',
|
||||
displayname_owner: 'Ben Beispiel',
|
||||
path: '/Ben-Ordner/Quelle',
|
||||
file_target: '/Ben-Ordner',
|
||||
permissions: 1,
|
||||
item_permissions: 1,
|
||||
can_edit: false,
|
||||
},
|
||||
'anna',
|
||||
);
|
||||
expect(incoming).toMatchObject({
|
||||
name: 'Ben-Ordner',
|
||||
target: '/Ben-Ordner',
|
||||
ownerId: 'ben',
|
||||
ownerName: 'Ben Beispiel',
|
||||
access: 'view',
|
||||
itemWritable: false,
|
||||
canEdit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('Anzeigetexte: Steuerzeichen raus', () => {
|
||||
const share = parseShare(
|
||||
{ ...USER_SHARE, share_with_displayname: 'Z\u0007oe\n Faktor' },
|
||||
'anna',
|
||||
);
|
||||
expect(share?.shareWithName).toBe('Z oe Faktor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessOf / permissionsFor', () => {
|
||||
it.each([
|
||||
[1, 'view'],
|
||||
[17, 'view'],
|
||||
[4, 'upload'],
|
||||
[3, 'edit'],
|
||||
[15, 'edit'],
|
||||
[31, 'edit'],
|
||||
[0, 'custom'],
|
||||
[16, 'custom'],
|
||||
])('accessOf(%i) = %s', (permissions, access) => {
|
||||
expect(accessOf(permissions)).toBe(access);
|
||||
});
|
||||
|
||||
it('permissionsFor nach Eintragsart', () => {
|
||||
expect(permissionsFor('edit', 'folder')).toBe(15);
|
||||
expect(permissionsFor('edit', 'file')).toBe(3);
|
||||
expect(permissionsFor('view', 'folder')).toBe(1);
|
||||
expect(permissionsFor('view', 'file')).toBe(1);
|
||||
expect(permissionsFor('upload', 'folder')).toBe(4);
|
||||
expect(permissionsFor('upload', 'file')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseShareList', () => {
|
||||
it('nimmt ein Feld (GET) und ein Objekt (POST/PUT), zaehlt andere Arten als versteckt', () => {
|
||||
const list = parseShareList(
|
||||
[USER_SHARE, { ...USER_SHARE, id: '2', share_type: 4 }, LINK_SHARE],
|
||||
'anna',
|
||||
);
|
||||
expect(list.shares.map((s) => s.id)).toEqual(['1', '5']);
|
||||
expect(list.hidden).toBe(1);
|
||||
expect(list.truncated).toBe(false);
|
||||
expect(parseShareList(USER_SHARE, 'anna').shares).toHaveLength(1);
|
||||
expect(parseShareList(null, 'anna')).toEqual({ shares: [], hidden: 0, truncated: false });
|
||||
});
|
||||
|
||||
it('behaelt hoechstens 2000 und meldet truncated', () => {
|
||||
const many = Array.from({ length: 2003 }, (_, i) => ({ ...USER_SHARE, id: String(i + 1) }));
|
||||
const list = parseShareList(many, 'anna');
|
||||
expect(list.shares).toHaveLength(2000);
|
||||
expect(list.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSharees', () => {
|
||||
it('fuehrt genaue und weitere Treffer zusammen, ohne Doppelte, nur Personen und Gruppen', () => {
|
||||
expect(parseSharees(SHAREES)).toEqual([
|
||||
{ kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' },
|
||||
{ kind: 'group', id: 'twofa', label: 'twofa', detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hoechstens 25 Treffer; Unsinn ergibt eine leere Liste', () => {
|
||||
const users = Array.from({ length: 40 }, (_, i) => ({
|
||||
label: `U${i}`,
|
||||
value: { shareType: 0, shareWith: `u${i}` },
|
||||
}));
|
||||
expect(parseSharees({ users })).toHaveLength(25);
|
||||
expect(parseSharees(null)).toEqual([]);
|
||||
expect(parseSharees({ users: 'x' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSharePolicy', () => {
|
||||
it('die lebenden Faehigkeiten', () => {
|
||||
expect(parseSharePolicy(CAPS)).toEqual({
|
||||
enabled: true,
|
||||
groupsEnabled: true,
|
||||
links: {
|
||||
enabled: true,
|
||||
passwordRequired: false,
|
||||
passwordSuggested: false,
|
||||
expiryDefaultDays: null,
|
||||
expiryEnforced: false,
|
||||
uploadAllowed: true,
|
||||
multipleLinks: true,
|
||||
},
|
||||
internalExpiry: { defaultDays: null, enforced: false },
|
||||
minSearchLength: 0,
|
||||
passwordMinLength: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('Links aus (public = { enabled: false }) schaltet jede Linkregel ab', () => {
|
||||
const caps = {
|
||||
capabilities: { files_sharing: { api_enabled: true, public: { enabled: false } } },
|
||||
};
|
||||
const policy = parseSharePolicy(caps);
|
||||
expect(policy.enabled).toBe(true);
|
||||
expect(policy.links).toEqual({
|
||||
enabled: false,
|
||||
passwordRequired: false,
|
||||
passwordSuggested: false,
|
||||
expiryDefaultDays: null,
|
||||
expiryEnforced: false,
|
||||
uploadAllowed: false,
|
||||
multipleLinks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('Ablauf erzwungen mit Tagen als Zeichenkette; ungueltige Tage ergeben null', () => {
|
||||
const withExpiry = (expire_date: unknown) => ({
|
||||
capabilities: {
|
||||
files_sharing: { api_enabled: true, public: { enabled: true, expire_date } },
|
||||
},
|
||||
});
|
||||
const enforced = parseSharePolicy(withExpiry({ enabled: true, days: '7', enforced: true }));
|
||||
expect(enforced.links.expiryDefaultDays).toBe(7);
|
||||
expect(enforced.links.expiryEnforced).toBe(true);
|
||||
expect(
|
||||
parseSharePolicy(withExpiry({ enabled: true, days: 'abc', enforced: true })).links
|
||||
.expiryDefaultDays,
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseSharePolicy(withExpiry({ enabled: true, days: '0' })).links.expiryDefaultDays,
|
||||
).toBeNull();
|
||||
// ohne enabled gibt es keine Regel, auch wenn Tage dastehen
|
||||
const disabled = parseSharePolicy(withExpiry({ enabled: false, days: '7', enforced: true }));
|
||||
expect(disabled.links.expiryDefaultDays).toBeNull();
|
||||
expect(disabled.links.expiryEnforced).toBe(false);
|
||||
});
|
||||
|
||||
it('Passwort erzwungen / empfohlen', () => {
|
||||
const withPw = (password: unknown) => ({
|
||||
capabilities: {
|
||||
files_sharing: { api_enabled: true, public: { enabled: true, password } },
|
||||
},
|
||||
});
|
||||
expect(parseSharePolicy(withPw({ enforced: true })).links.passwordRequired).toBe(true);
|
||||
const suggested = parseSharePolicy(withPw({ enforced: false, askForOptionalPassword: true }));
|
||||
expect(suggested.links.passwordRequired).toBe(false);
|
||||
expect(suggested.links.passwordSuggested).toBe(true);
|
||||
});
|
||||
|
||||
it('keine files_sharing-Faehigkeit oder api_enabled false -> ausgeschaltet', () => {
|
||||
expect(parseSharePolicy({ capabilities: {} }).enabled).toBe(false);
|
||||
expect(parseSharePolicy(null).enabled).toBe(false);
|
||||
const off = parseSharePolicy({
|
||||
capabilities: { files_sharing: { api_enabled: false, public: { enabled: true } } },
|
||||
});
|
||||
expect(off.enabled).toBe(false);
|
||||
expect(off.links.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keine Gruppenfreigabe, Mindestlaenge der Suche', () => {
|
||||
const policy = parseSharePolicy({
|
||||
capabilities: {
|
||||
files_sharing: {
|
||||
api_enabled: true,
|
||||
group_sharing: false,
|
||||
public: { enabled: true, multiple_links: false },
|
||||
sharee: { minSearchStringLength: '3' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(policy.groupsEnabled).toBe(false);
|
||||
expect(policy.links.multipleLinks).toBe(false);
|
||||
expect(policy.minSearchLength).toBe(3);
|
||||
expect(policy.passwordMinLength).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import type { NcSession } from './nextcloud-files.types';
|
||||
import {
|
||||
discardBody,
|
||||
type NcFailure,
|
||||
type NextcloudTransport,
|
||||
ncRequest,
|
||||
readCappedText,
|
||||
} from './nextcloud-http';
|
||||
|
||||
/**
|
||||
* Freigaben-Schicht des Moduls "Nextcloud-Dateien" (quick-261009-dkv, D-11/D-12):
|
||||
* die OCS-Schnittstelle fuer Freigaben, Empfaengersuche und die Freigaberegeln
|
||||
* (`cloud/capabilities`) auf Basis von `ncRequest`. Es gelten unveraendert die
|
||||
* Regeln der Etappe 1: fester Pfadanfang `/ocs/v2.php/`, Segmente einzeln codiert,
|
||||
* keine Weiterleitungen, keine Cookies, und es wird NIE eine Adresse aus einer
|
||||
* Nextcloud-Antwort aufgerufen (weder `url` einer Freigabe noch `api.generate`
|
||||
* aus den Faehigkeiten).
|
||||
*
|
||||
* Warum nicht `ocsRequest` aus `nextcloud-auth-client.ts`: der Anmeldecode
|
||||
* verlaesst sich darauf, dass ein 403 als `app-password-given` gilt und der Text
|
||||
* einer Fehlerantwort verworfen wird. Bei Freigaben steht genau dort die
|
||||
* Begruendung (`ocs.meta.message`), und ein 403 heisst "nicht erlaubt". Darum ein
|
||||
* eigener Aufruf, der den Koerper bei JEDEM Status liest (Erfolg: bis 8 MiB,
|
||||
* Fehler: bis 64 KiB, Faehigkeiten: bis 1 MiB).
|
||||
*
|
||||
* Nie loggen oder weitergeben: Passwoerter, Kennungen (`token`) und Link-Adressen.
|
||||
* Die Parser bauen kleine eigene Ansichten; unbekannte Felder (Speicherkennungen,
|
||||
* `attributes`, `mail_send` ...) werden nie kopiert.
|
||||
*/
|
||||
|
||||
export type NcShareKind = 'user' | 'group' | 'link';
|
||||
export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom';
|
||||
export type NcItemType = 'file' | 'folder';
|
||||
|
||||
/** Eine Freigabe, wie der Browser sie sieht. */
|
||||
export interface NcShareView {
|
||||
id: string;
|
||||
kind: NcShareKind;
|
||||
/** Pfad im Bereich des Eigentuemers (bei eigenen Freigaben im eigenen Konto). */
|
||||
path: string;
|
||||
name: string;
|
||||
itemType: NcItemType;
|
||||
mime: string | null;
|
||||
/** Der Eintrag selbst erlaubt Aendern/Anlegen (Berechtigungsbits 2 oder 4). */
|
||||
itemWritable: boolean;
|
||||
permissions: number;
|
||||
access: NcShareAccess;
|
||||
/** Kennung von Person/Gruppe; bei Links null. */
|
||||
shareWith: string | null;
|
||||
shareWithName: string | null;
|
||||
ownerId: string | null;
|
||||
ownerName: string | null;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
/** `YYYY-MM-DD` oder null. */
|
||||
expiration: string | null;
|
||||
label: string;
|
||||
/** Nur bei eigenen Links, nur http/https. */
|
||||
url: string | null;
|
||||
hasPassword: boolean;
|
||||
/** Pfad im eigenen Baum des Empfaengers (bei eigenen Freigaben der eigene Pfad). */
|
||||
target: string;
|
||||
sharedAt: string | null;
|
||||
/** Noch nicht angenommen (nur eingehende). */
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export interface NcSharee {
|
||||
kind: 'user' | 'group';
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface NcSharePolicy {
|
||||
/** Freigabe-Schnittstelle der Nextcloud an. */
|
||||
enabled: boolean;
|
||||
groupsEnabled: boolean;
|
||||
links: {
|
||||
enabled: boolean;
|
||||
passwordRequired: boolean;
|
||||
passwordSuggested: boolean;
|
||||
/** Tage des Standard-Ablaufs (nur wenn die Nextcloud einen vorgibt). */
|
||||
expiryDefaultDays: number | null;
|
||||
expiryEnforced: boolean;
|
||||
uploadAllowed: boolean;
|
||||
multipleLinks: boolean;
|
||||
};
|
||||
/** Regeln fuer Personen- und Gruppenfreigaben (nur zur Anzeige). */
|
||||
internalExpiry: { defaultDays: number | null; enforced: boolean };
|
||||
minSearchLength: number;
|
||||
passwordMinLength: number | null;
|
||||
}
|
||||
|
||||
// --- Grenzen -------------------------------------------------------------------------------
|
||||
|
||||
export const SHARES_BASE_SEGMENTS = ['apps', 'files_sharing', 'api', 'v1', 'shares'] as const;
|
||||
export const SHAREE_SEGMENTS = ['apps', 'files_sharing', 'api', 'v1', 'sharees'] as const;
|
||||
export const CAPABILITIES_SEGMENTS = ['cloud', 'capabilities'] as const;
|
||||
|
||||
export const OCS_OK_MAX_BYTES = 8 * 1024 * 1024;
|
||||
export const OCS_ERROR_MAX_BYTES = 64 * 1024;
|
||||
export const OCS_CAPABILITIES_MAX_BYTES = 1024 * 1024;
|
||||
export const MAX_SHARES = 2000;
|
||||
export const MAX_SHAREES = 25;
|
||||
const SHARE_TIMEOUT_MS = 15_000;
|
||||
const MESSAGE_MAX = 300;
|
||||
const DISPLAY_MAX = 255;
|
||||
const URL_MAX = 2048;
|
||||
|
||||
const SHARE_ID_RE = /^\d{1,20}$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// --- Hilfen ----------------------------------------------------------------------------------
|
||||
|
||||
type Dict = Record<string, unknown>;
|
||||
|
||||
function isDict(value: unknown): value is Dict {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Steuerzeichen entfernen, Leerraum zusammenfassen, kuerzen — fuer alles, was angezeigt wird. */
|
||||
export function cleanText(value: unknown, max: number): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return (
|
||||
value
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, max)
|
||||
);
|
||||
}
|
||||
|
||||
function intOf(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value);
|
||||
if (typeof value === 'string' && /^-?\d{1,15}$/.test(value.trim())) return Number(value.trim());
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isShareId(value: unknown): value is string {
|
||||
return typeof value === 'string' && SHARE_ID_RE.test(value);
|
||||
}
|
||||
|
||||
// --- Aufruf -----------------------------------------------------------------------------------
|
||||
|
||||
export interface OcsShareOptions {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
segments: readonly string[];
|
||||
query?: Record<string, string>;
|
||||
/** JSON-Koerper (POST/PUT); Passwoerter stehen nie in einer Adresse. */
|
||||
json?: Record<string, unknown>;
|
||||
/** Obergrenze fuer eine erfolgreiche Antwort (Standard 8 MiB). */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export type OcsShareResult =
|
||||
| { ok: true; status: number; message: string | null; data: unknown }
|
||||
| NcFailure;
|
||||
|
||||
/**
|
||||
* OCS-Aufruf mit dem Zugang der Sitzung. Liefert fuer JEDEN HTTP-Status
|
||||
* `{ ok: true, status, message, data }` (auch 4xx/5xx); Transportfehler, 401
|
||||
* (`credential-dead`) und 429 (Sperre) kommen wie bei `ncRequest` als Fehlerergebnis.
|
||||
*/
|
||||
export async function ocsShareRequest(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
session: NcSession,
|
||||
opts: OcsShareOptions,
|
||||
): Promise<OcsShareResult> {
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl: session.baseUrl,
|
||||
prefix: '/ocs/v2.php/',
|
||||
segments: opts.segments,
|
||||
query: opts.query,
|
||||
method: opts.method,
|
||||
ocs: true,
|
||||
authorization: session.authorization,
|
||||
credentialKey: session.credentialKey,
|
||||
...(opts.json !== undefined
|
||||
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(opts.json) }
|
||||
: {}),
|
||||
headersTimeoutMs: SHARE_TIMEOUT_MS,
|
||||
bodyTimeoutMs: SHARE_TIMEOUT_MS,
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
|
||||
const success = res.status >= 200 && res.status < 300;
|
||||
const text = await readCappedText(
|
||||
res.body,
|
||||
success ? (opts.maxBytes ?? OCS_OK_MAX_BYTES) : OCS_ERROR_MAX_BYTES,
|
||||
);
|
||||
if (!text.ok) {
|
||||
discardBody(res.body);
|
||||
// Eine zu grosse Fehlerantwort bleibt ein Fehlerstatus, nur ohne Text.
|
||||
if (!success && text.kind === 'too-large') {
|
||||
return { ok: true, status: res.status, message: null, data: null };
|
||||
}
|
||||
return { ok: false, kind: text.kind, detail: text.detail };
|
||||
}
|
||||
|
||||
let ocs: Dict | null = null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text.text);
|
||||
if (isDict(parsed) && isDict(parsed.ocs)) ocs = parsed.ocs;
|
||||
} catch {
|
||||
// kein JSON
|
||||
}
|
||||
if (ocs === null) {
|
||||
if (success) return { ok: false, kind: 'invalid-response' };
|
||||
return { ok: true, status: res.status, message: null, data: null };
|
||||
}
|
||||
const meta = isDict(ocs.meta) ? ocs.meta : {};
|
||||
const message = cleanText(meta.message, MESSAGE_MAX);
|
||||
return { ok: true, status: res.status, message: message === '' ? null : message, data: ocs.data };
|
||||
}
|
||||
|
||||
// --- Berechtigungen -----------------------------------------------------------------------------
|
||||
|
||||
const READ = 1;
|
||||
const UPDATE = 2;
|
||||
const CREATE = 4;
|
||||
const DELETE = 8;
|
||||
|
||||
/**
|
||||
* Berechtigungsmaske fuer die einfache Auswahl (D-11): Ansehen 1; Bearbeiten Ordner 15 /
|
||||
* Datei 3; Nur hochladen 4 (nur Ordner, sonst null). Das Teilen-Bit (16) wird nie gesendet.
|
||||
*/
|
||||
export function permissionsFor(
|
||||
access: 'view' | 'edit' | 'upload',
|
||||
itemType: NcItemType,
|
||||
): number | null {
|
||||
if (access === 'view') return READ;
|
||||
if (access === 'edit')
|
||||
return itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE;
|
||||
return itemType === 'folder' ? CREATE : null;
|
||||
}
|
||||
|
||||
/** Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. */
|
||||
export function accessOf(permissions: number): NcShareAccess {
|
||||
const mask = permissions & 15;
|
||||
if (mask === READ) return 'view';
|
||||
if (mask === CREATE) return 'upload';
|
||||
if ((mask & READ) !== 0 && (mask & (UPDATE | CREATE | DELETE)) !== 0) return 'edit';
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
// --- Parser -------------------------------------------------------------------------------------
|
||||
|
||||
function lastSegment(path: string): string {
|
||||
const parts = path.split('/').filter((p) => p !== '');
|
||||
return parts.length > 0 ? parts[parts.length - 1] : '';
|
||||
}
|
||||
|
||||
function isRealDate(value: string): boolean {
|
||||
if (!DATE_RE.test(value)) return false;
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
function publicUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege. */
|
||||
export function parseShare(raw: unknown, selfId: string): NcShareView | null {
|
||||
if (!isDict(raw)) return null;
|
||||
const type = intOf(raw.share_type);
|
||||
const kind: NcShareKind | null =
|
||||
type === 0 ? 'user' : type === 1 ? 'group' : type === 3 ? 'link' : null;
|
||||
if (kind === null) return null;
|
||||
const id = typeof raw.id === 'number' ? String(raw.id) : raw.id;
|
||||
if (!isShareId(id)) return null;
|
||||
|
||||
const ownerId = cleanText(raw.uid_owner, DISPLAY_MAX) || null;
|
||||
const received = ownerId !== null && ownerId !== selfId;
|
||||
const target = cleanText(raw.file_target, 4096);
|
||||
const path = cleanText(raw.path, 4096) || target;
|
||||
const itemType: NcItemType =
|
||||
raw.item_type === 'folder' || raw.mimetype === 'httpd/unix-directory' ? 'folder' : 'file';
|
||||
const name = lastSegment(received ? target || path : path || target);
|
||||
const permissions = intOf(raw.permissions) ?? 0;
|
||||
const itemPermissions = intOf(raw.item_permissions) ?? permissions;
|
||||
const expirationRaw = typeof raw.expiration === 'string' ? raw.expiration.slice(0, 10) : '';
|
||||
const stime = intOf(raw.stime);
|
||||
const sharedAt =
|
||||
stime !== null && stime > 0 && Number.isFinite(new Date(stime * 1000).getTime())
|
||||
? new Date(stime * 1000).toISOString()
|
||||
: null;
|
||||
const mimeRaw = cleanText(raw.mimetype, DISPLAY_MAX);
|
||||
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
path,
|
||||
name,
|
||||
itemType,
|
||||
mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null,
|
||||
itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0,
|
||||
permissions,
|
||||
access: accessOf(permissions),
|
||||
shareWith: kind === 'link' ? null : cleanText(raw.share_with, DISPLAY_MAX) || null,
|
||||
shareWithName:
|
||||
kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null,
|
||||
ownerId,
|
||||
ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null,
|
||||
canEdit: raw.can_edit === true,
|
||||
canDelete: raw.can_delete === true,
|
||||
expiration: isRealDate(expirationRaw) ? expirationRaw : null,
|
||||
label: cleanText(raw.label, DISPLAY_MAX),
|
||||
url: kind === 'link' && ownerId === selfId ? publicUrl(raw.url) : null,
|
||||
// Nextcloud sendet beim Passwort nie den Wert, nur den Text "redacted" — hier zaehlt nur, DASS eins gesetzt ist.
|
||||
hasPassword: kind === 'link' && typeof raw.password === 'string' && raw.password !== '',
|
||||
target,
|
||||
sharedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedShares {
|
||||
shares: NcShareView[];
|
||||
/** Eintraege anderer Arten (E-Mail, Server ...), nur als Zahl. */
|
||||
hidden: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** `data` ist bei Listen ein Feld, bei POST/PUT ein einzelnes Objekt. */
|
||||
export function parseShareList(data: unknown, selfId: string): ParsedShares {
|
||||
const items = Array.isArray(data) ? data : isDict(data) ? [data] : [];
|
||||
const shares: NcShareView[] = [];
|
||||
let hidden = 0;
|
||||
let truncated = false;
|
||||
for (const item of items) {
|
||||
const share = parseShare(item, selfId);
|
||||
if (share === null) {
|
||||
hidden += 1;
|
||||
} else if (shares.length >= MAX_SHARES) {
|
||||
truncated = true;
|
||||
} else {
|
||||
shares.push(share);
|
||||
}
|
||||
}
|
||||
return { shares, hidden, truncated };
|
||||
}
|
||||
|
||||
function shareeList(value: unknown, wanted: 0 | 1): NcSharee[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: NcSharee[] = [];
|
||||
for (const item of value) {
|
||||
if (!isDict(item) || !isDict(item.value)) continue;
|
||||
if (intOf(item.value.shareType) !== wanted) continue;
|
||||
const id = cleanText(item.value.shareWith, DISPLAY_MAX);
|
||||
if (id === '') continue;
|
||||
const label = cleanText(item.label, DISPLAY_MAX) || id;
|
||||
const unique = cleanText(item.shareWithDisplayNameUnique, DISPLAY_MAX);
|
||||
out.push({
|
||||
kind: wanted === 0 ? 'user' : 'group',
|
||||
id,
|
||||
label,
|
||||
detail: wanted === 0 ? unique || id : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Treffer der Empfaengersuche: nur Personen und Gruppen, genaue und weitere Treffer zusammen, ohne Doppelte. */
|
||||
export function parseSharees(data: unknown): NcSharee[] {
|
||||
if (!isDict(data)) return [];
|
||||
const exact = isDict(data.exact) ? data.exact : {};
|
||||
const all = [
|
||||
...shareeList(exact.users, 0),
|
||||
...shareeList(data.users, 0),
|
||||
...shareeList(exact.groups, 1),
|
||||
...shareeList(data.groups, 1),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const out: NcSharee[] = [];
|
||||
for (const s of all) {
|
||||
const key = `${s.kind}:${s.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(s);
|
||||
if (out.length >= MAX_SHAREES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function days(value: unknown): number | null {
|
||||
const n = intOf(value);
|
||||
return n !== null && n >= 1 ? Math.min(n, 3650) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`).
|
||||
* Fehlende Schluessel heissen "keine Regel", kein Fehler: ohne `expire_date.enabled` gibt
|
||||
* es keine Tage; sind Links aus, steht unter `public` nur `enabled: false`.
|
||||
*/
|
||||
export function parseSharePolicy(data: unknown): NcSharePolicy {
|
||||
const caps = isDict(data) && isDict(data.capabilities) ? data.capabilities : data;
|
||||
const files = isDict(caps) && isDict(caps.files_sharing) ? caps.files_sharing : null;
|
||||
const pwPolicy = isDict(caps) && isDict(caps.password_policy) ? caps.password_policy : null;
|
||||
const minLen = pwPolicy ? intOf(pwPolicy.minLength) : null;
|
||||
const passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null;
|
||||
|
||||
const off: NcSharePolicy = {
|
||||
enabled: false,
|
||||
groupsEnabled: false,
|
||||
links: {
|
||||
enabled: false,
|
||||
passwordRequired: false,
|
||||
passwordSuggested: false,
|
||||
expiryDefaultDays: null,
|
||||
expiryEnforced: false,
|
||||
uploadAllowed: false,
|
||||
multipleLinks: false,
|
||||
},
|
||||
internalExpiry: { defaultDays: null, enforced: false },
|
||||
minSearchLength: 0,
|
||||
passwordMinLength,
|
||||
};
|
||||
if (files === null || files.api_enabled === false) return off;
|
||||
|
||||
const sharee = isDict(files.sharee) ? files.sharee : {};
|
||||
const min = intOf(sharee.minSearchStringLength);
|
||||
const minSearchLength = min !== null ? Math.min(Math.max(min, 0), 32) : 0;
|
||||
|
||||
const pub = isDict(files.public) ? files.public : {};
|
||||
const linksEnabled = pub.enabled === true;
|
||||
const expiry = (value: unknown): { defaultDays: number | null; enforced: boolean } => {
|
||||
const e = isDict(value) ? value : {};
|
||||
if (e.enabled !== true) return { defaultDays: null, enforced: false };
|
||||
return { defaultDays: days(e.days), enforced: e.enforced === true };
|
||||
};
|
||||
const password = isDict(pub.password) ? pub.password : {};
|
||||
const passwordRequired = linksEnabled && password.enforced === true;
|
||||
const link = expiry(pub.expire_date);
|
||||
const internal = expiry(pub.expire_date_internal);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
groupsEnabled: files.group_sharing !== false,
|
||||
links: linksEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
passwordRequired,
|
||||
passwordSuggested: !passwordRequired && password.askForOptionalPassword === true,
|
||||
expiryDefaultDays: link.defaultDays,
|
||||
expiryEnforced: link.enforced,
|
||||
uploadAllowed: pub.upload === true,
|
||||
multipleLinks: pub.multiple_links !== false,
|
||||
}
|
||||
: off.links,
|
||||
internalExpiry: internal,
|
||||
minSearchLength,
|
||||
passwordMinLength,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user