From e3d78497e89d1f4f0c208a9060e6f8d75adf2790 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 28 Dec 2025 19:01:53 +0000 Subject: [PATCH] feat(backend): impl share expiry update --- apps/backend/internal/nullable/time.go | 28 ++++++++++++ apps/backend/internal/sharing/http.go | 57 +++++++++++++++++++++++- apps/backend/internal/sharing/service.go | 39 ++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 apps/backend/internal/nullable/time.go diff --git a/apps/backend/internal/nullable/time.go b/apps/backend/internal/nullable/time.go new file mode 100644 index 0000000..49dd864 --- /dev/null +++ b/apps/backend/internal/nullable/time.go @@ -0,0 +1,28 @@ +package nullable + +import ( + "bytes" + "encoding/json" + "time" +) + +// Time tracks whether a JSON field was set and stores an optional time value. +type Time struct { + Time *time.Time + Set bool +} + +func (nt *Time) UnmarshalJSON(b []byte) error { + nt.Set = true + if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { + nt.Time = nil + return nil + } + + var t time.Time + if err := json.Unmarshal(b, &t); err != nil { + return err + } + nt.Time = &t + return nil +} diff --git a/apps/backend/internal/sharing/http.go b/apps/backend/internal/sharing/http.go index 08cbafc..3eb48b4 100644 --- a/apps/backend/internal/sharing/http.go +++ b/apps/backend/internal/sharing/http.go @@ -6,6 +6,7 @@ import ( "github.com/get-drexa/drexa/internal/account" "github.com/get-drexa/drexa/internal/httperr" + "github.com/get-drexa/drexa/internal/nullable" "github.com/get-drexa/drexa/internal/reqctx" "github.com/get-drexa/drexa/internal/user" "github.com/get-drexa/drexa/internal/virtualfs" @@ -31,6 +32,13 @@ type createShareRequest struct { ExpiresAt *time.Time `json:"expiresAt" example:"2025-01-15T00:00:00Z"` } +// patchShareRequest represents a request to update a share link +// @Description Request to update a share link. Omit expiresAt to keep the current value. Use null to remove the expiry. +type patchShareRequest struct { + // Optional expiration time for the share (ISO 8601), null clears it. + ExpiresAt nullable.Time `json:"expiresAt" example:"2025-01-15T00:00:00Z"` +} + func NewHTTPHandler(sharingService *Service, accountService *account.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler) *HTTPHandler { return &HTTPHandler{ sharingService: sharingService, @@ -48,8 +56,9 @@ func (h *HTTPHandler) RegisterShareConsumeRoutes(r fiber.Router) *virtualfs.Scop func (h *HTTPHandler) RegisterShareManagementRoutes(api *account.ScopedRouter) { g := api.Group("/shares") - g.Get("/:shareID", h.getShare) g.Post("/", h.createShare) + g.Get("/:shareID", h.getShare) + g.Patch("/:shareID", h.updateShare) g.Delete("/:shareID", h.deleteShare) } @@ -216,6 +225,52 @@ func (h *HTTPHandler) createShare(c *fiber.Ctx) error { return c.JSON(share) } +func (h *HTTPHandler) updateShare(c *fiber.Ctx) error { + shareID := c.Params("shareID") + + share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID) + if err != nil { + if errors.Is(err, ErrShareNotFound) { + return c.SendStatus(fiber.StatusNotFound) + } + return httperr.Internal(err) + } + + var req patchShareRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid request", + }) + } + + tx, err := h.db.BeginTx(c.Context(), nil) + if err != nil { + return httperr.Internal(err) + } + defer tx.Rollback() + + opts := UpdateShareOptions{} + if req.ExpiresAt.Set { + if req.ExpiresAt.Time == nil { + opts.ExpiresAt = &time.Time{} + } else { + opts.ExpiresAt = req.ExpiresAt.Time + } + } + + err = h.sharingService.UpdateShare(c.Context(), tx, share, opts) + if err != nil { + return httperr.Internal(err) + } + + err = tx.Commit() + if err != nil { + return httperr.Internal(err) + } + + return c.JSON(share) +} + // deleteShare deletes a share link // @Summary Delete share // @Description Delete a share link, revoking access for all users diff --git a/apps/backend/internal/sharing/service.go b/apps/backend/internal/sharing/service.go index 919e873..f140873 100644 --- a/apps/backend/internal/sharing/service.go +++ b/apps/backend/internal/sharing/service.go @@ -34,6 +34,11 @@ type ListSharesOptions struct { IncludesExpired bool } +type UpdateShareOptions struct { + // ExpiresAt sets the expiration time for the share. If nil, the expiration time is not changed. If it is a zero time, the expiration time is cleared. + ExpiresAt *time.Time +} + var ( ErrShareNotFound = errors.New("share not found") ErrShareExpired = errors.New("share expired") @@ -317,3 +322,37 @@ func (s *Service) generatePublicID() (string, error) { n := binary.BigEndian.Uint64(b[:]) return s.sqid.Encode([]uint64{n}) } + +func (s *Service) UpdateShare(ctx context.Context, db bun.IDB, share *Share, opts UpdateShareOptions) error { + now := time.Now() + + cols := make([]string, 0, 2) + + if opts.ExpiresAt != nil { + newExpiresAt := opts.ExpiresAt + if opts.ExpiresAt.IsZero() { + newExpiresAt = nil + } + if !timePtrEqual(share.ExpiresAt, newExpiresAt) { + share.ExpiresAt = newExpiresAt + share.UpdatedAt = now + cols = append(cols, "expires_at", "updated_at") + } + } + + if len(cols) > 0 { + _, err := db.NewUpdate().Model(share).Column(cols...).WherePK().Exec(ctx) + if err != nil { + return err + } + } + + return nil +} + +func timePtrEqual(a, b *time.Time) bool { + if a == nil || b == nil { + return a == b + } + return a.Equal(*b) +}