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})
}
}