Files
pad/internal/store/attachments.go
T
xarmian 380b75e12c fix(attachments): gate transform on item visibility (TASK-2402)
handleTransformAttachment opened with a flat requireMinRole("editor") and
never looked at the attachment's parent item at all. A restricted editor —
one whose collection access excludes that item — could transform an
attachment on an item they cannot see, given only the attachment id: the
handler read the source blob and returned output metadata plus a new row.
The output URL inherits ItemID and is gated by TASK-2401's read gate, so
this was not direct byte exfiltration, but it crossed the same boundary and
leaked processing behaviour and metadata for an invisible item.

The handler now authorizes per-attachment, in the order the read path uses
(PLAN-2391 DR-10): load the row -> workspace identity -> load the parent
with GetItem -> parent workspace identity -> checkItemVisible -> edit
permission -> transform. Every denial goes through writeAttachmentNotFound,
so a missing attachment, a foreign parent, a soft-deleted parent and an
invisible item are byte-identical; a distinguishable code or message would
be an existence oracle. Malformed non-null parents that resolve nowhere are
rejected by the same guard.

Edit permission is requireEditPermission rather than the flat editor role:
an item- or collection-grant editor can already attach to the item
(BUG-1661), so refusing them a rotate on their own upload would be an
inconsistency, not a boundary. Orphan rows keep the flat editor gate and,
matching the DELETE path (PLAN-2382 DR-4), require unrestricted workspace
access — the storage listing hides orphans from restricted members, so the
transform must not confirm one exists.

DR-14's race is closed, not narrowed. The parent check is point-in-time:
item deletion commits in its own transaction, and the blob read, decode,
transform, encode and Put in between are unbounded work, so the item can be
archived mid-flight and the insert then writes a quota-counted live row
against an archived item whose bytes DR-13 refuses to serve. The new
store.CreateAttachmentForLiveItem re-checks the parent under a row lock
inside the insert's own transaction: the row is written against a live item
or not written at all. FOR NO KEY UPDATE, not FOR UPDATE — DeleteItem's
UPDATE touches no key column so the archival still blocks, while the many
tables with a REFERENCES items(id) foreign key (comments, stars, the Yjs
op-log) keep taking FOR KEY SHARE on the parent uncontended. SQLite skips
the clause: _txlock=immediate already serializes writers there.

Tests fail against the pre-fix code: the restricted-editor transform
returns 404 with a body byte-identical to the missing-attachment body, and
the mid-flight test archives the item from inside the processor's Encode —
between the up-front check and the insert — asserting the hook actually ran
so it cannot pass vacuously. The Postgres lock test polls pg_stat_activity
until the statement is registered as lock-blocked rather than sleeping, and
watches the completion channel so a missing lock fails immediately. Both
were mutation-verified.

Recorded, not fixed here: a refused insert leaves a rowless blob on disk,
and the orphan GC is row-driven so nothing reclaims it. Pre-existing on the
upload and thumbnail paths too; filed as BUG-2406 with the dedupe guard a
correct fix needs. The comment claiming GC reclaims a transform's original
was wrong and is corrected — only an orphan original is GC-eligible.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 23:05:26 +00:00

