diff --git a/.gitignore b/.gitignore index 634b0a2..1872795 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /bin /data +/config.toml diff --git a/README.md b/README.md index 5b9b351..8da58f6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ You will need [Go](https://go.dev/) 1.24 or newer. Run: ```console $ mkdir -p data -$ go run . data :3333 +$ cp config.toml.example config.toml +$ go run ./src ``` This starts an HTTP server on `0.0.0.0:3333` whose behavior is fully determined by the `data` directory. It will accept requests to any virtual host, but must first be provisioned. For example: diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..0be048b --- /dev/null +++ b/config.toml.example @@ -0,0 +1,5 @@ +data-dir = "./data" + +[listen] +protocol = "tcp" +address = ":3333" diff --git a/go.mod b/go.mod index a922b9d..e27b1d3 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect golang.org/x/crypto v0.41.0 // indirect diff --git a/go.sum b/go.sum index a0d9fa4..95d11ca 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7Dmvb github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/src/config.go b/src/config.go new file mode 100644 index 0000000..c8ab78c --- /dev/null +++ b/src/config.go @@ -0,0 +1,27 @@ +package main + +import ( + "os" + + "github.com/pelletier/go-toml/v2" +) + +type Config struct { + DataDir string `toml:"data-dir"` + Listen struct { + Protocol string `toml:"protocol"` + Address string `toml:"address"` + } `toml:"listen"` +} + +func readConfig(path string, config *Config) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + decoder := toml.NewDecoder(file) + decoder.DisallowUnknownFields() + return decoder.Decode(config) +} diff --git a/src/fetch.go b/src/fetch.go index bbed199..0c325bd 100644 --- a/src/fetch.go +++ b/src/fetch.go @@ -37,7 +37,6 @@ func splitHash(hash plumbing.Hash) string { } func fetch( - dataDir string, webRoot string, repoURL string, branch string, @@ -61,10 +60,10 @@ func fetch( } head := ref.Hash() - destDir := filepath.Join(dataDir, "tree", splitHash(head)) + destDir := filepath.Join(config.DataDir, "tree", splitHash(head)) if _, err := os.Stat(destDir); errors.Is(err, os.ErrNotExist) { // check out to a temporary directory to avoid TOCTTOU race on destDir - tempDir, err := os.MkdirTemp(dataDir, ".tree") + tempDir, err := os.MkdirTemp(config.DataDir, ".tree") if err != nil { return FetchResult{err: fmt.Errorf("mkdir temp: %s", err)} } @@ -96,10 +95,10 @@ func fetch( } } - webLink := filepath.Join(dataDir, "www", webRoot) + webLink := filepath.Join(config.DataDir, "www", webRoot) destDirRel, _ := filepath.Rel(filepath.Dir(webLink), destDir) - tempLink := filepath.Join(dataDir, + tempLink := filepath.Join(config.DataDir, fmt.Sprintf(".link.%s.%s", strings.ReplaceAll(webRoot, "/", ".."), head.String())) if err := os.Symlink(destDirRel, tempLink); err != nil { return FetchResult{err: fmt.Errorf("symlink temp: %s", err)} @@ -131,13 +130,12 @@ func fetch( } func Fetch( - dataDir string, webRoot string, repoURL string, branch string, ) FetchResult { log.Println("fetch:", webRoot, repoURL, branch) - result := fetch(dataDir, webRoot, repoURL, branch) + result := fetch(webRoot, repoURL, branch) if result.err == nil { status := "" switch result.outcome { @@ -156,7 +154,6 @@ func Fetch( } func FetchWithTimeout( - dataDir string, webRoot string, repoURL string, branch string, @@ -165,7 +162,7 @@ func FetchWithTimeout( // fetch the updated content with a timeout c := make(chan FetchResult, 1) go func() { - result := Fetch(dataDir, webRoot, repoURL, branch) + result := Fetch(webRoot, repoURL, branch) c <- result }() select { diff --git a/src/main.go b/src/main.go index 21fda38..7b24048 100644 --- a/src/main.go +++ b/src/main.go @@ -1,18 +1,29 @@ package main import ( + "flag" "log" + "net" "net/http" - "os" ) -func main() { - dataDir := os.Args[1] - listenAddr := os.Args[2] +var config Config - http.HandleFunc("/", Serve(dataDir)) - err := http.ListenAndServe(listenAddr, nil) +func main() { + configPath := flag.String("config", "config.toml", "path to configuration file") + flag.Parse() + + if err := readConfig(*configPath, &config); err != nil { + log.Fatalln("failed to read configuration:", err) + } + + listener, err := net.Listen(config.Listen.Protocol, config.Listen.Address) if err != nil { log.Fatalln("failed to listen:", err) } + + http.HandleFunc("/", Serve) + if err := http.Serve(listener, nil); err != nil { + log.Fatalln("failed to serve:", err) + } } diff --git a/src/serve.go b/src/serve.go index cb30700..424054e 100644 --- a/src/serve.go +++ b/src/serve.go @@ -19,7 +19,7 @@ import ( const fetchTimeout = 30 * time.Second -func getPage(dataDir string, w http.ResponseWriter, r *http.Request) error { +func getPage(w http.ResponseWriter, r *http.Request) error { host := getHost(r) // if the first directory of the path exists under `www/$host`, use it as the root, @@ -29,26 +29,26 @@ func getPage(dataDir string, w http.ResponseWriter, r *http.Request) error { requestPath := path if projectName, projectPath, found := strings.Cut(path, "/"); found { projectRoot := filepath.Join("www", host, projectName) - if file, _ := securejoin.OpenInRoot(dataDir, projectRoot); file != nil { + if file, _ := securejoin.OpenInRoot(config.DataDir, projectRoot); file != nil { file.Close() wwwRoot, requestPath = projectRoot, projectPath } } // try to serve `$root/$path` first - file, err := securejoin.OpenInRoot(dataDir, filepath.Join(wwwRoot, requestPath)) + file, err := securejoin.OpenInRoot(config.DataDir, filepath.Join(wwwRoot, requestPath)) if err == nil { // if it's a directory, serve `$root/$path/index.html` stat, statErr := file.Stat() if statErr == nil && stat.IsDir() { defer file.Close() - file, err = securejoin.OpenInRoot(dataDir, + file, err = securejoin.OpenInRoot(config.DataDir, filepath.Join(wwwRoot, requestPath, "index.html")) } } // if whatever we were serving doesn't exist, try to serve `$root/404.html` if errors.Is(err, os.ErrNotExist) { - file, _ = securejoin.OpenInRoot(dataDir, filepath.Join(wwwRoot, "404.html")) + file, _ = securejoin.OpenInRoot(config.DataDir, filepath.Join(wwwRoot, "404.html")) } // acquire read capability to the file being served (if possible) @@ -103,7 +103,7 @@ func getProjectName(w http.ResponseWriter, r *http.Request) (string, error) { } } -func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error { +func putPage(w http.ResponseWriter, r *http.Request) error { host := getHost(r) err := authorize(w, r) @@ -130,7 +130,7 @@ func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error { branch = "pages" } - result := FetchWithTimeout(dataDir, webRoot, repoURL, branch, fetchTimeout) + result := FetchWithTimeout(webRoot, repoURL, branch, fetchTimeout) if result.err == nil { w.Header().Add("Content-Location", r.URL.String()) } @@ -155,7 +155,7 @@ func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error { return result.err } -func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error { +func postPage(w http.ResponseWriter, r *http.Request) error { host := getHost(r) err := authorize(w, r) @@ -199,7 +199,7 @@ func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error { webRoot := fmt.Sprintf("%s/%s", host, projectName) repoURL := event["repository"].(map[string]any)["clone_url"].(string) - result := FetchWithTimeout(dataDir, webRoot, repoURL, "pages", fetchTimeout) + result := FetchWithTimeout(webRoot, repoURL, "pages", fetchTimeout) switch result.outcome { case FetchError: w.WriteHeader(http.StatusServiceUnavailable) @@ -214,23 +214,21 @@ func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error { return result.err } -func Serve(dataDir string) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - log.Println("serve:", r.Method, r.Host, r.URL) - err := error(nil) - switch r.Method { - case http.MethodGet: - err = getPage(dataDir, w, r) - case http.MethodPut: - err = putPage(dataDir, w, r) - case http.MethodPost: - err = postPage(dataDir, w, r) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - err = fmt.Errorf("method %s not allowed", r.Method) - } - if err != nil { - log.Println("serve err:", err) - } +func Serve(w http.ResponseWriter, r *http.Request) { + log.Println("serve:", r.Method, r.Host, r.URL) + err := error(nil) + switch r.Method { + case http.MethodGet: + err = getPage(w, r) + case http.MethodPut: + err = putPage(w, r) + case http.MethodPost: + err = postPage(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + err = fmt.Errorf("method %s not allowed", r.Method) + } + if err != nil { + log.Println("serve err:", err) } }