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
|
*.md
|
||||||
docs/
|
docs/
|
||||||
legacy/
|
legacy/
|
||||||
|
dwg2dxf-api/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user