// Package member implements the member self-service portal domain: registration, // login, subscription bootstrap/extension, and per-member panel connections // ("configs"), mirroring the guest share-link flows in internal/share but scoped // to a logged-in member account instead of an anonymous token link. package member import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "regexp" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "amnezia-share/internal/auth" "amnezia-share/internal/models" "amnezia-share/internal/panel" "amnezia-share/internal/settings" "amnezia-share/internal/share" ) var ( usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) ) // Config is one panel connection created for a member (the "member_configs" row). type Config struct { ID int64 MemberID int ServerID int Protocol string ClientID string ConnectionName string ResponseJSON *string CreatedAt time.Time DeletedAt *time.Time } // Repo is the PostgreSQL-backed repository for member accounts, subscriptions and // their created panel connections. type Repo struct { Pool *pgxpool.Pool } // New builds a Repo bound to pool. func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } func clampInt(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } // Register creates a new member account. email is optional. func (r *Repo) Register(ctx context.Context, username, password, email string) (*models.Member, error) { username = strings.TrimSpace(username) email = strings.TrimSpace(email) if len(username) < 3 || len(username) > 64 { return nil, errors.New("Логин: от 3 до 64 символов.") } if !usernameRe.MatchString(username) { return nil, errors.New("Логин: только латиница, цифры, точка, дефис, подчёркивание.") } if len(password) < 8 { return nil, errors.New("Пароль не короче 8 символов.") } if email != "" && !emailRe.MatchString(email) { return nil, errors.New("Некорректный e-mail.") } hash, err := auth.HashPassword(password) if err != nil { return nil, errors.New("Не удалось сохранить пароль.") } var emailVal *string if email != "" { emailVal = &email } var id int err = r.Pool.QueryRow(ctx, `INSERT INTO members (username, email, password_hash) VALUES ($1,$2,$3) RETURNING id`, username, emailVal, hash).Scan(&id) if err != nil { return nil, errors.New("Логин или e-mail уже занят.") } return r.GetByID(ctx, id) } // Login verifies username/password and returns the matching member, bumping // last_login_at. func (r *Repo) Login(ctx context.Context, username, password string) (*models.Member, error) { username = strings.TrimSpace(username) var m models.Member err := r.Pool.QueryRow(ctx, `SELECT id, username, email, password_hash, created_at, last_login_at FROM members WHERE username=$1 LIMIT 1`, username). Scan(&m.ID, &m.Username, &m.Email, &m.PasswordHash, &m.CreatedAt, &m.LastLoginAt) if errors.Is(err, pgx.ErrNoRows) { return nil, errors.New("Неверный логин или пароль.") } if err != nil { return nil, err } if !auth.VerifyPassword(m.PasswordHash, password) { return nil, errors.New("Неверный логин или пароль.") } _, _ = r.Pool.Exec(ctx, `UPDATE members SET last_login_at=NOW() WHERE id=$1`, m.ID) return &m, nil } // GetByID loads a member by id, or (nil, nil) if not found. func (r *Repo) GetByID(ctx context.Context, id int) (*models.Member, error) { if id <= 0 { return nil, nil } var m models.Member err := r.Pool.QueryRow(ctx, `SELECT id, username, email, password_hash, created_at, last_login_at FROM members WHERE id=$1 LIMIT 1`, id). Scan(&m.ID, &m.Username, &m.Email, &m.PasswordHash, &m.CreatedAt, &m.LastLoginAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &m, nil } // Subscription loads a member's subscription row, or (nil, nil) if it has not // been bootstrapped yet. func (r *Repo) Subscription(ctx context.Context, memberID int) (*models.MemberSubscription, error) { if memberID <= 0 { return nil, nil } var s models.MemberSubscription err := r.Pool.QueryRow(ctx, `SELECT member_id, expires_at, validity_days, max_configs, config_count, cleaned_at FROM member_subscriptions WHERE member_id=$1 LIMIT 1`, memberID). Scan(&s.MemberID, &s.ExpiresAt, &s.ValidityDays, &s.MaxConfigs, &s.ConfigCount, &s.CleanedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &s, nil } func subscriptionDefaults(ctx context.Context, st *settings.Store) (days, maxConfigs int) { days, maxConfigs = 30, 5 if st == nil { return } if v, _ := st.Get(ctx, settings.KeyMemberDays); strings.TrimSpace(v) != "" { if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { days = n } } if v, _ := st.Get(ctx, settings.KeyMemberMaxUses); strings.TrimSpace(v) != "" { if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { maxConfigs = n } } days = clampInt(days, 1, 3650) maxConfigs = clampInt(maxConfigs, 1, 1000) return } // EnsureSubscription returns the member's existing subscription, or bootstraps a // new one using site-wide defaults (settings keys member_default_days / // member_default_max_uses, falling back to 30 days / 5 configs). func (r *Repo) EnsureSubscription(ctx context.Context, st *settings.Store, memberID int) (*models.MemberSubscription, error) { if memberID <= 0 { return nil, errors.New("Некорректный пользователь.") } existing, err := r.Subscription(ctx, memberID) if err != nil { return nil, err } if existing != nil { return existing, nil } days, maxConfigs := subscriptionDefaults(ctx, st) _, err = r.Pool.Exec(ctx, `INSERT INTO member_subscriptions (member_id, expires_at, validity_days, max_configs, config_count) VALUES ($1, NULL, $2, $3, 0)`, memberID, days, maxConfigs) if err != nil { return nil, fmt.Errorf("не удалось создать подписку: %w", err) } return r.Subscription(ctx, memberID) } func subscriptionExpired(sub models.MemberSubscription) bool { if sub.CleanedAt != nil { return true } vd := 0 if sub.ValidityDays != nil { vd = *sub.ValidityDays } if vd > 0 && sub.ExpiresAt == nil { return false } if sub.ExpiresAt == nil { return true } return sub.ExpiresAt.Before(time.Now()) } // SubscriptionExpired reports whether sub has expired (exported wrapper for // callers outside this package, e.g. handlers building the portal view). func SubscriptionExpired(sub models.MemberSubscription) bool { return subscriptionExpired(sub) } // SubscriptionExpiresLabel formats a human label for the subscription's validity: // an explicit date, "from first config — N days" for floating subscriptions, or // "—" if unknown. func SubscriptionExpiresLabel(sub models.MemberSubscription) string { if sub.ExpiresAt != nil { return sub.ExpiresAt.Format("2006-01-02 15:04:05") } if sub.ValidityDays != nil && *sub.ValidityDays > 0 { return fmt.Sprintf("с первого конфига — %d дн.", *sub.ValidityDays) } return "—" } func (r *Repo) extendSubscriptionByDaysTx(ctx context.Context, tx pgx.Tx, memberID, days int) error { if days < 1 || days > 3650 { return errors.New("Число дней должно быть от 1 до 3650.") } var expiresAt *time.Time var validityDays *int var cleanedAt *time.Time err := tx.QueryRow(ctx, `SELECT expires_at, validity_days, cleaned_at FROM member_subscriptions WHERE member_id=$1 FOR UPDATE`, memberID). Scan(&expiresAt, &validityDays, &cleanedAt) if errors.Is(err, pgx.ErrNoRows) { return errors.New("Подписка не найдена.") } if err != nil { return err } if cleanedAt != nil { return errors.New("Доступ закрыт — продление невозможно.") } now := time.Now() switch { case expiresAt != nil: base := *expiresAt if base.Before(now) { base = now } newExp := base.AddDate(0, 0, days) if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET expires_at=$1 WHERE member_id=$2`, newExp, memberID); err != nil { return errors.New("не удалось продлить подписку") } case validityDays != nil && *validityDays > 0: newVd := *validityDays + days if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET validity_days=$1 WHERE member_id=$2`, newVd, memberID); err != nil { return errors.New("не удалось продлить подписку") } default: newExp := now.AddDate(0, 0, days) if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET expires_at=$1 WHERE member_id=$2`, newExp, memberID); err != nil { return errors.New("не удалось продлить подписку") } } return nil } // ExtendSubscriptionByDays extends a member's subscription validity by days // (mirrors share.Repo.ExtendLinkByDays, applied to member_subscriptions instead). func (r *Repo) ExtendSubscriptionByDays(ctx context.Context, memberID, days int) error { if memberID <= 0 { return errors.New("Некорректные параметры продления.") } tx, err := r.Pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if err := r.extendSubscriptionByDaysTx(ctx, tx, memberID, days); err != nil { return err } return tx.Commit(ctx) } // RedeemResult is returned by TryRedeemRenewal on success. type RedeemResult struct { AddDays int Sub *models.MemberSubscription } // TryRedeemRenewal applies a "member"-targeted renewal code (code_target=member, // not bound to any share_link_id) to the member's subscription: validates the // code, extends the subscription, and records a one-time redemption per // code+member — all inside a single transaction. func (r *Repo) TryRedeemRenewal(ctx context.Context, memberID int, codeRaw string) (*RedeemResult, error) { sub, err := r.Subscription(ctx, memberID) if err != nil { return nil, err } if sub == nil { return nil, errors.New("Подписка не найдена.") } if sub.CleanedAt != nil { return nil, errors.New("Доступ закрыт — продление невозможно.") } code := share.NormalizeCode(codeRaw) if len(code) < 6 || len(code) > 32 { return nil, errors.New("Код должен содержать от 6 до 32 латинских букв или цифр.") } var codeID, addDays, maxUses, useCount int var shareLinkID *int var codeExpiresAt *time.Time var codeTarget string err = r.Pool.QueryRow(ctx, `SELECT id, add_days, max_uses, use_count, share_link_id, code_expires_at, code_target FROM share_renewal_codes WHERE code=$1 LIMIT 1`, code). Scan(&codeID, &addDays, &maxUses, &useCount, &shareLinkID, &codeExpiresAt, &codeTarget) if errors.Is(err, pgx.ErrNoRows) { return nil, errors.New("Неверный код продления.") } if err != nil { return nil, err } if codeTarget != "member" { return nil, errors.New("Этот код предназначен для гостевой ссылки, не для кабинета.") } if shareLinkID != nil { return nil, errors.New("Этот код привязан к гостевой ссылке.") } if addDays < 1 { return nil, errors.New("Код настроен некорректно.") } if useCount >= maxUses { return nil, errors.New("Этот код уже исчерпан.") } if codeExpiresAt != nil && codeExpiresAt.Before(time.Now()) { return nil, errors.New("Срок действия кода истёк.") } var existingID int64 err = r.Pool.QueryRow(ctx, `SELECT id FROM member_renewal_redemptions WHERE renewal_code_id=$1 AND member_id=$2 LIMIT 1`, codeID, memberID).Scan(&existingID) if err == nil { return nil, errors.New("Этот код уже был использован в вашем кабинете.") } if !errors.Is(err, pgx.ErrNoRows) { return nil, err } tx, err := r.Pool.Begin(ctx) if err != nil { return nil, errors.New("не удалось применить код. Попробуйте позже") } defer func() { _ = tx.Rollback(ctx) }() if err := r.extendSubscriptionByDaysTx(ctx, tx, memberID, addDays); err != nil { return nil, err } if _, err := tx.Exec(ctx, `INSERT INTO member_renewal_redemptions (renewal_code_id, member_id, add_days) VALUES ($1,$2,$3)`, codeID, memberID, addDays); err != nil { return nil, errors.New("не удалось применить код. Попробуйте позже") } if _, err := tx.Exec(ctx, `UPDATE share_renewal_codes SET use_count = use_count + 1 WHERE id=$1`, codeID); err != nil { return nil, errors.New("не удалось применить код. Попробуйте позже") } if err := tx.Commit(ctx); err != nil { return nil, errors.New("не удалось применить код. Попробуйте позже") } fresh, err := r.Subscription(ctx, memberID) if err != nil || fresh == nil { fresh = sub } return &RedeemResult{AddDays: addDays, Sub: fresh}, nil } const configColumns = `id, member_id, server_id, protocol, client_id, connection_name, response_json, created_at, deleted_at` func scanConfig(row interface { Scan(dest ...any) error }) (*Config, error) { var c Config err := row.Scan(&c.ID, &c.MemberID, &c.ServerID, &c.Protocol, &c.ClientID, &c.ConnectionName, &c.ResponseJSON, &c.CreatedAt, &c.DeletedAt) if err != nil { return nil, err } return &c, nil } // ListConfigs returns a member's active (not soft-deleted) configs, oldest first. func (r *Repo) ListConfigs(ctx context.Context, memberID int) ([]Config, error) { rows, err := r.Pool.Query(ctx, `SELECT `+configColumns+` FROM member_configs WHERE member_id=$1 AND deleted_at IS NULL ORDER BY id ASC`, memberID) if err != nil { return nil, err } defer rows.Close() var out []Config for rows.Next() { c, err := scanConfig(rows) if err != nil { return nil, err } out = append(out, *c) } return out, rows.Err() } func (r *Repo) configByID(ctx context.Context, memberID int, configID int64) (*Config, error) { row := r.Pool.QueryRow(ctx, `SELECT `+configColumns+` FROM member_configs WHERE id=$1 AND member_id=$2 AND deleted_at IS NULL LIMIT 1`, configID, memberID) c, err := scanConfig(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return c, nil } // Bundles returns download bundles for all of a member's active configs, sorted // WireGuard-first (see share.SortBundlesWireguardFirst). func (r *Repo) Bundles(ctx context.Context, memberID int) ([]share.Bundle, error) { rows, err := r.ListConfigs(ctx, memberID) if err != nil { return nil, err } out := make([]share.Bundle, 0, len(rows)) for _, row := range rows { if row.ResponseJSON == nil || strings.TrimSpace(*row.ResponseJSON) == "" { continue } if row.Protocol == "" || row.ConnectionName == "" { continue } jsonBody := share.SlimPanelJSON(row.Protocol, *row.ResponseJSON) bundle := share.BundleFromResponseJSON(row.Protocol, row.ConnectionName, jsonBody) bundle.CreationID = row.ID bundle.CreatedAt = row.CreatedAt bundle.ServerID = row.ServerID out = append(out, bundle) } return share.SortBundlesWireguardFirst(out), nil } // DownloadPayload resolves a single download slot ("conf" | "vpn" | "zip") for one // of a member's configs. func (r *Repo) DownloadPayload(ctx context.Context, memberID int, configID int64, part string) (*share.FilePart, error) { c, err := r.configByID(ctx, memberID, configID) if err != nil { return nil, err } if c == nil || c.ResponseJSON == nil { return nil, nil } return share.DownloadPayloadForPart(c.Protocol, c.ConnectionName, *c.ResponseJSON, part), nil } // TryAddConfig creates a new panel connection for a member and records it. Like // share.Repo.TryAddConnection, the (slow) panel HTTP call happens before opening a // DB transaction. func (r *Repo) TryAddConfig(ctx context.Context, pc *panel.Client, st *settings.Store, memberID, serverID int, protocol string) (*Config, error) { if !share.GuestProtocolAllowed(protocol) { return nil, errors.New("Недопустимый протокол.") } if serverID < 0 { return nil, errors.New("Выберите сервер.") } if memberID <= 0 { return nil, errors.New("Требуется вход в кабинет.") } sub, err := r.Subscription(ctx, memberID) if err != nil { return nil, err } if sub == nil { return nil, errors.New("Подписка не найдена.") } if sub.CleanedAt != nil || subscriptionExpired(*sub) { return nil, errors.New("Срок доступа истёк. Продлите по коду.") } if sub.ConfigCount >= sub.MaxConfigs { return nil, fmt.Errorf("лимит конфигов (%d) исчерпан", sub.MaxConfigs) } prefixSuffix, err := randomHex(2) if err != nil { return nil, err } nextNum := sub.ConfigCount + 1 nameSuffix, err := randomHex(3) if err != nil { return nil, err } name := fmt.Sprintf("u%d_%s_%d_%s", memberID, prefixSuffix, nextNum, nameSuffix) baseURL := st.PanelURL(ctx) apiToken := st.PanelToken(ctx) addResult, err := pc.AddConnection(ctx, baseURL, apiToken, serverID, protocol, name) if err != nil { return nil, err } tx, err := r.Pool.Begin(ctx) if err != nil { return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") } defer func() { _ = tx.Rollback(ctx) }() var lockedCleanedAt *time.Time var lockedConfigCount, lockedMaxConfigs int var lockedExpiresAt *time.Time var lockedValidityDays *int err = tx.QueryRow(ctx, `SELECT cleaned_at, config_count, max_configs, expires_at, validity_days FROM member_subscriptions WHERE member_id=$1 FOR UPDATE`, memberID). Scan(&lockedCleanedAt, &lockedConfigCount, &lockedMaxConfigs, &lockedExpiresAt, &lockedValidityDays) if err != nil { return nil, errors.New("Доступ недействителен.") } lockedSub := models.MemberSubscription{MemberID: memberID, CleanedAt: lockedCleanedAt, ConfigCount: lockedConfigCount, MaxConfigs: lockedMaxConfigs, ExpiresAt: lockedExpiresAt, ValidityDays: lockedValidityDays} if lockedSub.CleanedAt != nil { return nil, errors.New("Доступ недействителен.") } if subscriptionExpired(lockedSub) { return nil, errors.New("Срок доступа истёк.") } if lockedSub.ConfigCount >= lockedSub.MaxConfigs { return nil, errors.New("Лимит конфигов исчерпан.") } jsonToStore := share.SlimPanelJSON(protocol, addResult.RawJSON) var configID int64 err = tx.QueryRow(ctx, ` INSERT INTO member_configs (member_id, server_id, protocol, client_id, connection_name, response_json) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, memberID, serverID, protocol, addResult.ClientID, name, jsonToStore, ).Scan(&configID) if err != nil { return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") } if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET config_count = config_count + 1 WHERE member_id=$1`, memberID); err != nil { return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") } if _, err := tx.Exec(ctx, ` UPDATE member_subscriptions SET expires_at = NOW() + make_interval(days => validity_days) WHERE member_id=$1 AND expires_at IS NULL AND validity_days IS NOT NULL AND validity_days > 0`, memberID); err != nil { _ = err // non-critical } if err := tx.Commit(ctx); err != nil { return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") } return &Config{ID: configID, MemberID: memberID, ServerID: serverID, Protocol: protocol, ClientID: addResult.ClientID, ConnectionName: name, ResponseJSON: &jsonToStore}, nil } // TryMigrate is the free server/protocol migration for a member's config: creates // the new connection first, persists it, then removes the old one. It does not // change config_count. func (r *Repo) TryMigrate(ctx context.Context, pc *panel.Client, st *settings.Store, memberID int, configID int64, newServerID int, newProtocol string) error { if !share.GuestProtocolAllowed(newProtocol) || newServerID < 0 { return errors.New("Укажите сервер и протокол.") } creation, err := r.configByID(ctx, memberID, configID) if err != nil { return err } if creation == nil { return errors.New("Конфигурация не найдена.") } sub, err := r.Subscription(ctx, memberID) if err != nil { return err } if sub == nil || sub.CleanedAt != nil || subscriptionExpired(*sub) { return errors.New("Срок доступа истёк.") } if creation.ServerID == newServerID && creation.Protocol == newProtocol { return errors.New("Конфиг уже на этом сервере с этим протоколом.") } suffix, err := randomHex(3) if err != nil { return err } name := fmt.Sprintf("m%d_%s", memberID, suffix) baseURL := st.PanelURL(ctx) apiToken := st.PanelToken(ctx) addResult, err := pc.AddConnection(ctx, baseURL, apiToken, newServerID, newProtocol, name) if err != nil { return err } jsonToStore := share.SlimPanelJSON(newProtocol, addResult.RawJSON) _, err = r.Pool.Exec(ctx, ` UPDATE member_configs SET server_id=$1, protocol=$2, client_id=$3, connection_name=$4, response_json=$5, created_at=NOW() WHERE id=$6 AND member_id=$7`, newServerID, newProtocol, addResult.ClientID, name, jsonToStore, configID, memberID) if err != nil { _ = pc.RemoveClientSmart(ctx, baseURL, apiToken, newServerID, newProtocol, addResult.ClientID) return errors.New("не удалось сохранить перенос") } if creation.ClientID != "" && creation.Protocol != "" { if err := pc.RemoveClientSmart(ctx, baseURL, apiToken, creation.ServerID, creation.Protocol, creation.ClientID); err != nil { return fmt.Errorf("не удалось удалить старый конфиг: %w", err) } } return nil } // SoftDelete removes a member's panel connection: it is first removed from the // Amnezia panel (all known protocol aliases attempted, 404 = success), then // marked deleted_at in the DB. config_count is left untouched (deleting a config // does not refund quota), matching the guest-side share.Repo.SoftDeleteCreation // behaviour. func (r *Repo) SoftDelete(ctx context.Context, pc *panel.Client, st *settings.Store, memberID int, configID int64) error { row := r.Pool.QueryRow(ctx, `SELECT `+configColumns+` FROM member_configs WHERE id=$1 AND member_id=$2 LIMIT 1`, configID, memberID) c, err := scanConfig(row) if errors.Is(err, pgx.ErrNoRows) { return errors.New("Конфигурация не найдена.") } if err != nil { return err } if c.DeletedAt != nil { return nil } baseURL := st.PanelURL(ctx) apiToken := st.PanelToken(ctx) if err := pc.RemoveClientSmart(ctx, baseURL, apiToken, c.ServerID, c.Protocol, c.ClientID); err != nil { return fmt.Errorf("панель: %w", err) } _, err = r.Pool.Exec(ctx, `UPDATE member_configs SET deleted_at=NOW() WHERE id=$1 AND member_id=$2 AND deleted_at IS NULL`, configID, memberID) return err }