Skip to content

Getting bytes in and out

The two halves of the job, and the decisions inside each that are much easier to make once than to fix later.

Uploading

The key is generated, the content type is decided by us, and nothing is buffered — three things a hand-rolled upload handler usually gets wrong. Storage also fails loudly where the cache degrades: a dropped upload is a file nobody can get back. See Uploading.

go
package main

import (
	"fmt"
	"io"
	"net/http"
	"path"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/errmsgs"
	"gitlab.finema.co/finema/idin-core/v2/utils"
)

// --- Example 1: putting bytes in a bucket -----------------------------------
//
// ctx.Storage() returns a handle already bound to the request, so the methods
// take no ctx and a client that hangs up aborts the upload it started.
//
// Storage does not degrade the way the cache does. A service with no S3_*
// configuration still gets a working handle, but every call on it fails with
// STORAGE_DISABLED — because a cache miss is recoverable (recompute the value)
// while an upload that was quietly dropped is a file the caller believes it
// saved and nobody can get back.

// maxUpload is what the service accepts through itself. Anything genuinely large
// should skip the process entirely — see 03_presign.go.
const maxUpload = 10 << 20 // 10 MiB

// documentKey builds the whole address of an object. Three decisions live here,
// and all three are easier to make once than to fix later:
//
//   - the prefix is structured (tenant → entity), so a tenant can be listed,
//     deleted or migrated without guessing which keys are theirs;
//   - the name is a UUID, because a filename the user supplied is untrusted and
//     because two people uploading "cv.pdf" must not become one object;
//   - the extension is kept, so a content type can still be guessed and a link
//     still looks like a file.
//
// The readable name is not lost — it goes in the database row and in
// Content-Disposition. The key must be unguessable and the filename must be
// readable, and those are different jobs.
func documentKey(tenantID, entity, filename string) string {
	return path.Join("tenants", tenantID, entity, utils.NewUUID()+path.Ext(filename))
}

// uploadReceipt stores something the service generated itself, so the type and
// the download name are known facts rather than claims from a client.
func uploadReceipt(ctx core.IContext, tenantID string, pdf []byte) (string, core.IError) {
	key := documentKey(tenantID, "receipts", "receipt.pdf")

	if err := ctx.Storage().PutBytes(key, pdf, core.StoragePutOptions{
		ContentType: "application/pdf",
		// non-ASCII survives: the header is written in both the quoted and the
		// RFC 5987 form
		Attachment: "ใบเสร็จ.pdf",
		// x-amz-meta-*, handed back by Stat. Small facts that should travel with
		// the object — not a database: it cannot be queried and is only visible
		// one object at a time
		Metadata: map[string]string{"tenant": tenantID},
	}); err != nil {
		return "", err
	}
	return key, nil
}

// uploadFromRequest streams a browser upload straight through to the bucket.
// Nothing is buffered, so a 200MB file does not become 200MB of heap.
func uploadFromRequest(c core.IHTTPContext) error {
	file, err := c.FormFile("file")
	if err != nil {
		return c.NewError(err, errmsgs.BadRequest)
	}
	// file.Size is the real size from the multipart parser — but the whole body
	// has already arrived by the time we can read it. To reject earlier, bound
	// the request body in middleware; to not receive it at all, presign.
	if file.Size > maxUpload {
		return c.NewError(nil, errmsgs.BadRequest)
	}

	src, err := file.Open()
	if err != nil {
		return c.NewError(err, errmsgs.BadRequest)
	}
	defer func() { _ = src.Close() }()

	key := documentKey(tenantOf(c), "uploads", file.Filename)

	// The Content-Type header is the uploader's claim, and an HTML file stored as
	// text/html *renders* when it is served — a stored-XSS hole. For anything a
	// user supplied, force a download instead of trusting the label.
	if err := c.Storage().Put(key, src, core.StoragePutOptions{
		ContentType: "application/octet-stream",
		Attachment:  file.Filename,
	}); err != nil {
		return err
	}

	// hand back the key, so the client can reference the object without the
	// service having to invent a URL for it
	return c.JSON(http.StatusOK, map[string]any{"key": key, "filename": file.Filename})
}

// uploadGenerated writes a file that never exists on disk and never exists whole
// in memory: the generator writes into one end of a pipe while the uploader
// reads the other.
func uploadGenerated(ctx core.IContext, key string, rows []string) core.IError {
	pr, pw := io.Pipe()

	go func() {
		// CloseWithError is the part that matters. Without it, a generator that
		// fails half way closes the pipe cleanly and produces a truncated object
		// that uploads *successfully*.
		_ = pw.CloseWithError(writeRows(pw, rows))
	}()

	return ctx.Storage().Put(key, pr, core.StoragePutOptions{
		ContentType: "text/csv",
		Attachment:  "orders.csv",
	})
}

func writeRows(w io.Writer, rows []string) error {
	for _, row := range rows {
		if _, err := fmt.Fprintln(w, row); err != nil {
			return err
		}
	}
	return nil
}

