diff --git a/bun.lock b/bun.lock index 9743307..bad725b 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "html-to-image": "^1.11.13", "lucide-react": "^1.17.0", "mqtt": "^5.15.1", "next": "16.2.9", @@ -602,6 +603,8 @@ "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], diff --git a/package.json b/package.json index 5f5a025..2fba302 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "html-to-image": "^1.11.13", "lucide-react": "^1.17.0", "mqtt": "^5.15.1", "next": "16.2.9", diff --git a/src/lib/ftps.ts b/src/lib/ftps.ts index 430a4a4..53a79c4 100644 --- a/src/lib/ftps.ts +++ b/src/lib/ftps.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { extname } from 'node:path'; +import { Readable } from 'node:stream'; import { Client } from 'basic-ftp'; export interface FtpConfig { @@ -47,26 +48,53 @@ export function loadFtpConfig(): FtpConfig { } /** - * Uploads a local file to the FTP server under a random `` name and - * returns the public URL of the uploaded file. + * Source for an upload: a local file path, an in-memory buffer, or a readable + * stream. `basic-ftp` itself only accepts `Readable | string`, so buffers are + * wrapped into a stream before transfer. + */ +export type UploadSource = + | { kind: 'file'; path: string } + | { kind: 'buffer'; data: Buffer; ext: string } + | { kind: 'stream'; data: Readable; ext: string }; + +/** File extension (with leading dot, lowercased) the remote name should use. */ +function extensionOf(source: UploadSource): string { + return source.kind === 'file' + ? extname(source.path).toLowerCase() + : source.ext.toLowerCase(); +} + +/** Coerce a source into the `Readable | string` that `uploadFrom` accepts. */ +function toFtpSource(source: UploadSource): Readable | string { + switch (source.kind) { + case 'file': + return createReadStream(source.path); + case 'buffer': + return Readable.from(source.data); + case 'stream': + return source.data; + } +} + +/** + * Uploads data to the FTP server under a random `` name and returns + * the public URL of the uploaded file. * * A random UUID name avoids collisions and, since the server rejects * overwriting existing files, guarantees every upload is a fresh create. * - * NOTE: must be run on the Node.js runtime (e.g. `tsx`), not Bun — Bun does not - * support the TLS session resumption this FTPS server requires on the data - * connection. + * NOTE: must be run on the Node.js runtime, not Bun — Bun does not support the + * TLS session resumption this FTPS server requires on the data connection. * - * @param localPath Path to the local file to upload (its extension is kept). - * @param config Optional FTP config; defaults to `loadFtpConfig()`. + * @param source Where the bytes come from (file path, buffer, or stream). + * @param config Optional FTP config; defaults to `loadFtpConfig()`. * @returns The public URL of the uploaded file. */ -export async function uploadFile( - localPath: string, +export async function upload( + source: UploadSource, config: FtpConfig = loadFtpConfig(), ): Promise { - const ext = extname(localPath).toLowerCase(); - const remoteName = `${randomUUID()}${ext}`; + const remoteName = `${randomUUID()}${extensionOf(source)}`; const remoteRoot = config.remoteDir.replace(/\/$/, ''); const remotePath = `${remoteRoot}/${remoteName}`; @@ -81,10 +109,41 @@ export async function uploadFile( secureOptions: { rejectUnauthorized: false }, }); - await client.uploadFrom(createReadStream(localPath), remotePath); + await client.uploadFrom(toFtpSource(source), remotePath); return `${config.publicBaseUrl.replace(/\/$/, '')}/${remoteName}`; } finally { client.close(); } } + +/** + * Uploads a local file, keeping its extension. Thin wrapper over `upload`. + * + * @param localPath Path to the local file to upload. + * @param config Optional FTP config; defaults to `loadFtpConfig()`. + * @returns The public URL of the uploaded file. + */ +export function uploadFile( + localPath: string, + config?: FtpConfig, +): Promise { + return upload({ kind: 'file', path: localPath }, config); +} + +/** + * Uploads an in-memory buffer (e.g. a captured screenshot) under the given + * extension. Thin wrapper over `upload`. + * + * @param data The bytes to upload. + * @param ext File extension including the leading dot, e.g. `.png`. + * @param config Optional FTP config; defaults to `loadFtpConfig()`. + * @returns The public URL of the uploaded file. + */ +export function uploadBuffer( + data: Buffer, + ext: string, + config?: FtpConfig, +): Promise { + return upload({ kind: 'buffer', data, ext }, config); +}