Integrate group files: nullable room for uploads, archive class, claim idempotency, upload route
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).
This commit is contained in:
parent
688a2ce996
commit
e7e703c105
21 changed files with 473 additions and 227 deletions
|
|
@ -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}$'),
|
||||
|
|
|
|||
|
|
@ -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<GroupFile> {
|
||||
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<GroupFile> {
|
||||
|
|
|
|||
|
|
@ -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<GroupFile> {
|
||||
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<string> {
|
||||
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<string, unknown>;
|
||||
originKind: string;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string, string> {
|
||||
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<OcisUploadResult> {
|
||||
await this.ensureGroupFolder(groupId);
|
||||
const hash = createHash("sha256");
|
||||
let sizeBytes = 0;
|
||||
let queuedBytes = 0;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card className="flex flex-wrap items-center gap-3 p-3">
|
||||
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium" title={item.originalName}>{item.safeName}</span>
|
||||
<Badge tone={ORIGIN_TONE[item.origin]}>{t(ORIGIN_LABEL[item.origin])}</Badge>
|
||||
<Badge tone={RETENTION_TONE[item.retentionMode]}>{retentionLabel}</Badge>
|
||||
{!item.available && <Badge tone="danger">{t({ en: "Unavailable", es: "No disponible" })}</Badge>}
|
||||
<span className="truncate text-sm font-medium" title={item.name}>{item.name}</span>
|
||||
<Badge tone={ORIGIN_TONE[item.origin.kind]}>{t(ORIGIN_LABEL[item.origin.kind])}</Badge>
|
||||
<Badge tone={RETENTION_TONE[item.retention.mode]}>{retentionLabel}</Badge>
|
||||
{item.retention.legalHold && <Badge tone="danger">{t(LEGAL_HOLD_LABEL)}</Badge>}
|
||||
{item.status !== "available" && (
|
||||
<Badge tone="danger">
|
||||
{item.status === "revoked"
|
||||
? t({ en: "Revoked", es: "Revocado" })
|
||||
: t({ en: "Expired", es: "Caducado" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-text-faint">
|
||||
<span>{formatBytes(item.byteSize)}</span>
|
||||
<span>{formatBytes(item.sizeBytes)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mime}`}>{t(mimeClassLabel(item.mimeClass))}</span>
|
||||
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mimeType}`}>{t(mimeClassLabel(item.mimeClass))}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="max-w-[16rem] truncate" title={item.senderMxid}>{item.senderMxid}</span>
|
||||
<span className="max-w-[16rem] truncate" title={item.senderId}>{item.senderId}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { API_URL, ApiError } from "../../lib/api-client";
|
||||
import { getSessionToken } from "../../lib/session-token";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
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<void> {
|
||||
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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MimeClass, LocalizedText> = {
|
|||
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<RetentionMode, LocalizedText> = {
|
||||
export const RETENTION_LABEL: Record<FileRetention["mode"], LocalizedText> = {
|
||||
keep: { en: "Keep", es: "Conservar" },
|
||||
until: { en: "Until", es: "Hasta" },
|
||||
legalHold: { en: "Legal hold", es: "Retención legal" },
|
||||
};
|
||||
|
||||
export const RETENTION_TONE: Record<RetentionMode, "neutral" | "accent" | "live" | "warn" | "danger"> = {
|
||||
export const RETENTION_TONE: Record<FileRetention["mode"], "neutral" | "accent" | "danger"> = {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<RetentionMode>(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<string | null>(null);
|
||||
|
||||
const modes: RetentionMode[] = ["keep", "until", "legalHold"];
|
||||
const modes: Array<"keep" | "until"> = ["keep", "until"];
|
||||
|
||||
async function save(): Promise<void> {
|
||||
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()}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">{t({ en: "Set retention", es: "Definir retención" })}</h2>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.name}>{item.name}</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-text-dim">
|
||||
{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 }: {
|
|||
<Input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} className="mt-1" />
|
||||
</label>
|
||||
)}
|
||||
<label className="mt-3 flex min-h-9 items-center gap-2 text-xs text-text-dim">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={legalHold}
|
||||
onChange={(event) => setLegalHold(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-border accent-accent"
|
||||
/>
|
||||
{t({ ...LEGAL_HOLD_LABEL, en: `${LEGAL_HOLD_LABEL.en} (blocks deletion)`, es: `${LEGAL_HOLD_LABEL.es} (bloquea el borrado)` })}
|
||||
</label>
|
||||
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
|
||||
|
|
|
|||
|
|
@ -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<Exclude<ExpiryKey, "custom">, number> = {
|
||||
"1h": 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
const EXPIRY_HOURS: Record<Exclude<ExpiryKey, "custom">, 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<ExpiryKey>("24h");
|
||||
const [customAt, setCustomAt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tokenUrl, setTokenUrl] = useState<string | null>(null);
|
||||
const [share, setShare] = useState<ShareCreated | null>(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<void> {
|
||||
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<ShareResponse>(`/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<ShareCreated>(`/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<void> {
|
||||
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()}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">{t({ en: "Share file", es: "Compartir archivo" })}</h2>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.name}>{item.name}</p>
|
||||
|
||||
{tokenUrl === null ? (
|
||||
{share === null ? (
|
||||
<>
|
||||
<p className="mt-3 text-xs leading-relaxed text-text-dim">
|
||||
{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.",
|
||||
})}
|
||||
</p>
|
||||
<Input readOnly value={tokenUrl} aria-label={t({ en: "Share link", es: "Enlace para compartir" })} onFocus={(e) => e.target.select()} className="mt-2 font-mono text-2xs" />
|
||||
<Input
|
||||
readOnly
|
||||
value={publicClaimUrl(share.token)}
|
||||
aria-label={t({ en: "Share link", es: "Enlace para compartir" })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="mt-2 font-mono text-2xs"
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
{t({ en: "Expires", es: "Caduca" })}: {new Date(share.expiresAt).toLocaleString()}
|
||||
</p>
|
||||
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>{t({ en: "Close", es: "Cerrar" })}</Button>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
|
|
@ -52,7 +52,7 @@ export function UploadDropzone({ groupId, live, onUploaded }: {
|
|||
setProgress(0);
|
||||
setUploading(`${file.name} (${formatBytes(file.size)})`);
|
||||
try {
|
||||
const created = await apiUploadFile<GroupFileItem>(
|
||||
const created = await apiUploadFile<GroupFile>(
|
||||
`/files/groups/${encodeURIComponent(groupId)}/upload`,
|
||||
file,
|
||||
setProgress,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
|
|
|
|||
44
packages/core/src/file-mime.ts
Normal file
44
packages/core/src/file-mime.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
44
packages/core/src/file-names.ts
Normal file
44
packages/core/src/file-names.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
101
pnpm-lock.yaml
generated
101
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue