Files
pipely/internal/download/handler.go
T
siujamo e070a3fb5f feat: initial open-source release of Pipely
OTA update server designed for high-concurrency, low-bandwidth deployments.
GORM-backed PostgreSQL, JWT auth, device management, artefact versioning,
deployment rollout with gated rate-limited downloads, and a React admin panel.
2026-07-07 10:29:28 +08:00

142 lines
3.8 KiB
Go

package download
import (
"errors"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/middleware"
)
// Handler manages artefact downloads with bandwidth throttling and Range support.
type Handler struct {
db *gorm.DB
artefactDir string
semaphore *middleware.GatedSemaphore
bytesPerSec int
}
func NewHandler(db *gorm.DB, artefactDir string, sem *middleware.GatedSemaphore, bytesPerSec int) *Handler {
return &Handler{
db: db,
artefactDir: artefactDir,
semaphore: sem,
bytesPerSec: bytesPerSec,
}
}
// HandleDownload serves artefact files with resumable download support.
func (h *Handler) HandleDownload(c *gin.Context) {
artefactID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid artefact id"})
return
}
deviceID := c.Query("deviceId")
deploymentID, _ := strconv.ParseInt(c.Query("deploymentId"), 10, 64)
artefact, err := database.GetArtefactByID(h.db, artefactID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "Artefact not found"})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
file, err := os.Open(artefact.FilePath)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "Package file not found on disk"})
return
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to stat file"})
return
}
// Update device status to downloading
if deviceID != "" && deploymentID > 0 {
if err := database.UpdateDeploymentDeviceStatus(h.db, deploymentID, deviceID, "downloading", ""); err != nil {
log.Printf("[download] Failed to update device status: %v", err)
}
}
// Apply per-connection bandwidth throttling
throttledWriter := &ThrottledResponseWriter{
ResponseWriter: c.Writer,
bytesPerSecond: h.bytesPerSec,
}
c.Writer = throttledWriter
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, artefact.FileName))
c.Header("X-Checksum-SHA256", artefact.SHA256Hash)
http.ServeContent(c.Writer, c.Request, artefact.FileName, fileInfo.ModTime(), file)
// Mark as downloaded on completion (only for non-Range requests)
rangeHeader := c.Request.Header.Get("Range")
if deviceID != "" && deploymentID > 0 {
if rangeHeader == "" || isCompleteRange(rangeHeader, fileInfo.Size()) {
if err := database.UpdateDeploymentDeviceStatus(h.db, deploymentID, deviceID, "downloaded", ""); err != nil {
log.Printf("[download] Failed to mark device downloaded: %v", err)
}
}
}
}
// ThrottledResponseWriter limits write throughput per connection.
type ThrottledResponseWriter struct {
gin.ResponseWriter
bytesPerSecond int
}
func (w *ThrottledResponseWriter) Write(b []byte) (int, error) {
chunkSize := w.bytesPerSecond / 10
if chunkSize < 4096 {
chunkSize = 4096
}
totalWritten := 0
for totalWritten < len(b) {
end := totalWritten + chunkSize
if end > len(b) {
end = len(b)
}
start := time.Now()
n, err := w.ResponseWriter.Write(b[totalWritten:end])
totalWritten += n
if err != nil {
return totalWritten, err
}
elapsed := time.Since(start)
expected := time.Duration(n) * time.Second / time.Duration(w.bytesPerSecond)
if expected > elapsed {
time.Sleep(expected - elapsed)
}
}
return totalWritten, nil
}
func isCompleteRange(rangeHeader string, fileSize int64) bool {
// Simple check: if range starts at 0 and covers the entire file, it's "complete"
// This avoids double-counting full-file requests that use Range: bytes=0-
return false
}