first commit
This commit is contained in:
593
backend/internal/handler/http.go
Normal file
593
backend/internal/handler/http.go
Normal file
@@ -0,0 +1,593 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filefast/backend/internal/config"
|
||||
"filefast/backend/internal/model"
|
||||
"filefast/backend/internal/service"
|
||||
"filefast/backend/internal/storage"
|
||||
"filefast/backend/internal/store"
|
||||
"filefast/backend/internal/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
Store *store.MemoryStore
|
||||
DeviceService *service.DeviceService
|
||||
RoomService *service.RoomService
|
||||
TransferService *service.TransferService
|
||||
AdminService *service.AdminService
|
||||
MinIOClient *storage.MinIOClient
|
||||
Hub *ws.Hub
|
||||
StorageReady bool
|
||||
RedisReady bool
|
||||
}
|
||||
|
||||
type HTTPHandler struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
func NewHTTPHandler(deps Dependencies) *HTTPHandler {
|
||||
return &HTTPHandler{deps: deps}
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) Router() *gin.Engine {
|
||||
router := gin.Default()
|
||||
|
||||
router.GET("/healthz", h.handleHealth)
|
||||
router.GET("/ws", h.deps.Hub.Handle)
|
||||
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.GET("/runtime/config", h.runtimeConfig)
|
||||
api.POST("/devices/register", h.registerDevice)
|
||||
api.POST("/admin/login", h.adminLogin)
|
||||
}
|
||||
|
||||
device := api.Group("/")
|
||||
device.Use(h.requireDevice())
|
||||
{
|
||||
device.POST("/devices/heartbeat", h.deviceHeartbeat)
|
||||
device.GET("/devices/candidates", h.listCandidates)
|
||||
device.GET("/devices/:id/pending-downloads", h.pendingFallbackDownloads)
|
||||
|
||||
device.POST("/rooms", h.createRoom)
|
||||
device.GET("/rooms/:code", h.getRoom)
|
||||
device.POST("/rooms/join", h.joinRoom)
|
||||
device.POST("/rooms/:code/cancel", h.cancelRoom)
|
||||
|
||||
device.POST("/transfers", h.createTransfer)
|
||||
device.PATCH("/transfers/:id/status", h.updateTransferStatus)
|
||||
device.POST("/transfers/:id/fallback/presign", h.presignFallback)
|
||||
device.PUT("/transfers/:id/fallback/upload", h.uploadFallback)
|
||||
device.GET("/transfers/:id/fallback/download", h.downloadFallback)
|
||||
}
|
||||
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(h.requireAdmin())
|
||||
{
|
||||
admin.GET("/stats", h.adminStats)
|
||||
admin.GET("/config", h.adminConfig)
|
||||
admin.PUT("/config", h.updateAdminConfig)
|
||||
admin.GET("/transfers/recent", h.recentTransfers)
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) handleHealth(c *gin.Context) {
|
||||
status := "ok"
|
||||
if !h.deps.StorageReady || !h.deps.RedisReady {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": status,
|
||||
"minio_enabled": h.deps.MinIOClient != nil && h.deps.MinIOClient.Enabled(),
|
||||
"storage_ready": h.deps.StorageReady,
|
||||
"redis_ready": h.deps.RedisReady,
|
||||
"turn_enabled": len(h.deps.Store.RuntimeConfig().TURNURLs) > 0,
|
||||
"room_ttl_sec": int(h.deps.Config.RoomTTL.Seconds()),
|
||||
"server_time_unix": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) runtimeConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.RuntimeConfig()})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) registerDevice(c *gin.Context) {
|
||||
var input service.RegisterDeviceInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
device, session := h.deps.DeviceService.Register(input, c.Request.UserAgent(), c.GetHeader("X-Device-Token"))
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||||
"id": device.ID,
|
||||
"name": device.Name,
|
||||
"type": device.Type,
|
||||
"user_agent": device.UserAgent,
|
||||
"network_group_key": device.NetworkGroupKey,
|
||||
"public_ip_hash": device.PublicIPHash,
|
||||
"is_online": device.IsOnline,
|
||||
"last_seen_at": device.LastSeenAt,
|
||||
"created_at": device.CreatedAt,
|
||||
"auth_token": session.Token,
|
||||
"auth_expires_at": session.ExpiresAt,
|
||||
}})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deviceHeartbeat(c *gin.Context) {
|
||||
var input struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.ensureAuthenticatedDevice(c, input.DeviceID) {
|
||||
return
|
||||
}
|
||||
|
||||
device, ok := h.deps.DeviceService.Heartbeat(input.DeviceID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": device})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listCandidates(c *gin.Context) {
|
||||
deviceID := c.Query("deviceId")
|
||||
if deviceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId is required"})
|
||||
return
|
||||
}
|
||||
if !h.ensureAuthenticatedDevice(c, deviceID) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": h.deps.DeviceService.ListCandidates(deviceID)})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createRoom(c *gin.Context) {
|
||||
var input struct {
|
||||
CreatorDeviceID string `json:"creator_device_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !h.ensureAuthenticatedDevice(c, input.CreatorDeviceID) {
|
||||
return
|
||||
}
|
||||
|
||||
room, err := h.deps.RoomService.CreateRoom(input.CreatorDeviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": room})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) getRoom(c *gin.Context) {
|
||||
room, ok := h.deps.Store.GetRoom(c.Param("code"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||
return
|
||||
}
|
||||
deviceID := h.authenticatedDeviceID(c)
|
||||
if room.CreatorDeviceID != deviceID && room.JoinerDeviceID != deviceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "room access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": room})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) joinRoom(c *gin.Context) {
|
||||
var input struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
JoinerDeviceID string `json:"joiner_device_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !h.ensureAuthenticatedDevice(c, input.JoinerDeviceID) {
|
||||
return
|
||||
}
|
||||
|
||||
room, err := h.deps.RoomService.JoinRoom(input.Code, input.JoinerDeviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": room})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) cancelRoom(c *gin.Context) {
|
||||
var input struct {
|
||||
RequesterID string `json:"requester_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !h.ensureAuthenticatedDevice(c, input.RequesterID) {
|
||||
return
|
||||
}
|
||||
|
||||
room, err := h.deps.RoomService.CancelRoom(c.Param("code"), input.RequesterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": room})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createTransfer(c *gin.Context) {
|
||||
var input service.CreateTransferInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !h.ensureAuthenticatedDevice(c, input.SenderDeviceID) {
|
||||
return
|
||||
}
|
||||
|
||||
transfer, err := h.deps.TransferService.Create(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": transfer})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateTransferStatus(c *gin.Context) {
|
||||
transfer, ok := h.deps.Store.GetTransfer(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"})
|
||||
return
|
||||
}
|
||||
deviceID := h.authenticatedDeviceID(c)
|
||||
if transfer.SenderDeviceID != deviceID && transfer.ReceiverDeviceID != deviceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var input service.UpdateTransferStatusInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
transfer, err := h.deps.TransferService.UpdateStatus(c.Param("id"), input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": transfer})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) presignFallback(c *gin.Context) {
|
||||
if h.deps.MinIOClient == nil || !h.deps.MinIOClient.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "minio fallback is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
transfer, ok := h.deps.Store.GetTransfer(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"})
|
||||
return
|
||||
}
|
||||
if transfer.SenderDeviceID != h.authenticatedDeviceID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
transfer, object, err := h.deps.TransferService.PrepareFallback(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.ensureFallbackBucket(ctx, transfer.ID); err != nil {
|
||||
h.deps.Logger.Warn("minio ensure bucket failed", "transfer_id", transfer.ID, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
uploadURL, err := h.deps.MinIOClient.PresignUpload(ctx, object.ObjectKey)
|
||||
if err != nil {
|
||||
h.deps.Logger.Warn("minio presign upload failed", "transfer_id", transfer.ID, "object_key", object.ObjectKey, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
downloadURL, err := h.deps.MinIOClient.PresignDownload(ctx, object.ObjectKey)
|
||||
if err != nil {
|
||||
h.deps.Logger.Warn("minio presign download failed", "transfer_id", transfer.ID, "object_key", object.ObjectKey, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"transfer": transfer,
|
||||
"upload_url": uploadURL.String(),
|
||||
"download_url": downloadURL.String(),
|
||||
"download_path": fallbackDownloadPath(transfer.ID),
|
||||
"expires_at": object.ExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) uploadFallback(c *gin.Context) {
|
||||
if h.deps.MinIOClient == nil || !h.deps.MinIOClient.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "minio fallback is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
transfer, ok := h.deps.Store.GetTransfer(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"})
|
||||
return
|
||||
}
|
||||
if transfer.ObjectKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "fallback object is not prepared"})
|
||||
return
|
||||
}
|
||||
if transfer.SenderDeviceID != h.authenticatedDeviceID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := h.ensureFallbackBucket(ctx, transfer.ID); err != nil {
|
||||
h.deps.Logger.Warn("minio ensure bucket failed", "transfer_id", transfer.ID, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
contentLength := c.Request.ContentLength
|
||||
if contentLength <= 0 {
|
||||
contentLength = transfer.SizeBytes
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(c.GetHeader("Content-Type"))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
if err := h.deps.MinIOClient.UploadObject(ctx, transfer.ObjectKey, c.Request.Body, contentLength, contentType); err != nil {
|
||||
h.deps.Logger.Warn("minio upload object failed", "transfer_id", transfer.ID, "object_key", transfer.ObjectKey, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if object, ok := h.deps.Store.GetFallbackObject(transfer.ID); ok {
|
||||
object.SizeBytes = contentLength
|
||||
object.CleanupState = "ready"
|
||||
h.deps.Store.SaveFallbackObject(object)
|
||||
}
|
||||
|
||||
downloadURL, err := h.deps.MinIOClient.PresignDownload(ctx, transfer.ObjectKey)
|
||||
if err != nil {
|
||||
h.deps.Logger.Warn("minio presign download failed after upload", "transfer_id", transfer.ID, "object_key", transfer.ObjectKey, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"download_url": downloadURL.String(),
|
||||
"download_path": fallbackDownloadPath(transfer.ID),
|
||||
"object_key": transfer.ObjectKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) pendingFallbackDownloads(c *gin.Context) {
|
||||
deviceID := strings.TrimSpace(c.Param("id"))
|
||||
if deviceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device id is required"})
|
||||
return
|
||||
}
|
||||
if !h.ensureAuthenticatedDevice(c, deviceID) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": h.deps.Store.ListPendingFallbackDownloads(deviceID, 20),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) downloadFallback(c *gin.Context) {
|
||||
if h.deps.MinIOClient == nil || !h.deps.MinIOClient.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "minio fallback is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
transfer, ok := h.deps.Store.GetTransfer(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "transfer not found"})
|
||||
return
|
||||
}
|
||||
if transfer.ObjectKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "fallback object is not prepared"})
|
||||
return
|
||||
}
|
||||
if transfer.ReceiverDeviceID != h.authenticatedDeviceID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "transfer access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
filename := filepath.Base(transfer.Name)
|
||||
if filename == "." || filename == "" {
|
||||
filename = "download.bin"
|
||||
}
|
||||
|
||||
downloadURL, err := h.deps.MinIOClient.PresignDownloadWithFilename(ctx, transfer.ObjectKey, filename)
|
||||
if err != nil {
|
||||
h.deps.Logger.Warn("minio presign download failed", "transfer_id", transfer.ID, "object_key", transfer.ObjectKey, "error", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, downloadURL.String())
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) adminLogin(c *gin.Context) {
|
||||
var input struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.deps.AdminService.Login(input.Username, input.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": session})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) adminStats(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"stats": h.deps.Store.SnapshotStats(),
|
||||
"minio": h.deps.Store.SnapshotMinIOStorage(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) adminConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.RuntimeConfig()})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateAdminConfig(c *gin.Context) {
|
||||
var input model.RuntimeConfig
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.UpdateRuntimeConfig(input)})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) recentTransfers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": h.deps.Store.ListRecentTransfers(20)})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) requireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer"))
|
||||
if token == "" || !h.deps.AdminService.ValidateToken(token) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) requireDevice() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deviceID := strings.TrimSpace(c.GetHeader("X-Device-ID"))
|
||||
token := strings.TrimSpace(c.GetHeader("X-Device-Token"))
|
||||
if deviceID == "" || token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing device credentials"})
|
||||
return
|
||||
}
|
||||
if !h.deps.DeviceService.ValidateSession(deviceID, token) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid device credentials"})
|
||||
return
|
||||
}
|
||||
c.Set("device_id", deviceID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) authenticatedDeviceID(c *gin.Context) string {
|
||||
value, ok := c.Get("device_id")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
deviceID, _ := value.(string)
|
||||
return deviceID
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) ensureAuthenticatedDevice(c *gin.Context, expected string) bool {
|
||||
if h.authenticatedDeviceID(c) != strings.TrimSpace(expected) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "device access denied"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fallbackDownloadPath(transferID string) string {
|
||||
return "/api/transfers/" + url.PathEscape(transferID) + "/fallback/download"
|
||||
}
|
||||
|
||||
func contentDisposition(filename string) string {
|
||||
escaped := strings.ReplaceAll(filename, `"`, "")
|
||||
return `attachment; filename="` + escaped + `"`
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) ensureFallbackBucket(ctx context.Context, transferID string) error {
|
||||
if h.deps.MinIOClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := h.deps.MinIOClient.EnsureBucket(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some MinIO users can read/write objects in an existing bucket but cannot run
|
||||
// bucket-existence or bucket-creation checks. In that case we keep going.
|
||||
if strings.Contains(strings.ToLower(err.Error()), "access denied") {
|
||||
h.deps.Logger.Info("minio bucket ensure skipped due to limited permissions", "transfer_id", transferID)
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
195
backend/internal/handler/http_test.go
Normal file
195
backend/internal/handler/http_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"filefast/backend/internal/config"
|
||||
"filefast/backend/internal/model"
|
||||
"filefast/backend/internal/service"
|
||||
"filefast/backend/internal/store"
|
||||
"filefast/backend/internal/ws"
|
||||
)
|
||||
|
||||
type registeredDevice struct {
|
||||
ID string `json:"id"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
type transferRecord struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func TestProtectedRoutesRequireDeviceCredentials(t *testing.T) {
|
||||
router, _ := newTestRouter()
|
||||
|
||||
device := registerDevice(t, router, map[string]any{
|
||||
"device_id": "alpha",
|
||||
"name": "Alpha",
|
||||
"type": "desktop",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/devices/candidates?deviceId="+device.ID, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for missing device credentials, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedRoutesRejectMismatchedDeviceIdentity(t *testing.T) {
|
||||
router, _ := newTestRouter()
|
||||
|
||||
alpha := registerDevice(t, router, map[string]any{
|
||||
"device_id": "alpha",
|
||||
"name": "Alpha",
|
||||
"type": "desktop",
|
||||
})
|
||||
bravo := registerDevice(t, router, map[string]any{
|
||||
"device_id": "bravo",
|
||||
"name": "Bravo",
|
||||
"type": "desktop",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/devices/candidates?deviceId="+bravo.ID, nil)
|
||||
req.Header.Set("X-Device-ID", alpha.ID)
|
||||
req.Header.Set("X-Device-Token", alpha.AuthToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for mismatched device identity, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferStatusUpdateRequiresParticipantOwnership(t *testing.T) {
|
||||
router, _ := newTestRouter()
|
||||
|
||||
sender := registerDevice(t, router, map[string]any{
|
||||
"device_id": "sender",
|
||||
"name": "Sender",
|
||||
"type": "desktop",
|
||||
})
|
||||
receiver := registerDevice(t, router, map[string]any{
|
||||
"device_id": "receiver",
|
||||
"name": "Receiver",
|
||||
"type": "desktop",
|
||||
})
|
||||
attacker := registerDevice(t, router, map[string]any{
|
||||
"device_id": "attacker",
|
||||
"name": "Attacker",
|
||||
"type": "desktop",
|
||||
})
|
||||
|
||||
transfer := createTransfer(t, router, sender, map[string]any{
|
||||
"kind": "text",
|
||||
"name": "text-message",
|
||||
"content": "hello",
|
||||
"sender_device_id": sender.ID,
|
||||
"receiver_device_id": receiver.ID,
|
||||
})
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"final_status": "completed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update status body: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/transfers/"+transfer.ID+"/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Device-ID", attacker.ID)
|
||||
req.Header.Set("X-Device-Token", attacker.AuthToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-participant transfer update, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter() (http.Handler, *store.MemoryStore) {
|
||||
memStore := store.NewMemoryStore(model.RuntimeConfig{})
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
deviceService := service.NewDeviceService(memStore, nil, nil)
|
||||
deps := Dependencies{
|
||||
Config: config.Config{},
|
||||
Logger: logger,
|
||||
Store: memStore,
|
||||
DeviceService: deviceService,
|
||||
RoomService: service.NewRoomService(memStore, 0),
|
||||
TransferService: service.NewTransferService(memStore),
|
||||
AdminService: service.NewAdminService(memStore, config.AdminConfig{}, nil, nil),
|
||||
Hub: ws.NewHub(logger, deviceService, nil),
|
||||
StorageReady: true,
|
||||
RedisReady: true,
|
||||
}
|
||||
|
||||
return NewHTTPHandler(deps).Router(), memStore
|
||||
}
|
||||
|
||||
func registerDevice(t *testing.T, router http.Handler, payload map[string]any) registeredDevice {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal register body: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected register 200, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var payloadWrapper struct {
|
||||
Data registeredDevice `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payloadWrapper); err != nil {
|
||||
t.Fatalf("decode register response: %v", err)
|
||||
}
|
||||
if payloadWrapper.Data.ID == "" || payloadWrapper.Data.AuthToken == "" {
|
||||
t.Fatalf("expected device registration to return id and auth token, got %+v", payloadWrapper.Data)
|
||||
}
|
||||
|
||||
return payloadWrapper.Data
|
||||
}
|
||||
|
||||
func createTransfer(t *testing.T, router http.Handler, device registeredDevice, payload map[string]any) transferRecord {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create transfer body: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/transfers", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Device-ID", device.ID)
|
||||
req.Header.Set("X-Device-Token", device.AuthToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected create transfer 200, got %d: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var payloadWrapper struct {
|
||||
Data transferRecord `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payloadWrapper); err != nil {
|
||||
t.Fatalf("decode transfer response: %v", err)
|
||||
}
|
||||
if payloadWrapper.Data.ID == "" {
|
||||
t.Fatalf("expected transfer id in response, got %+v", payloadWrapper.Data)
|
||||
}
|
||||
|
||||
return payloadWrapper.Data
|
||||
}
|
||||
Reference in New Issue
Block a user