Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,31 @@
All notable changes to buckt are documented here. This project follows
[Semantic Versioning](https://semver.org/).

## [1.9.0] — unreleased
## [1.10.0] — unreleased

A backward-compatible **minor** release adding file expiry / temp files.
Additive; no API removals.

### ✨ Added

- **Expiring / temp files** (`SetFileTTL`, `SetFileExpiry`, `PurgeExpired`,
`WithExpirySweeper`, and upload-with-TTL: `UploadFileWithTTL` /
`UploadFileWithExpiry` / `UploadFileFromReaderWithTTLContext`). Give a file a
TTL and buckt permanently deletes it —
blob, image derivatives, and metadata row — once it's due, emitting a
`file.purged` event. A new indexed `expires_at` column (schema migration v6,
additive) makes the sweep a cheap ranged query. The sweep is app-driven by
default (call `PurgeExpired` from your own scheduler); `WithExpirySweeper`
opts into a built-in background ticker (stopped by `Client.Close`). Built to
extend toward general scheduled actions (e.g. "email this after 24h") on the
same event-backed sweep. The web client exposes it too: `PUT /expiry/:file_id`
(ttl or absolute RFC3339) and `POST /purge-expired` on the API, and in the UI
a per-file "Expire in 1h/24h/7d" menu with an expiry badge (`POST
/web/set-ttl/:file_id`, `POST /web/purge-expired`). The `ui` example gains an
`-expiry=<interval>` flag (`make ui EXPIRY=30s`) to run the sweeper, and a new
`example/expiry/headless` walks the TTL → purge lifecycle.

## [1.9.0] — 2026-08-11

A backward-compatible **minor** release adding a pluggable upload-scanning hook.
Additive; no API removals.
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ In fact, Buckt can use MinIO as its storage backend, allowing you to combine Min
- [Lifecycle Events](#lifecycle-events)
- [Upload Scanning](#upload-scanning)
- [Metrics](#metrics)
- [Expiring / Temp Files](#expiring--temp-files)
- [Trash \& Deletion](#trash--deletion)
- [Web Client](#web-client)
- [Modes](#modes)
Expand Down Expand Up @@ -120,6 +121,7 @@ In fact, Buckt can use MinIO as its storage backend, allowing you to combine Min
| 🛡️ **Upload Scanning** | Pluggable pre-write hook to reject malware/disallowed files before they're stored |
| 🧬 **Content Dedup** | Collapse identical uploads in a folder to a single stored blob |
| 🏷️ **File Metadata** | Attach arbitrary key/value metadata to any file |
| ⏳ **Expiring / Temp Files** | Give a file a TTL; an app-driven or background sweep permanently deletes it when due |
| 📊 **Metrics** | Per-backend operation counters (count, errors, bytes, latency) via a pluggable recorder |
| 🗑️ **Trash & Restore** | Soft-delete moves items to a per-user trash folder, hard-delete is one click away |
| 🌐 **Web UI** | Optional Gin-based dashboard with drag-and-drop, breadcrumbs, and previews |
Expand Down Expand Up @@ -230,6 +232,7 @@ Or with options:
| `WithUploadScanner(scan.Scanner)` | Reject uploads before they're stored — see [Upload Scanning](#upload-scanning) |
| `WithDedup()` | Collapse identical uploads in a folder to one blob — see [Deduplication](#deduplication) |
| `WithMetrics(metrics.Recorder)` | Record per-backend operation metrics — see [Metrics](#metrics) |
| `WithExpirySweeper(time.Duration)` | Run a background ticker that purges expired files — see [Expiring / Temp Files](#expiring--temp-files) |
| `WithMaxFileSize(int64)` | Reject uploads larger than the limit (0 = no limit) |
| `WithMaxTrashBatchSize(int)` | Cap descendant files moved in a single folder delete |
| `WithBackendOpTimeout(time.Duration)` | Bound backend I/O time during a delete (negative = disable) |
Expand Down Expand Up @@ -583,6 +586,46 @@ Each `metrics.Stat` holds `Count`, `Errors`, `Bytes`, and `TotalDur` (divide by

---

## Expiring / Temp Files

Give a file a TTL and buckt will permanently delete it — blob, image derivatives, and metadata row — once it's due. Ideal for temp uploads, one-time shares, and scratch files.

```go
// Upload as a temp file — the TTL is written in the same insert ("save temp"),
// so there's no window where the file exists without an expiry:
fileID, _ := client.UploadFileWithTTL("user123", "", "share.zip", "application/zip", data, 24*time.Hour)
// (UploadFileWithExpiry takes an absolute time; UploadFileFromReaderWithTTLContext streams.)

// Or add / change / clear a TTL on an existing file:
client.SetFileTTL(fileID, 24*time.Hour) // expire 24h from now
client.SetFileExpiry(fileID, someTime) // absolute; pass the zero time.Time to clear
```

Temp uploads are never deduplicated, so a temp file is always its own object (it can never collapse onto — and later expire — a permanent file that shares its bytes).

Expiry isn't automatic on its own — a **sweep** does the deleting, and you choose who drives it:

**App-driven (recommended for control / multi-instance):** call `PurgeExpired` from your own cron/worker.

```go
purged, err := client.PurgeExpired(ctx) // permanently deletes everything past due; returns the count
```

**Built-in background sweeper (convenient for single-instance):** let buckt run the ticker for you.

```go
client, _ := buckt.Default(buckt.WithExpirySweeper(10 * time.Minute))
// buckt now calls PurgeExpired every 10 minutes; Client.Close() stops it.
```

Each expiry deletion emits a `file.purged` [event](#lifecycle-events), so you can hook post-deletion behaviour (audit log, notify, etc.). `PurgeExpired` works in batches and is safe to call repeatedly.

The bundled web client exposes expiry too: the upload endpoints accept an optional `ttl` form field (e.g. `ttl=24h`) to upload a temp file directly, and the UI's upload dialog has an **Auto-delete after** selector. There's also `PUT /expiry/:file_id` (form/query `ttl=24h` or absolute `at=<RFC3339>`; empty/`0` clears) and `POST /purge-expired`; each file's menu has an **Expire in 1h / 24h / 7d** control and an expiry badge. The `ui` example can run the sweeper with `make ui EXPIRY=30s`, and `example/expiry/headless` walks the whole lifecycle.

> **Beyond deletion:** because expiry rides on the events system, it's the first step toward general *scheduled actions* ("after 24h, email this file"). The delete action is built in; a future release can add app-registered actions on the same sweep. Ask if you need that now.

---

## Trash & Deletion

Buckt has two delete modes:
Expand Down Expand Up @@ -650,6 +693,7 @@ The optional web client gives you a Gin-based HTTP API and a Tailwind UI for fre
- 📝 Inline rename
- 🗂️ Folder browser for choosing move targets
- 🗑️ Move to Trash + Delete Permanently options
- ⏳ Per-file "Expire in 1h / 24h / 7d" with an expiry badge
- ⌨️ Keyboard shortcuts (Esc to close modals)

[<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/17061476-00806d0d-9584-4889-ade7-f8407932dba2?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D17061476-00806d0d-9584-4889-ade7-f8407932dba2%26entityType%3Dcollection%26workspaceId%3D28697276-d953-482a-bd39-c4695366a55a)
Expand Down Expand Up @@ -770,6 +814,13 @@ DeleteFilePermanently(fileID string) (string, error) // → hard delete
SetFileMetadata(fileID string, metadata map[string]string) error
GetFileMetadata(fileID string) (map[string]string, error)

// Expiry / temp files
UploadFileWithTTL(userID, parentID, name, contentType string, data []byte, ttl time.Duration) (string, error) // upload a temp file
UploadFileWithExpiry(userID, parentID, name, contentType string, data []byte, at time.Time) (string, error) // upload with absolute expiry
SetFileTTL(fileID string, ttl time.Duration) error // add/change a TTL after upload
SetFileExpiry(fileID string, at time.Time) error // absolute; zero time clears
PurgeExpired(ctx context.Context) (purged int, err error) // delete everything past due

// Image derivatives
GenerateDerivatives(fileID string) error
GetDerivative(fileID, name string) (data []byte, contentType string, err error)
Expand Down Expand Up @@ -814,6 +865,7 @@ Branch on failures with `errors.Is` using the re-exported sentinels (also availa
| [Azure Blob Storage](example/cloud/azure/main.go) | Azure setup |
| [Full-featured UI](example/client/web/ui/main.go) | Metrics, dedup, derivatives, and upload events across `local`/`migrate`/`r2` modes |
| [Headless migration](example/migration/headless/main.go) | Bulk `MigrateAll` + progress polling without the UI |
| [Expiring / temp files](example/expiry/headless/main.go) | `SetFileTTL` → `PurgeExpired` lifecycle |

---

Expand Down
177 changes: 175 additions & 2 deletions buckt.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type Client struct {

fileService domain.FileService
folderService domain.FolderService

// sweeperStop cancels the optional background expiry sweeper (WithExpirySweeper).
// Nil when no sweeper is running. Called by Close.
sweeperStop func()
}

// New initializes a new Buckt client with the provided configuration options.
Expand Down Expand Up @@ -155,6 +159,11 @@ func New(conf Config, opts ...ConfigFunc) (*Client, error) {
folderService: folderService,
}

// Optional background expiry sweeper (opt-in via WithExpirySweeper).
if conf.ExpirySweepInterval > 0 {
buckt.startExpirySweeper(conf.ExpirySweepInterval)
}

bucktLog.Info("✅ Buckt initialized")

return buckt, nil
Expand Down Expand Up @@ -196,6 +205,9 @@ func Default(opts ...ConfigFunc) (*Client, error) {
// value is source-compatible — existing `defer client.Close()` and
// `client.Close()` call sites keep working.
func (b *Client) Close() error {
if b.sweeperStop != nil {
b.sweeperStop()
}
b.lruCache.Close()
return b.db.Close()
}
Expand Down Expand Up @@ -650,6 +662,54 @@ func (b *Client) UploadFileContext(ctx context.Context, user_id string, parent_i
return b.fileService.CreateFile(ctx, user_id, parent_id, file_name, content_type, file_data)
}

// UploadFileWithTTL uploads a file that will be permanently deleted ttl from now
// — a "save temp". The expiry is written in the same insert as the file, so
// there's no window where the file exists without its TTL. A non-positive ttl
// uploads a normal (non-expiring) file. Temp uploads are never deduplicated, so
// a temp file is always its own object. Delete happens on a PurgeExpired sweep
// (call it yourself or use WithExpirySweeper). See also SetFileTTL to add or
// change a TTL after upload.
func (b *Client) UploadFileWithTTL(user_id, parent_id, file_name, content_type string, file_data []byte, ttl time.Duration) (string, error) {
return b.UploadFileWithTTLContext(context.Background(), user_id, parent_id, file_name, content_type, file_data, ttl)
}

// UploadFileWithTTLContext is UploadFileWithTTL with an explicit context.
func (b *Client) UploadFileWithTTLContext(ctx context.Context, user_id, parent_id, file_name, content_type string, file_data []byte, ttl time.Duration) (string, error) {
return b.fileService.CreateFileWithExpiry(ctx, user_id, parent_id, file_name, content_type, file_data, ttlToExpiry(ttl))
}

// UploadFileWithExpiry uploads a file that will be permanently deleted at the
// given absolute time. The zero time.Time uploads a normal (non-expiring) file.
// Like UploadFileWithTTL, the expiry is set atomically and the upload is not
// deduplicated.
func (b *Client) UploadFileWithExpiry(user_id, parent_id, file_name, content_type string, file_data []byte, at time.Time) (string, error) {
return b.UploadFileWithExpiryContext(context.Background(), user_id, parent_id, file_name, content_type, file_data, at)
}

// UploadFileWithExpiryContext is UploadFileWithExpiry with an explicit context.
func (b *Client) UploadFileWithExpiryContext(ctx context.Context, user_id, parent_id, file_name, content_type string, file_data []byte, at time.Time) (string, error) {
return b.fileService.CreateFileWithExpiry(ctx, user_id, parent_id, file_name, content_type, file_data, timeToExpiry(at))
}

// ttlToExpiry converts a TTL to an absolute-expiry pointer: nil for a
// non-positive ttl (no expiry), else now+ttl.
func ttlToExpiry(ttl time.Duration) *time.Time {
if ttl <= 0 {
return nil
}
t := time.Now().Add(ttl)
return &t
}

// timeToExpiry converts an absolute time to an expiry pointer: nil for the zero
// time (no expiry), else a copy of at.
func timeToExpiry(at time.Time) *time.Time {
if at.IsZero() {
return nil
}
return &at
}

// UploadFileFromReaderContext uploads a file to the specified user's bucket from an io.Reader.
//
// Parameters:
Expand All @@ -664,6 +724,19 @@ func (b *Client) UploadFileContext(ctx context.Context, user_id string, parent_i
// - string: The ID of the newly created file.
// - error: An error if the file upload fails, otherwise nil.
func (b *Client) UploadFileFromReaderContext(ctx context.Context, user_id string, parent_id string, file_name string, content_type string, file_data io.Reader) (string, error) {
return b.uploadFromReader(ctx, user_id, parent_id, file_name, content_type, file_data, nil)
}

// UploadFileFromReaderWithTTLContext streams a file that will be permanently
// deleted ttl from now — the streaming form of UploadFileWithTTL. A non-positive
// ttl uploads a normal (non-expiring) file.
func (b *Client) UploadFileFromReaderWithTTLContext(ctx context.Context, user_id, parent_id, file_name, content_type string, file_data io.Reader, ttl time.Duration) (string, error) {
return b.uploadFromReader(ctx, user_id, parent_id, file_name, content_type, file_data, ttlToExpiry(ttl))
}

// uploadFromReader is the shared reader-upload core; expiresAt is nil for a
// normal upload or a time for a temp/expiring upload.
func (b *Client) uploadFromReader(ctx context.Context, user_id, parent_id, file_name, content_type string, file_data io.Reader, expiresAt *time.Time) (string, error) {
// Try to use Seeker for efficiency if available
if seeker, ok := file_data.(io.Seeker); ok {
fileSize, err := seeker.Seek(0, io.SeekEnd)
Expand All @@ -686,7 +759,7 @@ func (b *Client) UploadFileFromReaderContext(ctx context.Context, user_id string
if _, err = io.ReadFull(file_data, file_bytes); err != nil {
return "", err
}
return b.fileService.CreateFile(ctx, user_id, parent_id, file_name, content_type, file_bytes)
return b.fileService.CreateFileWithExpiry(ctx, user_id, parent_id, file_name, content_type, file_bytes, expiresAt)
}

// Fallback: read with bounded reader for non-seekable streams
Expand All @@ -702,7 +775,7 @@ func (b *Client) UploadFileFromReaderContext(ctx context.Context, user_id string
return "", fmt.Errorf("file size exceeds maximum allowed size %d bytes", b.maxFileSize)
}

return b.fileService.CreateFile(ctx, user_id, parent_id, file_name, content_type, file_bytes)
return b.fileService.CreateFileWithExpiry(ctx, user_id, parent_id, file_name, content_type, file_bytes, expiresAt)
}

// GetFileContext retrieves a file based on the provided file ID.
Expand Down Expand Up @@ -983,6 +1056,106 @@ func (b *Client) MigrationFailures(ctx context.Context) (failed int64, ok bool)
return failed, true
}

/* Expiry */

// SetFileExpiry sets the time at which a file is automatically, permanently
// deleted by PurgeExpired (and by the optional background sweeper). Passing the
// zero time.Time clears the expiry, making the file permanent again.
func (b *Client) SetFileExpiry(file_id string, at time.Time) error {
return b.SetFileExpiryContext(context.Background(), file_id, at)
}

// SetFileExpiryContext is SetFileExpiry with an explicit context.
func (b *Client) SetFileExpiryContext(ctx context.Context, file_id string, at time.Time) error {
if at.IsZero() {
return b.fileService.SetExpiry(ctx, file_id, nil)
}
return b.fileService.SetExpiry(ctx, file_id, &at)
}

// SetFileTTL sets a file to expire ttl from now — the convenient form for temp
// files ("delete this in 1h"). A non-positive ttl clears the expiry.
func (b *Client) SetFileTTL(file_id string, ttl time.Duration) error {
return b.SetFileTTLContext(context.Background(), file_id, ttl)
}

// SetFileTTLContext is SetFileTTL with an explicit context.
func (b *Client) SetFileTTLContext(ctx context.Context, file_id string, ttl time.Duration) error {
if ttl <= 0 {
return b.fileService.SetExpiry(ctx, file_id, nil)
}
return b.SetFileExpiryContext(ctx, file_id, time.Now().Add(ttl))
}

// PurgeExpired permanently deletes every file whose expiry has passed — blob,
// image derivatives, and metadata row — emitting a file.uploaded-style
// file.purged event for each (so event handlers can react). It returns the
// number purged. Safe to call repeatedly; call it from your own scheduler, or
// let WithExpirySweeper call it for you.
//
// Work is done in batches so a large backlog doesn't load every row at once.
// A file that fails to purge is logged and left for the next run; if an entire
// batch fails to make progress, PurgeExpired stops and returns an error rather
// than spinning.
func (b *Client) PurgeExpired(ctx context.Context) (purged int, err error) {
const batch = 500
for {
if err := ctx.Err(); err != nil {
return purged, err
}

files, ferr := b.fileService.FindExpired(ctx, time.Now(), batch)
if ferr != nil {
return purged, ferr
}
if len(files) == 0 {
return purged, nil
}

progressed := 0
for _, f := range files {
if _, derr := b.DeleteFilePermanentlyContext(ctx, f.ID.String()); derr != nil {
b.logger.Warn("failed to purge expired file " + f.ID.String() + ": " + derr.Error())
continue
}
purged++
progressed++
}

// Every file in this batch failed — stop rather than loop forever over
// the same undeletable rows.
if progressed == 0 {
return purged, fmt.Errorf("failed to purge any of %d expired file(s): %w", len(files), ErrBackendUnavailable)
}
// A short final batch means there's nothing left to fetch.
if len(files) < batch {
return purged, nil
}
}
}

// startExpirySweeper launches the background ticker started by WithExpirySweeper.
func (b *Client) startExpirySweeper(interval time.Duration) {
ctx, cancel := context.WithCancel(context.Background())
b.sweeperStop = cancel
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := b.PurgeExpired(ctx); err != nil {
b.logger.Warn("expiry sweep failed: " + err.Error())
} else if n > 0 {
b.logger.Infof("expiry sweep purged %d file(s)", n)
}
}
}
}()
}

/* Helper Methods */

func initializeCache(conf CacheConfig, bucktLog domain.BucktLogger) (domain.CacheManager, domain.LRUCache) {
Expand Down
Loading
Loading