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}