Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


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 }