Rhaqim

SystemsGo

buckt

Application-layer media management for Go: folder trees, metadata, derivatives and dedup on top of any object store.

StableGoSince 2025★ 4MIT
go get github.com/Rhaqim/buckt

Buckt sits inside your Go application and manages media workflows, delegating the actual bytes to whatever backend you point it at. MinIO answers “where should the bytes be stored?” Buckt answers “how should my application manage media?” You can run both: Buckt happily uses MinIO as a backend.

┌─────────────────────────────────────────────────────────────┐
│                      Your Application                       │
└──────────────────────────┬──────────────────────────────────┘

                  ┌────────▼─────────┐
                  │   buckt.Client   │  ◄── direct API or web client
                  └────────┬─────────┘
            ┌──────────────┼──────────────┐
            │              │              │
       ┌────▼────┐   ┌─────▼─────┐  ┌─────▼──────┐
       │ Folder  │   │   File    │  │   Cache    │
       │ Service │   │  Service  │  │  Manager   │
       └────┬────┘   └─────┬─────┘  └────────────┘
            │              │
       ┌────▼──────────────▼─────┐
       │   Repository (GORM)     │  ◄── SQLite or Postgres
       └────┬────────────────────┘

       ┌────▼─────────────────────────────────────┐
       │            FileBackend                   │
       │  ┌──────┐ ┌─────┐ ┌─────┐ ┌──────┐ ┌──┐  │
       │  │Local │ │ S3  │ │ GCS │ │Azure │ │R2│  │
       │  └──────┘ └─────┘ └─────┘ └──────┘ └──┘  │
       └──────────────────────────────────────────┘

Install

go get github.com/Rhaqim/buckt

Cloud backends and the web UI are separate Go modules, so the core stays lean. Pull in only the SDKs you actually use.

go get github.com/Rhaqim/buckt/cloud/aws     # S3 + Cloudflare R2
go get github.com/Rhaqim/buckt/cloud/gcp     # Google Cloud Storage
go get github.com/Rhaqim/buckt/cloud/azure   # Azure Blob Storage
go get github.com/Rhaqim/buckt/client/web    # Web UI + HTTP API

Quick start

package main

import (
  "log"
  "github.com/Rhaqim/buckt"
)

func main() {
  client, err := buckt.Default()
  if err != nil {
    log.Fatal(err)
  }
  defer client.Close()

  fileID, err := client.UploadFile(
    "user123",    // owner
    "",           // parent folder ID (empty = root)
    "hello.txt",
    "text/plain",
    []byte("hello world"),
  )
  if err != nil {
    log.Fatal(err)
  }

  file, _ := client.GetFile(fileID)
  log.Println(string(file.Data))
}

Files land in ./media, metadata in ./db.sqlite. No extra setup.

Configuration

Functional options. Everything is optional, Default() gives you working defaults.

client, err := buckt.Default(
  buckt.WithLog(buckt.LogConfig{LogTerminal: true, LogFile: "logs"}),
  buckt.WithDB(buckt.Postgres, sqlDB),
  buckt.WithBackend(s3Backend),
  buckt.WithMaxFileSize(buckt.DefaultMaxFileSize),
  buckt.MediaDir("./uploads"),
  buckt.FlatNameSpaces(true),
)
OptionDescription
WithLog(LogConfig)Terminal/file logging, or pass a custom *log.Logger
WithDB(driver, *sql.DB)Bring your own connection (Postgres or SQLite)
WithTablePrefix(string)Prefix table names to share a database with other apps
WithCache(CacheConfig)Custom cache manager + LRU file-cache tuning
WithBackend(Backend)Set the storage backend
WithMigration(MigrationConfig)Dual-write migration between two backends
WithImageDerivatives(...DerivativeSpec)Define resized image variants
WithImageProcessor(imageproc.Processor)Swap the image processor (e.g. WebP)
WithEventHandler(events.Handler)Post-operation lifecycle hook
WithUploadScanner(scan.Scanner)Reject uploads before they’re stored
WithDedup()Collapse identical uploads in a folder to one blob
WithMetrics(metrics.Recorder)Per-backend operation metrics
WithMaxFileSize(int64)Reject oversized uploads (0 = no limit)
WithMaxTrashBatchSize(int)Cap descendants moved in a single folder delete
WithBackendOpTimeout(duration)Bound backend I/O during a delete
MediaDir(string)Local media directory
FlatNameSpaces(bool)UUID filenames at the root vs. hierarchical paths

