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