Files
Mako/cmd/basking/main.go
T
VeillaxandClaude Sonnet 5.5 98f7730913 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>
2026-10-31 22:51:26 -06:00

181 lines
5.4 KiB
Go

// 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 (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/web"
)
var version = "dev"
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 notebook
basking check [-config basking.json] test the Authentik settings without starting
basking version
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 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = serve(os.Args[2:])
case "check":
err = check(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("basking", version)
case "help", "-h", "--help":
fmt.Print(usage)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage)
os.Exit(2)
}
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 == "" {
def = "basking.json"
}
return fs.String("config", def, "path of the settings file")
}
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
path := configFlag(fs)
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 configError{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)
}
}
cfg := h.Get()
web, err := web.New(h, version)
if err != nil {
return configError{err}
}
srv := &http.Server{
Addr: cfg.Listen,
Handler: web.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
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:
return err
case <-ctx.Done():
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(sctx)
}
func check(args []string) error {
fs := flag.NewFlagSet("check", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
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
}
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, ", "))
}
return nil
}