1187 lines
46 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// WorkspaceStorageInfo is the consolidated quota summary for a workspace:
// used bytes (from the attachments table), the effective limit for the
// owner's plan (after override + platform-setting + hardcoded fallback),
// the plan name, and whether the owner has a per-user storage_bytes
// override configured. -1 in LimitBytes means unlimited.
//
// The override_active flag tracks whether the workspace owner's
// PlanOverrides JSON contains a storage_bytes key — independent of
// whether that override actually changes the effective limit. (On
// pro/self-hosted plans the limit is unlimited regardless; the flag
// still surfaces the admin's intent in the Settings → Storage UI.)
type WorkspaceStorageInfo struct {
UsedBytes int64 `json:"used_bytes"`
LimitBytes int64 `json:"limit_bytes"` // -1 = unlimited
Plan string `json:"plan"`
OverrideActive bool `json:"override_active"`
}
// attachmentColumns is the canonical column list. Keep the column names in
// alignment with migrations/047_attachments.sql + pgmigrations/026_attachments.sql.
const attachmentColumns = `id, workspace_id, item_id, uploaded_by, storage_key, content_hash,
mime_type, size_bytes, filename, width, height, parent_id, variant, created_at, deleted_at`
// scanAttachment scans a single row into a models.Attachment, handling
// nullables via *string / *int.
func scanAttachment(row interface {
Scan(dest ...any) error
}) (*models.Attachment, error) {
var a models.Attachment
var itemID, parentID, variant, deletedAt *string
var width, height *int
var createdAt string
err := row.Scan(
&a.ID, &a.WorkspaceID, &itemID, &a.UploadedBy, &a.StorageKey, &a.ContentHash,
&a.MimeType, &a.SizeBytes, &a.Filename, &width, &height,
&parentID, &variant, &createdAt, &deletedAt,
)
if err != nil {
return nil, err
}
a.ItemID = itemID
a.ParentID = parentID
a.Variant = variant
a.Width = width
a.Height = height
a.CreatedAt = parseTime(createdAt)
a.DeletedAt = parseTimePtr(deletedAt)
return &a, nil
}
// CreateAttachment inserts an attachment row. id and created_at are
// generated by the store. The caller is expected to have already written
// the blob via AttachmentStore.Put — store-level code does not touch the
// filesystem.
//
// An empty storage_key is rejected. The column is NOT NULL but "" passes
// that constraint, and a row with no key is a live attachment the registry
// cannot resolve: it fails at download time, long after the insert, with
// nothing to point at. The cross-backend arm of PlanAttachmentCopy emits
// exactly that shape deliberately — StorageKey blank until the caller
// transfers the bytes and writes back Put's key — so this guard is what
// turns "the caller must Put first" from a comment into an invariant.
func (s *Store) CreateAttachment(a *models.Attachment) error {
return s.createAttachmentOn(s.db, a)
}
// CreateAttachmentTx inserts an attachment row inside an existing transaction.
//
// It exists for the cross-workspace copy (PLAN-2357 / DR-9 / DR-11), which has
// to write the clone rows in the SAME transaction as the destination item —
// otherwise a rollback after the self-committing CreateAttachment leaves live
// attachment rows in workspace B pointing at an item that never existed, and a
// failure between the two leaves the copied body's rewritten refs dangling.
// RecordItemWorkspaceMoveTx is the shape this follows.
//
// Identical semantics to CreateAttachment, including the empty-storage_key
// refusal and the "stamp now() when CreatedAt is zero" rule that
// AttachmentCopyRow relies on (its rows carry a deliberately zero CreatedAt).
// Ordering is the caller's contract: attachments has no parent_id foreign key,
// so nothing stops a variant being inserted before its original — insert
// AttachmentCopyPlan.Rows in the order the planner emitted them.
func (s *Store) CreateAttachmentTx(tx *sql.Tx, a *models.Attachment) error {
return s.createAttachmentOn(tx, a)
}
// ErrAttachmentParentItemGone reports that the item an attachment is being
// written against is no longer a live item of the attachment's workspace —
// archived, hard-gone, or never in this workspace at all.
//
// It is a caller-facing sentinel, not an internal failure: handlers turn it
// into the same not-found response every other attachment denial writes, so
// it must not be wrapped in a way that hides it from errors.Is.
var ErrAttachmentParentItemGone = errors.New("attachment parent item is not live in this workspace")
// CreateAttachmentForLiveItem inserts an attachment row, refusing when its
// item_id does not name a LIVE item of the same workspace — re-checked and
// pinned inside the insert's own transaction.
//
// Why a transaction rather than a check in the handler (PLAN-2391 DR-14):
// producing an attachment is check-then-work. A handler that validates the
// parent up front and inserts afterwards leaves a window — item deletion
// commits in a separate transaction (DeleteItem) and can land in the middle,
// so the insert writes a quota-counted live row hanging off an item that is
// already archived, whose bytes the read gate (DR-13) then refuses to serve.
// Locking the item row for the duration of the insert closes the window
// instead of narrowing it.
//
// Postgres takes the row lock with FOR NO KEY UPDATE; a concurrent DeleteItem
// blocks on it and, once this transaction commits, its UPDATE ... WHERE
// deleted_at IS NULL still matches, so archival is delayed but never lost. In
// the other interleaving DeleteItem commits first, and the re-read —
// re-evaluated after the lock is released — no longer matches the deleted_at
// IS NULL predicate, so this call fails closed.
//
// FOR NO KEY UPDATE rather than FOR UPDATE: it is the exact strength needed
// and no more. DeleteItem's UPDATE touches no key column, so Postgres takes
// FOR NO KEY UPDATE for it, and two FOR NO KEY UPDATE holders conflict — the
// archival still blocks. What it does NOT block is FOR KEY SHARE, the lock an
// INSERT into any of the many tables with a `REFERENCES items(id)` foreign key
// takes on the parent row (comments, stars, the Yjs op-log — the last of which
// writes continuously while someone is editing the item). Plain FOR UPDATE
// would stall those for the duration of this transaction to buy nothing.
//
// SQLite skips the lock: its DSN sets _txlock=immediate, so every db.Begin()
// is a BEGIN IMMEDIATE and writers already serialize, and the row-locking
// clause is a syntax error there — the dialect gate is not an optimization.
//
// An orphan row (nil/empty ItemID) has no parent to pin and takes the plain
// insert path, identical to CreateAttachment.
func (s *Store) CreateAttachmentForLiveItem(a *models.Attachment) error {
if a.ItemID == nil || *a.ItemID == "" {
return s.createAttachmentOn(s.db, a)
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin create attachment tx: %w", err)
}
defer tx.Rollback() // no-op after Commit
query := `SELECT workspace_id FROM items WHERE id = ? AND deleted_at IS NULL`
if s.dialect.Driver() == DriverPostgres {
query += ` FOR NO KEY UPDATE`
}
var itemWorkspaceID string
switch err := tx.QueryRow(s.q(query), *a.ItemID).Scan(&itemWorkspaceID); {
case err == sql.ErrNoRows:
// Missing or archived — indistinguishable on purpose.
return ErrAttachmentParentItemGone
case err != nil:
return fmt.Errorf("lock attachment parent item: %w", err)
}
// Workspace identity is part of the invariant, not a redundant check:
// attachments.item_id carries no FK or same-workspace constraint, so a
// malformed association naming a live item in ANOTHER workspace would
// otherwise pass the liveness re-check and write a cross-workspace row.
if itemWorkspaceID != a.WorkspaceID {
return ErrAttachmentParentItemGone
}
if err := s.createAttachmentOn(tx, a); err != nil {
return err
}
return tx.Commit()
}
// createAttachmentOn is the shared insert body, parameterized over the pool or
// a transaction so CreateAttachment and CreateAttachmentTx cannot drift.
func (s *Store) createAttachmentOn(ex sqlExecer, a *models.Attachment) error {
if a.StorageKey == "" {
return fmt.Errorf("create attachment: storage_key is required (write the blob via AttachmentStore.Put first)")
}
if a.ID == "" {
a.ID = newID()
}
ts := now()
if a.CreatedAt.IsZero() {
a.CreatedAt = parseTime(ts)
} else {
ts = a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}
_, err := ex.Exec(s.q(`
INSERT INTO attachments (`+attachmentColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
a.ID, a.WorkspaceID, a.ItemID, a.UploadedBy, a.StorageKey, a.ContentHash,
a.MimeType, a.SizeBytes, a.Filename, a.Width, a.Height,
a.ParentID, a.Variant, ts, nil,
)
if err != nil {
return fmt.Errorf("create attachment: %w", err)
}
return nil
}
// GetAttachment returns the attachment with the given id, or (nil, nil)
// if no such row exists. Soft-deleted rows ARE returned (so download
// handlers can distinguish "deleted" from "never existed" and return a
// 410 instead of a 404 if they want to in the future); callers that
// want only live rows should check a.DeletedAt.
func (s *Store) GetAttachment(id string) (*models.Attachment, error) {
a, err := scanAttachment(s.db.QueryRow(s.q(`
SELECT `+attachmentColumns+` FROM attachments WHERE id = ?
`), id))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get attachment: %w", err)
}
return a, nil
}
// GetAttachmentVariant returns the derived attachment row for parentID
// with the given variant key (e.g. "thumb-sm"), or (nil, nil) if no such
// row exists. Used by the download handler when a client passes
// ?variant=thumb-sm — TASK-878 will populate these rows; TASK-872
// implements the lookup so the handler degrades gracefully when no
// thumbnail exists yet.
//
// workspaceID scopes the lookup (PLAN-2391 DR-16). parent_id has no FK or
// same-workspace constraint, so a variant row in workspace B can carry a
// parent_id belonging to workspace A — the copy planner demonstrates that
// shape (internal/store/attachments_copy_plan_test.go). Without the scope,
// the download handler authorizes A's original and then serves B's child,
// which defeats the entire item-visibility gate. The scope lives here rather
// than in the handler because the OTHER caller — thumbnail derivation — has
// its own stake in it: an unscoped "does this variant already exist?" probe
// would let a foreign row suppress generation of a legitimate local one.
func (s *Store) GetAttachmentVariant(workspaceID, parentID, variant string) (*models.Attachment, error) {
a, err := scanAttachment(s.db.QueryRow(s.q(`
SELECT `+attachmentColumns+` FROM attachments
WHERE workspace_id = ? AND parent_id = ? AND variant = ? AND deleted_at IS NULL
LIMIT 1
`), workspaceID, parentID, variant))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get attachment variant: %w", err)
}
return a, nil
}
// AttachmentListFilters narrow the WorkspaceAttachments result set.
// Zero values mean "no filter on this dimension". The handler turns
// query-string parameters into this struct so the SQL builder stays
// pure and unit-testable.
type AttachmentListFilters struct {
// MimeCategory restricts to a single MIME category bucket
// (matches attachments.Category — "image", "document", etc.).
// Empty = all categories. Translated to MIME predicates per
// mimePredicateForCategory; categories without a clean prefix
// (document, text, archive, other) use an explicit IN list.
MimeCategory string
// Attached restricts to attachments associated with an item.
Attached bool
// Unattached restricts to orphan attachments (item_id IS NULL).
// Mutually exclusive with Attached — handler validates upstream.
Unattached bool
// ItemID restricts to attachments whose parent item has this
// UUID. Empty = no per-item filter. Mutually exclusive with
// Unattached (an orphan can't match a specific item) — handler
// validates upstream. Used by `pad attachment list --item REF`
// after the CLI resolves a TASK-5-style ref to its UUID.
ItemID string
// CollectionID restricts to attachments belonging to items in
// this collection. Empty = no collection filter.
CollectionID string
// Sort field. Accepts "size", "filename", "created_at" with an
// optional " desc" suffix. Empty = "created_at desc" (newest first).
Sort string
// Limit caps the page size. Clamped to [1, 200] by the handler.
Limit int
// Offset pages forward. Combined with Limit + Total for the UI's
// classic page navigator. Negative values clamped to 0.
Offset int
// Restricted, FullCollectionIDs, GrantedItemIDs together encode
// per-user collection + item visibility. When Restricted is true
// the list filters to attachments whose parent item is either
// in one of FullCollectionIDs or has its id in GrantedItemIDs.
// Orphans (item_id IS NULL) are excluded from restricted views
// so a member who only sees one collection can't enumerate
// filenames of unattached uploads from collections they don't
// have access to.
//
// Restricted=false → no filter (admin / full-access member).
// Restricted=true with empty Full+Granted → zero rows.
// Restricted=true with one or both populated → SQL OR of the
// two predicates.
//
// Mirrors the (fullCollIDs, grantedItemIDs) tuple returned by
// Server.guestResourceFilter — keep the semantics in sync.
Restricted bool
FullCollectionIDs []string
GrantedItemIDs []string
}
// AttachmentListItem is a row from WorkspaceAttachments enriched with
// the parent item's title + slug + collection slug so the UI can render
// a clickable link without a follow-up GET. Item fields are nullable
// for orphan rows.
//
// URL construction: the item route is /{user}/{ws}/{collection_slug}/{item_slug},
// so the UI uses ItemSlug — never a synthetic "TASK-5"-style ref. The
// ref shape isn't 1:1 with the route, and exposing it here led to a
// double-collection-slug bug in an earlier draft.
//
// ItemDeleted is true when the parent item exists but has been soft-
// deleted. The row is still surfaced because the bytes still consume
// quota; the UI uses the flag to render "(deleted)" instead of a
// clickable link to a 404'd item.
type AttachmentListItem struct {
models.Attachment
ItemTitle *string `json:"item_title,omitempty"`
ItemSlug *string `json:"item_slug,omitempty"`
ItemDeleted bool `json:"item_deleted,omitempty"`
CollectionSlug *string `json:"collection_slug,omitempty"`
}
// allowedAttachmentSorts pins the columns + directions the list
// endpoint accepts, so a hand-crafted sort= query can't smuggle in
// arbitrary SQL. Map values are the literal SQL fragment we splice in.
var allowedAttachmentSorts = map[string]string{
"size": "a.size_bytes ASC",
"size_desc": "a.size_bytes DESC",
"filename": "a.filename ASC",
"filename_desc": "a.filename DESC",
"created_at": "a.created_at ASC",
"created_at_desc": "a.created_at DESC",
}
// mimePredicateForCategory maps an attachments.Category value to a
// SQL fragment + matching argument list that selects all MIMEs in
// that bucket. Categories with a clean type prefix (image/, video/,
// audio/) use a LIKE; the rest use an explicit IN list mirroring
// the entries in internal/attachments/mime.go.
//
// Returns (frag, args, true) when the category is known. The frag
// uses ? placeholders that the caller splices into the WHERE. ok=false
// for unknown categories — caller must skip the filter so the UI
// shows everything rather than zero rows for typos.
//
// Keep the literal MIME lists in lockstep with internal/attachments/
// mime.go: any time a new MIME is added to the allowlist there, mirror
// it here so the Settings → Storage filter stays useful.
func mimePredicateForCategory(category string) (frag string, args []any, ok bool) {
switch category {
case "image":
return "a.mime_type LIKE ?", []any{"image/%"}, true
case "video":
return "a.mime_type LIKE ?", []any{"video/%"}, true
case "audio":
return "a.mime_type LIKE ?", []any{"audio/%"}, true
case "document":
return mimeInPredicate([]string{
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/rtf",
})
case "text":
return mimeInPredicate([]string{
"text/plain", "text/markdown", "text/csv", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"application/yaml", "text/yaml", "application/toml",
"text/html", "text/javascript", "application/javascript",
})
case "archive":
return mimeInPredicate([]string{
"application/zip", "application/x-tar", "application/gzip",
"application/x-bzip2", "application/x-7z-compressed",
})
case "other":
// "Other" is the negation of every named bucket. Easier to
// build by exclusion: NOT in the union of all known MIMEs.
// Listing the categories explicitly keeps this in lockstep
// with the named buckets above without a second source of
// truth.
all := []string{
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/rtf",
"text/plain", "text/markdown", "text/csv", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"application/yaml", "text/yaml", "application/toml",
"text/html", "text/javascript", "application/javascript",
"application/zip", "application/x-tar", "application/gzip",
"application/x-bzip2", "application/x-7z-compressed",
}
placeholders := make([]string, len(all))
args := make([]any, len(all))
for i, m := range all {
placeholders[i] = "?"
args[i] = m
}
frag := "a.mime_type NOT LIKE 'image/%' AND a.mime_type NOT LIKE 'video/%' AND a.mime_type NOT LIKE 'audio/%' AND a.mime_type NOT IN (" + strings.Join(placeholders, ",") + ")"
return frag, args, true
}
return "", nil, false
}
// mimeInPredicate builds a `a.mime_type IN (?,?,...)` fragment with
// matching args. Helper used by mimePredicateForCategory for the
// non-prefix categories.
func mimeInPredicate(mimes []string) (string, []any, bool) {
placeholders := make([]string, len(mimes))
args := make([]any, len(mimes))
for i, m := range mimes {
placeholders[i] = "?"
args[i] = m
}
return "a.mime_type IN (" + strings.Join(placeholders, ",") + ")", args, true
}
// WorkspaceAttachments lists original (non-derived) attachments in a
// workspace, with optional filtering + sorting + pagination. Returns
// the page rows and the total count of matching rows so the UI can
// render a paginator without a second round-trip.
//
// Intentionally hides derived blobs (thumbnails — rows where
// parent_id IS NOT NULL): they're managed automatically and showing
// them in the list would clutter the page with rows the user didn't
// upload. They still count against storage quota via
// WorkspaceStorageUsage; the totals match the bar even when the list
// shows only originals.
//
// LEFT JOIN to items + collections gives the UI everything it needs
// to render an "in [[Task X]]" link. Soft-deleted items are returned
// with item fields nulled out — the attachment is still visible
// (a deleted item could still be restored), but the link target
// isn't reachable.
//
// Both joins are scoped to the attachment's own workspace, so an
// attachment whose item_id points at another workspace's item lists
// with NULL item/collection metadata rather than leaking a foreign
// title (TASK-2399). The row itself still lists — it consumes quota
// and must remain visible and repairable.
func (s *Store) WorkspaceAttachments(workspaceID string, filters AttachmentListFilters) ([]AttachmentListItem, int, error) {
// Build the WHERE clause incrementally. Every branch parameter
// goes through the placeholder slice — no string concatenation of
// user input. Sort is the only user-controllable splice and it
// goes through the allowedAttachmentSorts allowlist.
var conds []string
var args []any
conds = append(conds, "a.workspace_id = ?")
args = append(args, workspaceID)
conds = append(conds, "a.deleted_at IS NULL")
conds = append(conds, "a.parent_id IS NULL") // hide derived blobs
if filters.Attached {
conds = append(conds, "a.item_id IS NOT NULL")
}
if filters.Unattached {
conds = append(conds, "a.item_id IS NULL")
}
if filters.ItemID != "" {
conds = append(conds, "a.item_id = ?")
args = append(args, filters.ItemID)
}
if frag, mimeArgs, ok := mimePredicateForCategory(filters.MimeCategory); ok {
conds = append(conds, frag)
args = append(args, mimeArgs...)
}
if filters.CollectionID != "" {
conds = append(conds, "i.collection_id = ?")
args = append(args, filters.CollectionID)
}
// Collection + item-level visibility enforcement. Two sources of
// access: collections the user can see in full (FullCollectionIDs)
// and individual items granted to them (GrantedItemIDs). The
// predicate ORs them so an item-grant in a hidden collection still
// resolves; orphans (item_id IS NULL) are excluded entirely so a
// restricted user can't enumerate orphan filenames.
//
// Restricted=false → no filter (admin / full-access member).
// Restricted=true with both lists empty → zero rows.
if filters.Restricted {
var ors []string
if len(filters.FullCollectionIDs) > 0 {
ph := make([]string, len(filters.FullCollectionIDs))
for i, id := range filters.FullCollectionIDs {
ph[i] = "?"
args = append(args, id)
}
ors = append(ors, "i.collection_id IN ("+strings.Join(ph, ",")+")")
}
if len(filters.GrantedItemIDs) > 0 {
ph := make([]string, len(filters.GrantedItemIDs))
for i, id := range filters.GrantedItemIDs {
ph[i] = "?"
args = append(args, id)
}
ors = append(ors, "a.item_id IN ("+strings.Join(ph, ",")+")")
}
if len(ors) == 0 {
conds = append(conds, "1 = 0")
} else {
conds = append(conds, "("+strings.Join(ors, " OR ")+")")
}
}
where := strings.Join(conds, " AND ")
// Count total before applying limit/offset so the UI can render
// "showing 1–25 of 312".
//
// The items LEFT JOIN intentionally does NOT filter on
// items.deleted_at — attachments survive a soft-deleted parent
// (they still consume quota), so the storage list must surface
// them and the collection-level visibility predicate
// (i.collection_id IN ...) must keep working. The handler's
// delete path uses GetItemIncludeDeleted for the same reason.
//
// The workspace predicate belongs in the ON clause, NOT in WHERE:
// in WHERE the LEFT JOIN degenerates into an inner join and a row
// whose item_id points at another workspace's item would vanish
// from the listing entirely — hiding quota-consuming rows and
// making them unrepairable. In ON, a mismatched parent simply
// yields NULL item metadata and the row survives (TASK-2399).
var total int
if err := s.db.QueryRow(s.q(`
SELECT COUNT(*) FROM attachments a
LEFT JOIN items i ON i.id = a.item_id AND i.workspace_id = a.workspace_id
WHERE `+where), args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count workspace attachments: %w", err)
}
orderBy, ok := allowedAttachmentSorts[filters.Sort]
if !ok {
orderBy = "a.created_at DESC" // sensible default — newest first
}
limit := filters.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
offset := filters.Offset
if offset < 0 {
offset = 0
}
// Column list is the same as attachmentColumns but prefixed with
// `a.` so SQLite doesn't choke on the ambiguous `id` shared with
// the joined items table.
const aliasedAttachmentColumns = `a.id, a.workspace_id, a.item_id, a.uploaded_by, a.storage_key, a.content_hash,
a.mime_type, a.size_bytes, a.filename, a.width, a.height, a.parent_id, a.variant, a.created_at, a.deleted_at`
// Same rationale as the count query above: include soft-deleted
// parent items so the row is still visible for users who would
// be allowed to see the live item, and so the collection-level
// ACL predicate sees a non-NULL i.collection_id. The response
// surfaces deleted_at on the joined item via item_deleted so the
// UI can render a "(deleted)" tag instead of a clickable link.
//
// Same workspace scoping as the count query above, and for the
// same reason — the two must stay consistent or a restricted
// user's count would diverge from their results. Collections are
// reached through the now-scoped item join, so a foreign parent
// nulls out the collection columns too.
//
// The collections join carries its own workspace predicate as
// well: items.collection_id has no composite workspace foreign
// key (migrations/005_collections.sql), so an item can reference
// a collection in another workspace and would otherwise surface
// that collection's slug/name here. Same ON-clause rule — a
// mismatch nulls the collection columns, it does not drop the
// row.
q := `
SELECT ` + aliasedAttachmentColumns + `,
i.title, i.slug, i.deleted_at,
c.slug, c.name
FROM attachments a
LEFT JOIN items i ON i.id = a.item_id AND i.workspace_id = a.workspace_id
LEFT JOIN collections c ON c.id = i.collection_id AND c.workspace_id = i.workspace_id
WHERE ` + where + `
ORDER BY ` + orderBy + `
LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, 0, fmt.Errorf("list workspace attachments: %w", err)
}
defer rows.Close()
var out []AttachmentListItem
for rows.Next() {
var a models.Attachment
var itemID, parentID, variant, deletedAt *string
var width, height *int
var createdAt string
// Item + collection columns from the LEFT JOIN. All nullable.
var itemTitle, itemSlug, itemDeletedAt *string
var collSlug, collName *string
if err := rows.Scan(
&a.ID, &a.WorkspaceID, &itemID, &a.UploadedBy, &a.StorageKey, &a.ContentHash,
&a.MimeType, &a.SizeBytes, &a.Filename, &width, &height,
&parentID, &variant, &createdAt, &deletedAt,
&itemTitle, &itemSlug, &itemDeletedAt,
&collSlug, &collName,
); err != nil {
return nil, 0, fmt.Errorf("scan workspace attachment: %w", err)
}
a.ItemID = itemID
a.ParentID = parentID
a.Variant = variant
a.Width = width
a.Height = height
a.CreatedAt = parseTime(createdAt)
a.DeletedAt = parseTimePtr(deletedAt)
row := AttachmentListItem{Attachment: a}
if itemTitle != nil {
row.ItemTitle = itemTitle
}
if itemSlug != nil {
row.ItemSlug = itemSlug
}
if itemDeletedAt != nil && *itemDeletedAt != "" {
row.ItemDeleted = true
}
if collSlug != nil {
row.CollectionSlug = collSlug
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate workspace attachments: %w", err)
}
return out, total, nil
}
// OrphanedAttachments returns rows eligible for orphan GC reclamation
// (TASK-886). Two cases qualify:
//
// - Never-attached uploads: item_id IS NULL AND deleted_at IS NULL
// AND created_at < grace cutoff. The editor uploads first and
// PATCHes the item content second; a tab-close in between leaves
// a row in this state. 30 days is comfortable headroom for that
// race plus any deferred-attachment workflow we add later.
//
// - Soft-deleted past grace: deleted_at IS NOT NULL AND
// deleted_at < grace cutoff. Delete handlers tombstone rows
// immediately so undelete is possible; GC reclaims after the
// grace period.
//
// Both filters compare the timestamp column (TEXT, ISO 8601 UTC)
// against the cutoff string lexicographically — UTC RFC3339 collates
// in chronological order without parsing. The cutoff is computed by
// the caller so tests can inject a deterministic time.
//
// Returned rows include thumbnail variants (parent_id != NULL) when
// they meet either criterion — soft-deleting an original cascades
// to its thumbnails (SoftDeleteAttachment), so they all share the
// same deleted_at and reach the GC together.
func (s *Store) OrphanedAttachments(graceCutoff time.Time) ([]models.Attachment, error) {
cutoffStr := graceCutoff.UTC().Format(time.RFC3339)
rows, err := s.db.Query(s.q(`
SELECT `+attachmentColumns+`
FROM attachments
WHERE
(item_id IS NULL AND deleted_at IS NULL AND created_at < ?)
OR
(deleted_at IS NOT NULL AND deleted_at < ?)
ORDER BY created_at, id
`), cutoffStr, cutoffStr)
if err != nil {
return nil, fmt.Errorf("orphaned attachments: %w", err)
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, fmt.Errorf("scan orphan: %w", err)
}
out = append(out, *a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate orphans: %w", err)
}
return out, nil
}
// HardDeleteAttachment removes the attachments row outright. Used by
// orphan GC after the grace period; never call from a request
// handler — soft-delete is the safe default for user-facing flows.
func (s *Store) HardDeleteAttachment(id string) error {
_, err := s.db.Exec(s.q(`DELETE FROM attachments WHERE id = ?`), id)
if err != nil {
return fmt.Errorf("hard delete attachment: %w", err)
}
return nil
}
// CountProtectingAttachmentsForHash returns the number of rows
// pointing at the given content_hash whose presence requires the
// on-disk blob to stay. A row protects the blob when it is either:
//
// - live (deleted_at IS NULL), or
// - soft-deleted but still inside the grace window
// (deleted_at >= graceCutoff)
//
// excludeID is the row currently being GC'd; we don't count it
// against itself. Codex P2 round 3 caught the earlier version's
// gap: counting only deleted_at IS NULL would have GC reclaim the
// blob from row A (soft-deleted 31d ago) even though row B is
// also soft-deleted but still 1 day old — within grace, so its
// blob must stay reachable until its own grace expires.
func (s *Store) CountProtectingAttachmentsForHash(hash, excludeID string, graceCutoff time.Time) (int, error) {
var n int
cutoff := graceCutoff.UTC().Format(time.RFC3339)
err := s.db.QueryRow(s.q(`
SELECT COUNT(*) FROM attachments
WHERE content_hash = ? AND id <> ?
AND (deleted_at IS NULL OR deleted_at >= ?)
`), hash, excludeID, cutoff).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count protecting attachments for hash: %w", err)
}
return n, nil
}
// AttachmentReferenced returns true when anything in the workspace
// mentions "pad-attachment:<id>" — either a live item's content/fields
// JSON or a comment body. The editor and comment-composer upload flows
// both leave attachments.item_id NULL: the canonical association is the
// markdown reference inside the item's content or a comment, NOT a
// column in the attachments table — so the orphan GC has to look at
// that text directly before reclaiming a "never-attached" row,
// otherwise it'd hard-delete attachments that markdown still points at.
//
// Comments are covered because a pasted screenshot (IDEA-1650) may be
// referenced ONLY from a comment body; without the comments scan the GC
// would reclaim it after the grace period and break the embed.
//
// Scoped to one workspace because a "pad-attachment:UUID" reference
// only resolves within the workspace where the attachment lives;
// cross-workspace references are intentionally not supported.
//
// Dialect note: items.fields is TEXT on SQLite but JSONB on
// PostgreSQL (see migrations + pgmigrations). LIKE doesn't work on
// JSONB so the Postgres path casts to text first. Codex P1 round 2
// caught this — without the cast, the GC's reference scan errored
// on Postgres and every never-attached row got skipped. comments.body
// is TEXT on both dialects, so it needs no cast.
func (s *Store) AttachmentReferenced(workspaceID, attachmentID string) (bool, error) {
if workspaceID == "" || attachmentID == "" {
return false, nil
}
needle := "pad-attachment:" + attachmentID
pattern := "%" + needle + "%"
fieldsExpr := "fields"
if s.dialect.Driver() == DriverPostgres {
fieldsExpr = "fields::text"
}
var n int
err := s.db.QueryRow(s.q(`
SELECT
(SELECT COUNT(*) FROM items
WHERE workspace_id = ? AND deleted_at IS NULL
AND (content LIKE ? OR `+fieldsExpr+` LIKE ?))
+ (SELECT COUNT(*) FROM comments
WHERE workspace_id = ? AND body LIKE ?)
`), workspaceID, pattern, pattern, workspaceID, pattern).Scan(&n)
if err != nil {
return false, fmt.Errorf("attachment referenced: %w", err)
}
return n > 0, nil
}
// SoftDeleteWorkspaceAttachments tombstones every live attachment
// row (originals AND thumbnail variants) under a workspace in a
// single bulk UPDATE. Used by the bundle import handler to roll back
// a partial workspace when a validation reject fires AFTER blobs
// have already been rehydrated — without this cascade the
// attachment rows stay live (deleted_at IS NULL), pin their blobs
// from orphan-GC reclamation, and continue counting toward the
// importing user's storage usage. Codex P1 on PR #308.
//
// Both originals and thumbnails carry the same workspace_id, so a
// single WHERE workspace_id = ? clause covers both. Returns the
// number of rows tombstoned.
//
// The blob bytes on disk are NOT touched — content-addressed dedupe
// means another live row may still reference the same hash, so
// reclamation is the orphan-GC's job after the grace period.
func (s *Store) SoftDeleteWorkspaceAttachments(workspaceID string) (int64, error) {
ts := now()
res, err := s.db.Exec(s.q(`
UPDATE attachments
SET deleted_at = ?
WHERE workspace_id = ? AND deleted_at IS NULL
`), ts, workspaceID)
if err != nil {
return 0, fmt.Errorf("soft delete workspace attachments: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("soft delete workspace attachments rows affected: %w", err)
}
return n, nil
}
// SoftDeleteAttachment marks the given attachment row deleted (and
// every variant whose parent_id points at it) so the orphan GC will
// reclaim the bytes after the grace period. Returns sql.ErrNoRows if
// no live row matches.
//
// We mark variants via a separate UPDATE keyed on parent_id rather
// than letting a foreign-key cascade do it — the attachments table
// has no FK on parent_id, by design (DOC-865: thumbnails are
// independent rows so a missing original doesn't break a list query).
//
// The blob on disk is NOT removed here; the same content_hash may be
// referenced by other rows (content-addressed dedupe), so reclamation
// is the GC's job once it can prove no live row references the hash.
func (s *Store) SoftDeleteAttachment(id string) error {
ts := now()
res, err := s.db.Exec(s.q(`
UPDATE attachments
SET deleted_at = ?
WHERE id = ? AND deleted_at IS NULL
`), ts, id)
if err != nil {
return fmt.Errorf("soft delete attachment: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("soft delete attachment rows affected: %w", err)
}
if n == 0 {
return sql.ErrNoRows
}
// Also tombstone any thumbnail variants. They're synthetic rows
// derived from the original; without the original they have no
// reason to exist. Errors here are non-fatal — orphan GC will
// still reach them eventually via the deleted-parent path.
if _, err := s.db.Exec(s.q(`
UPDATE attachments
SET deleted_at = ?
WHERE parent_id = ? AND deleted_at IS NULL
`), ts, id); err != nil {
// Log via the caller; we don't have a logger here. The row
// went through, so don't fail the request.
return nil
}
return nil
}
// WorkspaceItemSlugMap returns slug → id for every live item in a
// workspace. Used by the bundle import path (TASK-885) to remap an
// old attachment's item_id (from the manifest) to the freshly-
// generated id, via item.slug which ImportWorkspace preserves.
//
// Soft-deleted items are excluded; the import path can't realistically
// recreate an attachment under a deleted parent without also
// resurrecting the parent, and the manifest's item_id only has
// meaning for live items at export time.
func (s *Store) WorkspaceItemSlugMap(workspaceID string) (map[string]string, error) {
rows, err := s.db.Query(s.q(`
SELECT id, slug FROM items WHERE workspace_id = ? AND deleted_at IS NULL
`), workspaceID)
if err != nil {
return nil, fmt.Errorf("workspace item slug map: %w", err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var id, slug string
if err := rows.Scan(&id, &slug); err != nil {
return nil, fmt.Errorf("scan slug map: %w", err)
}
out[slug] = id
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate slug map: %w", err)
}
return out, nil
}
// RemapAttachmentReferencesInWorkspace rewrites every
// "pad-attachment:OLD" reference in items.content + items.fields
// to "pad-attachment:NEW" for every (old, new) pair in the map.
// Run after a bundle import has rehydrated attachments so item
// content points at the new attachment ids instead of the source
// workspace's ids.
//
// Implementation: a single transaction that loads every item's
// content+fields, runs remapAttachmentRefs over each, and writes
// back only when something changed. That helper tokenizes with
// attachmentRefRE and matches whole ids — NOT strings.ReplaceAll,
// which used to rewrite a mapped id sitting as the PREFIX of a
// longer one (Codex round 26). See its doc.
//
// FTS reindex via the existing rebuild helper happens AFTER the
// transaction commits — direct UPDATE bypasses the SQLite FTS
// triggers the same way ImportWorkspace's INSERTs do.
func (s *Store) RemapAttachmentReferencesInWorkspace(workspaceID string, oldToNew map[string]string) error {
if len(oldToNew) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin remap tx: %w", err)
}
defer tx.Rollback()
rows, err := tx.Query(s.q(`SELECT id, content, fields FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), workspaceID)
if err != nil {
return fmt.Errorf("scan items for remap: %w", err)
}
type rowUpdate struct {
id string
content string
fields string
}
var updates []rowUpdate
for rows.Next() {
var id, content, fields string
if err := rows.Scan(&id, &content, &fields); err != nil {
rows.Close()
return fmt.Errorf("scan item: %w", err)
}
newContent := remapAttachmentRefs(content, oldToNew)
newFields := remapAttachmentRefs(fields, oldToNew)
if newContent != content || newFields != fields {
updates = append(updates, rowUpdate{id: id, content: newContent, fields: newFields})
}
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("iterate items for remap: %w", err)
}
rows.Close()
for _, u := range updates {
if _, err := tx.Exec(s.q(`UPDATE items SET content = ?, fields = ? WHERE id = ?`),
u.content, u.fields, u.id); err != nil {
return fmt.Errorf("update item %s: %w", u.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit remap: %w", err)
}
// Refresh FTS so search queries see the rewritten content.
s.rebuildFTSForWorkspace(workspaceID)
return nil
}
// remapAttachmentRefs replaces "pad-attachment:OLD" with
// "pad-attachment:NEW" for every (old, new) pair in the map. Pure
// string operation — kept private so callers go through
// RemapAttachmentReferencesInWorkspace which also handles the FTS
// reindex.
//
// IT TOKENIZES WITH attachmentRefRE, the SAME pattern the copy
// planner enumerates references with, and rewrites only when the
// captured id matches a map key EXACTLY.
//
// A plain strings.ReplaceAll over "pad-attachment:"+old was the
// obvious implementation and was subtly wrong (Codex round 26). The
// regex is greedy — `pad-attachment:<uuid>x` captures `<uuid>x`, one
// id, which resolves to nothing and is deliberately left alone — but a
// substring replace happily rewrote the `<uuid>` PREFIX inside it,
// producing text that matched neither what the planner enumerated nor
// what the user wrote. PLAN-2357 DR-11a is explicit that an
// unresolvable reference keeps its literal text "so the copy renders
// exactly as broken as the source did", and
// items_cross_workspace_copy.go claims the rewrite "covers precisely
// the reference set the plan cloned". Sharing the tokenizer is what
// makes both true rather than nearly true.
func remapAttachmentRefs(s string, oldToNew map[string]string) string {
if len(oldToNew) == 0 || !strings.Contains(s, attachmentRefPrefix) {
return s
}
return attachmentRefRE.ReplaceAllStringFunc(s, func(match string) string {
id := strings.TrimPrefix(match, attachmentRefPrefix)
fresh, ok := oldToNew[id]
if !ok || fresh == "" || fresh == id {
return match
}
return attachmentRefPrefix + fresh
})
}
// WorkspaceAttachmentsForExport returns every original (non-derived,
// non-deleted) attachment in the workspace so the export bundler
// can stream them into the tar. Derived rows (thumbnails) are
// excluded — they're re-derived on import via the existing pipeline.
//
// Soft-deleted parents are NOT excluded here: the attachment row is
// still live, the bytes still exist on disk, and the user explicitly
// chose to export the workspace. Surfacing them lets a round-trip
// preserve the audit trail (the import path will recreate the row
// as an orphan, or the soft-deleted item gets restored separately).
func (s *Store) WorkspaceAttachmentsForExport(workspaceID string) ([]models.Attachment, error) {
rows, err := s.db.Query(s.q(`
SELECT `+attachmentColumns+`
FROM attachments
WHERE workspace_id = ? AND deleted_at IS NULL AND parent_id IS NULL
ORDER BY created_at, id
`), workspaceID)
if err != nil {
return nil, fmt.Errorf("export attachments: %w", err)
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, fmt.Errorf("scan export attachment: %w", err)
}
out = append(out, *a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate export attachments: %w", err)
}
return out, nil
}
// WorkspaceStorageUsage returns the total bytes consumed by non-deleted
// attachments in the workspace. Includes derived blobs (thumbnails) —
// those are real bytes on disk and count against quota.
func (s *Store) WorkspaceStorageUsage(workspaceID string) (int64, error) {
var total sql.NullInt64
err := s.db.QueryRow(s.q(`
SELECT COALESCE(SUM(size_bytes), 0)
FROM attachments
WHERE workspace_id = ? AND deleted_at IS NULL
`), workspaceID).Scan(&total)
if err != nil {
return 0, fmt.Errorf("workspace storage usage: %w", err)
}
return total.Int64, nil
}
// WorkspaceStorageLimit returns the effective storage limit (bytes) for
// the workspace owner's plan, or -1 if the plan is unlimited. Thin
// wrapper around WorkspaceStorageInfo for the upload-time quota check
// path that doesn't care about used bytes / plan / override metadata.
//
// Resolution matches the CheckLimit three-tier order:
// 1. Per-user override (user.PlanOverrides JSON, key "storage_bytes")
// 2. Platform setting (plan_limits_<plan>_storage_bytes)
// 3. Hardcoded fallback (DefaultFreeLimits / DefaultProLimits)
//
// CheckLimit itself can't be reused here because its featureCount path
// only knows about row-counted features (items, members, webhooks, …) —
// storage_bytes is byte-counted via WorkspaceStorageUsage.
func (s *Store) WorkspaceStorageLimit(workspaceID string) (int64, error) {
info, err := s.WorkspaceStorageInfo(workspaceID)
if err != nil {
return 0, err
}
return info.LimitBytes, nil
}
// WorkspaceStorageInfo returns the consolidated quota summary for a
// workspace (used + limit + plan + override flag) in a single call.
// Used by the storage usage API (TASK-881) and the Settings → Storage
// page (TASK-882) so the UI can render the usage bar and the
// "(override)" badge from one fetch.
//
// Limit resolution mirrors WorkspaceStorageLimit; pro and self-hosted
// plans return -1 (unlimited) in Phase 1. Workspaces with no owner
// (fresh install, deleted owner) also resolve to unlimited rather than
// blocking uploads.
func (s *Store) WorkspaceStorageInfo(workspaceID string) (*WorkspaceStorageInfo, error) {
used, err := s.WorkspaceStorageUsage(workspaceID)
if err != nil {
return nil, err
}
info := &WorkspaceStorageInfo{
UsedBytes: used,
LimitBytes: -1, // default: no enforceable limit (no owner / pro / self-hosted)
}
var ownerID sql.NullString
err = s.db.QueryRow(s.q(`SELECT owner_id FROM workspaces WHERE id = ?`), workspaceID).Scan(&ownerID)
if err == sql.ErrNoRows {
return info, nil
}
if err != nil {
return nil, fmt.Errorf("workspace storage info: get owner: %w", err)
}
// Workspaces created on a fresh install (no users yet) and legacy
// workspaces from before owner_id was added have no owner. In both
// cases there is no plan to consult — treat as unlimited rather
// than rejecting uploads.
if !ownerID.Valid || ownerID.String == "" {
return info, nil
}
user, err := s.GetUser(ownerID.String)
if err != nil {
return nil, fmt.Errorf("workspace storage info: get user: %w", err)
}
if user == nil {
// Owner row was deleted but the workspace still references it.
return info, nil
}
plan := user.Plan
if plan == "" {
plan = "free"
}
info.Plan = plan
// Detect whether a per-user storage_bytes override is configured.
// We surface this as a flag for the UI even when the plan is
// pro/self-hosted (where the override has no effect on the
// effective limit) so admins can see the configured value.
if user.PlanOverrides != "" {
var overrides map[string]int
if err := json.Unmarshal([]byte(user.PlanOverrides), &overrides); err == nil {
if _, ok := overrides["storage_bytes"]; ok {
info.OverrideActive = true
}
}
}
// Pro and self-hosted have no enforced limit in Phase 1.
if plan == "pro" || plan == "self-hosted" {
return info, nil
}
// resolveLimit returns int (32-bit on 32-bit platforms) so we can't
// store a quota larger than 2 GiB on 32-bit hosts via this path.
// That's a non-issue for Phase 1: hardcoded free=500MB, pro=10GB,
// and 32-bit production hosts are not a supported deployment target
// for Pad Cloud. If a future plan-tier ever exceeds INT_MAX bytes
// we'll widen resolveLimit.
info.LimitBytes = int64(s.resolveLimit(plan, "storage_bytes", user.PlanOverrides))
return info, nil
}