storage

package
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 23, 2026 License: MIT Imports: 15 Imported by: 1

README

pkg/storage

Mesin penyimpanan sederhana dan minimal untuk Ziswapp dengan satu tanggung jawab:

  • Mengunggah konten biner dan mendapatkan path (key) kanonik.
  • Mengambil konten biner berdasarkan path (key) untuk diteruskan ke pengguna melalui API.

Prioritas driver:

  • AWS S3 (atau penyedia S3-compatible)
  • Cloudflare R2
  • Filesystem lokal
  • Null/Mock (untuk pengujian)

Secara default, semua file bersifat private. Visibilitas dapat dioverride per unggah (upload).


Fitur

  • API sangat kecil: Upload(ctx, path, []byte) / Get(ctx, path) dan dukungan streaming UploadStream(ctx, path, io.Reader) / GetStream(ctx, path) io.ReadCloser untuk objek besar
  • Factory: pilih driver via environment atau secara programatik
  • Implementasi S3/R2 maupun Local, plus driver Null/Mock in-memory untuk pengujian
  • Tidak mengekspos temporary URL atau tautan publik
  • Private secara default (bisa dioverride via opsi upload)
  • Tidak ada business logic; murni lapisan integrasi

Instalasi dan dependensi

Paket ini berada di dalam repository. Untuk driver S3/R2, pastikan modul berikut tersedia di go.mod:

  • github.com/aws/aws-sdk-go-v2
  • github.com/aws/aws-sdk-go-v2/config
  • github.com/aws/aws-sdk-go-v2/credentials
  • github.com/aws/aws-sdk-go-v2/service/s3

Jika Anda baru menambahkan paket ini atau driver S3/R2, pastikan dependensi tersinkron (mis. jalankan go mod tidy di proyek Anda).


Variabel environment

Pemilihan driver

  • STORAGE_DRIVER: s3 | r2 | local | null (default: null)
  • STORAGE_PUBLIC: true | false (default: false). Visibilitas default saat upload (spesifik driver). Private secara default.

S3 / R2 (S3-compatible)

  • S3_BUCKET: nama bucket (wajib untuk s3/r2)
  • AWS_REGION: region (untuk R2 dapat menggunakan "auto") (S3_REGION didukung sebagai fallback)
  • AWS_ACCESS_KEY_ID: access key ID (S3_ACCESS_KEY_ID didukung sebagai fallback)
  • AWS_SECRET_ACCESS_KEY: secret access key (S3_SECRET_ACCESS_KEY didukung sebagai fallback)
  • S3_ENDPOINT: URL endpoint (wajib untuk Cloudflare R2, opsional untuk S3 atau S3-compatible lain)
  • S3_FORCE_PATH_STYLE: true | false (disarankan true untuk R2)

Local

  • LOCAL_BASE_PATH: direktori dasar untuk menyimpan file (default: storage)

Contoh .env

# Driver storage
STORAGE_DRIVER=s3
STORAGE_PUBLIC=false

# Konfigurasi bersama untuk S3/R2
S3_BUCKET=ziswapp-uploads
AWS_REGION=ap-southeast-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...

# Untuk R2 (endpoint S3-compatible + path style)
# STORAGE_DRIVER=r2
# S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
# S3_FORCE_PATH_STYLE=true

# Local fallback
# STORAGE_DRIVER=local
# LOCAL_BASE_PATH=./storage

API

Konstruktor

  • NewFromEnv(ctx): Storage
  • New(ctx, cfg): Storage

Antarmuka

  • Upload(ctx, path, content, ...Option) (string, error)
  • Get(ctx, path) ([]byte, error)
  • UploadStream(ctx, path, r io.Reader, ...Option) (string, error)
  • GetStream(ctx, path) (io.ReadCloser, error)
  • Delete(ctx, path) error
  • Has(ctx, path) (bool, error)

Opsi

  • WithPublic(bool): override visibilitas default per unggah
  • WithContentType(string): set metadata content type (jika didukung driver)

Quick start

Inisialisasi dari environment

ctx := context.Background()

st, err := storage.NewFromEnv(ctx)
if err != nil {
    // tangani error (mis-konfigurasi, kredensial hilang, dsb.)
}

Konfigurasi programatik

