Basking: sign in with Authentik only, pages that nest and link

Sign-in is now OpenID Connect against Authentik (authorization code flow
with PKCE, state and nonce). The password, API tokens and the add command
are gone; each person who signs in gets a private notebook in a folder
named from a hash of who they are. An optional allowed list is checked on
every request.

Notes become pages with titles, a tree of subpages, [[links]] with
backlinks that follow renames, a Ctrl+K finder, slash commands, list
continuation, and Write and Read modes. Autosave keeps the text through a
dropped connection or an expired session. Export opens as an Obsidian vault.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 22:51:26 -06:00
1 parent b548faa258
commit 98f7730913
19 files changed
+3291 -1578

No files matched your search

+38 -138
View File
@@ -3,24 +3,16 @@ package web
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
)
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, map[string]any{"version": s.version, "api": p.API})
}
func (s *Server) summary(w http.ResponseWriter, r *http.Request, p principal) {
counts, tags := s.st.Summary()
writeJSON(w, map[string]any{"counts": counts, "tags": tags})
func (s *Server) pages(w http.ResponseWriter, r *http.Request, p principal) {
counts, tags := p.st.Summary()
writeJSON(w, map[string]any{"pages": p.st.Pages(), "counts": counts, "tags": tags})
}
func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal) {
@@ -34,29 +26,24 @@ func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal)
if len(text) > 200 {
text = text[:200]
}
notes, total := s.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit})
notes, total := p.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit})
writeJSON(w, map[string]any{"notes": notes, "total": total})
}
// createNote takes {"body": "..."} as JSON, or the note itself as plain text, so a script can
// just post what it has: curl -d 'buy milk' -H 'Content-Type: text/plain' ...
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
var body string
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" {
var in struct{ Body, Text string }
if !decode(w, r, &in) {
return
}
body = in.Body + in.Text
} else {
b, err := io.ReadAll(r.Body)
if err != nil {
fail(w, http.StatusRequestEntityTooLarge, "request too large")
return
}
body = string(b)
func (s *Server) find(w http.ResponseWriter, r *http.Request, p principal) {
q := r.URL.Query().Get("q")
if len(q) > 200 {
q = q[:200]
}
n, err := s.st.Create(body)
writeJSON(w, map[string]any{"hits": p.st.Find(q, 12)})
}
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Title, Body, Parent string }
if !decode(w, r, &in) {
return
}
n, err := p.st.Create(store.NewNote{Title: in.Title, Body: in.Body, Parent: in.Parent})
if err != nil {
storeFail(w, err)
return
@@ -64,19 +51,27 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal)
writeJSONStatus(w, http.StatusCreated, n)
}
// page is a note with the pages that link to it.
type page struct {
store.Note
Backlinks []store.Ref `json:"backlinks"`
}
func (s *Server) getNote(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.Get(r.PathValue("id"))
n, err := p.st.Get(r.PathValue("id"))
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
writeJSON(w, page{n, p.st.Backlinks(n.ID)})
}
func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Title *string `json:"title"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
Parent *string `json:"parent"`
Base string `json:"base"`
}
if !decode(w, r, &in) {
@@ -86,30 +81,30 @@ func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal)
if in.Base != "" {
t, err := time.Parse(time.RFC3339Nano, in.Base)
if err != nil {
fail(w, http.StatusBadRequest, "base must be the note's updated time")
fail(w, http.StatusBadRequest, "base must be the page's updated time")
return
}
base = t
}
n, err := s.st.Update(r.PathValue("id"), in.Body, in.Pinned, base)
n, err := p.st.Update(r.PathValue("id"), store.Change{Title: in.Title, Body: in.Body, Pinned: in.Pinned, Parent: in.Parent}, base)
if errors.Is(err, store.ErrConflict) {
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This note changed somewhere else", "note": n})
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This page changed somewhere else", "note": n})
return
}
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
writeJSON(w, page{n, p.st.Backlinks(n.ID)})
}
func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id")
var err error
if r.URL.Query().Get("purge") == "1" {
err = s.st.Purge(id)
err = p.st.Purge(id)
} else {
_, err = s.st.Trash(id)
_, err = p.st.Trash(id)
}
if err != nil {
storeFail(w, err)
@@ -119,7 +114,7 @@ func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal)
}
func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.Restore(r.PathValue("id"))
n, err := p.st.Restore(r.PathValue("id"))
if err != nil {
storeFail(w, err)
return
@@ -128,7 +123,7 @@ func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal
}
func (s *Server) emptyTrash(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.EmptyTrash()
n, err := p.st.EmptyTrash()
if err != nil {
storeFail(w, err)
return
@@ -141,114 +136,19 @@ func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
if !decode(w, r, &in) {
return
}
n, err := s.st.Daily(in.Date)
n, err := p.st.Daily(in.Date)
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
writeJSON(w, page{n, p.st.Backlinks(n.ID)})
}
func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="basking-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.st.WriteZip(w); err != nil {
if err := p.st.WriteZip(w); err != nil {
// The download has started, so all that can be done is to cut it short.
panic(http.ErrAbortHandler)
}
}
// ---- password and tokens ----
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Current, New string }
if !decode(w, r, &in) {
return
}
if !config.CheckPassword(s.cfg.Get().PasswordHash, in.Current) {
fail(w, http.StatusForbidden, "the current password is wrong")
return
}
if len(in.New) < 8 {
fail(w, http.StatusBadRequest, "the new password must be at least 8 characters")
return
}
hash, err := config.HashPassword(in.New)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.cfg.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
s.startSession(w, r) // the old cookie no longer matches, so give this browser a new one
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) {
type row struct {
Name string `json:"name"`
Created time.Time `json:"created"`
}
out := []row{}
for _, t := range s.cfg.Get().Tokens {
out = append(out, row{t.Name, t.Created})
}
writeJSON(w, out)
}
func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) {
if p.API {
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
return
}
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
token, hash, err := config.NewToken()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
name := strings.TrimSpace(in.Name)
err = s.cfg.Update(func(c *config.Config) error {
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now().UTC()})
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, map[string]string{"name": name, "token": token})
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) {
if p.API {
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
return
}
name := r.PathValue("name")
found := false
err := s.cfg.Update(func(c *config.Config) error {
keep := c.Tokens[:0]
for _, t := range c.Tokens {
if t.Name == name {
found = true
continue
}
keep = append(keep, t)
}
c.Tokens = keep
return nil
})
switch {
case err != nil:
fail(w, http.StatusInternalServerError, err.Error())
case !found:
fail(w, http.StatusNotFound, "no such token")
default:
writeJSON(w, map[string]bool{"ok": true})
}
}
+180
View File
@@ -0,0 +1,180 @@
package web
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"regexp"
"strings"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
)
const (
sessionCookie = "basking_session"
flowCookie = "basking_signin"
sessionTTL = 14 * 24 * time.Hour
flowTTL = 10 * time.Minute
)
// A cookie is "payload.signature": the payload is JSON, the signature is an HMAC with the secret from
// the settings file. Nothing is kept on the server, so a restart does not sign anyone out.
func seal(c config.Config, kind string, v any) string {
raw, _ := json.Marshal(v)
payload := base64.RawURLEncoding.EncodeToString(raw)
return payload + "." + mac(c, kind, payload)
}
func mac(c config.Config, kind, payload string) string {
m := hmac.New(sha256.New, []byte(c.Secret))
m.Write([]byte(kind + "|" + payload))
return hex.EncodeToString(m.Sum(nil))
}
func unseal(c config.Config, kind, value string, v any) bool {
payload, sig, ok := strings.Cut(value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(mac(c, kind, payload))) {
return false
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
return err == nil && json.Unmarshal(raw, v) == nil
}
type sessionData struct {
Sub string `json:"s"`
Name string `json:"n"`
Email string `json:"e"`
User string `json:"u"`
Exp int64 `json:"x"`
}
type flowData struct {
State string `json:"s"`
Nonce string `json:"n"`
Verifier string `json:"v"`
Next string `json:"t"`
Exp int64 `json:"x"`
}
// session reads the sign-in cookie. The allowed list is checked on every request, so taking someone
// off it takes effect at once.
func (s *Server) session(c config.Config, r *http.Request) (sessionData, bool) {
var d sessionData
ck, err := r.Cookie(sessionCookie)
if err != nil || !unseal(c, "session", ck.Value, &d) || d.Sub == "" || time.Now().Unix() > d.Exp {
return sessionData{}, false
}
return d, allowed(c, d)
}
func allowed(c config.Config, d sessionData) bool {
if len(c.Allowed) == 0 {
return true
}
for _, a := range c.Allowed {
for _, who := range []string{d.Email, d.User, d.Sub} {
if who != "" && strings.EqualFold(strings.TrimSpace(a), who) {
return true
}
}
}
return false
}
func setCookie(w http.ResponseWriter, c config.Config, name, value, path string, ttl time.Duration) {
ck := &http.Cookie{Name: name, Value: value, Path: path, HttpOnly: true, Secure: c.Secure(), SameSite: http.SameSiteLaxMode}
if value == "" {
ck.MaxAge = -1
} else {
ck.MaxAge = int(ttl.Seconds())
}
http.SetCookie(w, ck)
}
// Where to go after signing in: only a place inside Basking, such as #/p/abc.
var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`)
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
next := r.URL.Query().Get("next")
if !nextRe.MatchString(next) {
next = ""
}
f := flowData{State: oidc.Random(18), Nonce: oidc.Random(18), Verifier: oidc.Random(40), Next: next, Exp: time.Now().Add(flowTTL).Unix()}
to, err := s.oidc.AuthURL(r.Context(), f.State, f.Nonce, oidc.Challenge(f.Verifier))
if err != nil {
log.Printf("sign-in: %v", err)
http.Redirect(w, r, "/?signin=unavailable", http.StatusSeeOther)
return
}
setCookie(w, c, flowCookie, seal(c, "flow", f), "/auth/", flowTTL)
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
back := func(why string) { http.Redirect(w, r, "/?signin="+why, http.StatusSeeOther) }
var f flowData
ck, err := r.Cookie(flowCookie)
setCookie(w, c, flowCookie, "", "/auth/", 0) // a sign-in attempt is used once
if err != nil || !unseal(c, "flow", ck.Value, &f) || time.Now().Unix() > f.Exp {
back("expired")
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
log.Printf("sign-in: the provider answered %q: %s", e, q.Get("error_description"))
back("denied")
return
}
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(f.State)) != 1 || q.Get("code") == "" {
back("failed")
return
}
id, err := s.oidc.Exchange(r.Context(), q.Get("code"), f.Verifier, f.Nonce)
if err != nil {
log.Printf("sign-in: %v", err)
if errors.Is(err, oidc.ErrUnavailable) {
back("unavailable")
} else {
back("failed")
}
return
}
d := sessionData{Sub: id.Sub, Name: firstOf(id.Name, id.Username, id.Email, "You"), Email: id.Email, User: id.Username,
Exp: time.Now().Add(sessionTTL).Unix()}
if !allowed(c, d) {
log.Printf("sign-in: %s (%s) is not on the allowed list", d.Name, firstOf(d.Email, d.User, d.Sub))
back("denied")
return
}
setCookie(w, c, sessionCookie, seal(c, "session", d), "/", sessionTTL)
http.Redirect(w, r, "/"+f.Next, http.StatusSeeOther)
}
func firstOf(s ...string) string {
for _, x := range s {
if strings.TrimSpace(x) != "" {
return x
}
}
return ""
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, map[string]any{"name": p.Name, "email": p.Email, "version": s.version})
}
+75 -160
View File
@@ -2,62 +2,79 @@
package web
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"strconv"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"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
)
const maxRequestBody = store.MaxBody + 4096
type Server struct {
cfg *config.Holder
st *store.Store
oidc *oidc.Client
version string
mu sync.Mutex
fails map[string]failure
dummyOnce sync.Once
dummyHash string
books map[string]*store.Store
}
type failure struct {
n int
until time.Time
}
// principal is who is making a request.
// principal is who is making a request, with the notebook that is theirs alone.
type principal struct {
Name string
API bool // came with an API token rather than a browser session
Sub, Name, Email string
st *store.Store
}
func New(h *config.Holder, st *store.Store, version string) *Server {
return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}}
// New checks that Authentik is set up and returns the server.
func New(h *config.Holder, version string) (*Server, error) {
c := h.Get()
if err := c.Check(); err != nil {
return nil, err
}
return &Server{cfg: h, version: version, books: map[string]*store.Store{},
oidc: oidc.New(c.OIDC.Issuer, c.OIDC.ClientID, c.OIDC.ClientSecret, c.RedirectURL())}, nil
}
// OIDC is the sign-in client, for the check command.
func (s *Server) OIDC() *oidc.Client { return s.oidc }
// notebook opens the notes that belong to one person. Their folder is named from a hash of who they
// are, so nothing a person controls ever becomes part of a path.
func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Store, error) {
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
key := hex.EncodeToString(sum[:16])
s.mu.Lock()
defer s.mu.Unlock()
if st, ok := s.books[key]; ok {
return st, nil
}
dir := filepath.Join(c.DataDir, "users", key)
st, err := store.Open(dir)
if err != nil {
return nil, err
}
// A note for whoever looks after the server, so a folder can be matched to a person.
who := fmt.Sprintf("%s <%s>\n", name, email)
if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who {
os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644)
}
s.books[key] = st
return st, nil
}
// Handler returns the whole site.
@@ -65,10 +82,11 @@ 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("GET /auth/login", s.authLogin)
mux.HandleFunc("GET /auth/callback", s.authCallback)
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/pages", s.api(s.pages))
mux.HandleFunc("GET /api/notes", s.api(s.listNotes))
mux.HandleFunc("POST /api/notes", s.api(s.createNote))
@@ -78,168 +96,60 @@ func (s *Server) Handler() http.Handler {
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/find", s.api(s.find))
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)
return s.secure(mux)
}
// secure adds headers that keep the page from being framed, sniffed or fed outside scripts.
func secure(next http.Handler) http.Handler {
func (s *Server) secure(next http.Handler) http.Handler {
hsts := s.cfg.Get().Secure()
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("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if hsts {
h.Set("Strict-Transport-Security", "max-age=31536000")
}
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/auth/") {
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)
c := s.cfg.Get()
ses, ok := s.session(c, 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" {
// A browser must send a header a web page on another site cannot add.
if r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
fail(w, http.StatusForbidden, "missing X-Basking header")
return
}
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
if err != nil {
fail(w, http.StatusInternalServerError, "could not open your notes: "+err.Error())
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
fn(w, r, p)
fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st})
}
}
// 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) }
@@ -252,12 +162,14 @@ func writeJSONStatus(w http.ResponseWriter, code int, v any) {
// fail answers with an error. Messages are capitalized here, once, so they read as sentences.
func fail(w http.ResponseWriter, code int, msg string) {
failCode(w, code, msg, "")
}
func failCode(w http.ResponseWriter, code int, msg, kind 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})
writeJSONStatus(w, code, map[string]string{"error": msg, "code": kind})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
@@ -280,7 +192,10 @@ func storeFail(w http.ResponseWriter, err error) {
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):
case errors.Is(err, store.ErrTitleTaken):
failCode(w, http.StatusUnprocessableEntity, err.Error(), "title_taken")
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID),
errors.Is(err, store.ErrDailyTitle), errors.Is(err, store.ErrBadParent):
fail(w, http.StatusBadRequest, err.Error())
case errors.Is(err, store.ErrTooLong):
fail(w, http.StatusRequestEntityTooLarge, err.Error())
+384 -516
View File
File diff suppressed because it is too large. Load diff
+276
View File
@@ -0,0 +1,276 @@
'use strict';
// ---------- text helpers: edits go through the browser so Undo keeps working ----------
function replaceRange(ta, from, to, text) {
ta.focus(); ta.setSelectionRange(from, to);
const ok = text === '' ? document.execCommand('delete') : document.execCommand('insertText', false, text);
if (!ok) { ta.setRangeText(text, from, to, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })); }
}
const lineStart = (v, pos) => v.lastIndexOf('\n', pos - 1) + 1;
const lineEnd = (v, pos) => { const k = v.indexOf('\n', pos); return k < 0 ? v.length : k; };
// Where the caret is, in pixels inside the text box, found by laying the same text out in a hidden copy.
function caretXY(ta, pos) {
const cs = getComputedStyle(ta), mirror = h('div', { class: 'mirror' }), mark = h('span', {}, '.');
for (const p of ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingTop', 'paddingLeft', 'paddingRight', 'tabSize'])
mirror.style[p] = cs[p];
mirror.style.width = ta.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 'px';
mirror.append(ta.value.slice(0, pos), mark);
document.body.append(mirror);
const at = { x: mark.offsetLeft, y: mark.offsetTop, lh: parseFloat(cs.lineHeight) || 24 };
mirror.remove();
return at;
}
// ---------- the "/" commands and the "[[" page picker ----------
const SLASH = [
{ name: 'Heading 1', hint: '#', keys: 'h1 heading title', text: '# ' },
{ name: 'Heading 2', hint: '##', keys: 'h2 heading', text: '## ' },
{ name: 'Heading 3', hint: '###', keys: 'h3 heading', text: '### ' },
{ name: 'To-do list', hint: '- [ ]', keys: 'todo task checkbox check', text: '- [ ] ' },
{ name: 'Bulleted list', hint: '-', keys: 'bullet list ul', text: '- ' },
{ name: 'Numbered list', hint: '1.', keys: 'number list ol', text: '1. ' },
{ name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' },
{ name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 },
{ name: 'Divider', hint: '---', keys: 'divider line hr rule', text: '---\n' },
{ name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true },
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]' },
{ name: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' },
];
function makePopup(ta, wrap) {
const el = h('div', { class: 'popup', role: 'listbox', 'aria-label': 'Suggestions', hidden: true });
wrap.append(el);
let items = [], sel = 0, onPick = null;
function draw() {
fill(el, items.map((it, i) => h('div', {
class: 'popup-row' + (i === sel ? ' on' : ''), role: 'option', 'aria-selected': String(i === sel),
onmousedown: e => { e.preventDefault(); sel = i; pop.pick(); },
}, h('span', { class: 'popup-name' }, it.name), it.hint ? h('span', { class: 'popup-hint' }, typeof it.hint === 'function' ? it.hint() : it.hint) : null)));
el.querySelector('.on')?.scrollIntoView({ block: 'nearest' });
}
const pop = {
get open() { return !el.hidden; },
show(list, pick, pos) {
if (!list.length) return pop.hide();
items = list; onPick = pick; sel = 0; draw(); el.hidden = false;
const c = caretXY(ta, pos);
el.style.left = Math.max(0, Math.min(ta.offsetLeft + c.x, wrap.clientWidth - 270)) + 'px';
el.style.top = (ta.offsetTop + c.y + c.lh) + 'px';
},
hide() { el.hidden = true; items = []; },
move(d) { sel = (sel + d + items.length) % items.length; draw(); },
pick() { const it = items[sel]; pop.hide(); if (it) onPick(it); },
};
return pop;
}
function pageChoices(q) {
const lq = q.trim().toLowerCase();
const rank = p => { const t = p.title.toLowerCase(); return t === lq ? 0 : t.startsWith(lq) ? 1 : 2; };
const list = S.pages.filter(p => p.title && (!lq || p.title.toLowerCase().includes(lq)))
.sort((a, b) => rank(a) - rank(b) || a.title.localeCompare(b.title)).slice(0, 7)
.map(p => ({ name: p.title, hint: p.daily ? 'Journal' : S.byId.get(p.parent)?.name || '' }));
if (lq && !S.byTitle.has(lq)) list.push({ name: 'Create "' + q.trim() + '"', hint: 'New page', create: q.trim() });
return list;
}
function enhance(ta, wrap) {
const pop = makePopup(ta, wrap);
function check() {
if (ta.selectionStart !== ta.selectionEnd) return pop.hide();
const pos = ta.selectionStart, ls = lineStart(ta.value, pos), before = ta.value.slice(ls, pos);
let m;
if ((m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
const from = pos - m[1].length;
pop.show(pageChoices(m[1]), it => {
const closed = ta.value.startsWith(']]', pos);
replaceRange(ta, from, closed ? pos + 2 : pos, (it.create || it.name) + ']]');
}, pos);
} else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) {
const q = m[2].toLowerCase(), from = ls + m[1].length;
pop.show(SLASH.filter(c => !q || c.name.toLowerCase().includes(q) || c.keys.includes(q)), c => {
const text = typeof c.text === 'function' ? c.text() : c.text;
replaceRange(ta, from, pos, text);
if (c.caret) ta.setSelectionRange(from + c.caret, from + c.caret);
if (c.again) check();
}, pos);
} else pop.hide();
}
function continueList(e) {
if (ta.selectionStart !== ta.selectionEnd) return false;
const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), le = lineEnd(v, pos), line = v.slice(ls, le);
const m = /^(\s*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?/.exec(line);
if (!m || pos < ls + m[0].length) return false;
e.preventDefault();
if (line.trim() === m[0].trim()) { replaceRange(ta, ls, le, ''); return true; } // an empty item ends the list
const marker = m[3] ? (Number(m[3]) + 1) + m[4] : m[2];
replaceRange(ta, pos, pos, '\n' + m[1] + marker + ' ' + (m[5] ? '[ ] ' : ''));
return true;
}
function indentList(e) {
if (ta.selectionStart !== ta.selectionEnd) return false;
const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), line = v.slice(ls, lineEnd(v, pos));
if (!/^\s*([-*+]|\d+[.)])\s/.test(line)) return false; // anywhere else, Tab keeps moving focus
e.preventDefault();
if (!e.shiftKey) { replaceRange(ta, ls, ls, ' '); ta.setSelectionRange(pos + 2, pos + 2); }
else {
const n = line.startsWith(' ') ? 2 : /^[ \t]/.test(line) ? 1 : 0;
if (n) { replaceRange(ta, ls, ls + n, ''); ta.setSelectionRange(pos - n, pos - n); }
}
return true;
}
function wrapSelection(mark) {
const s = ta.selectionStart, e = ta.selectionEnd, sel = ta.value.slice(s, e);
replaceRange(ta, s, e, mark + sel + mark);
ta.setSelectionRange(s + mark.length, s + mark.length + sel.length);
}
ta.addEventListener('keydown', e => {
if (pop.open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); pop.move(e.key === 'ArrowDown' ? 1 : -1); return; }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pop.pick(); return; }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); pop.hide(); return; }
}
if (e.isComposing) return;
const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
if (e.key === 'Enter' && plain && !e.shiftKey) continueList(e);
else if (e.key === 'Tab' && plain) indentList(e);
else if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'b' || e.key === 'i')) { e.preventDefault(); wrapSelection(e.key === 'b' ? '**' : '*'); }
});
ta.addEventListener('input', check);
ta.addEventListener('click', check);
ta.addEventListener('keyup', e => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) check(); });
ta.addEventListener('blur', () => setTimeout(pop.hide, 150));
return pop;
}
// ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ----------
// With no note it starts a new page, which is only created once something is typed.
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false;
let timer = null, retry = null, badTitle = null, mode = 'write';
const titleEl = h('input', { class: 'title-input', type: 'text', placeholder: 'Untitled', 'aria-label': 'Page title', maxlength: 120, autocomplete: 'off', spellcheck: 'true' });
titleEl.value = fixedTitle ?? note?.title ?? '';
if (fixedTitle != null) { titleEl.readOnly = true; titleEl.tabIndex = -1; }
const titleErr = h('p', { class: 'err small', role: 'alert', hidden: true });
const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" });
ta.value = note?.body ?? '';
const view = h('div', { class: 'view', hidden: true });
const wrap = h('div', { class: 'editor-wrap' }, ta, view);
const status = h('span', { class: 'sub', role: 'status' });
const notice = h('div', { class: 'notice notice-warning', hidden: true });
const say = t => { status.textContent = t; };
const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 2 + 'px'; };
enhance(ta, wrap);
const dirty = () => titleDirty || bodyDirty;
const ctl = { save, dirty };
S.editors.add(ctl);
function flag(msg) { notice.hidden = !msg; if (msg) fill(notice, icon('alert', 16), msg); }
async function save() {
clearTimeout(timer); timer = null; clearTimeout(retry); retry = null;
if (blocked) return;
const t = titleEl.value, b = ta.value;
const sendTitle = titleDirty && t !== badTitle, sendBody = bodyDirty;
if (!sendTitle && !sendBody) return;
if (saving) { queued = true; return; }
if (!cur && !t.trim() && !b.trim()) { titleDirty = bodyDirty = false; return; } // nothing worth keeping
saving = true; say('Saving');
try {
let n;
if (!cur) n = await api('POST', '/api/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 });
else {
const payload = { base: cur.updated };
if (sendTitle) payload.title = t;
if (sendBody) payload.body = b;
n = await api('PUT', '/api/notes/' + cur.id, payload, { keepalive: b.length < 15000 });
}
const created = !cur;
cur = n;
if (sendTitle) { if (titleEl.value === t) titleDirty = false; badTitle = null; titleErr.hidden = true; }
if (sendBody && ta.value === b) bodyDirty = false;
flag(null);
say(dirty() ? 'Unsaved' : 'Saved');
if (created) onCreated?.(cur);
onSaved?.(cur, { renamed: sendTitle });
} catch (e) {
if (e.status === 409) conflict(e.data.note);
else if (e.status === 422 && e.data?.code === 'title_taken') {
badTitle = t; titleErr.textContent = 'Another page already has this title. Pick a different one.'; titleErr.hidden = false;
say('Unsaved'); queued = bodyDirty || !cur && !!b.trim();
} else if (e.status === 401) {
say('Not saved');
flag([h('span', { class: 'grow' }, 'Your session ended, so your latest changes are not saved yet. Your text is still here.'),
h('a', { class: 'btn-link', href: '/auth/login?next=' + encodeURIComponent(location.hash), target: '_blank', rel: 'noopener' }, 'Sign in again'),
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
retry = setTimeout(save, 4000);
} else {
say('Not saved');
if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); }
else toast(e.message, true);
}
} finally {
saving = false;
if (queued) { queued = false; save(); }
}
}
function conflict(theirs) {
blocked = true; say('Not saved');
flag([h('span', { class: 'grow' }, 'This page changed somewhere else, probably on another device.'),
h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = titleEl.value !== theirs.title; bodyDirty = ta.value !== theirs.body; flag(null); save(); } }, 'Keep my version'),
h('button', { class: 'ghost', onclick: () => {
cur = theirs; blocked = false; titleDirty = bodyDirty = false; titleEl.value = theirs.title; ta.value = theirs.body;
flag(null); say('Saved'); fit(); if (mode === 'read') render(); onSaved?.(cur, { renamed: true });
} }, 'Use the other version')]);
}
const later = () => { clearTimeout(timer); timer = setTimeout(save, 700); };
titleEl.addEventListener('input', () => { titleDirty = true; titleErr.hidden = true; if (badTitle !== null && titleEl.value !== badTitle) badTitle = null; say('Unsaved'); later(); });
titleEl.addEventListener('blur', () => save());
titleEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); setMode('write'); ta.focus(); ta.setSelectionRange(0, 0); }
});
ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); });
// Write shows the text box; Read shows the page as it will look, with tasks you can tick and links you can follow.
function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); }
function ticked(line, checked) {
const lines = ta.value.split('\n');
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
ta.value = lines.join('\n'); bodyDirty = true; say('Unsaved'); render(); later();
}
function setMode(m) {
mode = m;
if (m === 'read') { render(); fit(); }
ta.hidden = m === 'read'; view.hidden = m === 'write';
if (m === 'write') fit();
ctl.onMode?.(m);
}
view.addEventListener('click', e => {
if (e.target.closest('a,input,button') || inSelection()) return;
setMode('write'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length);
});
window.addEventListener('resize', fit);
// Getters must be defined on the object itself; Object.assign would copy their values once.
Object.defineProperties(ctl, { mode: { get: () => mode }, note: { get: () => cur }, body: { get: () => ta.value } });
return Object.assign(ctl, {
titleEl, titleErr, ta, view, wrap, status, notice, setMode,
fit, flush: () => save(),
// Insert the time where the caret is, on a line of its own.
stamp() {
setMode('write'); ta.focus();
const at = ta.selectionStart, before = ta.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1);
const lead = line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : '';
replaceRange(ta, at, ta.selectionEnd, lead + hhmm(new Date()) + ' ');
},
dispose() { S.editors.delete(ctl); window.removeEventListener('resize', fit); clearTimeout(timer); clearTimeout(retry); },
});
}
+3
View File
@@ -10,6 +10,9 @@
</head>
<body>
<div id="app"></div>
<script src="toolkit.js"></script>
<script src="markdown.js"></script>
<script src="editor.js"></script>
<script src="app.js"></script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// ---------- markdown ----------
// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links.
// [[Page title]] and [[Page title|text]] link to other pages; pageByTitle (app.js) says which exist.
const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^*\\n]*?)\\*(?![\\w*])|\\[\\[([^\\[\\]|\\n]+?)(?:\\|([^\\[\\]\\n]*))?\\]\\]|\\[([^\\]\\n]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)|(https?:\\/\\/[^\\s<]*[^\\s<.,;:!?)\\]\'"])|(?<=^|[\\s(])#([\\p{L}\\p{N}_][\\p{L}\\p{N}_/-]*)';
const tagChip = t => h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t);
const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label);
function wikiLink(target, text) {
const title = target.trim(), page = pageByTitle(title);
if (page) return h('a', { class: 'wikilink', href: '#/p/' + page.id }, text || title);
return h('a', { class: 'wikilink missing', href: '#/link/' + encodeURIComponent(title), title: 'No page called "' + title + '" yet. Click to create it.' }, text || title);
}
function inline(text) {
const re = new RegExp(INLINE_SRC, 'gu'), out = [];
let last = 0, m;
while ((m = re.exec(text))) {
if (m.index > last) out.push(text.slice(last, m.index));
last = re.lastIndex;
if (m[1] != null) out.push(h('code', {}, m[1]));
else if (m[2] != null) out.push(h('strong', {}, inline(m[2])));
else if (m[3] != null) out.push(h('em', {}, inline(m[3])));
else if (m[4] != null) out.push(wikiLink(m[4], m[5]));
else if (m[6] != null) out.push(extLink(m[7], m[6]));
else if (m[8] != null) out.push(extLink(m[8], m[8]));
else {
const t = m[9].replace(/[/-]+$/, '');
if (!t || /^\d+$/.test(t)) out.push('#' + m[9]);
else out.push(tagChip(t.toLowerCase()), m[9].slice(t.length));
}
}
if (last < text.length) out.push(text.slice(last));
return out;
}
// md renders a page. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
function md(src, onTask, big) {
const root = h('div', { class: 'md' + (big ? ' md-big' : '') }), lines = src.split('\n');
let para = [], i = 0, m;
const flush = () => {
if (!para.length) return;
const p = h('p');
para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t)); });
root.append(p); para = [];
};
while (i < lines.length) {
const line = lines[i];
if (/^\s*```/.test(line)) {
flush(); const code = []; i++;
while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]);
i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); continue;
}
if (!line.trim()) { flush(); i++; continue; }
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2]))); i++; continue; }
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); root.append(h('hr')); i++; continue; }
if (/^>/.test(line)) {
flush(); const q = [];
while (i < lines.length && /^>/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t)); }); root.append(bq); continue;
}
if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) {
flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul');
while (i < lines.length && (m = /^(\s*)([-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(lines[i]))) {
const lvl = Math.min(3, Math.floor(m[1].replace(/\t/g, ' ').length / 2)), at = i;
const li = h('li', { class: 'ind-' + lvl + (m[3] != null ? ' task' : '') });
if (m[3] != null) {
const box = h('input', { type: 'checkbox', checked: m[3] !== ' ', disabled: !onTask, 'aria-label': m[4] || 'Task' });
if (onTask) box.addEventListener('change', () => onTask(at, box.checked));
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4])));
} else li.append(...inline(m[4]));
list.append(li); i++;
}
root.append(list); continue;
}
para.push(line); i++;
}
flush();
return root;
}
+91 -3
View File
@@ -14,7 +14,7 @@
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--sidebar-w:272px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@@ -219,8 +219,6 @@
.toast-act { background:transparent; border:0; color:inherit; font:inherit; font-weight:600; text-decoration:underline; cursor:pointer; padding:0; }
.toolbar .push { margin-left:auto; }
.toolbar input[type=search] { max-width:360px; }
.snippet { margin:0 0 var(--s4); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
.snippet code { white-space:pre; }
/* rendered markdown */
.md > *:first-child { margin-top:0; } .md > *:last-child { margin-bottom:0; }
@@ -237,3 +235,93 @@
.md pre { margin:0 0 var(--s3); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
.md code { background:var(--surface-2); padding:1px 4px; border-radius:4px; } .md pre code { background:none; padding:0; }
.md hr { border:0; border-top:var(--border-w) solid var(--border); margin:var(--s4) 0; }
/* sign in */
.btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; white-space:nowrap; background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
.btn-link:hover { background:var(--surface-2); text-decoration:none; }
.btn-link.primary { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
.btn-link.primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
.btn-link.wide { display:flex; width:100%; min-height:var(--touch); margin-top:var(--s4); }
.login-h { font-size:var(--text-xl); text-align:center; margin-bottom:var(--s2); }
.center-text { text-align:center; }
kbd { font:inherit; font-family:var(--font-mono); font-size:var(--text-xs); padding:1px 6px; border:var(--border-w) solid var(--border-strong); border-radius:4px; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; }
/* sidebar: search, pages tree */
.navitem.find { border:var(--border-w) solid var(--border); margin-bottom:var(--s2); }
.navitem.find kbd { margin-left:auto; }
.navsec-row { display:flex; align-items:center; justify-content:space-between; padding-right:var(--s1); }
button.tiny { width:26px; min-height:26px; height:26px; }
.treerow { display:flex; align-items:center; gap:2px; }
.treerow.d1 { padding-left:var(--s4); } .treerow.d2 { padding-left:var(--s5); } .treerow.d3 { padding-left:calc(var(--s5) + var(--s3)); }
.treerow.d4 { padding-left:var(--s6); } .treerow.d5 { padding-left:calc(var(--s6) + var(--s3)); } .treerow.d6 { padding-left:calc(var(--s6) + var(--s4)); }
.tree-link { flex:1 1 auto; min-width:0; min-height:32px; padding:var(--s1) var(--s2); }
.twist, .twist-gap { width:24px; height:24px; flex:none; }
.twist { display:inline-flex; align-items:center; justify-content:center; border:0; background:none; color:var(--text-faint); cursor:pointer; border-radius:var(--radius); padding:0; }
.twist:hover { background:var(--surface-2); color:var(--text); }
.twist .icon { transition:transform var(--ease); }
.twist.open .icon { transform:rotate(90deg); }
.sub-add { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; flex:none; border:0; background:none; border-radius:var(--radius); color:var(--text-faint); cursor:pointer; opacity:0; padding:0; }
.treerow:hover .sub-add, .sub-add:focus-visible { opacity:1; }
.sub-add:hover { background:var(--surface-2); color:var(--text); }
.navhint { margin:0; padding:var(--s1) var(--s3); font-size:var(--text-sm); color:var(--text-muted); }
@media (pointer: coarse) { .sub-add { opacity:1; } .navitem.find kbd { display:none; } }
/* a page */
.page { max-width:780px; margin:0 auto; }
.page-top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; margin-bottom:var(--s5); }
.crumbs { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-sm); flex-wrap:wrap; min-width:0; }
.crumbs a { color:var(--text-muted); }
.crumbs .sep { color:var(--text-faint); }
.page-actions { display:flex; align-items:center; gap:var(--s2); flex-wrap:wrap; flex:1 1 auto; justify-content:flex-end; }
.page-actions .sub { min-width:4.5em; }
.daynav { margin-bottom:var(--s4); }
.seg { display:inline-flex; border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; }
.seg-b { min-height:30px; padding:0 var(--s3); font:inherit; font-size:var(--text-sm); font-weight:500; background:var(--surface); color:var(--text-muted); border:0; cursor:pointer; }
.seg-b + .seg-b { border-left:var(--border-w) solid var(--border-strong); }
.seg-b.on { background:var(--accent-soft); color:var(--accent-text); }
.title-wrap { margin-bottom:var(--s2); }
.title-input { font-size:var(--text-2xl); font-weight:700; letter-spacing:-.02em; line-height:1.25; border:0; padding:0; background:transparent; min-height:0; width:100%; }
.title-input:focus { outline:none; border:0; }
.title-input::placeholder { color:var(--text-faint); }
.day-title { font-size:var(--text-2xl); letter-spacing:-.02em; margin-bottom:var(--s2); }
.editor-wrap { position:relative; margin-top:var(--s3); }
.page textarea.write.big { min-height:45vh; line-height:1.7; font-family:var(--font); font-size:var(--text-base); resize:none; overflow:hidden; }
.view { min-height:45vh; cursor:text; overflow-wrap:anywhere; }
.md-big { font-size:var(--text-base); line-height:1.7; }
.md-big h3.md-h { font-size:var(--text-xl); margin-top:var(--s5); } .md-big h4.md-h { font-size:var(--text-lg); margin-top:var(--s4); }
.tagrow { display:flex; flex-wrap:wrap; gap:var(--s2); margin-top:var(--s4); }
.tagrow:empty { display:none; }
.aside { margin-top:var(--s6); display:flex; flex-direction:column; gap:var(--s5); }
.aside:empty { display:none; }
.aside-sec h2 { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); margin-bottom:var(--s2); }
.linklist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
.linklist a { display:inline-flex; align-items:center; gap:var(--s2); padding:var(--s1) 0; }
.page-meta { margin-top:var(--s6); }
.wikilink { text-decoration:none; border-bottom:var(--border-w) solid var(--accent-text); }
.wikilink:hover { background:var(--accent-soft); }
.wikilink.missing { color:var(--text-muted); border-bottom-style:dashed; border-bottom-color:var(--text-faint); }
.popup { position:absolute; z-index:var(--z-sticky); min-width:240px; max-width:320px; max-height:260px; overflow-y:auto; padding:var(--s1); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:var(--shadow); }
.popup-row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); padding:var(--s2) var(--s3); border-radius:var(--radius); cursor:pointer; }
.popup-row.on, .popup-row:hover { background:var(--accent-soft); }
.popup-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.popup-hint { color:var(--text-faint); font-size:var(--text-xs); font-family:var(--font-mono); white-space:nowrap; }
.mirror { position:absolute; left:-9999px; top:0; visibility:hidden; white-space:pre-wrap; overflow-wrap:break-word; box-sizing:content-box; }
.notice .btn-link { min-height:30px; }
/* quick find and the move picker */
.shade-top { align-items:start; padding-top:12vh; }
.finder-input { font-size:var(--text-lg); min-height:44px; margin-bottom:var(--s2); }
.finder-list { display:flex; flex-direction:column; gap:2px; max-height:50vh; overflow-y:auto; }
.finder-row { display:flex; align-items:center; gap:var(--s3); width:100%; padding:var(--s2) var(--s3); border:0; border-radius:var(--radius); background:none; color:var(--text); font:inherit; text-align:left; cursor:pointer; }
.finder-row.on, .finder-row:hover { background:var(--accent-soft); }
.finder-name { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.finder-sub { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
.pad { padding:var(--s3); margin:0; }
/* note cards and settings */
.note.openable { cursor:pointer; }
.note-title { font-size:var(--text-lg); margin-bottom:var(--s2); }
.note-title a { color:inherit; text-decoration:none; }
.note-title a:hover { text-decoration:underline; }
.keys { list-style:none; margin:0; padding:0; display:grid; gap:var(--s2); }
@media (max-width:640px) { .page-actions { justify-content:flex-start; } .shade-top { padding-top:var(--s4); } }
+173
View File
@@ -0,0 +1,173 @@
'use strict';
// ---------- tiny toolkit ----------
// h builds an element. Strings become text nodes, never HTML, so page text can never inject markup.
function h(tag, props, ...kids) {
const el = document.createElement(tag);
let value;
for (const [k, v] of Object.entries(props || {})) {
if (v == null || v === false) continue;
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (k === 'value') value = v;
else if (k === 'checked') el.checked = true;
else el.setAttribute(k, v === true ? '' : v);
}
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
if (value !== undefined) el.value = value;
return el;
}
const $app = document.getElementById('app');
// theme: follows the system unless the person picked one
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ }
function toggleTheme() {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const next = dark ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ }
}
const store = {
get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } },
set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } },
};
// short confirmations at the bottom right; errors stay longer and are announced as alerts
let toastBox;
function toast(msg, bad, action) {
if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); }
const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg),
action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null);
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000);
}
const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s;
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
// onSignedOut is set by the app; it decides what a 401 means while someone is writing
let onSignedOut = () => { };
async function api(method, url, body, opt = {}) {
const init = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin', keepalive: !!opt.keepalive };
if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
let r;
try { r = await fetch(url, init); }
catch { throw Object.assign(new Error('Could not reach Basking. Check your connection.'), { offline: true }); }
const j = await r.json().catch(() => ({}));
if (r.status === 401) { onSignedOut(); throw Object.assign(new Error(SIGNED_OUT), { status: 401 }); }
if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j });
return j;
}
// run an action; show errors as a toast instead of throwing into the void
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.status !== 401) toast(e.message, true); } };
const pad = n => String(n).padStart(2, '0');
const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; };
const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); };
function dayLabel(d) {
const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date();
if (day === isoDay(now)) return 'Today';
if (day === addDays(isoDay(now), -1)) return 'Yesterday';
const dt = parseDay(day);
return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
}
const isDay = s => /^\d{4}-\d{2}-\d{2}$/.test(s);
// status pill: color always travels with a text label
const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text);
const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
// placeholder rows while a page loads, so the layout does not jump
const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'),
['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w })));
// replaceChildren stringifies null and arrays, so everything goes through here
const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false));
const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids);
const inSelection = () => { const s = getSelection(); return s && !s.isCollapsed; };
// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ----------
const ICONS = {
fin: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
notes: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
today: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
journal: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
pin: '<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
tag: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
view: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
prev: '<path d="m15 18-6-6 6-6"/>',
next: '<path d="m9 18 6-6-6-6"/>',
chev: '<path d="m9 18 6-6-6-6"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
move: '<path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
close: '<path d="M18 6 6 18M6 6l12 12"/>',
add: '<path d="M5 12h14M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
};
function icon(name, size = 18) {
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Basking');
const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label);
// ---------- dialogs ----------
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
let dialogSeq = 0;
function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', { class: 'shade' + (opts.top ? ' shade-top' : '') });
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' },
h('h2', { id, class: opts.hideTitle ? 'sr-only' : '' }, title), body);
let closed = false;
const close = () => {
if (closed) return; closed = true;
document.removeEventListener('keydown', onKey, true); shade.remove();
if (opener && opener.isConnected && opener.focus) opener.focus();
if (opts.onClose) opts.onClose();
};
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
function onKey(e) {
if ([...document.querySelectorAll('.shade')].pop() !== shade) return;
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(box);
document.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close;
}
const modalOpen = () => !!document.querySelector('.shade');
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) {
return new Promise(resolve => {
let close;
const done = v => { resolve(v); close(); };
close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body),
h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel),
h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) });
});
}
+528 -253
View File
@@ -3,337 +3,612 @@ package web
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"io"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/basking/internal/oidc"
)
const password = "correct horse battery"
type rig struct {
t *testing.T
srv *httptest.Server
cfg *config.Holder
cookie *http.Cookie
// idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID
// token carrying whatever claims the test set.
type idp struct {
srv *httptest.Server
mu sync.Mutex
nonce string
challenge string
claims map[string]any
down bool
}
func newRig(t *testing.T) *rig {
func newIdP(t *testing.T) *idp {
p := &idp{}
mux := http.NewServeMux()
mux.HandleFunc("/app/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"issuer": p.srv.URL + "/app/", "authorization_endpoint": p.srv.URL + "/authorize", "token_endpoint": p.srv.URL + "/token"})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
defer p.mu.Unlock()
r.ParseForm()
if p.down {
http.Error(w, "boom", http.StatusBadGateway)
return
}
if r.Form.Get("code") != "good" || r.Form.Get("client_secret") != "shh" || r.Form.Get("grant_type") != "authorization_code" ||
oidc.Challenge(r.Form.Get("code_verifier")) != p.challenge || r.Form.Get("redirect_uri") != "http://127.0.0.1:8490/auth/callback" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "basking-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce}
for k, v := range p.claims {
c[k] = v
}
raw, _ := json.Marshal(c)
enc := base64.RawURLEncoding.EncodeToString
json.NewEncoder(w).Encode(map[string]string{"id_token": enc([]byte(`{"alg":"none"}`)) + "." + enc(raw) + ".sig", "access_token": "x"})
})
p.srv = httptest.NewServer(mux)
t.Cleanup(p.srv.Close)
return p
}
type env struct {
t *testing.T
s *Server
h http.Handler
idp *idp
dir string
cfg config.Config
}
func setup(t *testing.T, tweak func(map[string]any)) *env {
t.Helper()
p := newIdP(t)
dir := t.TempDir()
h, err := config.Load(filepath.Join(dir, "basking.json"))
conf := map[string]any{
"data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret",
"oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "basking-client", "client_secret": "shh"},
}
if tweak != nil {
tweak(conf)
}
b, _ := json.Marshal(conf)
path := filepath.Join(dir, "basking.json")
os.WriteFile(path, b, 0o600)
h, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
hash, _ := config.HashPassword(password)
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "data"))
s, err := New(h, "test")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(New(h, st, "test").Handler())
t.Cleanup(srv.Close)
return &rig{t: t, srv: srv, cfg: h}
return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()}
}
func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) {
r.t.Helper()
req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body))
if ctype != "" {
req.Header.Set("Content-Type", ctype)
func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder {
e.t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
r := httptest.NewRequest(method, path, rd)
if body != nil {
r.Header.Set("Content-Type", "application/json")
}
if r.cookie != nil {
req.AddCookie(r.cookie)
if method != http.MethodGet {
r.Header.Set("X-Basking", "1")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
r.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, b
}
// js sends JSON as a browser would, with the CSRF header.
func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) {
r.t.Helper()
var body string
if v != nil {
b, _ := json.Marshal(v)
body = string(b)
}
resp, b := r.do(method, path, "application/json", body, "X-Basking", "1")
var out map[string]any
json.Unmarshal(b, &out)
return resp, out
}
func (r *rig) signIn() {
r.t.Helper()
resp, _ := r.js("POST", "/api/login", map[string]string{"password": password})
if resp.StatusCode != 200 {
r.t.Fatalf("login: %d", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == cookieName {
r.cookie = c
for i := 0; i+1 < len(hdr); i += 2 {
if hdr[i+1] == "" {
r.Header.Del(hdr[i])
} else {
r.Header.Set(hdr[i], hdr[i+1])
}
}
if r.cookie == nil {
r.t.Fatal("no session cookie")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
e.h.ServeHTTP(w, r)
return w
}
func TestRequiresSignIn(t *testing.T) {
r := newRig(t)
for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} {
if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 {
t.Errorf("GET %s without signing in: %d", p, resp.StatusCode)
func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range w.Result().Cookies() {
if c.Name == name {
return c
}
}
if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 {
t.Error("healthz should be open")
}
if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 {
t.Error("the page itself should load")
return nil
}
func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
t.Fatalf("bad json %q: %v", w.Body.String(), err)
}
}
func TestLoginAndHeaders(t *testing.T) {
r := newRig(t)
if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 {
t.Errorf("login without the CSRF header: %d", resp.StatusCode)
// begin starts a sign-in and returns the redirect the browser would follow and the flow cookie.
func (e *env) begin(next string) (*url.URL, *http.Cookie) {
e.t.Helper()
w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil)
if w.Code != http.StatusSeeOther {
e.t.Fatalf("login: %d %s", w.Code, w.Body)
}
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
t.Errorf("wrong password: %d", resp.StatusCode)
to, _ := url.Parse(w.Header().Get("Location"))
return to, cookieNamed(w, flowCookie)
}
// signIn runs the whole flow for a person and returns their session cookie.
func (e *env) signIn(claims map[string]any) []*http.Cookie {
e.t.Helper()
to, flow := e.begin("")
e.idp.mu.Lock()
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims
e.idp.mu.Unlock()
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
ck := cookieNamed(w, sessionCookie)
if w.Code != http.StatusSeeOther || ck == nil {
e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location"))
}
r.signIn()
resp, _ := r.do("GET", "/api/me", "", "")
if resp.StatusCode != 200 {
t.Fatalf("me: %d", resp.StatusCode)
return []*http.Cookie{ck}
}
var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"}
var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"}
func TestNothingWorksWithoutSigningIn(t *testing.T) {
e := setup(t, nil)
for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} {
if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("GET %s without a session: %d", path, w.Code)
}
}
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
t.Errorf("CSP %q", csp)
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized {
t.Errorf("POST without a session: %d", w.Code)
}
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie flags %+v", r.cookie)
if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") {
t.Errorf("the sign-in page itself must load: %d", w.Code)
}
// a browser write needs the header
if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 {
t.Errorf("write without the header: %d", resp.StatusCode)
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
t.Errorf("healthz: %d", w.Code)
}
// a forged cookie does nothing
r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"}
if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 {
t.Errorf("forged cookie: %d", resp.StatusCode)
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized {
t.Errorf("garbage cookie: %d", w.Code)
}
}
func TestLoginIsRateLimited(t *testing.T) {
r := newRig(t)
var last int
for i := 0; i < maxLoginFails+2; i++ {
resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"})
last = resp.StatusCode
func TestSignInFlow(t *testing.T) {
e := setup(t, nil)
to, flow := e.begin("#/p/abc-123")
q := to.Query()
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
t.Fatalf("goes to Authentik: %s", to)
}
if last != 429 {
t.Fatalf("after many failures got %d", last)
for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
"scope": "openid profile email", "code_challenge_method": "S256"} {
if q.Get(k) != want {
t.Errorf("%s = %q, want %q", k, q.Get(k), want)
}
}
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}); resp.StatusCode != 429 {
t.Fatalf("even the right password waits: %d", resp.StatusCode)
for _, k := range []string{"state", "nonce", "code_challenge"} {
if len(q.Get(k)) < 16 {
t.Errorf("%s is too short: %q", k, q.Get(k))
}
}
if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" {
t.Errorf("flow cookie: %+v", flow)
}
e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow})
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/#/p/abc-123" {
t.Fatalf("callback: %d -> %s", w.Code, w.Header().Get("Location"))
}
ses := cookieNamed(w, sessionCookie)
if ses == nil || !ses.HttpOnly || ses.SameSite != http.SameSiteLaxMode || ses.Secure {
t.Fatalf("session cookie: %+v", ses)
}
if c := cookieNamed(w, flowCookie); c == nil || c.MaxAge >= 0 {
t.Error("the flow cookie should be cleared after use")
}
var me struct{ Name, Email string }
jsonOf(t, e.do("GET", "/api/me", nil, []*http.Cookie{ses}), &me)
if me.Name != "Alice" || me.Email != "alice@example.com" {
t.Fatalf("me: %+v", me)
}
// the same flow cookie cannot be used twice by a browser that kept it, and a stale state is refused
if w := e.do("GET", "/auth/callback?code=good&state=wrong", nil, []*http.Cookie{flow}); w.Header().Get("Location") != "/?signin=failed" {
t.Errorf("wrong state: %s", w.Header().Get("Location"))
}
}
func TestNoteLifecycle(t *testing.T) {
r := newRig(t)
r.signIn()
resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"})
if resp.StatusCode != 201 {
t.Fatalf("create: %d %v", resp.StatusCode, n)
func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) {
e := setup(t, nil)
try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) {
t.Helper()
to, flow := e.begin("")
e.idp.mu.Lock()
e.idp.nonce, e.idp.challenge, e.idp.claims, e.idp.down = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice, false
e.idp.mu.Unlock()
target := mutate(to, &flow)
var cks []*http.Cookie
if flow != nil {
cks = append(cks, flow)
}
w := e.do("GET", target, nil, cks)
if got := w.Header().Get("Location"); got != "/?signin="+want || cookieNamed(w, sessionCookie) != nil {
t.Errorf("%s: went to %q (%d), session %v; want %s", name, got, w.Code, cookieNamed(w, sessionCookie), want)
}
}
id, _ := n["id"].(string)
if id == "" || n["body"] != "remember the milk #errands" {
t.Fatalf("created %v", n)
}
if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 {
t.Errorf("empty note: %d", resp.StatusCode)
good := func(to *url.URL) string {
return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state"))
}
_, list := r.js("GET", "/api/notes?q=milk", nil)
if list["total"] != float64(1) {
t.Fatalf("search: %v", list)
}
_, list = r.js("GET", "/api/notes?tag=errands", nil)
if list["total"] != float64(1) {
t.Fatalf("tag: %v", list)
}
try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired")
try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired")
try("wrong state", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=good&state=nope" }, "failed")
try("no code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?state=" + to.Query().Get("state") }, "failed")
try("denied at Authentik", func(to *url.URL, f **http.Cookie) string {
return "/auth/callback?error=access_denied&state=" + to.Query().Get("state")
}, "denied")
try("bad code", func(to *url.URL, f **http.Cookie) string {
return "/auth/callback?code=stolen&state=" + url.QueryEscape(to.Query().Get("state"))
}, "failed")
try("wrong nonce", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.nonce = "other"
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("wrong audience", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "aud": "someone-else"}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("expired token", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "exp": time.Now().Add(-time.Hour).Unix()}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("wrong issuer", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "iss": "https://evil.example.com/"}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("no subject", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": ""}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("Authentik is down", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.down = true
e.idp.mu.Unlock()
return good(to)
}, "unavailable")
// edit with the version we have, then again with a stale one
resp, n2 := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "remember the oat milk", "base": n["updated"]})
if resp.StatusCode != 200 || n2["body"] != "remember the oat milk" {
t.Fatalf("update: %d %v", resp.StatusCode, n2)
}
resp, c := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "stale", "base": n["updated"]})
if resp.StatusCode != 409 {
t.Fatalf("stale update: %d", resp.StatusCode)
}
if cur, _ := c["note"].(map[string]any); cur["body"] != "remember the oat milk" {
t.Errorf("conflict should return the current note: %v", c)
}
if resp, _ := r.js("PUT", "/api/notes/"+id, map[string]any{"pinned": true}); resp.StatusCode != 200 {
t.Errorf("pin: %d", resp.StatusCode)
}
_, list = r.js("GET", "/api/notes?view=pinned", nil)
if list["total"] != float64(1) {
t.Errorf("pinned view: %v", list)
}
if resp, _ := r.js("DELETE", "/api/notes/"+id+"?purge=1", nil); resp.StatusCode != 400 {
t.Errorf("purge of a live note: %d", resp.StatusCode)
}
if resp, _ := r.js("DELETE", "/api/notes/"+id, nil); resp.StatusCode != 200 {
t.Errorf("trash: %d", resp.StatusCode)
}
_, sum := r.js("GET", "/api/summary", nil)
if counts := sum["counts"].(map[string]any); counts["trash"] != float64(1) || counts["all"] != float64(0) {
t.Errorf("summary after trashing: %v", sum)
}
if resp, _ := r.js("POST", "/api/notes/"+id+"/restore", nil); resp.StatusCode != 200 {
t.Errorf("restore: %d", resp.StatusCode)
}
r.js("DELETE", "/api/notes/"+id, nil)
if resp, _ := r.js("DELETE", "/api/trash", nil); resp.StatusCode != 200 {
t.Errorf("empty trash: %d", resp.StatusCode)
}
if resp, _ := r.js("GET", "/api/notes/"+id, nil); resp.StatusCode != 404 {
t.Errorf("gone: %d", resp.StatusCode)
}
for _, bad := range []string{"..%2f..%2fbasking.json", "UPPER", "a.b"} {
if resp, _ := r.js("GET", "/api/notes/"+bad, nil); resp.StatusCode != 404 {
t.Errorf("odd id %q: %d", bad, resp.StatusCode)
// and a next address that points anywhere but inside Basking is dropped
for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/<script>", "javascript:alert(1)"} {
to, flow := e.begin(bad)
e.idp.mu.Lock()
e.idp.down = false
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice
e.idp.mu.Unlock()
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
if w.Header().Get("Location") != "/" {
t.Errorf("next %q led to %q", bad, w.Header().Get("Location"))
}
}
}
func TestDailyEndpoint(t *testing.T) {
r := newRig(t)
r.signIn()
resp, n := r.js("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
if resp.StatusCode != 200 || n["id"] != "daily-2026-10-31" {
t.Fatalf("%d %v", resp.StatusCode, n)
}
if resp, _ := r.js("POST", "/api/daily", map[string]string{"date": "yesterday"}); resp.StatusCode != 400 {
t.Errorf("bad date: %d", resp.StatusCode)
func TestSignInWhenAuthentikIsUnreachable(t *testing.T) {
e := setup(t, nil)
e.idp.srv.Close()
w := e.do("GET", "/auth/login", nil, nil)
if w.Header().Get("Location") != "/?signin=unavailable" {
t.Fatalf("went to %q", w.Header().Get("Location"))
}
}
func TestTokensAndPlainTextCapture(t *testing.T) {
r := newRig(t)
r.signIn()
resp, tok := r.js("POST", "/api/tokens", map[string]string{"name": "phone"})
if resp.StatusCode != 200 {
t.Fatalf("new token: %d %v", resp.StatusCode, tok)
func TestAllowedList(t *testing.T) {
e := setup(t, func(c map[string]any) { c["allowed"] = []string{"ALICE@example.com", "carol"} })
to, flow := e.begin("")
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), bob
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
if w.Header().Get("Location") != "/?signin=denied" || cookieNamed(w, sessionCookie) != nil {
t.Fatalf("Bob should be turned away: %s", w.Header().Get("Location"))
}
token := tok["token"].(string)
if !strings.HasPrefix(token, "bsk_") {
t.Fatalf("token %q", token)
ses := e.signIn(alice) // matched by email, whatever the case
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatalf("Alice: %d", w.Code)
}
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 {
t.Errorf("duplicate name: %d", resp.StatusCode)
if c := e.signIn(map[string]any{"sub": "carol-sub", "preferred_username": "Carol"}); c == nil { // matched by username
t.Fatal("Carol should get in")
}
if b, _ := json.Marshal(r.cfg.Get()); strings.Contains(string(b), token) {
t.Error("the token itself must not be stored")
}
// a script with only the token: no cookie, no CSRF header, plain text body
api := &rig{t: t, srv: r.srv, cfg: r.cfg}
resp, b := api.do("POST", "/api/notes", "text/plain", "call mum #family", "Authorization", "Bearer "+token)
if resp.StatusCode != 201 {
t.Fatalf("capture: %d %s", resp.StatusCode, b)
}
if !strings.Contains(string(b), `"body":"call mum #family"`) {
t.Errorf("body %s", b)
}
if resp, _ := api.do("POST", "/api/notes", "text/plain", "x", "Authorization", "Bearer bsk_wrong"); resp.StatusCode != 401 {
t.Errorf("wrong token: %d", resp.StatusCode)
}
// a token cannot mint more tokens
if resp, _ := api.do("POST", "/api/tokens", "application/json", `{"name":"x"}`, "Authorization", "Bearer "+token); resp.StatusCode != 403 {
t.Errorf("token making a token: %d", resp.StatusCode)
}
_, list := r.js("GET", "/api/tokens", nil)
_ = list
if resp, _ := r.js("DELETE", "/api/tokens/phone", nil); resp.StatusCode != 200 {
t.Errorf("revoke: %d", resp.StatusCode)
}
if resp, _ := api.do("GET", "/api/notes", "", "", "Authorization", "Bearer "+token); resp.StatusCode != 401 {
t.Errorf("revoked token: %d", resp.StatusCode)
// taking someone off the list ends their session at once
e2 := setup(t, func(c map[string]any) { c["allowed"] = []string{"someone-else"} })
old := seal(e2.cfg, "session", sessionData{Sub: "alice-sub", Email: "alice@example.com", Exp: time.Now().Add(time.Hour).Unix()})
if w := e2.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: old}}); w.Code != http.StatusUnauthorized {
t.Fatalf("a valid cookie for someone no longer allowed: %d", w.Code)
}
}
func TestChangePassword(t *testing.T) {
r := newRig(t)
r.signIn()
old := r.cookie
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": "wrong", "new": "a brand new password"}); resp.StatusCode != 403 {
t.Errorf("wrong current: %d", resp.StatusCode)
func TestSessionsAreSignedAndExpire(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatal("valid session refused")
}
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "short"}); resp.StatusCode != 400 {
t.Errorf("short: %d", resp.StatusCode)
}
resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "a brand new password"})
if resp.StatusCode != 200 {
t.Fatalf("change: %d", resp.StatusCode)
}
// the old cookie stops working, the new one (sent back in the response) works
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 401 {
t.Errorf("old cookie after a password change: %d", resp2.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == cookieName {
r.cookie = c
payload, sig, _ := strings.Cut(ses[0].Value, ".")
forged := base64.RawURLEncoding.EncodeToString([]byte(`{"s":"mallory","n":"Mallory","x":99999999999}`))
for name, v := range map[string]string{"forged payload": forged + "." + sig, "no signature": payload, "empty": ""} {
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
t.Errorf("%s accepted: %d", name, w.Code)
}
}
if r.cookie == old {
t.Fatal("no new cookie")
expired := seal(e.cfg, "session", sessionData{Sub: "alice-sub", Exp: time.Now().Add(-time.Minute).Unix()})
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: expired}}); w.Code != http.StatusUnauthorized {
t.Errorf("expired session accepted: %d", w.Code)
}
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 {
t.Errorf("new cookie: %d", resp2.StatusCode)
// a cookie for another purpose, or signed with another secret, is not a session
other := e.cfg
other.Secret = "different"
for name, v := range map[string]string{
"flow cookie as session": seal(e.cfg, "flow", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
"other secret": seal(other, "session", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
} {
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
t.Errorf("%s accepted: %d", name, w.Code)
}
}
w := e.do("POST", "/api/logout", nil, ses)
if c := cookieNamed(w, sessionCookie); w.Code != 200 || c == nil || c.MaxAge >= 0 {
t.Fatalf("logout: %d %+v", w.Code, c)
}
}
func TestExport(t *testing.T) {
r := newRig(t)
r.signIn()
r.js("POST", "/api/notes", map[string]string{"body": "one"})
r.js("POST", "/api/notes", map[string]string{"body": "two"})
resp, b := r.do("GET", "/api/export", "", "")
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Disposition"), "attachment") {
t.Fatalf("export: %d %v", resp.StatusCode, resp.Header)
func TestWritesNeedTheHeader(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
t.Fatalf("a write without the header: %d", w.Code)
}
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil || len(zr.File) != 2 {
t.Fatalf("zip: %v, %d files", err, len(zr.File))
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
t.Fatalf("a delete without the header: %d", w.Code)
}
}
func TestEachPersonHasTheirOwnNotebook(t *testing.T) {
e := setup(t, nil)
a, b := e.signIn(alice), e.signIn(bob)
var mine struct{ ID string }
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Alice's secret", "body": "dear diary"}, a), &mine)
if mine.ID == "" {
t.Fatal("no note made")
}
if w := e.do("GET", "/api/notes/"+mine.ID, nil, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob can see Alice's page: %d", w.Code)
}
var list struct{ Notes []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/notes", nil, b), &list)
if len(list.Notes) != 0 {
t.Fatalf("Bob's list: %+v", list)
}
var found struct{ Hits []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/find?q=diary", nil, b), &found)
if len(found.Hits) != 0 {
t.Fatalf("Bob can search Alice's notes: %+v", found)
}
if w := e.do("PUT", "/api/notes/"+mine.ID, map[string]string{"body": "hacked"}, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob can edit Alice's page: %d", w.Code)
}
// on disk each has a folder named from a hash, never from anything they chose
entries, _ := os.ReadDir(filepath.Join(e.dir, "data", "users"))
if len(entries) != 2 {
t.Fatalf("expected two notebooks, got %d", len(entries))
}
for _, d := range entries {
if strings.Contains(d.Name(), "alice") || strings.Contains(d.Name(), "bob") || len(d.Name()) != 32 {
t.Errorf("folder name %q", d.Name())
}
}
}
func TestPageLifecycle(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
type pg struct {
ID, Title, Name, Parent, Body, Updated string
Backlinks []struct{ ID, Name string }
Error, Code string
Note struct{ Body string }
}
call := func(method, path string, body any) (pg, int) {
w := e.do(method, path, body, ses)
var p pg
json.Unmarshal(w.Body.Bytes(), &p)
return p, w.Code
}
plans, code := call("POST", "/api/notes", map[string]string{"title": "Plans", "body": "first"})
if code != 201 || plans.Name != "Plans" {
t.Fatalf("create: %d %+v", code, plans)
}
if _, code := call("POST", "/api/notes", map[string]string{"title": "plans"}); code != 422 {
t.Fatalf("duplicate title on create: %d", code)
}
trip, _ := call("POST", "/api/notes", map[string]string{"title": "Trip", "body": "see [[Plans]]", "parent": plans.ID})
if trip.Parent != plans.ID {
t.Fatalf("subpage: %+v", trip)
}
if _, code := call("POST", "/api/notes", map[string]string{}); code != 400 {
t.Fatalf("empty create: %d", code)
}
got, _ := call("GET", "/api/notes/"+plans.ID, nil)
if len(got.Backlinks) != 1 || got.Backlinks[0].Name != "Trip" {
t.Fatalf("backlinks: %+v", got.Backlinks)
}
upd, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "second", "base": plans.Updated})
if code != 200 || upd.Body != "second" {
t.Fatalf("update: %d %+v", code, upd)
}
stale, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "third", "base": plans.Updated})
if code != 409 || stale.Note.Body != "second" {
t.Fatalf("stale update: %d %+v", code, stale)
}
bad, code := call("PUT", "/api/notes/"+trip.ID, map[string]any{"title": "PLANS"})
if code != 422 || bad.Code != "title_taken" || !strings.HasPrefix(bad.Error, "Another") {
t.Fatalf("taken title: %d %+v", code, bad)
}
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"title": "Plans 2027"}); code != 200 {
t.Fatalf("rename: %d", code)
}
if moved, _ := call("GET", "/api/notes/"+trip.ID, nil); moved.Body != "see [[Plans 2027]]" {
t.Fatalf("links follow a rename: %q", moved.Body)
}
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"parent": trip.ID}); code != 400 {
t.Fatalf("a page inside its own child: %d", code)
}
var found struct{ Hits []struct{ ID, Name string } }
jsonOf(t, e.do("GET", "/api/find?q=2027", nil, ses), &found)
if len(found.Hits) != 2 || found.Hits[0].ID != plans.ID { // Trip mentions it in a link, but the title match leads
t.Fatalf("find: %+v", found)
}
var tree struct {
Pages []struct{ ID, Parent string }
Counts struct{ All, Trash int }
}
jsonOf(t, e.do("GET", "/api/pages", nil, ses), &tree)
if len(tree.Pages) != 2 || tree.Counts.All != 2 {
t.Fatalf("pages: %+v", tree)
}
if _, code := call("DELETE", "/api/notes/"+plans.ID, nil); code != 200 {
t.Fatalf("trash: %d", code)
}
if kid, _ := call("GET", "/api/notes/"+trip.ID, nil); kid.Parent != "" {
t.Fatalf("subpages move up when their page is trashed: %+v", kid)
}
if _, code := call("DELETE", "/api/notes/"+trip.ID+"?purge=1", nil); code != 400 {
t.Fatalf("purging a live page: %d", code)
}
if _, code := call("POST", "/api/notes/"+plans.ID+"/restore", nil); code != 200 {
t.Fatalf("restore: %d", code)
}
if _, code := call("GET", "/api/notes/..%2f..%2fetc%2fpasswd", nil); code != 404 {
t.Fatalf("path tricks: %d", code)
}
d, code := call("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
if code != 200 || d.Title != "2026-10-31" {
t.Fatalf("daily: %d %+v", code, d)
}
if _, code := call("POST", "/api/daily", map[string]string{"date": "nope"}); code != 400 {
t.Fatalf("bad date: %d", code)
}
w := e.do("GET", "/api/export", nil, ses)
zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil || w.Header().Get("Content-Type") != "application/zip" {
t.Fatalf("export: %v %s", err, w.Header().Get("Content-Type"))
}
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
if fmt.Sprint(names) != "[basking/Journal/2026-10-31.md basking/Plans 2027.md basking/Trip.md]" && len(names) != 3 {
t.Fatalf("export has %v", names)
}
}
func TestSecurityHeaders(t *testing.T) {
e := setup(t, nil)
w := e.do("GET", "/", nil, nil)
csp := w.Header().Get("Content-Security-Policy")
for _, want := range []string{"default-src 'none'", "script-src 'self'", "style-src 'self'", "frame-ancestors 'none'"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q: %s", want, csp)
}
}
if w.Header().Get("X-Frame-Options") != "DENY" || w.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Error("framing and sniffing headers missing")
}
if w.Header().Get("Strict-Transport-Security") != "" {
t.Error("no HSTS over plain http")
}
if got := e.do("GET", "/api/me", nil, nil).Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("API responses must not be cached: %q", got)
}
s := setup(t, func(c map[string]any) { c["public_url"] = "https://notes.example.com/" })
if s.do("GET", "/", nil, nil).Header().Get("Strict-Transport-Security") == "" {
t.Error("HSTS expected behind https")
}
if !s.cfg.Secure() || s.cfg.PublicURL != "https://notes.example.com" {
t.Errorf("public url: %+v", s.cfg)
}
}
func TestServerWillNotStartHalfConfigured(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "basking.json")
os.WriteFile(path, []byte(`{"public_url":"https://notes.example.com"}`), 0o600)
h, _ := config.Load(path)
_, err := New(h, "t")
if err == nil {
t.Fatal("started without Authentik settings")
}
for _, want := range []string{"oidc.issuer", "BASKING_OIDC_CLIENT_ID", "client_secret", "/auth/callback"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the message should mention %q:\n%v", want, err)
}
}
os.WriteFile(path, []byte(`{"public_url":"http://notes.example.com","oidc":{"issuer":"https://a.example.com/","client_id":"x","client_secret":"y"}}`), 0o600)
h, _ = config.Load(path)
if _, err := New(h, "t"); err == nil || !strings.Contains(err.Error(), "https") {
t.Errorf("plain http on a real host should be refused: %v", err)
}
t.Setenv("BASKING_PUBLIC_URL", "https://notes.example.com")
h, _ = config.Load(path)
if _, err := New(h, "t"); err != nil {
t.Errorf("environment settings should override the file: %v", err)
}
if b, _ := os.ReadFile(path); strings.Contains(string(b), "notes.example.com\"") && strings.Contains(string(b), "https://notes.example.com") {
t.Error("environment values must never be written to the file")
}
}
func TestFailCapitalizes(t *testing.T) {
w := httptest.NewRecorder()
fail(w, 400, "écrit mal")
if !strings.Contains(w.Body.String(), "Écrit mal") {
t.Errorf("%s", w.Body.String())
fail(w, 400, "éclair is ready")
var out map[string]string
json.Unmarshal(w.Body.Bytes(), &out)
if out["error"] != "Éclair is ready" {
t.Fatalf("%q", out["error"])
}
}