package handler import ( "encoding/json" "strings" "time" "github.com/gofiber/fiber/v3" "github.com/peterqiu0516/sub-store/internal/middleware" "github.com/peterqiu0516/sub-store/internal/model" ) // --- Share (download grant) handlers --- func (d *Deps) HandleListShares(c fiber.Ctx) error { grants, err := d.GrantRepo.List() if err != nil { return failed(c, "Failed to list shares", 500) } return success(c, grants) } func (d *Deps) HandleCreateShare(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } resourceType := getStringValue(input["resourceType"]) if resourceType != "source" && resourceType != "collection" { return failed(c, "Share resourceType and resourceId are required", 400) } resourceId := getStringValue(input["resourceId"]) if resourceId == "" { return failed(c, "Share resourceType and resourceId are required", 400) } // Check resource exists if resourceType == "source" { s, _ := d.SourceRepo.Get(resourceId) if s == nil { return failed(c, "Share resource does not exist", 404) } } else { col, _ := d.CollectionRepo.Get(resourceId) if col == nil { return failed(c, "Share resource does not exist", 404) } } target := "" if input["target"] != nil { target = model.NormalizeTargetAlias(input["target"]) if input["target"] != "" && target == "" { return failed(c, "Unsupported target", 400) } } var expiresAt *int64 if v, ok := input["expiresAt"]; ok && v != nil { if n, ok := v.(float64); ok && n > 0 { e := int64(n) expiresAt = &e } } else if v, ok := input["expiresIn"]; ok && v != nil { if n, ok := v.(float64); ok && n > 0 { e := time.Now().UnixMilli() + int64(n)*1000 expiresAt = &e } } grant, token, err := d.GrantRepo.Create(resourceType, resourceId, target, expiresAt) if err != nil { return failed(c, "Failed to create share", 500) } // Build download URL path := "/download/" + resourceType + "/" + resourceId if target != "" { path += "/" + target } url := getPublicBaseUrl(c) + path + "?token=" + token return success(c, fiber.Map{ "id": grant.ID, "resourceType": grant.ResourceType, "resourceId": grant.ResourceId, "target": grant.Target, "expiresAt": grant.ExpiresAt, "enabled": grant.Enabled, "createdAt": grant.CreatedAt, "updatedAt": grant.UpdatedAt, "token": token, "url": url, }) } func (d *Deps) HandleUpdateShare(c fiber.Ctx) error { id := c.Params("id") var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } var enabled *bool if v, ok := input["enabled"].(bool); ok { enabled = &v } var expiresAt *int64 if v, ok := input["expiresAt"]; ok { if v == nil { expiresAt = new(int64) // 0 means nil } else if n, ok := v.(float64); ok { e := int64(n) expiresAt = &e } } updated, err := d.GrantRepo.Update(id, enabled, expiresAt) if err != nil || updated == nil { return failed(c, "Share not found", 404) } return success(c, updated) } func (d *Deps) HandleDeleteShare(c fiber.Ctx) error { id := c.Params("id") snapshot, err := d.GrantRepo.GetSnapshot(id) if err != nil || snapshot == nil { return failed(c, "Share not found", 404) } _, err = d.RecycleRepo.ArchiveAndDelete("share", id, snapshot, "DELETE FROM download_grants WHERE id = ?", id) if err != nil { return failed(c, "Failed to delete share", 500) } return success(c, fiber.Map{"deleted": true}) } // --- Recycle bin handlers --- func (d *Deps) HandleListRecycleBin(c fiber.Ctx) error { entries, err := d.RecycleRepo.List() if err != nil { return failed(c, "Failed to list recycle bin", 500) } return success(c, entries) } func (d *Deps) HandleDeleteRecycleBinEntry(c fiber.Ctx) error { id := c.Params("id") entry, err := d.RecycleRepo.Get(id) if err != nil || entry == nil { return failed(c, "Recycle entry not found", 404) } if err := d.RecycleRepo.Delete(id); err != nil { return failed(c, "Failed to delete", 500) } return success(c, fiber.Map{"deleted": true}) } func (d *Deps) HandleRestoreRecycleBinEntry(c fiber.Ctx) error { id := c.Params("id") entry, err := d.RecycleRepo.Get(id) if err != nil || entry == nil { return failed(c, "Recycle entry not found", 404) } resourceType := getStringValue(entry["resourceType"]) resourceId := getStringValue(entry["resourceId"]) snapshot := getMapValue(entry["snapshot"]) switch resourceType { case "source": existing, _ := d.SourceRepo.Get(resourceId) if existing != nil { return failed(c, "Source id already exists", 409) } d.SourceRepo.Upsert(mapToSourceRecord(snapshot)) case "collection": existing, _ := d.CollectionRepo.Get(resourceId) if existing != nil { return failed(c, "Collection id already exists", 409) } d.CollectionRepo.Upsert(mapToCollectionRecord(snapshot)) case "template": existing, _ := d.TemplateRepo.Get(resourceId) if existing != nil { return failed(c, "Template id already exists", 409) } d.TemplateRepo.Upsert(mapToTemplateRecord(snapshot)) case "share": existing, _ := d.GrantRepo.Get(resourceId) if existing != nil { return failed(c, "Share id already exists", 409) } // review-resolution #38: restore tokenHash d.GrantRepo.RestoreFromSnapshot(snapshot) } d.RecycleRepo.Delete(id) return success(c, fiber.Map{ "restored": true, "resourceType": resourceType, "resourceId": resourceId, }) } // getPublicBaseUrl returns the public base URL for download links. // Per review-resolution #11: PUBLIC_DOWNLOAD_HOSTS config, fallback to request origin. func getPublicBaseUrl(c fiber.Ctx) string { // This is set by the handler using deps config publicHosts := c.Locals("publicDownloadHosts") if hosts, ok := publicHosts.(string); ok && hosts != "" { parts := strings.Split(hosts, ",") for _, p := range parts { p = strings.TrimSpace(p) if p != "" { return "https://" + p } } } // Use X-Forwarded-Proto or default to http proto := c.Get("X-Forwarded-Proto") if proto == "" { proto = "http" } return proto + "://" + c.Hostname() } // SetSafeResponseHeader wraps middleware.SetSafeResponseHeader for handler use. var SetSafeResponseHeader = middleware.SetSafeResponseHeader