Aplikasi web hampir selalu butuh menyajikan berkas statis: CSS, JavaScript, gambar, dan dokumen unduhan. Go tidak membutuhkan server eksternal untuk itu — http.FileServer di standard library sudah menangani pembacaan berkas, penetapan MIME type, listing direktori, sampai perlindungan path traversal. Chapter penutup section ini membahas dua model penyajian: membaca dari direktori di disk dengan http.Dir, dan menyematkan berkas langsung ke binary dengan embed.FS agar hasil kompilasi menjadi satu artefak mandiri.
Menyajikan Direktori dengan FileServer #
http.FileServer(http.Dir("statis")) membentuk handler yang melayani seluruh isi direktori statis: permintaan /gaya.css membaca berkas statis/gaya.css lengkap dengan header Content-Type sesuai ekstensi. Tipe http.Dir adalah adaptor http.FileSystem di atas direktori disk — nama berkas selalu diselesaikan di dalam direktori tersebut.
Ketika FileServer dipasang di bawah prefix route seperti /statis/, handler menerima path lengkap /statis/gaya.css dan akan mencari berkas statis/statis/gaya.css. Middleware http.StripPrefix memotong prefix sebelum request diteruskan:
mux.Handle("/statis/", http.StripPrefix("/statis/", http.FileServer(http.Dir("statis"))))Satu Berkas dan Header Cache #
Untuk rute unduhan spesifik, http.ServeFile(w, r, namaBerkas) menyajikan satu berkas tanpa FileServer. Karena pemanggil yang memilih nama berkas, ServeFile memasang jaring pengaman: request yang r.URL.Path-nya memuat elemen .. ditolak dengan status 400.
Berkas statis juga tempat paling murah memasang cache. Middleware pembungkus menetapkan header sebelum request diteruskan:
func denganCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
next.ServeHTTP(w, r)
})
}
mux.Handle("/statis/", http.StripPrefix("/statis/", denganCache(http.FileServer(http.Dir("statis")))))Nilai public, max-age=3600 mengizinkan browser dan CDN menyimpan salinan selama satu jam, sehingga kunjungan berikutnya tidak lagi menekan server kita.
Keamanan Path Traversal #
Percobaan klasik terhadap file server adalah meminta path seperti /../../etc/passwd untuk membaca berkas di luar root. Dua lapis di net/http mencegahnya. Pertama, http.ServeMux membersihkan path memakai path.Clean lalu mengalihkan request yang berubah — /statis/../pesan.txt menjadi /pesan.txt dengan status 307. Kedua, http.FileServer membersihkan path sebelum menggabungkannya dengan root, sehingga elemen .. runtuh dan hasilnya selalu di dalam direktori yang dilayani — percobaan kabur berakhir 404, bukan isi berkas sistem.
Menyematkan Berkas dengan embed #
embed.FS menyimpan isi folder ke dalam binary pada saat kompilasi lewat directive //go:embed. Package io/fs lalu mengubahnya menjadi file system yang bisa dilayani FileServer:
//go:embed statis
var konten embed.FS
sub, _ := fs.Sub(konten, "statis")
ts := httptest.NewServer(http.FileServer(http.FS(sub)))Fungsi fs.Sub memotong tampilan ke subdirektori tertentu dan http.FS membungkusnya menjadi http.FileSystem. Server melayani berkas yang sama seperti versi disk, tetapi tanpa dependensi pada folder di mesin produksi.
Dua model ini punya konsekuensi berbeda:
| Aspek | http.Dir (disk) | embed.FS (binary) |
|---|---|---|
| Ukuran deploy | Binary kecil, berkas terpisah | Binary membawa seluruh aset |
| Pembaruan aset | Cukup ganti berkas, tanpa kompilasi ulang | Wajib kompilasi ulang |
| Konsistensi versi | Bisa melenceng dari binary | Selalu satu paket dengan binary |
| Cocok untuk | Aset besar atau sering berubah | Aset kecil, kontainer, CLI tunggal |
Program Demonstrasi #
Berkas contoh.go menguji kedua model sekaligus beserta dua percobaan traversal. Direktori sementara dibuat lewat os.MkdirTemp — path acaknya sengaja tidak pernah dicetak agar output tetap deterministik:
package main
import (
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
)
// konten menampung seluruh isi folder statis ke dalam binary.
//
//go:embed statis
var konten embed.FS
func main() {
// ===== Bagian 1: FileServer dari direktori di disk =====
dir, err := os.MkdirTemp("", "fileserver-demo-*")
if err != nil {
fmt.Println("gagal membuat direktori sementara:", err)
return
}
defer os.RemoveAll(dir)
berkasPesan := filepath.Join(dir, "pesan.txt")
if err := os.WriteFile(berkasPesan, []byte("Pesan dari direktori sementara.\n"), 0o644); err != nil {
fmt.Println("gagal menulis berkas:", err)
return
}
mux := http.NewServeMux()
mux.Handle("/statis/", http.StripPrefix("/statis/", http.FileServer(http.Dir(dir))))
// satu berkas spesifik dengan header cache.
mux.HandleFunc("/unduh", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, berkasPesan)
})
ts := httptest.NewServer(mux)
defer ts.Close()
klien := ts.Client()
resp, err := klien.Get(ts.URL + "/statis/pesan.txt")
if err != nil {
fmt.Println("permintaan berkas gagal:", err)
return
}
isi, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println("GET /statis/pesan.txt ->", resp.StatusCode, "|", resp.Header.Get("Content-Type"), "|", strings.TrimSpace(string(isi)))
respU, err := klien.Get(ts.URL + "/unduh")
if err != nil {
fmt.Println("permintaan unduh gagal:", err)
return
}
isiU, _ := io.ReadAll(respU.Body)
respU.Body.Close()
fmt.Println("GET /unduh ->", respU.StatusCode, "| Cache-Control:", respU.Header.Get("Cache-Control"), "|", strings.TrimSpace(string(isiU)))
// percobaan path traversal: ServeMux membersihkan path lalu mengalihkan.
klienAneh := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
respT, err := klienAneh.Get(ts.URL + "/statis/../pesan.txt")
if err != nil {
fmt.Println("permintaan traversal gagal:", err)
return
}
respT.Body.Close()
fmt.Println("GET /statis/../pesan.txt ->", respT.StatusCode, "| dialihkan ke:", respT.Header.Get("Location"))
// FileServer murni tanpa ServeMux: percobaan kabur dari direktori root.
tsMurni := httptest.NewServer(http.FileServer(http.Dir(dir)))
defer tsMurni.Close()
respM, err := klien.Get(tsMurni.URL + "/../../etc/passwd")
if err != nil {
fmt.Println("permintaan murni gagal:", err)
return
}
respM.Body.Close()
fmt.Println("GET /../../etc/passwd di FileServer murni ->", respM.StatusCode)
// ===== Bagian 2: FileServer dari berkas yang di-embed =====
sub, err := fs.Sub(konten, "statis")
if err != nil {
fmt.Println("gagal mengambil subdirektori:", err)
return
}
tsEmbed := httptest.NewServer(http.FileServer(http.FS(sub)))
defer tsEmbed.Close()
ambil := func(path string) (int, string, string) {
resp, err := klien.Get(tsEmbed.URL + path)
if err != nil {
return 0, "", ""
}
isi, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp.StatusCode, resp.Header.Get("Content-Type"), strings.TrimSpace(string(isi))
}
kode, jenis, teks := ambil("/pesan.txt")
fmt.Println("embed /pesan.txt ->", kode, "|", jenis, "|", teks)
kode, jenis, teks = ambil("/gaya.css")
fmt.Println("embed /gaya.css ->", kode, "|", jenis, "|", teks)
kode, jenis, teks = ambil("/data/daftar.txt")
fmt.Println("embed /data/daftar.txt ->", kode, "|", jenis, "|", teks)
kode, jenis, _ = ambil("/")
fmt.Println("embed / ->", kode, "|", jenis, "| halaman listing direktori")
}Output yang Dihasilkan:
GET /statis/pesan.txt -> 200 | text/plain; charset=utf-8 | Pesan dari direktori sementara.
GET /unduh -> 200 | Cache-Control: public, max-age=3600 | Pesan dari direktori sementara.
GET /statis/../pesan.txt -> 307 | dialihkan ke: /pesan.txt
GET /../../etc/passwd di FileServer murni -> 404
embed /pesan.txt -> 200 | text/plain; charset=utf-8 | Selamat datang di server berkas statis!
embed /gaya.css -> 200 | text/css; charset=utf-8 | body { font-family: sans-serif; margin: 0; }
embed /data/daftar.txt -> 200 | text/plain; charset=utf-8 | Budi, Siti, Ani
embed / -> 200 | text/html; charset=utf-8 | halaman listing direktoriMari kita telaah bagian-bagian penting program di atas:
- Baris 17-18: directive
//go:embed statismenyuruh compiler menyematkan folderstatismilik bundle chapter ke variabelkonten. - Baris 22-33:
os.MkdirTempmembuat direktori uji unik laluos.WriteFilemengisipesan.txtdengan isi tetap;defer os.RemoveAll(dir)membersihkannya saat program selesai. - Baris 35-36:
http.StripPrefixmemotong/statis/agar FileServer mencaripesan.txt, bukanstatis/pesan.txt. - Baris 39-42: handler
/unduhmemasangCache-Controllebih dulu, baruhttp.ServeFileuntuk satu berkas. - Baris 49-56: respons pertama membuktikan FileServer menetapkan
text/plain; charset=utf-8dari ekstensi.txttanpa konfigurasi. - Baris 68-79: klien dengan
CheckRedirectberhenti di redirect;/statis/../pesan.txtdibersihkan menjadi/pesan.txtdan dialihkan 307. - Baris 81-91: pada FileServer murni,
/../../etc/passwdberakhir 404 — path runtuh ke dalam root dan berkasnya tidak ada. - Baris 93-101:
fs.Submemotongembed.FSke subdirektoristatis, laluhttp.FSmenyerahkannya ke FileServer. - Baris 113-120: berkas embed dilayani dengan isi dan MIME type persis seperti versi disk.
- Baris 122-123: permintaan
/menghasilkan halaman listing direktori karena tidak adaindex.html.
MIME Type dan Listing Direktori #
FileServer menetapkan Content-Type lewat tabel ekstensi bawaan package mime. Ekstensi yang paling sering dipakai:
| Ekstensi | Content-Type |
|---|---|
.txt | text/plain; charset=utf-8 |
.css | text/css; charset=utf-8 |
.js | text/javascript; charset=utf-8 |
.json | application/json |
.png | image/png |
Permintaan ke path direktori dijawab dua cara: bila folder memuat index.html, berkas itu yang dilayani; bila tidak, FileServer membuat halaman listing HTML seperti baris terakhir output di atas. Untuk SPA berbasis hash routing, cukup pastikan index.html ada di root aset. SPA dengan history routing butuh fallback manual — semua path yang tidak cocok diarahkan ke index.html:
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// route API ditangani pattern yang lebih spesifik lebih dulu;
// sisanya diarahkan ke halaman utama aplikasi SPA.
http.ServeFile(w, r, "statis/index.html")
})Dengan berkas statis beres, fondasi HTTP kita lengkap: routing, middleware, timeout, context, shutdown aman, TLS, dan penyajian aset. Section berikutnya merakit fondasi itu menjadi REST API yang utuh — mulai dari desain resource hingga status code yang bermakna.
Referensi #
- Dokumentasi http.FileServer — penyaji berkas beserta perilaku listing.
- Dokumentasi embed.FS
— directive
//go:embeddan tipeembed.FS. - Dokumentasi package io/fs
— antarmuka
fs.FSdanfs.Sub. - Dokumentasi net/http/httptest — server pengujian lokal demo chapter ini.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.go
Unduh berkaspackage main
import (
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
)
// konten menampung seluruh isi folder statis ke dalam binary.
//
//go:embed statis
var konten embed.FS
func main() {
// ===== Bagian 1: FileServer dari direktori di disk =====
dir, err := os.MkdirTemp("", "fileserver-demo-*")
if err != nil {
fmt.Println("gagal membuat direktori sementara:", err)
return
}
defer os.RemoveAll(dir)
berkasPesan := filepath.Join(dir, "pesan.txt")
if err := os.WriteFile(berkasPesan, []byte("Pesan dari direktori sementara.\n"), 0o644); err != nil {
fmt.Println("gagal menulis berkas:", err)
return
}
mux := http.NewServeMux()
mux.Handle("/statis/", http.StripPrefix("/statis/", http.FileServer(http.Dir(dir))))
// satu berkas spesifik dengan header cache.
mux.HandleFunc("/unduh", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, berkasPesan)
})
ts := httptest.NewServer(mux)
defer ts.Close()
klien := ts.Client()
resp, err := klien.Get(ts.URL + "/statis/pesan.txt")
if err != nil {
fmt.Println("permintaan berkas gagal:", err)
return
}
isi, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println("GET /statis/pesan.txt ->", resp.StatusCode, "|", resp.Header.Get("Content-Type"), "|", strings.TrimSpace(string(isi)))
respU, err := klien.Get(ts.URL + "/unduh")
if err != nil {
fmt.Println("permintaan unduh gagal:", err)
return
}
isiU, _ := io.ReadAll(respU.Body)
respU.Body.Close()
fmt.Println("GET /unduh ->", respU.StatusCode, "| Cache-Control:", respU.Header.Get("Cache-Control"), "|", strings.TrimSpace(string(isiU)))
// percobaan path traversal: ServeMux membersihkan path lalu mengalihkan.
klienAneh := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
respT, err := klienAneh.Get(ts.URL + "/statis/../pesan.txt")
if err != nil {
fmt.Println("permintaan traversal gagal:", err)
return
}
respT.Body.Close()
fmt.Println("GET /statis/../pesan.txt ->", respT.StatusCode, "| dialihkan ke:", respT.Header.Get("Location"))
// FileServer murni tanpa ServeMux: percobaan kabur dari direktori root.
tsMurni := httptest.NewServer(http.FileServer(http.Dir(dir)))
defer tsMurni.Close()
respM, err := klien.Get(tsMurni.URL + "/../../etc/passwd")
if err != nil {
fmt.Println("permintaan murni gagal:", err)
return
}
respM.Body.Close()
fmt.Println("GET /../../etc/passwd di FileServer murni ->", respM.StatusCode)
// ===== Bagian 2: FileServer dari berkas yang di-embed =====
sub, err := fs.Sub(konten, "statis")
if err != nil {
fmt.Println("gagal mengambil subdirektori:", err)
return
}
tsEmbed := httptest.NewServer(http.FileServer(http.FS(sub)))
defer tsEmbed.Close()
ambil := func(path string) (int, string, string) {
resp, err := klien.Get(tsEmbed.URL + path)
if err != nil {
return 0, "", ""
}
isi, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp.StatusCode, resp.Header.Get("Content-Type"), strings.TrimSpace(string(isi))
}
kode, jenis, teks := ambil("/pesan.txt")
fmt.Println("embed /pesan.txt ->", kode, "|", jenis, "|", teks)
kode, jenis, teks = ambil("/gaya.css")
fmt.Println("embed /gaya.css ->", kode, "|", jenis, "|", teks)
kode, jenis, teks = ambil("/data/daftar.txt")
fmt.Println("embed /data/daftar.txt ->", kode, "|", jenis, "|", teks)
kode, jenis, _ = ambil("/")
fmt.Println("embed / ->", kode, "|", jenis, "| halaman listing direktori")
}
statis/gaya.css
Unduh berkasbody { font-family: sans-serif; margin: 0; }