homelabby/internal/api/handlers/label.go
Mikkel Georgsen 9f57cbdf6c feat(04-03): LabelHandler, USBEventsHandler, router wiring, main.go USB+printer init
- LabelHandler: POST /api/labels/:deviceID/print with 1/s rate limit (T-04-09)
- USBEventsHandler: GET /api/usb/events SSE stream, exits on context cancel (T-04-11)
- router.go: two new parameters + routes wired
- main.go: USB Manager started with ctx, MockDriver connected, handlers passed to router
2026-04-10 06:52:52 +00:00

125 lines
3.7 KiB
Go

package handlers
import (
"context"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
"git.georgsen.dk/hwlab/internal/labels"
"git.georgsen.dk/hwlab/internal/netbox"
"git.georgsen.dk/hwlab/internal/printer"
)
// LabelNetBoxClient is the narrow interface LabelHandler needs from NetBox.
// *netbox.Client satisfies this interface.
type LabelNetBoxClient interface {
GetDevice(ctx context.Context, id int) (*netbox.Device, error)
}
// LabelPrinter is the narrow interface over printer.PrinterDriver that the
// label handler requires. Allows mock injection in tests.
type LabelPrinter interface {
Print(bitmap []byte, width, height int) error
}
// LabelHandler handles POST /api/labels/:deviceID/print.
// T-04-09 mitigation: print calls are rate-limited to 1/second per instance
// to prevent runaway label waste.
type LabelHandler struct {
nb LabelNetBoxClient
printer LabelPrinter
// rate limiting (T-04-09 — DoS / label waste mitigation)
mu sync.Mutex
lastPrintTime time.Time
printCooldown time.Duration
}
// NewLabelHandler constructs a LabelHandler with a 1-second print cooldown.
func NewLabelHandler(nb LabelNetBoxClient, p LabelPrinter) *LabelHandler {
return &LabelHandler{
nb: nb,
printer: p,
printCooldown: time.Second,
}
}
// PrintLabel handles POST /api/labels/:deviceID/print.
//
// Flow:
// 1. Parse deviceID path param
// 2. Rate-limit check (1/second — T-04-09)
// 3. Fetch device from NetBox
// 4. Render label via labels.RenderCable or labels.RenderStandard
// 5. Convert to 1-bit bitmap via printer.ImageToRawBitmap
// 6. Send to printer
// 7. Return 200 {"status":"printed"}
func (h *LabelHandler) PrintLabel(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "deviceID")
id, err := strconv.Atoi(idStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
return
}
// T-04-09: rate limit — reject if last print was less than 1 second ago.
h.mu.Lock()
now := time.Now()
if !h.lastPrintTime.IsZero() && now.Sub(h.lastPrintTime) < h.printCooldown {
h.mu.Unlock()
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "print rate limit: wait 1 second between prints"})
return
}
h.lastPrintTime = now
h.mu.Unlock()
device, err := h.nb.GetDevice(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return
}
// Choose renderer based on device type.
var bitmap []byte
var bWidth, bHeight int
if labels.IsCableDevice(*device) {
d := labels.CableLabelData{
HWID: device.CustomFields.HWID,
Name: device.Name,
TestDate: device.CustomFields.TestDate,
// USBVersion/MaxSpeedGbps/MaxWatts: parsed from TestData in Phase 5.
// Default values allow label to render without error before that.
USBVersion: "Unknown",
}
labelImg, err := labels.RenderCable(d)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()})
return
}
bitmap, bWidth, bHeight = printer.ImageToRawBitmap(labelImg)
} else {
d := labels.LabelData{
HWID: device.CustomFields.HWID,
Name: device.Name,
SpecLine: device.CustomFields.AINotes,
}
labelImg, err := labels.RenderStandard(d)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed: " + err.Error()})
return
}
bitmap, bWidth, bHeight = printer.ImageToRawBitmap(labelImg)
}
if err := h.printer.Print(bitmap, bWidth, bHeight); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "print failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "printed"})
}