oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "context"
5 "crypto/rand"
6 "errors"
7 "fmt"
8 "regexp"
9 "strings"
10 "time"
11
12 "gorm.io/gorm"
13)
14
15// ErrHandleTaken is another person already holding the handle. Its own
16// error rather than a ValidationError because the form's message for
17// it is different ("taken", not "not allowed") and because a race
18// between two people choosing the same handle at once surfaces here
19// from the unique index, not from a check the handler could run first.
20var ErrHandleTaken = errors.New("calendar: handle taken")
21
22// reHandle is the handle grammar after lowercasing: the characters a
23// URL path segment can carry without escaping, at a length somebody
24// can read out loud.
25var reHandle = regexp.MustCompile(`^[a-z0-9-]{2,32}$`)
26
27// reservedHandles are path segments under /book/ that are not people.
28// /book/m/{token} is the management page; the others are refused so a
29// later route can take them without stranding somebody's URL.
30var reservedHandles = map[string]bool{"m": true, "new": true, "static": true}
31
32// normalizeHandle is the spelling that is stored and compared.
33func normalizeHandle(h string) string {
34 return strings.ToLower(strings.TrimSpace(h))
35}
36
37// ValidateHandle reports why h cannot be a handle, after lowercasing.
38// An empty handle is not valid here; SaveHandle treats it as "clear".
39func ValidateHandle(h string) error {
40 h = normalizeHandle(h)
41 if !reHandle.MatchString(h) {
42 return ValidationError{"handle", "Use 2 to 32 of a-z, 0-9 and dashes."}
43 }
44 if reservedHandles[h] {
45 return ValidationError{"handle", "That one is taken by the app itself. Pick another."}
46 }
47 return nil
48}
49
50// SaveHandle sets a person's handle, lowercased, or clears it when
51// handle is empty.
52//
53// The unique index is the arbiter of "taken", not a read-then-write:
54// two people saving the same handle at the same moment would both read
55// "free". The pre-check below is only so the common case gives a clean
56// answer without relying on the driver's error text; the index catches
57// the race.
58func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error {
59 h := normalizeHandle(handle)
60 now := time.Now().UTC()
61 if h == "" {
62 err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
63 Updates(map[string]any{"handle": nil, "updated_at": now}).Error
64 if err != nil {
65 return fmt.Errorf("calendar: clear handle: %w", err)
66 }
67 return nil
68 }
69 if err := ValidateHandle(h); err != nil {
70 return err
71 }
72 if p, err := s.PersonByHandle(ctx, h); err == nil && p.ID != personID {
73 return ErrHandleTaken
74 } else if err != nil && !NotFound(err) {
75 return err
76 }
77 err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
78 Updates(map[string]any{"handle": h, "updated_at": now}).Error
79 if err != nil {
80 if isUniqueViolation(err) {
81 return ErrHandleTaken
82 }
83 return fmt.Errorf("calendar: save handle: %w", err)
84 }
85 return nil
86}
87
88// isUniqueViolation recognises the SQLite driver's constraint error.
89// By text, because the cgo-free driver does not expose a typed code
90// through database/sql and GORM's translation is not switched on here.
91func isUniqueViolation(err error) bool {
92 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
93}
94
95// PersonByHandle resolves a public URL's first segment. A miss is
96// ErrNotFound, the same answer an unknown code or an unpublished page
97// gives, so the three cannot be told apart from outside.
98func (s *Store) PersonByHandle(ctx context.Context, handle string) (Person, error) {
99 h := normalizeHandle(handle)
100 if h == "" {
101 return Person{}, ErrNotFound
102 }
103 var p Person
104 err := s.g.WithContext(ctx).Where("handle = ?", h).First(&p).Error
105 if errors.Is(err, gorm.ErrRecordNotFound) {
106 return Person{}, ErrNotFound
107 }
108 if err != nil {
109 return Person{}, fmt.Errorf("calendar: person by handle: %w", err)
110 }
111 return p, nil
112}
113
114// pageCodeAlphabet is what a page code is spelled from: lowercase and
115// digits, so the URL survives being read aloud or typed on a phone.
116const pageCodeAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
117
118// newPageCode is eight characters of pageCodeAlphabet from crypto/rand.
119//
120// Rejection sampling rather than a bare modulo: 256 is not a multiple
121// of 36, so `b % 36` would favour the first four letters. The code is
122// not a secret, but a biased generator is a bug that would look fine
123// forever, and the fix costs a few extra bytes of randomness.
124func newPageCode() (string, error) {
125 const n = 8
126 const limit = 252 // the largest multiple of 36 that fits a byte
127 out := make([]byte, 0, n)
128 buf := make([]byte, 2*n)
129 for len(out) < n {
130 if _, err := rand.Read(buf); err != nil {
131 return "", fmt.Errorf("calendar: no randomness for a page code: %w", err)
132 }
133 for _, b := range buf {
134 if b < limit && len(out) < n {
135 out = append(out, pageCodeAlphabet[int(b)%len(pageCodeAlphabet)])
136 }
137 }
138 }
139 return string(out), nil
140}
141