@@ 1,327 @@
+package main
+
+import "bytes"
+import "crypto/sha1"
+import "encoding/hex"
+import "encoding/json"
+import "errors"
+import "fmt"
+import "io"
+import "os"
+import "path/filepath"
+import "regexp"
+import "sort"
+import "strconv"
+import "time"
+
+
+var stripname *regexp.Regexp
+var NotFound error
+
+func init() {
+ stripname, _ = regexp.Compile("[^a-zA-Z0-9_]")
+ NotFound = errors.New("object not found")
+}
+
+
+var Mtpt string
+var Jrnls map[string]*Jrnl
+
+
+type Jrnl struct {
+ Id string
+ Passhash Passhash
+ Timezone *Location
+}
+type Passhash struct {
+ Hash [20]byte
+}
+type Location time.Location
+type Entry struct {
+ jrnl *Jrnl
+ Year, Month, Day int
+}
+type EntryVersion struct {
+ entry *Entry
+ Id int
+ Body string
+ ModTime time.Time
+}
+
+
+func (p *Passhash) UnmarshalJSON(b []byte) error {
+ var s string
+
+ dec := json.NewDecoder(bytes.NewBuffer(b))
+ if err := dec.Decode(&s); err != nil {
+ return errors.New("sha1 password hash is not a string")
+ }
+
+ if len(s) != 40 {
+ return errors.New("sha1 password hash is not 40 chars")
+ }
+
+ if _, err := hex.Decode(p.Hash[:], []byte(s)); err != nil {
+ return errors.New("sha1 password hash is not hexadecimal")
+ }
+
+ return nil
+}
+func (p *Passhash) Check(pw string) bool {
+ return sha1.Sum([]byte(pw)) == p.Hash
+}
+
+func (l *Location) UnmarshalJSON(b []byte) error {
+ var s string
+
+ dec := json.NewDecoder(bytes.NewBuffer(b))
+ if err := dec.Decode(&s); err != nil {
+ return errors.New("timezone is not a string")
+ }
+
+ loc, err := time.LoadLocation(s)
+ if err != nil {
+ return err
+ }
+
+ *l = Location(*loc)
+
+ return nil
+}
+
+func MountDB() error {
+ var rawJrnls []*Jrnl
+
+ fp, err := os.Open(filepath.Join(Mtpt, "users.json"))
+ if err != nil {
+ return err
+ }
+
+ dec := json.NewDecoder(fp)
+ if err = dec.Decode(&rawJrnls); err == io.EOF {
+ return errors.New("must have users in users.json!")
+ } else if err != nil {
+ return err
+ }
+
+ Jrnls = make(map [string]*Jrnl)
+ for _, jrnl := range rawJrnls {
+ jrnl.Id = stripname.ReplaceAllString(jrnl.Id, "")
+ if _, ok := Jrnls[jrnl.Id]; ok {
+ return errors.New("overlapping usernames")
+ }
+
+ Jrnls[jrnl.Id] = jrnl
+ if err := os.MkdirAll(jrnl.Path(), 0750); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (j *Jrnl) Path() string {
+ return filepath.Join(Mtpt, j.Id)
+}
+
+func (j *Jrnl) GetText() (s string, err error) {
+ ents, err := j.ListEntries()
+ if err != nil {
+ return s, err
+ }
+
+ for _, e := range ents {
+ n, err := e.GetLatest()
+ if err != nil {
+ return "", err
+ }
+
+ v, err := e.GetVersion(n)
+ if err != nil {
+ return "", err
+ }
+
+ s += fmt.Sprintf("> %04d-%02d-%02d\n\n%s\n\n\n", e.Year, e.Month, e.Day, v.Body)
+ }
+
+ return s, nil
+}
+
+func (j *Jrnl) PutText(s string) error {
+ for {
+ if ts, body, ok := NextEntry(&s); ok {
+ _, err := j.PutEntry(ts.Year(), int(ts.Month()), ts.Day(), body)
+ if err != nil {
+ return err
+ }
+ } else {
+ break
+ }
+ }
+ return nil
+}
+
+func (j *Jrnl) ListEntries() (ents []Entry, err error) {
+ readdir, err := os.ReadDir(j.Path())
+ if err != nil {
+ return ents, err
+ }
+
+ years := make([]int, 0, len(readdir))
+ for _, ent := range readdir {
+ if !ent.IsDir() { continue }
+ if n, err := strconv.ParseUint(ent.Name(), 10, 32); err == nil {
+ years = append(years, int(n))
+ }
+ }
+
+ sort.Slice(years, func(i, j int) bool { return years[i] > years[j] })
+
+ ents = make([]Entry, 0, len(years) * 365)
+ for _, year := range years {
+ for month := 12; month > 0; month-- {
+ for day := 31; day > 0; day-- {
+ if e, err := j.GetEntry(year, month, day); err == nil {
+ ents = append(ents, e)
+ } else if err != NotFound {
+ return ents, err
+ }
+ }
+ }
+ }
+
+ return ents, err
+}
+func (j *Jrnl) newEntry(year, month, day int) (e Entry) {
+ e.jrnl = j
+ e.Year = year
+ e.Month = month
+ e.Day = day
+ return
+}
+func (j *Jrnl) GetEntry(year, month, day int) (e Entry, err error) {
+ e = j.newEntry(year, month, day)
+
+ if fi, err := os.Stat(e.Path()); fi != nil && fi.IsDir() {
+ return e, nil
+ } else if err.(*os.PathError).Err.Error() == "no such file or directory" {
+ return e, NotFound
+ } else {
+ return e, err
+ }
+}
+func (j *Jrnl) PutEntry(year, month, day int, text string) (v EntryVersion, err error) {
+ e := j.newEntry(year, month, day)
+ path := e.Path()
+
+ if err = os.MkdirAll(path, 0750); err != nil {
+ return
+ }
+
+ n, err := e.GetLatest()
+ if err != nil {
+ return
+ }
+
+ // don't add a duplicate version
+ if n >= 0 {
+ latest, err := e.GetVersion(n)
+ if err != nil {
+ return v, err
+ }
+
+ if latest.Body == text {
+ return latest, err
+ }
+ }
+
+ for {
+ n++
+
+ latestpath := filepath.Join(path, "latest")
+ err = os.WriteFile(latestpath, []byte(fmt.Sprint(n)), 0640)
+ if err != nil {
+ return
+ }
+
+ verpath := filepath.Join(path, fmt.Sprint(n))
+ fp, err := os.OpenFile(verpath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640)
+ if err != nil && err.(*os.PathError).Err.Error() == "file exists" {
+ continue
+ } else if err != nil {
+ return v, err
+ }
+
+ _, err = fp.WriteString(text)
+ fp.Close()
+ if err != nil {
+ return v, err
+ } else {
+ return e.GetVersion(n)
+ }
+ }
+}
+
+func (e *Entry) Path() string {
+ return filepath.Join(
+ e.jrnl.Path(),
+ fmt.Sprintf("%04d/%02d/%02d", e.Year, e.Month, e.Day) )
+}
+func (e *Entry) GetLatest() (n int, err error) {
+ data, err := os.ReadFile(filepath.Join(e.Path(), "latest"))
+ if err != nil && err.(*os.PathError).Err.Error() == "no such file or directory" {
+ return -1, nil
+ } else if err != nil {
+ return n, err
+ }
+
+ parsed, err := strconv.ParseUint(string(data), 10, 32)
+ if err != nil {
+ return -1, nil
+ } else {
+ return int(parsed), nil
+ }
+}
+func (e *Entry) GetVersion(n int) (v EntryVersion, err error) {
+ v.entry = e
+ v.Id = n
+
+ path := v.Path()
+
+ fi, err := os.Stat(path)
+ if err != nil && err.(*os.PathError).Err.Error() == "no such file or directory" {
+ return v, NotFound
+ } else if err != nil {
+ return v, err
+ }
+ v.ModTime = fi.ModTime()
+
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return v, err
+ }
+ v.Body = string(body)
+
+ return v, nil
+}
+func (e *Entry) GetVersions() (vers []EntryVersion, err error) {
+ n, err := e.GetLatest()
+ if err != nil {
+ return vers, err
+ }
+
+ vers = make([]EntryVersion, n + 1)
+ for i := 0; i <= n; i++ {
+ v, err := e.GetVersion(i)
+ if err != nil {
+ return vers, err
+ }
+ vers[n - i] = v
+ }
+
+ return vers, nil
+}
+
+func (v *EntryVersion) Path() string {
+ return filepath.Join(v.entry.Path(), fmt.Sprint(v.Id))
+}
@@ 1,43 @@
+package main
+
+import "strings"
+import "time"
+
+const HeadFormat = "> 2006-01-02"
+
+func NextLine(s string) (line, rest string) {
+ line, rest, _ = strings.Cut(s, "\n")
+ return
+}
+
+func NextEntry(s *string) (ts time.Time, body string, ok bool) {
+ var err error
+
+ for {
+ if *s == "" {
+ return ts, body, false
+ }
+
+ line, rest := NextLine(*s)
+ *s = rest
+ if ts, err = time.Parse(HeadFormat, line); err == nil {
+ break
+ }
+ }
+
+ for {
+ if *s == "" {
+ break
+ }
+
+ line, rest := NextLine(*s)
+ if _, err = time.Parse(HeadFormat, line); err != nil {
+ body += line + "\n"
+ *s = rest
+ } else {
+ break
+ }
+ }
+
+ return ts, strings.TrimSpace(body), true
+}