Files
pad/internal/server/handlers_attachments_transform.go
T
xarmian 335762c2bf feat(attachments): storage usage API + effective-limit computation (TASK-881) (#302)
* feat(attachments): storage usage API + effective-limit computation (TASK-881)

Adds GET /api/v1/workspaces/{ws}/storage/usage returning
{used_bytes, limit_bytes, plan, override_active}. Resolves the effective
limit through the existing three-tier chain (per-user override → platform
setting → hardcoded plan default) and surfaces the override flag for the
upcoming Settings → Storage and admin user-detail UIs.

Implementation:
- store.WorkspaceStorageInfo consolidates SUM(size_bytes) + owner-plan
  resolution in one call; WorkspaceStorageLimit is now a thin wrapper so
  the upload-time quota check and the API path stay consistent.
- Server.storageInfoCache is a 30s TTL memoizer to absorb repeated
  Settings → Storage page loads. Invalidation hooks fire on upload,
  thumbnail derivation, and transform — the ~30s eventual-consistency
  window is bounded by TTL only when invalidation isn't reachable.
- Defensive copy on cache read so a caller mutating the returned struct
  can't poison subsequent reads.
- New CLI command `pad workspace storage` prints "X used of Y (Z%)" with
  IEC units (humanBytes helper) and surfaces the override flag.
- TS api.attachments.storageUsage() + WorkspaceStorageInfo type ready
  for TASK-882's Settings → Storage page consumer.

Tests:
- Store-level: no-owner fallback, free-plan resolution chain, override
  flip, pro-plan override-active visibility, soft-delete exclusion.
- Server-level: empty-workspace happy path, two uploads with cache
  invalidation between, dedicated cache TTL/invalidate/copy-safety test.

Parent: PLAN-866.

* fix(attachments): gate storage usage on viewer+ per Codex review (round 1)

Codex correctly flagged that the storage/usage handler relied solely on
RequireWorkspaceAccess, which admits item-grant guests with
workspaceRole=="guest". Workspace-wide quota numbers (used_bytes, plan,
override status) shouldn't surface to guests — every other workspace-
level read handler uses requireMinRole("viewer") for exactly this case.

Adds the explicit gate + a regression test that calls the handler with
a guest-role context and asserts 403.
2026-04-29 17:05:19 -04:00

293 lines
10 KiB
Go

package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"image"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/attachments"
"github.com/PerpetualSoftware/pad/internal/models"
)
// transformRequest is the body shape for POST /transform. Operation
// is the discriminator; the per-operation params live in their own
// fields so callers don't have to wrap them in a generic args map.
//
// Adding a new operation = adding a new case in the dispatch below
// + a new param field here. Keeps the wire format tight and the
// handler boring.
type transformRequest struct {
Operation string `json:"operation"`
// rotate
Degrees int `json:"degrees,omitempty"`
// crop (TASK-880 will populate this; defined here so the wire
// format is stable across the two PRs).
Rect *transformRect `json:"rect,omitempty"`
}
// transformRect is the crop rectangle in original-image pixel space.
// Origin top-left, x/y/w/h are integers — preview-pixel-to-original
// conversion is the editor's responsibility, so server-side math
// stays in canonical coordinates.
type transformRect struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
// transformResponse is the success payload — same shape as the
// upload response so the editor can swap the node's UUID and any
// width/height attrs in one go without a follow-up GET.
type transformResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
Filename string `json:"filename"`
}
// handleTransformAttachment implements POST
// /api/v1/workspaces/{slug}/attachments/{attachmentID}/transform.
//
// Phase 1 supports two operations: "rotate" (TASK-879) and "crop"
// (TASK-880). Both follow the same flow:
//
// 1. Auth: editor+ on the workspace.
// 2. Load the parent attachment row (cross-workspace = 404).
// 3. Reject if no image processor is wired (libvips build that
// hasn't shipped Phase 2 yet, or a self-host that opted out).
// 4. Decode the original — defends against unsupported MIMEs and
// oversized inputs via the processor's Decode rules.
// 5. Apply the operation. Each op validates its own params.
// 6. Encode in the policy format (PNG → PNG to preserve alpha,
// everything else → JPEG q=85). Same policy as thumbnails so
// transformed and thumbnail blobs deduplicate cleanly.
// 7. Hash + Put through the storage backend (content-addressed,
// so identical transforms collapse onto the same blob).
// 8. Insert a NEW attachments row owned by the same workspace /
// uploaded_by / item as the parent. The original is left in
// place — orphan GC reclaims it past the grace period
// (TASK-886) once the editor swaps its reference.
// 9. Return the new row's ID/URL/dimensions so the editor can
// update its node attrs without a follow-up GET.
func (s *Server) handleTransformAttachment(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
if s.attachments == nil {
writeError(w, http.StatusServiceUnavailable, "attachments_disabled",
"Attachment storage is not configured on this server")
return
}
if s.imageProcessor == nil {
writeError(w, http.StatusServiceUnavailable, "image_processor_disabled",
"Image transformation is not available on this build (the libvips backend has not shipped yet)")
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
id := chi.URLParam(r, "attachmentID")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Missing attachment id")
return
}
parent, err := s.store.GetAttachment(id)
if err != nil {
writeInternalError(w, err)
return
}
// 404 — same cross-workspace defense as the GET handler. Leaking
// 403 vs 404 here would let a member of workspace B enumerate
// attachment IDs in workspace A.
if parent == nil || parent.WorkspaceID != workspaceID || parent.DeletedAt != nil {
writeError(w, http.StatusNotFound, "not_found", "Attachment not found")
return
}
var req transformRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body")
return
}
// Open the original blob through the registry. Defer Close so
// every error path below releases the file handle / network
// connection.
srcStore, err := s.attachments.Resolve(parent.StorageKey)
if err != nil {
writeInternalError(w, fmt.Errorf("resolve source backend: %w", err))
return
}
body, err := srcStore.Get(r.Context(), parent.StorageKey)
if err != nil {
writeInternalError(w, fmt.Errorf("read source blob: %w", err))
return
}
defer body.Close()
srcImg, srcFormat, err := s.imageProcessor.Decode(body)
if err != nil {
switch {
case errors.Is(err, attachments.ErrUnsupportedFormat):
// Per spec: editor surfaces this as an inline tooltip.
// 415 is the canonical "media type not supported" code.
writeError(w, http.StatusUnsupportedMediaType, "unsupported_format",
"Image transformation isn't available for "+parent.MimeType+" on this build")
case errors.Is(err, attachments.ErrImageTooLarge):
writeError(w, http.StatusRequestEntityTooLarge, "image_too_large",
"Image dimensions exceed the processor's safe-decode limit")
default:
writeInternalError(w, fmt.Errorf("decode source: %w", err))
}
return
}
transformed, opErr := applyImageTransform(s.imageProcessor, srcImg, &req)
if opErr != nil {
writeError(w, http.StatusBadRequest, "bad_transform", opErr.Error())
return
}
outFormat := attachments.ThumbnailFormat(srcFormat)
var buf bytes.Buffer
if err := s.imageProcessor.Encode(transformed, outFormat, &buf); err != nil {
writeInternalError(w, fmt.Errorf("encode transformed image: %w", err))
return
}
hash := sha256Hex(buf.Bytes())
dstStore, err := s.attachments.Resolve(attachments.FSPrefix + ":" + hash)
if err != nil {
writeInternalError(w, fmt.Errorf("resolve destination backend: %w", err))
return
}
storageKey, err := dstStore.Put(r.Context(), hash, attachments.ThumbnailMime(outFormat), bytes.NewReader(buf.Bytes()))
if err != nil {
writeInternalError(w, fmt.Errorf("put transformed blob: %w", err))
return
}
bounds := transformed.Bounds()
tw := bounds.Dx()
th := bounds.Dy()
// Inherit attribution + item linkage from the parent. The new
// row is a peer (NOT a derived/variant row — that's only for
// thumbnails) so ParentID and Variant stay nil; the editor
// swaps its reference and the original ages into orphan GC.
//
// UploadedBy inherits from the parent — same policy as the
// thumbnail pipeline. A user who rotates someone else's upload
// shouldn't inadvertently take ownership of the resulting blob;
// audit attribution stays anchored to whoever first put the
// bytes into the workspace. (The transform itself is auditable
// at the request layer if/when we add a transform-events log.)
row := &models.Attachment{
WorkspaceID: parent.WorkspaceID,
ItemID: parent.ItemID,
UploadedBy: parent.UploadedBy,
StorageKey: storageKey,
ContentHash: hash,
MimeType: attachments.ThumbnailMime(outFormat),
SizeBytes: int64(buf.Len()),
Filename: transformedFilename(parent.Filename, req.Operation, outFormat),
Width: &tw,
Height: &th,
}
if err := s.store.CreateAttachment(row); err != nil {
writeInternalError(w, fmt.Errorf("create transformed row: %w", err))
return
}
// Drop the storage-usage cache so the Settings → Storage UI sees
// the new attachment bytes on the next read. Same rationale as
// the upload path.
s.storageInfoCache.invalidate(workspaceID)
// Quota tracking — observational only in Phase 1, same as upload.
s.goAsync(func() { s.maybeWarnStorageQuota(workspaceID) })
writeJSON(w, http.StatusCreated, transformResponse{
ID: row.ID,
URL: attachmentURL(chi.URLParam(r, "slug"), row.ID),
Mime: row.MimeType,
Size: row.SizeBytes,
Width: row.Width,
Height: row.Height,
Filename: row.Filename,
})
}
// applyImageTransform dispatches on the operation discriminator.
// Each branch validates its own params and returns a clear error
// — the handler turns those into 400 Bad Request without leaking
// internal detail. New ops slot in here.
func applyImageTransform(p attachments.Processor, src image.Image, req *transformRequest) (image.Image, error) {
switch req.Operation {
case "rotate":
// Validate degrees up-front so we can return a clean 400
// instead of leaking the processor's "only multiples of 90"
// internal-error message. The set is intentionally narrow:
// 90 / 180 / 270 are pixel-exact reorders (no resampling),
// and 0 is a no-op the editor shouldn't be sending.
switch req.Degrees {
case 90, 180, 270:
return p.Rotate(src, req.Degrees)
default:
return nil, fmt.Errorf("rotate: degrees must be 90, 180, or 270 (got %d)", req.Degrees)
}
case "crop":
if req.Rect == nil {
return nil, fmt.Errorf("crop: rect is required")
}
if req.Rect.W <= 0 || req.Rect.H <= 0 {
return nil, fmt.Errorf("crop: rect width/height must be positive")
}
if req.Rect.X < 0 || req.Rect.Y < 0 {
return nil, fmt.Errorf("crop: rect x/y must be non-negative")
}
// Processor.Crop intersects with the image bounds itself, so
// we don't need to clip here. The processor returns an error
// only for empty intersections (rect entirely outside the
// image) — bubble that up as a 400.
return p.Crop(src, image.Rect(req.Rect.X, req.Rect.Y, req.Rect.X+req.Rect.W, req.Rect.Y+req.Rect.H))
default:
return nil, fmt.Errorf("operation must be one of: rotate, crop (got %q)", req.Operation)
}
}
// transformedFilename builds the synthetic filename for the derived
// row. We append the operation tag (e.g. ".rotated", ".cropped")
// before the extension so a user downloading the file directly sees
// what happened to it. Matches the shape thumbnailFilename emits.
func transformedFilename(parent, op, format string) string {
ext := attachments.ThumbnailExt(format)
base := parent
// Strip any existing extension so we don't end up with
// "shot.png.rotated.png" round-trip cruft.
for i := len(base) - 1; i >= 0; i-- {
if base[i] == '.' {
base = base[:i]
break
}
}
if base == "" {
base = "attachment"
}
return base + "." + op + ext
}