first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

74
internal/user/model.go Normal file
View File

@@ -0,0 +1,74 @@
package user
import (
"errors"
"regexp"
"strings"
"time"
)
type SubscriptionType string
const (
SubYearly SubscriptionType = "yearly"
SubLifetime SubscriptionType = "lifetime"
)
func (s SubscriptionType) Valid() bool {
return s == SubYearly || s == SubLifetime
}
type User struct {
ID int64
Pubkey string
Username string
SubscriptionType SubscriptionType
ExpiresAt *time.Time
IsActive bool
ManualUsername bool
LastSyncedAt *time.Time
ExpiringReminderSentAt *time.Time
DeactivatedAt *time.Time
CreatedAt time.Time
}
func (u *User) IsLifetime() bool { return u.SubscriptionType == SubLifetime }
func (u *User) InGrace() bool { return !u.IsActive && u.DeactivatedAt != nil }
var (
ErrInvalidUsername = errors.New("invalid username")
ErrUserNotFound = errors.New("user not found")
ErrUsernameTaken = errors.New("username taken")
)
// Username rules: 1-30 chars, [a-z0-9_-], lowercase, must start with alnum.
var usernameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,29}$`)
func ValidateUsername(name string, reserved []string) error {
name = strings.ToLower(strings.TrimSpace(name))
if !usernameRE.MatchString(name) {
return ErrInvalidUsername
}
for _, r := range reserved {
if strings.EqualFold(name, strings.TrimSpace(r)) {
return ErrInvalidUsername
}
}
return nil
}
func NormalizeUsername(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// ProvisionalUsername returns a deterministic placeholder handle for a pubkey
// when the caller did not supply one. The format `u_<first 16 hex>` keeps the
// result inside the 30-char username rule and matches usernameRE.
func ProvisionalUsername(pubkey string) string {
hex := strings.ToLower(strings.TrimSpace(pubkey))
if len(hex) > 16 {
hex = hex[:16]
}
return "u_" + hex
}

View File

@@ -0,0 +1,55 @@
package user
import "testing"
func TestValidateUsername(t *testing.T) {
cases := []struct {
name string
ok bool
}{
{"alice", true},
{"al-ice_42", true},
{"a", true},
{"", false},
{"-alice", false},
{"_alice", false},
{"thisusernameiswaytoolongtobevalid12345", false},
{"admin", false},
}
reserved := []string{"admin", "root"}
for _, tc := range cases {
err := ValidateUsername(tc.name, reserved)
if tc.ok && err != nil {
t.Errorf("%q expected ok, got %v", tc.name, err)
}
if !tc.ok && err == nil {
t.Errorf("%q expected fail", tc.name)
}
}
}
func TestProvisionalUsername(t *testing.T) {
const pk = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
got := ProvisionalUsername(pk)
want := "u_0e8c41ebcd55a8d8"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if err := ValidateUsername(got, nil); err != nil {
t.Fatalf("provisional name should validate: %v", err)
}
short := ProvisionalUsername("AbC")
if short != "u_abc" {
t.Errorf("expected lowercase trimmed prefix, got %q", short)
}
}
func TestSubscriptionType(t *testing.T) {
if !SubYearly.Valid() || !SubLifetime.Valid() {
t.Fatal("valid types reported invalid")
}
if SubscriptionType("monthly").Valid() {
t.Fatal("invalid type reported valid")
}
}

101
internal/user/nostr_sync.go Normal file
View File

@@ -0,0 +1,101 @@
package user
import (
"strings"
)
// SanitizeForUsername coerces an arbitrary profile string into a candidate
// that matches usernameRE: lowercase ASCII alphanumerics, `_`, and `-`,
// length <= 30, with an alphanumeric first character. Returns "" when no
// usable handle can be derived.
func SanitizeForUsername(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s))
prevSep := false
for _, r := range s {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevSep = false
case r == '-' || r == '_':
if b.Len() == 0 {
continue
}
if prevSep {
continue
}
b.WriteRune(r)
prevSep = true
default:
if b.Len() == 0 {
continue
}
if prevSep {
continue
}
b.WriteRune('_')
prevSep = true
}
}
out := strings.TrimRight(b.String(), "_-")
if len(out) > 30 {
out = strings.TrimRight(out[:30], "_-")
}
return out
}
// nip05LocalPart returns the local part of a NIP-05 identifier (`local@domain`)
// when its domain matches `serviceDomain` (case-insensitive). The bare form
// `local` (no `@`) per NIP-05 implies `_@domain`, which we ignore for sync
// since we cannot prove the domain match.
func nip05LocalPart(nip05, serviceDomain string) string {
nip05 = strings.TrimSpace(nip05)
if nip05 == "" || serviceDomain == "" {
return ""
}
at := strings.LastIndex(nip05, "@")
if at <= 0 || at == len(nip05)-1 {
return ""
}
local := nip05[:at]
domain := nip05[at+1:]
if !strings.EqualFold(domain, serviceDomain) {
return ""
}
return local
}
// CandidateFromMetadata returns a sanitized, validated username derived from a
// kind:0 profile, or "" if no field yields a usable handle. Precedence:
// 1. NIP-05 local part when its domain matches serviceDomain.
// 2. `name` (NIP-01).
// 3. `display_name` / `displayName` (NIP-24).
// 4. Deprecated `username` field.
func CandidateFromMetadata(name, displayName, displayNameAlt, username, nip05, serviceDomain string, reserved []string) string {
tryFields := []string{
nip05LocalPart(nip05, serviceDomain),
name,
displayName,
displayNameAlt,
username,
}
for _, raw := range tryFields {
if raw == "" {
continue
}
c := SanitizeForUsername(raw)
if c == "" {
continue
}
if err := ValidateUsername(c, reserved); err != nil {
continue
}
return c
}
return ""
}

View File

@@ -0,0 +1,119 @@
package user
import "testing"
func TestSanitizeForUsername(t *testing.T) {
cases := []struct {
in, want string
}{
{"alice", "alice"},
{"Alice", "alice"},
{" Alice ", "alice"},
{"Alice Bob", "alice_bob"},
{"alice bob", "alice_bob"},
{"Alice!@#Bob", "alice_bob"},
{"-alice", "alice"},
{"_alice", "alice"},
{"alice_", "alice"},
{"alice--bob", "alice-bob"},
{"alice__bob", "alice_bob"},
{" ", ""},
{"", ""},
{"!!!", ""},
{"日本語", ""},
{"alice日本", "alice"},
{"thisusernameiswaytoolongtobevalid12345", "thisusernameiswaytoolongtobeva"},
}
for _, tc := range cases {
got := SanitizeForUsername(tc.in)
if got != tc.want {
t.Errorf("SanitizeForUsername(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSanitizeForUsernamePassesValidate(t *testing.T) {
inputs := []string{"Alice Bob", " S0me User! ", "alice--bob"}
for _, in := range inputs {
s := SanitizeForUsername(in)
if s == "" {
t.Fatalf("unexpected empty sanitize for %q", in)
}
if err := ValidateUsername(s, nil); err != nil {
t.Errorf("sanitized %q -> %q failed ValidateUsername: %v", in, s, err)
}
}
}
func TestCandidateFromMetadataPrecedence(t *testing.T) {
const domain = "azzamo.net"
reserved := []string{"admin"}
cases := []struct {
desc string
name, dn, dnAlt, username, nip05, dom string
want string
}{
{
desc: "nip05 local part wins when domain matches",
name: "alice", nip05: "preferred@azzamo.net", dom: domain,
want: "preferred",
},
{
desc: "nip05 ignored when domain differs",
name: "alice", nip05: "preferred@other.example", dom: domain,
want: "alice",
},
{
desc: "name takes precedence over display_name",
name: "alice", dn: "Bob Builder", dom: domain,
want: "alice",
},
{
desc: "falls back to display_name when name empty",
dn: "Bob Builder", dom: domain,
want: "bob_builder",
},
{
desc: "falls back to camelCase displayName when others empty",
dnAlt: "Bob B", dom: domain,
want: "bob_b",
},
{
desc: "falls back to deprecated username last",
username: "legacy_user", dom: domain,
want: "legacy_user",
},
{
desc: "skips fields that sanitize to empty",
name: "!!!", dn: "Real Name", dom: domain,
want: "real_name",
},
{
desc: "skips reserved words and falls through",
name: "admin", dn: "Real Name", dom: domain,
want: "real_name",
},
{
desc: "no usable field returns empty",
name: "!!!", dom: domain,
want: "",
},
{
desc: "bare nip05 with no @ is ignored",
nip05: "alice", name: "fallback", dom: domain,
want: "fallback",
},
{
desc: "empty service domain ignores nip05",
name: "alice", nip05: "preferred@azzamo.net",
want: "alice",
},
}
for _, tc := range cases {
got := CandidateFromMetadata(tc.name, tc.dn, tc.dnAlt, tc.username, tc.nip05, tc.dom, reserved)
if got != tc.want {
t.Errorf("%s: got %q want %q", tc.desc, got, tc.want)
}
}
}

150
internal/user/repo.go Normal file
View File

@@ -0,0 +1,150 @@
package user
import (
"context"
"database/sql"
"errors"
"time"
"github.com/noderunners/nip05api/internal/db"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
func parseTime(s sql.NullString) *time.Time {
if !s.Valid || s.String == "" {
return nil
}
t, err := time.Parse(time.RFC3339, s.String)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", s.String)
if err != nil {
return nil
}
}
return &t
}
func mustParseTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err == nil {
return t
}
t, err = time.Parse("2006-01-02 15:04:05", s)
if err == nil {
return t
}
return time.Time{}
}
func formatTime(t *time.Time) any {
if t == nil {
return nil
}
return t.UTC().Format(time.RFC3339)
}
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
var sub string
var expiresAt, lastSynced, reminderSent, deactivatedAt, createdAt sql.NullString
if err := row.Scan(
&u.ID, &u.Pubkey, &u.Username, &sub,
&expiresAt, &u.IsActive, &u.ManualUsername,
&lastSynced, &reminderSent, &deactivatedAt, &createdAt,
); err != nil {
return nil, err
}
u.SubscriptionType = SubscriptionType(sub)
u.ExpiresAt = parseTime(expiresAt)
u.LastSyncedAt = parseTime(lastSynced)
u.ExpiringReminderSentAt = parseTime(reminderSent)
u.DeactivatedAt = parseTime(deactivatedAt)
if t := parseTime(createdAt); t != nil {
u.CreatedAt = *t
}
return &u, nil
}
const userCols = `id, pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, expiring_reminder_sent_at, deactivated_at, created_at`
func (r *Repo) GetByPubkey(ctx context.Context, pubkey string) (*User, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE pubkey = ?`, pubkey)
u, err := scanUser(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return u, err
}
func (r *Repo) GetByUsername(ctx context.Context, username string) (*User, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ? COLLATE NOCASE`, username)
u, err := scanUser(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return u, err
}
func (r *Repo) Insert(ctx context.Context, u *User) error {
now := time.Now().UTC()
res, err := r.db.ExecContext(ctx, `INSERT INTO users
(pubkey, username, subscription_type, expires_at, is_active, manual_username, created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
u.Pubkey, u.Username, string(u.SubscriptionType),
formatTime(u.ExpiresAt), u.ManualUsername,
now.Format(time.RFC3339))
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
u.ID = id
u.CreatedAt = now
return nil
}
func (r *Repo) Update(ctx context.Context, u *User) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET
username = ?,
subscription_type = ?,
expires_at = ?,
is_active = ?,
manual_username = ?,
last_synced_at = ?,
expiring_reminder_sent_at = ?,
deactivated_at = ?
WHERE pubkey = ?`,
u.Username, string(u.SubscriptionType),
formatTime(u.ExpiresAt), u.IsActive, u.ManualUsername,
formatTime(u.LastSyncedAt), formatTime(u.ExpiringReminderSentAt),
formatTime(u.DeactivatedAt), u.Pubkey)
return err
}
func (r *Repo) Delete(ctx context.Context, pubkey string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE pubkey = ?`, pubkey)
return err
}
// SetActiveExpiry sets a user's expires_at to an absolute value and reactivates
// them. Idempotent: applying the same input twice produces the same end state.
func (r *Repo) SetActiveExpiry(ctx context.Context, pubkey string, sub SubscriptionType, expiresAt *time.Time) error {
_, err := r.db.ExecContext(ctx, `UPDATE users SET
is_active = 1,
deactivated_at = NULL,
expiring_reminder_sent_at = NULL,
subscription_type = ?,
expires_at = ?
WHERE pubkey = ?`,
string(sub), formatTime(expiresAt), pubkey)
return err
}

