first commit
This commit is contained in:
74
internal/user/model.go
Normal file
74
internal/user/model.go
Normal 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
|
||||
}
|
||||
55
internal/user/model_test.go
Normal file
55
internal/user/model_test.go
Normal 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
101
internal/user/nostr_sync.go
Normal 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 ""
|
||||
}
|
||||
119
internal/user/nostr_sync_test.go
Normal file
119
internal/user/nostr_sync_test.go
Normal 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
150
internal/user/repo.go
Normal 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
115
internal/user/repo_query.go
Normal 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)
|
||||
}
|
||||
56
internal/user/repo_test.go
Normal file
56
internal/user/repo_test.go
Normal 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
126
internal/user/service.go
Normal 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
|
||||
}
|
||||
46
internal/user/service_test.go
Normal file
46
internal/user/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user