Files
vpwg/Go/internal/member/repo.go
T

666 lines
23 KiB
Go

// 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
}