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

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

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

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

No files matched your search

+76 -141
View File
@@ -1,16 +1,12 @@
// Command basking is a place to dump thoughts, jot notes and keep a journal. Notes are plain
// markdown files; the dashboard is one small binary.
// Command basking is a private notebook: pages you can link, nest and search, kept as plain
// markdown files. You sign in with Authentik; there is no other way in.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -20,26 +16,32 @@ import (
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/web"
)
var version = "dev"
const usage = `basking is a place to dump thoughts, jot notes and keep a journal.
const usage = `basking is a private notebook that you sign in to with Authentik.
basking serve [-config basking.json] [-listen addr] [-data dir]
run the dashboard
basking password [-config ...] set the password (reads BASKING_PASSWORD or a line on stdin)
basking token [-config ...] NAME create an API token for scripts (shown once)
basking add [TEXT...] send a note to a running Basking; reads stdin if no text
basking serve [-config basking.json] [-listen addr] [-data dir]
run the notebook
basking check [-config basking.json] test the Authentik settings without starting
basking version
"add" needs BASKING_URL (such as http://127.0.0.1:8490) and BASKING_TOKEN.
The settings path can also be set with BASKING_CONFIG, the address with BASKING_LISTEN and the
notes folder with BASKING_DATA.
Settings come from the settings file and can be overridden by environment variables:
BASKING_PUBLIC_URL where you open Basking, such as https://notes.example.com
BASKING_OIDC_ISSUER the Authentik provider's OpenID configuration issuer
BASKING_OIDC_CLIENT_ID the provider's Client ID
BASKING_OIDC_CLIENT_SECRET the provider's Client Secret
BASKING_ALLOWED optional: only these emails or usernames may sign in (comma separated)
BASKING_LISTEN, BASKING_DATA, BASKING_CONFIG
`
// exitConfig is the exit status for settings that need fixing by hand, so a service manager does
// not keep restarting something that cannot start.
const exitConfig = 2
func main() {
log.SetFlags(log.LstdFlags)
if len(os.Args) < 2 {
@@ -50,12 +52,8 @@ func main() {
switch os.Args[1] {
case "serve":
err = serve(os.Args[2:])
case "password":
err = passwordCmd(os.Args[2:])
case "token":
err = tokenCmd(os.Args[2:])
case "add":
err = addCmd(os.Args[2:])
case "check":
err = check(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("basking", version)
case "help", "-h", "--help":
@@ -66,10 +64,16 @@ func main() {
}
if err != nil {
fmt.Fprintln(os.Stderr, "basking:", err)
var ce configError
if errors.As(err, &ce) {
os.Exit(exitConfig)
}
os.Exit(1)
}
}
type configError struct{ error }
func configFlag(fs *flag.FlagSet) *string {
def := os.Getenv("BASKING_CONFIG")
if def == "" {
@@ -81,43 +85,34 @@ func configFlag(fs *flag.FlagSet) *string {
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
path := configFlag(fs)
listen := fs.String("listen", os.Getenv("BASKING_LISTEN"), "override the listen address")
data := fs.String("data", os.Getenv("BASKING_DATA"), "override the folder the notes are kept in")
listen := fs.String("listen", "", "override the listen address")
data := fs.String("data", "", "override the folder the notes are kept in")
fs.Parse(args)
if *listen != "" {
os.Setenv("BASKING_LISTEN", *listen)
}
if *data != "" {
os.Setenv("BASKING_DATA", *data)
}
h, err := config.Load(*path)
if err != nil {
return err
return configError{err}
}
// First run: make a password so the dashboard can be opened, and show it once.
if h.Get().PasswordHash == "" {
pw := config.RandomPassword()
hash, err := config.HashPassword(pw)
if err != nil {
return err
if !h.Exists() { // writing it now is what makes the generated secret stick
if err := h.Save(); err != nil {
return fmt.Errorf("could not write %s: %w", *path, err)
}
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
return err
}
log.Printf("first run: your password is %s (change it in Settings; written to %s)", pw, *path)
}
cfg := h.Get()
if *listen != "" {
cfg.Listen = *listen
}
if *data != "" {
cfg.DataDir = *data
}
st, err := store.Open(cfg.DataDir)
web, err := web.New(h, version)
if err != nil {
return err
return configError{err}
}
counts, _ := st.Summary()
srv := &http.Server{
Addr: cfg.Listen,
Handler: web.New(h, st, version).Handler(),
Handler: web.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
@@ -127,10 +122,21 @@ func serve(args []string) error {
defer stop()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
log.Printf("basking %s: %d note(s) in %s; open http://%s", version, counts.All, cfg.DataDir, cfg.Listen)
if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") {
log.Print("note: this is not a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure")
log.Printf("basking %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL)
log.Printf("sign-in goes through %s; the redirect URI to allow there is %s", cfg.OIDC.Issuer, cfg.RedirectURL())
if len(cfg.Allowed) == 0 {
log.Print("anyone Authentik lets into this application gets a notebook of their own; set \"allowed\" to limit it")
}
if !cfg.Secure() && !strings.Contains(cfg.PublicURL, "localhost") && !strings.Contains(cfg.PublicURL, "127.0.0.1") {
log.Print("warning: public_url is not https, and session cookies will not be marked Secure")
}
go func() { // say early if Authentik cannot be reached, instead of at the first sign-in
c, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err := web.OIDC().Discover(c); err != nil {
log.Printf("warning: %v (sign-in will work once Authentik is reachable)", err)
}
}()
select {
case err := <-errc:
@@ -142,104 +148,33 @@ func serve(args []string) error {
return srv.Shutdown(sctx)
}
func readPassword() (string, error) {
if pw := os.Getenv("BASKING_PASSWORD"); pw != "" {
return pw, nil
}
fmt.Fprint(os.Stderr, "new password: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", errors.New("no password given")
}
return strings.TrimRight(line, "\r\n"), nil
}
func passwordCmd(args []string) error {
fs := flag.NewFlagSet("password", flag.ExitOnError)
func check(args []string) error {
fs := flag.NewFlagSet("check", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
pw, err := readPassword()
if err != nil {
return err
}
if len(pw) < 8 {
return errors.New("the password must be at least 8 characters")
}
hash, err := config.HashPassword(pw)
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return configError{err}
}
cfg := h.Get()
if err := cfg.Check(); err != nil {
return configError{err}
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
d, err := oidc.New(cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret, cfg.RedirectURL()).Discover(ctx)
if err != nil {
return err
}
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
return err
fmt.Println("Authentik answered at", cfg.OIDC.Issuer)
fmt.Println(" sign-in page:", d.AuthorizationEndpoint)
fmt.Println(" token address:", d.TokenEndpoint)
fmt.Println("Make sure the provider in Authentik allows this redirect URI exactly:")
fmt.Println(" " + cfg.RedirectURL())
if len(cfg.Allowed) == 0 {
fmt.Println("Everyone Authentik lets into the application gets their own notebook.")
} else {
fmt.Println("Only these may sign in:", strings.Join(cfg.Allowed, ", "))
}
fmt.Println("password saved; restart Basking if it is running, and everyone will be signed out")
return nil
}
func tokenCmd(args []string) error {
fs := flag.NewFlagSet("token", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("expected NAME")
}
token, hash, err := config.NewToken()
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return err
}
err = h.Update(func(c *config.Config) error {
c.Tokens = append(c.Tokens, config.Token{Name: fs.Arg(0), Hash: hash, Created: time.Now().UTC()})
return nil
})
if err == nil {
fmt.Println(token)
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; send it as 'Authorization: Bearer <token>'")
fmt.Fprintln(os.Stderr, "a running Basking picks it up after a restart")
}
return err
}
func addCmd(args []string) error {
url, token := os.Getenv("BASKING_URL"), os.Getenv("BASKING_TOKEN")
if url == "" || token == "" {
return errors.New("set BASKING_URL and BASKING_TOKEN (make a token in Settings)")
}
text := strings.Join(args, " ")
if text == "" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
text = string(b)
}
if strings.TrimSpace(text) == "" {
return errors.New("nothing to add")
}
body, _ := json.Marshal(map[string]string{"body": text})
req, err := http.NewRequest("POST", strings.TrimRight(url, "/")+"/api/notes", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
var e struct{ Error string }
json.NewDecoder(resp.Body).Decode(&e)
return fmt.Errorf("%s: %s", resp.Status, e.Error)
}
fmt.Println("saved")
return nil
}