feat(dwg): DWG→DXF via sidecar microservice (libredwg)

Add dedicated dwg2dxf container (Debian slim + libredwg-tools + Flask)
instead of modifying the Alpine base image. The ArchiTools API route
proxies to the sidecar over Docker internal network.

- dwg2dxf-api/: Dockerfile + Flask app (POST /convert, GET /health)
- docker-compose.yml: dwg2dxf service, healthcheck, depends_on
- route.ts: rewritten from local exec to HTTP proxy
- .dockerignore: exclude sidecar from main build context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-09 10:51:27 +02:00
parent 7ed653eaec
commit 5209fd5dd0
6 changed files with 161 additions and 36 deletions
+1
View File
@@ -8,4 +8,5 @@ node_modules
*.md *.md
docs/ docs/
legacy/ legacy/
dwg2dxf-api/
.DS_Store .DS_Store
+1 -1
View File
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf 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 addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
+17
View File
@@ -44,8 +44,25 @@ services:
# eTerra ANCPI (parcel-sync module) # eTerra ANCPI (parcel-sync module)
- ETERRA_USERNAME=${ETERRA_USERNAME:-} - ETERRA_USERNAME=${ETERRA_USERNAME:-}
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-} - ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
# DWG-to-DXF sidecar
- DWG2DXF_URL=http://dwg2dxf:5001
depends_on:
dwg2dxf:
condition: service_healthy
volumes: volumes:
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
- /mnt/manictime:/mnt/manictime - /mnt/manictime:/mnt/manictime
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "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
+22
View File
@@ -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"]
+93
View File
@@ -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)
+27 -35
View File
@@ -1,18 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; 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) { export async function POST(req: NextRequest) {
const tmpDir = join(tmpdir(), `dwg-${randomUUID()}`);
try { try {
const formData = await req.formData(); const formData = await req.formData();
const file = formData.get("fileInput") as File | null; const file = formData.get("fileInput") as File | null;
if (!file) { if (!file) {
return NextResponse.json( return NextResponse.json(
{ error: "Lipsește fișierul DWG." }, { 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 res = await fetch(`${DWG2DXF_URL}/convert`, {
const outputPath = inputPath.replace(/\.dwg$/i, ".dxf"); method: "POST",
body: sidecarForm,
});
const buffer = Buffer.from(await file.arrayBuffer()); if (!res.ok) {
await writeFile(inputPath, buffer); const data = await res.json().catch(() => ({ error: res.statusText }));
const errorMsg =
typeof data === "object" && data !== null && "error" in data
? String((data as Record<string, unknown>).error)
: `Eroare sidecar DWG: ${res.status}`;
return NextResponse.json({ error: errorMsg }, { status: res.status });
}
await execAsync(`dwg2dxf "${inputPath}"`, { timeout: 60_000 }); const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
const dxfBuffer = await readFile(outputPath);
const dxfName = name.replace(/\.dwg$/i, ".dxf"); const dxfName = name.replace(/\.dwg$/i, ".dxf");
return new NextResponse(dxfBuffer, { return new NextResponse(buffer, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/dxf", "Content-Type": "application/dxf",
@@ -50,35 +53,24 @@ export async function POST(req: NextRequest) {
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"; const message = err instanceof Error ? err.message : "Unknown error";
if ( if (
message.includes("ENOENT") || message.includes("ECONNREFUSED") ||
message.includes("not found") || message.includes("fetch failed") ||
message.includes("not recognized") message.includes("ENOTFOUND")
) { ) {
return NextResponse.json( return NextResponse.json(
{ {
error: 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( return NextResponse.json(
{ error: `Eroare la conversie DWG→DXF: ${message}` }, { error: `Eroare la conversie DWG→DXF: ${message}` },
{ status: 500 }, { 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
}
} }
} }