Add Go rewrite with Postgres 17 and Dokploy Docker Compose
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user