web/srv.go

312 lines
9.4 KiB
Go

// 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
}