Add file storage service and image upload API (#27)
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the mounted volume), the attachments model, and POST /ponds/:id/files, GET /media/:fileId, DELETE /files/:id. Uploads are validated by sniffing magic bytes rather than trusting the client's Content-Type/filename (catches a renamed .html-as-.png), checked against the max_file_bytes and storage_bytes quotas, and served with nosniff + immutable caching. Closes #27
This commit is contained in:
parent
49beb45b3e
commit
0fae699018
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,3 +8,5 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
test-results/
|
test-results/
|
||||||
|
# Local upload storage for native (non-Docker) dev runs (UPLOADS_DIR default).
|
||||||
|
apps/api/data/
|
||||||
|
|||||||
@ -22,6 +22,10 @@ WORKDIR /app
|
|||||||
COPY --from=build --chown=node:node /out /app
|
COPY --from=build --chown=node:node /out /app
|
||||||
# Generate the Prisma client for this image's platform.
|
# Generate the Prisma client for this image's platform.
|
||||||
RUN node node_modules/prisma/build/index.js generate
|
RUN node node_modules/prisma/build/index.js generate
|
||||||
|
# A fresh named volume mounted at /data/uploads is created root-owned;
|
||||||
|
# pre-creating it here (Docker copies an image directory's ownership into
|
||||||
|
# a new volume on first mount) lets the non-root `node` user write to it.
|
||||||
|
RUN mkdir -p /data/uploads && chown -R node:node /data/uploads
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"fractional-indexing": "^4.0.0",
|
"fractional-indexing": "^4.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"nestjs-pino": "^4.3.0",
|
"nestjs-pino": "^4.3.0",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
@ -44,6 +45,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/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "attachments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"pond_id" TEXT NOT NULL,
|
||||||
|
"page_id" TEXT,
|
||||||
|
"file_name" TEXT NOT NULL,
|
||||||
|
"mime_type" TEXT NOT NULL,
|
||||||
|
"size_bytes" INTEGER NOT NULL,
|
||||||
|
"storage_path" TEXT NOT NULL,
|
||||||
|
"uploaded_by" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "attachments_pond_id_idx" ON "attachments"("pond_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "attachments_page_id_idx" ON "attachments"("page_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -47,6 +47,7 @@ model User {
|
|||||||
authTokens AuthToken[]
|
authTokens AuthToken[]
|
||||||
ponds Pond[]
|
ponds Pond[]
|
||||||
pages Page[]
|
pages Page[]
|
||||||
|
attachments Attachment[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -76,6 +77,7 @@ model Pond {
|
|||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
usage PondUsage?
|
usage PondUsage?
|
||||||
pages Page[]
|
pages Page[]
|
||||||
|
attachments Attachment[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("ponds")
|
@@map("ponds")
|
||||||
@ -103,6 +105,7 @@ model Page {
|
|||||||
creator User @relation(fields: [createdBy], references: [id])
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
updates PageUpdate[]
|
updates PageUpdate[]
|
||||||
contentCache PageContentCache?
|
contentCache PageContentCache?
|
||||||
|
attachments Attachment[]
|
||||||
|
|
||||||
@@unique([pondId, slug])
|
@@unique([pondId, slug])
|
||||||
@@index([pondId])
|
@@index([pondId])
|
||||||
@ -175,6 +178,37 @@ model PondUsage {
|
|||||||
@@map("pond_usage")
|
@@map("pond_usage")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at
|
||||||
|
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
|
||||||
|
/// metadata needed to serve and account for it. `pageId` is nullable and
|
||||||
|
/// left unset by #27's endpoints — images are uploaded before the page
|
||||||
|
/// referencing them is known (paste-then-insert, issue #28); a later story
|
||||||
|
/// wires pages to set it once they track their embedded attachments.
|
||||||
|
/// `deletedAt` is unused by #27 (its `DELETE /files/:id` hard-deletes
|
||||||
|
/// immediately, bytes and all) — reserved for the page-trash-purge flow
|
||||||
|
/// (#31, ADR 0011 "orphan cleanup"), which soft-deletes an attachment when
|
||||||
|
/// its page is purged before the nightly job physically removes it.
|
||||||
|
model Attachment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
pondId String @map("pond_id")
|
||||||
|
pageId String? @map("page_id")
|
||||||
|
fileName String @map("file_name")
|
||||||
|
mimeType String @map("mime_type")
|
||||||
|
sizeBytes Int @map("size_bytes")
|
||||||
|
storagePath String @map("storage_path")
|
||||||
|
uploadedBy String @map("uploaded_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
pond Pond @relation(fields: [pondId], references: [id])
|
||||||
|
page Page? @relation(fields: [pageId], references: [id])
|
||||||
|
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||||
|
|
||||||
|
@@index([pondId])
|
||||||
|
@@index([pageId])
|
||||||
|
@@map("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
/// One row per login method. `provider` is "password" today and
|
/// One row per login method. `provider` is "password" today and
|
||||||
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
||||||
/// password identities.
|
/// password identities.
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
import { ConfigModule } from './config/config.module';
|
import { ConfigModule } from './config/config.module';
|
||||||
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
import { PagesModule } from './pages/pages.module';
|
import { PagesModule } from './pages/pages.module';
|
||||||
@ -26,6 +27,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
PondsModule,
|
PondsModule,
|
||||||
PagesModule,
|
PagesModule,
|
||||||
|
FilesModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
|
|||||||
37
apps/api/src/files/file-storage.service.ts
Normal file
37
apps/api/src/files/file-storage.service.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem binding for uploaded files (ADR 0011, issue #27): opaque
|
||||||
|
* layout `<uploadsDir>/<pondId>/<fileId>`, original filenames and metadata
|
||||||
|
* live in the database, not on disk. Kept behind this interface so an S3
|
||||||
|
* binding stays possible later without touching callers.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FileStorageService {
|
||||||
|
constructor(private readonly config: AppConfig) {}
|
||||||
|
|
||||||
|
private pathFor(pondId: string, fileId: string): string {
|
||||||
|
return join(this.config.env.UPLOADS_DIR, pondId, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(pondId: string, fileId: string, data: Buffer): Promise<void> {
|
||||||
|
await mkdir(join(this.config.env.UPLOADS_DIR, pondId), { recursive: true });
|
||||||
|
await writeFile(this.pathFor(pondId, fileId), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
createReadStream(pondId: string, fileId: string): Readable {
|
||||||
|
return createReadStream(this.pathFor(pondId, fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent — removing an already-absent file is not an error. */
|
||||||
|
async delete(pondId: string, fileId: string): Promise<void> {
|
||||||
|
await rm(this.pathFor(pondId, fileId), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/api/src/files/files.controller.ts
Normal file
61
apps/api/src/files/files.controller.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
|
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
|
/** File storage and image-upload API (issue #27). */
|
||||||
|
@Controller()
|
||||||
|
export class FilesController {
|
||||||
|
constructor(private readonly files: FilesService) {}
|
||||||
|
|
||||||
|
@Post('ponds/:pondId/files')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } }))
|
||||||
|
async upload(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<AttachmentView> {
|
||||||
|
if (!file) throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
return this.files.upload(request.user!, pondId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */
|
||||||
|
@Get('media/:fileId')
|
||||||
|
async download(
|
||||||
|
@Param('fileId') fileId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { attachment, stream } = await this.files.download(request.user!, 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');
|
||||||
|
return new StreamableFile(stream, {
|
||||||
|
type: attachment.mimeType,
|
||||||
|
disposition: `inline; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('files/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||||
|
await this.files.remove(request.user!, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
apps/api/src/files/files.e2e.db.test.ts
Normal file
257
apps/api/src/files/files.e2e.db.test.ts
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { Test } from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const pngBuffer = (payload = 'fake-but-signed png bytes'): Buffer =>
|
||||||
|
Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
|
||||||
|
|
||||||
|
/** superagent has no default parser for image/*; buffer the raw bytes ourselves. */
|
||||||
|
function binaryParser(
|
||||||
|
res: NodeJS.ReadableStream,
|
||||||
|
callback: (err: Error | null, body: Buffer) => void,
|
||||||
|
): void {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
||||||
|
}
|
||||||
|
type ParseCallback = Parameters<Test['parse']>[0];
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'bilder hochladen ist toll 1';
|
||||||
|
|
||||||
|
const owner = { username: `fiona-files-${suffix}`, displayName: `Fiona Files ${suffix}` };
|
||||||
|
const outsider = { username: `otto-files-${suffix}`, displayName: `Otto Outside ${suffix}` };
|
||||||
|
let ownerCookie: string;
|
||||||
|
let outsiderCookie: string;
|
||||||
|
let pondId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function loginOf(username: string): Promise<string> {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200);
|
||||||
|
return sessionCookieOf(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPondOverride(quotaKey: string, value: number): Promise<void> {
|
||||||
|
await prisma.quotaOverride.upsert({
|
||||||
|
where: {
|
||||||
|
subjectType_subjectId_quotaKey: { subjectType: 'POND', subjectId: pondId, quotaKey },
|
||||||
|
},
|
||||||
|
create: { subjectType: 'POND', subjectId: pondId, quotaKey, value },
|
||||||
|
update: { value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPondOverrides(): Promise<void> {
|
||||||
|
await prisma.quotaOverride.deleteMany({ where: { subjectType: 'POND', subjectId: pondId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
|
||||||
|
const ownerUser = await users.createUser({
|
||||||
|
username: owner.username,
|
||||||
|
email: `${owner.username}@example.org`,
|
||||||
|
displayName: owner.displayName,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
|
||||||
|
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
|
||||||
|
ownerCookie = await loginOf(owner.username);
|
||||||
|
|
||||||
|
const outsiderUser = await users.createUser({
|
||||||
|
username: outsider.username,
|
||||||
|
email: `${outsider.username}@example.org`,
|
||||||
|
displayName: outsider.displayName,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(outsiderUser.id);
|
||||||
|
outsiderCookie = await loginOf(outsider.username);
|
||||||
|
|
||||||
|
const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
||||||
|
pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.page.deleteMany({
|
||||||
|
where: { pond: { owner: { username: { contains: suffix } } } },
|
||||||
|
});
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { username: { contains: suffix } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await prisma.quotaOverride.deleteMany({
|
||||||
|
where: { subjectId: { in: [pondId, ...users.map((u) => u.id)] } },
|
||||||
|
});
|
||||||
|
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips an uploaded image: same bytes, sniffed content type', async () => {
|
||||||
|
const bytes = pngBuffer('roundtrip');
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', bytes, 'photo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(uploaded.body.mimeType).toBe('image/png');
|
||||||
|
expect(uploaded.body.sizeBytes).toBe(bytes.length);
|
||||||
|
|
||||||
|
const served = await api()
|
||||||
|
.get(`/api/v1/media/${uploaded.body.id}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.buffer(true)
|
||||||
|
.parse(binaryParser as unknown as ParseCallback)
|
||||||
|
.expect(200);
|
||||||
|
expect(served.headers['content-type']).toBe('image/png');
|
||||||
|
expect(served.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(Buffer.compare(served.body, bytes)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-image uploads', 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')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('unsupported_file_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a renamed .html-as-.png via the magic-byte check', 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an oversize file with a distinct error from quota_exceeded', async () => {
|
||||||
|
await setPondOverride('max_file_bytes', 10);
|
||||||
|
try {
|
||||||
|
const res = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', pngBuffer('this payload is well beyond ten bytes'), 'big.png')
|
||||||
|
.expect(413);
|
||||||
|
expect(res.body.code).toBe('file_too_large');
|
||||||
|
expect(res.body.details.limitBytes).toBe(10);
|
||||||
|
} finally {
|
||||||
|
await clearPondOverrides();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects uploads that exceed the storage quota', async () => {
|
||||||
|
await setPondOverride('max_file_bytes', 10_000);
|
||||||
|
await setPondOverride('storage_bytes', 5);
|
||||||
|
try {
|
||||||
|
const res = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', pngBuffer('needs more than five bytes of budget'), 'photo.png')
|
||||||
|
.expect(403);
|
||||||
|
expect(res.body.code).toBe('quota_exceeded');
|
||||||
|
expect(res.body.details.quotaKey).toBe('storage_bytes');
|
||||||
|
} finally {
|
||||||
|
await clearPondOverrides();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases quota and removes the on-disk bytes on delete', async () => {
|
||||||
|
const before = await prisma.pondUsage.findUnique({ where: { pondId } });
|
||||||
|
const startUsage = Number(before?.storageBytesUsed ?? 0);
|
||||||
|
|
||||||
|
const bytes = pngBuffer('to be deleted');
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', bytes, 'delete-me.png')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const afterUpload = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
||||||
|
expect(Number(afterUpload.storageBytesUsed)).toBe(startUsage + bytes.length);
|
||||||
|
|
||||||
|
const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id);
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
|
||||||
|
await api().delete(`/api/v1/files/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(204);
|
||||||
|
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
const afterDelete = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
||||||
|
expect(Number(afterDelete.storageBytesUsed)).toBe(startUsage);
|
||||||
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a file reachable after its page is soft-deleted (no eager purge)', async () => {
|
||||||
|
const page = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ title: `With Attachment ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', pngBuffer('page-attached'), 'inline.png')
|
||||||
|
.expect(201);
|
||||||
|
// #27 does not expose pageId on the upload endpoint yet (it is set once
|
||||||
|
// a future story tracks a page's embedded attachments) — associate it
|
||||||
|
// directly to exercise the "stays until purge" guarantee now.
|
||||||
|
await prisma.attachment.update({
|
||||||
|
where: { id: uploaded.body.id },
|
||||||
|
data: { pageId: page.body.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await api().delete(`/api/v1/pages/${page.body.id}`).set('Cookie', ownerCookie).expect(204);
|
||||||
|
|
||||||
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
|
||||||
|
const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } });
|
||||||
|
expect(stillThere).not.toBeNull();
|
||||||
|
expect(stillThere?.deletedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides foreign-pond files from download and delete (404, not 403)', async () => {
|
||||||
|
const uploaded = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', pngBuffer('private'), 'private.png')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', outsiderCookie).expect(404);
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/files/${uploaded.body.id}`)
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.expect(404);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/files`)
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.attach('file', pngBuffer('sneaky'), 'sneaky.png')
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/api/src/files/files.module.ts
Normal file
15
apps/api/src/files/files.module.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
|
|
||||||
|
import { FileStorageService } from './file-storage.service';
|
||||||
|
import { FilesController } from './files.controller';
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PondsModule, QuotasModule],
|
||||||
|
controllers: [FilesController],
|
||||||
|
providers: [FilesService, FileStorageService],
|
||||||
|
})
|
||||||
|
export class FilesModule {}
|
||||||
135
apps/api/src/files/files.service.ts
Normal file
135
apps/api/src/files/files.service.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AttachmentView } from '@dorfteich/shared';
|
||||||
|
import { Attachment, User } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
|
|
||||||
|
import { FileStorageService } from './file-storage.service';
|
||||||
|
import { sniffImageMimeType } from './magic-bytes';
|
||||||
|
|
||||||
|
export interface FileDownload {
|
||||||
|
attachment: Attachment;
|
||||||
|
stream: Readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File storage and image-upload API (issue #27, ADR 0011). */
|
||||||
|
@Injectable()
|
||||||
|
export class FilesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly access: InterimAccessService,
|
||||||
|
private readonly quotas: QuotaService,
|
||||||
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(FilesService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewOf(attachment: Attachment): AttachmentView {
|
||||||
|
return {
|
||||||
|
id: attachment.id,
|
||||||
|
pondId: attachment.pondId,
|
||||||
|
pageId: attachment.pageId,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
sizeBytes: attachment.sizeBytes,
|
||||||
|
createdAt: attachment.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
file: { buffer: Buffer; size: number; originalname: string },
|
||||||
|
): Promise<AttachmentView> {
|
||||||
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
|
this.access.assertCanModify(user, pond);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
if (file.size > maxFileBytes) {
|
||||||
|
throw new PayloadTooLargeException({
|
||||||
|
code: 'file_too_large',
|
||||||
|
details: { limitBytes: maxFileBytes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.storage.save(pond.id, id, file.buffer);
|
||||||
|
const attachment = await this.prisma.attachment.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
pondId: pond.id,
|
||||||
|
fileName: file.originalname,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
storagePath: `${pond.id}/${id}`,
|
||||||
|
uploadedBy: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.info(
|
||||||
|
{ attachmentId: id, pondId: pond.id, 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.storage.delete(pond.id, id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(user: User, id: string): Promise<FileDownload> {
|
||||||
|
const attachment = await this.prisma.attachment.findFirst({
|
||||||
|
where: { id },
|
||||||
|
include: { pond: true },
|
||||||
|
});
|
||||||
|
if (!attachment) throw new NotFoundException();
|
||||||
|
this.access.assertCanSee(user, attachment.pond);
|
||||||
|
return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(user: User, id: string): Promise<void> {
|
||||||
|
const attachment = await this.prisma.attachment.findFirst({
|
||||||
|
where: { id },
|
||||||
|
include: { pond: true },
|
||||||
|
});
|
||||||
|
if (!attachment) throw new NotFoundException();
|
||||||
|
this.access.assertCanModify(user, attachment.pond);
|
||||||
|
|
||||||
|
await this.prisma.attachment.delete({ where: { id: attachment.id } });
|
||||||
|
await this.storage.delete(attachment.pondId, attachment.id);
|
||||||
|
await this.quotas.release(attachment.pondId, attachment.sizeBytes);
|
||||||
|
this.logger.info(
|
||||||
|
{ attachmentId: id, pondId: attachment.pondId, userId: user.id },
|
||||||
|
'audit: file deleted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/api/src/files/magic-bytes.ts
Normal file
42
apps/api/src/files/magic-bytes.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { AttachmentMimeType } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic-byte signatures for the M2 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.
|
||||||
|
*/
|
||||||
|
const SIGNATURES: ReadonlyArray<{
|
||||||
|
mimeType: AttachmentMimeType;
|
||||||
|
matches: (buf: Buffer) => boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
mimeType: 'image/png',
|
||||||
|
matches: (buf) =>
|
||||||
|
buf.length >= 8 &&
|
||||||
|
buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
matches: (buf) => buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mimeType: 'image/gif',
|
||||||
|
matches: (buf) =>
|
||||||
|
buf.length >= 6 &&
|
||||||
|
(buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mimeType: 'image/webp',
|
||||||
|
matches: (buf) =>
|
||||||
|
buf.length >= 12 &&
|
||||||
|
buf.toString('ascii', 0, 4) === 'RIFF' &&
|
||||||
|
buf.toString('ascii', 8, 12) === 'WEBP',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Returns the sniffed 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;
|
||||||
|
}
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
@ -16,6 +20,9 @@ export async function createTestApp(): Promise<INestApplication> {
|
|||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
}
|
}
|
||||||
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
|
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
|
||||||
|
// Fresh scratch directory per test file so upload tests never touch the
|
||||||
|
// repository or collide with each other.
|
||||||
|
process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-'));
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
const app = moduleRef.createNestApplication<NestExpressApplication>();
|
const app = moduleRef.createNestApplication<NestExpressApplication>();
|
||||||
|
|||||||
@ -55,6 +55,8 @@ services:
|
|||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
SMTP_PASS: ${SMTP_PASS:-}
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
||||||
|
# Matches the `uploads` volume mount below (ADR 0011).
|
||||||
|
UPLOADS_DIR: /data/uploads
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:${API_PORT:-8101}:3000'
|
- '127.0.0.1:${API_PORT:-8101}:3000'
|
||||||
networks: [frontend, internal]
|
networks: [frontend, internal]
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
"slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.",
|
"slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.",
|
||||||
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
|
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
|
||||||
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
|
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
|
||||||
|
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
|
||||||
|
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
|
||||||
"network": "Der Server war nicht erreichbar.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich.",
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
"slug_taken": "This slug is already taken in this pond.",
|
"slug_taken": "This slug is already taken in this pond.",
|
||||||
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
|
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
|
||||||
"invalid_page_state": "The submitted page content is invalid.",
|
"invalid_page_state": "The submitted page content is invalid.",
|
||||||
|
"unsupported_file_type": "This file type is not supported.",
|
||||||
|
"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.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "This field is required.",
|
"required": "This field is required.",
|
||||||
|
|||||||
@ -40,6 +40,13 @@ export const apiEnvSchema = z.object({
|
|||||||
SMTP_USER: z.string().optional(),
|
SMTP_USER: z.string().optional(),
|
||||||
SMTP_PASS: z.string().optional(),
|
SMTP_PASS: z.string().optional(),
|
||||||
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
|
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
|
||||||
|
/**
|
||||||
|
* Filesystem root for uploaded files (ADR 0011). The compose stack
|
||||||
|
* mounts the `uploads` volume at `/data/uploads` and sets this
|
||||||
|
* explicitly; the relative default only serves native (non-Docker)
|
||||||
|
* dev/test runs.
|
||||||
|
*/
|
||||||
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
30
packages/shared/src/files.ts
Normal file
30
packages/shared/src/files.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Attachment types shared between api and web (issue #27, ADR 0011). M2
|
||||||
|
* accepts images only; the general type allowlist arrives in M6 (#61).
|
||||||
|
*/
|
||||||
|
export const ATTACHMENT_IMAGE_MIME_TYPES = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard ceiling on the raw multipart body the api will buffer in memory,
|
||||||
|
* independent of the per-pond/user `max_file_bytes` quota (QuotaService)
|
||||||
|
* that governs the actually accepted size — mirrors how
|
||||||
|
* `MAX_PAGE_DOCUMENT_BYTES` relates to the JSON body-parser limit (pages.ts).
|
||||||
|
*/
|
||||||
|
export const MAX_UPLOAD_PARSE_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface AttachmentView {
|
||||||
|
id: string;
|
||||||
|
pondId: string;
|
||||||
|
pageId: string | null;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ export * from './api-error';
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './editor-schema';
|
export * from './editor-schema';
|
||||||
export * from './env';
|
export * from './env';
|
||||||
|
export * from './files';
|
||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './i18n-tools';
|
export * from './i18n-tools';
|
||||||
export * from './pages';
|
export * from './pages';
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -56,6 +56,9 @@ importers:
|
|||||||
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)
|
||||||
|
multer:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
nestjs-pino:
|
nestjs-pino:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2)
|
version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2)
|
||||||
@ -114,6 +117,9 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.2.0
|
||||||
'@types/nodemailer':
|
'@types/nodemailer':
|
||||||
specifier: ^8.0.1
|
specifier: ^8.0.1
|
||||||
version: 8.0.1
|
version: 8.0.1
|
||||||
@ -1627,6 +1633,9 @@ packages:
|
|||||||
'@types/methods@1.1.4':
|
'@types/methods@1.1.4':
|
||||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||||
|
|
||||||
|
'@types/multer@2.2.0':
|
||||||
|
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
|
||||||
|
|
||||||
'@types/node@26.1.0':
|
'@types/node@26.1.0':
|
||||||
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
|
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
|
||||||
|
|
||||||
@ -5129,6 +5138,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/methods@1.1.4': {}
|
'@types/methods@1.1.4': {}
|
||||||
|
|
||||||
|
'@types/multer@2.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
|
||||||
'@types/node@26.1.0':
|
'@types/node@26.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user