Kembali ke Artikel

Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

Solusi IT

Di Belajar Golang dari Nol #19 kita sudah membuat worker dan job terjadwal. API kita sekarang bisa mengerjakan tugas berat di belakang layar tanpa membuat user menunggu. Kali ini kita masuk ke kemampuan yang hampir selalu diminta klien: upload file. Kita akan menerima foto produk lewat API, memvalidasinya dengan benar, menyimpannya dengan aman, lalu menyajikannya kembali. Kalau kamu baru bergabung di tengah seri, silakan mampir dulu ke daftar lengkap seri supaya alurnya nyambung.

Kasus nyata: API produk butuh foto

Sejak bagian 12 kita punya API produk yang tersambung ke database. Tabel produk berisi nama, harga, dan stok. Semua berjalan baik sampai satu permintaan datang: setiap produk harus punya foto.

Foto bukan teks. Dia tidak bisa dikirim begitu saja lewat body JSON seperti request kita selama ini. Untuk mengirim file, browser dan aplikasi memakai format bernama multipart/form-data.

Bayangkan multipart seperti paket kiriman. Di dalam satu paket ada beberapa bungkusan kecil. Tiap bungkusan punya label nama, misalnya nama_produk atau foto. Isinya bisa teks biasa, bisa juga file mentah lengkap dengan nama asli dan tipenya. Antar bungkusan dipisah oleh garis pembatas yang disebut boundary. Server tinggal membuka paket itu satu per satu dan mengambil bagian yang dia butuhkan.

Kenapa tidak lewat JSON saja? Sebenarnya bisa, dengan mengubah file menjadi teks base64. Tapi ukurannya membengkak sekitar sepertiga dan server harus decode manual. Multipart lebih hemat, didukung semua bahasa dan tool, jadi dia menjadi standar untuk urusan upload.

Kabar baiknya, paket net/http di Go sudah bisa membongkar paket ini tanpa library tambahan.

Menerima file di handler

Dua fungsi kuncinya adalah r.ParseMultipartForm untuk membongkar paket, dan r.FormFile untuk mengambil satu file berdasarkan nama field. Versi paling sederhana terlihat seperti ini.

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    // bongkar form, maksimal 2 MB ditahan di memori
    if err := r.ParseMultipartForm(2 << 20); err != nil {
        http.Error(w, "form tidak valid", http.StatusBadRequest)
        return
    }

    file, header, err := r.FormFile("foto")
    if err != nil {
        http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
        return
    }
    defer file.Close()

    fmt.Printf("nama: %s, ukuran: %d byte\n", header.Filename, header.Size)
}

FormFile mengembalikan tiga hal. Pertama isi file yang bisa dibaca seperti io.Reader biasa. Kedua header berisi nama asli file dan ukurannya. Ketiga error kalau field itu tidak ada di request.

Sampai sini file sudah masuk. Tapi handler ini masih polos. Dia menerima file apa pun, sebesar apa pun. Itu berbahaya. Kita perbaiki satu per satu.

Validasi pertama: batasi ukuran body

Banyak tutorial berhenti di angka 2 << 20 pada ParseMultipartForm dan menganggap itu batas ukuran upload. Ini salah kaprah yang perlu diluruskan. Angka itu hanya mengatur berapa banyak data yang ditahan di memori. Sisanya tetap diterima, lalu ditulis ke file sementara di disk. Artinya orang masih bisa mengirim file 5 GB dan server kamu tetap sibuk menampungnya.

Cara yang benar untuk membatasi ukuran adalah http.MaxBytesReader. Fungsi ini membungkus body request. Begitu jumlah byte yang dibaca melewati batas, pembacaan langsung dihentikan dan koneksi ditutup. Server tidak buang tenaga menampung sisa kiriman.

const maksUkuran = 2 << 20 // 2 MB

r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)

if err := r.ParseMultipartForm(maksUkuran); err != nil {
    http.Error(w, "file terlalu besar, maksimal 2 MB",
        http.StatusRequestEntityTooLarge)
    return
}

