// 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 '") 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 }