internal/app/web.go
raw ยท 4686 bytes
package app
import (
"context"
_ "embed"
"encoding/json"
"errors"
"homekey.local/provisioner/internal/blegateway"
"io"
"net"
"net/http"
"strings"
"time"
)
// HA ingress authenticates the user. Only its gateway may reach this listener;
// standalone mode binds to loopback instead.
func Handler(locks []*Lock, readers []ReaderConfig, ingress bool, settings ...*Settings) http.Handler {
mux := http.NewServeMux()
if len(settings) > 0 && settings[0] != nil {
cfg := settings[0]
physicalRoutes(mux, cfg)
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
c, rev := cfg.Snapshot()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Config Config `json:"config"`
Revision string `json:"revision"`
}{c, rev})
})
mux.HandleFunc("POST /api/config", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Homekey-Action") != "configuration" {
http.Error(w, "missing action header", 403)
return
}
var payload struct {
Config Config `json:"config"`
Revision string `json:"revision"`
}
r.Body = http.MaxBytesReader(w, r.Body, 128*1024)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
http.Error(w, "invalid configuration JSON", 400)
return
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
http.Error(w, "trailing configuration data", 400)
return
}
if err := cfg.Save(payload.Config, payload.Revision); err != nil {
code := 400
if errors.Is(err, ErrConflict) || errors.Is(err, ErrReloading) {
code = 409
}
http.Error(w, err.Error(), code)
return
}
w.WriteHeader(http.StatusAccepted)
if cfg.Reload != nil {
cfg.Reload()
}
})
mux.HandleFunc("GET /api/discovery", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
c, _ := cfg.Snapshot()
known := make([]string, 0, len(c.Readers))
for _, r := range c.Readers {
known = append(known, r.Address)
}
d, err := blegateway.Discover(ctx, known)
if err != nil {
http.Error(w, err.Error(), 503)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(d)
})
}
mux.HandleFunc("GET /api/locks", func(w http.ResponseWriter, r *http.Request) {
statuses := make([]LockStatus, 0, len(locks))
for _, l := range locks {
statuses = append(statuses, l.Status())
}
readerViews := make([]ReaderView, 0, len(readers))
for _, reader := range readers {
v := ReaderView{ReaderConfig: reader}
if _, err := Group(Assigned(locks, reader.ID)); err != nil {
v.Warning = err.Error()
}
readerViews = append(readerViews, v)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Locks []LockStatus `json:"locks"`
Readers []ReaderView `json:"readers"`
}{statuses, readerViews})
})
mux.HandleFunc("POST /api/locks/{id}/{action}", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Homekey-Action") != "pairing" {
http.Error(w, "missing action header", http.StatusForbidden)
return
}
for _, l := range locks {
if l.Config.ID == r.PathValue("id") {
switch r.PathValue("action") {
case "pair":
if err := l.Pair(); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
case "cancel":
l.CancelPair()
default:
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
}
http.NotFound(w, r)
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if ingress && (err != nil || host != "172.30.32.2") {
http.Error(w, "ingress only", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); !ingress && origin != "" && !strings.HasSuffix(origin, "://"+r.Host) {
http.Error(w, "invalid origin", http.StatusForbidden)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'self'")
mux.ServeHTTP(w, r)
})
}
type ReaderView struct {
ReaderConfig
Warning string `json:"warning,omitempty"`
}
//go:embed page.html
var page string