cfg := &storage.Config{
    Driver: "s3",   // atau "r2", "local", "null"
    Public: false,  // visibilitas default untuk Upload
}
cfg.S3.Bucket          = "ziswapp-uploads"
cfg.S3.Region          = "ap-southeast-1" // "auto" oke untuk R2
cfg.S3.AccessKeyID     = os.Getenv("AWS_ACCESS_KEY_ID")
cfg.S3.SecretAccessKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
cfg.S3.Endpoint        = ""               // isi untuk R2 atau S3-compatible
cfg.S3.ForcePathStyle  = false            // disarankan true untuk R2

st, err := storage.New(context.Background(), cfg)
if err != nil {
    // tangani error
}

Unggah file (PDF) - mode byte-slice

pdf := []byte("%PDF-1.4...") // contoh bytes
key := fmt.Sprintf("org/%d/invoices/%s.pdf", orgID, invoiceNumber)

storedPath, err := st.Upload(ctx, key, pdf,
    storage.WithContentType("application/pdf"),
    // storage.WithPublic(true), // override per unggah (opsional)
)
if err != nil {
    // tangani error
}

// storedPath adalah key kanonik untuk disimpan di DB
fmt.Println("uploaded:", storedPath)

Unggah file besar (streaming) - UploadStream

key := fmt.Sprintf("org/%d/invoices/%s.pdf", orgID, invoiceNumber)
// Batasi ukuran agar aman (contoh 50MB)
limited := http.MaxBytesReader(w, r.Body, 50<<20)
defer r.Body.Close()

storedPath, err := st.UploadStream(ctx, key, limited,
    storage.WithContentType("application/pdf"),
)
if err != nil {
    // tangani error
}

fmt.Println("uploaded stream:", storedPath)

Ambil file dan teruskan ke respons HTTP Ambil file dan teruskan ke respons HTTP (streaming untuk objek besar) - GetStream

rc, err := st.GetStream(ctx, storedPath)
if err != nil {
    // terjemahkan ke HTTP status yang sesuai (mis. 404 jika tidak ditemukan)
    w.WriteHeader(http.StatusNotFound)
    return
}
defer rc.Close()

// Jika Anda tahu Content-Type, set eksplisit; jika tidak, bisa dideteksi sebagian di sisi klien/CDN.
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, rc)

Ambil file ukuran kecil-menengah (byte-slice) - Get

data, err := st.Get(ctx, storedPath)
if err != nil {
    w.WriteHeader(http.StatusNotFound)
    return
}

// Tentukan Content-Type (jika diketahui) atau deteksi cepat
ct := http.DetectContentType(data)
w.Header().Set("Content-Type", ct)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)

Contoh cepat Has dan Delete

// Cek keberadaan file
exists, err := st.Has(ctx, storedPath)
if err != nil {
    // tangani error
}
fmt.Println("exists:", exists)

// Hapus file
if err := st.Delete(ctx, storedPath); err != nil {
    // tangani error
}
fmt.Println("deleted")

Praktik terbaik

  • Simpan storedPath (key) yang dikembalikan ke DB; jangan simpan bucket atau endpoint per objek.
  • Bucket/endpoint dikelola di environment/konfigurasi sehingga migrasi/perubahan tidak menyentuh DB.
  • Pertahankan file tetap private kecuali ada kebutuhan publik yang jelas. Gunakan WithPublic(true) hanya bila perlu (dengan kebijakan bucket atau CDN).

Rincian driver dan catatan

S3 (AWS S3)

  • Konfigurasi AWS standar; endpoint opsional. Jika memakai endpoint kustom (S3-compatible), implementasi saat ini menggunakan BaseEndpoint pada opsi S3 (beserta pengaturan path-style bila diperlukan).
  • WithPublic(true) akan set ACL public-read (tergantung kebijakan bucket). Lebih disarankan kebijakan bucket.

Cloudflare R2

  • Wajib set S3_ENDPOINT (contoh: https://<accountid>.r2.cloudflarestorage.com).
  • Path-style addressing dipaksakan; set S3_FORCE_PATH_STYLE=true.
  • Region dapat "auto".

Local

  • Menulis di bawah LOCAL_BASE_PATH (default ./storage).
  • Ditujukan untuk development atau lingkungan single-node.

Null/Mock

  • In-memory, tidak persisten.
  • Untuk pengujian dan lokal saat tidak ingin menyentuh storage nyata.

Contoh handler HTTP

Handler minimal yang mengunggah data dari request dan mengembalikan path penyimpanan (byte-slice):

func UploadInvoiceHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        invoiceNo := r.URL.Query().Get("no")
        if invoiceNo == "" {
            http.Error(w, "missing invoice number", http.StatusBadRequest)
            return
        }
        orgID := r.PathValue("org")

        b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20)) // batasi 10MB
        if err != nil {
            http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
            return
        }
        defer r.Body.Close()

        key := fmt.Sprintf("org/%s/invoices/%s.pdf", orgID, invoiceNo)
        storedPath, err := st.Upload(ctx, key, b, storage.WithContentType("application/pdf"))
        if err != nil {
            http.Error(w, "failed to store file", http.StatusInternalServerError)
            return
        }

        SendRespondWithJson(w, r, http.StatusCreated, map[string]any{
            "path": storedPath,
        })
    }
}