115
internal/user/repo_query.go Normal file
View File

@@ -0,0 +1,115 @@
package user
import (
"context"
"time"
)
type ListFilter struct {
ActiveOnly bool
Search string
Limit int
}
func (r *Repo) List(ctx context.Context, f ListFilter) ([]*User, error) {
q := `SELECT ` + userCols + ` FROM users WHERE 1=1`
args := []any{}
if f.ActiveOnly {
q += ` AND is_active = 1`
}
if f.Search != "" {
q += ` AND (username LIKE ? COLLATE NOCASE OR pubkey LIKE ?)`
args = append(args, "%"+f.Search+"%", "%"+f.Search+"%")
}
q += ` ORDER BY created_at DESC`
if f.Limit > 0 {
q += ` LIMIT ?`
args = append(args, f.Limit)
}
rows, err := r.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ActiveByName(ctx context.Context) (map[string]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT username, pubkey FROM users WHERE is_active = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var u, p string
if err := rows.Scan(&u, &p); err != nil {
return nil, err
}
out[u] = p
}
return out, rows.Err()
}
func (r *Repo) collect(rows interface {
Next() bool
Scan(...any) error
Err() error
Close() error
}) ([]*User, error) {
defer rows.Close()
out := []*User{}
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (r *Repo) ListPendingReminders(ctx context.Context, days int, now time.Time) ([]*User, error) {
low := now.Add(time.Duration(days)*24*time.Hour - 12*time.Hour).UTC().Format(time.RFC3339)
high := now.Add(time.Duration(days)*24*time.Hour + 12*time.Hour).UTC().Format(time.RFC3339)
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 1
AND subscription_type = 'yearly'
AND expires_at BETWEEN ? AND ?
AND (expiring_reminder_sent_at IS NULL OR expiring_reminder_sent_at < ?)`,
low, high, now.Add(-23*time.Hour).UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ListExpired(ctx context.Context, now time.Time) ([]*User, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 1 AND subscription_type = 'yearly' AND expires_at < ?`,
now.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ListGraceExpired(ctx context.Context, cutoff time.Time) ([]*User, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 0 AND deactivated_at IS NOT NULL AND deactivated_at < ?`,
cutoff.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}
func (r *Repo) ListForSync(ctx context.Context, staleBefore time.Time) ([]*User, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users
WHERE is_active = 1 AND manual_username = 0
AND (last_synced_at IS NULL OR last_synced_at < ?)`,
staleBefore.UTC().Format(time.RFC3339))
if err != nil {
return nil, err
}
return r.collect(rows)
}

View File

@@ -0,0 +1,56 @@
package user
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/noderunners/nip05api/internal/db"
)
func newTestRepo(t *testing.T) *Repo {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
if err := d.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
return NewRepo(d)
}
func TestRepo_InsertAndFetch(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
exp := time.Now().Add(365 * 24 * time.Hour)
u := &User{
Pubkey: "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c",
Username: "alice",
SubscriptionType: SubYearly,
ExpiresAt: &exp,
IsActive: true,
}
if err := repo.Insert(ctx, u); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := repo.GetByPubkey(ctx, u.Pubkey)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Username != "alice" {
t.Errorf("got username %q", got.Username)
}
if !got.IsActive {
t.Error("expected active")
}
}
func TestRepo_GetByUsername_NotFound(t *testing.T) {
repo := newTestRepo(t)
if _, err := repo.GetByUsername(context.Background(), "nope"); err != ErrUserNotFound {
t.Errorf("got %v want ErrUserNotFound", err)
}
}

