mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
380b75e12c
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
1187 lines
46 KiB
Go
1187 lines
46 KiB
Go
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
|
||
}
|