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. 81
      src/lib/ftps.ts

@ -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=="],

@ -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",

@ -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 `<uuid><ext>` 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 `<uuid><ext>` 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 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<string> {
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<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