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:

go
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:

go
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
//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:

Aspekhttp.Dir (disk)embed.FS (binary)
Ukuran deployBinary kecil, berkas terpisahBinary membawa seluruh aset
Pembaruan asetCukup ganti berkas, tanpa kompilasi ulangWajib kompilasi ulang
Konsistensi versiBisa melenceng dari binarySelalu satu paket dengan binary
Cocok untukAset besar atau sering berubahAset 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:

go
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:

text
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 direktori

Mari kita telaah bagian-bagian penting program di atas:

  1. Baris 17-18: directive //go:embed statis menyuruh compiler menyematkan folder statis milik bundle chapter ke variabel konten.
  2. Baris 22-33: os.MkdirTemp membuat direktori uji unik lalu os.WriteFile mengisi pesan.txt dengan isi tetap; defer os.RemoveAll(dir) membersihkannya saat program selesai.
  3. Baris 35-36: http.StripPrefix memotong /statis/ agar FileServer mencari pesan.txt, bukan statis/pesan.txt.
  4. Baris 39-42: handler /unduh memasang Cache-Control lebih dulu, baru http.ServeFile untuk satu berkas.
  5. Baris 49-56: respons pertama membuktikan FileServer menetapkan text/plain; charset=utf-8 dari ekstensi .txt tanpa konfigurasi.
  6. Baris 68-79: klien dengan CheckRedirect berhenti di redirect; /statis/../pesan.txt dibersihkan menjadi /pesan.txt dan dialihkan 307.
  7. Baris 81-91: pada FileServer murni, /../../etc/passwd berakhir 404 — path runtuh ke dalam root dan berkasnya tidak ada.
  8. Baris 93-101: fs.Sub memotong embed.FS ke subdirektori statis, lalu http.FS menyerahkannya ke FileServer.
  9. Baris 113-120: berkas embed dilayani dengan isi dan MIME type persis seperti versi disk.
  10. Baris 122-123: permintaan / menghasilkan halaman listing direktori karena tidak ada index.html.

MIME Type dan Listing Direktori #

FileServer menetapkan Content-Type lewat tabel ekstensi bawaan package mime. Ekstensi yang paling sering dipakai:

EkstensiContent-Type
.txttext/plain; charset=utf-8
.csstext/css; charset=utf-8
.jstext/javascript; charset=utf-8
.jsonapplication/json
.pngimage/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:

go
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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

contoh.go

Unduh berkas
go
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")
}

statis/gaya.css

Unduh berkas
css
body { font-family: sans-serif; margin: 0; }