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}