penguin/homekey-go

homekey-go

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