git.s-ol.nu json-hopper / main main.go
main

Tree @main (Download .tar.gz)

main.go @mainraw · history · blame

package main

import (
	"bytes"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/google/uuid"
	jsonschema "github.com/santhosh-tekuri/jsonschema/v5"
)

type HTTPError struct {
	code int
	root error
}

func (e *HTTPError) Error() string {
	msg := http.StatusText(e.code)
	if e.root != nil {
		msg += ": " + e.root.Error()
	}
	return msg
}

type SiteHandler struct {
	name         string
	path         string
	schema       *jsonschema.Schema
	referer_pat  *regexp.Regexp
	redirect_tpl *template.Template
}

type SiteConfig struct {
	RefererPattern   string
	RedirectTemplate string
}

type TemplateParams struct {
	Id  string
	Key string
}

func NewSiteHandler(site_name string, site_path string) (*SiteHandler, error) {
	schema, err := jsonschema.Compile(filepath.Join(site_path, ".schema.json"))
	if err != nil {
		return nil, err
	}

	var cfg SiteConfig
	cfg_data, err := os.ReadFile(filepath.Join(site_path, ".config.json"))
	if err != nil {
		return nil, err
	}
	if err := json.Unmarshal(cfg_data, &cfg); err != nil {
		return nil, err
	}

	referer := regexp.MustCompile(cfg.RefererPattern)
	template := template.Must(template.New(site_name + "_redirect").Parse(cfg.RedirectTemplate))

	return &SiteHandler{site_name, site_path, schema, referer, template}, nil
}

func NewMeta() (map[string]interface{}, error) {
	id, err := uuid.NewUUID()
	if err != nil {
		return nil, err
	}

	key, err := uuid.NewRandom()
	if err != nil {
		return nil, err
	}

	now := time.Now().UTC().String()

	data := make(map[string]interface{})
	data["id"] = id.String()
	data["key"] = key.String()
	data["submitted"] = now

	return data, nil
}

func processValue(typ string, value string) interface{} {
	switch typ {
	case "null":
		if value == "" {
			return nil
		}
	case "boolean":
		if value == "true" || value == "on" {
			return true
		} else if value == "false" || value == "off" {
			return false
		}
	case "number":
		val, err := strconv.ParseFloat(value, 64)
		if err == nil {
			return val
		}
	case "integer":
		val, err := strconv.Atoi(value)
		if err == nil {
			return val
		}
	}
	return value
}

func (h *SiteHandler) collectData(r *http.Request) (map[string]interface{}, error) {
	content_type := r.Header.Get("Content-Type")
	var err error
	if content_type == "application/x-www-form-urlencoded" {
		err = r.ParseForm()
	} else if content_type == "multipart/form-data" {
		err = r.ParseMultipartForm(1 * 1024 * 1024)
	}
	if err != nil {
		return nil, &HTTPError{http.StatusBadRequest, err}
	}

	data := make(map[string]interface{})
	for key, values := range r.PostForm {
		if key == "_key" {
			continue
		}

		key_schema, ok := h.schema.Properties[key]

		val_type := ""
		if ok && key_schema.Types[0] == "array" {
			if key_schema.Items2020 != nil {
				val_type = key_schema.Items2020.Types[0]
			}
		} else if ok {
			val_type = key_schema.Types[0]
		}

		if ok && key_schema.Types[0] != "array" && len(values) == 1 {
			data[key] = processValue(val_type, values[0])
		} else {
			tmp := make([]interface{}, len(values))
			for i := range values {
				tmp[i] = processValue(val_type, values[i])
			}

			data[key] = tmp
		}
	}

	return data, nil
}

func (h *SiteHandler) handlePOST(w http.ResponseWriter, r *http.Request, meta map[string]interface{}) error {
	data, err := h.collectData(r)
	if err != nil {
		return err
	}

	if err := h.schema.Validate(data); err != nil {
		log.Printf("Validation Error %s", err)
		return &HTTPError{http.StatusNotAcceptable, nil}
	}

	if meta == nil {
		meta, err = NewMeta()
		if err != nil {
			return err
		}
	} else {
		meta["updated"] = time.Now().UTC().String()
	}
	data["$meta"] = meta

	raw_data, err := json.Marshal(data)
	if err != nil {
		return err
	}

	id, key := meta["id"].(string), meta["key"].(string)
	if err := os.WriteFile(
		filepath.Join(h.path, id + ".json"),
		append(raw_data, '\n'),
		0644,
	); err != nil {
		return err
	}

	log.Printf("POST %s", meta["id"])

	var redirect_buf bytes.Buffer
	h.redirect_tpl.Execute(&redirect_buf, TemplateParams{id, key})

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Location", redirect_buf.String())
	w.WriteHeader(http.StatusSeeOther)
	w.Write(raw_data)
	return nil
}

func (h *SiteHandler) handle(w http.ResponseWriter, r *http.Request) error {
	parts := strings.Split(r.URL.Path, "/")

	if len(parts) != 3 || parts[1] != h.name {
		return &HTTPError{http.StatusInternalServerError, nil}
	}

	if !h.referer_pat.MatchString(r.Header.Get("Referer")) {
		return &HTTPError{http.StatusUnauthorized, nil}
	}

	w.Header().Set("Access-Control-Allow-Origin", "*")
	w.Header().Set("Access-Control-Allow-Methods", "*")

	id := parts[2]

	if id == "" {
		if r.Method != "POST" {
			return &HTTPError{http.StatusMethodNotAllowed, nil}
		}

		return h.handlePOST(w, r, nil)
	}

	var data map[string]interface{}
	raw_data, err := os.ReadFile(filepath.Join(h.path, id+".json"))
	if err != nil {
		return &HTTPError{http.StatusNotFound, nil}
	}

	if err := json.Unmarshal(raw_data, &data); err != nil {
		return err
	}

	meta := data["$meta"].(map[string]interface{})
	key := r.FormValue("_key")
	if meta["key"] != key {
		return &HTTPError{http.StatusUnauthorized, nil}
	}

	if r.Method == "POST" {
		return h.handlePOST(w, r, meta)
	} else if r.Method == "GET" {
		w.Header().Set("Content-Type", "application/json")
		w.Write(raw_data)
		return nil
	}

	return &HTTPError{http.StatusMethodNotAllowed, nil}
}

func (h *SiteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	err := h.handle(w, r)

	if err != nil {
		var code int

		switch e := err.(type) {
		case *HTTPError:
			code = e.code
			if e.root != nil {
				log.Printf("Error %s: ", err.Error(), e.root)
			}
		default:
			code = http.StatusInternalServerError
			log.Printf("Error: ", err)
		}

		http.Error(w, http.StatusText(code), code)
	}
}

func main() {
	sites, err := filepath.Glob(os.Args[1] + "/*")
	if err != nil {
		log.Fatalf("Error locating sites: %#v", err)
	}

	for _, site_path := range sites {
		site_name := filepath.Base(site_path)
		log.Printf("loading site %s", site_name)
		handler, err := NewSiteHandler(site_name, site_path)
		if err != nil {
			log.Fatalf("NewSiteHandler %s: ", site_path, err)
		}

		http.Handle("/"+site_name+"/", handler)
	}

	log.Printf("ready, listening on :3000")
	err = http.ListenAndServe(":3000", nil) // setting listening port
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}