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 \
|
||||
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
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
|
||||
@ -23,8 +23,10 @@
|
||||
"@prisma/client": "^6.3.0",
|
||||
"argon2": "^0.44.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dompurify": "^3.4.11",
|
||||
"fractional-indexing": "^4.0.0",
|
||||
"i18next": "^26.3.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"multer": "^2.1.1",
|
||||
"nestjs-pino": "^4.3.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
@ -47,6 +49,7 @@
|
||||
"@swc/core": "^1.10.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
|
||||
@ -13,18 +13,24 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
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 { AuthedRequest, Public } from '../auth/auth.guard';
|
||||
import {
|
||||
RequiresAttachmentPermission,
|
||||
RequiresPagePermission,
|
||||
RequiresPondRole,
|
||||
} from '../permissions/permission.decorators';
|
||||
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
/** File storage and image-upload API (issue #27). */
|
||||
/** File storage, upload, and attachment listing API (issue #27, #61). */
|
||||
@Controller()
|
||||
export class FilesController {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
@ -41,11 +47,40 @@ export class FilesController {
|
||||
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
|
||||
* executable content. `@Public()` so embedded images on a public page load
|
||||
* 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')
|
||||
@Public()
|
||||
@ -55,13 +90,14 @@ export class FilesController {
|
||||
@Req() request: AuthedRequest,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): 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');
|
||||
// Attachments are immutable — a new upload always gets a new id.
|
||||
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
||||
const kind = inline ? 'inline' : 'attachment';
|
||||
return new StreamableFile(stream, {
|
||||
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 { AuthTokensService } from '../auth/auth-tokens.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
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);
|
||||
});
|
||||
|
||||
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()
|
||||
.post(`/api/v1/ponds/${pondId}/files`)
|
||||
.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(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()
|
||||
.post(`/api/v1/ponds/${pondId}/files`)
|
||||
.set('Cookie', ownerCookie)
|
||||
.attach('file', Buffer.from('<html><body>evil</body></html>'), 'sneaky.png')
|
||||
.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 () => {
|
||||
@ -236,6 +304,65 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
||||
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 () => {
|
||||
const uploaded = await api()
|
||||
.post(`/api/v1/ponds/${pondId}/files`)
|
||||
|
||||
@ -7,28 +7,50 @@ import {
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
} 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 { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QuotaService } from '../quotas/quota.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.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 {
|
||||
attachment: Attachment;
|
||||
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()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly quotas: QuotaService,
|
||||
private readonly storage: FileStorageService,
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
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(
|
||||
user: User,
|
||||
pondId: string,
|
||||
file: { buffer: Buffer; size: number; originalname: string },
|
||||
pageId?: string,
|
||||
): Promise<AttachmentView> {
|
||||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||
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', {
|
||||
userId: pond.ownerId,
|
||||
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();
|
||||
// Consume the storage budget before touching the disk so a race never
|
||||
// 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 {
|
||||
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({
|
||||
data: {
|
||||
id,
|
||||
pondId: pond.id,
|
||||
pageId: pageId ?? null,
|
||||
fileName: file.originalname,
|
||||
mimeType,
|
||||
sizeBytes: file.size,
|
||||
mimeType: resolved.mimeType,
|
||||
sizeBytes,
|
||||
storagePath: `${pond.id}/${id}`,
|
||||
uploadedBy: user.id,
|
||||
},
|
||||
});
|
||||
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',
|
||||
);
|
||||
return this.viewOf(attachment);
|
||||
} catch (error) {
|
||||
// Roll back the reservation and any bytes already written so usage
|
||||
// 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);
|
||||
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> {
|
||||
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
|
||||
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> {
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
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 —
|
||||
* only the actual bytes decide, which is what catches a renamed
|
||||
* `.html`-as-`.png` upload. SVG has no reliable magic-byte signature (it's
|
||||
* XML) and is rejected in M2 regardless, per ADR 0011.
|
||||
* `.html`-as-`.png` upload.
|
||||
*/
|
||||
const SIGNATURES: ReadonlyArray<{
|
||||
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 {
|
||||
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 { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -32,6 +33,20 @@ export const INSTANCE_SETTINGS = {
|
||||
// Trash retention (ADR 0013, issue #31): days a soft-deleted page stays
|
||||
// restorable before the daily purge job removes it for good.
|
||||
'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;
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
`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 deEditor from '@dorfteich/shared/i18n/de/editor.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 deLinks from '@dorfteich/shared/i18n/de/links.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 enEditor from '@dorfteich/shared/i18n/en/editor.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 enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
||||
@ -46,6 +48,7 @@ void i18n
|
||||
auth: enAuth,
|
||||
settings: enSettings,
|
||||
editor: enEditor,
|
||||
files: enFiles,
|
||||
labels: enLabels,
|
||||
links: enLinks,
|
||||
members: enMembers,
|
||||
@ -61,6 +64,7 @@ void i18n
|
||||
auth: deAuth,
|
||||
settings: deSettings,
|
||||
editor: deEditor,
|
||||
files: deFiles,
|
||||
labels: deLabels,
|
||||
links: deLinks,
|
||||
members: deMembers,
|
||||
|
||||
@ -17,6 +17,8 @@ interface InstanceSettings {
|
||||
'quota.additionalPonds': number;
|
||||
'quota.storageBytes': number;
|
||||
'quota.maxFileBytes': number;
|
||||
'upload.allowedExtensions': string[];
|
||||
'upload.svgPolicy': 'reject' | 'sanitize';
|
||||
}
|
||||
|
||||
export function AdminSettingsPage(): React.JSX.Element {
|
||||
@ -97,12 +99,80 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<UploadSettingsForm settings={settings.data} />
|
||||
|
||||
<QuotaManager />
|
||||
<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. */
|
||||
const SETTING_TO_QUOTA_KEY = {
|
||||
'quota.editorsPerPond': 'editors_per_pond',
|
||||
|
||||
@ -10,6 +10,7 @@ import * as Y from 'yjs';
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { FormError } from '../components/forms';
|
||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||
import { AttachmentsPanel } from '../files/AttachmentsPanel';
|
||||
import { HistoryPanel } from '../editor/HistoryPanel';
|
||||
import { LabelPicker } from '../labels/LabelPicker';
|
||||
import { BacklinksPanel } from '../links/BacklinksPanel';
|
||||
@ -48,6 +49,7 @@ function PageEditor({
|
||||
const { t } = useTranslation('editor');
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [showAttachments, setShowAttachments] = useState(false);
|
||||
|
||||
// Created and destroyed within the same effect (not `useMemo` + a separate
|
||||
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
|
||||
@ -120,6 +122,24 @@ function PageEditor({
|
||||
<WikilinkContext.Provider value={wikilinks}>
|
||||
<div className="editor-shell">
|
||||
{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}>
|
||||
{t(`connection.${collab.status}`)}
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@ import { LabelManager } from '../labels/LabelManager';
|
||||
import { PhantomPagesView } from '../links/PhantomPagesView';
|
||||
import { AccessRulesManager } from '../access/AccessRulesManager';
|
||||
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
|
||||
import { PondFileManager } from '../files/PondFileManager';
|
||||
import { apiGet } from '../lib/api';
|
||||
import { MemberManager } from '../members/MemberManager';
|
||||
|
||||
@ -25,6 +26,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
const { t: tLinks } = useTranslation('links');
|
||||
const { t: tMembers } = useTranslation('members');
|
||||
const { t: tErrors } = useTranslation('errors');
|
||||
const { t: tFiles } = useTranslation('files');
|
||||
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
||||
const { user } = useAuth();
|
||||
|
||||
@ -63,6 +65,12 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
|
||||
</section>
|
||||
)}
|
||||
{canModify && (
|
||||
<section>
|
||||
<h2>{tFiles('manager.title')}</h2>
|
||||
<PondFileManager pondId={pond.data.id} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1754,3 +1754,94 @@ button {
|
||||
gap: 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_wrong_pond": "Dieses Label gehört zu einem anderen Teich.",
|
||||
"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).",
|
||||
"network": "Der Server war nicht erreichbar.",
|
||||
"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_wrong_pond": "This label belongs to a different pond.",
|
||||
"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).",
|
||||
"network": "The server could not be reached.",
|
||||
"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
|
||||
* 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 = [
|
||||
'image/png',
|
||||
@ -11,6 +12,75 @@ export const ATTACHMENT_IMAGE_MIME_TYPES = [
|
||||
|
||||
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,
|
||||
* independent of the per-pond/user `max_file_bytes` quota (QuotaService)
|
||||
@ -28,3 +98,20 @@ export interface AttachmentView {
|
||||
sizeBytes: number;
|
||||
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:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
dompurify:
|
||||
specifier: ^3.4.11
|
||||
version: 3.4.11
|
||||
fractional-indexing:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
i18next:
|
||||
specifier: ^26.3.4
|
||||
version: 26.3.4(typescript@5.9.3)
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@ -117,6 +123,9 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
'@types/jsdom':
|
||||
specifier: ^28.0.3
|
||||
version: 28.0.3
|
||||
'@types/multer':
|
||||
specifier: ^2.0.0
|
||||
version: 2.2.0
|
||||
@ -2176,6 +2185,9 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/jsdom@28.0.3':
|
||||
resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@ -2232,6 +2244,9 @@ packages:
|
||||
'@types/supertest@6.0.3':
|
||||
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':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@ -2870,6 +2885,9 @@ packages:
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
@ -2918,6 +2936,10 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
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:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@ -3896,6 +3918,9 @@ packages:
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
parse5@8.0.1:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -4793,6 +4818,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
undici-types@7.28.0:
|
||||
resolution: {integrity: sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==}
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
@ -6972,6 +7000,13 @@ snapshots:
|
||||
|
||||
'@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/linkify-it@5.0.0': {}
|
||||
@ -7038,6 +7073,8 @@ snapshots:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.10
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
@ -7729,6 +7766,10 @@ snapshots:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
dompurify@3.4.11:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@ -7769,6 +7810,8 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@ -8899,6 +8942,10 @@ snapshots:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parse5@8.0.1:
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
@ -9884,6 +9931,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
undici-types@7.28.0: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user