main.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/ccmpbll/printspy/api"
"github.com/ccmpbll/printspy/db"
"github.com/ccmpbll/printspy/ingest"
"github.com/ccmpbll/printspy/logging"
"github.com/ccmpbll/printspy/poller"
_ "github.com/ccmpbll/printspy/plugin/octoprint"
_ "github.com/ccmpbll/printspy/plugin/prusalink"
)
var version = "dev"
func main() {
port := 8080
dataDir := "/data"
if envPort := os.Getenv("PRINTSPY_PORT"); envPort != "" {
if p, err := strconv.Atoi(envPort); err == nil && p > 0 {
port = p
}
}
if envDataDir := os.Getenv("PRINTSPY_DATA_DIR"); envDataDir != "" {
dataDir = envDataDir
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("failed to create data directory: %v", err)
}
dbPath := filepath.Join(dataDir, "printspy.db")
database, err := db.Open(dbPath)
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer database.Close()
if v, _ := database.GetSetting("debug_logging"); v == "1" {
logging.SetDebug(true)
log.Print("debug logging enabled")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Housekeeping before anything can touch these: jobs a restart left in
// 'dispatching', staging dirs/cache rows whose owner was deleted.
if n, err := database.RecoverInterruptedIngestJobs(); err == nil && n > 0 {
log.Printf("marked %d interrupted ingest job(s) as failed - retry from the dashboard", n)
}
ingest.SweepOrphans(dataDir, database)
if n, err := database.PruneOrphanedFileMeta(); err == nil && n > 0 {
log.Printf("pruned %d orphaned file metadata row(s)", n)
}
p := poller.New(database)
if err := p.Start(ctx); err != nil {
log.Fatalf("failed to start poller: %v", err)
}
if err := p.ConfigureMQTT(); err != nil {
log.Printf("mqtt: initial connect failed (will retry via settings save): %v", err)
}
handler := api.New(ctx, database, p)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
ingestHandler := ingest.New(database, dataDir)
ingestHandler.SetDispatchFunc(func(jobID, printerID int64) {
p.Go(func() { handler.AutoDispatchIngestJob(jobID, printerID) })
})
ingestHandler.SetRelayFunc(func(jobID, printerID int64) {
p.Go(func() { p.RelayIngestJob(ctx, jobID, printerID) })
})
ingestHandler.SetBroadcastFunc(p.BroadcastRefresh)
ingestHandler.RegisterRoutes(mux)
mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"version":%q}`, version)
})
webDir := findWebDir()
mux.Handle("/", http.FileServer(http.Dir(webDir)))
addr := fmt.Sprintf(":%d", port)
// No ReadTimeout/WriteTimeout: SSE, webcam streams and large uploads are long-lived.
server := &http.Server{
Addr: addr,
Handler: logRequests(nosniff(handler.RequireAuth(mux))),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
// Request contexts derive from ctx, so cancelling it on shutdown
// ends SSE streams and in-flight upstream calls instead of letting
// Shutdown wait on them.
BaseContext: func(net.Listener) context.Context { return ctx },
}
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutting down...")
cancel()
shutCtx, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
if err := server.Shutdown(shutCtx); err != nil {
log.Printf("graceful shutdown: %v", err)
server.Close()
}
// Nothing may write to the DB after this returns: main closes it next.
p.Wait()
}()
log.Printf("PrintSpy starting on http://0.0.0.0%s", addr)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
<-shutdownDone
}
// logRequests logs every request at debug level (method, path, status,
// duration) - a no-op cost when debug logging is off (slog drops it before
// formatting), the cheapest way to get visibility into "pretty much
// everything" hitting the API without threading logging through every
// handler individually.
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
slog.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "duration", time.Since(start))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
// Flush forwards to the underlying ResponseWriter's Flusher, if it has one.
// Without this, wrapping breaks SSE - handleSSE type-asserts w.(http.Flusher)
// and, finding a *statusWriter that doesn't implement it, bails out with
// "streaming not supported" on every connection attempt. The dashboard's
// printer list is populated from the SSE init event, not a REST fetch on
// page load, so this alone made every printer disappear from the UI - the
// data was never touched, the live connection just never came up.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func nosniff(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
func findWebDir() string {
candidates := []string{
"web",
"/usr/local/share/printspy/web",
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
log.Println("warning: web directory not found, static files will not be served")
return "web"
}