Basking: a place to dump thoughts, jot notes and keep a journal
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>
This commit is contained in:
commit
b548faa258
23 files changed
+4026
No files matched your search
@@ -0,0 +1,254 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
writeJSON(w, map[string]any{"version": s.version, "api": p.API})
|
||||
}
|
||||
|
||||
func (s *Server) summary(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
counts, tags := s.st.Summary()
|
||||
writeJSON(w, map[string]any{"counts": counts, "tags": tags})
|
||||
}
|
||||
|
||||
func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
text := q.Get("q")
|
||||
if len(text) > 200 {
|
||||
text = text[:200]
|
||||
}
|
||||
notes, total := s.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit})
|
||||
writeJSON(w, map[string]any{"notes": notes, "total": total})
|
||||
}
|
||||
|
||||
// createNote takes {"body": "..."} as JSON, or the note itself as plain text, so a script can
|
||||
// just post what it has: curl -d 'buy milk' -H 'Content-Type: text/plain' ...
|
||||
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var body string
|
||||
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" {
|
||||
var in struct{ Body, Text string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
body = in.Body + in.Text
|
||||
} else {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fail(w, http.StatusRequestEntityTooLarge, "request too large")
|
||||
return
|
||||
}
|
||||
body = string(b)
|
||||
}
|
||||
n, err := s.st.Create(body)
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONStatus(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
func (s *Server) getNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
n, err := s.st.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var in struct {
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
Base string `json:"base"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
var base time.Time
|
||||
if in.Base != "" {
|
||||
t, err := time.Parse(time.RFC3339Nano, in.Base)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "base must be the note's updated time")
|
||||
return
|
||||
}
|
||||
base = t
|
||||
}
|
||||
n, err := s.st.Update(r.PathValue("id"), in.Body, in.Pinned, base)
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This note changed somewhere else", "note": n})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
id := r.PathValue("id")
|
||||
var err error
|
||||
if r.URL.Query().Get("purge") == "1" {
|
||||
err = s.st.Purge(id)
|
||||
} else {
|
||||
_, err = s.st.Trash(id)
|
||||
}
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
n, err := s.st.Restore(r.PathValue("id"))
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) emptyTrash(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
n, err := s.st.EmptyTrash()
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]int{"deleted": n})
|
||||
}
|
||||
|
||||
func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var in struct{ Date string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
n, err := s.st.Daily(in.Date)
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="basking-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.st.WriteZip(w); err != nil {
|
||||
// The download has started, so all that can be done is to cut it short.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- password and tokens ----
|
||||
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var in struct{ Current, New string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !config.CheckPassword(s.cfg.Get().PasswordHash, in.Current) {
|
||||
fail(w, http.StatusForbidden, "the current password is wrong")
|
||||
return
|
||||
}
|
||||
if len(in.New) < 8 {
|
||||
fail(w, http.StatusBadRequest, "the new password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
hash, err := config.HashPassword(in.New)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.startSession(w, r) // the old cookie no longer matches, so give this browser a new one
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
type row struct {
|
||||
Name string `json:"name"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
out := []row{}
|
||||
for _, t := range s.cfg.Get().Tokens {
|
||||
out = append(out, row{t.Name, t.Created})
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
if p.API {
|
||||
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
|
||||
return
|
||||
}
|
||||
var in struct{ Name string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
token, hash, err := config.NewToken()
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
err = s.cfg.Update(func(c *config.Config) error {
|
||||
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now().UTC()})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"name": name, "token": token})
|
||||
}
|
||||
|
||||
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
if p.API {
|
||||
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
found := false
|
||||
err := s.cfg.Update(func(c *config.Config) error {
|
||||
keep := c.Tokens[:0]
|
||||
for _, t := range c.Tokens {
|
||||
if t.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
keep = append(keep, t)
|
||||
}
|
||||
c.Tokens = keep
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
case !found:
|
||||
fail(w, http.StatusNotFound, "no such token")
|
||||
default:
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user