Handler minimal yang menyajikan konten tersimpan secara streaming (GetStream):

func GetInvoiceStreamHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        path := r.URL.Query().Get("path")
        if strings.TrimSpace(path) == "" {
            http.Error(w, "missing path", http.StatusBadRequest)
            return
        }

        rc, err := st.GetStream(ctx, path)
        if err != nil {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        defer rc.Close()

        // Jika Anda tahu content-type, set eksplisit; jika tidak, biarkan klien/CDN menangani.
        w.WriteHeader(http.StatusOK)
        _, _ = io.Copy(w, rc)
    }
}

Handler minimal yang menyajikan konten kecil-menengah (Get):

func GetInvoiceHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        path := r.URL.Query().Get("path")
        if strings.TrimSpace(path) == "" {
            http.Error(w, "missing path", http.StatusBadRequest)
            return
        }

        data, err := st.Get(ctx, path)
        if err != nil {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }

        // Tentukan Content-Type (jika diketahui) atau deteksi cepat
        ct := http.DetectContentType(data)
        w.Header().Set("Content-Type", ct)
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write(data)
    }
}

Handler minimal untuk unggah streaming (UploadStream):

func UploadInvoiceStreamHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        invoiceNo := r.URL.Query().Get("no")
        if invoiceNo == "" {
            http.Error(w, "missing invoice number", http.StatusBadRequest)
            return
        }
        orgID := r.PathValue("org")

        // Batasi ukuran payload (contoh 50MB)
        limited := http.MaxBytesReader(w, r.Body, 50<<20)
        defer r.Body.Close()

        key := fmt.Sprintf("org/%s/invoices/%s.pdf", orgID, invoiceNo)
        storedPath, err := st.UploadStream(ctx, key, limited, storage.WithContentType("application/pdf"))
        if err != nil {
            http.Error(w, "failed to store file", http.StatusInternalServerError)
            return
        }

        SendRespondWithJson(w, r, http.StatusCreated, map[string]any{
            "path": storedPath,
        })
    }
}

Handler minimal yang menyajikan kembali konten tersimpan:

func GetInvoiceHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        path := r.URL.Query().Get("path")
        if strings.TrimSpace(path) == "" {
            http.Error(w, "missing path", http.StatusBadRequest)
            return
        }

        data, err := st.Get(ctx, path)
        if err != nil {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }

        // Jika Anda tahu content-type, set eksplisit; jika tidak, deteksi
        ct := http.DetectContentType(data)
        w.Header().Set("Content-Type", ct)
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write(data)
    }
}

Contoh handler HTTP tambahan (Has/Delete)

Handler minimal untuk pengecekan keberadaan file (Has):

func HasInvoiceHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        path := r.URL.Query().Get("path")
        if strings.TrimSpace(path) == "" {
            http.Error(w, "missing path", http.StatusBadRequest)
            return
        }

        exists, err := st.Has(ctx, path)
        if err != nil {
            http.Error(w, "failed to check", http.StatusInternalServerError)
            return
        }

        SendRespondWithJson(w, r, http.StatusOK, map[string]any{
            "path":   path,
            "exists": exists,
        })
    }
}

Handler minimal untuk menghapus file (Delete):

func DeleteInvoiceHandler(st storage.Storage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        path := r.URL.Query().Get("path")
        if strings.TrimSpace(path) == "" {
            http.Error(w, "missing path", http.StatusBadRequest)
            return
        }

        if err := st.Delete(ctx, path); err != nil {
            http.Error(w, "failed to delete", http.StatusInternalServerError)
            return
        }

        w.WriteHeader(http.StatusNoContent)
    }
}

Pengujian dengan driver null

st := storage.NewFromEnv // atau buat langsung
st = &struct{ storage.Storage }{} // Anda bisa membungkus atau gunakan New dengan konfigurasi null

// Rekomendasi: paksa driver null saat pengujian
os.Setenv("STORAGE_DRIVER", "null")

