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>
213 lines
5.3 KiB
Go
213 lines
5.3 KiB
Go
// 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)
|
|
}
|