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 }