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:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3291
-1578
No files matched your search
+76
-141
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user