Storage backends

The FileBackend interface lets you swap providers without touching application code.

BackendModuleUse case
Local FSbuilt inDevelopment, single-server deployments
AWS S3cloud/awsProduction cloud, durable object storage
Cloudflare R2cloud/awsS3-compatible, zero egress fees
Google Cloud Storagecloud/gcpGCP-native applications
Azure Blobcloud/azureAzure-native applications
MinIO / Cephcloud/awsSelf-hosted S3-compatible storage
import (
  "github.com/Rhaqim/buckt"
  "github.com/Rhaqim/buckt/cloud/aws"
)

s3, err := aws.NewBackend(aws.Config{
  AccessKey: "AKIA...",
  SecretKey: "...",
  Region:    "us-east-1",
  Bucket:    "my-bucket",
})
if err := s3.Ping(ctx); err != nil { /* fail fast on bad credentials */ }

client, _ := buckt.Default(buckt.WithBackend(s3))

R2 is S3-compatible, so the cloud/aws backend auto-detects it from the endpoint suffix and switches to path-style addressing. Leave Region empty; it defaults to auto.

r2, err := aws.NewBackend(aws.Config{
  AccessKey: "your-r2-access-key",
  SecretKey: "your-r2-secret",
  Bucket:    "my-bucket",
  Endpoint:  "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
})

Every cloud backend exposes Ping(ctx). Call it after NewBackend so credential and network problems surface at startup instead of on the first upload.

Live migration

Moving from local disk to S3, or S3 to R2, without downtime. Migration mode dual-writes, reads from both, and lazily migrates forward on read.

              ┌───────────┐    Put   ┌──────────────┐
   Write ────►│  Primary  ├─────────►│  Secondary   │
              │ (current) │          │   (target)   │
              └─────┬─────┘          └──────┬───────┘
                    │                       │
                    │  Get: try primary,    │
                    │  fall back to         │
                    │  secondary if missing │
                    │                       │
                    └─────────► File ◄──────┘
s3, _ := aws.NewBackend(s3Config)

client, _ := buckt.Default(buckt.WithMigration(buckt.MigrationConfig{
  From:        buckt.LocalBackend(), // current source of truth
  To:          s3,                   // target
  Concurrency: 16,                   // default 8
}))
OperationBehavior
PutWrites to both. Primary failure is a hard error; secondary failure is logged.
GetPrimary first, lazy-mirrors to secondary. Falls back to secondary if primary is missing.
DeleteDeletes from both.
MoveMoves in both.

Migration is always forward. The primary is the source of truth and is never overwritten by secondary content.

Dual-write only mirrors new activity. To copy everything that predates the cutover, call MigrateAll and poll:

if err := client.MigrateAll(ctx); err != nil {
  log.Fatal(err) // ErrBackendUnavailable if not built WithMigration
}

for {
  done, total, _ := client.MigrationStatus(ctx)
  log.Printf("migrated %d/%d", done, total)
  if total > 0 && done >= total {
    break
  }
  time.Sleep(time.Second)
}

if failed, _ := client.MigrationFailures(ctx); failed > 0 {
  log.Printf("%d file(s) failed after retries; fix the cause and re-run", failed)
}

MigrateAll is resumable and idempotent: each copied object is recorded in buckt_migration_models, so a restarted migration skips what it already did rather than re-scanning the target. Each in-flight file is buffered in full, so higher concurrency trades memory and provider rate-limit headroom for throughput.

client.BackendName() reports the active backend: "local", "s3", or "local->s3" mid-migration. Once you’re done, drop migration mode:

client, _ := buckt.Default(buckt.WithBackend(s3))

Image derivatives

