// Copyright (C) 2023-2024 Umorpha Systems // SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "encoding/json" "errors" "flag" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" sd "git.lukeshu.com/go/libsystemd/sd_daemon" ) // Main entrypoint ///////////////////////////////////////////////////////////// // Standard exit codes defined by [LSB]. // // [LSB]: http://refspecs.linuxbase.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/iniscrptact.html const ( ExitFailure = 1 ExitInvalidArgument = 2 ) type ExitError struct { Err error Code int } // Error implements the [error] interface. func (e *ExitError) Error() string { return e.Err.Error() } // Unwrap implements the interface for [errors.Unwrap]. func (e *ExitError) Unwrap() error { return e.Err } // main is the function that is called by Go itself when the program // starts. func main() { if err := MainWithError(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "%s: error: %v\n", os.Args[0], err) var ecode *ExitError if errors.As(err, &ecode) { if ecode.Code == ExitInvalidArgument { fmt.Fprintf(os.Stderr, "Try '%s --help' for more information.\n", os.Args[0]) } os.Exit(ecode.Code) } os.Exit(1) } } func usage(argparser *flag.FlagSet) { fmt.Printf(`Usage: %s [OPTIONS] http daemon - the dynamic parts of the umorpha.io website OPTIONS: `, argparser.Name()) argparser.SetOutput(os.Stdout) argparser.PrintDefaults() } // MAinWithError is the "main" function, but unlike the actual [main] // function, it returns an error; this is a useful thing for 2 reasons: // // 1. Convenience/consistency: When you encounter an error in Go, // most of the time the correct thing to do is to return it. If // you're writing code in a function that doesn't return an error, // then you can't do that, and it means you have to start doing // things that 90% of the time are bad practices. // // 2. I haven't written tests for avatard, but if I did: When writing // tests, it is often useful to have an exported "main" function // that does not call [os.Exit]. func MainWithError(argStrs []string) error { argparser := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) argHelp := argparser.Bool("help", false, "show this help text") argSocket := argparser.String("socket", "tcp::8080", "the socket to listen on; a \"family:address\" pair, where the\n"+ "\"family\" is one of \"tcp\", \"tcp4\", \"tcp6\", \"unix\", \"unixpacket\"\n"+ "(the usual Go net.Listen() network names), or \"fd\" (to listen\n"+ "on an already-open file descriptor). The \"address\" part for\n"+ "the \"fd\" family is one of\n"+ " 1. a file descriptor number (or the special names \"stdin\",\n"+ " \"stdout\", or \"stderr\" to refer to 0, 1, or 2\n"+ " respectively),\n"+ " 2. \"systemd:N\" to refer to the Nth file descriptor passed in\n"+ " via systemd (or \"systemd\" as shorthand for \"systemd:0\")\n"+ " (see sd_listend_fds(3)),\n"+ " 3. \"systemd:NAME\" to refer to a named file descriptor passed\n"+ " in via systemd (see sd_listend_fds_with_names(3)).\n") argStaticDir := argparser.String("static-dir", "./static", "Where to find static files") argDataDir := argparser.String("data-dir", "./data", "Where to store uploads") argparser.SetOutput(io.Discard) err := argparser.Parse(argStrs) if *argHelp { usage(argparser) return nil } if err != nil { return &ExitError{ Err: err, Code: ExitInvalidArgument, } } if argparser.NArg() > 0 { return &ExitError{ Err: fmt.Errorf("unexpected positional arguments: %q", argparser.Args()), Code: ExitInvalidArgument, } } stype, saddr, ok := strings.Cut(*argSocket, ":") if !ok { return &ExitError{ Err: fmt.Errorf("invalid address: %q", *argSocket), Code: ExitInvalidArgument, } } sock, err := netListen(stype, saddr) if err != nil { return err } srv := &server{ DataDir: *argDataDir, UploadTypes: map[string]struct{}{}, } router := http.NewServeMux() router.Handle("/", http.FileServer(http.Dir(*argStaticDir))) router.Handle("/api/webflow/form", HTTPHandler(srv.ServeForm)) router.Handle("/api/webflow/sign-file", HTTPHandler(srv.ServeSignFile)) log.Printf("Serving on %v...", sock.Addr()) return http.Serve(sock, router) } // netListen is like [net.Listen], but with support for our special // "fd" address family. // // The purpose of doing this is that for development I really want to // be able to have it listen on a TCP port, but for actual deployment // I want it to be able to accept an already-opened socket from // systemd, and so I wrote a reasonably genereral solution. // // Did I avoid unnecessary complexity writing special-purpose code, or // did I introduce it by failing to remember that YAGNI? func netListen(stype, saddr string) (net.Listener, error) { switch stype { case "fd": switch saddr { case "stdin", "0": return net.FileListener(os.Stdin) case "stdout", "1": return net.FileListener(os.Stdout) case "stderr", "2": return net.FileListener(os.Stderr) case "systemd": sdFds := sd.ListenFds(true) if len(sdFds) == 0 { return nil, fmt.Errorf("fd:systemd given, but no systemd file descriptors passed in") } return net.FileListener(sdFds[0]) default: if fd, _ := strconv.Atoi(saddr); fd > 0 { return net.FileListener(os.NewFile(uintptr(fd), fmt.Sprintf("/dev/fd/%d", fd))) } if name, ok := strings.CutPrefix(saddr, "systemd:"); ok { sdFiles := sd.ListenFds(true) for _, file := range sdFiles { if file.Name() == name { return net.FileListener(file) } } if n, err := strconv.Atoi(name); err == nil && n >= 0 && n < len(sdFiles) { return net.FileListener(sdFiles[n]) } return nil, fmt.Errorf("does not match any systemd file descriptor: %q", name) } return nil, fmt.Errorf("invalid file descriptor name: %q", saddr) } default: return net.Listen(stype, saddr) } } type HTTPError struct { Err error Code int } // Error implements the [error] interface. func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d: %v", e.Code, e.Err) } // Unwrap implements the interface for [errors.Unwrap]. func (e *HTTPError) Unwrap() error { return e.Err } const httpStatusWriteError = -100 func HTTPHandler(fn func(w http.ResponseWriter, r *http.Request) *HTTPError) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := fn(w, r); err != nil { if err.Code != httpStatusWriteError { w.WriteHeader(err.Code) _ = json.NewEncoder(w).Encode(map[string]any{ "msg": err.Err.Error(), "code": err.Code, }) } if err.Code/100 == 5 { log.Printf("error: %q: %v", r.URL, err) } } }) } type server struct { DataDir string UploadTypes map[string]struct{} UploadMaxSize int } func (o *server) ServeForm(w http.ResponseWriter, r *http.Request) *HTTPError { if r.Method != http.MethodPost { return &HTTPError{fmt.Errorf("only POST is supported"), http.StatusMethodNotAllowed} } if err := r.ParseForm(); err != nil { return &HTTPError{fmt.Errorf("could not parse POST form: %w", err), http.StatusBadRequest} } dat := r.PostForm ts := time.Now().Unix() if err := os.MkdirAll(filepath.Join(o.DataDir, "form"), 0777); err != nil { return &HTTPError{err, http.StatusInternalServerError} } file, err := os.CreateTemp(filepath.Join(o.DataDir, "form"), fmt.Sprintf("form-%v-*.json", ts)) if err != nil { return &HTTPError{err, http.StatusInternalServerError} } if err := json.NewEncoder(file).Encode(dat); err != nil { _ = file.Close() return &HTTPError{err, http.StatusInternalServerError} } if err := file.Close(); err != nil { return &HTTPError{err, http.StatusInternalServerError} } if err := json.NewEncoder(w).Encode(map[string]any{ "msg": "ok", "code": http.StatusOK, }); err != nil { return &HTTPError{err, httpStatusWriteError} } return nil } func (o *server) ServeSignFile(w http.ResponseWriter, r *http.Request) *HTTPError { if r.Method != http.MethodGet { return &HTTPError{fmt.Errorf("only GET is supported"), http.StatusMethodNotAllowed} } name := r.FormValue("name") size, err := strconv.Atoi(r.FormValue("size")) if err != nil { return &HTTPError{fmt.Errorf("could not parse size=%q: %w", r.FormValue("size"), err), http.StatusBadRequest} } if _, ok := o.UploadTypes[filepath.Ext(name)]; !ok { types := make([]string, 0, len(o.UploadTypes)) for typ := range o.UploadTypes { types = append(types, typ) } sort.Strings(types) return &HTTPError{fmt.Errorf("InvalidFileTypeError: disallowed file type %q, allowed types are: %q", filepath.Ext(name), types), http.StatusBadRequest} } if size > o.UploadMaxSize { return &HTTPError{fmt.Errorf("MaxFileSizeError: %v > %v", size, o.UploadMaxSize), http.StatusBadRequest} } const TODO = "1234" if err := json.NewEncoder(w).Encode(map[string]any{ "fileId": TODO, // .attr("data-value") gets set to this, but idk the implications of that // how to upload the file "s3url": "https://umorpha.io/api/webflow/file", // where to POST to "postData": map[string]any{ // what to POST with (in addition to the "file" value "authorization": TODO, }, "fileName": filepath.Base(name), // what filename to set for the "file" value }); err != nil { return &HTTPError{err, httpStatusWriteError} } return nil }