Media

Storage, Assets, And Media

Understand GoLazy object storage, asset export, file catalogs, and generated media variants.

By Guillermo Alvarez - Published Updated

The layers

The media stack is deliberately split:

  • lazystorage stores object bytes by key.
  • lazyassets registers, fingerprints, serves, and exports application assets.
  • lazyfiles gives stored bytes stable file IDs and fallback URLs.
  • lazymedia records generated variants such as thumbnails or previews.

Keep those layers separate in application services. Controllers should call product-level operations such as "store avatar" or "preview document", not assemble storage keys, write repositories, and generate variants inline.

Storage

golazy.dev/lazystorage is the byte layer. Its smallest interface opens an object:

type Storage interface {
    Open(context.Context, string, ...any) (lazystorage.File, []any, error)
}

Optional interfaces add capabilities:

type Writer interface {
    Put(context.Context, string, io.Reader, ...any) (lazystorage.Info, []any, error)
}

type Deleter interface {
    Delete(context.Context, string, ...any) ([]any, error)
}

type Lister interface {
    List(context.Context, string, ...any) (lazystorage.Iterator, []any, error)
}

type URLer interface {
    URL(context.Context, string, ...any) (lazystorage.URL, []any, error)
}

Backends consume the options they understand and return the rest. Object keys use io/fs path rules: relative, clean, no leading slash, and no ...

The local backend is lazystorage.NewFilesystem. The embedded S3-compatible backend is golazy.dev/lazystorage/s3:

storage := s3.New(
    s3.WithEndpoint(os.Getenv("ASSETS_S3_ENDPOINT")),
    s3.WithRegion(os.Getenv("ASSETS_S3_REGION")),
    s3.WithBucket(os.Getenv("ASSETS_S3_BUCKET")),
    s3.WithCredentials(
        os.Getenv("ASSETS_S3_ACCESS_KEY_ID"),
        os.Getenv("ASSETS_S3_SECRET_ACCESS_KEY"),
    ),
    s3.WithPublicBaseURL(os.Getenv("ASSETS_PUBLIC_URL")),
)

PostgreSQL-backed storage lives in golazy.dev/pg/pgstorage; read PostgreSQL Storage.

Assets

golazy.dev/lazyassets owns application assets, not uploaded user files. A Registry maps logical paths such as /styles.css to registered assets and, in production mode, to content-hashed permanent paths with ETags and integrity metadata.

Generated asset packages can implement:

type Source interface {
    Assets(*lazyassets.Registry) error
}

Applications usually let lazyapp register embedded public files and generated asset sources. The registry then serves assets through Handler and installs template helpers:

  • asset_path
  • asset_integrity
  • stylesheet
  • importmap
  • permalink

For deploys, assets can be exported to any lazystorage.Writer:

err := registry.Upload(
    ctx,
    storage,
    lazyassets.WithUploadPrefix("assets"),
)

There is no separate asset repository backend. If the export target should be PostgreSQL, use pgstorage.New(db) as the storage writer; read PostgreSQL Asset Uploads.

File catalog

golazy.dev/lazyfiles assigns stable IDs and metadata to stored bytes. The repository contract stores file records and locations:

type Repository interface {
    Put(context.Context, lazyfiles.File, lazyfiles.Location, ...any) (lazyfiles.File, []any, error)
    Find(context.Context, lazyfiles.Query, ...any) (lazyfiles.File, []lazyfiles.Location, []any, error)
    Delete(context.Context, string, ...any) ([]any, error)
}

Files combines a repository with named lazystorage backends:

files := &lazyfiles.Files{
    Repository: filesjsonlRepo,
    Storages: map[string]lazystorage.Storage{
        "local": lazystorage.NewFilesystem("storage/objects"),
    },
    DefaultStorage: "local",
    RoutePrefix:    "/files",
    SigningKey:     []byte(os.Getenv("FILE_SIGNING_KEY")),
}

The JSONL repository lives in golazy.dev/lazyfiles/jsonl. PostgreSQL-backed catalogs live in golazy.dev/pg/pgfiles; read PostgreSQL File Catalogs.

File URLs and controller fallback

Ask the file service for URLs:

href, _, err := files.URL(
    ctx,
    file.ID,
    lazystorage.ExpiresIn{Duration: 15 * time.Minute},
)

If the active storage implements lazystorage.URLer, Files.URL returns the direct backend URL. Otherwise it returns a signed fallback route under RoutePrefix.

Install files.Handler as middleware when fallback routes should be served by the app:

Middlewares: []lazydispatch.Middleware{
    lazydispatch.MiddlewareFunc(files.Handler),
},

Controllers should still call an app service. The service can store the upload, return a file ID, and decide whether a controller should redirect, render a URL, or enqueue follow-up media work.

Media variants

golazy.dev/lazymedia records generated representations of stored files. It does not own byte storage. It depends on a file service, a variant repository, and a processor:

type FileStore interface {
    Open(context.Context, string, ...any) (io.ReadCloser, lazymedia.File, []any, error)
    Put(context.Context, io.Reader, ...any) (lazymedia.File, []any, error)
    URL(context.Context, string, ...any) (string, []any, error)
}

type Repository interface {
    FindVariant(context.Context, string, string, ...any) (lazymedia.Variant, []any, error)
    SaveVariant(context.Context, lazymedia.Variant, ...any) (lazymedia.Variant, []any, error)
    DeleteVariant(context.Context, string, string, ...any) ([]any, error)
}

type Processor interface {
    Process(context.Context, lazymedia.Source, lazymedia.Request, ...any) (lazymedia.Result, []any, error)
}

Adapt lazyfiles.Files to FileStore in your app. The adapter is the right place to translate media options into file-catalog options:

type MediaFiles struct {
    Files *lazyfiles.Files
}

func (m MediaFiles) Put(ctx context.Context, body io.Reader, options ...any) (lazymedia.File, []any, error) {
    if name, remaining, ok := lazystorage.Take[lazymedia.OutputFilename](options); ok {
        options = append(remaining, lazyfiles.Filename{Name: name.Name})
    }
    file, remaining, err := m.Files.Put(ctx, body, options...)
    return mediaFile(file), remaining, err
}

The JSONL variant repository lives in golazy.dev/lazymedia/jsonl. PostgreSQL-backed variants live in golazy.dev/pg/pgmedia; read PostgreSQL Media Variants.

Compose the service

media := &lazymedia.Media{
    Files:      MediaFiles{Files: files},
    Repository: mediaRepo,
    Processor:  imageProcessor,
}

url, _, err := media.URL(ctx, lazymedia.Request{
    SourceFileID: file.ID,
    VariantKey:   "preview",
})

The first request generates and records the variant. Later requests reuse the ready output unless lazymedia.Regenerate{} is passed.

Helpers and controllers

Use template helpers for application assets, Files.URL for stored-file URLs, and app-owned service methods from controllers. Keep controller code focused on HTTP: parse the request, call the service, and render or redirect.

This keeps backend decisions in one place and lets tests swap filesystem, S3, PostgreSQL, JSONL, or in-memory fakes without changing controller actions.