|
|
|
|
@ -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 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<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); |
|
|
|
|
} |
|
|
|
|
|