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 (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 }