Pasang MaxBytesReader sebelum ParseMultipartForm dipanggil. Kalau body melebihi batas, ParseMultipartForm akan gagal dan kita balas dengan status 413, kode standar untuk body yang terlalu besar.

Angka 2 MB di sini hanya contoh. Sesuaikan dengan kebutuhan aplikasimu. Untuk foto katalog produk, 2 sampai 5 MB biasanya lebih dari cukup. Semakin longgar batasnya, semakin besar ruang yang bisa dimanfaatkan orang iseng untuk membebani server.

Validasi kedua: cek tipe file dari isinya

Jangan pernah percaya ekstensi file. Mengganti virus.exe menjadi virus.jpg cuma butuh satu kali rename. Header Content-Type yang dikirim klien juga bisa dipalsukan dengan mudah.

Yang tidak bisa dipalsukan adalah isi filenya sendiri. Setiap format punya tanda pengenal di byte awalnya, sering disebut magic number. File PNG selalu diawali deretan byte tertentu, begitu juga JPEG dan WebP. Go menyediakan http.DetectContentType yang membaca maksimal 512 byte pertama lalu menebak tipenya dari tanda itu.

var tipeDiizinkan = map[string]string{
    "image/jpeg": ".jpg",
    "image/png":  ".png",
    "image/webp": ".webp",
}

buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && err != io.EOF {
    http.Error(w, "gagal membaca file", http.StatusInternalServerError)
    return
}

tipe := http.DetectContentType(buf[:n])
ekstensi, ok := tipeDiizinkan[tipe]
if !ok {
    http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
        http.StatusUnsupportedMediaType)
    return
}

// kembalikan posisi baca ke awal file
if _, err := file.Seek(0, io.SeekStart); err != nil {
    http.Error(w, "gagal membaca file", http.StatusInternalServerError)
    return
}

Perhatikan dua hal. Pertama, kita memakai map untuk daftar tipe yang diizinkan sekaligus menentukan ekstensi penyimpanan. Selain jpg, png, dan webp, semuanya ditolak dengan status 415. Kedua, setelah membaca 512 byte pertama, posisi baca file sudah bergeser. Kita harus Seek kembali ke awal supaya nanti file tersimpan utuh, bukan terpotong.

Kamu mungkin bertanya kenapa SVG tidak masuk daftar, padahal dia juga format gambar. SVG sebenarnya file teks XML dan bisa berisi script. Kalau disajikan dari domain yang sama dengan aplikasimu, script itu bisa dijalankan browser dan menjadi celah XSS. Untuk upload gambar dari user, amannya batasi ke format bitmap saja.

Menyimpan file dengan aman

Sekarang bagian yang paling sering jadi lubang keamanan: nama file. Naluri pertama kebanyakan orang adalah memakai header.Filename apa adanya. Jangan.

Nama file dikirim oleh user, dan user bisa mengirim apa saja. Termasuk nama seperti ini.

../../etc/cron.d/jahat

Kalau kamu menggabungkan nama itu dengan folder uploads begitu saja, tanda ../ akan membawa tulisan keluar dari folder uploads dan menimpa file lain di server. Serangan ini disebut path traversal. Satu request upload bisa berubah jadi pintu masuk ke seluruh sistem.

Solusinya sederhana. Abaikan nama dari user sepenuhnya. Server yang menentukan nama, dibuat acak supaya tidak bisa ditebak dan tidak mungkin bentrok dengan file lain.

import (
    "crypto/rand"
    "encoding/hex"
)

