From e7e703c105fefd08c08cc86ae1629cc4556e8a8e Mon Sep 17 00:00:00 2001 From: glass beadian Date: Sun, 11 Oct 2026 10:09:24 +0000 Subject: [PATCH] Integrate group files: nullable room for uploads, archive class, claim idempotency, upload route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the three parallel group-cloud tracks to one surface and fixes the seams between them: - chat_media_archive.room_id becomes nullable (direct web uploads have no Matrix room yet — the contract's null-event pattern extends to the room); core GroupFile.roomId and ArchiveEntryInput.roomId follow. - mime_class gains 'archive' (zip) across the 029 CHECK, validation MIME_CLASSES and the bot classifier — one vocabulary everywhere. - registerArchiveEntry claim keys are null-safe for room-less uploads. - New: POST /files/groups/:groupId/upload — FileInterceptor (20 MiB) streams through magic-byte sniffing, library-safe naming, oCIS upload and the metadata index, behind group-membership ACL (Owner/Admin bypass). - safeFileName/sniffMime promoted to packages/core (file-names.ts, file-mime.ts) so the bot and the api share one implementation; the bot's local modules become re-export shims; @types/node added to core. Verified: api typecheck clean, group-files 22/22, bot 74/74, web typecheck clean; 029 re-applied clean on a fresh scratch DB (room_id nullable, archive class, claim_key unique all confirmed in the catalog). --- apps/api/migrations/029_group_files.sql | 9 +- .../src/group-files/group-files.controller.ts | 32 +++++- .../src/group-files/group-files.service.ts | 61 ++++++++++- apps/api/src/group-files/group-files.test.ts | 25 ++++- .../src/group-files/group-files.validation.ts | 5 +- apps/api/src/group-files/ocis-webdav.ts | 9 ++ apps/archive-bot/src/mime.ts | 44 +------- apps/archive-bot/src/names.ts | 45 +------- apps/web/src/components/files/file-row.tsx | 35 +++--- apps/web/src/components/files/files-client.ts | 31 +++--- apps/web/src/components/files/labels.ts | 11 +- .../src/components/files/retention-dialog.tsx | 35 +++--- .../web/src/components/files/share-dialog.tsx | 71 +++++++----- apps/web/src/components/files/types.ts | 86 ++++++++------- .../src/components/files/upload-dropzone.tsx | 6 +- packages/core/package.json | 1 + packages/core/src/file-mime.ts | 44 ++++++++ packages/core/src/file-names.ts | 44 ++++++++ packages/core/src/index.ts | 2 + packages/core/src/shared-files.ts | 3 +- pnpm-lock.yaml | 101 ++++++++++++++++-- 21 files changed, 473 insertions(+), 227 deletions(-) create mode 100644 packages/core/src/file-mime.ts create mode 100644 packages/core/src/file-names.ts diff --git a/apps/api/migrations/029_group_files.sql b/apps/api/migrations/029_group_files.sql index 9727b19..5be4935 100644 --- a/apps/api/migrations/029_group_files.sql +++ b/apps/api/migrations/029_group_files.sql @@ -44,8 +44,9 @@ CREATE TABLE chat_media_archive ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE ON UPDATE RESTRICT, - -- Immutable Matrix room ID, never an alias. Tenant-pinned below. - room_id text NOT NULL CHECK (btrim(room_id) <> '' AND room_id ~ '^!'), + -- Matrix room ID when the file arrived through a room; NULL for direct + -- uploads/exports that have not been announced into one yet. Tenant-pinned. + room_id text CHECK (btrim(room_id) = '' OR room_id ~ '^!'), -- NULL for direct uploads/pad exports until a room announcement supplies one. event_id text CHECK (event_id IS NULL OR btrim(event_id) <> ''), origin text NOT NULL CHECK (origin IN ('matrix','bridge','upload','pad-export')), @@ -58,8 +59,8 @@ CREATE TABLE chat_media_archive ( -- Normalized safe display filename (app-side sanitizeName). safe_name text NOT NULL CHECK (btrim(safe_name) <> '' AND char_length(safe_name) <= 255), mime text NOT NULL CHECK (mime <> ''), - -- Coarse class derived from mime (image/video/audio/document/other). - mime_class text NOT NULL CHECK (mime_class IN ('image','video','audio','document','other')), + -- Coarse class derived from mime (image/video/audio/document/archive/other). + mime_class text NOT NULL CHECK (mime_class IN ('image','video','audio','document','archive','other')), byte_size bigint NOT NULL CHECK (byte_size >= 0), -- SHA-256 of verified payload bytes, lowercase hex. Never an S3 ETag. sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), diff --git a/apps/api/src/group-files/group-files.controller.ts b/apps/api/src/group-files/group-files.controller.ts index 1a60a79..c6c1637 100644 --- a/apps/api/src/group-files/group-files.controller.ts +++ b/apps/api/src/group-files/group-files.controller.ts @@ -1,4 +1,5 @@ -import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UseGuards } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; import type { Request, Response } from "express"; import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js"; @@ -6,11 +7,20 @@ import { GroupFilesService, type FilePage } from "./group-files.service.js"; import type { GroupFile } from "@tactical-media-broadcast-hub/core"; import { verifyWhileStreaming } from "./ocis-webdav.js"; +const MAX_FILE_UPLOAD_BYTES = 20 * 1024 * 1024; + +interface FileUpload { + buffer: Buffer; + mimetype: string; + originalname: string; +} + /** * Group files API — the Telegram-like group file surface. * * GET /files list/search, keyset (groupId, senderMxid, * mimeClass, name, from, to, cursor, limit) + * POST /files/groups/:groupId/upload direct web upload (≤20 MiB, multipart) * GET /files/:id one GroupFile * GET /files/:id/download stream payload (sha256 re-verified) * POST /files/:id/retention set retention (keep/until/legalHold) @@ -39,6 +49,26 @@ export class GroupFilesController { return this.files.listFiles(session.workspaceId, session.userId, session.role, query); } + @Post("groups/:groupId/upload") + @RequirePermission("media.view") + @UseInterceptors(FileInterceptor("file", { + limits: { fileSize: MAX_FILE_UPLOAD_BYTES, files: 1, fields: 3, fieldSize: 1024, parts: 4 }, + })) + async upload( + @Req() request: Request & AuthenticatedRequest, + @Param("groupId", ParseUUIDPipe) groupId: string, + @UploadedFile() file?: FileUpload, + ): Promise { + const session = requireSession(request); + if (file === undefined) throw new BadRequestException("a file is required (field: file)"); + return this.files.uploadForGroup( + session.workspaceId, + groupId, + { userId: session.userId, role: session.role }, + { buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname }, + ); + } + @Get(":id") @RequirePermission("media.view") async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise { diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index 432fa7e..a4bc15c 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -21,8 +21,9 @@ */ import { BadRequestException, ConflictException, ForbiddenException, GoneException, Injectable, NotFoundException, Optional, Inject, ServiceUnavailableException } from "@nestjs/common"; import { createHash } from "node:crypto"; +import { Readable } from "node:stream"; import type { PoolClient } from "pg"; -import { attachmentArchiveKey, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core"; +import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core"; import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js"; import { withSystem, withTenant } from "../db/client.js"; import { isUuid, parseLimit } from "../http/validation.js"; @@ -441,7 +442,10 @@ export class GroupFilesService { workspaceId: string, entry: ArchiveEntryInput, ): Promise<{ id: string; claimKey: string | null }> { - const claimKey = entry.eventId === null ? null : attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId); + const claimKey = + entry.eventId === null || entry.roomId === null + ? null + : attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId); return withTenant(workspaceId, async (client) => { let inserted: { rows: Array<{ id: string }> }; try { @@ -513,6 +517,56 @@ export class GroupFilesService { event.diff ?? null, ]); } + + /** + * Direct web upload: ACL (active member of the group, or Owner/Admin), + * stream the buffer to oCIS (magic-byte sniff decides mime/class, the + * stored name is library-safe), index the entry, return the wire shape. + * Attribution uses a synthetic MXID — the Hub session carries no chat + * identity (docs/occupymedia-storage.md, direct-upload origin). + */ + async uploadForGroup( + workspaceId: string, + groupId: string, + actor: { userId: string; role: string }, + file: { buffer: Buffer; mimetype: string; originalName: string }, + ): Promise { + if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) { + const membership = await withTenant(workspaceId, (client) => + client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 AND status = 'active' LIMIT 1", [groupId, actor.userId]), + ); + if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group"); + } + const sniff = sniffMime(file.buffer.subarray(0, SNIFF_BYTES)); + const safe = safeFileName(file.originalName, sniff.extension); + const uploaded = await this.webdav().upload(groupId, safe, Readable.from(file.buffer), sniff.mime); + const username = await this.usernameFor(workspaceId, actor.userId); + const { id } = await this.registerArchiveEntry(workspaceId, { + groupId, + roomId: null, + eventId: null, + origin: { kind: "upload" }, + originKind: "upload", + senderMxid: `@${username}:tmbh.local`, + originalName: file.originalName, + safeName: safe, + mime: sniff.mime, + mimeClass: sniff.mimeClass, + byteSize: uploaded.sizeBytes, + sha256: uploaded.sha256, + ocisResourceId: uploaded.resourceId, + ocisResourceVersion: uploaded.version, + createdAt: new Date(), + }); + return this.getFile(workspaceId, actor.userId, actor.role, id); + } + + private async usernameFor(workspaceId: string, userId: string): Promise { + const row = await withTenant(workspaceId, (client) => + client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]), + ); + return row.rows[0]?.username ?? "unknown"; + } } export interface ActorRef { @@ -522,7 +576,8 @@ export interface ActorRef { export interface ArchiveEntryInput { groupId: string; - roomId: string; + /** Matrix room when mirrored from a room; null for direct web uploads. */ + roomId: string | null; eventId: string | null; origin: Record; originKind: string; diff --git a/apps/api/src/group-files/group-files.test.ts b/apps/api/src/group-files/group-files.test.ts index 92b68ce..31ca246 100644 --- a/apps/api/src/group-files/group-files.test.ts +++ b/apps/api/src/group-files/group-files.test.ts @@ -200,11 +200,14 @@ describe("ocis-webdav config + streaming verification", () => { it("uploads hash the actual streamed bytes and surface oc-fileid/oc-etag", async () => { const bytes = Buffer.from("group file payload"); - const fetcher = (async () => - new Response(null, { + const calls: string[] = []; + const fetcher = (async (url: string, init: RequestInit) => { + calls.push(`${init.method} ${url}`); + return new Response(null, { status: 201, - headers: { "oc-fileid": "res-1", "oc-etag": "v-9" }, - })) as unknown as typeof fetch; + headers: init.method === "MKCOL" ? new Headers() : { "oc-fileid": "res-1", "oc-etag": "v-9" }, + }); + }) as unknown as typeof fetch; const client = new OcisWebdavClient( { webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" }, fetcher, @@ -215,10 +218,24 @@ describe("ocis-webdav config + streaming verification", () => { expect(result.sizeBytes).toBe(bytes.byteLength); expect(result.resourceId).toBe("res-1"); expect(result.version).toBe("v-9"); + expect(calls[0]).toBe("MKCOL https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1"); + expect(calls[1]).toBe("PUT https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1/clip.mp4"); expect(client.uploadUrl("g", "n.mp4")).toContain("/remote.php/dav/files/svc/hub-groups/g/n.mp4"); expect(client.libraryUrl("res-1")).toBe("https://files.example.org/f/res-1"); }); + it("fails the upload when the group folder cannot be created", async () => { + const fetcher = (async (url: string, init: RequestInit) => + new Response(null, { status: init.method === "MKCOL" ? 500 : 201 })) as unknown as typeof fetch; + const client = new OcisWebdavClient( + { webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" }, + fetcher, + ); + await expect( + client.upload("group-1", "clip.mp4", Readable.from(["x"]), "text/plain"), + ).rejects.toThrow("oCIS folder creation failed with 500"); + }); + it("verifyWhileStreaming passes on match and aborts on tamper", async () => { const { createHash } = await import("node:crypto"); const payload = Buffer.from("0123456789"); diff --git a/apps/api/src/group-files/group-files.validation.ts b/apps/api/src/group-files/group-files.validation.ts index 6f4ba4c..f2e2ba1 100644 --- a/apps/api/src/group-files/group-files.validation.ts +++ b/apps/api/src/group-files/group-files.validation.ts @@ -13,11 +13,13 @@ * `any`, and DB CHECK constraints (029) are coarse backstops only. */ import { BadRequestException } from "@nestjs/common"; +import { MIME_CLASSES } from "@tactical-media-broadcast-hub/core"; import type { FileRetention, GroupFile, GroupFileMapping, MatrixAttachmentCandidate, + MimeClass, SharedFileOrigin, } from "@tactical-media-broadcast-hub/core"; @@ -29,8 +31,7 @@ export type RetentionMode = (typeof RETENTION_MODES)[number]; export const ORIGIN_KINDS = ["matrix", "bridge", "upload", "pad-export"] as const; export type OriginKind = (typeof ORIGIN_KINDS)[number]; -export const MIME_CLASSES = ["image", "video", "audio", "document", "other"] as const; -export type MimeClass = (typeof MIME_CLASSES)[number]; +export { MIME_CLASSES, type MimeClass }; export const FILE_STATUSES = ["available", "revoked", "expired"] as const; export type FileStatus = (typeof FILE_STATUSES)[number]; diff --git a/apps/api/src/group-files/ocis-webdav.ts b/apps/api/src/group-files/ocis-webdav.ts index 5b98c11..46f452d 100644 --- a/apps/api/src/group-files/ocis-webdav.ts +++ b/apps/api/src/group-files/ocis-webdav.ts @@ -102,6 +102,14 @@ export class OcisWebdavClient { return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-resources/${encodeURIComponent(resourceId)}`; } + /** MKCOL the group folder; 405 (already exists) is fine. */ + private async ensureGroupFolder(groupId: string): Promise { + const url = `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-groups/${encodeURIComponent(groupId)}`; + const response = await this.fetcher(url, { method: "MKCOL", headers: this.authHeaders() }); + if (response.ok || response.status === 405) return; + throw new Error(`oCIS folder creation failed with ${response.status}`); + } + private authHeaders(): Record { const basic = Buffer.from(`${this.config.user}:${this.config.password}`, "utf8").toString("base64"); return { authorization: `Basic ${basic}` }; @@ -119,6 +127,7 @@ export class OcisWebdavClient { * reads its request body is broken and will surface as an integrity error). */ async upload(groupId: string, safeName: string, body: Readable, contentType: string): Promise { + await this.ensureGroupFolder(groupId); const hash = createHash("sha256"); let sizeBytes = 0; let queuedBytes = 0; diff --git a/apps/archive-bot/src/mime.ts b/apps/archive-bot/src/mime.ts index 493ea78..f5f4667 100644 --- a/apps/archive-bot/src/mime.ts +++ b/apps/archive-bot/src/mime.ts @@ -1,43 +1 @@ -export type MimeClass = "image" | "video" | "audio" | "document" | "archive" | "other"; - -export interface SniffedMime { - mime: string; - mimeClass: MimeClass; - extension: string | null; -} - -/** Bytes needed to recognise every supported signature. */ -export const SNIFF_BYTES = 16; - -const UNKNOWN: SniffedMime = { mime: "application/octet-stream", mimeClass: "other", extension: null }; - -function startsWith(head: Uint8Array, signature: readonly number[], offset = 0): boolean { - if (head.length < offset + signature.length) return false; - return signature.every((byte, i) => head[offset + i] === byte); -} - -function ascii(text: string): number[] { - return [...text].map((c) => c.charCodeAt(0)); -} - -/** - * MIME is decided by magic bytes only; the sender's declared `info.mimetype` - * is never trusted. Unrecognised payloads are stored as octet-stream/other. - */ -export function sniffMime(head: Uint8Array): SniffedMime { - if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { - return { mime: "image/png", mimeClass: "image", extension: "png" }; - } - if (startsWith(head, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", mimeClass: "image", extension: "jpg" }; - if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a"))) { - return { mime: "image/gif", mimeClass: "image", extension: "gif" }; - } - if (startsWith(head, ascii("%PDF-"))) return { mime: "application/pdf", mimeClass: "document", extension: "pdf" }; - if (startsWith(head, [0x50, 0x4b, 0x03, 0x04])) { - return { mime: "application/zip", mimeClass: "archive", extension: "zip" }; - } - if (startsWith(head, ascii("ftyp"), 4)) return { mime: "video/mp4", mimeClass: "video", extension: "mp4" }; - if (startsWith(head, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "video/webm", mimeClass: "video", extension: "webm" }; - if (startsWith(head, ascii("OggS"))) return { mime: "audio/ogg", mimeClass: "audio", extension: "ogg" }; - return UNKNOWN; -} +export { SNIFF_BYTES, sniffMime, type MimeClass, type SniffedMime } from "@tactical-media-broadcast-hub/core"; diff --git a/apps/archive-bot/src/names.ts b/apps/archive-bot/src/names.ts index 3cc5a98..9de7522 100644 --- a/apps/archive-bot/src/names.ts +++ b/apps/archive-bot/src/names.ts @@ -1,44 +1 @@ -export const MAX_SAFE_NAME_BYTES = 120; -const FALLBACK = "attachment"; -const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; - -function truncateUtf8(text: string, maxBytes: number): string { - let out = ""; - for (const ch of text) { - if (Buffer.byteLength(out + ch, "utf8") > maxBytes) break; - out += ch; - } - return out; -} - -/** - * Library-safe single path segment: no directories, control or reserved - * characters, leading dots, or unbounded length. Extension is preserved when - * truncating; a sniffed extension is appended when the name has none. - */ -export function safeFileName(raw: string, sniffedExtension: string | null = null): string { - const basename = raw.normalize("NFKC").split(/[/\\]/).pop() ?? ""; - let name = basename - .replace(/[\p{Cc}\p{Cf}]/gu, "") - .replace(/[<>:"|?*#%{}^~[\]`]/g, "_") - .replace(/\s+/g, " ") - .replace(/_+/g, "_") - .trim() - .replace(/^[.\s_-]+/, "") - .replace(/[.\s]+$/, ""); - if (name === "") name = FALLBACK; - if (WINDOWS_RESERVED.test(name)) name = `_${name}`; - - const dot = name.lastIndexOf("."); - let stem = dot > 0 ? name.slice(0, dot) : name; - let ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; - if (ext === "" && sniffedExtension !== null) ext = sniffedExtension; - if (Buffer.byteLength(ext, "utf8") > 16) { - stem = name; - ext = sniffedExtension ?? ""; - } - const suffix = ext === "" ? "" : `.${ext}`; - stem = truncateUtf8(stem, MAX_SAFE_NAME_BYTES - Buffer.byteLength(suffix, "utf8")).trim(); - if (stem === "") stem = FALLBACK; - return `${stem}${suffix}`; -} +export { MAX_SAFE_NAME_BYTES, safeFileName } from "@tactical-media-broadcast-hub/core"; diff --git a/apps/web/src/components/files/file-row.tsx b/apps/web/src/components/files/file-row.tsx index 2ca6f2d..6049de8 100644 --- a/apps/web/src/components/files/file-row.tsx +++ b/apps/web/src/components/files/file-row.tsx @@ -3,13 +3,13 @@ import { Badge, Button, Card } from "../ui/primitives"; import { formatBytes } from "../video/format"; import { MimeIcon } from "./mime-icon"; -import { mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels"; +import { LEGAL_HOLD_LABEL, mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels"; import { useFilesLang } from "./i18n"; -import type { GroupFileItem } from "./types"; +import type { GroupFile } from "./types"; /** One file row: identity + metadata on the left, actions on the right. */ export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDownload, onShare, onRevoke, onRetention }: { - item: GroupFileItem; + item: GroupFile; live: boolean; canRetention: boolean; busy: boolean; @@ -20,28 +20,35 @@ export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDown onRetention: () => void; }): React.ReactElement { const { t } = useFilesLang(); - const disabled = busy || !live || !item.available; + const disabled = busy || !live || item.status !== "available"; const retentionLabel = - item.retentionMode === "until" && item.retentionUntil !== null - ? `${t(RETENTION_LABEL.until)} ${new Date(item.retentionUntil).toLocaleDateString()}` - : t(RETENTION_LABEL[item.retentionMode]); + item.retention.mode === "until" && item.retention.expiresAt !== null + ? `${t(RETENTION_LABEL.until)} ${new Date(item.retention.expiresAt).toLocaleDateString()}` + : t(RETENTION_LABEL[item.retention.mode]); return (
- {item.safeName} - {t(ORIGIN_LABEL[item.origin])} - {retentionLabel} - {!item.available && {t({ en: "Unavailable", es: "No disponible" })}} + {item.name} + {t(ORIGIN_LABEL[item.origin.kind])} + {retentionLabel} + {item.retention.legalHold && {t(LEGAL_HOLD_LABEL)}} + {item.status !== "available" && ( + + {item.status === "revoked" + ? t({ en: "Revoked", es: "Revocado" }) + : t({ en: "Expired", es: "Caducado" })} + + )}
- {formatBytes(item.byteSize)} + {formatBytes(item.sizeBytes)} - {t(mimeClassLabel(item.mimeClass))} + {t(mimeClassLabel(item.mimeClass))} - {item.senderMxid} + {item.senderId}
diff --git a/apps/web/src/components/files/files-client.ts b/apps/web/src/components/files/files-client.ts index bf66f57..7a99f1c 100644 --- a/apps/web/src/components/files/files-client.ts +++ b/apps/web/src/components/files/files-client.ts @@ -1,18 +1,22 @@ import { API_URL, ApiError } from "../../lib/api-client"; import { getSessionToken } from "../../lib/session-token"; +function authHeaders(): Record { + const token = getSessionToken(); + return token === null ? {} : { Authorization: `Bearer ${token}` }; +} + /** - * Binary download for GET /files/items/:itemId/download. This cannot use - * `apiFetch` (it parses JSON) or a bare anchor (the endpoint needs the - * Authorization header, which anchors cannot send), so it replays the client's - * auth rules: same base URL, Bearer token, cookie credentials — then streams - * the body into a Blob and clicks a temporary anchor. + * Binary download for GET /files/:id/download. This cannot use `apiFetch` + * (it parses JSON) or a bare anchor (the endpoint needs the Authorization + * header, which anchors cannot send), so it replays the client's auth rules: + * same base URL, Bearer token, cookie credentials — then streams the body + * into a Blob and clicks a temporary anchor. */ export async function downloadGroupFile(itemId: string, fileName: string): Promise { - const token = getSessionToken(); - const response = await fetch(`${API_URL}/files/items/${encodeURIComponent(itemId)}/download`, { + const response = await fetch(`${API_URL}/files/${encodeURIComponent(itemId)}/download`, { credentials: "include", - headers: token === null ? {} : { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!response.ok) { if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null); @@ -29,12 +33,7 @@ export async function downloadGroupFile(itemId: string, fileName: string): Promi URL.revokeObjectURL(url); } -/** - * Derive the share id from a public tokenUrl of the form - * `/files/shares/:token/download`, used for DELETE /files/shares/:shareId when - * the POST response omits an explicit shareId. - */ -export function shareIdFromUrl(tokenUrl: string): string | null { - const match = /\/files\/shares\/([^/?#]+)/.exec(tokenUrl); - return match?.[1] !== undefined && match[1] !== "" ? match[1] : null; +/** Public share-claim URL for a minted capability token (GET /public/files/s/:token). */ +export function publicClaimUrl(token: string): string { + return `${API_URL}/public/files/s/${encodeURIComponent(token)}`; } diff --git a/apps/web/src/components/files/labels.ts b/apps/web/src/components/files/labels.ts index ef98ba4..70fa275 100644 --- a/apps/web/src/components/files/labels.ts +++ b/apps/web/src/components/files/labels.ts @@ -1,5 +1,5 @@ import type { LocalizedText } from "./i18n"; -import type { FileOrigin, MimeClass, RetentionMode } from "./types"; +import type { FileOrigin, FileRetention, MimeClass } from "./types"; /** Bilingual display labels for the group-files enums (values are fixed by the API). */ @@ -22,21 +22,22 @@ export const MIME_CLASS_LABEL: Record = { video: { en: "Video", es: "Vídeo" }, audio: { en: "Audio", es: "Audio" }, document: { en: "Document", es: "Documento" }, + archive: { en: "Archive", es: "Archivo comprimido" }, other: { en: "File", es: "Archivo" }, }; -export const RETENTION_LABEL: Record = { +export const RETENTION_LABEL: Record = { keep: { en: "Keep", es: "Conservar" }, until: { en: "Until", es: "Hasta" }, - legalHold: { en: "Legal hold", es: "Retención legal" }, }; -export const RETENTION_TONE: Record = { +export const RETENTION_TONE: Record = { keep: "neutral", until: "accent", - legalHold: "danger", }; +export const LEGAL_HOLD_LABEL: LocalizedText = { en: "Legal hold", es: "Retención legal" }; + /** Human label for a MIME class, tolerating an unknown value from the server. */ export function mimeClassLabel(mimeClass: MimeClass): LocalizedText { return MIME_CLASS_LABEL[mimeClass] ?? MIME_CLASS_LABEL.other; diff --git a/apps/web/src/components/files/retention-dialog.tsx b/apps/web/src/components/files/retention-dialog.tsx index 22c97e2..633b465 100644 --- a/apps/web/src/components/files/retention-dialog.tsx +++ b/apps/web/src/components/files/retention-dialog.tsx @@ -3,28 +3,30 @@ import { useState } from "react"; import { Button, Input } from "../ui/primitives"; import { api, errorMessage } from "../../lib/api-client"; -import { RETENTION_LABEL } from "./labels"; +import { LEGAL_HOLD_LABEL, RETENTION_LABEL } from "./labels"; import { useFilesLang } from "./i18n"; -import type { GroupFileItem, RetentionInput, RetentionMode } from "./types"; +import type { GroupFile, RetentionInput } from "./types"; /** - * Admin-only retention change: POST /files/items/:itemId/retention - * {mode, until?}. Retention is separate from share expiry — legalHold blocks - * deletion, `until` requires a deadline, `keep` is the no-purge default. + * Admin-only retention change: POST /files/:id/retention + * {mode: keep|until, legalHold?, until?}. Retention is separate from share + * expiry — legal hold blocks deletion outright (orthogonal flag), `until` + * requires a deadline, `keep` is the no-purge default. */ export function RetentionDialog({ item, onClose, onSaved }: { - item: GroupFileItem; + item: GroupFile; onClose: () => void; onSaved: (itemId: string, input: RetentionInput) => void; }): React.ReactElement { const { t } = useFilesLang(); - const [mode, setMode] = useState(item.retentionMode); - const [until, setUntil] = useState(item.retentionUntil?.slice(0, 16) ?? ""); + const [mode, setMode] = useState<"keep" | "until">(item.retention.mode); + const [legalHold, setLegalHold] = useState(item.retention.legalHold); + const [until, setUntil] = useState(item.retention.expiresAt?.slice(0, 16) ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const modes: RetentionMode[] = ["keep", "until", "legalHold"]; + const modes: Array<"keep" | "until"> = ["keep", "until"]; async function save(): Promise { let iso: string | undefined; @@ -43,8 +45,8 @@ export function RetentionDialog({ item, onClose, onSaved }: { setBusy(true); setError(null); try { - const body: RetentionInput = mode === "until" ? { mode, until: iso } : { mode }; - await api.post(`/files/items/${item.id}/retention`, body); + const body: RetentionInput = { mode, legalHold, ...(mode === "until" && iso !== undefined ? { until: iso } : {}) }; + await api.post(`/files/${item.id}/retention`, body); onSaved(item.id, body); onClose(); } catch (err) { @@ -64,7 +66,7 @@ export function RetentionDialog({ item, onClose, onSaved }: { onClick={(e: React.MouseEvent) => e.stopPropagation()} >

{t({ en: "Set retention", es: "Definir retención" })}

-

{item.safeName}

+

{item.name}

{t({ en: "Retention is independent of share expiry. Legal hold blocks deletion until an admin releases it.", @@ -92,6 +94,15 @@ export function RetentionDialog({ item, onClose, onSaved }: { setUntil(e.target.value)} className="mt-1" /> )} + {error !== null &&

{error}

}
diff --git a/apps/web/src/components/files/share-dialog.tsx b/apps/web/src/components/files/share-dialog.tsx index 2788228..59c5eff 100644 --- a/apps/web/src/components/files/share-dialog.tsx +++ b/apps/web/src/components/files/share-dialog.tsx @@ -3,36 +3,41 @@ import { useMemo, useState } from "react"; import { Button, Input } from "../ui/primitives"; import { api, errorMessage } from "../../lib/api-client"; -import { shareIdFromUrl } from "./files-client"; +import { publicClaimUrl } from "./files-client"; import { useFilesLang } from "./i18n"; -import type { GroupFileItem, ShareResponse } from "./types"; +import type { GroupFile, ShareCreated } from "./types"; /** - * Mint a public share link for one file: POST /files/items/:itemId/share - * {expiresAt}. The token is returned once — after this dialog closes it can - * never be recovered, so the result view says so in plain language and offers - * a copy button before the user leaves. + * Mint a public share link for one file: POST /files/:id/shares + * {access: "public", expiresInHours} → ShareCreated. The capability token is + * returned once — after this dialog closes it can never be recovered, so the + * result view says so in plain language and offers a copy button before the + * user leaves. The shareable URL is the public claim endpoint; the library + * link in the response stays members-only. */ type ExpiryKey = "1h" | "24h" | "7d" | "custom"; -const EXPIRY_MS: Record, number> = { - "1h": 60 * 60 * 1000, - "24h": 24 * 60 * 60 * 1000, - "7d": 7 * 24 * 60 * 60 * 1000, +const EXPIRY_HOURS: Record, number> = { + "1h": 1, + "24h": 24, + "7d": 168, }; +/** The api caps share TTL at 720 hours (30 days). */ +const MAX_SHARE_HOURS = 720; + export function ShareDialog({ item, onClose, onCreated }: { - item: GroupFileItem; + item: GroupFile; onClose: () => void; - onCreated: (itemId: string, share: { shareId: string | null; tokenUrl: string }) => void; + onCreated: (itemId: string, share: ShareCreated) => void; }): React.ReactElement { const { t } = useFilesLang(); const [expiry, setExpiry] = useState("24h"); const [customAt, setCustomAt] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [tokenUrl, setTokenUrl] = useState(null); + const [share, setShare] = useState(null); const [copied, setCopied] = useState(false); const options: Array<{ key: ExpiryKey; label: string }> = [ @@ -47,27 +52,30 @@ export function ShareDialog({ item, onClose, onCreated }: { return now.toISOString().slice(0, 16); }, []); - function expiresAtIso(): string | null { + function expiresInHours(): number | null { if (expiry === "custom") { if (customAt === "") return null; const parsed = new Date(customAt); - return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null; + if (!Number.isFinite(parsed.getTime())) return null; + const hours = Math.ceil((parsed.getTime() - Date.now()) / 3_600_000); + if (hours < 1) return null; + return Math.min(hours, MAX_SHARE_HOURS); } - return new Date(Date.now() + EXPIRY_MS[expiry]).toISOString(); + return EXPIRY_HOURS[expiry]; } async function create(): Promise { - const expiresAt = expiresAtIso(); - if (expiresAt === null) { - setError(t({ en: "Pick a future expiry date.", es: "Elige una fecha de caducidad futura." })); + const hours = expiresInHours(); + if (hours === null) { + setError(t({ en: "Pick a future expiry date (up to 30 days).", es: "Elige una fecha de caducidad futura (hasta 30 días)." })); return; } setBusy(true); setError(null); try { - const response = await api.post(`/files/items/${item.id}/share`, { expiresAt }); - setTokenUrl(response.tokenUrl); - onCreated(item.id, { shareId: response.shareId ?? shareIdFromUrl(response.tokenUrl), tokenUrl: response.tokenUrl }); + const created = await api.post(`/files/${item.id}/shares`, { access: "public", expiresInHours: hours }); + setShare(created); + onCreated(item.id, created); } catch (err) { setError(errorMessage(err)); } finally { @@ -76,9 +84,9 @@ export function ShareDialog({ item, onClose, onCreated }: { } async function copy(): Promise { - if (tokenUrl === null) return; + if (share === null) return; try { - await navigator.clipboard.writeText(tokenUrl); + await navigator.clipboard.writeText(publicClaimUrl(share.token)); setCopied(true); } catch { setError(t({ en: "Could not copy — select the link and copy it manually.", es: "No se pudo copiar — selecciona el enlace y cópialo a mano." })); @@ -95,9 +103,9 @@ export function ShareDialog({ item, onClose, onCreated }: { onClick={(e: React.MouseEvent) => e.stopPropagation()} >

{t({ en: "Share file", es: "Compartir archivo" })}

-

{item.safeName}

+

{item.name}

- {tokenUrl === null ? ( + {share === null ? ( <>

{t({ @@ -142,7 +150,16 @@ export function ShareDialog({ item, onClose, onCreated }: { es: "Copia este enlace ahora — se muestra solo una vez y no se puede recuperar tras cerrar este cuadro.", })}

- e.target.select()} className="mt-2 font-mono text-2xs" /> + e.target.select()} + className="mt-2 font-mono text-2xs" + /> +

+ {t({ en: "Expires", es: "Caduca" })}: {new Date(share.expiresAt).toLocaleString()} +

{error !== null &&

{error}

}
diff --git a/apps/web/src/components/files/types.ts b/apps/web/src/components/files/types.ts index 3a72de3..17f7a92 100644 --- a/apps/web/src/components/files/types.ts +++ b/apps/web/src/components/files/types.ts @@ -1,64 +1,68 @@ /** * Wire shapes for the group-files HTTP surface (apps/api/src/group-files). * Kept local to the Files feature so it does not collide with the shared - * api-types.ts other builders may be editing in parallel. Hand-synced with the - * controller: GET /files/groups/:groupId/items returns the flat item below. + * api-types.ts other builders may be editing in parallel. Hand-synced with + * the controller + the core GroupFile contract. */ export type FileOrigin = "matrix" | "bridge" | "upload" | "pad-export"; -/** - * Coarse MIME class. NOTE: the API's DB CHECK (migration 029) and - * group-files.validation.ts MIME_CLASSES both use - * "image" | "video" | "audio" | "document" | "other" — the task brief said - * image/video/audio/file/doc; this follows the backend's real enum. - */ -export type MimeClass = "image" | "video" | "audio" | "document" | "other"; +export type MimeClass = "image" | "video" | "audio" | "document" | "archive" | "other"; -export type RetentionMode = "keep" | "until" | "legalHold"; +export type FileStatus = "available" | "revoked" | "expired"; -/** One row of the group file library — the served item shape. */ -export interface GroupFileItem { +/** Retention is separate from share expiry: legalHold blocks deletion, `until` sets a deadline. */ +export interface FileRetention { + mode: "keep" | "until"; + legalHold: boolean; + expiresAt: string | null; +} + +/** One row of the group file library — the served item shape (core GroupFile). */ +export interface GroupFile { id: string; + workspaceId: string; groupId: string; - origin: FileOrigin; - senderMxid: string; - originalName: string; - safeName: string; - mime: string; - mimeClass: MimeClass; - byteSize: number; - sha256: string; + roomId: string | null; + senderId: string; + eventId: string | null; + origin: { kind: FileOrigin }; + name: string; + mimeType: string; + sizeBytes: number; + checksum: { value: string }; createdAt: string; - available: boolean; - retentionMode: RetentionMode; - retentionUntil: string | null; + indexedAt: string; + file: { resourceId: string }; + shareLink: { url: string; access: "members" | "public"; expiresAt: string | null }; + retention: FileRetention; + status: FileStatus; } export interface GroupFilePage { - items: GroupFileItem[]; + items: GroupFile[]; nextCursor: string | null; } +/** POST /files/:id/shares response. `token` is the public capability — shown once. */ +export interface ShareCreated { + id: string; + itemId: string; + access: "members" | "public"; + url: string; + expiresAt: string; + token: string; +} + +/** POST /files/:id/retention body. */ +export interface RetentionInput { + mode: "keep" | "until"; + legalHold?: boolean; + until?: string; +} + /** GET /groups/mine (same shape the More page already consumes). */ export interface GroupSummary { id: string; name: string; } - -/** - * POST /files/items/:itemId/share response. The task surface documents only - * `tokenUrl`; the API may also return a `shareId` used by - * DELETE /files/shares/:shareId. When absent the client derives the id from - * the tokenUrl's path segment so revoke still works. - */ -export interface ShareResponse { - tokenUrl: string; - shareId?: string; -} - -/** POST /files/items/:itemId/retention body. */ -export interface RetentionInput { - mode: RetentionMode; - until?: string; -} diff --git a/apps/web/src/components/files/upload-dropzone.tsx b/apps/web/src/components/files/upload-dropzone.tsx index 2c5697e..b3eb79c 100644 --- a/apps/web/src/components/files/upload-dropzone.tsx +++ b/apps/web/src/components/files/upload-dropzone.tsx @@ -6,7 +6,7 @@ import { Loading } from "../brand/loading"; import { apiUploadFile, errorMessage } from "../../lib/api-client"; import { formatBytes } from "../video/format"; import { useFilesLang } from "./i18n"; -import type { GroupFileItem } from "./types"; +import type { GroupFile } from "./types"; /** * Drag-drop + picker for POST /files/groups/:groupId/upload. The API accepts a @@ -19,7 +19,7 @@ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; export function UploadDropzone({ groupId, live, onUploaded }: { groupId: string; live: boolean; - onUploaded: (item: GroupFileItem) => void; + onUploaded: (item: GroupFile) => void; }): React.ReactElement { const { t } = useFilesLang(); const inputRef = useRef(null); @@ -52,7 +52,7 @@ export function UploadDropzone({ groupId, live, onUploaded }: { setProgress(0); setUploading(`${file.name} (${formatBytes(file.size)})`); try { - const created = await apiUploadFile( + const created = await apiUploadFile( `/files/groups/${encodeURIComponent(groupId)}/upload`, file, setProgress, diff --git a/packages/core/package.json b/packages/core/package.json index 014f96f..078b70a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { + "@types/node": "^26.6.5", "typescript": "^5.5.4", "vitest": "^2.0.5" } diff --git a/packages/core/src/file-mime.ts b/packages/core/src/file-mime.ts new file mode 100644 index 0000000..e65d0d9 --- /dev/null +++ b/packages/core/src/file-mime.ts @@ -0,0 +1,44 @@ +export const MIME_CLASSES = ["image", "video", "audio", "document", "archive", "other"] as const; +export type MimeClass = (typeof MIME_CLASSES)[number]; + +export interface SniffedMime { + mime: string; + mimeClass: MimeClass; + extension: string | null; +} + +/** Bytes needed to recognise every supported signature. */ +export const SNIFF_BYTES = 16; + +const UNKNOWN: SniffedMime = { mime: "application/octet-stream", mimeClass: "other", extension: null }; + +function startsWith(head: Uint8Array, signature: readonly number[], offset = 0): boolean { + if (head.length < offset + signature.length) return false; + return signature.every((byte, i) => head[offset + i] === byte); +} + +function ascii(text: string): number[] { + return [...text].map((c) => c.charCodeAt(0)); +} + +/** + * MIME is decided by magic bytes only; the sender's declared `info.mimetype` + * is never trusted. Unrecognised payloads are stored as octet-stream/other. + */ +export function sniffMime(head: Uint8Array): SniffedMime { + if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { mime: "image/png", mimeClass: "image", extension: "png" }; + } + if (startsWith(head, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", mimeClass: "image", extension: "jpg" }; + if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a"))) { + return { mime: "image/gif", mimeClass: "image", extension: "gif" }; + } + if (startsWith(head, ascii("%PDF-"))) return { mime: "application/pdf", mimeClass: "document", extension: "pdf" }; + if (startsWith(head, [0x50, 0x4b, 0x03, 0x04])) { + return { mime: "application/zip", mimeClass: "archive", extension: "zip" }; + } + if (startsWith(head, ascii("ftyp"), 4)) return { mime: "video/mp4", mimeClass: "video", extension: "mp4" }; + if (startsWith(head, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "video/webm", mimeClass: "video", extension: "webm" }; + if (startsWith(head, ascii("OggS"))) return { mime: "audio/ogg", mimeClass: "audio", extension: "ogg" }; + return UNKNOWN; +} diff --git a/packages/core/src/file-names.ts b/packages/core/src/file-names.ts new file mode 100644 index 0000000..3cc5a98 --- /dev/null +++ b/packages/core/src/file-names.ts @@ -0,0 +1,44 @@ +export const MAX_SAFE_NAME_BYTES = 120; +const FALLBACK = "attachment"; +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; + +function truncateUtf8(text: string, maxBytes: number): string { + let out = ""; + for (const ch of text) { + if (Buffer.byteLength(out + ch, "utf8") > maxBytes) break; + out += ch; + } + return out; +} + +/** + * Library-safe single path segment: no directories, control or reserved + * characters, leading dots, or unbounded length. Extension is preserved when + * truncating; a sniffed extension is appended when the name has none. + */ +export function safeFileName(raw: string, sniffedExtension: string | null = null): string { + const basename = raw.normalize("NFKC").split(/[/\\]/).pop() ?? ""; + let name = basename + .replace(/[\p{Cc}\p{Cf}]/gu, "") + .replace(/[<>:"|?*#%{}^~[\]`]/g, "_") + .replace(/\s+/g, " ") + .replace(/_+/g, "_") + .trim() + .replace(/^[.\s_-]+/, "") + .replace(/[.\s]+$/, ""); + if (name === "") name = FALLBACK; + if (WINDOWS_RESERVED.test(name)) name = `_${name}`; + + const dot = name.lastIndexOf("."); + let stem = dot > 0 ? name.slice(0, dot) : name; + let ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; + if (ext === "" && sniffedExtension !== null) ext = sniffedExtension; + if (Buffer.byteLength(ext, "utf8") > 16) { + stem = name; + ext = sniffedExtension ?? ""; + } + const suffix = ext === "" ? "" : `.${ext}`; + stem = truncateUtf8(stem, MAX_SAFE_NAME_BYTES - Buffer.byteLength(suffix, "utf8")).trim(); + if (stem === "") stem = FALLBACK; + return `${stem}${suffix}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3bcbfbb..fb3975a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,8 @@ export * from "./capabilities.js"; export * from "./chat-contract.js"; export * from "./chat-contract.schema.js"; export * from "./connection.js"; +export * from "./file-mime.js"; +export * from "./file-names.js"; export * from "./messaging.js"; export * from "./publishing.js"; export * from "./shared-files.js"; diff --git a/packages/core/src/shared-files.ts b/packages/core/src/shared-files.ts index 1875918..5fe32f9 100644 --- a/packages/core/src/shared-files.ts +++ b/packages/core/src/shared-files.ts @@ -26,7 +26,8 @@ export interface GroupFile { id: string; workspaceId: string; groupId: string; - roomId: string; + /** Matrix room when the file arrived through one; null for direct uploads. */ + roomId: string | null; senderId: string; /** Null for a direct upload/export until a link is posted in the room. */ eventId: string | null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2ab09a..1705466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,7 +315,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/connector-facebook: dependencies: @@ -331,7 +331,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/connector-instagram: dependencies: @@ -404,7 +404,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/connector-mock-social: dependencies: @@ -420,7 +420,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/connector-mock-video: dependencies: @@ -436,7 +436,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/connector-peertube: dependencies: @@ -518,7 +518,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/connector-slack: dependencies: @@ -598,12 +598,15 @@ importers: packages/core: devDependencies: + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 typescript: specifier: ^5.5.4 version: 5.9.3 vitest: specifier: ^2.0.5 - version: 2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) packages/egress: dependencies: @@ -2686,6 +2689,9 @@ packages: '@types/node@22.20.5': resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + '@types/pg-pool@2.0.7': resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} @@ -4416,6 +4422,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unionfs@4.6.0: resolution: {integrity: sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==} @@ -6696,6 +6705,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + '@types/pg-pool@2.0.7': dependencies: '@types/pg': 8.23.1 @@ -6850,6 +6863,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -8508,6 +8529,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@8.9.0: {} + unionfs@4.6.0: dependencies: fs-monkey: 1.1.0 @@ -8569,6 +8592,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2): dependencies: esbuild: 0.21.5 @@ -8580,6 +8621,17 @@ snapshots: lightningcss: 1.32.0 terser: 5.51.2 + vite@5.4.21(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.29 + rollup: 4.64.0 + optionalDependencies: + '@types/node': 26.6.5 + fsevents: 2.3.3 + lightningcss: 1.32.0 + terser: 5.51.2 + vitest@2.1.9(@types/node@22.20.5)(lightningcss@1.32.0)(terser@5.51.2): dependencies: '@vitest/expect': 2.1.9 @@ -8615,6 +8667,41 @@ snapshots: - supports-color - terser + vitest@2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) + vite-node: 2.1.9(@types/node@26.6.5)(lightningcss@1.32.0)(terser@5.51.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.6.5 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + w3c-keyname@2.2.8: {} watchpack@2.5.2: