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