feat(ftps): support uploading from file, buffer, or stream

main
uc-hoba 2 weeks ago
parent 71ea095ff0
commit 60a5a19cba
  1. 3
      bun.lock
  2. 1
      package.json
  3. 83
      src/lib/ftps.ts

@ -10,6 +10,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"mqtt": "^5.15.1", "mqtt": "^5.15.1",
"next": "16.2.9", "next": "16.2.9",
@ -602,6 +603,8 @@
"hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], "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=="], "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=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],

@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"mqtt": "^5.15.1", "mqtt": "^5.15.1",
"next": "16.2.9", "next": "16.2.9",

@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { extname } from 'node:path'; import { extname } from 'node:path';
import { Readable } from 'node:stream';
import { Client } from 'basic-ftp'; import { Client } from 'basic-ftp';
export interface FtpConfig { export interface FtpConfig {
@ -47,26 +48,53 @@ export function loadFtpConfig(): FtpConfig {
} }
/** /**
* Uploads a local file to the FTP server under a random `<uuid><ext>` name and * Source for an upload: a local file path, an in-memory buffer, or a readable
* returns the public URL of the uploaded file. * 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 `<uuid><ext>` name and returns
* the public URL of the uploaded file.
* *
* A random UUID name avoids collisions and, since the server rejects * A random UUID name avoids collisions and, since the server rejects
* overwriting existing files, guarantees every upload is a fresh create. * 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 * NOTE: must be run on the Node.js runtime, not Bun Bun does not support the
* support the TLS session resumption this FTPS server requires on the data * TLS session resumption this FTPS server requires on the data connection.
* connection.
* *
* @param localPath Path to the local file to upload (its extension is kept). * @param source Where the bytes come from (file path, buffer, or stream).
* @param config Optional FTP config; defaults to `loadFtpConfig()`. * @param config Optional FTP config; defaults to `loadFtpConfig()`.
* @returns The public URL of the uploaded file. * @returns The public URL of the uploaded file.
*/ */
export async function uploadFile( export async function upload(
localPath: string, source: UploadSource,
config: FtpConfig = loadFtpConfig(), config: FtpConfig = loadFtpConfig(),
): Promise<string> { ): Promise<string> {
const ext = extname(localPath).toLowerCase(); const remoteName = `${randomUUID()}${extensionOf(source)}`;
const remoteName = `${randomUUID()}${ext}`;
const remoteRoot = config.remoteDir.replace(/\/$/, ''); const remoteRoot = config.remoteDir.replace(/\/$/, '');
const remotePath = `${remoteRoot}/${remoteName}`; const remotePath = `${remoteRoot}/${remoteName}`;
@ -81,10 +109,41 @@ export async function uploadFile(
secureOptions: { rejectUnauthorized: false }, secureOptions: { rejectUnauthorized: false },
}); });
await client.uploadFrom(createReadStream(localPath), remotePath); await client.uploadFrom(toFtpSource(source), remotePath);
return `${config.publicBaseUrl.replace(/\/$/, '')}/${remoteName}`; return `${config.publicBaseUrl.replace(/\/$/, '')}/${remoteName}`;
} finally { } finally {
client.close(); 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<string> {
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<string> {
return upload({ kind: 'buffer', data, ext }, config);
}

Loading…
Cancel
Save