// Package web serves Basking's dashboard and its JSON API. package web import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "embed" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "net" "net/http" "strconv" "strings" "sync" "time" "git.veillax.com/Veillax/basking/internal/config" "git.veillax.com/Veillax/basking/internal/store" ) //go:embed static var staticFiles embed.FS const ( cookieName = "basking_session" sessionTTL = 30 * 24 * time.Hour maxLoginFails = 6 loginFailSpan = 5 * time.Minute maxRequestBody = store.MaxBody + 4096 ) type Server struct { cfg *config.Holder st *store.Store version string mu sync.Mutex fails map[string]failure dummyOnce sync.Once dummyHash string } type failure struct { n int until time.Time } // principal is who is making a request. type principal struct { Name string API bool // came with an API token rather than a browser session } func New(h *config.Holder, st *store.Store, version string) *Server { return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}} } // Handler returns the whole site. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) mux.HandleFunc("POST /api/login", s.login) mux.HandleFunc("POST /api/logout", s.api(s.logout)) mux.HandleFunc("GET /api/me", s.api(s.me)) mux.HandleFunc("GET /api/summary", s.api(s.summary)) mux.HandleFunc("GET /api/notes", s.api(s.listNotes)) mux.HandleFunc("POST /api/notes", s.api(s.createNote)) mux.HandleFunc("GET /api/notes/{id}", s.api(s.getNote)) mux.HandleFunc("PUT /api/notes/{id}", s.api(s.updateNote)) mux.HandleFunc("DELETE /api/notes/{id}", s.api(s.deleteNote)) mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote)) mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash)) mux.HandleFunc("POST /api/daily", s.api(s.daily)) mux.HandleFunc("GET /api/export", s.api(s.export)) mux.HandleFunc("POST /api/password", s.api(s.changePassword)) mux.HandleFunc("GET /api/tokens", s.api(s.listTokens)) mux.HandleFunc("POST /api/tokens", s.api(s.newToken)) mux.HandleFunc("DELETE /api/tokens/{name}", s.api(s.deleteToken)) static, _ := fs.Sub(staticFiles, "static") mux.Handle("GET /", http.FileServerFS(static)) return secure(mux) } // secure adds headers that keep the page from being framed, sniffed or fed outside scripts. func secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") if strings.HasPrefix(r.URL.Path, "/api/") { h.Set("Cache-Control", "no-store") } next.ServeHTTP(w, r) }) } // ---- authentication ---- type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal) // api wraps a handler with sign-in and CSRF protection. func (s *Server) api(fn handlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p, ok := s.authenticate(r) if !ok { fail(w, http.StatusUnauthorized, "sign in required") return } // Browser sessions must send a header a web page on another site cannot add. if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" { fail(w, http.StatusForbidden, "missing X-Basking header") return } r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) fn(w, r, p) } } // A session cookie is "expiry.signature". The signature covers the password hash, so changing // the password signs every browser out, and nothing has to be remembered on the server. func sign(c config.Config, exp int64) string { m := hmac.New(sha256.New, []byte(c.Secret)) fmt.Fprintf(m, "session|%d|%s", exp, c.PasswordHash) return hex.EncodeToString(m.Sum(nil)) } func (s *Server) authenticate(r *http.Request) (principal, bool) { c := s.cfg.Get() if c.PasswordHash == "" { return principal{}, false } if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { sum := config.HashToken(strings.TrimPrefix(h, "Bearer ")) for _, t := range c.Tokens { if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 { return principal{Name: "token:" + t.Name, API: true}, true } } return principal{}, false } ck, err := r.Cookie(cookieName) if err != nil { return principal{}, false } expStr, sig, ok := strings.Cut(ck.Value, ".") exp, err := strconv.ParseInt(expStr, 10, 64) if !ok || err != nil || time.Now().Unix() > exp { return principal{}, false } if !hmac.Equal([]byte(sig), []byte(sign(c, exp))) { return principal{}, false } return principal{Name: "you"}, true } func (s *Server) clientIP(r *http.Request) string { if s.cfg.Get().TrustProxy { if f := r.Header.Get("X-Forwarded-For"); f != "" { return strings.TrimSpace(strings.Split(f, ",")[0]) } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func (s *Server) secureCookie(r *http.Request) bool { c := s.cfg.Get() return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https") } func (s *Server) startSession(w http.ResponseWriter, r *http.Request) { c := s.cfg.Get() exp := time.Now().Add(sessionTTL).Unix() http.SetCookie(w, &http.Cookie{Name: cookieName, Value: fmt.Sprintf("%d.%s", exp, sign(c, exp)), Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())}) } func (s *Server) login(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 4096) if r.Header.Get("X-Basking") != "1" { fail(w, http.StatusForbidden, "missing X-Basking header") return } var in struct{ Password string } if json.NewDecoder(r.Body).Decode(&in) != nil { fail(w, http.StatusBadRequest, "bad request") return } ip := s.clientIP(r) s.mu.Lock() f := s.fails[ip] if f.n >= maxLoginFails && time.Now().Before(f.until) { s.mu.Unlock() fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes") return } s.mu.Unlock() hash := s.cfg.Get().PasswordHash if hash == "" { // Spend the same time as for a real check. s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") }) hash = s.dummyHash } ok := config.CheckPassword(hash, in.Password) && s.cfg.Get().PasswordHash != "" s.mu.Lock() if !ok { if time.Now().After(f.until) { f = failure{} } f.n++ f.until = time.Now().Add(loginFailSpan) s.fails[ip] = f s.mu.Unlock() fail(w, http.StatusUnauthorized, "wrong password") return } delete(s.fails, ip) s.mu.Unlock() s.startSession(w, r) writeJSON(w, map[string]bool{"ok": true}) } func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) { http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) writeJSON(w, map[string]bool{"ok": true}) } // ---- helpers ---- func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) } func writeJSONStatus(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } // fail answers with an error. Messages are capitalized here, once, so they read as sentences. func fail(w http.ResponseWriter, code int, msg string) { if r := []rune(msg); len(r) > 0 { msg = strings.ToUpper(string(r[:1])) + string(r[1:]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func decode(w http.ResponseWriter, r *http.Request, v any) bool { if err := json.NewDecoder(r.Body).Decode(v); err != nil { var mbe *http.MaxBytesError if errors.As(err, &mbe) { fail(w, http.StatusRequestEntityTooLarge, "request too large") } else { fail(w, http.StatusBadRequest, "bad request: "+err.Error()) } return false } return true } // storeFail turns a store error into the right status. func storeFail(w http.ResponseWriter, err error) { switch { case errors.Is(err, store.ErrNotFound): fail(w, http.StatusNotFound, err.Error()) case errors.Is(err, store.ErrConflict): fail(w, http.StatusConflict, err.Error()) case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID): fail(w, http.StatusBadRequest, err.Error()) case errors.Is(err, store.ErrTooLong): fail(w, http.StatusRequestEntityTooLarge, err.Error()) default: fail(w, http.StatusInternalServerError, "could not save: "+err.Error()) } }