// tenantOf reads the tenant off the authenticated user. ContextUser carries the
// identity the auth middleware resolved; anything beyond the named fields lives
// in Data.
func tenantOf(c core.IHTTPContext) string {
	if user := c.GetUser(); user != nil {
		return user.Data["tenant_id"]
	}
	return "public"
}

Reading, listing, deleting

Streaming an object through the service, and why you usually should not. The absent key is an ordinary 404, List is a paginated scan rather than an index, and Move is a copy plus a delete because S3 has no rename.

go
package main

import (
	"errors"
	"net/http"
	"strconv"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/errmsgs"
)

// --- Example 2: reading, listing, deleting ----------------------------------
//
// Object storage is not a filesystem, and most storage bugs come from treating
// it like one: the key space is flat, an object is immutable, there is no
// rename, and "ls" is a paginated scan. Reading is a network call with network
// latency and network failures — including the most ordinary one of all, the key
// that is not there.

// readSmallObject reads a whole object into memory. That is exactly Get plus
// io.ReadAll, so the size limit is however much memory you are willing to spend
// per concurrent caller — fine for a JSON blob, wrong for a video.
func readSmallObject(ctx core.IContext, key string) ([]byte, core.IError) {
	data, err := ctx.Storage().GetBytes(key)
	if errors.Is(err, core.ErrObjectNotFound) {
		// an absent key is an answer, not a failure: it becomes a 404, and it
		// must not be reported to Sentry as a server error
		return nil, ctx.NewError(err, errmsgs.NotFound)
	}
	if err != nil {
		return nil, err
	}
	return data, nil
}

// serveObject proxies an object through the service. Do this when access has to
// be *checked* — a private document, a per-user file. Otherwise prefer a signed
// link (03_presign.go): proxying moves every byte through the process, and one
// large download holds a request slot for its whole duration.
func serveObject(c core.IHTTPContext, key string) error {
	// Stat first, because the response needs the length and the type — and it
	// transfers no body, so it costs one cheap round trip rather than a guess
	info, err := c.Storage().Stat(key)
	if errors.Is(err, core.ErrObjectNotFound) {
		return c.NewError(err, errmsgs.NotFound)
	}
	if err != nil {
		return err
	}

	body, err := c.Storage().Get(key)
	if err != nil {
		return err
	}
	// Close it. An unclosed body holds an HTTP connection from the SDK's pool, so
	// a handler that leaks one per request runs out of connections rather than
	// out of memory — a far more confusing outage.
	defer func() { _ = body.Close() }()

	c.Response().Header().Set("Content-Length", strconv.FormatInt(info.Size, 10))
	return c.Stream(http.StatusOK, info.ContentType, body)
}

// listTenantObjects lists one tenant's objects through a prefixed handle, so no
// call site ever writes the tenant id into a key and no call site can get it
// wrong.
//
// A Limit is passed on purpose: without one, List follows pagination to the end
// and returns every object under the prefix — many round trips and a large slice
// for a prefix nobody has bounded.
func listTenantObjects(ctx core.IContext, tenantID, prefix string) ([]core.StorageObject, core.IError) {
	tenant := ctx.Storage().WithPrefix("tenants/" + tenantID)
	// keys come back without the storage prefix — the same names they were
	// written under, so a listing feeds straight back into Get or Delete
	return tenant.List(prefix, core.StorageListOptions{Limit: 100})
}

// checkThenRead is the mistake worth naming: two round trips to answer one
// question, and the object can disappear between them. Just Get and handle the
// not-found. Exists earns its keep when the answer itself is the product ("is
// this upload finished yet"), not as a guard.
func checkThenRead(ctx core.IContext, key string) ([]byte, core.IError) {
	ok, err := ctx.Storage().Exists(key) // absence is false, not an error
	if err != nil || !ok {
		return nil, err
	}
	return ctx.Storage().GetBytes(key)
}

// promoteUpload moves an object from its temporary key to its final one. S3 has
// no rename, so Move is a copy followed by a delete and therefore not atomic: a
// failure in between leaves both keys. For this pattern that is harmless — the
// temporary key is swept by a lifecycle rule.
//
// Copy happens inside S3: the bytes never travel to this process, so promoting a
// 2GB object costs one API call rather than 2GB in each direction.
func promoteUpload(ctx core.IContext, tmpKey, finalKey string) core.IError {
	return ctx.Storage().Move(tmpKey, finalKey)
}

// deleteDocument removes the object after the row that referenced it is gone.
// Delete takes any number of keys, batches them into calls of 1000, and does not
// mind an absent one — which makes cleanup code idempotent for free.
//
// Unless the bucket has versioning, a delete is permanent. For anything a user
// can trigger, prefer marking the row deleted and letting a lifecycle rule
// remove the object after a grace period.
func deleteDocument(ctx core.IContext, keys ...string) core.IError {
	return ctx.Storage().Delete(keys...)
}

Maintained by Passakon Puttasuwan & Dev Core Team.