From 39824e45d936e812b31fa057b6574bbe7e4ddd15 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 29 Nov 2025 17:25:11 +0000 Subject: [PATCH] feat: impl upload api endpoints --- apps/backend/internal/drexa/server.go | 20 +++- apps/backend/internal/upload/http.go | 104 ++++++++++++++++++ apps/backend/internal/upload/service.go | 31 ++---- apps/backend/internal/upload/upload.go | 18 +++ .../internal/virtualfs/key_resolver.go | 4 + 5 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 apps/backend/internal/upload/http.go create mode 100644 apps/backend/internal/upload/upload.go diff --git a/apps/backend/internal/drexa/server.go b/apps/backend/internal/drexa/server.go index 99c41a1..2eb59c0 100644 --- a/apps/backend/internal/drexa/server.go +++ b/apps/backend/internal/drexa/server.go @@ -8,8 +8,11 @@ import ( "strconv" "github.com/get-drexa/drexa/internal/auth" + "github.com/get-drexa/drexa/internal/blob" "github.com/get-drexa/drexa/internal/database" + "github.com/get-drexa/drexa/internal/upload" "github.com/get-drexa/drexa/internal/user" + "github.com/get-drexa/drexa/internal/virtualfs" "github.com/gofiber/fiber/v2" ) @@ -21,21 +24,34 @@ type ServerConfig struct { JWTSecretKey []byte } -func NewServer(c ServerConfig) *fiber.App { +func NewServer(c ServerConfig) (*fiber.App, error) { app := fiber.New() db := database.NewFromPostgres(c.PostgresURL) + // TODO: load correct blob store and resolver from config + blobStore := blob.NewFSStore(blob.FSStoreConfig{ + Root: os.Getenv("BLOB_ROOT"), + UploadURL: os.Getenv("BLOB_UPLOAD_URL"), + }) + keyResolver := virtualfs.NewFlatKeyResolver() + vfs, err := virtualfs.NewVirtualFS(db, blobStore, keyResolver) + if err != nil { + return nil, fmt.Errorf("failed to create virtual file system: %w", err) + } + userService := user.NewService(db) authService := auth.NewService(db, userService, auth.TokenConfig{ Issuer: c.JWTIssuer, Audience: c.JWTAudience, SecretKey: c.JWTSecretKey, }) + uploadService := upload.NewService(vfs, blobStore) api := app.Group("/api") auth.RegisterAPIRoutes(api, authService) + upload.RegisterAPIRoutes(api, uploadService) - return app + return app, nil } // ServerConfigFromEnv creates a ServerConfig from environment variables. diff --git a/apps/backend/internal/upload/http.go b/apps/backend/internal/upload/http.go new file mode 100644 index 0000000..57d038c --- /dev/null +++ b/apps/backend/internal/upload/http.go @@ -0,0 +1,104 @@ +package upload + +import ( + "errors" + + "github.com/get-drexa/drexa/internal/auth" + "github.com/gofiber/fiber/v2" +) + +const uploadServiceKey = "uploadService" + +type createUploadRequest struct { + ParentID string `json:"parentId"` + Name string `json:"name"` +} + +type updateUploadRequest struct { + Status Status `json:"status"` +} + +func RegisterAPIRoutes(api fiber.Router, s *Service) { + upload := api.Group("/uploads", func(c *fiber.Ctx) error { + c.Locals(uploadServiceKey, s) + return c.Next() + }) + + upload.Post("/", createUpload) + upload.Put("/:uploadID/content", receiveUpload) + upload.Patch("/:uploadID", updateUpload) +} + +func mustUploadService(c *fiber.Ctx) *Service { + return c.Locals(uploadServiceKey).(*Service) +} + +func createUpload(c *fiber.Ctx) error { + u, err := auth.AuthenticatedUser(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + s := mustUploadService(c) + + req := new(createUploadRequest) + if err := c.BodyParser(req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + } + + upload, err := s.CreateUpload(c.Context(), u.ID, CreateUploadOptions{ + ParentID: req.ParentID, + Name: req.Name, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"}) + } + + return c.JSON(upload) +} + +func receiveUpload(c *fiber.Ctx) error { + u, err := auth.AuthenticatedUser(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + s := mustUploadService(c) + + uploadID := c.Params("uploadID") + + err = s.ReceiveUpload(c.Context(), u.ID, uploadID, c.Request().BodyStream()) + defer c.Request().CloseBodyStream() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +func updateUpload(c *fiber.Ctx) error { + u, err := auth.AuthenticatedUser(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + s := mustUploadService(c) + + req := new(updateUploadRequest) + if err := c.BodyParser(req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + } + + if req.Status == StatusCompleted { + upload, err := s.CompleteUpload(c.Context(), u.ID, c.Params("uploadID")) + if err != nil { + if errors.Is(err, ErrNotFound) { + return c.SendStatus(fiber.StatusNotFound) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"}) + } + return c.JSON(upload) + } + + return c.SendStatus(fiber.StatusBadRequest) +} diff --git a/apps/backend/internal/upload/service.go b/apps/backend/internal/upload/service.go index 806f740..a01af1b 100644 --- a/apps/backend/internal/upload/service.go +++ b/apps/backend/internal/upload/service.go @@ -33,17 +33,6 @@ type CreateUploadOptions struct { Name string } -type Upload struct { - ID string - TargetNode *virtualfs.Node - UploadURL string -} - -type CreateUploadResult struct { - UploadID string - UploadURL string -} - func (s *Service) CreateUpload(ctx context.Context, userID uuid.UUID, opts CreateUploadOptions) (*Upload, error) { parentNode, err := s.vfs.FindNodeByPublicID(ctx, userID, opts.ParentID) if err != nil { @@ -77,6 +66,7 @@ func (s *Service) CreateUpload(ctx context.Context, userID uuid.UUID, opts Creat upload := &Upload{ ID: node.PublicID, + Status: StatusPending, TargetNode: node, UploadURL: uploadURL, } @@ -106,36 +96,37 @@ func (s *Service) ReceiveUpload(ctx context.Context, userID uuid.UUID, uploadID return err } - s.pendingUploads.Delete(uploadID) + upload.Status = StatusCompleted return nil } -func (s *Service) CompleteUpload(ctx context.Context, userID uuid.UUID, uploadID string) error { +func (s *Service) CompleteUpload(ctx context.Context, userID uuid.UUID, uploadID string) (*Upload, error) { n, ok := s.pendingUploads.Load(uploadID) if !ok { - return ErrNotFound + return nil, ErrNotFound } upload, ok := n.(*Upload) if !ok { - return ErrNotFound + return nil, ErrNotFound } if upload.TargetNode.UserID != userID { - return ErrNotFound + return nil, ErrNotFound } - if upload.TargetNode.Status == virtualfs.NodeStatusReady { - return nil + if upload.TargetNode.Status == virtualfs.NodeStatusReady && upload.Status == StatusCompleted { + return upload, nil } err := s.vfs.WriteFile(ctx, upload.TargetNode, virtualfs.FileContentFromBlobKey(upload.TargetNode.BlobKey)) if err != nil { - return err + return nil, err } + upload.Status = StatusCompleted s.pendingUploads.Delete(uploadID) - return nil + return upload, nil } diff --git a/apps/backend/internal/upload/upload.go b/apps/backend/internal/upload/upload.go new file mode 100644 index 0000000..6bc9e00 --- /dev/null +++ b/apps/backend/internal/upload/upload.go @@ -0,0 +1,18 @@ +package upload + +import "github.com/get-drexa/drexa/internal/virtualfs" + +type Status string + +const ( + StatusPending Status = "pending" + StatusCompleted Status = "completed" + StatusFailed Status = "failed" +) + +type Upload struct { + ID string `json:"id"` + Status Status `json:"status"` + TargetNode *virtualfs.Node `json:"-"` + UploadURL string `json:"uploadUrl"` +} diff --git a/apps/backend/internal/virtualfs/key_resolver.go b/apps/backend/internal/virtualfs/key_resolver.go index e89b9eb..d2a3967 100644 --- a/apps/backend/internal/virtualfs/key_resolver.go +++ b/apps/backend/internal/virtualfs/key_resolver.go @@ -15,6 +15,10 @@ type BlobKeyResolver interface { type FlatKeyResolver struct{} +func NewFlatKeyResolver() *FlatKeyResolver { + return &FlatKeyResolver{} +} + func (r *FlatKeyResolver) KeyMode() blob.KeyMode { return blob.KeyModeStable }