Files
hpc/internal/server/response.go
dailz a114821615 feat(server): add streaming response helpers for file download
Add ParseRange, StreamFile, StreamRange for full and partial content delivery.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 09:22:58 +08:00

143 lines
4.3 KiB
Go

package server
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// APIResponse represents the unified JSON structure for all API responses.
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// OK responds with 200 and success data.
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, APIResponse{Success: true, Data: data})
}
// Created responds with 201 and success data.
func Created(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, APIResponse{Success: true, Data: data})
}
// BadRequest responds with 400 and an error message.
func BadRequest(c *gin.Context, msg string) {
c.JSON(http.StatusBadRequest, APIResponse{Success: false, Error: msg})
}
// NotFound responds with 404 and an error message.
func NotFound(c *gin.Context, msg string) {
c.JSON(http.StatusNotFound, APIResponse{Success: false, Error: msg})
}
// InternalError responds with 500 and an error message.
func InternalError(c *gin.Context, msg string) {
c.JSON(http.StatusInternalServerError, APIResponse{Success: false, Error: msg})
}
// ErrorWithStatus responds with a custom status code and an error message.
func ErrorWithStatus(c *gin.Context, code int, msg string) {
c.JSON(code, APIResponse{Success: false, Error: msg})
}
// ParseRange parses an HTTP Range header (RFC 7233).
// Only single-part ranges are supported: bytes=start-end, bytes=start-, bytes=-suffix.
// Multi-part ranges (bytes=0-100,200-300) return an error.
func ParseRange(rangeHeader string, fileSize int64) (start, end int64, err error) {
if rangeHeader == "" {
return 0, 0, fmt.Errorf("empty range header")
}
if !strings.HasPrefix(rangeHeader, "bytes=") {
return 0, 0, fmt.Errorf("invalid range unit: %s", rangeHeader)
}
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
if strings.Contains(rangeSpec, ",") {
return 0, 0, fmt.Errorf("multi-part ranges are not supported")
}
rangeSpec = strings.TrimSpace(rangeSpec)
parts := strings.Split(rangeSpec, "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range format: %s", rangeSpec)
}
if parts[0] == "" {
suffix, parseErr := strconv.ParseInt(parts[1], 10, 64)
if parseErr != nil {
return 0, 0, fmt.Errorf("invalid suffix range: %s", parts[1])
}
if suffix <= 0 || suffix > fileSize {
return 0, 0, fmt.Errorf("suffix range %d exceeds file size %d", suffix, fileSize)
}
start = fileSize - suffix
end = fileSize - 1
} else if parts[1] == "" {
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid range start: %s", parts[0])
}
if start >= fileSize {
return 0, 0, fmt.Errorf("range start %d exceeds file size %d", start, fileSize)
}
end = fileSize - 1
} else {
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid range start: %s", parts[0])
}
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid range end: %s", parts[1])
}
if start > end {
return 0, 0, fmt.Errorf("range start %d > end %d", start, end)
}
if start >= fileSize {
return 0, 0, fmt.Errorf("range start %d exceeds file size %d", start, fileSize)
}
if end >= fileSize {
end = fileSize - 1
}
}
return start, end, nil
}
// StreamFile sends a full file as an HTTP response with proper headers.
func StreamFile(c *gin.Context, reader io.ReadCloser, filename string, fileSize int64, contentType string) {
defer reader.Close()
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.Header("Content-Type", contentType)
c.Header("Content-Length", strconv.FormatInt(fileSize, 10))
c.Header("Accept-Ranges", "bytes")
c.Status(http.StatusOK)
io.Copy(c.Writer, reader)
}
// StreamRange sends a partial content response (206) for a byte range.
func StreamRange(c *gin.Context, reader io.ReadCloser, start, end, totalSize int64, contentType string) {
defer reader.Close()
contentLength := end - start + 1
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
c.Header("Content-Type", contentType)
c.Header("Content-Length", strconv.FormatInt(contentLength, 10))
c.Header("Accept-Ranges", "bytes")
c.Status(http.StatusPartialContent)
io.Copy(c.Writer, reader)
}