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// Start creates a new session bound to path. When a session already exists for
 71// the directory, an AlreadyActiveError is returned containing the existing
 72// session document.
 73func (s *Store) Start(ctx context.Context, path string) (Document, error) {
 74	if s.db == nil {
 75		return Document{}, errors.New("session: database is nil")
 76	}
 77
 78	canonical, hash, err := db.CanonicalizeAndHash(path)
 79	if err != nil {
 80		return Document{}, fmt.Errorf("session: canonicalise path: %w", err)
 81	}
 82
 83	var doc Document
 84	err = s.db.Update(ctx, func(txn *db.Txn) error {
 85		keyActive := db.KeyDirActive(hash)
 86		exists, err := txn.Exists(keyActive)
 87		if err != nil {
 88			return err
 89		}
 90		if exists {
 91			sidBytes, err := txn.Get(keyActive)
 92			if err != nil {
 93				return err
 94			}
 95			sid := string(sidBytes)
 96			metaKey := db.KeySessionMeta(sid)
 97			already, err := txn.Exists(metaKey)
 98			if err != nil {
 99				return err
100			}
101			if !already {
102				return fmt.Errorf("session: active session %q missing metadata", sid)
103			}
104			var existing Document
105			if err := txn.GetJSON(metaKey, &existing); err != nil {
106				return err
107			}
108			return AlreadyActiveError{Session: existing}
109		}
110
111		now := timeutil.EnsureUTC(s.clock.Now())
112		sid, err := newSessionID(now)
113		if err != nil {
114			return err
115		}
116
117		doc = Document{
118			SID:           sid,
119			DirPath:       canonical,
120			DirHash:       hash,
121			State:         StateActive,
122			CreatedAt:     now,
123			ArchivedAt:    nil,
124			LastUpdatedAt: now,
125		}
126
127		if err := txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil {
128			return err
129		}
130		if err := txn.Set(db.KeyDirActive(hash), []byte(sid)); err != nil {
131			return err
132		}
133		if err := txn.Set(db.KeyIdxActive(sid), []byte(hash)); err != nil {
134			return err
135		}
136		if err := txn.Set(db.KeySessionEventSeq(sid), make([]byte, 8)); err != nil {
137			return err
138		}
139		return nil
140	})
141	if err != nil {
142		var already AlreadyActiveError
143		if errors.As(err, &already) {
144			return already.Session, already
145		}
146		return Document{}, err
147	}
148
149	return doc, nil
150}
151
152// Get retrieves a session by SID.
153func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
154	var doc Document
155	err := s.db.View(ctx, func(txn *db.Txn) error {
156		metaKey := db.KeySessionMeta(sid)
157		exists, err := txn.Exists(metaKey)
158		if err != nil {
159			return err
160		}
161		if !exists {
162			return ErrNotFound
163		}
164		return txn.GetJSON(metaKey, &doc)
165	})
166	return doc, err
167}
168
169var crockfordEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
170
171func newSessionID(now time.Time) (string, error) {
172	ms := uint64(now.UnixMilli())
173
174	var data [16]byte
175	data[0] = byte(ms >> 40)
176	data[1] = byte(ms >> 32)
177	data[2] = byte(ms >> 24)
178	data[3] = byte(ms >> 16)
179	data[4] = byte(ms >> 8)
180	data[5] = byte(ms)
181
182	if _, err := io.ReadFull(rand.Reader, data[6:]); err != nil {
183		return "", fmt.Errorf("session: generate randomness: %w", err)
184	}
185
186	id := crockfordEncoding.EncodeToString(data[:])
187	if len(id) != 26 {
188		return "", fmt.Errorf("session: unexpected ulid length %d", len(id))
189	}
190	return id, nil
191}
192
193// ActiveByPath returns the active session for path or any of its parents. The
194// returned boolean reports whether a session was found.
195func (s *Store) ActiveByPath(ctx context.Context, path string) (Document, bool, error) {
196	canonical, err := db.CanonicalizeDir(path)
197	if err != nil {
198		return Document{}, false, fmt.Errorf("session: canonicalise path: %w", err)
199	}
200
201	var doc Document
202	found := false
203
204	err = s.db.View(ctx, func(txn *db.Txn) error {
205		for _, candidate := range db.ParentWalk(canonical) {
206			hash := db.DirHash(candidate)
207			keyActive := db.KeyDirActive(hash)
208
209			exists, err := txn.Exists(keyActive)
210			if err != nil {
211				return err
212			}
213			if !exists {
214				continue
215			}
216
217			sidBytes, err := txn.Get(keyActive)
218			if err != nil {
219				return err
220			}
221			metaKey := db.KeySessionMeta(string(sidBytes))
222			if err := txn.GetJSON(metaKey, &doc); err != nil {
223				return err
224			}
225			found = true
226			return nil
227		}
228		return nil
229	})
230	if err != nil {
231		return Document{}, false, err
232	}
233	return doc, found, nil
234}
235
236// Archive transitions sid to the archived state. When the session is already
237// archived, the stored document is returned without error.
238func (s *Store) Archive(ctx context.Context, sid string) (Document, error) {
239	if s.db == nil {
240		return Document{}, errors.New("session: database is nil")
241	}
242
243	var doc Document
244	err := s.db.Update(ctx, func(txn *db.Txn) error {
245		metaKey := db.KeySessionMeta(sid)
246		exists, err := txn.Exists(metaKey)
247		if err != nil {
248			return err
249		}
250		if !exists {
251			return ErrNotFound
252		}
253		if err := txn.GetJSON(metaKey, &doc); err != nil {
254			return err
255		}
256		if doc.State == StateArchived {
257			return nil
258		}
259
260		now := timeutil.EnsureUTC(s.clock.Now())
261		doc.State = StateArchived
262		doc.LastUpdatedAt = now
263		doc.ArchivedAt = &now
264
265		if err := txn.Delete(db.KeyDirActive(doc.DirHash)); err != nil {
266			return err
267		}
268		if err := txn.Delete(db.KeyIdxActive(doc.SID)); err != nil {
269			return err
270		}
271
272		tsHex := db.Uint64Hex(uint64(now.UnixNano()))
273		if err := txn.Set(db.KeyIdxArchived(tsHex, doc.SID), []byte(doc.DirHash)); err != nil {
274			return err
275		}
276		if err := txn.Set(db.KeyDirArchived(doc.DirHash, tsHex, doc.SID), []byte{}); err != nil {
277			return err
278		}
279		if err := txn.SetJSON(metaKey, doc); err != nil {
280			return err
281		}
282		return nil
283	})
284
285	return doc, err
286}