user.go
1package user
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "os"
10 "path"
11 "sync"
12 "time"
13
14 "git.kilimanjaro.io/rtw/pkg/abac"
15 "git.kilimanjaro.io/rtw/pkg/id"
16 "git.kilimanjaro.io/rtw/pkg/session"
17 "git.kilimanjaro.io/rtw/pkg/user"
18 _ "github.com/mattn/go-sqlite3"
19)
20
21var (
22 ErrPathNotExist = fmt.Errorf("path does not exist")
23 ErrLoginFailed = fmt.Errorf("user login failed")
24 ErrMissingEmailOrHandle = fmt.Errorf("account requires email or handle")
25 ErrMissingPassword = fmt.Errorf("account requires password")
26 ErrSystemIssue = fmt.Errorf("database operation failed")
27 ErrNotAnonymous = fmt.Errorf("existing session is not anonymous")
28 ErrUserNotFound = fmt.Errorf("user not found")
29 // reexport errors from the lower level package
30 ErrInvalidHandle error = user.ErrInvalidHandle
31 ErrInvalidEmail error = user.ErrInvalidEmail
32 ErrPasswordMismatch error = user.ErrPasswordMismatch
33 ErrHandleExists error = user.ErrHandleExists
34 ErrAccountExists error = user.ErrAccountExists
35 ErrHandleNotFound error = user.ErrHandleNotFound
36)
37
38type User struct {
39 ID id.Key `json:"id"`
40 Email string `json:"email"`
41 Handle string `json:"handle"`
42 Registered bool `json:"registered"`
43}
44
45type Service struct {
46 basepath string
47 mu *sync.RWMutex
48 db *sql.DB
49 expires time.Duration
50}
51
52type ServiceOption func(u *Service) error
53
54// WithBasePath sets the base path for the user service database
55func WithBasePath(path string) ServiceOption {
56 return func(u *Service) error {
57 if _, err := os.Stat(path); os.IsNotExist(err) {
58 return fmt.Errorf("path=%s does not exist; create it first: %w", path, ErrPathNotExist)
59 }
60 u.basepath = path
61 return nil
62 }
63}
64
65// WithExpiry sets the default session length
66func WithExpiry(d time.Duration) ServiceOption {
67 return func(u *Service) error {
68 u.expires = d
69 return nil
70 }
71}
72
73// NewService creates or opens a database containing user, ABAC, and sessions.
74func NewService(opts ...ServiceOption) (*Service, error) {
75 us := &Service{
76 mu: &sync.RWMutex{},
77 expires: time.Hour * 24 * 30, // 30 days by default
78 }
79 for _, opt := range opts {
80 if err := opt(us); err != nil {
81 return nil, fmt.Errorf("failed to create user service: %w", err)
82 }
83 }
84 dataDir := path.Join(us.basepath, "data")
85 if err := os.MkdirAll(dataDir, 0755); err != nil {
86 return nil, fmt.Errorf("failed to create data directory: %w", err)
87 }
88 db, err := sql.Open("sqlite3", "file:"+path.Join(dataDir, "users.db")+"?_journal=WAL&_timeout=5000&_fk=1&cache=shared")
89 if err != nil {
90 return nil, fmt.Errorf("error opening database: %w", err)
91 }
92 us.db = db
93 if err := user.Migrate(db); err != nil {
94 return nil, fmt.Errorf("failed to migrate user database: %w", err)
95 }
96 if err := abac.Migrate(db); err != nil {
97 return nil, fmt.Errorf("failed to migrate abac database: %w", err)
98 }
99 if err := session.Migrate(db); err != nil {
100 return nil, fmt.Errorf("failed to migrate session database: %w", err)
101 }
102 return us, nil
103}
104
105// DB returns the underlying database connection
106func (u *Service) DB() *sql.DB {
107 return u.db
108}
109
110// Shutdown fulfills the service interface
111func (u *Service) Shutdown() error {
112 u.mu.Lock()
113 defer u.mu.Unlock()
114 if u.db == nil {
115 return nil
116 }
117 return u.db.Close()
118}
119
120// Login a user
121func (u *Service) Login(emailOrHandle string, password string) (id.Key, error) {
122 u.mu.RLock()
123 defer u.mu.RUnlock()
124
125 n, err := user.Login[id.Key](u.db, emailOrHandle, password)
126 if err != nil {
127 slog.Info("login failed", "user", emailOrHandle)
128 return id.NotExist, fmt.Errorf("login failed: %w: %w", ErrLoginFailed, err)
129 }
130 return n, nil
131}
132
133// GetUserByID returns the user associated with id
134func (u *Service) GetUserByID(n id.Key) (User, error) {
135 email, handle, err := user.GetEmailAndHandle(u.db, n)
136 if err != nil {
137 slog.Error("error getting user details", "user_id", n)
138 return User{}, fmt.Errorf("error getting user details: %w", err)
139 }
140 reg, err := user.IsRegistered(u.db, n)
141 if err != nil {
142 slog.Error("error getting registration", "user_id", n)
143 return User{}, fmt.Errorf("error getting user registration: %w", err)
144 }
145 return User{
146 ID: n,
147 Email: email,
148 Handle: handle,
149 Registered: reg,
150 }, nil
151}
152
153// Signup creates a user account with an email or handle. If either is not defined, pass nil.
154// Must have at least one of email or handle + password.
155func (u *Service) Signup(email *string, handle *string, password string) (id.Key, error) {
156 u.mu.Lock()
157 defer u.mu.Unlock()
158
159 if password == "" {
160 return id.NotExist, ErrMissingPassword
161 }
162
163 n := id.New()
164 opts := []user.Option{user.Password(password)}
165 if email != nil && *email != "" {
166 if err := user.CheckEmail(u.db, *email); err != nil {
167 return id.NotExist, err
168 }
169 opts = append(opts, user.Email(*email))
170 }
171 if handle != nil && *handle != "" {
172 if err := user.CheckHandle(u.db, *handle); err != nil {
173 return id.NotExist, err
174 }
175 opts = append(opts, user.Handle(*handle))
176 }
177 if len(opts) == 1 {
178 // neither an email or a handle was added
179 return id.NotExist, ErrMissingEmailOrHandle
180 }
181 if err := user.Set(u.db, n, opts...); err != nil {
182 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
183 }
184 return n, nil
185}
186
187// SignupWithExistingSession turns an anonymous user account into a registered account.
188// The existingUserID must be an anonymous user; it is updated with email/handle/password
189// and remains the same user ID. No new user is created.
190func (u *Service) SignupWithExistingSession(email *string, handle *string, password string, existingUserID id.Key) (id.Key, error) {
191 u.mu.Lock()
192 defer u.mu.Unlock()
193
194 if password == "" {
195 return id.NotExist, ErrMissingPassword
196 }
197
198 // Verify the user exists
199 _, _, err := user.GetEmailAndHandle(u.db, existingUserID)
200 if err != nil {
201 if errors.Is(err, sql.ErrNoRows) {
202 return id.NotExist, ErrUserNotFound
203 }
204 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
205 }
206
207 // Verify the user is actually anonymous
208 isAnon, err := user.IsAnonymous(u.db, existingUserID)
209 if err != nil {
210 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
211 }
212 if !isAnon {
213 return id.NotExist, ErrNotAnonymous
214 }
215
216 opts := []user.Option{user.Password(password)}
217 if email != nil && *email != "" {
218 if err := user.CheckEmail(u.db, *email); err != nil {
219 return id.NotExist, err
220 }
221 opts = append(opts, user.Email(*email))
222 }
223 if handle != nil && *handle != "" {
224 if err := user.CheckHandle(u.db, *handle); err != nil {
225 return id.NotExist, err
226 }
227 opts = append(opts, user.Handle(*handle))
228 }
229 if len(opts) == 1 {
230 // neither an email nor a handle was added
231 return id.NotExist, ErrMissingEmailOrHandle
232 }
233
234 if err := user.Set(u.db, existingUserID, opts...); err != nil {
235 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
236 }
237 return existingUserID, nil
238}
239
240// LoginWithExistingSession authenticates a user and migrates permissions from an anonymous user.
241// The anonymous user is deleted after successful migration. The caller must create a new session.
242func (u *Service) LoginWithExistingSession(emailOrHandle string, password string, anonUserID id.Key) (id.Key, error) {
243 u.mu.Lock()
244 defer u.mu.Unlock()
245
246 // Verify the anonymous user exists
247 isAnon, err := user.IsAnonymous(u.db, anonUserID)
248 if err != nil {
249 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
250 }
251 if !isAnon {
252 return id.NotExist, ErrNotAnonymous
253 }
254
255 // Authenticate the registered user
256 userID, err := user.Login[id.Key](u.db, emailOrHandle, password)
257 if err != nil {
258 slog.Info("login failed", "user", emailOrHandle)
259 return id.NotExist, fmt.Errorf("login failed: %w: %w", ErrLoginFailed, err)
260 }
261
262 // Migrate ABAC permissions from anonymous to logged-in user
263 if err := u.migratePermissions(anonUserID, userID); err != nil {
264 return id.NotExist, err
265 }
266
267 // Delete the anonymous user (cascades to sessions via FK ON DELETE CASCADE)
268 if _, err := u.db.Exec("DELETE FROM users WHERE id = ?", int64(anonUserID)); err != nil {
269 return id.NotExist, fmt.Errorf("system error deleting anonymous user: %w", err)
270 }
271
272 return userID, nil
273}
274
275func (u *Service) migratePermissions(anonUserID id.Key, userID id.Key) error {
276 perms, err := abac.GetPermissionsForUser(u.db, int64(anonUserID))
277 if err != nil {
278 return fmt.Errorf("error querying anonymous permissions: %w", err)
279 }
280
281 for _, p := range perms {
282 // Check if logged-in user already has permission for this resource
283 var existingPerm int64
284 err = u.db.QueryRow(
285 "SELECT permissions FROM permissions WHERE resource_type = ? AND resource_id = ? AND user_id = ?",
286 p.ResourceType, p.ResourceID, int64(userID),
287 ).Scan(&existingPerm)
288
289 if err == nil {
290 // Conflict exists — merge permissions via bitwise OR
291 newPerm := existingPerm | int64(p.Permission)
292 _, err = u.db.Exec(
293 "UPDATE permissions SET permissions = ? WHERE resource_type = ? AND resource_id = ? AND user_id = ?",
294 newPerm, p.ResourceType, p.ResourceID, int64(userID),
295 )
296 if err != nil {
297 return fmt.Errorf("error updating permission: %w", err)
298 }
299 // Delete the anonymous permission row
300 _, err = u.db.Exec(
301 "DELETE FROM permissions WHERE resource_type = ? AND resource_id = ? AND user_id = ?",
302 p.ResourceType, p.ResourceID, int64(anonUserID),
303 )
304 if err != nil {
305 return fmt.Errorf("error deleting anonymous permission: %w", err)
306 }
307 } else if errors.Is(err, sql.ErrNoRows) {
308 // No conflict — reassign the permission row to the logged-in user
309 _, err = u.db.Exec(
310 "UPDATE permissions SET user_id = ? WHERE resource_type = ? AND resource_id = ? AND user_id = ?",
311 int64(userID), p.ResourceType, p.ResourceID, int64(anonUserID),
312 )
313 if err != nil {
314 return fmt.Errorf("error migrating permission: %w", err)
315 }
316 } else {
317 return fmt.Errorf("error checking existing permission: %w", err)
318 }
319 }
320 return nil
321}
322
323// CheckHandle returns an error on invalid handle, already exists, or
324// server error. Err == nil means handle is valid and available.
325func (u *Service) CheckHandle(handle string) error {
326 u.mu.RLock()
327 defer u.mu.RUnlock()
328
329 err := user.CheckHandle(u.db, handle)
330 if err != nil {
331 if errors.Is(err, user.ErrInvalidHandle) || errors.Is(err, user.ErrHandleExists) {
332 return err
333 }
334 return fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
335 }
336 return nil
337}
338
339func (u *Service) CreateNewUserSession(userID id.Key, w http.ResponseWriter) (id.Key, error) {
340 cookie := session.NewID()
341 if err := session.Persist(u.db, cookie, userID, time.Now().UTC().Add(time.Hour*24*30)); err != nil {
342 return userID, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
343 }
344 session.SetCookie(w, cookie, time.Hour*24*30)
345 return userID, nil
346}
347
348func (u *Service) CreateNewAnonSession(w http.ResponseWriter) (id.Key, error) {
349 cookie := session.NewID()
350 n := id.New()
351 if err := user.Set(u.db, n); err != nil {
352 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
353 }
354 if err := session.Persist(u.db, cookie, n, time.Now().UTC().Add(time.Hour*24*30)); err != nil {
355 return id.NotExist, fmt.Errorf("system error: %w: %w", ErrSystemIssue, err)
356 }
357 session.SetCookie(w, cookie, time.Hour*24*30)
358 return n, nil
359}
360
361func (u *Service) HomeURL(userID id.Key) string {
362 return fmt.Sprintf("/u/%s", userID.Compact())
363}
364
365// HasValidRegisteredSession returns the user ID iff a valid session exists for a registered user (non-anon)
366func (u *Service) HasValidRegisteredSession(r *http.Request) (session.Info[id.Key], bool) {
367 userInfo, err := session.FromContext[id.Key](r.Context())
368 if err == nil && userInfo.ID != 0 && userInfo.Type == session.Registered {
369 return userInfo, true
370 }
371 return session.Info[id.Key]{}, false
372}
373
374// HasValidSession returns the user ID if a valid sessions exists of any type (registered or anon)
375func (u *Service) HasValidSession(r *http.Request) (session.Info[id.Key], bool) {
376 userInfo, err := session.FromContext[id.Key](r.Context())
377 if err == nil && userInfo.ID != 0 {
378 return userInfo, true
379 }
380 return session.Info[id.Key]{}, false
381}
382
383// SetDocumentPermission sets permissions for this document for a user
384func (u *Service) SetDocumentPermission(resourceID id.Key, userID id.Key, p abac.Permission) error {
385 if err := abac.SetPermissionForUser(u.db, "document", int64(resourceID), int64(userID), p); err != nil {
386 return fmt.Errorf("error setting document permission: %w", err)
387 }
388 return nil
389}
390
391func (u *Service) GetDocumentPermission(resourceID id.Key, userID id.Key) (abac.Permission, error) {
392 return abac.GetPermission(u.db, "document", int64(resourceID), int64(userID))
393}
394
395// GetDocumentsForUser returns all documents where the user has at least write permission
396func (u *Service) GetDocumentsForUser(userID id.Key) ([]abac.UserPermission, error) {
397 ups, err := abac.GetPermissionsForUser(u.db, int64(userID))
398 if err != nil {
399 return nil, fmt.Errorf("error getting documents for user: %w", err)
400 }
401 var out []abac.UserPermission
402 for _, up := range ups {
403 if up.ResourceType == "document" && abac.Has(up.Permission, abac.Write) {
404 out = append(out, up)
405 }
406 }
407 return out, nil
408}