312 lines
9.4 KiB
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
|
|
}
|