session.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package session
  6
  7import (
  8	"context"
  9	"crypto/rand"
 10	"encoding/base32"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"time"
 15
 16	"git.secluded.site/np/internal/db"
 17	"git.secluded.site/np/internal/timeutil"
 18)
 19
 20// State captures the lifecycle of a session.
 21type State string
 22
 23const (
 24	// StateActive indicates that the session is currently active for a directory.
 25	StateActive State = "active"
 26	// StateArchived indicates that the session has been archived.
 27	StateArchived State = "archived"
 28)
 29
 30// Document represents the persisted session metadata.
 31type Document struct {
 32	SID           string     `json:"sid"`
 33	DirPath       string     `json:"dir_path"`
 34	DirHash       string     `json:"dir_hash"`
 35	State         State      `json:"state"`
 36	CreatedAt     time.Time  `json:"created_at"`
 37	ArchivedAt    *time.Time `json:"archived_at"`
 38	LastUpdatedAt time.Time  `json:"last_updated_at"`
 39}
 40
 41// ErrNotFound indicates that a session could not be located.
 42var ErrNotFound = errors.New("session: not found")
 43
 44// AlreadyActiveError is returned when attempting to start a session where one already exists.
 45type AlreadyActiveError struct {
 46	Session Document
 47}
 48
 49func (e AlreadyActiveError) Error() string {
 50	return "session: active session already exists for directory"
 51}
 52
 53// Store provides helpers for session lifecycle management.
 54type Store struct {
 55	db    *db.Database
 56	clock timeutil.Clock
 57}
 58
 59// NewStore constructs a Store. When clock is nil, a UTC clock is used.
 60func NewStore(database *db.Database, clock timeutil.Clock) *Store {
 61	if clock == nil {
 62		clock = timeutil.UTCClock{}
 63	}
 64	return &Store{
 65		db:    database,
 66		clock: clock,
 67	}
 68}
 69
 70// WithTxn exposes transactional helpers for use within db.Update.
 71func (s *Store) WithTxn(txn *db.Txn) TxnStore {
 72	clock := s.clock
 73	if clock == nil {
 74		clock = timeutil.UTCClock{}
 75	}
 76	return TxnStore{
 77		txn:   txn,
 78		clock: clock,
 79	}
 80}
 81
 82// TxnStore coordinates session operations within an existing transaction.
 83type TxnStore struct {
 84	txn   *db.Txn
 85	clock timeutil.Clock
 86}
 87
 88// Load retrieves the session document for sid.
 89func (s TxnStore) Load(sid string) (Document, error) {
 90	if s.txn == nil {
 91		return Document{}, errors.New("session: transaction is nil")
 92	}
 93	return loadDocument(s.txn, sid)
 94}
 95
 96// TouchAt updates LastUpdatedAt for sid using at when provided (or the store's clock).
 97func (s TxnStore) TouchAt(sid string, at time.Time) (Document, error) {
 98	if s.txn == nil {
 99		return Document{}, errors.New("session: transaction is nil")
100	}
101
102	if at.IsZero() {
103		clock := s.clock
104		if clock == nil {
105			clock = timeutil.UTCClock{}
106		}
107		at = clock.Now()
108	}
109	at = timeutil.EnsureUTC(at)
110
111	doc, err := loadDocument(s.txn, sid)
112	if err != nil {
113		return Document{}, err
114	}
115	doc.LastUpdatedAt = at
116
117	if err := s.txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil {
118		return Document{}, err
119	}
120	return doc, nil
121}
122
123// Start creates a new session bound to path. When a session already exists for
124// the directory, an AlreadyActiveError is returned containing the existing
125// session document.
126func (s *Store) Start(ctx context.Context, path string) (Document, error) {
127	if s.db == nil {
128		return Document{}, errors.New("session: database is nil")
129	}
130
131	canonical, hash, err := db.CanonicalizeAndHash(path)
132	if err != nil {
133		return Document{}, fmt.Errorf("session: canonicalise path: %w", err)
134	}
135
136	var doc Document
137	err = s.db.Update(ctx, func(txn *db.Txn) error {
138		keyActive := db.KeyDirActive(hash)
139		exists, err := txn.Exists(keyActive)
140		if err != nil {
141			return err
142		}
143		if exists {
144			sidBytes, err := txn.Get(keyActive)
145			if err != nil {
146				return err
147			}
148			sid := string(sidBytes)
149			existing, err := loadDocument(txn, sid)
150			if err != nil {
151				return err
152			}
153			return AlreadyActiveError{Session: existing}
154		}
155
156		now := timeutil.EnsureUTC(s.clock.Now())
157		sid, err := newSessionID(now)
158		if err != nil {
159			return err
160		}
161
162		doc = Document{
163			SID:           sid,
164			DirPath:       canonical,
165			DirHash:       hash,
166			State:         StateActive,
167			CreatedAt:     now,
168			ArchivedAt:    nil,
169			LastUpdatedAt: now,
170		}
171
172		if err := txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil {
173			return err
174		}
175		if err := txn.Set(db.KeyDirActive(hash), []byte(sid)); err != nil {
176			return err
177		}
178		if err := txn.Set(db.KeyIdxActive(sid), []byte(hash)); err != nil {
179			return err
180		}
181		if err := txn.Set(db.KeySessionEventSeq(sid), make([]byte, 8)); err != nil {
182			return err
183		}
184		return nil
185	})
186	if err != nil {
187		var already AlreadyActiveError
188		if errors.As(err, &already) {
189			return already.Session, already
190		}
191		return Document{}, err
192	}
193
194	return doc, nil
195}
196
197// Get retrieves a session by SID.
198func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
199	var doc Document
200	err := s.db.View(ctx, func(txn *db.Txn) error {
201		var err error
202		doc, err = loadDocument(txn, sid)
203		return err
204	})
205	return doc, err
206}
207
208func loadDocument(txn *db.Txn, sid string) (Document, error) {
209	if txn == nil {
210		return Document{}, errors.New("session: transaction is nil")
211	}
212	key := db.KeySessionMeta(sid)
213	exists, err := txn.Exists(key)
214	if err != nil {
215		return Document{}, err
216	}
217	if !exists {
218		return Document{}, ErrNotFound
219	}
220
221	var doc Document
222	if err := txn.GetJSON(key, &doc); err != nil {
223		return Document{}, err
224	}
225	if doc.SID == "" {
226		doc.SID = sid
227	}
228	return doc, nil
229}
230
231var crockfordEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
232
233func newSessionID(now time.Time) (string, error) {
234	ms := uint64(now.UnixMilli())
235
236	var data [16]byte
237	data[0] = byte(ms >> 40)
238	data[1] = byte(ms >> 32)
239	data[2] = byte(ms >> 24)
240	data[3] = byte(ms >> 16)
241	data[4] = byte(ms >> 8)
242	data[5] = byte(ms)
243
244	if _, err := io.ReadFull(rand.Reader, data[6:]); err != nil {
245		return "", fmt.Errorf("session: generate randomness: %w", err)
246	}
247
248	id := crockfordEncoding.EncodeToString(data[:])
249	if len(id) != 26 {
250		return "", fmt.Errorf("session: unexpected ulid length %d", len(id))
251	}
252	return id, nil
253}
254
255// ActiveByPath returns the active session for path or any of its parents. The
256// returned boolean reports whether a session was found.
257func (s *Store) ActiveByPath(ctx context.Context, path string) (Document, bool, error) {
258	canonical, err := db.CanonicalizeDir(path)
259	if err != nil {
260		return Document{}, false, fmt.Errorf("session: canonicalise path: %w", err)
261	}
262
263	var doc Document
264	found := false
265
266	err = s.db.View(ctx, func(txn *db.Txn) error {
267		for _, candidate := range db.ParentWalk(canonical) {
268			hash := db.DirHash(candidate)
269			keyActive := db.KeyDirActive(hash)
270
271			exists, err := txn.Exists(keyActive)
272			if err != nil {
273				return err
274			}
275			if !exists {
276				continue
277			}
278
279			sidBytes, err := txn.Get(keyActive)
280			if err != nil {
281				return err
282			}
283			loaded, err := loadDocument(txn, string(sidBytes))
284			if err != nil {
285				if errors.Is(err, ErrNotFound) {
286					continue
287				}
288				return err
289			}
290			doc = loaded
291			found = true
292			return nil
293		}
294		return nil
295	})
296	if err != nil {
297		return Document{}, false, err
298	}
299	return doc, found, nil
300}
301
302// Archive transitions sid to the archived state. When the session is already
303// archived, the stored document is returned without error.
304func (s *Store) Archive(ctx context.Context, sid string) (Document, error) {
305	if s.db == nil {
306		return Document{}, errors.New("session: database is nil")
307	}
308
309	var doc Document
310	err := s.db.Update(ctx, func(txn *db.Txn) error {
311		var err error
312		doc, err = loadDocument(txn, sid)
313		if err != nil {
314			return err
315		}
316		if doc.State == StateArchived {
317			return nil
318		}
319
320		now := timeutil.EnsureUTC(s.clock.Now())
321		doc.State = StateArchived
322		doc.LastUpdatedAt = now
323		doc.ArchivedAt = &now
324
325		if err := txn.Delete(db.KeyDirActive(doc.DirHash)); err != nil {
326			return err
327		}
328		if err := txn.Delete(db.KeyIdxActive(doc.SID)); err != nil {
329			return err
330		}
331
332		tsHex := db.Uint64Hex(uint64(now.UnixNano()))
333		if err := txn.Set(db.KeyIdxArchived(tsHex, doc.SID), []byte(doc.DirHash)); err != nil {
334			return err
335		}
336		if err := txn.Set(db.KeyDirArchived(doc.DirHash, tsHex, doc.SID), []byte{}); err != nil {
337			return err
338		}
339		if err := txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil {
340			return err
341		}
342		return nil
343	})
344
345	return doc, err
346}