Examples

uploads

A document locker: one multipart upload, served back to its owner

Browse on GitHub
$ git clone https://github.com/angvp/tango
$ cd tango/examples/uploads
$ go run .

Uploaded files

A document locker. Signed-in accounts upload a file, list their files, download them again and delete them. It shows the path tanGO's storage package expects a host to write:

  • storage.Upload reads one multipart file, streaming it into the object store with a size cap and a content-type allowlist. The type is sniffed from the bytes; the type the browser declares is never used.
  • The host keeps the key and the metadata in its own model, Document (apps/documents), with bounded strings (varchar=n). The generated key is never shown to a client and never decides access: the owner check does.
  • Bytes first, then the row, in a transaction. If the row cannot be written, the new object is deleted. Deleting a document removes the row first and the bytes best-effort, so a failure leaves an unreachable object, never a row pointing at nothing.
  • An admin changelist (apps/documents/admin.go) lists every file with its size (admin.ByteSize), a shortened digest (admin.ShortHash) and a download link. The link is the same owner-only download route the API uses, so a staff session alone does not download a file. The admin's edit form never shows the storage key (apps/documents/admin_widget.go renders a note instead), keeps it on save, and cannot repoint a row at another object. Upload metadata (owner, size, digest, type, time) is read-only there, and the form cannot create a file: uploads go through the upload route only. Staff can rename a file.
  • storage.Serve delivers a download the View has already authorized: attachment by default, nosniff, ranges and conditional requests. ?inline=1 shows an image in the browser; any other type stays an attachment.
  • Body limits: every route that carries no file keeps a 64 KiB limit; the upload route has its own, larger one, outside the small one.

Run it

git clone https://github.com/angvp/tango
cd tango/examples/uploads
go run . -migrate
go run .

Files are kept under UPLOADS_DIR (default ./uploads) by storage/local, which suits development and a single host with a persistent volume. Create an account at /accounts/register/, then, with its session cookie:

curl -b cookies.txt -F file=@photo.png localhost:8000/documents/
curl -b cookies.txt localhost:8000/documents/
curl -b cookies.txt -OJ localhost:8000/documents/1/download/
curl -b cookies.txt -X DELETE localhost:8000/documents/1/

The admin is at /admin/. Create a staff account with the admin CLI, then sign in at /admin/login/ to see the Documents changelist:

go run . -tango-admin-create=staff

The list links each file to /documents/{id}/download/, which still asks for the owning account's session. Signing in as staff does not open another account's file.

Things to know

  • Deleting a file in the admin removes its row but leaves its stored bytes behind, since admin has no hook into the host's storage. Delete through DELETE /documents/{id}/ to remove both.
  • Local disk is not shared between instances and does not outlive a container with no volume. For that, use storage/s3 (Amazon S3, Cloudflare R2 or another S3-compatible provider) instead of storage/local; only main.go changes.
  • tanGO ships no sweeper, virus scanning or CDN. See the uploads guide.
  • go test . runs the whole flow against an in-memory object store (storagetest.NewMemory), on SQLite or, with TANGO_TEST_DSN, PostgreSQL.
examples/uploads/main.go
// Command uploads is a small document locker: signed-in accounts upload
// files, list them, download them again and delete them. It shows the
// whole path tanGO's storage package expects a host to write: a View reads
// one multipart file with storage.Upload, the host keeps the generated key
// and metadata in its own model (inside a transaction), authorizes every
// download itself, and delivers it with storage.Serve.
//
// Files are kept on the local disk under UPLOADS_DIR, which suits
// development and a single host with a persistent volume. For more than one
// instance, or a disk that does not outlive the container, swap
// storage/local for storage/s3 by configuration; nothing else changes.
package main

import (
	"context"
	"database/sql"
	"fmt"
	"os"
	"os/signal"
	"syscall"

	"github.com/angvp/tango"
	"github.com/angvp/tango/accounts"
	"github.com/angvp/tango/admin"
	"github.com/angvp/tango/db"
	"github.com/angvp/tango/storage"
	"github.com/angvp/tango/storage/local"

	"uploads/apps/documents"
	"uploads/migrations"

	_ "modernc.org/sqlite"
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func run() error {
	dsn, err := tango.LoadDBConfigFromEnv()
	if err != nil {
		return err
	}
	sqlDB, err := sql.Open(dsn.Driver, dsn.Source)
	if err != nil {
		return err
	}
	defer sqlDB.Close()

	objects, err := local.New(uploadsDir())
	if err != nil {
		return err
	}
	defer objects.Close()

	store := db.NewStore(sqlDB, dsn.Dialect)
	if handled, err := admin.HandleCLI(context.Background(), store, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); handled || err != nil {
		return err
	}
	config := appConfig(store, objects)
	if handled, err := tango.DispatchFlags(config, sqlDB, dsn.Dialect, migrations.Migrations); handled || err != nil {
		return err
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	fmt.Println("listening on", config.Addr)
	return tango.ServeContext(ctx, config, sqlDB, dsn.Dialect)
}

// uploadsDir is UPLOADS_DIR, else ./uploads.
func uploadsDir() string {
	if dir := os.Getenv("UPLOADS_DIR"); dir != "" {
		return dir
	}
	return "uploads"
}

// appConfig is the whole application, keeping object bytes in objects.
// The test builds the same one around an in-memory store.
func appConfig(store *db.Store, objects storage.Store) tango.Config {
	config := tango.LoadConfigFromEnv(tango.WithPortFromEnv())
	config.InstalledApps = []tango.App{
		accounts.New(store),
		documents.New(store, objects, documents.DefaultLimits()),
		admin.New(store),
	}
	return config
}