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