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}