feat: v0.4.0 — File sharing + multi-target messages
Some checks failed
Some checks failed
Files: MinIO-backed file sharing built into the broker. share_file for persistent mesh files, send_message(file:) for ephemeral attachments. Presigned URLs for download, access tracking per peer. Broker infra: MinIO in docker-compose, internal network. HTTP POST /upload endpoint. WS handlers for get_file, list_files, file_status, delete_file. Multi-target: send_message(to:) accepts string or array. Targets deduplicated before delivery. Targeted views: MCP instructions teach Claude to send tailored messages per audience instead of generic broadcasts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,20 +21,25 @@ import { db } from "./db";
|
||||
import { messageQueue } from "@turbostarter/db/schema/mesh";
|
||||
import {
|
||||
connectPresence,
|
||||
deleteFile,
|
||||
disconnectPresence,
|
||||
drainForMember,
|
||||
findMemberByPubkey,
|
||||
forgetMemory,
|
||||
getFile,
|
||||
getFileStatus,
|
||||
getState,
|
||||
handleHookSetStatus,
|
||||
heartbeat,
|
||||
joinGroup,
|
||||
joinMesh,
|
||||
leaveGroup,
|
||||
listFiles,
|
||||
listPeersInMesh,
|
||||
listState,
|
||||
queueMessage,
|
||||
recallMemory,
|
||||
recordFileAccess,
|
||||
refreshQueueDepth,
|
||||
refreshStatusFromJsonl,
|
||||
rememberMemory,
|
||||
@@ -42,8 +47,10 @@ import {
|
||||
setState,
|
||||
startSweepers,
|
||||
stopSweepers,
|
||||
uploadFile,
|
||||
writeStatus,
|
||||
} from "./broker";
|
||||
import { ensureBucket, meshBucketName, minioClient } from "./minio";
|
||||
import type {
|
||||
HookSetStatusRequest,
|
||||
WSClientMessage,
|
||||
@@ -140,7 +147,7 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
const started = Date.now();
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Mesh-Id, X-Member-Id, X-File-Name, X-Tags, X-Persistent, X-Target-Spec");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
@@ -177,6 +184,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/upload") {
|
||||
handleUploadPost(req, res, started);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
log.debug("http", { route, status: 404, latency_ms: Date.now() - started });
|
||||
@@ -327,6 +339,119 @@ function handleJoinPost(
|
||||
});
|
||||
}
|
||||
|
||||
function handleUploadPost(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
started: number,
|
||||
): void {
|
||||
const meshId = req.headers["x-mesh-id"] as string | undefined;
|
||||
const memberId = req.headers["x-member-id"] as string | undefined;
|
||||
const fileName = req.headers["x-file-name"] as string | undefined;
|
||||
const tagsRaw = req.headers["x-tags"] as string | undefined;
|
||||
const persistentRaw = req.headers["x-persistent"] as string | undefined;
|
||||
const targetSpec = req.headers["x-target-spec"] as string | undefined;
|
||||
|
||||
if (!meshId || !memberId || !fileName) {
|
||||
writeJson(res, 400, {
|
||||
ok: false,
|
||||
error: "X-Mesh-Id, X-Member-Id, and X-File-Name headers required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const persistent = persistentRaw !== "false";
|
||||
let tags: string[] = [];
|
||||
if (tagsRaw) {
|
||||
try {
|
||||
tags = JSON.parse(tagsRaw);
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let aborted = false;
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (aborted) return;
|
||||
total += chunk.length;
|
||||
if (total > MAX_UPLOAD_SIZE) {
|
||||
aborted = true;
|
||||
writeJson(res, 413, { ok: false, error: "file too large (max 50MB)" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
if (aborted) return;
|
||||
try {
|
||||
const body = Buffer.concat(chunks);
|
||||
if (body.length === 0) {
|
||||
writeJson(res, 400, { ok: false, error: "empty body" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a file ID for the MinIO key
|
||||
const { generateId } = await import("@turbostarter/shared/utils");
|
||||
const fileId = generateId();
|
||||
const dateStr = new Date().toISOString().split("T")[0];
|
||||
const keyPrefix = persistent
|
||||
? `shared/${fileId}`
|
||||
: `ephemeral/${dateStr}/${fileId}`;
|
||||
const minioKey = `${keyPrefix}/${fileName}`;
|
||||
const bucket = meshBucketName(meshId);
|
||||
|
||||
// Ensure bucket exists + upload
|
||||
await ensureBucket(bucket);
|
||||
await minioClient.putObject(
|
||||
bucket,
|
||||
minioKey,
|
||||
body,
|
||||
body.length,
|
||||
req.headers["content-type"]
|
||||
? { "Content-Type": req.headers["content-type"] }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Insert DB row
|
||||
const dbFileId = await uploadFile({
|
||||
meshId,
|
||||
name: fileName,
|
||||
sizeBytes: body.length,
|
||||
mimeType: (req.headers["content-type"] as string) || undefined,
|
||||
minioKey,
|
||||
tags,
|
||||
persistent,
|
||||
uploadedByMember: memberId,
|
||||
targetSpec: targetSpec || undefined,
|
||||
});
|
||||
|
||||
writeJson(res, 200, { ok: true, fileId: dbFileId });
|
||||
log.info("upload", {
|
||||
route: "POST /upload",
|
||||
mesh_id: meshId,
|
||||
file_id: dbFileId,
|
||||
name: fileName,
|
||||
size: body.length,
|
||||
persistent,
|
||||
latency_ms: Date.now() - started,
|
||||
});
|
||||
} catch (e) {
|
||||
writeJson(res, 500, {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
log.error("upload handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpgrade(
|
||||
wss: WebSocketServer,
|
||||
req: IncomingMessage,
|
||||
@@ -775,6 +900,106 @@ function handleConnection(ws: WebSocket): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "get_file": {
|
||||
const gf = msg as Extract<WSClientMessage, { type: "get_file" }>;
|
||||
const file = await getFile(conn.meshId, gf.fileId);
|
||||
if (!file) {
|
||||
sendError(conn.ws, "not_found", "file not found");
|
||||
break;
|
||||
}
|
||||
// Access control: if targetSpec is set, verify peer matches
|
||||
if (file.targetSpec) {
|
||||
const matches =
|
||||
file.targetSpec === conn.memberPubkey ||
|
||||
file.targetSpec === conn.sessionPubkey ||
|
||||
file.targetSpec === "*";
|
||||
if (!matches) {
|
||||
sendError(conn.ws, "forbidden", "file not targeted at you");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Generate presigned URL (60s expiry)
|
||||
const bucket = meshBucketName(conn.meshId);
|
||||
const presignedUrl = await minioClient.presignedGetObject(
|
||||
bucket,
|
||||
file.minioKey,
|
||||
60,
|
||||
);
|
||||
// Record access
|
||||
const memberInfo = conn.memberPubkey
|
||||
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
|
||||
: null;
|
||||
await recordFileAccess(
|
||||
gf.fileId,
|
||||
conn.sessionPubkey ?? undefined,
|
||||
memberInfo?.displayName,
|
||||
);
|
||||
sendToPeer(presenceId, {
|
||||
type: "file_url",
|
||||
fileId: gf.fileId,
|
||||
url: presignedUrl,
|
||||
name: file.name,
|
||||
});
|
||||
log.info("ws get_file", {
|
||||
presence_id: presenceId,
|
||||
file_id: gf.fileId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "list_files": {
|
||||
const lf = msg as Extract<WSClientMessage, { type: "list_files" }>;
|
||||
const files = await listFiles(conn.meshId, lf.query, lf.from);
|
||||
sendToPeer(presenceId, {
|
||||
type: "file_list",
|
||||
files: files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: f.sizeBytes,
|
||||
tags: f.tags,
|
||||
uploadedBy: f.uploadedBy,
|
||||
uploadedAt: f.uploadedAt.toISOString(),
|
||||
persistent: f.persistent,
|
||||
})),
|
||||
});
|
||||
log.info("ws list_files", {
|
||||
presence_id: presenceId,
|
||||
mesh_id: conn.meshId,
|
||||
count: files.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "file_status": {
|
||||
const fs = msg as Extract<WSClientMessage, { type: "file_status" }>;
|
||||
const accesses = await getFileStatus(fs.fileId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "file_status_result",
|
||||
fileId: fs.fileId,
|
||||
accesses: accesses.map((a) => ({
|
||||
peerName: a.peerName,
|
||||
accessedAt: a.accessedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
log.info("ws file_status", {
|
||||
presence_id: presenceId,
|
||||
file_id: fs.fileId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete_file": {
|
||||
const df = msg as Extract<WSClientMessage, { type: "delete_file" }>;
|
||||
await deleteFile(conn.meshId, df.fileId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "ack" as const,
|
||||
id: df.fileId,
|
||||
messageId: df.fileId,
|
||||
queued: false,
|
||||
});
|
||||
log.info("ws delete_file", {
|
||||
presence_id: presenceId,
|
||||
file_id: df.fileId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "message_status": {
|
||||
const ms = msg as Extract<WSClientMessage, { type: "message_status" }>;
|
||||
// Look up the message in the queue.
|
||||
|
||||
Reference in New Issue
Block a user