126
internal/user/service.go Normal file
View File

@@ -0,0 +1,126 @@
package user
import (
"context"
"errors"
"time"
)
type Service struct {
repo *Repo
reserved []string
}
func NewService(repo *Repo, reserved []string) *Service {
return &Service{repo: repo, reserved: reserved}
}
func (s *Service) Repo() *Repo { return s.repo }
func (s *Service) Reserved() []string { return s.reserved }
// IsAvailable returns true if no active/in-grace user has the username.
func (s *Service) IsAvailable(ctx context.Context, username string) (bool, error) {
if err := ValidateUsername(username, s.reserved); err != nil {
return false, err
}
u, err := s.repo.GetByUsername(ctx, NormalizeUsername(username))
if errors.Is(err, ErrUserNotFound) {
return true, nil
}
if err != nil {
return false, err
}
_ = u
return false, nil
}
// CreateOrActivate inserts a new active user. Caller is responsible for transactional
// concerns (e.g. payments worker uses this within a tx).
func (s *Service) CreateOrActivate(ctx context.Context, pubkey, username string, sub SubscriptionType, years int, manual bool) (*User, error) {
username = NormalizeUsername(username)
if err := ValidateUsername(username, s.reserved); err != nil {
return nil, err
}
now := time.Now().UTC()
expiresAt := computeExpiry(sub, years, time.Time{}, now)
u := &User{
Pubkey: pubkey,
Username: username,
SubscriptionType: sub,
ExpiresAt: expiresAt,
IsActive: true,
ManualUsername: manual,
}
if err := s.repo.Insert(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// Renew extends an existing user. Used by payments worker and admin extend.
func (s *Service) Renew(ctx context.Context, u *User, sub SubscriptionType, years int) error {
now := time.Now().UTC()
var current time.Time
if u.ExpiresAt != nil {
current = *u.ExpiresAt
}
u.SubscriptionType = sub
u.ExpiresAt = computeExpiry(sub, years, current, now)
u.IsActive = true
u.DeactivatedAt = nil
u.ExpiringReminderSentAt = nil
return s.repo.Update(ctx, u)
}
func (s *Service) SetUsername(ctx context.Context, pubkey, username string) (*User, error) {
username = NormalizeUsername(username)
if err := ValidateUsername(username, s.reserved); err != nil {
return nil, err
}
u, err := s.repo.GetByPubkey(ctx, pubkey)
if err != nil {
return nil, err
}
if existing, err := s.repo.GetByUsername(ctx, username); err == nil && existing.Pubkey != pubkey {
return nil, ErrUsernameTaken
}
u.Username = username
u.ManualUsername = true
if err := s.repo.Update(ctx, u); err != nil {
return nil, err
}
return u, nil
}
func (s *Service) Delete(ctx context.Context, pubkey string) error {
return s.repo.Delete(ctx, pubkey)
}
// computeExpiry returns *time.Time (nil for lifetime).
func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time {
if sub == SubLifetime {
return nil
}
var cur time.Time
if !current.IsZero() {
cur = current
}
return YearlyTargetExpiry(&cur, years, now)
}
// YearlyTargetExpiry computes the new expiry for a yearly subscription using
// effective_start = max(now, current_expiry); new_expiry = effective_start + years.
// currentExpiry may be nil or zero for first-time purchases.
func YearlyTargetExpiry(currentExpiry *time.Time, years int, now time.Time) *time.Time {
if years <= 0 {
years = 1
}
base := now
if currentExpiry != nil && currentExpiry.After(base) {
base = *currentExpiry
}
t := base.AddDate(years, 0, 0)
return &t
}

View File

@@ -0,0 +1,46 @@
package user
import (
"testing"
"time"
)
func TestComputeExpiry_NewYearly(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
exp := computeExpiry(SubYearly, 1, time.Time{}, now)
if exp == nil {
t.Fatal("nil expiry")
}
want := now.AddDate(1, 0, 0)
if !exp.Equal(want) {
t.Errorf("got %v want %v", exp, want)
}
}
func TestComputeExpiry_RenewActive(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
current := now.AddDate(0, 6, 0)
exp := computeExpiry(SubYearly, 1, current, now)
want := current.AddDate(1, 0, 0)
if !exp.Equal(want) {
t.Errorf("active renew: got %v want %v", exp, want)
}
}
func TestComputeExpiry_RenewExpired(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
past := now.AddDate(0, -1, 0)
exp := computeExpiry(SubYearly, 1, past, now)
want := now.AddDate(1, 0, 0)
if !exp.Equal(want) {
t.Errorf("expired renew: got %v want %v", exp, want)
}
}
func TestComputeExpiry_Lifetime(t *testing.T) {
now := time.Now()
exp := computeExpiry(SubLifetime, 1, time.Time{}, now)
if exp != nil {
t.Errorf("lifetime should be nil, got %v", exp)
}
}