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>
246 lines
6.7 KiB
Go
246 lines
6.7 KiB
Go
// 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.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/basking/internal/config"
|
|
"git.veillax.com/Veillax/basking/internal/store"
|
|
"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.
|
|
|
|
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 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.
|
|
`
|
|
|
|
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 "password":
|
|
err = passwordCmd(os.Args[2:])
|
|
case "token":
|
|
err = tokenCmd(os.Args[2:])
|
|
case "add":
|
|
err = addCmd(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)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
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", os.Getenv("BASKING_LISTEN"), "override the listen address")
|
|
data := fs.String("data", os.Getenv("BASKING_DATA"), "override the folder the notes are kept in")
|
|
fs.Parse(args)
|
|
|
|
h, err := config.Load(*path)
|
|
if err != nil {
|
|
return 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 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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
counts, _ := st.Summary()
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.Listen,
|
|
Handler: web.New(h, st, version).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: %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")
|
|
}
|
|
|
|
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 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)
|
|
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 err
|
|
}
|
|
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|