Generate resized variants from uploads. The built-in processor is pure Go and handles JPEG and PNG with no external dependencies.

client, _ := buckt.Default(
  buckt.WithImageDerivatives(
    buckt.DerivativeSpec{Name: "thumbnail", MaxWidth: 200},
    buckt.DerivativeSpec{Name: "medium", MaxWidth: 800},
  ),
)

_ = client.GenerateDerivatives(fileID)

data, contentType, err := client.GetDerivative(fileID, "thumbnail")
FieldDescription
NameVariant name used to fetch it back
MaxWidthMax width in px; aspect ratio preserved, never upscaled
Format"" keeps the source format; jpeg/png built in; webp needs a matching processor

WebP lives in its own module so the core stays dependency-free:

import "github.com/Rhaqim/buckt/imageproc/webp"

client, _ := buckt.Default(
  buckt.WithImageProcessor(webp.New()),
  buckt.WithImageDerivatives(
    buckt.DerivativeSpec{Name: "thumbnail", MaxWidth: 200, Format: "webp"},
  ),
)

Resizing runs inline with GenerateDerivatives. For heavy workloads, call it from an event handler that enqueues to a worker rather than blocking the upload.

Events and scanning

Lifecycle handlers run synchronously after the operation commits. Keep them fast. The intended pattern is enqueue-and-return. A panicking handler is recovered and never fails the originating call.

import "github.com/Rhaqim/buckt/pkg/events"

onEvent := func(ctx context.Context, e events.Event) {
  if e.Type == events.FileUploaded {
    _ = client.GenerateDerivatives(e.FileID)
  }
}

client, _ := buckt.Default(buckt.WithEventHandler(onEvent))
EventFires when
events.FileUploadedA new file’s bytes are committed
events.FileTrashedA file is moved to trash
events.FileRestoredA trashed file is restored
events.FilePurgedA file is hard-deleted

Events fire after the write, so they can’t block an upload. To reject a file before it’s stored, register a scanner. Buckt ships no scanning engine by design. You supply one (ClamAV, VirusTotal, a content-type allowlist).

import "github.com/Rhaqim/buckt/pkg/scan"

scanner := scan.ScannerFunc(func(ctx context.Context, name string, data []byte) error {
  return clamav.Scan(ctx, data) // non-nil rejects the upload
})

client, _ := buckt.Default(buckt.WithUploadScanner(scanner))
_, err := client.UploadFile(userID, "", "invoice.pdf", "application/pdf", data)
if errors.Is(err, buckt.ErrUploadRejected) {
  // err also wraps the scanner's own reason
}

The scanner runs at Buckt’s single upload chokepoint, so every upload path is covered. A caller can’t forget to wire it in per call site.

Dedup, metadata, metrics

With WithDedup(), an upload whose bytes hash-match a file already in the same target folder for the same owner returns the existing file’s ID instead of writing the blob again. Scoped to the folder, so it composes with nested namespaces and never resurrects a trashed duplicate. Off by default.

client, _ := buckt.Default(buckt.WithDedup())

Arbitrary key/value metadata attaches to any file, stored as JSON on the record:

_ = client.SetFileMetadata(fileID, map[string]string{
  "source": "web-ui",
  "album":  "vacation-2026",
})

meta, _ := client.GetFileMetadata(fileID)

Metrics use a pluggable recorder; the built-in collector has no dependencies and costs nothing when unused.

import "github.com/Rhaqim/buckt/pkg/metrics"

collector := metrics.NewCollector()
client, _ := buckt.Default(buckt.WithMetrics(collector))

snap := collector.Snapshot() // map[backend]map[operation]metrics.Stat

Each Stat holds Count, Errors, Bytes and TotalDur. Implement the one-method metrics.Recorder interface to forward to Prometheus, StatsD, or your own sink.

Trash and deletion

ActionResultAPI
Move to trashMoved to a per-user __trash__ folder. Reversible.DeleteFile / DeleteFolder
Delete permanentlyHard-deleted from DB and backend. Irreversible.DeleteFilePermanently / DeleteFolderPermanently

