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:
@@ -8,4 +8,5 @@ node_modules
|
||||
*.md
|
||||
docs/
|
||||
legacy/
|
||||
dwg2dxf-api/
|
||||
.DS_Store
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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<string, unknown>).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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user