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