Files

130 lines
3.6 KiB
Go

package endpoint
import (
"bufio"
"crypto/subtle"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
)
// The submissions viewer: stored form submissions, read by the editor over a
// listener of its own. Never the public address: the public endpoint stays
// write-only. It answers only with the token, and only on a loopback or
// private address, since submissions are people's personal data.
//
// viewer:
// listen: 10.0.0.5:8182 # a private or loopback address
// token_env: HOTDOG_VIEWER_TOKEN # at least 32 characters
//
// GET /sites/<host>/forms/<form>?limit=50&offset=0 newest first
// ViewerConfig turns the viewer on.
type ViewerConfig struct {
Listen string `yaml:"listen"`
TokenEnv string `yaml:"token_env"`
}
func (v ViewerConfig) check() error {
if v.Listen == "" {
return nil
}
host, _, err := net.SplitHostPort(v.Listen)
if err != nil {
return fmt.Errorf("viewer.listen: %w", err)
}
ip := net.ParseIP(host)
if ip == nil || !(ip.IsLoopback() || ip.IsPrivate()) {
return fmt.Errorf("viewer.listen %q should be a loopback or private address (10.x, 192.168.x, a WireGuard address…): submissions are personal data", v.Listen)
}
if v.TokenEnv == "" {
return fmt.Errorf("viewer needs token_env, the variable holding its token")
}
return nil
}
type viewer struct {
s *Server
token []byte
}
// Viewer is the submissions viewer and where it listens, or nil when the
// endpoint has none.
func (s *Server) Viewer() (http.Handler, string, error) {
v := s.cfg.Viewer
if v.Listen == "" {
return nil, "", nil
}
tok := os.Getenv(v.TokenEnv)
if len(tok) < 32 {
return nil, "", fmt.Errorf("%s must hold the viewer's token, at least 32 characters (openssl rand -hex 32)", v.TokenEnv)
}
return &viewer{s: s, token: []byte(tok)}, v.Listen, nil
}
type stored struct {
Time string `json:"time"`
Values map[string]string `json:"values"`
}
func (v *viewer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
got := []byte(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare(got, v.token) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if r.Method != http.MethodGet || len(parts) != 4 || parts[0] != "sites" || parts[2] != "forms" {
http.NotFound(w, r)
return
}
sr := v.s.sites[strings.ToLower(parts[1])]
if sr == nil {
http.NotFound(w, r)
return
}
f := sr.forms[parts[3]]
if f == nil || !f.Store || sr.cfg.Store == "" {
http.Error(w, "no stored submissions for that form", http.StatusNotFound)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
var all []stored
if file, err := os.Open(filepath.Join(sr.cfg.Store, f.Name+".jsonl")); err == nil {
sc := bufio.NewScanner(file)
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
var e stored
if json.Unmarshal(sc.Bytes(), &e) == nil {
all = append(all, e)
}
}
file.Close()
}
total := len(all)
out := []stored{}
for i := total - 1 - offset; i >= 0 && len(out) < limit; i-- {
out = append(out, all[i]) // newest first
}
fields := []map[string]string{}
for _, fd := range f.Fields {
fields = append(fields, map[string]string{"name": fd.Name, "label": fd.Label})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"form": f.Name, "fields": fields, "total": total, "submissions": out})
}