The trash folder is a real folder hidden from normal listings, and hierarchy is preserved when items are trashed. Calling DeleteFile on something already in trash hard-deletes it, giving you an “empty trash” mechanism with no extra API.

trash, _ := client.GetTrashFolder("user123")

client.MoveFile(fileID, originalParentID) // restore
client.DeleteFile(fileID)                 // already trashed → hard delete

In non-flat namespace mode, deleted items are physically moved on the backend so paths stay consistent, not just renamed in the database.

Web client

An optional Gin-based HTTP API and Tailwind UI, in its own module.

import (
  "github.com/Rhaqim/buckt"
  web "github.com/Rhaqim/buckt/client/web"
)

func main() {
  bucktClient, _ := buckt.Default()
  defer bucktClient.Close()

  router, _ := web.NewClient(bucktClient)
  router.Run(":8080")
}
ModeRoutes
WebModeAll (default)UI at /web + API
WebModeAPIAPI only
WebModeUIUI only
WebModeMountAPI only, for mounting onto a parent Gin engine

The UI gives you breadcrumb navigation, image/video/audio/PDF previews, drag-and-drop moves, inline rename, a folder browser for move targets, and both delete modes.

API reference

// Folders
NewFolder(userID, parentID, name, description string) (string, error)
ListFolders(folderID string) ([]FolderModel, error)
GetFolderWithContent(userID, folderID string) (*FolderModel, error)
GetTrashFolder(userID string) (*FolderModel, error)
MoveFolder(userID, folderID, newParentID string) error
RenameFolder(userID, folderID, newName string) error
DeleteFolder(folderID string) (string, error)                     // → trash
DeleteFolderPermanently(userID, folderID string) (string, error)  // → hard delete

// Files
UploadFile(userID, parentID, name, contentType string, data []byte) (string, error)
UploadFileFromReader(userID, parentID, name, contentType string, r io.Reader) (string, error)
GetFile(fileID string) (*FileModel, error)
GetFileStream(fileID string) (*FileModel, io.ReadCloser, error)
ListFiles(folderID string) ([]FileModel, error)
ListFilesMetadata(folderID string) ([]FileModel, error)
MoveFile(fileID, newParentID string) error
DeleteFile(fileID string) (string, error)              // → trash
DeleteFilePermanently(fileID string) (string, error)   // → hard delete

// Metadata
SetFileMetadata(fileID string, metadata map[string]string) error
GetFileMetadata(fileID string) (map[string]string, error)

// Derivatives
GenerateDerivatives(fileID string) error
GetDerivative(fileID, name string) (data []byte, contentType string, err error)

// Migration (only WithMigration)
MigrateAll(ctx context.Context) error
MigrationStatus(ctx context.Context) (completed, total int64, ok bool)
MigrationFailures(ctx context.Context) (failed int64, ok bool)
BackendName() string

Every method has a *Context variant taking an explicit context.Context.

Branch on failures with errors.Is using the re-exported sentinels:

SentinelMeaningSuggested status
ErrNotFoundFile, folder or derivative doesn’t exist404
ErrInvalidIDNot a valid UUID400
ErrInvalidNameEmpty or unsafe name400
ErrAlreadyExistsName collision on create/move/rename409
ErrFileTooLargeExceeds WithMaxFileSize413
ErrUploadRejectedRejected by an upload scanner422
ErrTrashBatchExceededFolder delete exceeds the trash batch cap409
ErrBackendUnavailableBackend unreachable / feature not enabled503

Security defaults

ProtectionDetails
Path traversalAll paths validated against the media directory boundary
File size limitsOversized uploads rejected before allocating memory (io.LimitReader)
Content sniffingReal content type detected from bytes when the client sends a generic one
Filename injectionRFC 6266 percent-encoded Content-Disposition
MIME sniffingX-Content-Type-Options: nosniff on all file-serving endpoints
Command injectionPath validation before passing to ffmpeg/convert
Self-moveFolders can’t be moved into themselves or their descendants
Constraint integrityUnique (user_id, parent_id, name) prevents collisions

The repo also runs gitleaks as a pre-commit hook and in CI, so credentials never land in git.