diff --git a/.dockerignore b/.dockerignore index 56b59b0..e9a0c55 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ node_modules *.md docs/ legacy/ +dwg2dxf-api/ .DS_Store diff --git a/Dockerfile b/Dockerfile index 9d4a769..afba611 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ ENV NODE_ENV=production RUN apk add --no-cache gdal gdal-tools ghostscript qpdf -# Note: DWG→DXF conversion not available in Alpine (libredwg missing) +# Note: DWG→DXF conversion handled by dwg2dxf sidecar container (see docker-compose.yml) RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/docker-compose.yml b/docker-compose.yml index b2a3ad9..d31f2b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,8 +44,25 @@ services: # eTerra ANCPI (parcel-sync module) - ETERRA_USERNAME=${ETERRA_USERNAME:-} - ETERRA_PASSWORD=${ETERRA_PASSWORD:-} + # DWG-to-DXF sidecar + - DWG2DXF_URL=http://dwg2dxf:5001 + depends_on: + dwg2dxf: + condition: service_healthy volumes: # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) - /mnt/manictime:/mnt/manictime labels: - "com.centurylinklabs.watchtower.enable=true" + + dwg2dxf: + build: + context: ./dwg2dxf-api + container_name: dwg2dxf + restart: unless-stopped + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/dwg2dxf-api/Dockerfile b/dwg2dxf-api/Dockerfile new file mode 100644 index 0000000..56a73c6 --- /dev/null +++ b/dwg2dxf-api/Dockerfile @@ -0,0 +1,22 @@ +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + python3 python3-pip libredwg-tools && \ + pip3 install --no-cache-dir --break-system-packages flask && \ + apt-get purge -y python3-pip && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY app.py . + +RUN useradd --system --no-create-home converter +USER converter + +EXPOSE 5001 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')" || exit 1 + +CMD ["python3", "app.py"] diff --git a/dwg2dxf-api/app.py b/dwg2dxf-api/app.py new file mode 100644 index 0000000..216ee49 --- /dev/null +++ b/dwg2dxf-api/app.py @@ -0,0 +1,93 @@ +"""Minimal DWG-to-DXF conversion microservice via libredwg.""" + +import os +import subprocess +import tempfile +import uuid + +from flask import Flask, request, send_file, jsonify + +app = Flask(__name__) + +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB + + +@app.route("/health", methods=["GET"]) +def health(): + """Health check — verifies dwg2dxf binary is callable.""" + try: + subprocess.run(["dwg2dxf", "--version"], capture_output=True, timeout=5) + return jsonify({"status": "ok", "dwg2dxf": "available"}), 200 + except Exception as e: + return jsonify({"status": "error", "detail": str(e)}), 503 + + +@app.route("/convert", methods=["POST"]) +def convert(): + """Accept a DWG file via multipart upload, return DXF.""" + if "file" not in request.files: + return jsonify({"error": "Missing 'file' field in upload."}), 400 + + uploaded = request.files["file"] + original_name = uploaded.filename or "input.dwg" + + if not original_name.lower().endswith(".dwg"): + return jsonify({"error": "File must have .dwg extension."}), 400 + + safe_name = "".join( + c if c.isalnum() or c in "._-" else "_" for c in original_name + ) + + tmp_dir = os.path.join(tempfile.gettempdir(), f"dwg-{uuid.uuid4().hex}") + os.makedirs(tmp_dir, exist_ok=True) + + input_path = os.path.join(tmp_dir, safe_name) + output_path = input_path.rsplit(".", 1)[0] + ".dxf" + + try: + uploaded.save(input_path) + + file_size = os.path.getsize(input_path) + if file_size > MAX_FILE_SIZE: + return jsonify({"error": f"File too large ({file_size} bytes)."}), 413 + + result = subprocess.run( + ["dwg2dxf", input_path], + capture_output=True, + timeout=120, + ) + + if not os.path.exists(output_path): + stderr = result.stderr.decode("utf-8", errors="replace") + return jsonify({ + "error": f"Conversion failed: {stderr or 'no output file'}" + }), 500 + + dxf_name = safe_name.rsplit(".", 1)[0] + ".dxf" + return send_file( + output_path, + mimetype="application/dxf", + as_attachment=True, + download_name=dxf_name, + ) + + except subprocess.TimeoutExpired: + return jsonify({"error": "Conversion timed out (120s limit)."}), 504 + + except Exception as e: + return jsonify({"error": f"Unexpected error: {str(e)}"}), 500 + + finally: + for f in os.listdir(tmp_dir): + try: + os.unlink(os.path.join(tmp_dir, f)) + except OSError: + pass + try: + os.rmdir(tmp_dir) + except OSError: + pass + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5001) diff --git a/src/app/api/dwg-convert/route.ts b/src/app/api/dwg-convert/route.ts index 93d430c..8da6435 100644 --- a/src/app/api/dwg-convert/route.ts +++ b/src/app/api/dwg-convert/route.ts @@ -1,18 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { writeFile, readFile, unlink, mkdir } from "fs/promises"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { randomUUID } from "crypto"; -import { join } from "path"; -import { tmpdir } from "os"; -const execAsync = promisify(exec); +const DWG2DXF_URL = process.env.DWG2DXF_URL ?? "http://localhost:5001"; export async function POST(req: NextRequest) { - const tmpDir = join(tmpdir(), `dwg-${randomUUID()}`); try { const formData = await req.formData(); const file = formData.get("fileInput") as File | null; + if (!file) { return NextResponse.json( { error: "Lipsește fișierul DWG." }, @@ -28,20 +22,29 @@ export async function POST(req: NextRequest) { ); } - await mkdir(tmpDir, { recursive: true }); + // Re-package for sidecar (field name: "file") + const sidecarForm = new FormData(); + sidecarForm.append("file", file, name); - const inputPath = join(tmpDir, name); - const outputPath = inputPath.replace(/\.dwg$/i, ".dxf"); + const res = await fetch(`${DWG2DXF_URL}/convert`, { + method: "POST", + body: sidecarForm, + }); - const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(inputPath, buffer); + if (!res.ok) { + const data = await res.json().catch(() => ({ error: res.statusText })); + const errorMsg = + typeof data === "object" && data !== null && "error" in data + ? String((data as Record).error) + : `Eroare sidecar DWG: ${res.status}`; + return NextResponse.json({ error: errorMsg }, { status: res.status }); + } - await execAsync(`dwg2dxf "${inputPath}"`, { timeout: 60_000 }); - - const dxfBuffer = await readFile(outputPath); + const blob = await res.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); const dxfName = name.replace(/\.dwg$/i, ".dxf"); - return new NextResponse(dxfBuffer, { + return new NextResponse(buffer, { status: 200, headers: { "Content-Type": "application/dxf", @@ -50,35 +53,24 @@ export async function POST(req: NextRequest) { }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; + if ( - message.includes("ENOENT") || - message.includes("not found") || - message.includes("not recognized") + message.includes("ECONNREFUSED") || + message.includes("fetch failed") || + message.includes("ENOTFOUND") ) { return NextResponse.json( { error: - "Conversia DWG→DXF este disponibilă doar pe server (Docker). Local nu este instalat dwg2dxf (libredwg).", + "Serviciul de conversie DWG nu este disponibil. Verificați că containerul dwg2dxf este pornit.", }, - { status: 501 }, + { status: 503 }, ); } + return NextResponse.json( { error: `Eroare la conversie DWG→DXF: ${message}` }, { status: 500 }, ); - } finally { - // Clean up temp files - try { - const { readdir } = await import("fs/promises"); - const files = await readdir(tmpDir); - for (const f of files) { - await unlink(join(tmpDir, f)).catch(() => {}); - } - const { rmdir } = await import("fs/promises"); - await rmdir(tmpDir).catch(() => {}); - } catch { - // temp cleanup failure is non-critical - } } }