277 lines
7.4 KiB
Go
277 lines
7.4 KiB
Go
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)
|
|
}
|