Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m25s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
Instrument every full-content read channel for pages with classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read rendering, public JSON content, no-JS shell, expanded embeds, public API GET (incl. the MCP read_page path and write echoes), attachment download under the #212 effective classification, all export shapes (markdown, pond ZIP, account data export, queued docx/odt/pdf at enqueue), and collab-token issuance as the api-side proxy for the WS join. Events land in the new read_events table (no FKs — evidence survives page purges and hard user deletions) with actor, session key (session:/token:/job:/anon), page, pond, channel and the classification at read time. Recording failures are NOT swallowed: a failed write aborts the read (hard failure, the deliberate contrast to AuditService — decision recorded in ADR 0023 and security.md §Logging, together with the recorded residuals: content fragments and feeds). One e2e test per channel proves both the event and its absence for unclassified pages, plus the hard-failure semantics. Refs #222. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { Readable } from 'node:stream';
|
|
|
|
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
NotFoundException,
|
|
PayloadTooLargeException,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ATTACHMENT_EXTENSION_MIME_TYPES,
|
|
AttachmentListItemView,
|
|
AttachmentView,
|
|
PondFilesView,
|
|
PageClassification,
|
|
SVG_MIME_TYPE,
|
|
classificationFilenamePrefix,
|
|
fileExtension,
|
|
isImageMimeType,
|
|
} from '@dorfteich/shared';
|
|
import { Attachment, User } from '@prisma/client';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { QuotaService } from '../quotas/quota.service';
|
|
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
|
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
|
|
import { FileStorageService } from './file-storage.service';
|
|
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;
|
|
/** The filename for the Content-Disposition (issue #212, ADR 0022): the
|
|
* original name, prefixed `VS-NfD_` when the attachment's effective
|
|
* classification is vs_nfd — the one marker an arbitrary binary can
|
|
* carry. The file's CONTENT stays unmarked (documented residual risk). */
|
|
downloadName: string;
|
|
}
|
|
|
|
/** 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 audit: AuditService,
|
|
private readonly readTrail: ReadTrailService,
|
|
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(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
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 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, sizeBytes);
|
|
|
|
try {
|
|
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: resolved.mimeType,
|
|
sizeBytes,
|
|
storagePath: `${pond.id}/${id}`,
|
|
uploadedBy: user.id,
|
|
// Integrity hash (issue #199): computed from the exact in-memory
|
|
// bytes that were just written — never by re-reading the disk.
|
|
sha256: createHash('sha256').update(resolved.buffer).digest('hex'),
|
|
},
|
|
});
|
|
this.logger.info(
|
|
{ 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, sizeBytes);
|
|
await this.storage.delete(pond.id, id);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Point a set of just-uploaded attachments at the page that now embeds them
|
|
* (issue #63 import). The import worker stores a document's media before the
|
|
* page exists (the page's Yjs state references their ids), then calls this so
|
|
* they list under the page and are purged with it (#31), the same invariant
|
|
* the collab persistence hook maintains for pasted images.
|
|
*/
|
|
async linkAttachmentsToPage(attachmentIds: string[], pageId: string): Promise<void> {
|
|
if (attachmentIds.length === 0) return;
|
|
await this.prisma.attachment.updateMany({
|
|
where: { id: { in: attachmentIds } },
|
|
data: { pageId },
|
|
});
|
|
}
|
|
|
|
/** 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();
|
|
// Attaching to a classified page (issue #213, ADR 0022): the file will
|
|
// inherit a classification its content cannot carry (#212). The UI warns;
|
|
// the instance can harden the warning into a server-side block — enforced
|
|
// HERE, not only client-side.
|
|
if (page.classification === 'VS_NFD') {
|
|
const policy = await this.settings.get('classification.uploadPolicy');
|
|
if (policy === 'block') {
|
|
throw new ForbiddenException({ code: 'classified_upload_blocked' });
|
|
}
|
|
}
|
|
return this.upload(user, page.pondId, file, page.id);
|
|
}
|
|
|
|
/**
|
|
* Serve an attachment, verifying its integrity first (issue #199): the
|
|
* whole object is read and hashed BEFORE the first byte leaves — a stream
|
|
* cannot be un-sent, so verification must precede serving. Memory is
|
|
* bounded by the `max_file_bytes` quota that gated the upload. A mismatch
|
|
* fails closed with its own error code and lands in the audit trail (a
|
|
* security event, not content activity); the operator's move is a restore
|
|
* from backup (runbook). Rows that predate #199 (sha256 still null until
|
|
* the nightly backfill reaches them) are served unverified — that is the
|
|
* pre-#199 status quo, not a downgrade.
|
|
*/
|
|
async download(_user: User | null, id: string, read: ReadActor): Promise<FileDownload> {
|
|
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
|
|
if (!attachment) throw new NotFoundException();
|
|
const buffer = await this.storage.read(attachment.pondId, attachment.id).catch(() => null);
|
|
if (!buffer) throw new NotFoundException();
|
|
if (attachment.sha256) {
|
|
const actual = createHash('sha256').update(buffer).digest('hex');
|
|
if (actual !== attachment.sha256) {
|
|
await this.audit.record({
|
|
action: 'file.integrity_failed',
|
|
targetType: 'attachment',
|
|
targetId: attachment.id,
|
|
details: { pondId: attachment.pondId, expected: attachment.sha256, actual },
|
|
});
|
|
throw new InternalServerErrorException({ code: 'attachment_integrity_failure' });
|
|
}
|
|
}
|
|
const classification = await this.effectiveClassification(attachment);
|
|
// Read trail (issue #222): a download whose effective classification is
|
|
// vs_nfd (#212 semantics — page level, pond max when page-less) is a read
|
|
// of classified content. `pageId` may be null for pond-level files; the
|
|
// attachment id in `details` keeps the object identifiable.
|
|
if (classification === 'vs_nfd') {
|
|
await this.readTrail.record({
|
|
...read,
|
|
pageId: attachment.pageId,
|
|
pondId: attachment.pondId,
|
|
channel: 'attachment',
|
|
details: { attachmentId: attachment.id },
|
|
});
|
|
}
|
|
return {
|
|
attachment,
|
|
stream: Readable.from(buffer),
|
|
inline: isImageMimeType(attachment.mimeType),
|
|
downloadName: `${classificationFilenamePrefix(classification)}${attachment.fileName}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The classification an attachment inherits (issue #212, ADR 0022): its
|
|
* page's level. An attachment whose `pageId` is still unset
|
|
* (paste-then-insert, pond-level files) FAILS CLOSED to the highest level
|
|
* of any live page in its pond — it could belong to any of them, so it is
|
|
* treated as classified as the most classified candidate. In an all-open
|
|
* pond that is `unclassified`, so nothing gets marked noise.
|
|
*/
|
|
private async effectiveClassification(attachment: Attachment): Promise<PageClassification> {
|
|
if (attachment.pageId) {
|
|
const page = await this.prisma.page.findUnique({
|
|
where: { id: attachment.pageId },
|
|
select: { classification: true },
|
|
});
|
|
if (page) return page.classification.toLowerCase() as PageClassification;
|
|
// Page row gone but link set (race with purge): fall through to the
|
|
// pond-wide fail-closed answer below.
|
|
}
|
|
const classified = await this.prisma.page.findFirst({
|
|
where: { pondId: attachment.pondId, deletedAt: null, classification: 'VS_NFD' },
|
|
select: { id: true },
|
|
});
|
|
return classified ? 'vs_nfd' : 'unclassified';
|
|
}
|
|
|
|
/**
|
|
* Hash attachments that predate #199 (sha256 null), a bounded batch per
|
|
* nightly run until none remain — idempotent by construction (hashed rows
|
|
* stop matching). An unreadable file is reported (log + count) and left
|
|
* null so the next run retries it; the orphan sweep is the mechanism that
|
|
* eventually explains truly missing bytes.
|
|
*/
|
|
async backfillHashes(limit = 1000): Promise<{ hashed: number; unreadable: number }> {
|
|
const rows = await this.prisma.attachment.findMany({
|
|
where: { sha256: null },
|
|
select: { id: true, pondId: true },
|
|
take: limit,
|
|
});
|
|
let hashed = 0;
|
|
let unreadable = 0;
|
|
for (const row of rows) {
|
|
let buffer: Buffer;
|
|
try {
|
|
buffer = await this.storage.read(row.pondId, row.id);
|
|
} catch (error) {
|
|
unreadable += 1;
|
|
this.logger.error(
|
|
{ attachmentId: row.id, pondId: row.pondId, err: error },
|
|
'attachment unreadable during hash backfill; will retry next run',
|
|
);
|
|
continue;
|
|
}
|
|
await this.prisma.attachment.update({
|
|
where: { id: row.id },
|
|
data: { sha256: createHash('sha256').update(buffer).digest('hex') },
|
|
});
|
|
hashed += 1;
|
|
}
|
|
if (rows.length > 0) {
|
|
this.logger.info(
|
|
{ hashed, unreadable, batch: rows.length, batchLimit: limit },
|
|
'audit: attachment hash backfill progress',
|
|
);
|
|
}
|
|
return { hashed, unreadable };
|
|
}
|
|
|
|
/** 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 },
|
|
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 },
|
|
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> {
|
|
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
|
|
if (!attachment) throw new NotFoundException();
|
|
|
|
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',
|
|
);
|
|
}
|
|
}
|