diff --git a/bun.lock b/bun.lock index e83c55f..78f546f 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@serwist/next": "^9.5.11", + "basic-ftp": "^6.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -304,6 +305,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], + "basic-ftp": ["basic-ftp@6.0.1", "", {}, "sha512-3ilxa3n4276wGQp/ImRAuz4ALdsj/2Wd3FqoZBZlajDYnByCZ0JMb4+26Rde0wGXIbM0G2HWSfr/Fi8b21KX8g=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], diff --git a/package.json b/package.json index b9a7cf3..ea47e11 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@serwist/next": "^9.5.11", + "basic-ftp": "^6.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", diff --git a/src/lib/ftps.ts b/src/lib/ftps.ts new file mode 100644 index 0000000..8183fbf --- /dev/null +++ b/src/lib/ftps.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { extname } from 'node:path'; +import { Client } from 'basic-ftp'; + +export interface FtpConfig { + host: string; + port: number; + user: string; + password: string; + /** Remote directory the files are uploaded into. */ + remoteDir: string; + /** Public base URL that serves files from `remoteDir`. */ + publicBaseUrl: string; +} + +/** + * Build the FTP config from environment variables. + * + * Required: FTP_HOST / FTP_USER / FTP_PASSWORD + * Optional: FTP_PORT (990) / FTP_REMOTE_DIR (/) / FTP_PUBLIC_BASE_URL + */ +export function loadFtpConfig(): FtpConfig { + const { + FTP_HOST, + FTP_PORT, + FTP_USER, + FTP_PASSWORD, + FTP_REMOTE_DIR, + FTP_PUBLIC_BASE_URL, + } = process.env; + + if (!FTP_HOST || !FTP_USER || !FTP_PASSWORD) { + throw new Error( + 'Missing required FTP env vars (FTP_HOST / FTP_USER / FTP_PASSWORD)', + ); + } + + return { + host: FTP_HOST, + port: FTP_PORT ? Number(FTP_PORT) : 990, + user: FTP_USER, + password: FTP_PASSWORD, + remoteDir: FTP_REMOTE_DIR ?? '/', + publicBaseUrl: FTP_PUBLIC_BASE_URL ?? 'https://dl.giant.com.tw/MQR', + }; +} + +/** + * Uploads a local file 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. + * + * @param localPath Path to the local file to upload (its extension is kept). + * @param config Optional FTP config; defaults to `loadFtpConfig()`. + * @returns The public URL of the uploaded file. + */ +export async function uploadFile( + localPath: string, + config: FtpConfig = loadFtpConfig(), +): Promise { + const ext = extname(localPath).toLowerCase(); + const remoteName = `${randomUUID()}${ext}`; + const remoteRoot = config.remoteDir.replace(/\/$/, ''); + const remotePath = `${remoteRoot}/${remoteName}`; + + const client = new Client(30_000); + try { + await client.access({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + secure: 'implicit', + secureOptions: { rejectUnauthorized: false }, + }); + + await client.uploadFrom(createReadStream(localPath), remotePath); + + return `${config.publicBaseUrl.replace(/\/$/, '')}/${remoteName}`; + } finally { + client.close(); + } +}