func namaAcak() (string, error) {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

Enam belas byte acak dari crypto/rand menghasilkan nama 32 karakter heksadesimal. Kalau kamu lebih suka format standar, library uuid seperti github.com/google/uuid juga hasilnya sama amannya. Karena nama selalu baru, upload tidak akan pernah menimpa file yang sudah ada.

Lalu proses simpannya. Pastikan folder tujuan ada dengan os.MkdirAll, buat file tujuan, salin isinya dengan io.Copy.

const folderUpload = "uploads"

if err := os.MkdirAll(folderUpload, 0o755); err != nil {
    http.Error(w, "gagal menyiapkan folder", http.StatusInternalServerError)
    return
}

nama, err := namaAcak()
if err != nil {
    http.Error(w, "gagal membuat nama file", http.StatusInternalServerError)
    return
}

lokasi := filepath.Join(folderUpload, nama+ekstensi)

tujuan, err := os.Create(lokasi)
if err != nil {
    http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
    return
}
defer tujuan.Close()

if _, err := io.Copy(tujuan, file); err != nil {
    http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
    return
}

io.Copy menyalin per potongan kecil, jadi file besar tidak dimuat seluruhnya ke memori. Ekstensi kita ambil dari hasil deteksi tipe tadi, bukan dari nama asli, jadi konsisten dengan isi sebenarnya. Angka 0o755 pada MkdirAll adalah izin folder di Linux. Pemilik boleh menulis, user lain hanya bisa membaca. Formatnya oktal, sama dengan yang dipakai perintah chmod.

Menyimpan path ke database

File fisik sudah tersimpan. Sekarang produk harus tahu di mana fotonya. Kita tidak menyimpan file di database, cukup path-nya saja. Ini menyambung pola query yang sudah kita pakai sejak bagian 12.

pathFoto := "/uploads/" + nama + ekstensi

_, err = db.Exec(
    "UPDATE produk SET foto = ? WHERE id = ?",
    pathFoto, id,
)
if err != nil {
    http.Error(w, "gagal menyimpan data foto", http.StatusInternalServerError)
    return
}

Kolom foto tinggal ditambahkan ke tabel produk dengan satu perintah ALTER TABLE produk ADD COLUMN foto VARCHAR(100). Yang tersimpan adalah path publiknya, jadi frontend bisa langsung memakainya sebagai src gambar.

Menyajikan file yang sudah diupload

Foto yang tersimpan harus bisa diakses lewat URL. Go punya http.FileServer untuk menyajikan isi folder, dan http.StripPrefix untuk memotong awalan URL supaya cocok dengan struktur folder.

fs := http.FileServer(http.Dir("uploads"))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", fs))

Tanpa StripPrefix, request ke /uploads/abc.jpg akan dicari sebagai uploads/uploads/abc.jpg. Dengan pemotongan awalan, dia benar mengarah ke uploads/abc.jpg.

Untuk belajar dan untuk aplikasi kecil, ini cukup. Di production, file statis biasanya diserahkan ke Nginx yang sudah kita pasang sebagai reverse proxy di bagian 15. Nginx memang dirancang untuk menyajikan file statis, lengkap dengan cache header, dan dia melakukannya tanpa menyentuh aplikasi Go sama sekali. Aplikasi kamu jadi fokus mengurus logika bisnis saja.

location /uploads/ {
    alias /var/www/aplikasi/uploads/;
    expires 30d;
}

Blok ini menyuruh Nginx melayani semua request /uploads/ langsung dari folder di disk, plus cache 30 hari di browser.

Satu aturan penting apa pun cara penyajiannya. Folder uploads harus hanya berisi file hasil upload, terpisah dari kode aplikasi. Jangan pernah menyajikan folder aplikasi sebagai file statis, dan jangan menaruh file upload di folder yang bisa dieksekusi sebagai kode.

Menghapus foto lama saat diganti

Ada satu detail yang sering dilupakan. Kalau user mengganti foto produk, file lama masih tergeletak di disk. Lama kelamaan folder uploads penuh sampah. Jadi sebelum menyimpan path baru, ambil dulu path lama, lalu hapus filenya setelah semuanya sukses.

var fotoLama sql.NullString
err = db.QueryRow(
    "SELECT foto FROM produk WHERE id = ?", id,
).Scan(&fotoLama)

// ... proses simpan file baru dan UPDATE database ...

if fotoLama.Valid && fotoLama.String != "" {
    lokasiLama := filepath.Join(".", fotoLama.String)
    if err := os.Remove(lokasiLama); err != nil {
        slog.Warn("gagal menghapus foto lama",
            "path", lokasiLama, "error", err)
    }
}

Perhatikan cara kita menangani errornya. Kalau os.Remove gagal, misalnya file sudah tidak ada, request tidak perlu digagalkan. Foto baru sudah tersimpan dan database sudah benar, user tidak dirugikan apa pun. Cukup catat kejadiannya lewat slog yang sudah kita siapkan di bagian 18, supaya kamu bisa memeriksa dan membersihkannya nanti. Tidak semua error layak mematikan request.

Batas jujur pendekatan simpan di disk

Sebelum kamu memakai pola ini di semua project, ada batasnya yang perlu kamu tahu.

Pertama, file menempel di satu server. Kalau kamu pindah server atau server rusak dan tidak ada backup, semua foto hilang. Kedua, begitu aplikasi berjalan di dua server di belakang load balancer, masalah muncul. Upload masuk ke server A, tapi request berikutnya dilayani server B yang tidak punya filenya.

Jawaban untuk masalah ini adalah object storage, layanan seperti Amazon S3, Cloudflare R2, atau DigitalOcean Spaces. Konsepnya begini: file tidak disimpan di disk server kamu, melainkan dikirim ke layanan penyimpanan terpisah yang bisa diakses semua server lewat API. Kamu mendapat URL untuk tiap file, tahan hilang karena datanya direplikasi, dan server aplikasi bisa ditambah kurangi tanpa pusing soal file. Alur kodenya mirip, hanya tujuan io.Copy berganti dari file lokal ke client S3. Untuk aplikasi satu server dengan backup rutin, simpan di disk masih pilihan yang wajar dan murah.

Latihan: endpoint foto produk yang utuh

Sekarang kita rangkai semuanya menjadi satu program lengkap yang bisa langsung kamu jalankan.

package main

import (
    "crypto/rand"
    "database/sql"
    "encoding/hex"
    "encoding/json"
    "io"
    "log/slog"
    "net/http"
    "os"
    "path/filepath"

    _ "github.com/go-sql-driver/mysql"
)

const (
    folderUpload = "uploads"
    maksUkuran   = 2 << 20 // 2 MB
)

var tipeDiizinkan = map[string]string{
    "image/jpeg": ".jpg",
    "image/png":  ".png",
    "image/webp": ".webp",
}

func namaAcak() (string, error) {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

func uploadFoto(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")

        r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
        if err := r.ParseMultipartForm(maksUkuran); err != nil {
            http.Error(w, "file terlalu besar, maksimal 2 MB",
                http.StatusRequestEntityTooLarge)
            return
        }

        file, _, err := r.FormFile("foto")
        if err != nil {
            http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
            return
        }
        defer file.Close()

        buf := make([]byte, 512)
        n, err := file.Read(buf)
        if err != nil && err != io.EOF {
            http.Error(w, "gagal membaca file", http.StatusInternalServerError)
            return
        }
        ekstensi, ok := tipeDiizinkan[http.DetectContentType(buf[:n])]
        if !ok {
            http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
                http.StatusUnsupportedMediaType)
            return
        }
        if _, err := file.Seek(0, io.SeekStart); err != nil {
            http.Error(w, "gagal membaca file", http.StatusInternalServerError)
            return
        }

        var fotoLama sql.NullString
        err = db.QueryRow(
            "SELECT foto FROM produk WHERE id = ?", id,
        ).Scan(&fotoLama)
        if err == sql.ErrNoRows {
            http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
            return
        }
        if err != nil {
            http.Error(w, "gagal membaca data produk",
                http.StatusInternalServerError)
            return
        }

        if err := os.MkdirAll(folderUpload, 0o755); err != nil {
            http.Error(w, "gagal menyiapkan folder",
                http.StatusInternalServerError)
            return
        }
        nama, err := namaAcak()
        if err != nil {
            http.Error(w, "gagal membuat nama file",
                http.StatusInternalServerError)
            return
        }
        lokasi := filepath.Join(folderUpload, nama+ekstensi)

        tujuan, err := os.Create(lokasi)
        if err != nil {
            http.Error(w, "gagal menyimpan file",
                http.StatusInternalServerError)
            return
        }
        defer tujuan.Close()
        if _, err := io.Copy(tujuan, file); err != nil {
            http.Error(w, "gagal menyimpan file",
                http.StatusInternalServerError)
            return
        }

        pathFoto := "/uploads/" + nama + ekstensi
        if _, err := db.Exec(
            "UPDATE produk SET foto = ? WHERE id = ?", pathFoto, id,
        ); err != nil {
            http.Error(w, "gagal menyimpan data foto",
                http.StatusInternalServerError)
            return
        }

        if fotoLama.Valid && fotoLama.String != "" {
            lokasiLama := filepath.Join(".", fotoLama.String)
            if err := os.Remove(lokasiLama); err != nil {
                slog.Warn("gagal menghapus foto lama",
                    "path", lokasiLama, "error", err)
            }
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{
            "id":   id,
            "foto": pathFoto,
        })
    }
}

