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

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

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

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
// Package config holds Basking's settings file: where to listen, where notes live, the password
// hash and the API tokens. The notes themselves are not in here.
package config
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
type Token struct {
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
}
type Config struct {
Listen string `json:"listen"`
DataDir string `json:"data_dir"`
// PasswordHash is the one password that opens the dashboard.
PasswordHash string `json:"password_hash,omitempty"`
// Secret signs session cookies, so a restart does not sign you out. It is made on first run.
Secret string `json:"secret,omitempty"`
Tokens []Token `json:"tokens,omitempty"`
// CookieSecure and TrustProxy are for running behind an HTTPS reverse proxy.
CookieSecure bool `json:"cookie_secure,omitempty"`
TrustProxy bool `json:"trust_proxy,omitempty"`
}
var tokenName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$`)
// Defaults fills in what is missing.
func (c *Config) Defaults() {
if c.Listen == "" {
c.Listen = "127.0.0.1:8490"
}
if c.DataDir == "" {
c.DataDir = "data"
}
if c.Secret == "" {
b := make([]byte, 32)
rand.Read(b)
c.Secret = base64.RawURLEncoding.EncodeToString(b)
}
}
func (c *Config) Validate() error {
seen := map[string]bool{}
for _, t := range c.Tokens {
if !tokenName.MatchString(t.Name) {
return fmt.Errorf("token name %q must be 1 to 40 letters, numbers, spaces, dots, dashes or underscores", t.Name)
}
if seen[strings.ToLower(t.Name)] {
return fmt.Errorf("there is already a token called %q", t.Name)
}
seen[strings.ToLower(t.Name)] = true
}
return nil
}
// Holder keeps the live configuration and writes every change to disk.
type Holder struct {
path string
mu sync.RWMutex
cfg Config
}
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
func Load(path string) (*Holder, error) {
h := &Holder{path: path}
b, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
case err != nil:
return nil, err
default:
if err := json.Unmarshal(b, &h.cfg); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
h.cfg.Defaults()
if err := h.cfg.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return h, nil
}
func (h *Holder) Path() string { return h.path }
// Exists says whether the file is on disk yet.
func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil }
// Get returns a private copy; change it freely.
func (h *Holder) Get() Config {
h.mu.RLock()
defer h.mu.RUnlock()
return clone(h.cfg)
}
func clone(c Config) Config {
b, _ := json.Marshal(c)
var out Config
json.Unmarshal(b, &out)
return out
}
// Update applies fn to a copy, validates it, saves it and only then makes it current.
func (h *Holder) Update(fn func(*Config) error) error {
h.mu.Lock()
defer h.mu.Unlock()
c := clone(h.cfg)
if err := fn(&c); err != nil {
return err
}
c.Defaults()
if err := c.Validate(); err != nil {
return err
}
if err := save(h.path, c); err != nil {
return err
}
h.cfg = c
return nil
}
func save(path string, c Config) error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
os.MkdirAll(dir, 0o755)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ---- passwords and tokens ----
const pbkdf2Rounds = 600_000
// HashPassword returns "pbkdf2$rounds$salt$key".
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// CheckPassword compares in constant time. An unparseable hash never matches.
func CheckPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
rounds, err := strconv.Atoi(parts[1])
if err != nil || rounds < 1 || rounds > 10_000_000 {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
want, err2 := base64.RawStdEncoding.DecodeString(parts[3])
if err1 != nil || err2 != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// NewToken makes an API token and the hash to store for it. The token itself is shown once.
func NewToken() (token, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
token = "bsk_" + base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RandomPassword makes a password for a first run.
func RandomPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+543
View File
@@ -0,0 +1,543 @@
// Package store keeps notes as plain markdown files, one per note, so they stay readable and
// portable with or without Basking. An in-memory index makes listing and searching quick.
package store
import (
"archive/zip"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// MaxBody is the longest note, in bytes.
const MaxBody = 256 << 10
var (
ErrNotFound = errors.New("note not found")
ErrBadID = errors.New("bad note id")
ErrEmpty = errors.New("a note needs some text")
ErrTooLong = errors.New("that note is too long")
ErrConflict = errors.New("this note changed somewhere else")
ErrBadDate = errors.New("the date must look like 2026-10-31")
ErrNotTrash = errors.New("only a note in the trash can be deleted for good")
)
type Note struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Pinned bool `json:"pinned"`
Daily string `json:"daily,omitempty"` // the date, for a daily note
Trashed bool `json:"trashed,omitempty"`
Body string `json:"body"`
Tags []string `json:"tags"`
lower string // lower-cased body, for searching
}
type Store struct {
dir string
mu sync.RWMutex
by map[string]*Note
}
var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// ValidID says whether s can be used as a note id (and so as a file name).
func ValidID(s string) bool { return idRe.MatchString(s) }
// Open reads every note under dir, creating the folders if needed.
func Open(dir string) (*Store, error) {
s := &Store{dir: dir, by: map[string]*Note{}}
for _, sub := range []string{"notes", "trash"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
return nil, err
}
entries, err := os.ReadDir(filepath.Join(dir, sub))
if err != nil {
return nil, err
}
for _, e := range entries {
id := strings.TrimSuffix(e.Name(), ".md")
if e.IsDir() || id == e.Name() || !ValidID(id) {
continue
}
path := filepath.Join(dir, sub, e.Name())
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
info, err := e.Info()
if err != nil {
return nil, err
}
n := parse(id, string(b), info.ModTime())
n.Trashed = sub == "trash"
s.by[id] = n
}
}
return s, nil
}
func (s *Store) path(n *Note) string {
sub := "notes"
if n.Trashed {
sub = "trash"
}
return filepath.Join(s.dir, sub, n.ID+".md")
}
// ---- file format ----
//
// ---
// created: 2026-10-31T14:32:05.123Z
// updated: 2026-10-31T14:35:00.456Z
// pinned: true
// daily: 2026-10-31
// ---
//
// the note, in markdown
func format(n *Note) []byte {
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339Nano))
fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano))
if n.Pinned {
b.WriteString("pinned: true\n")
}
if n.Daily != "" {
fmt.Fprintf(&b, "daily: %s\n", n.Daily)
}
b.WriteString("---\n\n")
b.WriteString(n.Body)
b.WriteString("\n")
return []byte(b.String())
}
// parse reads a note file. A file with no header is still a note: it takes its time from the file.
func parse(id, text string, mod time.Time) *Note {
text = strings.ReplaceAll(text, "\r\n", "\n")
n := &Note{ID: id, Created: mod.UTC(), Updated: mod.UTC()}
if rest, ok := strings.CutPrefix(text, "---\n"); ok {
if head, body, ok := strings.Cut(rest, "\n---\n"); ok || strings.HasSuffix(rest, "\n---") {
if !ok {
head, body = strings.TrimSuffix(rest, "\n---"), ""
}
for _, line := range strings.Split(head, "\n") {
k, v, _ := strings.Cut(line, ":")
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "created":
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
n.Created = t.UTC()
}
case "updated":
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
n.Updated = t.UTC()
}
case "pinned":
n.Pinned = v == "true"
case "daily":
if _, err := time.Parse("2006-01-02", v); err == nil {
n.Daily = v
}
}
}
text = strings.TrimPrefix(body, "\n")
}
}
n.set(text)
return n
}
// set stores the body (without trailing blank space) and works out the tags.
func (n *Note) set(body string) {
n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n")
n.lower = strings.ToLower(n.Body)
n.Tags = Tags(n.Body)
}
var (
tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`)
inlineCodeRe = regexp.MustCompile("`[^`\n]*`")
)
// Tags returns the #tags in a note, lower-cased and without repeats. Code is skipped, and so are
// numbers such as #12.
func Tags(body string) []string {
var lines []string
fence := false
for _, l := range strings.Split(body, "\n") {
if strings.HasPrefix(strings.TrimSpace(l), "```") {
fence = !fence
continue
}
if !fence {
lines = append(lines, inlineCodeRe.ReplaceAllString(l, ""))
}
}
seen := map[string]bool{}
out := []string{}
for _, m := range tagRe.FindAllStringSubmatch(strings.Join(lines, "\n"), -1) {
t := strings.ToLower(strings.TrimRight(m[1], "/-"))
if t == "" || len(t) > 40 || isNumber(t) || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
func isNumber(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func (s *Store) write(n *Note) error {
path := s.path(n)
tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp")
if err := os.WriteFile(tmp, format(n), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func newID(t time.Time) string {
b := make([]byte, 3)
rand.Read(b)
return t.UTC().Format("20060102-150405") + "-" + hex.EncodeToString(b)
}
func (n *Note) copy() Note {
c := *n
c.Tags = append([]string{}, n.Tags...)
return c
}
func checkBody(body string) error {
if len(body) > MaxBody {
return ErrTooLong
}
return nil
}
// ---- changes ----
// Create saves a new note.
func (s *Store) Create(body string) (Note, error) {
if err := checkBody(body); err != nil {
return Note{}, err
}
if strings.TrimSpace(body) == "" {
return Note{}, ErrEmpty
}
now := time.Now().UTC()
n := &Note{ID: newID(now), Created: now, Updated: now}
n.set(body)
s.mu.Lock()
defer s.mu.Unlock()
if err := s.write(n); err != nil {
return Note{}, err
}
s.by[n.ID] = n
return n.copy(), nil
}
// Daily returns the note for a date (like 2026-10-31), making it if it is not there yet.
func (s *Store) Daily(date string) (Note, error) {
if _, err := time.Parse("2006-01-02", date); err != nil {
return Note{}, ErrBadDate
}
id := "daily-" + date
s.mu.Lock()
defer s.mu.Unlock()
if n, ok := s.by[id]; ok {
if n.Trashed { // asking for the day brings it back
old := s.path(n)
n.Trashed = false
if err := os.Rename(old, s.path(n)); err != nil {
n.Trashed = true
return Note{}, err
}
}
return n.copy(), nil
}
now := time.Now().UTC()
n := &Note{ID: id, Created: now, Updated: now, Daily: date}
if err := s.write(n); err != nil {
return Note{}, err
}
s.by[id] = n
return n.copy(), nil
}
func (s *Store) Get(id string) (Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
n, ok := s.by[id]
if !ok {
return Note{}, ErrNotFound
}
return n.copy(), nil
}
// Update changes the text and/or the pin. When base is given, the text is only replaced if the
// note has not been changed since then; otherwise the current note comes back with ErrConflict.
func (s *Store) Update(id string, body *string, pinned *bool, base time.Time) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok {
return Note{}, ErrNotFound
}
if body != nil {
if err := checkBody(*body); err != nil {
return Note{}, err
}
if !base.IsZero() && !n.Updated.Equal(base) {
return n.copy(), ErrConflict
}
}
next := n.copy()
if body != nil {
next.set(*body)
next.Updated = time.Now().UTC()
if !next.Updated.After(n.Updated) { // keep updated moving forward, so a stale tab is always noticed
next.Updated = n.Updated.Add(time.Microsecond)
}
}
if pinned != nil {
next.Pinned = *pinned
}
if err := s.write(&next); err != nil {
return Note{}, err
}
*n = next
return n.copy(), nil
}
func (s *Store) move(id string, toTrash bool) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok {
return Note{}, ErrNotFound
}
if n.Trashed == toTrash {
return n.copy(), nil
}
old := s.path(n)
n.Trashed = toTrash
if err := os.Rename(old, s.path(n)); err != nil {
n.Trashed = !toTrash
return Note{}, err
}
return n.copy(), nil
}
// Trash moves a note to the trash. Restore brings it back.
func (s *Store) Trash(id string) (Note, error) { return s.move(id, true) }
func (s *Store) Restore(id string) (Note, error) { return s.move(id, false) }
// Purge deletes a note that is already in the trash, for good.
func (s *Store) Purge(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok {
return ErrNotFound
}
if !n.Trashed {
return ErrNotTrash
}
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
delete(s.by, id)
return nil
}
// EmptyTrash deletes everything in the trash for good and says how many notes that was.
func (s *Store) EmptyTrash() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
count := 0
for id, n := range s.by {
if !n.Trashed {
continue
}
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
return count, err
}
delete(s.by, id)
count++
}
return count, nil
}
// ---- reading ----
type Query struct {
Text string // words that must all appear; #tag words must be tags
Tag string
View string // "" or "all", "pinned", "daily", "trash"
Offset int
Limit int
}
// List returns one page of matching notes, newest first, and how many matched in all.
func (s *Store) List(q Query) ([]Note, int) {
var words, tags []string
for _, w := range strings.Fields(strings.ToLower(q.Text)) {
if t := strings.TrimPrefix(w, "#"); t != w && t != "" {
tags = append(tags, t)
} else {
words = append(words, w)
}
}
if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" {
tags = append(tags, t)
}
s.mu.RLock()
var hits []*Note
for _, n := range s.by {
if n.Trashed != (q.View == "trash") || (q.View == "pinned" && !n.Pinned) || (q.View == "daily" && n.Daily == "") {
continue
}
if matches(n, words, tags) {
hits = append(hits, n)
}
}
sort.Slice(hits, func(i, j int) bool {
a, b := hits[i], hits[j]
if q.View == "daily" && a.Daily != b.Daily {
return a.Daily > b.Daily
}
if !a.Created.Equal(b.Created) {
return a.Created.After(b.Created)
}
return a.ID > b.ID
})
total := len(hits)
if q.Offset > 0 {
hits = hits[min(q.Offset, len(hits)):]
}
if q.Limit > 0 && len(hits) > q.Limit {
hits = hits[:q.Limit]
}
out := make([]Note, len(hits))
for i, n := range hits {
out[i] = n.copy()
}
s.mu.RUnlock()
return out, total
}
func matches(n *Note, words, tags []string) bool {
for _, w := range words {
if !strings.Contains(n.lower, w) {
return false
}
}
for _, want := range tags {
found := false
for _, have := range n.Tags {
if have == want || strings.HasPrefix(have, want+"/") {
found = true
break
}
}
if !found {
return false
}
}
return true
}
type TagCount struct {
Tag string `json:"tag"`
Count int `json:"count"`
}
type Counts struct {
All int `json:"all"`
Pinned int `json:"pinned"`
Daily int `json:"daily"`
Trash int `json:"trash"`
}
// Summary counts the notes and the tags in use (trashed notes do not count towards tags).
func (s *Store) Summary() (Counts, []TagCount) {
s.mu.RLock()
defer s.mu.RUnlock()
var c Counts
per := map[string]int{}
for _, n := range s.by {
if n.Trashed {
c.Trash++
continue
}
c.All++
if n.Pinned {
c.Pinned++
}
if n.Daily != "" {
c.Daily++
}
for _, t := range n.Tags {
per[t]++
}
}
tags := make([]TagCount, 0, len(per))
for t, k := range per {
tags = append(tags, TagCount{t, k})
}
sort.Slice(tags, func(i, j int) bool {
if tags[i].Count != tags[j].Count {
return tags[i].Count > tags[j].Count
}
return tags[i].Tag < tags[j].Tag
})
return c, tags
}
// WriteZip writes every note that is not in the trash as a .md file in a zip archive.
func (s *Store) WriteZip(w io.Writer) error {
s.mu.RLock()
defer s.mu.RUnlock()
ids := make([]string, 0, len(s.by))
for id, n := range s.by {
if !n.Trashed {
ids = append(ids, id)
}
}
sort.Strings(ids)
zw := zip.NewWriter(w)
for _, id := range ids {
n := s.by[id]
f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + id + ".md", Method: zip.Deflate, Modified: n.Updated})
if err != nil {
return err
}
if _, err := f.Write(format(n)); err != nil {
return err
}
}
return zw.Close()
}
+273
View File
@@ -0,0 +1,273 @@
package store
import (
"archive/zip"
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func open(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
return s, dir
}
func TestTags(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"buy milk #errands #Errands and #home/garden", []string{"errands", "home/garden"}},
{"issue #12 is not a tag, nor is a#b", []string{}},
{"# Heading is not a tag\n#idea at the start", []string{"idea"}},
{"`#code` and\n```\n#fenced\n```\n#real", []string{"real"}},
{"(#paren) and #trailing-", []string{"paren", "trailing"}},
{"#ünï and #日本語", []string{"ünï", "日本語"}},
}
for _, c := range cases {
if got := Tags(c.in); !reflect.DeepEqual(got, c.want) {
t.Errorf("Tags(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestCreateRoundTripsThroughDisk(t *testing.T) {
s, dir := open(t)
n, err := s.Create("first thought #idea\n\nwith a second paragraph \n\n")
if err != nil {
t.Fatal(err)
}
if n.Body != "first thought #idea\n\nwith a second paragraph" {
t.Fatalf("body %q", n.Body)
}
if !ValidID(n.ID) {
t.Fatalf("id %q", n.ID)
}
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
t.Fatal(err)
}
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
got, err := s2.Get(n.ID)
if err != nil {
t.Fatal(err)
}
if got.Body != n.Body || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) {
t.Fatalf("reloaded %+v, want %+v", got, n)
}
}
func TestCreateRejectsEmptyAndHuge(t *testing.T) {
s, _ := open(t)
if _, err := s.Create(" \n\t"); !errors.Is(err, ErrEmpty) {
t.Fatalf("empty: %v", err)
}
if _, err := s.Create(strings.Repeat("x", MaxBody+1)); !errors.Is(err, ErrTooLong) {
t.Fatalf("huge: %v", err)
}
}
func TestPlainMarkdownFileIsANote(t *testing.T) {
s, dir := open(t)
path := filepath.Join(dir, "notes", "from-elsewhere.md")
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
os.Chtimes(path, mod, mod)
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
n, err := s2.Get("from-elsewhere")
if err != nil {
t.Fatal(err)
}
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) {
t.Fatalf("%+v", n)
}
_ = s
}
func TestUpdateConflictAndPin(t *testing.T) {
s, _ := open(t)
n, _ := s.Create("one")
body := "two"
n2, err := s.Update(n.ID, &body, nil, n.Updated)
if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) {
t.Fatalf("update: %v %+v", err, n2)
}
stale := "three"
cur, err := s.Update(n.ID, &stale, nil, n.Updated)
if !errors.Is(err, ErrConflict) || cur.Body != "two" {
t.Fatalf("stale write: %v %+v", err, cur)
}
yes := true
n3, err := s.Update(n.ID, nil, &yes, time.Time{})
if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) {
t.Fatalf("pin should not move updated: %v %+v", err, n3)
}
// a write without a base always goes through (scripts appending)
if _, err := s.Update(n.ID, &stale, nil, time.Time{}); err != nil {
t.Fatal(err)
}
if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing: %v", err)
}
}
func TestTrashRestorePurge(t *testing.T) {
s, dir := open(t)
n, _ := s.Create("to be thrown away")
if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) {
t.Fatalf("purge of a live note: %v", err)
}
if _, err := s.Trash(n.ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil {
t.Fatal("not moved to trash:", err)
}
if list, _ := s.List(Query{}); len(list) != 0 {
t.Fatal("trashed note still listed")
}
if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 {
t.Fatal("trash view empty")
}
// survives a restart in the trash
s2, _ := Open(dir)
if list, _ := s2.List(Query{View: "trash"}); len(list) != 1 {
t.Fatal("trash lost on reload")
}
if _, err := s2.Restore(n.ID); err != nil {
t.Fatal(err)
}
if list, _ := s2.List(Query{}); len(list) != 1 {
t.Fatal("restore failed")
}
s2.Trash(n.ID)
if err := s2.Purge(n.ID); err != nil {
t.Fatal(err)
}
if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) {
t.Fatal("purged note is still there")
}
a, _ := s2.Create("a")
b, _ := s2.Create("b")
s2.Trash(a.ID)
s2.Trash(b.ID)
if k, err := s2.EmptyTrash(); err != nil || k != 2 {
t.Fatalf("empty trash: %d %v", k, err)
}
}
func TestDaily(t *testing.T) {
s, _ := open(t)
d1, err := s.Daily("2026-10-31")
if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" {
t.Fatalf("%v %+v", err, d1)
}
d2, _ := s.Daily("2026-10-31")
if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) {
t.Fatal("asking twice should give the same note")
}
for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} {
if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) {
t.Errorf("%q: %v", bad, err)
}
}
s.Trash(d1.ID)
d3, _ := s.Daily("2026-10-31")
if d3.Trashed {
t.Fatal("asking for a trashed day should bring it back")
}
s.Daily("2026-10-30")
list, _ := s.List(Query{View: "daily"})
if len(list) != 2 || list[0].Daily != "2026-10-31" {
t.Fatalf("daily view order: %+v", list)
}
}
func TestSearchAndTags(t *testing.T) {
s, _ := open(t)
s.Create("Buy oat MILK #errands")
time.Sleep(2 * time.Millisecond)
s.Create("Call the dentist #errands #health")
time.Sleep(2 * time.Millisecond)
s.Create("Idea: a #projects/basking dashboard")
if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
t.Fatalf("case-insensitive search: %d", len(list))
}
if list, _ := s.List(Query{Text: "the dentist"}); len(list) != 1 {
t.Fatalf("all words must match: %d", len(list))
}
if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 {
t.Fatalf("words in different notes: %d", len(list))
}
if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 {
t.Fatalf("tag filter: %d", len(list))
}
if list, _ := s.List(Query{Text: "#errands #health"}); len(list) != 1 {
t.Fatalf("two tags in the query: %d", len(list))
}
if list, _ := s.List(Query{Tag: "projects"}); len(list) != 1 {
t.Fatalf("a parent tag finds its children: %d", len(list))
}
all, total := s.List(Query{Limit: 2})
if len(all) != 2 || total != 3 || !strings.HasPrefix(all[0].Body, "Idea") {
t.Fatalf("newest first with a page size: %d of %d, first %q", len(all), total, all[0].Body)
}
if page2, _ := s.List(Query{Limit: 2, Offset: 2}); len(page2) != 1 {
t.Fatalf("second page: %d", len(page2))
}
counts, tags := s.Summary()
if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 {
t.Fatalf("summary %+v %+v", counts, tags)
}
}
func TestIDsCannotEscape(t *testing.T) {
for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} {
if ValidID(id) {
t.Errorf("%q should not be a valid id", id)
}
}
s, dir := open(t)
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
s2, _ := Open(dir)
if list, _ := s2.List(Query{}); len(list) != 0 {
t.Fatal("a file whose name is not a valid id should be ignored")
}
_ = s
}
func TestZip(t *testing.T) {
s, _ := open(t)
a, _ := s.Create("keep me")
b, _ := s.Create("bin me")
s.Trash(b.ID)
var buf bytes.Buffer
if err := s.WriteZip(&buf); err != nil {
t.Fatal(err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatal(err)
}
if len(zr.File) != 1 || zr.File[0].Name != "basking/"+a.ID+".md" {
t.Fatalf("zip has %d files", len(zr.File))
}
}
+254
View File
@@ -0,0 +1,254 @@
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) listNotes(w http.ResponseWriter, r *http.Request, p principal) {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(q.Get("offset"))
text := q.Get("q")
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})
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)
}
n, err := s.st.Create(body)
if err != nil {
storeFail(w, err)
return
}
writeJSONStatus(w, http.StatusCreated, n)
}
func (s *Server) getNote(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.Get(r.PathValue("id"))
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
}
func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
Base string `json:"base"`
}
if !decode(w, r, &in) {
return
}
var base time.Time
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")
return
}
base = t
}
n, err := s.st.Update(r.PathValue("id"), in.Body, in.Pinned, base)
if errors.Is(err, store.ErrConflict) {
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This note changed somewhere else", "note": n})
return
}
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
}
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)
} else {
_, err = s.st.Trash(id)
}
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.Restore(r.PathValue("id"))
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
}
func (s *Server) emptyTrash(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.EmptyTrash()
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, map[string]int{"deleted": n})
}
func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Date string }
if !decode(w, r, &in) {
return
}
n, err := s.st.Daily(in.Date)
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
}
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 {
// 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})
}
}
+290
View File
@@ -0,0 +1,290 @@
// Package web serves Basking's dashboard and its JSON API.
package web
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
)
//go:embed static
var staticFiles embed.FS
const (
cookieName = "basking_session"
sessionTTL = 30 * 24 * time.Hour
maxLoginFails = 6
loginFailSpan = 5 * time.Minute
maxRequestBody = store.MaxBody + 4096
)
type Server struct {
cfg *config.Holder
st *store.Store
version string
mu sync.Mutex
fails map[string]failure
dummyOnce sync.Once
dummyHash string
}
type failure struct {
n int
until time.Time
}
// principal is who is making a request.
type principal struct {
Name string
API bool // came with an API token rather than a browser session
}
func New(h *config.Holder, st *store.Store, version string) *Server {
return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}}
}
// Handler returns the whole site.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
mux.HandleFunc("POST /api/login", s.login)
mux.HandleFunc("POST /api/logout", s.api(s.logout))
mux.HandleFunc("GET /api/me", s.api(s.me))
mux.HandleFunc("GET /api/summary", s.api(s.summary))
mux.HandleFunc("GET /api/notes", s.api(s.listNotes))
mux.HandleFunc("POST /api/notes", s.api(s.createNote))
mux.HandleFunc("GET /api/notes/{id}", s.api(s.getNote))
mux.HandleFunc("PUT /api/notes/{id}", s.api(s.updateNote))
mux.HandleFunc("DELETE /api/notes/{id}", s.api(s.deleteNote))
mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote))
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
mux.HandleFunc("POST /api/daily", s.api(s.daily))
mux.HandleFunc("GET /api/export", s.api(s.export))
mux.HandleFunc("POST /api/password", s.api(s.changePassword))
mux.HandleFunc("GET /api/tokens", s.api(s.listTokens))
mux.HandleFunc("POST /api/tokens", s.api(s.newToken))
mux.HandleFunc("DELETE /api/tokens/{name}", s.api(s.deleteToken))
static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /", http.FileServerFS(static))
return secure(mux)
}
// secure adds headers that keep the page from being framed, sniffed or fed outside scripts.
func secure(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
// ---- authentication ----
type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal)
// api wraps a handler with sign-in and CSRF protection.
func (s *Server) api(fn handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, ok := s.authenticate(r)
if !ok {
fail(w, http.StatusUnauthorized, "sign in required")
return
}
// Browser sessions must send a header a web page on another site cannot add.
if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
fail(w, http.StatusForbidden, "missing X-Basking header")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
fn(w, r, p)
}
}
// A session cookie is "expiry.signature". The signature covers the password hash, so changing
// the password signs every browser out, and nothing has to be remembered on the server.
func sign(c config.Config, exp int64) string {
m := hmac.New(sha256.New, []byte(c.Secret))
fmt.Fprintf(m, "session|%d|%s", exp, c.PasswordHash)
return hex.EncodeToString(m.Sum(nil))
}
func (s *Server) authenticate(r *http.Request) (principal, bool) {
c := s.cfg.Get()
if c.PasswordHash == "" {
return principal{}, false
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
sum := config.HashToken(strings.TrimPrefix(h, "Bearer "))
for _, t := range c.Tokens {
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 {
return principal{Name: "token:" + t.Name, API: true}, true
}
}
return principal{}, false
}
ck, err := r.Cookie(cookieName)
if err != nil {
return principal{}, false
}
expStr, sig, ok := strings.Cut(ck.Value, ".")
exp, err := strconv.ParseInt(expStr, 10, 64)
if !ok || err != nil || time.Now().Unix() > exp {
return principal{}, false
}
if !hmac.Equal([]byte(sig), []byte(sign(c, exp))) {
return principal{}, false
}
return principal{Name: "you"}, true
}
func (s *Server) clientIP(r *http.Request) string {
if s.cfg.Get().TrustProxy {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
return strings.TrimSpace(strings.Split(f, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) secureCookie(r *http.Request) bool {
c := s.cfg.Get()
return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https")
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
exp := time.Now().Add(sessionTTL).Unix()
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: fmt.Sprintf("%d.%s", exp, sign(c, exp)), Path: "/", HttpOnly: true,
SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if r.Header.Get("X-Basking") != "1" {
fail(w, http.StatusForbidden, "missing X-Basking header")
return
}
var in struct{ Password string }
if json.NewDecoder(r.Body).Decode(&in) != nil {
fail(w, http.StatusBadRequest, "bad request")
return
}
ip := s.clientIP(r)
s.mu.Lock()
f := s.fails[ip]
if f.n >= maxLoginFails && time.Now().Before(f.until) {
s.mu.Unlock()
fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes")
return
}
s.mu.Unlock()
hash := s.cfg.Get().PasswordHash
if hash == "" {
// Spend the same time as for a real check.
s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") })
hash = s.dummyHash
}
ok := config.CheckPassword(hash, in.Password) && s.cfg.Get().PasswordHash != ""
s.mu.Lock()
if !ok {
if time.Now().After(f.until) {
f = failure{}
}
f.n++
f.until = time.Now().Add(loginFailSpan)
s.fails[ip] = f
s.mu.Unlock()
fail(w, http.StatusUnauthorized, "wrong password")
return
}
delete(s.fails, ip)
s.mu.Unlock()
s.startSession(w, r)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
writeJSON(w, map[string]bool{"ok": true})
}
// ---- helpers ----
func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) }
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// fail answers with an error. Messages are capitalized here, once, so they read as sentences.
func fail(w http.ResponseWriter, code int, msg string) {
if r := []rune(msg); len(r) > 0 {
msg = strings.ToUpper(string(r[:1])) + string(r[1:])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
fail(w, http.StatusRequestEntityTooLarge, "request too large")
} else {
fail(w, http.StatusBadRequest, "bad request: "+err.Error())
}
return false
}
return true
}
// storeFail turns a store error into the right status.
func storeFail(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
fail(w, http.StatusNotFound, err.Error())
case errors.Is(err, store.ErrConflict):
fail(w, http.StatusConflict, err.Error())
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID):
fail(w, http.StatusBadRequest, err.Error())
case errors.Is(err, store.ErrTooLong):
fail(w, http.StatusRequestEntityTooLarge, err.Error())
default:
fail(w, http.StatusInternalServerError, "could not save: "+err.Error())
}
}
+710
View File
@@ -0,0 +1,710 @@
'use strict';
// ---------- tiny toolkit ----------
// h builds an element. Strings become text nodes, never HTML, so note 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.';
async function api(method, url, body) {
const opt = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin' };
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
const j = await r.json().catch(() => ({}));
if (r.status === 401 && url !== '/api/login' && url !== '/api/me') {
if (S.ready) { S.ready = false; boot(); }
throw new Error(SIGNED_OUT);
}
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.message !== SIGNED_OUT) 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 field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', { class: 'sub' }, hint) : null);
const chk = (label, checked) => { const i = h('input', { type: 'checkbox', checked: !!checked }); return Object.assign(h('label', { class: 'chk' }, i, label), { box: i }); };
// 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);
// ---------- 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"/>',
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"/>',
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"/>',
};
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' });
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, h('h2', { id }, 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) });
});
}
// formDialog shows a form and resolves with its values, or null if it was cancelled.
// Field types: text, password, textarea, note.
function formDialog({ title, intro, fields, submit = 'Save' }) {
return new Promise(resolve => {
const parts = {};
const err = h('p', { class: 'err', role: 'alert' });
const rows = fields.map(f => {
if (f.type === 'note') return h('p', { class: 'dim' }, f.text);
let el, get;
if (f.type === 'textarea') {
el = h('textarea', { placeholder: f.placeholder, rows: 5 }); el.value = f.value || ''; get = () => el.value.trim();
} else {
el = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off' });
el.value = f.value ?? ''; get = () => f.type === 'password' ? el.value : el.value.trim();
}
parts[f.name] = { f, get, el };
return field(f.label, el, f.hint);
});
let close;
const send = () => {
const out = {};
for (const [name, p] of Object.entries(parts)) {
const v = p.get();
if (p.f.required && !v) { err.textContent = `${p.f.label} is required.`; p.el.setAttribute('aria-invalid', 'true'); p.el.focus(); return; }
out[name] = v;
}
done(out);
};
const done = v => { resolve(v); close(); };
const form = h('form', { class: 'stack', onsubmit: e => { e.preventDefault(); send(); } }, intro ? h('p', { class: 'dim' }, intro) : null, rows, err,
h('div', { class: 'actions form-actions' }, h('button', { type: 'button', class: 'ghost', onclick: () => done(null) }, 'Cancel'), h('button', { type: 'submit', class: 'btn' }, submit)));
close = modal(title, form, { sticky: true, onClose: () => resolve(null) });
});
}
// ---------- markdown ----------
// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links.
const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^*\\n]*?)\\*(?![\\w*])|\\[([^\\]\\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 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(extLink(m[5], m[4]));
else if (m[6] != null) out.push(extLink(m[6], m[6]));
else {
const t = m[7].replace(/[/-]+$/, '');
if (!t || /^\d+$/.test(t)) out.push('#' + m[7]);
else out.push(tagChip(t.toLowerCase()), m[7].slice(t.length));
}
}
if (last < text.length) out.push(text.slice(last));
return out;
}
// md renders a note. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
function md(src, onTask) {
const root = h('div', { class: 'md' }), 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;
}
// ---------- state ----------
const S = { ready: false, counts: { all: 0, pinned: 0, daily: 0, trash: 0 }, tags: [], view: null, q: '', pending: new Set(), nav: {} };
async function refreshSummary() {
const r = await api('GET', '/api/summary');
S.counts = r.counts; S.tags = r.tags; updateNav();
}
// Anything still waiting to autosave is saved before the page changes or the tab is hidden.
const flushAll = () => Promise.all([...S.pending].map(f => f()));
document.addEventListener('visibilitychange', () => { if (document.hidden) flushAll(); });
window.addEventListener('pagehide', () => { flushAll(); });
async function boot() {
S.view = null; S.pending.clear();
try { await api('GET', '/api/me'); } catch { return $app.replaceChildren(loginView()); }
await refreshSummary().catch(() => { });
S.ready = true;
render();
}
function loginView() {
const pw = h('input', { type: 'password', autocomplete: 'current-password' });
const go = act(async () => {
await api('POST', '/api/login', { password: pw.value });
if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies
boot();
});
pw.addEventListener('keydown', e => e.key === 'Enter' && go());
setTimeout(() => pw.focus());
return h('div', { class: 'center' }, card('', brand(), h('h2', {}, 'Sign in'), h('div', { class: 'stack' },
field('Password', pw, 'Basking printed it in its log the first time it started.'), h('button', { class: 'btn', onclick: go }, 'Sign in'))));
}
// ---------- shell ----------
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) sb.querySelector('a,button')?.focus(); else mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) document.getElementById('sidebar')?.classList.contains('open') && setDrawer(false); });
const route = () => { const p = location.hash.slice(2).split('/'); return { name: p[0] || 'notes', arg: decodeURIComponent(p.slice(1).join('/')) }; };
const routeKey = r => r.name === 'tag' ? 'tag/' + r.arg : r.name === 'day' ? (r.arg === isoDay(new Date()) ? 'today' : 'journal') : r.name;
function navItem(href, key, label, ic, count) {
const cur = routeKey(route()) === key;
const el = h('a', { class: 'navitem' + (cur ? ' on' : ''), href, 'aria-current': cur ? 'page' : null, onclick: () => setDrawer(false) },
icon(ic), h('span', { class: 'label' }, label), count == null ? null : h('span', { class: 'count' }, count));
return el;
}
// The sidebar's lists and counts change as notes do, so they are rebuilt on their own.
function updateNav() {
const box = document.getElementById('nav-dynamic');
if (!box) return;
fill(box,
navItem('#/', 'notes', 'Notes', 'notes', S.counts.all),
navItem('#/today', 'today', 'Today', 'today'),
navItem('#/journal', 'journal', 'Journal', 'journal', S.counts.daily),
navItem('#/pinned', 'pinned', 'Pinned', 'pin', S.counts.pinned),
S.tags.length ? h('div', { class: 'navsec' }, 'Tags') : null,
S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count)),
h('div', { class: 'navsec' }, 'Other'),
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null));
}
async function render() {
await flushAll();
S.pending.clear();
const main = h('main', { id: 'view', tabindex: '-1' });
$app.replaceChildren(h('a', { class: 'skip', href: '#view', onclick: e => { e.preventDefault(); main.focus(); } }, 'Skip to content'), h('div', { class: 'shell' },
h('div', { class: 'topbar' }, brand(), h('button', { class: 'ghost quiet btn-icon', id: 'menu-btn', 'aria-label': 'Open menu', 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => setDrawer(true) }, icon('menu'))),
h('div', { class: 'backdrop', id: 'backdrop', onclick: () => setDrawer(false) }),
h('aside', { class: 'sidebar', id: 'sidebar', 'aria-label': 'Main' },
h('div', { class: 'sidebar-head' }, brand(), h('button', { class: 'ghost quiet btn-icon sidebar-close', 'aria-label': 'Close menu', onclick: () => setDrawer(false) }, icon('close'))),
h('nav', { id: 'nav-dynamic', class: 'nav', 'aria-label': 'Notes' }),
h('div', { class: 'grow' }),
h('div', { class: 'userbox' },
navItemLink('#/settings', 'settings', 'Settings', 'settings'),
h('button', { class: 'navitem', onclick: toggleTheme }, icon('dark'), 'Switch theme'),
h('button', { class: 'navitem', onclick: act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; boot(); }) }, icon('out'), 'Sign out'))),
h('div', { class: 'content' }, main)));
updateNav();
show();
}
function navItemLink(href, key, label, ic) { return navItem(href, key, label, ic); }
window.addEventListener('hashchange', () => { if (S.ready) render(); });
function pageHeader(title, desc, primary) {
return h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, title), desc ? h('p', {}, desc) : null),
primary ? h('button', { class: 'btn', onclick: primary[1] }, icon(primary[2] || 'add', 16), primary[0]) : null);
}
function show() {
const r = route(), main = document.getElementById('view');
S.q = '';
let page;
switch (r.name) {
case 'today': location.replace('#/day/' + isoDay(new Date())); return;
case 'day': page = /^\d{4}-\d{2}-\d{2}$/.test(r.arg) ? dayPage(r.arg) : notesPage({ view: 'all' }); break;
case 'journal': page = journalPage(); break;
case 'pinned': page = notesPage({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break;
case 'tag': page = notesPage({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every note with this tag.' }); break;
case 'trash': page = notesPage({ view: 'trash', title: 'Trash', desc: 'Deleted notes wait here until you empty the trash.' }); break;
case 'settings': page = settingsPage(); break;
default: page = notesPage({ view: 'all', composer: true, title: 'Notes', desc: 'Write anything down. Sort it out later, or never.' });
}
S.view = page;
main.replaceChildren(page.el);
document.title = (page.title ? page.title + ' · ' : '') + 'Basking';
page.start?.();
}
// ---------- the editor: autosaves, and notices when a note changed somewhere else ----------
function makeEditor(note, { rows = 4, onSaved, big } = {}) {
let cur = note, dirty = false, saving = false, queued = false, blocked = false, timer = null;
const ta = h('textarea', { class: 'write' + (big ? ' big' : ''), rows, 'aria-label': 'Note text', placeholder: big ? 'Write about your day...' : '' });
ta.value = note.body;
const status = h('span', { class: 'sub', role: 'status' });
const notice = h('div', { class: 'notice notice-warning', hidden: true });
const fit = () => { if (big) return; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, window.innerHeight * 0.7) + 'px'; };
async function save() {
clearTimeout(timer); timer = null;
if (!dirty || blocked) return;
if (saving) { queued = true; return; }
saving = true; dirty = false; status.textContent = 'Saving';
try {
cur = await api('PUT', '/api/notes/' + cur.id, { body: ta.value, base: cur.updated });
if (!dirty) status.textContent = 'Saved';
onSaved?.(cur);
} catch (e) {
dirty = true;
if (e.status === 409) conflict(e.data.note);
else { status.textContent = 'Not saved'; if (e.message !== SIGNED_OUT) toast(e.message, true); }
} finally {
saving = false;
if (queued) { queued = false; save(); }
}
}
function conflict(theirs) {
blocked = true; status.textContent = 'Not saved';
notice.hidden = false;
notice.replaceChildren(icon('alert', 16), h('span', { class: 'grow' }, 'This note changed somewhere else, probably on another device.'),
h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; notice.hidden = true; save(); } }, 'Keep my version'),
h('button', { class: 'ghost', onclick: () => { cur = theirs; ta.value = theirs.body; dirty = false; blocked = false; notice.hidden = true; status.textContent = 'Saved'; fit(); onSaved?.(cur); } }, 'Use the other version'));
}
ta.addEventListener('input', () => {
dirty = true; status.textContent = 'Unsaved'; fit();
clearTimeout(timer); timer = setTimeout(save, 700);
});
S.pending.add(save);
return {
ta, status, notice, save,
get note() { return cur; },
flush: async () => { await save(); },
close() { S.pending.delete(save); },
fit,
};
}
// ---------- note cards ----------
const LONG_LINES = 14, LONG_CHARS = 1200;
function noteCard(n, ctx) {
const el = h('article', { class: 'note' + (n.pinned ? ' pinned' : ''), 'data-id': n.id });
let ed = null;
const toggleTask = act(async (line, checked) => {
const lines = n.body.split('\n');
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
n = await api('PUT', '/api/notes/' + n.id, { body: lines.join('\n'), base: n.updated });
ctx.changed(n, true);
});
function view() {
ed = null;
const long = n.body.split('\n').length > LONG_LINES || n.body.length > LONG_CHARS;
let open = false;
const body = n.body.trim() ? md(n.body, n.trashed ? null : toggleTask) : h('p', { class: 'dim' }, 'Empty note');
const wrap = h('div', { class: 'note-body' + (long ? ' clamp' : '') }, body);
const more = long ? h('button', { class: 'ghost quiet small-btn', 'aria-expanded': 'false', onclick: e => {
open = !open; wrap.classList.toggle('clamp', !open); e.currentTarget.setAttribute('aria-expanded', open); e.currentTarget.textContent = open ? 'Show less' : 'Show more';
} }, 'Show more') : null;
const when = n.daily ? h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily)) : h('time', { datetime: n.created, title: 'Written ' + fmtTime(n.created) + (n.updated !== n.created ? '. Edited ' + fmtTime(n.updated) : '') }, hhmm(n.created));
const actions = n.trashed
? [btn('Restore', 'restore', act(() => ctx.restore(n))), btn('Delete for good', 'trash', act(() => ctx.purge(n)), 'ghost danger')]
: [btn('Edit', 'edit', edit), btn(n.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { n = await api('PUT', '/api/notes/' + n.id, { pinned: !n.pinned }); ctx.changed(n, true); })), btn('Delete', 'trash', act(() => ctx.trash(n)))];
el.className = 'note' + (n.pinned ? ' pinned' : '');
fill(el, wrap, more, h('div', { class: 'note-foot' }, when, n.pinned ? badge('Pinned', 'accent') : null, h('span', { class: 'grow' }), h('div', { class: 'actions' }, actions)));
}
function edit() {
ed = makeEditor(n, { rows: 3, onSaved: s => { n = s; ctx.changed(n, false); } });
el.className = 'note editing';
el.replaceChildren(ed.notice, ed.ta, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }), h('div', { class: 'actions' },
btn('Done', 'check', act(done), 'btn'))));
ed.fit(); ed.ta.focus(); ed.ta.setSelectionRange(ed.ta.value.length, ed.ta.value.length);
ed.ta.addEventListener('keydown', e => {
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { e.preventDefault(); e.stopPropagation(); done(); }
});
}
async function done() {
const e = ed; await e.flush(); e.close(); n = e.note;
view(); ctx.changed(n, true); el.querySelector('button')?.focus();
}
view();
el.edit = edit;
return el;
}
// ---------- the composer ----------
function composer(onSaved) {
const ta = h('textarea', { class: 'write', rows: 3, placeholder: 'What is on your mind?', 'aria-label': 'New note' });
ta.value = store.get('draft');
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(Math.max(ta.scrollHeight + 2, 84), window.innerHeight * 0.5) + 'px'; };
const go = h('button', { class: 'btn' }, icon('check', 16), 'Save note');
const save = async () => {
const text = ta.value.trim();
if (!text) { ta.focus(); return; }
go.disabled = true;
try {
await api('POST', '/api/notes', { body: text });
ta.value = ''; store.set('draft', ''); fit();
await onSaved();
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); }
go.disabled = false; ta.focus();
};
go.addEventListener('click', save);
ta.addEventListener('input', () => { store.set('draft', ta.value); fit(); });
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); } });
setTimeout(fit);
return Object.assign(h('div', { class: 'card composer' }, ta, h('div', { class: 'note-foot' }, h('span', { class: 'sub' }, 'Ctrl+Enter saves. Add #tags to find things later.'), h('span', { class: 'grow' }), go)), { ta });
}
// ---------- notes list ----------
function notesPage({ view, tag = '', title, desc, composer: withComposer }) {
const list = h('div', { class: 'notes' }), moreBox = h('div', { class: 'more' });
const search = h('input', { type: 'search', placeholder: 'Search notes, or type #tag', 'aria-label': 'Search notes' });
let items = [], total = 0, loaded = false, timer = null, token = 0;
async function load(reset) {
const mine = ++token, offset = reset ? 0 : items.length;
const q = new URLSearchParams({ view, tag, q: search.value.trim(), offset, limit: 50 });
const r = await api('GET', '/api/notes?' + q);
if (mine !== token) return;
items = reset ? r.notes : items.concat(r.notes); total = r.total; loaded = true; S.q = search.value.trim();
draw();
}
const reload = act(() => Promise.all([load(true), refreshSummary()]));
const ctx = {
changed(n, redraw) {
const k = items.findIndex(x => x.id === n.id);
if (k >= 0) items[k] = n;
if (view === 'pinned' && !n.pinned) { items.splice(k, 1); total--; }
if (redraw) { draw(); } refreshSummary().catch(() => { });
},
async trash(n) {
await api('DELETE', '/api/notes/' + n.id);
toast('Moved to the trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await reload(); }) });
await reload();
},
async restore(n) { await api('POST', `/api/notes/${n.id}/restore`); toast('Note restored'); await reload(); },
async purge(n) {
const ok = await confirmDialog({ title: 'Delete this note for good?', body: `"${preview(n.body)}" will be deleted and cannot be brought back.`, confirm: 'Delete note' });
if (!ok) return;
await api('DELETE', `/api/notes/${n.id}?purge=1`); toast('Note deleted'); await reload();
},
};
function draw() {
list.replaceChildren(); moreBox.replaceChildren();
if (!items.length) {
const searching = search.value.trim() || tag;
const [ic, t, p] = view === 'trash' ? ['trash', 'The trash is empty', 'Notes you delete wait here until you empty the trash.']
: view === 'pinned' ? ['pin', 'Nothing pinned', 'Pin a note to keep it here.']
: searching ? ['view', 'No notes match', 'Try fewer words, or clear the search.']
: ['notes', 'No notes yet', 'Write anything in the box above. It is saved when you press Ctrl+Enter.'];
list.append(emptyState(ic, t, p));
} else {
let day = '', group;
for (const n of items) {
const d = isoDay(new Date(n.created));
if (d !== day && view !== 'trash') {
day = d; group = h('section', { class: 'day', 'aria-label': dayLabel(d) }, h('h2', { class: 'day-h' }, dayLabel(d)));
list.append(group);
} else if (view === 'trash' && !group) { group = h('section', { class: 'day' }); list.append(group); }
group.append(noteCard(n, ctx));
}
}
if (items.length < total) moreBox.append(h('button', { class: 'ghost', onclick: act(() => load(false)) }, `Show more (${total - items.length} left)`));
}
const comp = withComposer ? composer(async () => { search.value = ''; await load(true); await refreshSummary(); }) : null;
const emptyBtn = view === 'trash' ? btn('Empty trash', 'trash', act(async () => {
if (!total) { toast('The trash is already empty'); return; }
const ok = await confirmDialog({ title: 'Empty the trash?', body: `${total} ${total === 1 ? 'note' : 'notes'} will be deleted for good. This cannot be undone.`, confirm: 'Empty trash' });
if (!ok) return;
await api('DELETE', '/api/trash'); toast('Trash emptied'); await reload();
}), 'ghost danger') : null;
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(act(() => load(true)), 250); });
search.addEventListener('keydown', e => { if (e.key === 'Escape') { search.value = ''; load(true); search.blur(); } });
const el = h('div', {}, pageHeader(title || 'Notes', desc), comp,
h('div', { class: 'toolbar' }, search, emptyBtn ? h('span', { class: 'push' }, emptyBtn) : null), list, moreBox);
list.append(skeleton());
return { el, title, search, composer: comp, start: act(() => load(true)), newNote: () => comp?.ta.focus() };
}
const preview = s => { const t = s.replace(/\s+/g, ' ').trim(); return t.length > 60 ? t.slice(0, 60) + '...' : t; };
// ---------- journal ----------
function journalPage() {
const list = h('div', { class: 'notes' });
return {
title: 'Journal',
el: h('div', {}, pageHeader('Journal', 'One page for each day. Open a day to write it, or read back over the ones before.', ['Write today', () => { location.hash = '#/day/' + isoDay(new Date()); }, 'edit']), list),
start: act(async () => {
const r = await api('GET', '/api/notes?view=daily&limit=200');
list.replaceChildren();
const filled = r.notes.filter(n => n.body.trim());
if (!filled.length) { list.append(emptyState('journal', 'No journal entries yet', 'Open today and write a few lines. Each day gets its own page.')); return; }
for (const n of filled) {
list.append(h('article', { class: 'note' }, h('h2', { class: 'day-h' }, h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily))),
h('div', { class: 'note-body clamp' }, md(n.body)),
h('div', { class: 'note-foot' }, h('span', { class: 'grow' }), h('div', { class: 'actions' }, btn('Open', 'edit', () => { location.hash = '#/day/' + n.daily; })))));
}
}),
};
}
// ---------- one day ----------
function dayPage(date) {
const body = h('div'), today = isoDay(new Date());
let ed = null, previewing = false;
const nav = h('div', { class: 'actions' },
btn('Previous day', 'prev', () => { location.hash = '#/day/' + addDays(date, -1); }),
date === today ? null : btn('Today', 'today', () => { location.hash = '#/day/' + today; }),
date >= today ? null : btn('Next day', 'next', () => { location.hash = '#/day/' + addDays(date, 1); }));
function stamp() {
const t = ed.ta, at = t.selectionStart, before = t.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1);
const text = (line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : '') + hhmm(new Date()) + ' ';
t.setRangeText(text, at, t.selectionEnd, 'end'); t.focus(); t.dispatchEvent(new Event('input'));
}
function draw(n) {
ed = makeEditor(n, { rows: 14, big: true });
const rendered = h('div', { class: 'card note-body', hidden: true });
const toggle = btn('Preview', 'view', () => {
previewing = !previewing;
if (previewing) { rendered.replaceChildren(ed.ta.value.trim() ? md(ed.ta.value) : h('p', { class: 'dim' }, 'Nothing written yet')); }
rendered.hidden = !previewing; ed.ta.hidden = previewing;
toggle.replaceChildren(icon(previewing ? 'edit' : 'view', 16), previewing ? 'Edit' : 'Preview');
});
body.replaceChildren(ed.notice, ed.ta, rendered, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }),
h('div', { class: 'actions' }, btn('Insert time', 'clock', stamp), toggle)));
setTimeout(() => ed.ta.focus());
}
return {
title: dayLabel(date),
el: h('div', {}, pageHeader(dayLabel(date), date === today ? parseDay(date).toLocaleDateString(undefined, { dateStyle: 'full' }) : 'Your page for this day. It saves as you type.'),
h('div', { class: 'toolbar' }, nav), h('div', { class: 'card' }, body)),
start: act(async () => { body.replaceChildren(skeleton()); draw(await api('POST', '/api/daily', { date })); }),
};
}
// ---------- settings ----------
async function copyText(text, input) {
try { await navigator.clipboard.writeText(text); toast('Copied'); }
catch { input.select(); toast('Press Ctrl+C to copy'); }
}
function settingsPage() {
const tokens = h('div');
const loadTokens = act(async () => {
const rows = await api('GET', '/api/tokens');
tokens.replaceChildren(rows.length ? h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Created'), h('th', {}))),
h('tbody', {}, rows.map(t => h('tr', {}, h('td', {}, t.name), h('td', {}, fmtTime(t.created)), h('td', { class: 'actions-cell' }, btn('Revoke', null, act(async () => {
const ok = await confirmDialog({ title: `Revoke ${t.name}?`, body: 'Anything using this token stops working at once. You can make another one later.', confirm: 'Revoke token' });
if (!ok) return;
await api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)); toast('Token revoked'); loadTokens();
}), 'ghost danger'))))))) : emptyState('tag', 'No tokens yet', 'Make a token to send notes from a script or a phone shortcut.'));
});
const newToken = act(async () => {
const r = await formDialog({ title: 'New API token', submit: 'Make token', fields: [{ name: 'name', label: 'Name', required: true, placeholder: 'phone', hint: 'Something that tells you where it is used.' }] });
if (!r) return;
const t = await api('POST', '/api/tokens', r);
const box = h('input', { type: 'text', readonly: true, class: 'mono' }); box.value = t.token;
let close;
close = modal('Your new token', h('div', { class: 'stack' }, h('p', { class: 'dim' }, 'This is the only time it is shown. Copy it now.'), box,
h('div', { class: 'actions form-actions' }, btn('Copy token', 'copy', () => copyText(t.token, box)), h('button', { class: 'btn', onclick: () => close() }, 'Done'))), { sticky: true });
box.select(); loadTokens();
});
const changePassword = act(async () => {
const r = await formDialog({ title: 'Change password', submit: 'Change password', fields: [
{ name: 'current', label: 'Current password', type: 'password', required: true },
{ name: 'next', label: 'New password', type: 'password', required: true, hint: 'At least 8 characters.' },
{ name: 'again', label: 'New password again', type: 'password', required: true }] });
if (!r) return;
if (r.next !== r.again) { toast('The two new passwords do not match', true); return; }
await api('POST', '/api/password', { current: r.current, new: r.next });
toast('Password changed');
});
const origin = location.origin;
const curl = `curl -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: text/plain" \\\n -d "Call the dentist #errands" \\\n ${origin}/api/notes`;
return {
title: 'Settings',
el: h('div', {}, pageHeader('Settings', 'Your password, tokens for scripts, and a copy of everything.'),
card('Password', h('p', { class: 'dim' }, 'Changing it signs out every other browser.'), btn('Change password', 'edit', changePassword)),
card('', h('div', { class: 'card-h' }, h('h2', {}, 'API tokens'), btn('New token', 'add', newToken)),
h('p', { class: 'dim' }, 'A token lets a script or a phone shortcut add notes without signing in.'), tokens),
card('Send a note from anywhere', h('p', { class: 'dim' }, 'With a token, post the text of a note to Basking:'), h('pre', { class: 'snippet' }, h('code', {}, curl)),
h('p', { class: 'dim' }, 'Or, from a terminal with the basking command:'), h('pre', { class: 'snippet' }, h('code', {}, `BASKING_URL=${origin} BASKING_TOKEN=... basking add "an idea"`))),
card('Your notes', h('p', { class: 'dim' }, 'Every note is a plain markdown file in the folder Basking keeps its data in, so you can back that folder up or sync it as it is. This downloads the same files as one zip.'),
btn('Download all notes', 'download', () => { location.href = '/api/export'; }))),
start: loadTokens,
};
}
// ---------- keyboard ----------
document.addEventListener('keydown', e => {
if (!S.ready || modalOpen() || e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t.matches?.('input,textarea,select,[contenteditable]')) return;
if (e.key === '/' && S.view?.search) { e.preventDefault(); S.view.search.focus(); }
else if (e.key === 'n') {
e.preventDefault();
if (S.view?.composer) S.view.composer.ta.focus(); else { location.hash = '#/'; setTimeout(() => S.view?.composer?.ta.focus(), 400); }
}
});
boot();
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Basking</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231f5c5c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20c4-1 7-5 8-14 3 5 8 7 10 14z'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
<script src="app.js"></script>
</body>
</html>
+239
View File
@@ -0,0 +1,239 @@
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--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;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html, body { height:100%; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
p { margin:0 0 var(--s3); }
a { color:var(--accent-text); text-underline-offset:2px; }
a:hover { text-decoration-thickness:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
[hidden] { display:none !important; }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
.inline-icon { display:inline-flex; }
/* shell */
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.sidebar button.sidebar-close { display:none; }
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
.brand .icon { color:var(--accent-text); }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
.nav { display:flex; flex-direction:column; gap:2px; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.navitem .label { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.grow { flex:1 1 auto; min-width:0; }
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { min-width:0; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
main:focus { outline:none; }
.topbar, .backdrop { display:none; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h h1 { display:flex; align-items:center; gap:var(--s3); flex-wrap:wrap; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
.page-h .btn { white-space:nowrap; }
.back { display:inline-block; margin-bottom:var(--s3); font-size:var(--text-sm); }
/* cards, layout */
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.card-h { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.card-h h2 { font-size:var(--text-lg); margin:0; }
.meta { color:var(--text-muted); font-size:var(--text-sm); }
.stack { display:flex; flex-direction:column; gap:var(--s4); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
td.actions-cell { text-align:right; white-space:nowrap; }
td.actions-cell button + button { margin-left:var(--s2); }
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.toolbar .push { margin-left:auto; }
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; font-family:var(--font-mono); font-size:var(--text-sm); }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
input[aria-invalid="true"] { border-color:var(--danger); }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
/* buttons: btn is the primary action, ghost the default, danger and quiet the others */
button.btn, button.ghost { 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; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.quiet { background:transparent; border-color:transparent; }
button.danger { color:var(--danger); border-color:var(--danger); }
button.danger:hover { background:var(--danger-bg); }
button.btn-icon { width:var(--control-h); padding:0; }
td button.ghost, .actions button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:320px; }
/* badges: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
/* the event and chat feed */
/* empty state, notice, loading */
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
.empty p { margin:0 auto var(--s3); max-width:56ch; }
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
.card > .empty { margin:0; }
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); display:flex; gap:var(--s3); align-items:center; flex-wrap:wrap; }
.notice-warning { background:var(--warning-bg); border-color:var(--warning); color:var(--warning); }
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); height:16px; margin-bottom:14px; }
.w60 { width:60%; } .w75 { width:75%; } .w90 { width:90%; } .w100 { width:100%; }
@keyframes shimmer { to { background-position:-200% 0; } }
/* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(640px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); overflow-wrap:anywhere; }
.toast-error { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
@media (pointer: coarse) {
button.btn, button.ghost, .navitem, input, select, .tabs2 a { min-height:var(--touch); }
td button.ghost, .actions button.ghost { min-height:36px; }
input, select, textarea { font-size:16px; }
}
@media (max-width:860px) {
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.sidebar { display:none; }
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
.sidebar button.sidebar-close { display:inline-flex; }
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
main { padding:var(--s5) var(--s4) var(--s7); }
.ev { grid-template-columns:4.5rem minmax(0,1fr); } .ev .badge { display:none; }
}
@media (max-width:640px) {
.modal { padding:var(--s4); }
.shade { padding:var(--s4) var(--s3); }
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
.page-h > .btn { width:100%; }
}
.icon-16 { width:16px; height:16px; } .icon-22 { width:22px; height:22px; }
/* notes */
textarea.write { width:100%; min-height:84px; font-family:var(--font); font-size:var(--text-base); line-height:1.6; resize:none; border:0; padding:0; background:transparent; }
textarea.write:focus { outline:none; border:0; }
textarea.write.big { min-height:50vh; resize:vertical; }
.composer, .note { padding:var(--s4) var(--s5); }
.composer:focus-within, .note.editing { border-color:var(--accent); }
.note { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); margin-bottom:var(--s3); overflow:visible; }
.note.pinned { border-left:3px solid var(--accent); }
.note-body { overflow-wrap:anywhere; }
.note-body.clamp { max-height:15em; overflow:hidden; -webkit-mask-image:linear-gradient(black 70%, transparent); mask-image:linear-gradient(black 70%, transparent); }
.note-foot { display:flex; align-items:center; gap:var(--s3); margin-top:var(--s3); flex-wrap:wrap; font-size:var(--text-sm); color:var(--text-muted); }
.note-foot a, .note-foot time { color:var(--text-muted); }
.notes .empty { margin:0; }
.day-h { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); text-transform:none; margin:var(--s5) 0 var(--s2); letter-spacing:0; }
.day-h a { color:inherit; }
.day:first-child .day-h { margin-top:var(--s2); }
.more { text-align:center; margin:var(--s4) 0; }
button.small-btn { min-height:28px; padding:0 var(--s2); font-size:var(--text-sm); }
.navitem .count { font-size:var(--text-xs); color:var(--text-faint); font-variant-numeric:tabular-nums; }
.chip { display:inline-block; padding:0 var(--s2); border-radius:999px; background:var(--accent-soft); color:var(--accent-text); text-decoration:none; font-size:var(--text-sm); font-weight:500; }
.chip:hover { text-decoration:underline; }
.toast { display:flex; gap:var(--s3); align-items:center; justify-content:space-between; }
.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; }
.md p { margin:0 0 var(--s3); }
.md .md-h { margin:var(--s4) 0 var(--s2); font-size:var(--text-lg); }
.md h4.md-h { font-size:var(--text-base); } .md h5.md-h { font-size:var(--text-base); color:var(--text-muted); }
.md ul, .md ol { margin:0 0 var(--s3); padding-left:var(--s5); }
.md li.task { list-style:none; margin-left:calc(var(--s5) * -1); display:flex; gap:var(--s2); align-items:flex-start; }
.md li.task input { margin-top:5px; }
.md li .done { color:var(--text-muted); text-decoration:line-through; }
.md li.ind-1 { margin-left:var(--s4); } .md li.ind-2 { margin-left:var(--s6); } .md li.ind-3 { margin-left:var(--s7); }
.md li.task.ind-1 { margin-left:calc(var(--s5) * -1 + var(--s4)); } .md li.task.ind-2 { margin-left:calc(var(--s5) * -1 + var(--s6)); } .md li.task.ind-3 { margin-left:calc(var(--s5) * -1 + var(--s7)); }
.md blockquote { margin:0 0 var(--s3); padding:var(--s1) var(--s4); border-left:3px solid var(--border-strong); color:var(--text-muted); }
.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; }
+339
View File
@@ -0,0 +1,339 @@
package web
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
)
const password = "correct horse battery"
type rig struct {
t *testing.T
srv *httptest.Server
cfg *config.Holder
cookie *http.Cookie
}
func newRig(t *testing.T) *rig {
t.Helper()
dir := t.TempDir()
h, err := config.Load(filepath.Join(dir, "basking.json"))
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"))
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}
}
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)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
if r.cookie != nil {
req.AddCookie(r.cookie)
}
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
}
}
if r.cookie == nil {
r.t.Fatal("no session cookie")
}
}
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)
}
}
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")
}
}
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)
}
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
t.Errorf("wrong password: %d", resp.StatusCode)
}
r.signIn()
resp, _ := r.do("GET", "/api/me", "", "")
if resp.StatusCode != 200 {
t.Fatalf("me: %d", resp.StatusCode)
}
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
t.Errorf("CSP %q", csp)
}
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie flags %+v", r.cookie)
}
// 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)
}
// 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)
}
}
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
}
if last != 429 {
t.Fatalf("after many failures got %d", last)
}
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)
}
}
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)
}
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)
}
_, 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)
}
// 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)
}
}
}
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 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)
}
token := tok["token"].(string)
if !strings.HasPrefix(token, "bsk_") {
t.Fatalf("token %q", token)
}
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 {
t.Errorf("duplicate name: %d", resp.StatusCode)
}
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)
}
}
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)
}
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
}
}
if r.cookie == old {
t.Fatal("no new cookie")
}
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 {
t.Errorf("new cookie: %d", resp2.StatusCode)
}
}
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)
}
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))
}
}
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())
}
}