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}