Basking: a place to dump thoughts, jot notes and keep a journal

Plain markdown files, tags by typing #hashtags, a page per day, search,
pin and trash with undo, autosave with conflict warnings, API tokens,
zip export, and the Lightwell dashboard. Includes install.sh, CI and
release workflows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 20:16:33 -06:00
commit b548faa258
23 files changed
+4026

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
// 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())
}
}