stg, err := storage.NewFromEnv(context.Background())
if err != nil {
    t.Fatalf("storage init: %v", err)
}

p, err := stg.Upload(ctx, "test/hello.txt", []byte("hello"))
if err != nil {
    t.Fatalf("upload: %v", err)
}
b, err := stg.Get(ctx, p)
if err != nil {
    t.Fatalf("get: %v", err)
}
if string(b) != "hello" {
    t.Fatalf("unexpected content: %q", string(b))
}

Catatan dan keterbatasan

  • Paket ini kini mendukung dua mode akses: byte-slice (Upload/Get) dan streaming (UploadStream/GetStream).
    • Gunakan mode streaming untuk objek besar agar hemat memori dan lebih stabil.
  • Content-Type tidak dipaksakan oleh API storage. Sertakan via WithContentType bila diperlukan.
  • Akses publik adalah concern spesifik driver. Default yang direkomendasikan adalah private. Jika harus dipublikasikan, atur kebijakan bucket atau aturan CDN secara eksplisit dan gunakan WithPublic(true) hanya saat diperlukan.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	Driver string
	// Public menentukan visibilitas/ACL bawaan saat upload (tergantung driver).
	// Implementasi sebaiknya menjaga objek tetap private secara default kecuali disetel true atau dioverride per-upload.
	Public bool

	S3 struct {
		Bucket          string
		Region          string
		AccessKeyID     string
		SecretAccessKey string
		Endpoint        string
		ForcePathStyle  bool
	}

	Local struct {
		// BasePath adalah direktori root untuk penyimpanan lokal.
		// Jika kosong, gunakan default "storage".
		BasePath string
	}
}

Config menyimpan konfigurasi storage. Gunakan FromEnv() untuk memuat dari variabel environment.

Driver: - "s3" : AWS S3 atau S3-compatible - "r2" : Cloudflare R2 (S3-compatible; umumnya perlu path-style + endpoint kustom) - "local" : Penyimpanan filesystem lokal - "null" : Penyimpanan in-memory (untuk pengujian); alias: "mock"

func FromEnv

func FromEnv() *Config

FromEnv memuat nilai Config storage dari variabel environment.

Variabel: - STORAGE_DRIVER: s3 | r2 | local | null (default: null) - STORAGE_PUBLIC: true | false (default: false) - S3_BUCKET, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY - S3_ENDPOINT (opsional; wajib untuk sebagian penyedia S3-compatible seperti R2) - S3_FORCE_PATH_STYLE: true | false (disarankan true untuk R2) - LOCAL_BASE_PATH: direktori dasar untuk driver "local" (default: storage)

type Option

type Option func(*uploadOptions)

Option mengatur perilaku saat upload.

func WithContentType

func WithContentType(ct string) Option

WithContentType mengatur metadata content-type (jika didukung driver).

func WithPublic

func WithPublic(public bool) Option

WithPublic menimpa visibilitas default untuk satu kali pemanggilan Upload.

type Storage

type Storage interface {
	Upload(ctx context.Context, path string, content []byte, opts ...Option) (string, error)
	Get(ctx context.Context, path string) ([]byte, error)
	UploadStream(ctx context.Context, path string, r io.Reader, opts ...Option) (string, error)
	GetStream(ctx context.Context, path string) (io.ReadCloser, error)
	Delete(ctx context.Context, path string) error
	Has(ctx context.Context, path string) (bool, error)
}

Storage adalah antarmuka minimal untuk menyimpan dan mengambil objek biner. Mendukung dua mode: - Byte-slice: Upload/Get untuk ukuran kecil-menengah. - Streaming: UploadStream/GetStream untuk objek besar (io.Reader/io.ReadCloser). Catatan: - Upload/UploadStream mengembalikan path (key) kanonik dan error (jika ada). - Get mengembalikan seluruh konten sebagai []byte; GetStream mengembalikan io.ReadCloser. - Delete menghapus objek pada path (key) tertentu. - Has memeriksa keberadaan objek untuk path (key) tertentu.

func New

func New(ctx context.Context, cfg *Config) (Storage, error)

New membuat Storage berdasarkan Config yang diberikan. Factory ini mendelegasikan ke konstruktor driver di file lain:

  • aws.go / cloudflare.go: newS3Storage, newR2Storage
  • local.go: newLocalStorage
  • null.go: newNullStorage

func NewFromEnv

func NewFromEnv(ctx context.Context) (Storage, error)

NewFromEnv membuat Storage dari variabel environment.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL