This commit is contained in:
Sotig
2026-05-29 21:16:10 +03:00
parent 778380dc35
commit 0c40983293
35 changed files with 4905 additions and 1 deletions

276
api/cmd/actions.go Normal file
View File

@@ -0,0 +1,276 @@
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)
}