feat: initial tools service with document scanner, image & PDF tools

Self-hosted document scanner and media processing tools.
- Rust Axum gateway + worker pool with NATS JetStream
- Next.js 16 frontend with shadcn/ui
- Scanner pipeline: edge detection, warp, binarization, OCR
- Image tools: compress, resize, convert
- PDF tools: merge, split, compress
- CI/CD with Docker multi-stage build

Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
asepharyana
2026-07-24 13:10:59 +07:00
co-authored by Kilo
commit a00ad62f6c
98 changed files with 11399 additions and 0 deletions
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const response = await fetch(`${RUST_GATEWAY}/api/download/${id}`);
if (!response.ok) {
const data = await response.json().catch(() => null);
return NextResponse.json(
data ?? { error: "Download failed" },
{ status: response.status },
);
}
// Stream the file back
const blob = await response.blob();
const contentType =
response.headers.get("content-type") || "application/octet-stream";
const contentDisposition =
response.headers.get("content-disposition") ||
"attachment; filename=\"result\"";
return new NextResponse(blob, {
headers: {
"Content-Type": contentType,
"Content-Disposition": contentDisposition,
},
});
} catch (error) {
console.error("Download proxy error:", error);
return NextResponse.json(
{ error: "Failed to download file" },
{ status: 500 },
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const response = await fetch(`${RUST_GATEWAY}/api/job/${id}`);
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error("Job status proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch job status" },
{ status: 500 },
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
// WebSocket is handled directly by the client connecting to the Rust gateway.
// Next.js App Router cannot proxy WebSocket connections in route handlers.
// The client-side useJobStatus hook connects directly to ws://localhost:3001/api/job/{id}/ws
// In production, configure the WebSocket to connect to wss://tools.asepharyana.my.id/api/job/{id}/ws
export function GET() {
return NextResponse.json(
{
note: "WebSocket connections go directly to the Rust gateway",
ws_url:
process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:3001/api/job/{id}/ws",
},
);
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file");
const tool = formData.get("tool");
const options = formData.get("options");
if (!file || !tool) {
return NextResponse.json(
{ error: "Missing file or tool parameter" },
{ status: 400 },
);
}
// Forward to Rust gateway
const gatewayForm = new FormData();
gatewayForm.append("file", file);
gatewayForm.append("tool", tool as string);
if (options) {
gatewayForm.append("options", options as string);
}
const response = await fetch(`${RUST_GATEWAY}/api/upload`, {
method: "POST",
body: gatewayForm,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data, { status: 202 });
} catch (error) {
console.error("Upload proxy error:", error);
return NextResponse.json(
{ error: "Failed to process upload" },
{ status: 500 },
);
}
}