func main() {
    db, err := sql.Open("mysql",
        "root:rahasia@tcp(127.0.0.1:3306)/toko?parseTime=true")
    if err != nil {
        slog.Error("gagal membuka database", "error", err)
        os.Exit(1)
    }
    defer db.Close()

    mux := http.NewServeMux()
    mux.HandleFunc("POST /produk/{id}/foto", uploadFoto(db))
    mux.Handle("GET /uploads/",
        http.StripPrefix("/uploads/",
            http.FileServer(http.Dir(folderUpload))))

    slog.Info("server berjalan", "port", 8080)
    http.ListenAndServe(":8080", mux)
}

Coba dari terminal. Flag -F pada curl otomatis mengirim request sebagai multipart, dan tanda @ berarti ambil isi dari file di komputermu.

curl -F "foto=@kaos-polos.jpg" http://localhost:8080/produk/12/foto

Kalau sukses, responsnya seperti ini.

{"id":"12","foto":"/uploads/9f3c1a7e2b64d0f18a5c47e9b2d6031f.jpg"}

Buka http://localhost:8080/uploads/9f3c...jpg di browser dan foto akan tampil. Sekarang uji jalur gagalnya. Kirim file 10 MB, kamu akan menerima status 413 dengan pesan file terlalu besar. Kirim file PDF yang di-rename jadi .jpg, kamu akan menerima status 415 karena isi filenya bukan gambar. Validasi kita membaca isi, bukan nama, jadi trik rename tidak mempan.

Sebagai latihan tambahan, coba dua hal. Pertama, catat setiap upload sukses lewat slog lengkap dengan id produk dan ukuran file, lalu amati lognya. Kedua, buat endpoint DELETE /produk/{id}/foto yang menghapus file sekaligus mengosongkan kolom foto di database. Semua bahannya sudah ada di artikel ini.

Penutup

API produk kamu sekarang bisa menerima foto dengan aman. Ukuran dibatasi lewat MaxBytesReader, tipe dicek dari isi file, nama dibuat acak supaya bebas path traversal, path tersimpan rapi di database, dan file lama ikut dibersihkan. Pola yang sama berlaku untuk dokumen, bukti transfer, atau lampiran apa pun.

Di bagian 21 kita akan membahas kemampuan lain yang hampir pasti dibutuhkan aplikasi nyata, yaitu Mengirim Email dari Aplikasi Go. Mulai dari email verifikasi sampai notifikasi transaksi, semuanya dari kode Go sendiri.

Kalau kamu sedang membangun aplikasi yang butuh fitur upload, manajemen produk, atau sistem internal lain dan ingin dikerjakan tim yang berpengalaman, lihat layanan pengembangan sistem aplikasi Arrazy. Sampai jumpa di bagian berikutnya.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel