package server import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "time" "mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/mcables/internal/exporter" ) // syncExport runs the project's snapshot through the exporter, persists // the assigned excalidraw_ids, then PUTs the scene to mxdrw.msbls.de. func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) { pid, ok := parseInt64Path(r, "pid") if !ok { writeError(w, db.ErrInvalidInput, "pid must be a positive integer") return } base := os.Getenv("MEXDRAW_BASE_URL") if base == "" { base = "https://mxdrw.msbls.de" } user := os.Getenv("MEXDRAW_USER") pass := os.Getenv("MEXDRAW_PASS") if user == "" || pass == "" { writeJSON(w, http.StatusBadRequest, errorBody{ Error: "MEXDRAW_USER / MEXDRAW_PASS not set", Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth", }) return } snap, err := h.store.Snapshot(pid) if err != nil { writeError(w, err, nil) return } now := time.Now().UnixMilli() scene, ids := exporter.BuildScene(snap, now, exporter.Generate21) // Persist the freshly-assigned ids so the next export reuses them. // We pass in the full maps; PersistExcalidrawIDs is idempotent (it // only updates rows whose excalidraw_id is still NULL). if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil { writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil) return } payload, err := exporter.MarshalScene(scene) if err != nil { writeError(w, fmt.Errorf("marshal scene: %w", err), nil) return } drawingName := snap.Project.DrawingName if !strings.HasSuffix(drawingName, ".excalidraw") { drawingName += ".excalidraw" } url := strings.TrimSuffix(base, "/") + "/api/drawings/" + drawingName // Sane network timeout; mxdrw is on the LAN so this should be quick. ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload)) if err != nil { writeError(w, fmt.Errorf("build PUT: %w", err), nil) return } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth(user, pass) resp, err := http.DefaultClient.Do(req) if err != nil { writeJSON(w, http.StatusBadGateway, errorBody{ Error: "mxdrw unreachable", Details: err.Error(), }) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { writeJSON(w, http.StatusBadGateway, errorBody{ Error: fmt.Sprintf("mxdrw rejected upload (%d)", resp.StatusCode), Details: map[string]any{ "status": resp.StatusCode, "body": string(body), "url": url, }, }) return } // Best-effort parse — mxdrw returns whatever it returns; we surface // the public viewer URL no matter what. var serverEcho any _ = json.Unmarshal(body, &serverEcho) viewerURL := strings.TrimSuffix(base, "/") + "/draw/" + strings.TrimSuffix(drawingName, ".excalidraw") + ".excalidraw" writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "drawing_name": drawingName, "url": viewerURL, "element_count": len(scene.Elements), "mxdrw_response": serverEcho, }) } // noLeak prevents unused-import errors if errors-pkg ever becomes unused // after a refactor — keeps the import light. var _ = errors.New