package main import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/google/uuid" "github.com/sotigr/slic3r-api/internal/cache" "github.com/sotigr/slic3r-api/internal/slicer" "github.com/sotigr/slic3r-api/internal/thumbnail" ) var allowedModelExtensions = map[string]bool{ ".stl": true, ".3mf": true, ".obj": true, ".amf": true, } func getEnvOrDefault(key, defaultVal string) string { if v := os.Getenv(key); v != "" { return v } return defaultVal } func actions(mux *http.ServeMux) { slicerSvc := slicer.NewSlicerService( getEnvOrDefault("FFF_CONFIG_PATH", "configs/p1s-cfg.ini"), getEnvOrDefault("SLA_CONFIG_PATH", "configs/gktwo-sla-cfg.ini"), ) thumbnailSvc := thumbnail.NewThumbnailService() var c cache.Cache if url := getEnvOrDefault("VALKEY_URL", ""); url != "" { vc, err := cache.NewValkeyCache(url) if err != nil { log.Printf("cache: failed to connect to Valkey at %s: %v — running without cache", url, err) c = cache.NewNoopCache() } else { log.Printf("cache: connected to Valkey at %s", url) c = vc } } else { log.Print("cache: VALKEY_URL not set — running without cache") c = cache.NewNoopCache() } cacheTTL := 24 * time.Hour if s := getEnvOrDefault("CACHE_TTL_SECONDS", ""); s != "" { if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 { cacheTTL = time.Duration(v) * time.Second } } mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) mux.HandleFunc("/analyze-fff", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } modelPath, cleanup, err := saveUploadedFile(r, "file") if err != nil { http.Error(w, fmt.Sprintf("failed to read file: %v", err), http.StatusBadRequest) return } defer cleanup() opts, err := parseAnalyzeOptions(r) if err != nil { http.Error(w, fmt.Sprintf("invalid options: %v", err), http.StatusBadRequest) return } fileHash, err := cache.HashFile(modelPath) if err != nil { http.Error(w, "failed to hash file", http.StatusInternalServerError) return } key := cache.AnalysisKey("fff", fileHash, opts.Scale) if cached, ok := c.Get(r.Context(), key); ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(cached) return } result, err := slicerSvc.AnalyzeFFF(modelPath, opts) if err != nil { http.Error(w, fmt.Sprintf("analysis failed: %v", err), http.StatusInternalServerError) return } payload, _ := json.Marshal(result) c.Set(r.Context(), key, payload, cacheTTL) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(payload) }) mux.HandleFunc("/analyze-sla", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } modelPath, cleanup, err := saveUploadedFile(r, "file") if err != nil { http.Error(w, fmt.Sprintf("failed to read file: %v", err), http.StatusBadRequest) return } defer cleanup() opts, err := parseAnalyzeOptions(r) if err != nil { http.Error(w, fmt.Sprintf("invalid options: %v", err), http.StatusBadRequest) return } fileHash, err := cache.HashFile(modelPath) if err != nil { http.Error(w, "failed to hash file", http.StatusInternalServerError) return } key := cache.AnalysisKey("sla", fileHash, opts.Scale) if cached, ok := c.Get(r.Context(), key); ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(cached) return } result, err := slicerSvc.AnalyzeSLA(modelPath, opts) if err != nil { http.Error(w, fmt.Sprintf("analysis failed: %v", err), http.StatusInternalServerError) return } payload, _ := json.Marshal(result) c.Set(r.Context(), key, payload, cacheTTL) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(payload) }) mux.HandleFunc("/thumbnail", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } modelPath, cleanup, err := saveUploadedFile(r, "file") if err != nil { http.Error(w, fmt.Sprintf("failed to read file: %v", err), http.StatusBadRequest) return } defer cleanup() fileHash, err := cache.HashFile(modelPath) if err != nil { http.Error(w, "failed to hash file", http.StatusInternalServerError) return } key := cache.ThumbnailKey(fileHash) if cached, ok := c.Get(r.Context(), key); ok { w.Header().Set("Content-Type", "image/webp") w.WriteHeader(http.StatusOK) w.Write(cached) return } outputPath := filepath.Join(filepath.Dir(modelPath), "thumbnail.webp") if err := thumbnailSvc.Generate(r.Context(), modelPath, outputPath); err != nil { http.Error(w, fmt.Sprintf("thumbnail generation failed: %v", err), http.StatusInternalServerError) return } pngData, err := os.ReadFile(outputPath) os.Remove(outputPath) if err != nil { http.Error(w, "failed to read generated thumbnail", http.StatusInternalServerError) return } c.Set(r.Context(), key, pngData, cacheTTL) w.Header().Set("Content-Type", "image/webp") w.WriteHeader(http.StatusOK) w.Write(pngData) }) } // saveUploadedFile reads the named file field from a multipart request and saves it to a // temporary directory. Returns the saved path and a cleanup function to remove the directory. func saveUploadedFile(r *http.Request, field string) (string, func(), error) { const maxUploadSize = 512 << 20 // 64 MB if err := r.ParseMultipartForm(maxUploadSize); err != nil { return "", nil, fmt.Errorf("parse multipart form: %w", err) } file, header, err := r.FormFile(field) if err != nil { return "", nil, fmt.Errorf("get form field %q: %w", field, err) } defer file.Close() origName := filepath.Base(header.Filename) ext := strings.ToLower(filepath.Ext(origName)) if !allowedModelExtensions[ext] { return "", nil, fmt.Errorf("unsupported file format %q, allowed: .stl .3mf .obj .amf", ext) } tmpDir, err := os.MkdirTemp("", "upload-*") if err != nil { return "", nil, fmt.Errorf("create temp dir: %w", err) } cleanup := func() { os.RemoveAll(tmpDir) } dst := filepath.Join(tmpDir, uuid.New().String()+ext) f, err := os.Create(dst) if err != nil { cleanup() return "", nil, fmt.Errorf("create temp file: %w", err) } defer f.Close() if _, err := io.Copy(f, file); err != nil { cleanup() return "", nil, fmt.Errorf("write uploaded file: %w", err) } return dst, cleanup, nil } // parseAnalyzeOptions reads optional analysis parameters from the multipart form. // Supported fields: scale (float, e.g. "1.5" = 150% size). func parseAnalyzeOptions(r *http.Request) (slicer.AnalyzeOptions, error) { var opts slicer.AnalyzeOptions if s := r.FormValue("scale"); s != "" { scale, err := strconv.ParseFloat(s, 64) if err != nil || scale <= 0 { return opts, fmt.Errorf("scale must be a positive number of millimeters, got %q", s) } opts.Scale = scale } return opts, nil } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(v) }