store.go (9840 bytes)
1 package main 2 3 import ( 4 "crypto/rand" 5 "crypto/sha256" 6 "encoding/hex" 7 "encoding/json" 8 "errors" 9 "io/fs" 10 "net/mail" 11 "os" 12 "path/filepath" 13 "regexp" 14 "sort" 15 "strings" 16 "sync" 17 "time" 18 19 "golang.org/x/crypto/bcrypt" 20 ) 21 22 const ( 23 maxTokens = 20 // sign-ins kept per account; the oldest goes first 24 maxVersions = 50 // versions of the bookmark file kept per account 25 ) 26 27 var ( 28 errTaken = errors.New("an account with this email exists already") 29 errEmail = errors.New("this is not an email address") 30 errPassword = errors.New("the password must have 8 to 72 characters") 31 errWrong = errors.New("wrong email or password") 32 errGone = errors.New("the account does not exist") 33 hashName = regexp.MustCompile(`^[0-9a-f]{64}$`) 34 ) 35 36 // Account is one user. The file of the user is in files/<ID>. 37 type Account struct { 38 ID string `json:"id"` 39 Email string `json:"email"` 40 Hash string `json:"hash"` // bcrypt hash of the password 41 Created time.Time `json:"created"` 42 Tokens []string `json:"tokens"` // SHA-256 of each sign-in token 43 Customer string `json:"customer,omitempty"` 44 Status string `json:"status,omitempty"` // of the Stripe subscription 45 StatusAt int64 `json:"status_at,omitempty"` // time of the Stripe event that set Status 46 } 47 48 // Store keeps the accounts in one JSON file, accounts.json, and the versions 49 // of each bookmark file in files/<account ID>/<SHA-256 of the text>. The file 50 // HEAD beside the versions names the current one. 51 type Store struct { 52 dir string 53 mu sync.Mutex // for the maps, the accounts and accounts.json 54 accounts map[string]*Account 55 byEmail map[string]*Account 56 byToken map[string]*Account 57 locks map[string]*sync.Mutex // one for the files of each account 58 } 59 60 func openStore(dir string) (*Store, error) { 61 if err := os.MkdirAll(filepath.Join(dir, "files"), 0o700); err != nil { 62 return nil, err 63 } 64 s := &Store{ 65 dir: dir, 66 accounts: map[string]*Account{}, 67 byEmail: map[string]*Account{}, 68 byToken: map[string]*Account{}, 69 locks: map[string]*sync.Mutex{}, 70 } 71 b, err := os.ReadFile(filepath.Join(dir, "accounts.json")) 72 if errors.Is(err, fs.ErrNotExist) { 73 return s, nil 74 } else if err != nil { 75 return nil, err 76 } 77 var list []*Account 78 if err := json.Unmarshal(b, &list); err != nil { 79 return nil, err 80 } 81 for _, a := range list { 82 s.index(a) 83 } 84 return s, nil 85 } 86 87 func (s *Store) index(a *Account) { 88 s.accounts[a.ID] = a 89 s.byEmail[a.Email] = a 90 for _, t := range a.Tokens { 91 s.byToken[t] = a 92 } 93 } 94 95 // save writes accounts.json. The caller holds s.mu. 96 func (s *Store) save() error { 97 list := make([]*Account, 0, len(s.accounts)) 98 for _, a := range s.accounts { 99 list = append(list, a) 100 } 101 sort.Slice(list, func(i, j int) bool { return list[i].Created.Before(list[j].Created) }) 102 b, err := json.MarshalIndent(list, "", "\t") 103 if err != nil { 104 return err 105 } 106 return writeFile(filepath.Join(s.dir, "accounts.json"), b) 107 } 108 109 // writeFile replaces a file in one step: a crash leaves the old file or the 110 // new one, never a part of one. 111 func writeFile(name string, data []byte) error { 112 tmp := name + ".tmp" 113 f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) 114 if err != nil { 115 return err 116 } 117 _, err = f.Write(data) 118 if err == nil { 119 err = f.Sync() 120 } 121 if cerr := f.Close(); err == nil { 122 err = cerr 123 } 124 if err != nil { 125 os.Remove(tmp) 126 return err 127 } 128 return os.Rename(tmp, name) 129 } 130 131 func random(n int) string { 132 b := make([]byte, n) 133 if _, err := rand.Read(b); err != nil { 134 panic(err) // crypto/rand does not fail on supported systems 135 } 136 return hex.EncodeToString(b) 137 } 138 139 func digest(s string) string { 140 sum := sha256.Sum256([]byte(s)) 141 return hex.EncodeToString(sum[:]) 142 } 143 144 // cleanEmail gives the email address in the form that the store keeps. 145 func cleanEmail(email string) (string, error) { 146 email = strings.ToLower(strings.TrimSpace(email)) 147 a, err := mail.ParseAddress(email) 148 if err != nil || a.Name != "" || a.Address != email || len(email) > 254 { 149 return "", errEmail 150 } 151 return email, nil 152 } 153 154 func (s *Store) signup(email, password string) (*Account, error) { 155 email, err := cleanEmail(email) 156 if err != nil { 157 return nil, err 158 } 159 if len(password) < 8 || len(password) > 72 { 160 return nil, errPassword 161 } 162 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 163 if err != nil { 164 return nil, err 165 } 166 s.mu.Lock() 167 defer s.mu.Unlock() 168 if s.byEmail[email] != nil { 169 return nil, errTaken 170 } 171 a := &Account{ID: random(16), Email: email, Hash: string(hash), Created: time.Now().UTC()} 172 s.index(a) 173 if err := s.save(); err != nil { 174 delete(s.accounts, a.ID) 175 delete(s.byEmail, a.Email) 176 return nil, err 177 } 178 return a, nil 179 } 180 181 // dummy is compared when the email is unknown, so that a wrong email takes 182 // as long as a wrong password. 183 var dummy, _ = bcrypt.GenerateFromPassword([]byte("sbm-sync dummy password"), bcrypt.DefaultCost) 184 185 func (s *Store) check(email, password string) (*Account, error) { 186 email = strings.ToLower(strings.TrimSpace(email)) 187 s.mu.Lock() 188 a := s.byEmail[email] 189 hash := dummy 190 if a != nil { 191 hash = []byte(a.Hash) 192 } 193 s.mu.Unlock() 194 if bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil || a == nil { 195 return nil, errWrong 196 } 197 return a, nil 198 } 199 200 // signIn gives a new token for the account. Only its hash is kept. 201 func (s *Store) signIn(a *Account) (string, error) { 202 token := random(32) 203 s.mu.Lock() 204 defer s.mu.Unlock() 205 if s.accounts[a.ID] == nil { 206 return "", errGone 207 } 208 a.Tokens = append(a.Tokens, digest(token)) 209 s.byToken[digest(token)] = a 210 for len(a.Tokens) > maxTokens { 211 delete(s.byToken, a.Tokens[0]) 212 a.Tokens = a.Tokens[1:] 213 } 214 return token, s.save() 215 } 216 217 func (s *Store) signOut(token string) error { 218 h := digest(token) 219 s.mu.Lock() 220 defer s.mu.Unlock() 221 a := s.byToken[h] 222 if a == nil { 223 return nil 224 } 225 delete(s.byToken, h) 226 for i, t := range a.Tokens { 227 if t == h { 228 a.Tokens = append(a.Tokens[:i], a.Tokens[i+1:]...) 229 break 230 } 231 } 232 return s.save() 233 } 234 235 func (s *Store) byTok(token string) *Account { 236 if token == "" { 237 return nil 238 } 239 s.mu.Lock() 240 defer s.mu.Unlock() 241 return s.byToken[digest(token)] 242 } 243 244 func (s *Store) byID(id string) *Account { 245 s.mu.Lock() 246 defer s.mu.Unlock() 247 return s.accounts[id] 248 } 249 250 func (s *Store) byCustomer(customer string) *Account { 251 if customer == "" { 252 return nil 253 } 254 s.mu.Lock() 255 defer s.mu.Unlock() 256 for _, a := range s.accounts { 257 if a.Customer == customer { 258 return a 259 } 260 } 261 return nil 262 } 263 264 // view gives a copy of the account, safe to read without the lock. 265 func (s *Store) view(a *Account) Account { 266 s.mu.Lock() 267 defer s.mu.Unlock() 268 c := *a 269 c.Tokens = nil 270 return c 271 } 272 273 func (s *Store) setCustomer(a *Account, customer string) error { 274 s.mu.Lock() 275 defer s.mu.Unlock() 276 a.Customer = customer 277 return s.save() 278 } 279 280 // setStatus records the state of the subscription, unless the store has a 281 // newer event already: Stripe does not send events in order. 282 func (s *Store) setStatus(a *Account, status string, at int64) error { 283 s.mu.Lock() 284 defer s.mu.Unlock() 285 if at < a.StatusAt { 286 return nil 287 } 288 a.Status, a.StatusAt = status, at 289 return s.save() 290 } 291 292 func (s *Store) lock(id string) *sync.Mutex { 293 s.mu.Lock() 294 defer s.mu.Unlock() 295 m := s.locks[id] 296 if m == nil { 297 m = &sync.Mutex{} 298 s.locks[id] = m 299 } 300 return m 301 } 302 303 // remove deletes the account and all versions of its file. 304 func (s *Store) remove(a *Account) error { 305 m := s.lock(a.ID) 306 m.Lock() 307 defer m.Unlock() 308 s.mu.Lock() 309 delete(s.accounts, a.ID) 310 delete(s.byEmail, a.Email) 311 for _, t := range a.Tokens { 312 delete(s.byToken, t) 313 } 314 err := s.save() 315 s.mu.Unlock() 316 if err != nil { 317 return err 318 } 319 return os.RemoveAll(filepath.Join(s.dir, "files", a.ID)) 320 } 321 322 // sync merges the file that a device sends into the current version, keeps 323 // the result as the new current version and gives it with its name. base 324 // names the version that the device got last time, or is "" for none. 325 func (s *Store) sync(a *Account, base, local string) (string, string, error) { 326 m := s.lock(a.ID) 327 m.Lock() 328 defer m.Unlock() 329 if s.byID(a.ID) == nil { 330 return "", "", errGone 331 } 332 dir := filepath.Join(s.dir, "files", a.ID) 333 if err := os.MkdirAll(dir, 0o700); err != nil { 334 return "", "", err 335 } 336 remote := "" 337 head, err := os.ReadFile(filepath.Join(dir, "HEAD")) 338 if err == nil { 339 b, err := os.ReadFile(filepath.Join(dir, strings.TrimSpace(string(head)))) 340 if err != nil { 341 return "", "", err 342 } 343 remote = string(b) 344 } else if !errors.Is(err, fs.ErrNotExist) { 345 return "", "", err 346 } 347 old := "" 348 if hashName.MatchString(base) { 349 if b, err := os.ReadFile(filepath.Join(dir, base)); err == nil { 350 old = string(b) 351 } 352 } 353 merged := merge(old, local, remote) 354 name := digest(merged) 355 path := filepath.Join(dir, name) 356 if _, err := os.Stat(path); err == nil { 357 now := time.Now() 358 err = os.Chtimes(path, now, now) // the newest versions are kept 359 if err != nil { 360 return "", "", err 361 } 362 } else if err := writeFile(path, []byte(merged)); err != nil { 363 return "", "", err 364 } 365 if err := writeFile(filepath.Join(dir, "HEAD"), []byte(name+"\n")); err != nil { 366 return "", "", err 367 } 368 return merged, name, prune(dir, name) 369 } 370 371 // prune deletes all but the newest versions in dir, and never keep. 372 func prune(dir, keep string) error { 373 entries, err := os.ReadDir(dir) 374 if err != nil { 375 return err 376 } 377 type version struct { 378 name string 379 time time.Time 380 } 381 var vs []version 382 for _, e := range entries { 383 if !hashName.MatchString(e.Name()) || e.Name() == keep { 384 continue 385 } 386 info, err := e.Info() 387 if err != nil { 388 return err 389 } 390 vs = append(vs, version{e.Name(), info.ModTime()}) 391 } 392 sort.Slice(vs, func(i, j int) bool { return vs[i].time.After(vs[j].time) }) 393 for i := maxVersions - 1; i < len(vs); i++ { 394 if err := os.Remove(filepath.Join(dir, vs[i].name)); err != nil { 395 return err 396 } 397 } 398 return nil 399 }