Add non-image attachments with allowlist, SVG policy, and file managers (#61)
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
Extend uploads (#27, ADR 0011) beyond images to a configurable general attachment allowlist, plus the page attachments section and the Pond Admin file manager. Backend: - Two instance settings: `upload.allowedExtensions` (lowercase, dot-stripped, images always allowed regardless) and `upload.svgPolicy` (reject | sanitize). - FilesService.resolveUpload: raster images still decided by magic bytes; SVG is sanitized with DOMPurify (scripts, event handlers, foreignObject stripped) or rejected per policy; everything else is admitted only if its extension is on the allowlist. A sanitized SVG's stored bytes are re-accounted so pond_usage matches disk. - Downloads set `Content-Disposition: attachment` for every non-raster type (office files, PDFs, SVG) with `nosniff`, so they can never execute inline; raster images stay inline for page embeds. - New endpoints: `GET /ponds/:id/files` (pond_admin: all files + usage + orphan flag), `POST /pages/:id/files` and `GET /pages/:id/files` (page-write/read: the attachments section). New error code `upload_type_not_allowed` (de+en). Frontend: - Page attachments section (AttachmentsPanel): upload, list with type glyph, size, and uploader, insert-as-link into the document (an internal media link that downloads, never renders inline), and delete. Toggled in the editor. - Pond file manager (PondFileManager) in pond settings for Pond Admins: every file with its referencing page (or an orphan flag) and storage usage. - Admin uploads settings form (allowlist + SVG policy). New `files` i18n namespace (de+en). Tests: - files.e2e.db.test.ts: allowlisted non-image accepted and served as a download; disallowed extension rejected; renamed-.html-as-.png still fails; SVG sanitized (scripts/handlers stripped) and reject-mode rejects; page attachment listing; pond file manager usage/orphan; non-admin denied. - New e2e pack apps/web/e2e/attachments.spec.ts (+ CI step): upload → list → insert link (verified attachment disposition + nosniff), disallowed-type error, pond file manager usage/orphan. Local: typecheck, lint, i18n:check, build all green; api-db 184, shared 121, web 50; attachments pack 3/3, members 3/3, content 5/5. Adds dompurify + jsdom to the api for server-side SVG sanitization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
ae8cdd0e1e
commit
30891f99cf
@ -228,6 +228,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/permission-matrix.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/permission-matrix.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before attachments pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
- name: Run attachments pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/attachments.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before offline pack
|
- name: Reset login rate limit before offline pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
|||||||
@ -23,8 +23,10 @@
|
|||||||
"@prisma/client": "^6.3.0",
|
"@prisma/client": "^6.3.0",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"fractional-indexing": "^4.0.0",
|
"fractional-indexing": "^4.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nestjs-pino": "^4.3.0",
|
"nestjs-pino": "^4.3.0",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
@ -47,6 +49,7 @@
|
|||||||
"@swc/core": "^1.10.0",
|
"@swc/core": "^1.10.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
|
|||||||
@ -13,18 +13,24 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared';
|
import {
|
||||||
|
AttachmentListItemView,
|
||||||
|
AttachmentView,
|
||||||
|
MAX_UPLOAD_PARSE_BYTES,
|
||||||
|
PondFilesView,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
import {
|
import {
|
||||||
RequiresAttachmentPermission,
|
RequiresAttachmentPermission,
|
||||||
|
RequiresPagePermission,
|
||||||
RequiresPondRole,
|
RequiresPondRole,
|
||||||
} from '../permissions/permission.decorators';
|
} from '../permissions/permission.decorators';
|
||||||
|
|
||||||
import { FilesService } from './files.service';
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
/** File storage and image-upload API (issue #27). */
|
/** File storage, upload, and attachment listing API (issue #27, #61). */
|
||||||
@Controller()
|
@Controller()
|
||||||
export class FilesController {
|
export class FilesController {
|
||||||
constructor(private readonly files: FilesService) {}
|
constructor(private readonly files: FilesService) {}
|
||||||
@ -41,11 +47,40 @@ export class FilesController {
|
|||||||
return this.files.upload(request.user!, pondId, file);
|
return this.files.upload(request.user!, pondId, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All files in a pond, for the Pond Admin file manager (#61). */
|
||||||
|
@Get('ponds/:pondId/files')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
listPondFiles(@Param('pondId') pondId: string): Promise<PondFilesView> {
|
||||||
|
return this.files.listForPond(pondId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload an attachment linked to a page — its attachments section (#61). */
|
||||||
|
@Post('pages/:pageId/files')
|
||||||
|
@RequiresPagePermission('write', { idParam: 'pageId' })
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } }))
|
||||||
|
async uploadToPage(
|
||||||
|
@Param('pageId') pageId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<AttachmentView> {
|
||||||
|
if (!file) throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
return this.files.uploadToPage(request.user!, pageId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attachments linked to a page, for its attachments section (#61). */
|
||||||
|
@Get('pages/:pageId/files')
|
||||||
|
@RequiresPagePermission('read', { idParam: 'pageId' })
|
||||||
|
listPageFiles(@Param('pageId') pageId: string): Promise<AttachmentListItemView[]> {
|
||||||
|
return this.files.listForPage(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission-checked file streaming (ADR 0011) — never served same-origin as
|
* Permission-checked file streaming (ADR 0011) — never served same-origin as
|
||||||
* executable content. `@Public()` so embedded images on a public page load
|
* executable content. `@Public()` so embedded images on a public page load
|
||||||
* for anonymous visitors (issue #56); the guard still resolves the `public`
|
* for anonymous visitors (issue #56); the guard still resolves the `public`
|
||||||
* grant via the attachment's page and 404s otherwise.
|
* grant via the attachment's page and 404s otherwise. Raster images render
|
||||||
|
* inline; every other type — office files, PDFs, SVG — is sent as a download
|
||||||
|
* with its original filename and can never execute inline (#61).
|
||||||
*/
|
*/
|
||||||
@Get('media/:fileId')
|
@Get('media/:fileId')
|
||||||
@Public()
|
@Public()
|
||||||
@ -55,13 +90,14 @@ export class FilesController {
|
|||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const { attachment, stream } = await this.files.download(request.user ?? null, fileId);
|
const { attachment, stream, inline } = await this.files.download(request.user ?? null, fileId);
|
||||||
response.set('X-Content-Type-Options', 'nosniff');
|
response.set('X-Content-Type-Options', 'nosniff');
|
||||||
// Attachments are immutable — a new upload always gets a new id.
|
// Attachments are immutable — a new upload always gets a new id.
|
||||||
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
||||||
|
const kind = inline ? 'inline' : 'attachment';
|
||||||
return new StreamableFile(stream, {
|
return new StreamableFile(stream, {
|
||||||
type: attachment.mimeType,
|
type: attachment.mimeType,
|
||||||
disposition: `inline; filename="${encodeURIComponent(attachment.fileName)}"`,
|
disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { Test } from 'supertest';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
@ -134,22 +135,89 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
|||||||
expect(Buffer.compare(served.body, bytes)).toBe(0);
|
expect(Buffer.compare(served.body, bytes)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-image uploads', async () => {
|
it('accepts an allowlisted non-image file and serves it as a download (#61)', async () => {
|
||||||
|
const pdf = Buffer.from('%PDF-1.4 minimal but allowlisted');
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', pdf, 'document.pdf')
|
||||||
|
.expect(201);
|
||||||
|
expect(uploaded.body.mimeType).toBe('application/pdf');
|
||||||
|
|
||||||
|
const served = await api()
|
||||||
|
.get(`/api/v1/media/${uploaded.body.id}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.buffer(true)
|
||||||
|
.parse(binaryParser as unknown as ParseCallback)
|
||||||
|
.expect(200);
|
||||||
|
// Non-images are always downloads, never inline (ADR 0011, security.md).
|
||||||
|
expect(served.headers['content-disposition']).toContain('attachment');
|
||||||
|
expect(served.headers['content-disposition']).toContain('document.pdf');
|
||||||
|
expect(served.headers['content-type']).toContain('application/pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a file whose extension is not on the allowlist (#61)', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/files`)
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
.set('Cookie', ownerCookie)
|
.set('Cookie', ownerCookie)
|
||||||
.attach('file', Buffer.from('%PDF-1.4 not really'), 'document.pdf')
|
.attach('file', Buffer.from('binary junk'), 'malware.exe')
|
||||||
.expect(400);
|
.expect(400);
|
||||||
expect(res.body.code).toBe('unsupported_file_type');
|
expect(res.body.code).toBe('upload_type_not_allowed');
|
||||||
|
expect(res.body.details.allowed).toContain('pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a renamed .html-as-.png via the magic-byte check', async () => {
|
it('rejects a renamed .html-as-.png via the magic-byte check (#61)', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/files`)
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
.set('Cookie', ownerCookie)
|
.set('Cookie', ownerCookie)
|
||||||
.attach('file', Buffer.from('<html><body>evil</body></html>'), 'sneaky.png')
|
.attach('file', Buffer.from('<html><body>evil</body></html>'), 'sneaky.png')
|
||||||
.expect(400);
|
.expect(400);
|
||||||
expect(res.body.code).toBe('unsupported_file_type');
|
// png is validated by bytes, not extension, so the disguise fails the
|
||||||
|
// allowlist (png is not a configured non-image extension either).
|
||||||
|
expect(res.body.code).toBe('upload_type_not_allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes an uploaded SVG, stripping scripts and event handlers (#61)', async () => {
|
||||||
|
const settings = app.get(InstanceSettingsService);
|
||||||
|
await settings.set('upload.svgPolicy', 'sanitize', 'test');
|
||||||
|
const dirty = Buffer.from(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">' +
|
||||||
|
'<script>alert(2)</script><rect width="10" height="10"/></svg>',
|
||||||
|
);
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', dirty, 'diagram.svg')
|
||||||
|
.expect(201);
|
||||||
|
expect(uploaded.body.mimeType).toBe('image/svg+xml');
|
||||||
|
|
||||||
|
const served = await api()
|
||||||
|
.get(`/api/v1/media/${uploaded.body.id}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.buffer(true)
|
||||||
|
.parse(binaryParser as unknown as ParseCallback)
|
||||||
|
.expect(200);
|
||||||
|
const cleaned = served.body.toString('utf8').toLowerCase();
|
||||||
|
expect(cleaned).not.toContain('<script');
|
||||||
|
expect(cleaned).not.toContain('onload');
|
||||||
|
expect(cleaned).toContain('<rect');
|
||||||
|
// SVG is always a download, never inline.
|
||||||
|
expect(served.headers['content-disposition']).toContain('attachment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an SVG upload when the policy is reject (#61)', async () => {
|
||||||
|
const settings = app.get(InstanceSettingsService);
|
||||||
|
await settings.set('upload.svgPolicy', 'reject', 'test');
|
||||||
|
try {
|
||||||
|
const res = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'x.svg')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('upload_type_not_allowed');
|
||||||
|
} finally {
|
||||||
|
await settings.set('upload.svgPolicy', 'sanitize', 'test');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an oversize file with a distinct error from quota_exceeded', async () => {
|
it('rejects an oversize file with a distinct error from quota_exceeded', async () => {
|
||||||
@ -236,6 +304,65 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
|||||||
expect(stillThere?.deletedAt).toBeNull();
|
expect(stillThere?.deletedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists a page attachment for the page and links it (#61)', async () => {
|
||||||
|
const page = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ title: `Page Files ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/pages/${page.body.id}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', Buffer.from('%PDF-1.4 attached to a page'), 'report.pdf')
|
||||||
|
.expect(201);
|
||||||
|
expect(uploaded.body.pageId).toBe(page.body.id);
|
||||||
|
|
||||||
|
const listed = await api()
|
||||||
|
.get(`/api/v1/pages/${page.body.id}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
const item = listed.body.find((f: { id: string }) => f.id === uploaded.body.id);
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item.uploaderName).toBe(owner.displayName);
|
||||||
|
expect(item.pageTitle).toBe(`Page Files ${suffix}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pond file manager reports usage, orphans, and page links (#61)', async () => {
|
||||||
|
const page = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ title: `Manager Page ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const linked = await api()
|
||||||
|
.post(`/api/v1/pages/${page.body.id}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', Buffer.from('%PDF-1.4 linked'), 'linked.pdf')
|
||||||
|
.expect(201);
|
||||||
|
const orphan = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', pngBuffer('orphan image'), 'orphan.png')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const manager = await api()
|
||||||
|
.get(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(manager.body.storageBytesLimit).toBeGreaterThan(0);
|
||||||
|
const usage = await prisma.pondUsage.findUnique({ where: { pondId } });
|
||||||
|
expect(manager.body.storageBytesUsed).toBe(Number(usage?.storageBytesUsed ?? 0));
|
||||||
|
|
||||||
|
const linkedItem = manager.body.files.find((f: { id: string }) => f.id === linked.body.id);
|
||||||
|
const orphanItem = manager.body.files.find((f: { id: string }) => f.id === orphan.body.id);
|
||||||
|
expect(linkedItem.pageTitle).toBe(`Manager Page ${suffix}`);
|
||||||
|
expect(orphanItem.pageTitle).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies the pond file manager to a non-admin (#61)', async () => {
|
||||||
|
await api().get(`/api/v1/ponds/${pondId}/files`).set('Cookie', outsiderCookie).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('hides foreign-pond files from download and delete (404, not 403)', async () => {
|
it('hides foreign-pond files from download and delete (404, not 403)', async () => {
|
||||||
const uploaded = await api()
|
const uploaded = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/files`)
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
|||||||
@ -7,28 +7,50 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AttachmentView } from '@dorfteich/shared';
|
import {
|
||||||
|
ATTACHMENT_EXTENSION_MIME_TYPES,
|
||||||
|
AttachmentListItemView,
|
||||||
|
AttachmentView,
|
||||||
|
PondFilesView,
|
||||||
|
SVG_MIME_TYPE,
|
||||||
|
fileExtension,
|
||||||
|
isImageMimeType,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import { Attachment, User } from '@prisma/client';
|
import { Attachment, User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { QuotaService } from '../quotas/quota.service';
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
|
||||||
import { FileStorageService } from './file-storage.service';
|
import { FileStorageService } from './file-storage.service';
|
||||||
import { sniffImageMimeType } from './magic-bytes';
|
import { looksLikeSvg, sniffImageMimeType } from './magic-bytes';
|
||||||
|
import { sanitizeSvg } from './svg-sanitize';
|
||||||
|
|
||||||
export interface FileDownload {
|
export interface FileDownload {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
stream: Readable;
|
stream: Readable;
|
||||||
|
/** Raster images render inline (they are embedded in pages); everything
|
||||||
|
* else — office files, PDFs, and SVG — is always sent as a download so it
|
||||||
|
* can never execute inline (ADR 0011, security.md §Uploads). */
|
||||||
|
inline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** File storage and image-upload API (issue #27, ADR 0011). */
|
/** What the upload bytes resolved to after allowlist + SVG handling. */
|
||||||
|
interface ResolvedUpload {
|
||||||
|
mimeType: string;
|
||||||
|
/** The bytes to store — identical to the input except for a sanitized SVG. */
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File storage, upload, and attachment listing API (issue #27, #61, ADR 0011). */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FilesService {
|
export class FilesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(FilesService.name);
|
this.logger.setContext(FilesService.name);
|
||||||
@ -46,21 +68,58 @@ export class FilesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the served MIME type and the bytes to persist. Raster images pass
|
||||||
|
* the magic-byte sniff and are always allowed. An SVG is sanitized (its
|
||||||
|
* scripts/handlers stripped) or rejected per the instance policy. Anything
|
||||||
|
* else is admitted only if its extension is on the configurable allowlist;
|
||||||
|
* its bytes are never trusted to be inert, but it is only ever downloaded,
|
||||||
|
* never rendered.
|
||||||
|
*/
|
||||||
|
private async resolveUpload(fileName: string, buffer: Buffer): Promise<ResolvedUpload> {
|
||||||
|
const rasterMime = sniffImageMimeType(buffer);
|
||||||
|
if (rasterMime) return { mimeType: rasterMime, buffer };
|
||||||
|
|
||||||
|
if (looksLikeSvg(buffer)) {
|
||||||
|
const policy = await this.settings.get('upload.svgPolicy');
|
||||||
|
if (policy === 'reject') {
|
||||||
|
throw new BadRequestException({ code: 'upload_type_not_allowed' });
|
||||||
|
}
|
||||||
|
let clean: string;
|
||||||
|
try {
|
||||||
|
clean = sanitizeSvg(buffer.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException({ code: 'upload_type_not_allowed' });
|
||||||
|
}
|
||||||
|
return { mimeType: SVG_MIME_TYPE, buffer: Buffer.from(clean, 'utf8') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = fileExtension(fileName);
|
||||||
|
const allowed = await this.settings.get('upload.allowedExtensions');
|
||||||
|
if (!ext || !allowed.includes(ext)) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'upload_type_not_allowed',
|
||||||
|
details: { allowed },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { mimeType: ATTACHMENT_EXTENSION_MIME_TYPES[ext] ?? 'application/octet-stream', buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an upload against a pond, optionally linked to a page. `pageId` is
|
||||||
|
* set for a page attachment (uploaded from the page's attachments section)
|
||||||
|
* so it is listed there and purged with the page; image pastes leave it null
|
||||||
|
* and the collab persistence hook links them to whichever page embeds them.
|
||||||
|
*/
|
||||||
async upload(
|
async upload(
|
||||||
user: User,
|
user: User,
|
||||||
pondId: string,
|
pondId: string,
|
||||||
file: { buffer: Buffer; size: number; originalname: string },
|
file: { buffer: Buffer; size: number; originalname: string },
|
||||||
|
pageId?: string,
|
||||||
): Promise<AttachmentView> {
|
): Promise<AttachmentView> {
|
||||||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
if (!pond) throw new NotFoundException();
|
if (!pond) throw new NotFoundException();
|
||||||
|
|
||||||
// Bytes decide, not the client-declared Content-Type or extension —
|
|
||||||
// catches a renamed .html-as-.png (ADR 0011 acceptance criterion).
|
|
||||||
const mimeType = sniffImageMimeType(file.buffer);
|
|
||||||
if (!mimeType) {
|
|
||||||
throw new BadRequestException({ code: 'unsupported_file_type' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxFileBytes = await this.quotas.getEffective('max_file_bytes', {
|
const maxFileBytes = await this.quotas.getEffective('max_file_bytes', {
|
||||||
userId: pond.ownerId,
|
userId: pond.ownerId,
|
||||||
pondId: pond.id,
|
pondId: pond.id,
|
||||||
@ -72,42 +131,107 @@ export class FilesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolved = await this.resolveUpload(file.originalname, file.buffer);
|
||||||
|
// Account for what actually lands on the volume (a sanitized SVG is
|
||||||
|
// usually smaller than the upload) so pond_usage matches disk exactly.
|
||||||
|
const sizeBytes = resolved.buffer.length;
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
// Consume the storage budget before touching the disk so a race never
|
// Consume the storage budget before touching the disk so a race never
|
||||||
// leaves bytes written without a matching reservation.
|
// leaves bytes written without a matching reservation.
|
||||||
await this.quotas.checkAndConsume(pond.id, pond.ownerId, file.size);
|
await this.quotas.checkAndConsume(pond.id, pond.ownerId, sizeBytes);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.storage.save(pond.id, id, file.buffer);
|
await this.storage.save(pond.id, id, resolved.buffer);
|
||||||
const attachment = await this.prisma.attachment.create({
|
const attachment = await this.prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
pondId: pond.id,
|
pondId: pond.id,
|
||||||
|
pageId: pageId ?? null,
|
||||||
fileName: file.originalname,
|
fileName: file.originalname,
|
||||||
mimeType,
|
mimeType: resolved.mimeType,
|
||||||
sizeBytes: file.size,
|
sizeBytes,
|
||||||
storagePath: `${pond.id}/${id}`,
|
storagePath: `${pond.id}/${id}`,
|
||||||
uploadedBy: user.id,
|
uploadedBy: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ attachmentId: id, pondId: pond.id, userId: user.id },
|
{ attachmentId: id, pondId: pond.id, pageId: pageId ?? null, userId: user.id },
|
||||||
'audit: file uploaded',
|
'audit: file uploaded',
|
||||||
);
|
);
|
||||||
return this.viewOf(attachment);
|
return this.viewOf(attachment);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Roll back the reservation and any bytes already written so usage
|
// Roll back the reservation and any bytes already written so usage
|
||||||
// never drifts from what is actually on the volume/in the database.
|
// never drifts from what is actually on the volume/in the database.
|
||||||
await this.quotas.release(pond.id, file.size);
|
await this.quotas.release(pond.id, sizeBytes);
|
||||||
await this.storage.delete(pond.id, id);
|
await this.storage.delete(pond.id, id);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upload against the pond of `pageId`, linked to that page (#61). */
|
||||||
|
async uploadToPage(
|
||||||
|
user: User,
|
||||||
|
pageId: string,
|
||||||
|
file: { buffer: Buffer; size: number; originalname: string },
|
||||||
|
): Promise<AttachmentView> {
|
||||||
|
const page = await this.prisma.page.findFirst({ where: { id: pageId } });
|
||||||
|
if (!page) throw new NotFoundException();
|
||||||
|
return this.upload(user, page.pondId, file, page.id);
|
||||||
|
}
|
||||||
|
|
||||||
async download(_user: User | null, id: string): Promise<FileDownload> {
|
async download(_user: User | null, id: string): Promise<FileDownload> {
|
||||||
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
|
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
|
||||||
if (!attachment) throw new NotFoundException();
|
if (!attachment) throw new NotFoundException();
|
||||||
return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) };
|
return {
|
||||||
|
attachment,
|
||||||
|
stream: this.storage.createReadStream(attachment.pondId, attachment.id),
|
||||||
|
inline: isImageMimeType(attachment.mimeType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attachments linked to a page, for its attachments section (#61). */
|
||||||
|
async listForPage(pageId: string): Promise<AttachmentListItemView[]> {
|
||||||
|
const rows = await this.prisma.attachment.findMany({
|
||||||
|
where: { pageId, deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { uploader: true, page: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => this.listItemOf(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every file in a pond, for the Pond Admin file manager (#61). */
|
||||||
|
async listForPond(pondId: string): Promise<PondFilesView> {
|
||||||
|
const [rows, usage, storageBytesLimit] = await Promise.all([
|
||||||
|
this.prisma.attachment.findMany({
|
||||||
|
where: { pondId, deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { uploader: true, page: true },
|
||||||
|
}),
|
||||||
|
this.prisma.pondUsage.findUnique({ where: { pondId } }),
|
||||||
|
this.pondStorageLimit(pondId),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
files: rows.map((row) => this.listItemOf(row)),
|
||||||
|
storageBytesUsed: Number(usage?.storageBytesUsed ?? 0n),
|
||||||
|
storageBytesLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pondStorageLimit(pondId: string): Promise<number> {
|
||||||
|
const pond = await this.prisma.pond.findUnique({ where: { id: pondId } });
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return this.quotas.getEffective('storage_bytes', { userId: pond.ownerId, pondId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private listItemOf(
|
||||||
|
row: Attachment & { uploader: User; page: { title: string } | null },
|
||||||
|
): AttachmentListItemView {
|
||||||
|
return {
|
||||||
|
...this.viewOf(row),
|
||||||
|
uploaderName: row.uploader.displayName,
|
||||||
|
pageTitle: row.page?.title ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(user: User, id: string): Promise<void> {
|
async remove(user: User, id: string): Promise<void> {
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import type { AttachmentMimeType } from '@dorfteich/shared';
|
import type { AttachmentMimeType } from '@dorfteich/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Magic-byte signatures for the M2 image allowlist (ADR 0011). The
|
* Magic-byte signatures for the raster-image allowlist (ADR 0011). The
|
||||||
* client-declared MIME type and filename extension are never trusted —
|
* client-declared MIME type and filename extension are never trusted —
|
||||||
* only the actual bytes decide, which is what catches a renamed
|
* only the actual bytes decide, which is what catches a renamed
|
||||||
* `.html`-as-`.png` upload. SVG has no reliable magic-byte signature (it's
|
* `.html`-as-`.png` upload.
|
||||||
* XML) and is rejected in M2 regardless, per ADR 0011.
|
|
||||||
*/
|
*/
|
||||||
const SIGNATURES: ReadonlyArray<{
|
const SIGNATURES: ReadonlyArray<{
|
||||||
mimeType: AttachmentMimeType;
|
mimeType: AttachmentMimeType;
|
||||||
@ -36,7 +35,18 @@ const SIGNATURES: ReadonlyArray<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Returns the sniffed image MIME type, or null when the bytes match none of the allowed signatures. */
|
/** Returns the sniffed raster-image MIME type, or null when the bytes match none of the allowed signatures. */
|
||||||
export function sniffImageMimeType(buffer: Buffer): AttachmentMimeType | null {
|
export function sniffImageMimeType(buffer: Buffer): AttachmentMimeType | null {
|
||||||
return SIGNATURES.find((signature) => signature.matches(buffer))?.mimeType ?? null;
|
return SIGNATURES.find((signature) => signature.matches(buffer))?.mimeType ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic SVG detection: an XML document whose leading bytes contain an
|
||||||
|
* `<svg` tag. SVG has no binary magic number (it is XML), so we scan the
|
||||||
|
* head, tolerating a BOM, an XML prolog, and a leading DOCTYPE/comment. This
|
||||||
|
* only gates *candidacy*; the bytes are still sanitized or rejected per the
|
||||||
|
* SVG policy before storage, so a false positive is harmless.
|
||||||
|
*/
|
||||||
|
export function looksLikeSvg(buffer: Buffer): boolean {
|
||||||
|
return buffer.subarray(0, 1024).toString('utf8').toLowerCase().includes('<svg');
|
||||||
|
}
|
||||||
|
|||||||
29
apps/api/src/files/svg-sanitize.ts
Normal file
29
apps/api/src/files/svg-sanitize.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import createDOMPurify from 'dompurify';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side SVG sanitizer (security.md §Uploads, issue #61). SVG is XML that
|
||||||
|
* can carry `<script>`, event handlers (`onload`, …), and external references,
|
||||||
|
* so an uploaded SVG is scrubbed with DOMPurify before it is ever stored or
|
||||||
|
* served. One jsdom window is created once and reused across calls (DOMPurify
|
||||||
|
* is stateless per `sanitize`).
|
||||||
|
*/
|
||||||
|
const window = new JSDOM('').window;
|
||||||
|
const purify = createDOMPurify(window as unknown as Window & typeof globalThis);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sanitized copy of the SVG markup with scripts, event handlers, and
|
||||||
|
* other active content removed. Throws when the input contains no SVG element
|
||||||
|
* at all (a non-SVG file that only tripped the `<svg` heuristic).
|
||||||
|
*/
|
||||||
|
export function sanitizeSvg(source: string): string {
|
||||||
|
const clean = purify.sanitize(source, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true },
|
||||||
|
// Foreign objects can embed arbitrary HTML/scripts into an SVG — drop them.
|
||||||
|
FORBID_TAGS: ['script', 'foreignObject'],
|
||||||
|
});
|
||||||
|
if (!clean.toLowerCase().includes('<svg')) {
|
||||||
|
throw new Error('sanitized SVG has no <svg> root');
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -32,6 +33,20 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// Trash retention (ADR 0013, issue #31): days a soft-deleted page stays
|
// Trash retention (ADR 0013, issue #31): days a soft-deleted page stays
|
||||||
// restorable before the daily purge job removes it for good.
|
// restorable before the daily purge job removes it for good.
|
||||||
'trash.retentionDays': z.number().int().min(1).default(30),
|
'trash.retentionDays': z.number().int().min(1).default(30),
|
||||||
|
// Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions
|
||||||
|
// without the dot. Images are always allowed regardless; SVG is governed
|
||||||
|
// by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so
|
||||||
|
// an admin can paste `.PDF` or `pdf` interchangeably.
|
||||||
|
'upload.allowedExtensions': z
|
||||||
|
.array(z.string())
|
||||||
|
.transform((exts) => [
|
||||||
|
...new Set(exts.map((e) => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean)),
|
||||||
|
])
|
||||||
|
.pipe(z.array(z.string().regex(/^[a-z0-9]+$/)))
|
||||||
|
.default([...DEFAULT_ATTACHMENT_EXTENSIONS]),
|
||||||
|
// SVG upload handling (security.md §Uploads): sanitize strips scripts and
|
||||||
|
// event handlers with a maintained library; reject refuses SVG outright.
|
||||||
|
'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
|
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;
|
||||||
|
|||||||
@ -70,6 +70,16 @@ _write_ on something readable is 403.
|
|||||||
one place the policy is pinned. A weakened guard is caught here — verified by
|
one place the policy is pinned. A weakened guard is caught here — verified by
|
||||||
temporarily loosening a route decorator and watching the pack go red.
|
temporarily loosening a route decorator and watching the pack go red.
|
||||||
|
|
||||||
|
## Attachments (`attachments.spec.ts`, issue #61)
|
||||||
|
|
||||||
|
Non-image attachments: a page's attachments section uploads an allowlisted
|
||||||
|
file, lists it, and inserts it into the document as a download link (verified
|
||||||
|
to serve with `Content-Disposition: attachment` + `nosniff`, never inline); a
|
||||||
|
disallowed extension is rejected with the localized allowlist error; the Pond
|
||||||
|
Admin file manager reports storage usage and flags an orphan (a pond-level
|
||||||
|
upload with no embedding page). The SVG sanitize/reject policy is covered at
|
||||||
|
the api level in `files.e2e.db.test.ts`.
|
||||||
|
|
||||||
## Content fixtures
|
## Content fixtures
|
||||||
|
|
||||||
`db:seed` also creates a **shared** pond `content-fixtures` (owned by
|
`db:seed` also creates a **shared** pond `content-fixtures` (owned by
|
||||||
|
|||||||
111
apps/web/e2e/attachments.spec.ts
Normal file
111
apps/web/e2e/attachments.spec.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-image attachments pack (issue #61): a page's attachments section uploads
|
||||||
|
* an allowlisted file, lists it, and inserts it into the document as a
|
||||||
|
* download link; a disallowed extension is rejected with the localized error;
|
||||||
|
* the Pond Admin file manager reports usage and flags an orphan. Selectors are
|
||||||
|
* language-neutral (the UI follows the user's locale) — CSS classes, not the
|
||||||
|
* button text.
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
const PDF = Buffer.from('%PDF-1.4 e2e attachment body');
|
||||||
|
|
||||||
|
async function createPage(
|
||||||
|
context: Awaited<ReturnType<typeof contextForUser>>,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ pondSlug: string; pageSlug: string }> {
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||||
|
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title } });
|
||||||
|
const page = await created.json();
|
||||||
|
return { pondSlug: pond.slug, pageSlug: page.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAttachments(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
|
||||||
|
await page.locator('.editor-shell__attachments-toggle').click();
|
||||||
|
await expect(page.locator('.attachments-panel')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('uploads a page attachment, lists it, and inserts a download link', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E Attach ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await openAttachments(page);
|
||||||
|
|
||||||
|
await page.locator('.attachments-panel__input').setInputFiles({
|
||||||
|
name: 'report.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: PDF,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = page.locator('.attachments-item__name', { hasText: 'report.pdf' });
|
||||||
|
await expect(item).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Insert into the document as a link, then confirm it landed as an anchor.
|
||||||
|
await page.locator('.attachments-item__insert').first().click();
|
||||||
|
const link = page.locator('.ProseMirror a[href^="/api/v1/media/"]');
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
|
||||||
|
// The linked media downloads (attachment disposition) with its filename and
|
||||||
|
// is never rendered inline as HTML (ADR 0011, security.md §Uploads).
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
const served = await context.request.get(href!);
|
||||||
|
expect(served.status()).toBe(200);
|
||||||
|
expect(served.headers()['content-disposition']).toContain('attachment');
|
||||||
|
expect(served.headers()['content-disposition']).toContain('report.pdf');
|
||||||
|
expect(served.headers()['x-content-type-options']).toBe('nosniff');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a disallowed extension with the localized error', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E Reject ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await openAttachments(page);
|
||||||
|
|
||||||
|
await page.locator('.attachments-panel__input').setInputFiles({
|
||||||
|
name: 'malware.exe',
|
||||||
|
mimeType: 'application/octet-stream',
|
||||||
|
buffer: Buffer.from('MZ not allowed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('.attachments-panel .form-banner--error')).toBeVisible();
|
||||||
|
await expect(page.locator('.attachments-item__name')).toHaveCount(0);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pond file manager shows usage and flags an orphan (Pond Admin)', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||||
|
|
||||||
|
// A pond-level upload with no embedding page is an orphan candidate.
|
||||||
|
const upload = await context.request.post(`/api/v1/ponds/${pond.id}/files`, {
|
||||||
|
multipart: { file: { name: 'loose.pdf', mimeType: 'application/pdf', buffer: PDF } },
|
||||||
|
});
|
||||||
|
expect(upload.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}/settings`);
|
||||||
|
|
||||||
|
const manager = page.locator('.pond-file-manager');
|
||||||
|
await expect(manager).toBeVisible();
|
||||||
|
await expect(manager.locator('.pond-file-manager__usage')).toBeVisible();
|
||||||
|
const row = manager.locator('.attachments-item', { hasText: 'loose.pdf' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('.attachments-item__orphan')).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
165
apps/web/src/files/AttachmentsPanel.tsx
Normal file
165
apps/web/src/files/AttachmentsPanel.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import type { AttachmentListItemView } from '@dorfteich/shared';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { Editor } from '@tiptap/react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { FormError } from '../components/forms';
|
||||||
|
import { apiDelete, apiGet, apiUploadFile } from '../lib/api';
|
||||||
|
|
||||||
|
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page's attachments section (issue #61): upload a file, see it listed with
|
||||||
|
* its type icon, size, and uploader, insert it into the document as a download
|
||||||
|
* link, or delete it. Uploads and deletes are page-write-gated in the api; in
|
||||||
|
* read mode the panel is a download list (no upload/insert/delete controls).
|
||||||
|
*/
|
||||||
|
export function AttachmentsPanel({
|
||||||
|
pageId,
|
||||||
|
editor,
|
||||||
|
canEdit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
pageId: string;
|
||||||
|
editor: Editor | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('files');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['page-files', pageId],
|
||||||
|
queryFn: () => apiGet<AttachmentListItemView[]>(`/pages/${pageId}/files`),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['page-files', pageId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPick(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiUploadFile(`/pages/${pageId}/files`, file);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiDelete(`/files/${id}`);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLink(item: AttachmentListItemView): void {
|
||||||
|
if (!editor) return;
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: item.fileName,
|
||||||
|
marks: [{ type: 'link', attrs: { href: mediaUrl(item.id) } }],
|
||||||
|
},
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = list.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="attachments-panel" aria-label={t('title')}>
|
||||||
|
<div className="attachments-panel__header">
|
||||||
|
<h3>{t('title')}</h3>
|
||||||
|
<button type="button" className="button" onClick={onClose}>
|
||||||
|
{t('close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError error={error} />
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="attachments-panel__upload">
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
className="attachments-panel__input"
|
||||||
|
onChange={(event) => void onPick(event)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
>
|
||||||
|
{busy ? t('uploading') : t('upload')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="attachments-panel__empty">{t('empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="attachments-panel__list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="attachments-item">
|
||||||
|
<span className="attachments-item__glyph" aria-hidden="true">
|
||||||
|
{fileGlyph(item.fileName, item.mimeType)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="attachments-item__name"
|
||||||
|
href={mediaUrl(item.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={item.fileName}
|
||||||
|
>
|
||||||
|
{item.fileName}
|
||||||
|
</a>
|
||||||
|
<span className="attachments-item__meta">
|
||||||
|
{formatBytes(item.sizeBytes)} · {item.uploaderName}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<span className="attachments-item__actions">
|
||||||
|
{editor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button attachments-item__insert"
|
||||||
|
onClick={() => insertLink(item)}
|
||||||
|
>
|
||||||
|
{t('insert')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button attachments-item__delete"
|
||||||
|
onClick={() => void remove(item.id)}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/web/src/files/PondFileManager.tsx
Normal file
84
apps/web/src/files/PondFileManager.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { PondFilesView } from '@dorfteich/shared';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { FormError } from '../components/forms';
|
||||||
|
import { apiDelete, apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pond-wide file manager (issue #61), Pond-Admin-gated in the api. Lists every
|
||||||
|
* attachment in the pond with its type, size, uploader, and the page that
|
||||||
|
* references it — a file with no referencing page is flagged as an orphan
|
||||||
|
* candidate — plus the pond's storage usage against its quota.
|
||||||
|
*/
|
||||||
|
export function PondFileManager({ pondId }: { pondId: string }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('files');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const files = useQuery({
|
||||||
|
queryKey: ['pond-files', pondId],
|
||||||
|
queryFn: () => apiGet<PondFilesView>(`/ponds/${pondId}/files`),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiDelete(`/files/${id}`);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pond-files', pondId] });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files.data) return <></>;
|
||||||
|
const { files: items, storageBytesUsed, storageBytesLimit } = files.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pond-file-manager">
|
||||||
|
<p className="pond-file-manager__usage">
|
||||||
|
{t('usage', {
|
||||||
|
used: formatBytes(storageBytesUsed),
|
||||||
|
limit: formatBytes(storageBytesLimit),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<FormError error={error} />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="pond-file-manager__empty">{t('empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="pond-file-manager__list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="attachments-item">
|
||||||
|
<span className="attachments-item__glyph" aria-hidden="true">
|
||||||
|
{fileGlyph(item.fileName, item.mimeType)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="attachments-item__name"
|
||||||
|
href={mediaUrl(item.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={item.fileName}
|
||||||
|
>
|
||||||
|
{item.fileName}
|
||||||
|
</a>
|
||||||
|
<span className="attachments-item__meta">
|
||||||
|
{formatBytes(item.sizeBytes)} · {item.uploaderName} ·{' '}
|
||||||
|
{item.pageTitle ?? <span className="attachments-item__orphan">{t('orphan')}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button attachments-item__delete"
|
||||||
|
onClick={() => void remove(item.id)}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/src/files/file-format.ts
Normal file
33
apps/web/src/files/file-format.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { fileExtension, isImageMimeType } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
/** Human-readable byte size (binary units) for the file listings (#61). */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny emoji glyph standing in for a file-type icon (no icon asset pipeline
|
||||||
|
* yet) — enough to tell an image, a document, a spreadsheet, and an archive
|
||||||
|
* apart at a glance in the attachment lists.
|
||||||
|
*/
|
||||||
|
export function fileGlyph(fileName: string, mimeType: string): string {
|
||||||
|
if (isImageMimeType(mimeType) || mimeType === 'image/svg+xml') return '🖼️';
|
||||||
|
const ext = fileExtension(fileName);
|
||||||
|
if (ext === 'pdf') return '📕';
|
||||||
|
if (['doc', 'docx', 'odt', 'rtf', 'txt', 'md'].includes(ext)) return '📄';
|
||||||
|
if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return '📊';
|
||||||
|
if (['ppt', 'pptx', 'odp'].includes(ext)) return '📽️';
|
||||||
|
if (['zip'].includes(ext)) return '🗜️';
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permission-checked download URL for an attachment (ADR 0011). */
|
||||||
|
export const mediaUrl = (fileId: string): string => `/api/v1/media/${fileId}`;
|
||||||
@ -3,6 +3,7 @@ import deAuth from '@dorfteich/shared/i18n/de/auth.json';
|
|||||||
import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
||||||
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
|
import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
||||||
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||||
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
||||||
import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
||||||
@ -16,6 +17,7 @@ import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
|||||||
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
||||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
|
import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
||||||
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||||
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||||
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
||||||
@ -46,6 +48,7 @@ void i18n
|
|||||||
auth: enAuth,
|
auth: enAuth,
|
||||||
settings: enSettings,
|
settings: enSettings,
|
||||||
editor: enEditor,
|
editor: enEditor,
|
||||||
|
files: enFiles,
|
||||||
labels: enLabels,
|
labels: enLabels,
|
||||||
links: enLinks,
|
links: enLinks,
|
||||||
members: enMembers,
|
members: enMembers,
|
||||||
@ -61,6 +64,7 @@ void i18n
|
|||||||
auth: deAuth,
|
auth: deAuth,
|
||||||
settings: deSettings,
|
settings: deSettings,
|
||||||
editor: deEditor,
|
editor: deEditor,
|
||||||
|
files: deFiles,
|
||||||
labels: deLabels,
|
labels: deLabels,
|
||||||
links: deLinks,
|
links: deLinks,
|
||||||
members: deMembers,
|
members: deMembers,
|
||||||
|
|||||||
@ -17,6 +17,8 @@ interface InstanceSettings {
|
|||||||
'quota.additionalPonds': number;
|
'quota.additionalPonds': number;
|
||||||
'quota.storageBytes': number;
|
'quota.storageBytes': number;
|
||||||
'quota.maxFileBytes': number;
|
'quota.maxFileBytes': number;
|
||||||
|
'upload.allowedExtensions': string[];
|
||||||
|
'upload.svgPolicy': 'reject' | 'sanitize';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSettingsPage(): React.JSX.Element {
|
export function AdminSettingsPage(): React.JSX.Element {
|
||||||
@ -97,12 +99,80 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<UploadSettingsForm settings={settings.data} />
|
||||||
|
|
||||||
<QuotaManager />
|
<QuotaManager />
|
||||||
<UserManager />
|
<UserManager />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload allowlist + SVG policy (issue #61). The allowlist is an array in the
|
||||||
|
* api but edited here as a comma-separated field; images are always allowed
|
||||||
|
* and are not part of this list.
|
||||||
|
*/
|
||||||
|
function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('files');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [extensions, setExtensions] = useState(settings['upload.allowedExtensions'].join(', '));
|
||||||
|
const [svgPolicy, setSvgPolicy] = useState(settings['upload.svgPolicy']);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiPatch('/admin/settings', {
|
||||||
|
'upload.allowedExtensions': extensions
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
'upload.svgPolicy': svgPolicy,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
||||||
|
setSaved(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section">
|
||||||
|
<h2>{t('settings.title')}</h2>
|
||||||
|
<form onSubmit={(event) => void onSubmit(event)} noValidate>
|
||||||
|
<FormError error={error} />
|
||||||
|
<FormSuccess message={saved ? t('settings.save') : null} />
|
||||||
|
<Field label={t('settings.allowedExtensions')} hint={t('settings.allowedExtensionsHelp')}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={extensions}
|
||||||
|
onChange={(event) => setExtensions(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('settings.svgPolicy')}>
|
||||||
|
<select
|
||||||
|
value={svgPolicy}
|
||||||
|
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
|
||||||
|
>
|
||||||
|
<option value="sanitize">{t('settings.svgSanitize')}</option>
|
||||||
|
<option value="reject">{t('settings.svgReject')}</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<button type="submit" className="button" disabled={busy}>
|
||||||
|
{t('settings.save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Map the instance-setting key to the shared quota key its label lives under. */
|
/** Map the instance-setting key to the shared quota key its label lives under. */
|
||||||
const SETTING_TO_QUOTA_KEY = {
|
const SETTING_TO_QUOTA_KEY = {
|
||||||
'quota.editorsPerPond': 'editors_per_pond',
|
'quota.editorsPerPond': 'editors_per_pond',
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import * as Y from 'yjs';
|
|||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||||
|
import { AttachmentsPanel } from '../files/AttachmentsPanel';
|
||||||
import { HistoryPanel } from '../editor/HistoryPanel';
|
import { HistoryPanel } from '../editor/HistoryPanel';
|
||||||
import { LabelPicker } from '../labels/LabelPicker';
|
import { LabelPicker } from '../labels/LabelPicker';
|
||||||
import { BacklinksPanel } from '../links/BacklinksPanel';
|
import { BacklinksPanel } from '../links/BacklinksPanel';
|
||||||
@ -48,6 +49,7 @@ function PageEditor({
|
|||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [showAttachments, setShowAttachments] = useState(false);
|
||||||
|
|
||||||
// Created and destroyed within the same effect (not `useMemo` + a separate
|
// Created and destroyed within the same effect (not `useMemo` + a separate
|
||||||
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
||||||
@ -120,6 +122,24 @@ function PageEditor({
|
|||||||
<WikilinkContext.Provider value={wikilinks}>
|
<WikilinkContext.Provider value={wikilinks}>
|
||||||
<div className="editor-shell">
|
<div className="editor-shell">
|
||||||
{canEdit && <Toolbar editor={editor} />}
|
{canEdit && <Toolbar editor={editor} />}
|
||||||
|
<div className="editor-shell__tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button editor-shell__attachments-toggle"
|
||||||
|
aria-expanded={showAttachments}
|
||||||
|
onClick={() => setShowAttachments((open) => !open)}
|
||||||
|
>
|
||||||
|
{t('files:title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showAttachments && (
|
||||||
|
<AttachmentsPanel
|
||||||
|
pageId={page.id}
|
||||||
|
editor={editor}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onClose={() => setShowAttachments(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||||
{t(`connection.${collab.status}`)}
|
{t(`connection.${collab.status}`)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { LabelManager } from '../labels/LabelManager';
|
|||||||
import { PhantomPagesView } from '../links/PhantomPagesView';
|
import { PhantomPagesView } from '../links/PhantomPagesView';
|
||||||
import { AccessRulesManager } from '../access/AccessRulesManager';
|
import { AccessRulesManager } from '../access/AccessRulesManager';
|
||||||
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
|
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
|
||||||
|
import { PondFileManager } from '../files/PondFileManager';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
const { t: tLinks } = useTranslation('links');
|
const { t: tLinks } = useTranslation('links');
|
||||||
const { t: tMembers } = useTranslation('members');
|
const { t: tMembers } = useTranslation('members');
|
||||||
const { t: tErrors } = useTranslation('errors');
|
const { t: tErrors } = useTranslation('errors');
|
||||||
|
const { t: tFiles } = useTranslation('files');
|
||||||
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@ -63,6 +65,12 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
|
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tFiles('manager.title')}</h2>
|
||||||
|
<PondFileManager pondId={pond.data.id} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1754,3 +1754,94 @@ button {
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Attachments: page section + pond file manager (issue #61) */
|
||||||
|
.editor-shell__tools {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm, 0.375rem);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
background: var(--color-surface-muted, rgba(127, 127, 127, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel__upload {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel__input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel__empty,
|
||||||
|
.pond-file-manager__empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-panel__list,
|
||||||
|
.pond-file-manager__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-item__glyph {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-item__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-item__meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-item__orphan {
|
||||||
|
color: var(--color-danger, #b91c1c);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-item__actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-file-manager__usage {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
"label_has_pages": "Diesem Label sind noch Seiten zugeordnet; bitte bestätigen, um sie zu lösen.",
|
"label_has_pages": "Diesem Label sind noch Seiten zugeordnet; bitte bestätigen, um sie zu lösen.",
|
||||||
"label_wrong_pond": "Dieses Label gehört zu einem anderen Teich.",
|
"label_wrong_pond": "Dieses Label gehört zu einem anderen Teich.",
|
||||||
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
|
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
|
||||||
|
"upload_type_not_allowed": "Dieser Dateityp ist auf dieser Instanz nicht erlaubt.",
|
||||||
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
|
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
|
||||||
"network": "Der Server war nicht erreichbar.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
"grant_exists": "Diese Berechtigung existiert bereits.",
|
"grant_exists": "Diese Berechtigung existiert bereits.",
|
||||||
|
|||||||
23
packages/shared/i18n/de/files.json
Normal file
23
packages/shared/i18n/de/files.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"title": "Anhänge",
|
||||||
|
"upload": "Datei hochladen",
|
||||||
|
"uploading": "Wird hochgeladen…",
|
||||||
|
"insert": "Als Link einfügen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"empty": "Noch keine Anhänge.",
|
||||||
|
"orphan": "nicht referenziert",
|
||||||
|
"usage": "{{used}} von {{limit}} belegt",
|
||||||
|
"manager": {
|
||||||
|
"title": "Dateien"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Uploads",
|
||||||
|
"allowedExtensions": "Erlaubte Dateiendungen",
|
||||||
|
"allowedExtensionsHelp": "Kommagetrennt, ohne Punkt. Bilder sind immer erlaubt.",
|
||||||
|
"svgPolicy": "SVG-Uploads",
|
||||||
|
"svgSanitize": "Bereinigen (Skripte entfernen)",
|
||||||
|
"svgReject": "Ablehnen",
|
||||||
|
"save": "Upload-Einstellungen speichern"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@
|
|||||||
"label_has_pages": "This label still has pages assigned; confirm to detach them.",
|
"label_has_pages": "This label still has pages assigned; confirm to detach them.",
|
||||||
"label_wrong_pond": "This label belongs to a different pond.",
|
"label_wrong_pond": "This label belongs to a different pond.",
|
||||||
"unsupported_file_type": "This file type is not supported.",
|
"unsupported_file_type": "This file type is not supported.",
|
||||||
|
"upload_type_not_allowed": "This file type is not allowed on this instance.",
|
||||||
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
|
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
|
||||||
"network": "The server could not be reached.",
|
"network": "The server could not be reached.",
|
||||||
"grant_exists": "This grant already exists.",
|
"grant_exists": "This grant already exists.",
|
||||||
|
|||||||
23
packages/shared/i18n/en/files.json
Normal file
23
packages/shared/i18n/en/files.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"title": "Attachments",
|
||||||
|
"upload": "Upload file",
|
||||||
|
"uploading": "Uploading…",
|
||||||
|
"insert": "Insert as link",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"empty": "No attachments yet.",
|
||||||
|
"orphan": "not referenced",
|
||||||
|
"usage": "{{used}} of {{limit}} used",
|
||||||
|
"manager": {
|
||||||
|
"title": "Files"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Uploads",
|
||||||
|
"allowedExtensions": "Allowed file extensions",
|
||||||
|
"allowedExtensionsHelp": "Comma-separated, without the dot. Images are always allowed.",
|
||||||
|
"svgPolicy": "SVG uploads",
|
||||||
|
"svgSanitize": "Sanitize (strip scripts)",
|
||||||
|
"svgReject": "Reject",
|
||||||
|
"save": "Save upload settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Attachment types shared between api and web (issue #27, ADR 0011). M2
|
* Attachment types shared between api and web (issue #27, ADR 0011). M2
|
||||||
* accepts images only; the general type allowlist arrives in M6 (#61).
|
* accepted images only; M6 (#61) adds a configurable allowlist for general
|
||||||
|
* attachments (PDF, office files, …) plus an SVG policy.
|
||||||
*/
|
*/
|
||||||
export const ATTACHMENT_IMAGE_MIME_TYPES = [
|
export const ATTACHMENT_IMAGE_MIME_TYPES = [
|
||||||
'image/png',
|
'image/png',
|
||||||
@ -11,6 +12,75 @@ export const ATTACHMENT_IMAGE_MIME_TYPES = [
|
|||||||
|
|
||||||
export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number];
|
export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number];
|
||||||
|
|
||||||
|
export function isImageMimeType(mimeType: string): boolean {
|
||||||
|
return (ATTACHMENT_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG is an image but also an XML document that can carry scripts and event
|
||||||
|
* handlers, so it is never treated like a raster image: it is sanitized or
|
||||||
|
* rejected on upload (instance setting) and always served as a download,
|
||||||
|
* never inline (security.md §Uploads).
|
||||||
|
*/
|
||||||
|
export const SVG_MIME_TYPE = 'image/svg+xml';
|
||||||
|
|
||||||
|
/** How the instance handles SVG uploads (ADR 0011, security.md §Uploads). */
|
||||||
|
export type SvgPolicy = 'reject' | 'sanitize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension → served MIME type for the non-image allowlist. The allowlist is
|
||||||
|
* keyed on the lowercase extension (what an admin configures and what names
|
||||||
|
* the download); the MIME here only sets the response `Content-Type`, and
|
||||||
|
* non-images are always sent with `Content-Disposition: attachment` +
|
||||||
|
* `nosniff`, so a wrong guess can never cause inline execution.
|
||||||
|
*/
|
||||||
|
export const ATTACHMENT_EXTENSION_MIME_TYPES: Readonly<Record<string, string>> = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
txt: 'text/plain',
|
||||||
|
md: 'text/markdown',
|
||||||
|
csv: 'text/csv',
|
||||||
|
rtf: 'application/rtf',
|
||||||
|
doc: 'application/msword',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
odt: 'application/vnd.oasis.opendocument.text',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
||||||
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
odp: 'application/vnd.oasis.opendocument.presentation',
|
||||||
|
zip: 'application/zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default non-image allowlist (extensions, lowercase, no dot). Images from
|
||||||
|
* {@link ATTACHMENT_IMAGE_MIME_TYPES} are always allowed regardless of this
|
||||||
|
* list; SVG is governed separately by the SVG policy.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ATTACHMENT_EXTENSIONS: readonly string[] = [
|
||||||
|
'pdf',
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'csv',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'odt',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ods',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'odp',
|
||||||
|
'zip',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Lowercase extension without the leading dot, or '' when the name has none. */
|
||||||
|
export function fileExtension(fileName: string): string {
|
||||||
|
const dot = fileName.lastIndexOf('.');
|
||||||
|
if (dot <= 0 || dot === fileName.length - 1) return '';
|
||||||
|
return fileName.slice(dot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard ceiling on the raw multipart body the api will buffer in memory,
|
* Hard ceiling on the raw multipart body the api will buffer in memory,
|
||||||
* independent of the per-pond/user `max_file_bytes` quota (QuotaService)
|
* independent of the per-pond/user `max_file_bytes` quota (QuotaService)
|
||||||
@ -28,3 +98,20 @@ export interface AttachmentView {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row in the page-attachments section and the pond file manager (#61):
|
||||||
|
* carries the uploader's display name and, for the pond manager, the title
|
||||||
|
* of the page currently referencing the file (null = orphan candidate).
|
||||||
|
*/
|
||||||
|
export interface AttachmentListItemView extends AttachmentView {
|
||||||
|
uploaderName: string;
|
||||||
|
pageTitle: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pond-wide file manager payload (Pond Admin, #61). */
|
||||||
|
export interface PondFilesView {
|
||||||
|
files: AttachmentListItemView[];
|
||||||
|
storageBytesUsed: number;
|
||||||
|
storageBytesLimit: number;
|
||||||
|
}
|
||||||
|
|||||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@ -50,12 +50,18 @@ importers:
|
|||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.4.11
|
||||||
|
version: 3.4.11
|
||||||
fractional-indexing:
|
fractional-indexing:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.4
|
specifier: ^26.3.4
|
||||||
version: 26.3.4(typescript@5.9.3)
|
version: 26.3.4(typescript@5.9.3)
|
||||||
|
jsdom:
|
||||||
|
specifier: ^26.1.0
|
||||||
|
version: 26.1.0
|
||||||
multer:
|
multer:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@ -117,6 +123,9 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
|
'@types/jsdom':
|
||||||
|
specifier: ^28.0.3
|
||||||
|
version: 28.0.3
|
||||||
'@types/multer':
|
'@types/multer':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
@ -2176,6 +2185,9 @@ packages:
|
|||||||
'@types/http-errors@2.0.5':
|
'@types/http-errors@2.0.5':
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||||
|
|
||||||
|
'@types/jsdom@28.0.3':
|
||||||
|
resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@ -2232,6 +2244,9 @@ packages:
|
|||||||
'@types/supertest@6.0.3':
|
'@types/supertest@6.0.3':
|
||||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||||
|
|
||||||
|
'@types/tough-cookie@4.0.5':
|
||||||
|
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@ -2870,6 +2885,9 @@ packages:
|
|||||||
dezalgo@1.0.4:
|
dezalgo@1.0.4:
|
||||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||||
|
|
||||||
|
dompurify@3.4.11:
|
||||||
|
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||||
|
|
||||||
dotenv@16.6.1:
|
dotenv@16.6.1:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -2918,6 +2936,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@8.0.0:
|
||||||
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||||
|
|
||||||
@ -3896,6 +3918,9 @@ packages:
|
|||||||
parse5@7.3.0:
|
parse5@7.3.0:
|
||||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
|
parse5@8.0.1:
|
||||||
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -4793,6 +4818,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
undici-types@7.28.0:
|
||||||
|
resolution: {integrity: sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==}
|
||||||
|
|
||||||
undici-types@8.3.0:
|
undici-types@8.3.0:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
@ -6972,6 +7000,13 @@ snapshots:
|
|||||||
|
|
||||||
'@types/http-errors@2.0.5': {}
|
'@types/http-errors@2.0.5': {}
|
||||||
|
|
||||||
|
'@types/jsdom@28.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.1.0
|
||||||
|
'@types/tough-cookie': 4.0.5
|
||||||
|
parse5: 8.0.1
|
||||||
|
undici-types: 7.28.0
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
@ -7038,6 +7073,8 @@ snapshots:
|
|||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/superagent': 8.1.10
|
'@types/superagent': 8.1.10
|
||||||
|
|
||||||
|
'@types/tough-cookie@4.0.5': {}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
'@types/use-sync-external-store@0.0.6': {}
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
@ -7729,6 +7766,10 @@ snapshots:
|
|||||||
asap: 2.0.6
|
asap: 2.0.6
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
|
dompurify@3.4.11:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@ -7769,6 +7810,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
entities@8.0.0: {}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.2.1
|
is-arrayish: 0.2.1
|
||||||
@ -8899,6 +8942,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
entities: 6.0.1
|
entities: 6.0.1
|
||||||
|
|
||||||
|
parse5@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
entities: 8.0.0
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
path-exists@4.0.0: {}
|
path-exists@4.0.0: {}
|
||||||
@ -9884,6 +9931,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
undici-types@7.28.0: {}
|
||||||
|
|
||||||
undici-types@8.3.0: {}
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user