// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "context" "strings" "time" "gamertan.com/web/auth" "gamertan.com/web/organizations" ) var _ auth.UserDirectoryRepository = (*Store)(nil) var _ organizations.DirectoryRepository = (*Store)(nil) // UserDirectory is an administrative read; the adapter cannot infer application // authorization. Search covers ID, username, email and display name. SQLite LIKE // folds ASCII case; non-ASCII display-name text matches with its original case. func (store *Store) UserDirectory(ctx context.Context, query auth.UserDirectoryQuery) (auth.UserDirectoryPage, error) { pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit) if !valid { return auth.UserDirectoryPage{}, auth.ErrDirectoryQuery } rows, err := store.db.QueryContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id>? AND (?='' OR id=? OR username_normalized LIKE ? ESCAPE '\' OR email_normalized LIKE ? ESCAPE '\' OR display_name LIKE ? ESCAPE '\') ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), strings.ToLower(pattern), strings.ToLower(pattern), pattern, limit+1) if err != nil { return auth.UserDirectoryPage{}, err } defer rows.Close() page := auth.UserDirectoryPage{Users: make([]auth.User, 0, limit)} for rows.Next() { user, err := scanPasskeyUser(rows) if err != nil { return auth.UserDirectoryPage{}, err } page.Users = append(page.Users, user) } if err = rows.Err(); err != nil { return auth.UserDirectoryPage{}, err } if len(page.Users) > limit { page.Users = page.Users[:limit] page.NextID = page.Users[limit-1].ID } return page, nil } // OrganizationDirectory reads all personal/business and active/archived records. // It does not join membership, grant access, or choose a merchant. Search covers // exact ID and literal slug/name text using SQLite's ASCII case folding. func (store *Store) OrganizationDirectory(ctx context.Context, query organizations.DirectoryQuery) (organizations.DirectoryPage, error) { pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit) if !valid { return organizations.DirectoryPage{}, organizations.ErrDirectoryQuery } rows, err := store.db.QueryContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at FROM gwf_organizations WHERE id>? AND (?='' OR id=? OR slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\') ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), pattern, pattern, limit+1) if err != nil { return organizations.DirectoryPage{}, err } defer rows.Close() page := organizations.DirectoryPage{Organizations: make([]organizations.Organization, 0, limit)} for rows.Next() { var value organizations.Organization var created, updated int64 if err = rows.Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &value.Personal, &value.Revision, &created, &updated); err != nil { return organizations.DirectoryPage{}, err } value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() page.Organizations = append(page.Organizations, value) } if err = rows.Err(); err != nil { return organizations.DirectoryPage{}, err } if len(page.Organizations) > limit { page.Organizations = page.Organizations[:limit] page.NextID = page.Organizations[limit-1].ID } return page, nil } func directoryQuery(search, after string, limit int) (string, int, bool) { if !text(search, 128, true) || after != "" && !opaqueID(after) || limit < 0 || limit > 200 { return "", 0, false } if limit == 0 { limit = 50 } // Wildcards and the escape character are literal user text, never operators. pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(search)) + "%" return pattern, limit, true }