diff --git a/src/app/api/ftp-watch/test/route.ts b/src/app/api/ftp-watch/test/route.ts
index 00bfef8..e79178c 100644
--- a/src/app/api/ftp-watch/test/route.ts
+++ b/src/app/api/ftp-watch/test/route.ts
@@ -16,12 +16,15 @@ export async function POST(request: NextRequest) {
pass = settings?.ftpPass || "";
}
+ const protocol = body.protocol || "sftp";
+ const defaultPort = protocol === "sftp" ? 22 : 21;
+
const result = await testFtpConnection({
host: body.host,
- port: body.port || 21,
+ port: body.port || defaultPort,
user: body.user,
pass,
- tls: body.tls ?? true,
+ protocol,
incomingDir: body.incomingDir || "/incoming",
});
return NextResponse.json(result);
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
index 6faa2c5..9e40021 100644
--- a/src/app/api/settings/route.ts
+++ b/src/app/api/settings/route.ts
@@ -67,7 +67,8 @@ export async function PUT(request: NextRequest) {
if (body.ftpPort != null) data.ftpPort = Math.max(1, parseInt(String(body.ftpPort)) || 21);
if (body.ftpUser != null) data.ftpUser = String(body.ftpUser);
if (body.ftpPass != null) data.ftpPass = String(body.ftpPass);
- if (body.ftpTls != null) data.ftpTls = Boolean(body.ftpTls);
+ if (body.ftpProtocol != null && ["sftp", "ftp", "ftps"].includes(body.ftpProtocol))
+ data.ftpProtocol = String(body.ftpProtocol);
if (body.ftpIncomingDir != null) data.ftpIncomingDir = String(body.ftpIncomingDir);
if (body.ftpProcessedDir != null) data.ftpProcessedDir = String(body.ftpProcessedDir);
diff --git a/src/lib/ftp-watcher.ts b/src/lib/ftp-watcher.ts
index a8bd70d..8403611 100644
--- a/src/lib/ftp-watcher.ts
+++ b/src/lib/ftp-watcher.ts
@@ -1,4 +1,5 @@
-import { Client } from "basic-ftp";
+import { Client as FtpClient } from "basic-ftp";
+import SftpClient from "ssh2-sftp-client";
import { Writable } from "stream";
import { prisma } from "./db";
import { uploadBuffer } from "./storage";
@@ -6,6 +7,25 @@ import { enqueueProcessing } from "./processing-queue";
const ALLOWED_EXTENSIONS = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
+const CONNECT_TIMEOUT = 15_000;
+
+type Protocol = "sftp" | "ftp" | "ftps";
+
+interface ConnectionConfig {
+ host: string;
+ port: number;
+ user: string;
+ pass: string;
+ protocol: Protocol;
+ incomingDir: string;
+ processedDir?: string;
+}
+
+interface FileEntry {
+ name: string;
+ size: number;
+ isDirectory: boolean;
+}
function log(msg: string, ...args: unknown[]) {
console.log(`[ftp-watcher] ${msg}`, ...args);
@@ -15,7 +35,71 @@ function logError(msg: string, ...args: unknown[]) {
console.error(`[ftp-watcher] ${msg}`, ...args);
}
-async function downloadToBuffer(client: Client, remotePath: string): Promise {
+// ── SFTP helpers ──────────────────────────────────────────────────────────
+
+async function sftpConnect(config: ConnectionConfig): Promise {
+ const sftp = new SftpClient();
+ await sftp.connect({
+ host: config.host,
+ port: config.port,
+ username: config.user,
+ password: config.pass,
+ readyTimeout: CONNECT_TIMEOUT,
+ retries: 0,
+ });
+ return sftp;
+}
+
+async function sftpList(sftp: SftpClient, dir: string): Promise {
+ const items = await sftp.list(dir);
+ return items.map((f) => ({
+ name: f.name,
+ size: f.size,
+ isDirectory: f.type === "d",
+ }));
+}
+
+async function sftpDownload(sftp: SftpClient, remotePath: string): Promise {
+ const result = await sftp.get(remotePath);
+ if (Buffer.isBuffer(result)) return result;
+ if (typeof result === "string") return Buffer.from(result);
+ return Buffer.from(result as unknown as ArrayBuffer);
+}
+
+async function sftpMove(
+ sftp: SftpClient,
+ src: string,
+ dest: string,
+ destDir: string
+): Promise {
+ try {
+ const exists = await sftp.exists(destDir);
+ if (!exists) await sftp.mkdir(destDir, true);
+ await sftp.rename(src, dest);
+ } catch {
+ log(`Could not move file to processed dir, removing instead`);
+ try {
+ await sftp.delete(src);
+ } catch {}
+ }
+}
+
+// ── FTP / FTPS helpers ────────────────────────────────────────────────────
+
+async function ftpConnect(config: ConnectionConfig): Promise {
+ const client = new FtpClient(CONNECT_TIMEOUT);
+ await client.access({
+ host: config.host,
+ port: config.port,
+ user: config.user,
+ password: config.pass,
+ secure: config.protocol === "ftps",
+ secureOptions: { rejectUnauthorized: false },
+ });
+ return client;
+}
+
+async function ftpDownloadToBuffer(client: FtpClient, remotePath: string): Promise {
const chunks: Buffer[] = [];
const writable = new Writable({
write(chunk, _encoding, callback) {
@@ -27,6 +111,8 @@ async function downloadToBuffer(client: Client, remotePath: string): Promise {
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
@@ -38,112 +124,194 @@ export async function pollFtp(): Promise<{ processed: number; skipped: number; e
return { processed: 0, skipped: 0, error: "FTP not configured" };
}
- const client = new Client(15_000);
+ const protocol = (settings.ftpProtocol || "sftp") as Protocol;
+ const config: ConnectionConfig = {
+ host: settings.ftpHost,
+ port: settings.ftpPort,
+ user: settings.ftpUser,
+ pass: settings.ftpPass,
+ protocol,
+ incomingDir: settings.ftpIncomingDir,
+ processedDir: settings.ftpProcessedDir,
+ };
+
let processed = 0;
let skipped = 0;
- try {
- await client.access({
- host: settings.ftpHost,
- port: settings.ftpPort,
- user: settings.ftpUser,
- password: settings.ftpPass,
- secure: settings.ftpTls,
- secureOptions: { rejectUnauthorized: false },
+ if (protocol === "sftp") {
+ const sftp = await sftpConnect(config).catch((err) => {
+ throw new Error(`SFTP connect failed: ${err.message}`);
});
- log(`Connected to ${settings.ftpHost}:${settings.ftpPort}`);
+ try {
+ log(`Connected via SFTP to ${config.host}:${config.port}`);
+ const allFiles = await sftpList(sftp, config.incomingDir);
+ const validFiles = allFiles.filter((f) => {
+ if (f.isDirectory) return false;
+ const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
+ return ALLOWED_EXTENSIONS.includes(ext);
+ });
- const files = await client.list(settings.ftpIncomingDir);
- const validFiles = files.filter((f) => {
- if (f.isDirectory) return false;
- const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
- return ALLOWED_EXTENSIONS.includes(ext);
- });
+ log(`Found ${validFiles.length} file(s) in ${config.incomingDir}`);
- log(`Found ${validFiles.length} file(s) in ${settings.ftpIncomingDir}`);
+ for (const file of validFiles) {
+ const remotePath = `${config.incomingDir}/${file.name}`;
+ const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
- for (const file of validFiles) {
- const remotePath = `${settings.ftpIncomingDir}/${file.name}`;
- const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
-
- if (file.size > MAX_FILE_SIZE) {
- log(`Skipping "${file.name}" — exceeds 50 MB (${(file.size / 1024 / 1024).toFixed(1)} MB)`);
- skipped++;
- continue;
- }
-
- try {
- const buffer = await downloadToBuffer(client, remotePath);
- const isPdf = ext === ".pdf";
- const contentType = isPdf ? "application/pdf" : "image/jpeg";
-
- const job = await prisma.processingJob.create({
- data: {
- fileName: file.name,
- filePath: `ftp/${file.name}`,
- status: "queued",
- },
- });
-
- const sourceKey = `sources/${job.id}/${file.name}`;
- await uploadBuffer(sourceKey, buffer, contentType);
- await enqueueProcessing(job.id);
-
- const destPath = `${settings.ftpProcessedDir}/${file.name}`;
- try {
- await client.ensureDir(settings.ftpProcessedDir);
- await client.rename(remotePath, destPath);
- } catch {
- log(`Could not move "${file.name}" to processed dir, removing instead`);
- try { await client.remove(remotePath); } catch {}
+ if (file.size > MAX_FILE_SIZE) {
+ log(`Skipping "${file.name}" — exceeds 50 MB`);
+ skipped++;
+ continue;
}
- processed++;
- log(`Queued: ${file.name} (job ${job.id})`);
- } catch (err) {
- logError(`Failed to process "${file.name}":`, err);
- skipped++;
- }
- }
+ try {
+ const buffer = await sftpDownload(sftp, remotePath);
+ const isPdf = ext === ".pdf";
+ const contentType = isPdf ? "application/pdf" : "image/jpeg";
- client.close();
- } catch (err) {
- const message = err instanceof Error ? err.message : "FTP poll failed";
- logError("Error:", message);
- client.close();
- return { processed, skipped, error: message };
+ const job = await prisma.processingJob.create({
+ data: { fileName: file.name, filePath: `ftp/${file.name}`, status: "queued" },
+ });
+
+ await uploadBuffer(`sources/${job.id}/${file.name}`, buffer, contentType);
+ await enqueueProcessing(job.id);
+
+ await sftpMove(
+ sftp,
+ remotePath,
+ `${config.processedDir}/${file.name}`,
+ config.processedDir!
+ );
+
+ processed++;
+ log(`Queued: ${file.name} (job ${job.id})`);
+ } catch (err) {
+ logError(`Failed to process "${file.name}":`, err);
+ skipped++;
+ }
+ }
+
+ await sftp.end();
+ } catch (err) {
+ await sftp.end().catch(() => {});
+ const message = err instanceof Error ? err.message : "SFTP poll failed";
+ logError("Error:", message);
+ return { processed, skipped, error: message };
+ }
+ } else {
+ let client: FtpClient | null = null;
+ try {
+ client = await ftpConnect(config);
+ log(`Connected via ${protocol.toUpperCase()} to ${config.host}:${config.port}`);
+
+ const files = await client.list(config.incomingDir);
+ const validFiles = files.filter((f) => {
+ if (f.isDirectory) return false;
+ const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
+ return ALLOWED_EXTENSIONS.includes(ext);
+ });
+
+ log(`Found ${validFiles.length} file(s) in ${config.incomingDir}`);
+
+ for (const file of validFiles) {
+ const remotePath = `${config.incomingDir}/${file.name}`;
+ const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
+
+ if (file.size > MAX_FILE_SIZE) {
+ log(`Skipping "${file.name}" — exceeds 50 MB`);
+ skipped++;
+ continue;
+ }
+
+ try {
+ const buffer = await ftpDownloadToBuffer(client, remotePath);
+ const isPdf = ext === ".pdf";
+ const contentType = isPdf ? "application/pdf" : "image/jpeg";
+
+ const job = await prisma.processingJob.create({
+ data: { fileName: file.name, filePath: `ftp/${file.name}`, status: "queued" },
+ });
+
+ await uploadBuffer(`sources/${job.id}/${file.name}`, buffer, contentType);
+ await enqueueProcessing(job.id);
+
+ const destPath = `${config.processedDir}/${file.name}`;
+ try {
+ await client.ensureDir(config.processedDir!);
+ await client.rename(remotePath, destPath);
+ } catch {
+ log(`Could not move "${file.name}" to processed dir, removing instead`);
+ try { await client.remove(remotePath); } catch {}
+ }
+
+ processed++;
+ log(`Queued: ${file.name} (job ${job.id})`);
+ } catch (err) {
+ logError(`Failed to process "${file.name}":`, err);
+ skipped++;
+ }
+ }
+
+ client.close();
+ } catch (err) {
+ client?.close();
+ const message = err instanceof Error ? err.message : "FTP poll failed";
+ logError("Error:", message);
+ return { processed, skipped, error: message };
+ }
}
log(`Complete: ${processed} processed, ${skipped} skipped`);
return { processed, skipped };
}
+// ── Test connection ──────────────────────────────────────────────────────
+
export async function testFtpConnection(config: {
host: string;
port: number;
user: string;
pass: string;
- tls: boolean;
+ protocol: Protocol;
incomingDir: string;
}): Promise<{ ok: boolean; files?: number; error?: string }> {
- const client = new Client(15_000);
- try {
- await client.access({
- host: config.host,
- port: config.port,
- user: config.user,
- password: config.pass,
- secure: config.tls,
- secureOptions: { rejectUnauthorized: false },
- });
-
- const files = await client.list(config.incomingDir);
- client.close();
- return { ok: true, files: files.length };
- } catch (err) {
- const message = err instanceof Error ? err.message : "Connection failed";
- client.close();
- return { ok: false, error: message };
+ if (config.protocol === "sftp") {
+ const sftp = new SftpClient();
+ try {
+ await sftp.connect({
+ host: config.host,
+ port: config.port,
+ username: config.user,
+ password: config.pass,
+ readyTimeout: CONNECT_TIMEOUT,
+ retries: 0,
+ });
+ const files = await sftp.list(config.incomingDir);
+ await sftp.end();
+ return { ok: true, files: files.length };
+ } catch (err) {
+ await sftp.end().catch(() => {});
+ const message = err instanceof Error ? err.message : "SFTP connection failed";
+ return { ok: false, error: message };
+ }
+ } else {
+ const client = new FtpClient(CONNECT_TIMEOUT);
+ try {
+ await client.access({
+ host: config.host,
+ port: config.port,
+ user: config.user,
+ password: config.pass,
+ secure: config.protocol === "ftps",
+ secureOptions: { rejectUnauthorized: false },
+ });
+ const files = await client.list(config.incomingDir);
+ client.close();
+ return { ok: true, files: files.length };
+ } catch (err) {
+ client.close();
+ const message = err instanceof Error ? err.message : "FTP connection failed";
+ return { ok: false, error: message };
+ }
}
}