@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
117 lines (116 loc) • 5.94 kB
JavaScript
import { mkdir, readdir, lstat, realpath, readFile, stat, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { bridgeRoot, BridgeError, MAX_FRAME } from "./protocol.js";
export function imageBytes(bytes) {
return bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|| bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255]))
|| /^GIF8[79]a/.test(bytes.subarray(0, 6).toString())
|| (bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP");
}
export const IMAGE_NAME = /\.(png|jpe?g|gif|webp)$/i;
let uploadQueue = Promise.resolve();
/** Stores a file the phone sent under uploads/<session>/. A name with an
* image extension must hold image bytes; anything else is kept as-is. */
export async function saveUpload(session, name, bytes) {
if (!bytes.length || bytes.length > MAX_FRAME)
throw new BridgeError(400, "The file is empty or too large.");
if (IMAGE_NAME.test(name) && !imageBytes(bytes))
throw new BridgeError(400, "Choose a PNG, JPEG, GIF, or WebP image.");
let saved = "";
const task = uploadQueue.catch(() => { }).then(async () => {
const root = path.join(bridgeRoot(), "uploads");
await mkdir(root, { recursive: true, mode: 0o700 });
let used = 0;
const directories = await readdir(root, { withFileTypes: true });
for (const dir of directories) {
if (!dir.isDirectory())
continue;
const folder = path.join(root, dir.name);
for (const entry of await readdir(folder, { withFileTypes: true })) {
if (!entry.isFile())
continue;
const file = path.join(folder, entry.name), metadata = await lstat(file);
if (Date.now() - metadata.mtimeMs > 14 * 86400_000)
await unlink(file);
else
used += metadata.size;
}
}
if (used + bytes.length > 268_435_456)
throw new BridgeError(413, "Phren's image storage is full. Remove older uploads on the computer.");
const folder = path.join(root, session);
await mkdir(folder, { recursive: true, mode: 0o700 });
saved = path.join(folder, `${randomUUID()}-${name}`);
await writeFile(saved, bytes, { flag: "wx", mode: 0o600 });
});
uploadQueue = task;
await task;
return saved;
}
/** What the phone has put in one folder, newest first. */
export async function listUploads(session) {
const folder = path.join(bridgeRoot(), "uploads", session);
const entries = await readdir(folder, { withFileTypes: true }).catch(() => []);
const files = [];
for (const entry of entries) {
if (!entry.isFile())
continue;
const file = path.join(folder, entry.name), metadata = await stat(file);
files.push({ name: entry.name.replace(/^[0-9a-f-]{36}-/, ""), path: file, size: metadata.size, modified: metadata.mtime.toISOString() });
}
return files.sort((a, b) => b.modified.localeCompare(a.modified)).slice(0, 200);
}
/** Removes one file the phone put in uploads/<session>/, by the path
* `listUploads` gave. Anything that does not resolve to a regular file
* directly inside that folder is 404, so no other file can be named. */
export async function removeUpload(session, requested) {
const missing = new BridgeError(404, "This file is not one of the phone's uploads.");
if (!requested || requested.length > 4096 || !path.isAbsolute(requested))
throw missing;
const folder = await realpath(path.join(bridgeRoot(), "uploads", session)).catch(() => undefined);
const file = await realpath(requested).catch(() => undefined);
if (!folder || !file || path.dirname(file) !== folder)
throw missing;
if (!(await lstat(file)).isFile())
throw missing;
await unlink(file);
}
/** The bytes of one image the phone put under uploads/, by the absolute
* path the transcript names. Served only when the path resolves (through
* any link) to a regular file inside the uploads folder whose bytes are
* an image; anything else — outside, traversal, a note — is 404. */
export async function uploadImage(requested) {
const missing = new BridgeError(404, "This image is not one of the phone's uploads.");
if (!requested || requested.length > 4096 || !path.isAbsolute(requested))
throw missing;
const root = await realpath(path.join(bridgeRoot(), "uploads")).catch(() => undefined);
const file = await realpath(requested).catch(() => undefined);
if (!root || !file || !file.startsWith(root + path.sep))
throw missing;
const metadata = await stat(file);
if (!metadata.isFile())
throw missing;
if (metadata.size > 8_388_608)
throw new BridgeError(413, "This image is too large.");
const bytes = await readFile(file);
if (bytes.length > 8_388_608)
throw new BridgeError(413, "This image is too large.");
if (!imageBytes(bytes))
throw missing;
return bytes;
}
/** A file the phone uploaded for this conversation, by the path `/v1/upload`
* returned: it must resolve to a regular file inside uploads/<session>/. */
export async function sessionUpload(session, requested) {
const refused = new BridgeError(400, "An attachment is not one of this conversation's uploads. Attach it again.");
if (!requested || requested.length > 4096 || !path.isAbsolute(requested))
throw refused;
const folder = await realpath(path.join(bridgeRoot(), "uploads", session)).catch(() => undefined);
const file = await realpath(requested).catch(() => undefined);
if (!folder || !file || !file.startsWith(folder + path.sep))
throw refused;
if (!(await stat(file)).isFile())
throw refused;
return file;
}