package share import ( "context" "crypto/rand" "errors" "fmt" "math/big" "regexp" "strings" "time" "github.com/jackc/pgx/v5" "amnezia-share/internal/models" ) // codeCharset avoids visually ambiguous characters (0/O, 1/I) in generated codes. const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" var codeStripRe = regexp.MustCompile(`[^A-Z0-9]`) // NormalizeCode upper-cases a renewal code and strips anything that is not A-Z0-9. func NormalizeCode(code string) string { return codeStripRe.ReplaceAllString(strings.ToUpper(code), "") } // GenerateCode returns a random renewal code of the given length (clamped 6..20) // drawn from codeCharset. func GenerateCode(length int) (string, error) { if length < 6 { length = 6 } if length > 20 { length = 20 } max := big.NewInt(int64(len(codeCharset))) b := make([]byte, length) for i := range b { n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } b[i] = codeCharset[n.Int64()] } return string(b), nil } const renewalCodeColumns = `id, code, add_days, max_uses, use_count, share_link_id, code_expires_at, note, code_target, created_at` func scanRenewalCode(row rowScanner) (*models.RenewalCode, error) { var c models.RenewalCode err := row.Scan(&c.ID, &c.Code, &c.AddDays, &c.MaxUses, &c.UseCount, &c.ShareLinkID, &c.CodeExpiresAt, &c.Note, &c.CodeTarget, &c.CreatedAt) if err != nil { return nil, err } return &c, nil } // CreateRenewalCode creates a new renewal code. If codeRaw is empty, a random // 10-char code is generated. codeTarget must be "guest" (optionally bound to a // specific shareLinkID) or "member" (in which case shareLinkID is forced to nil). func (r *Repo) CreateRenewalCode(ctx context.Context, codeRaw string, addDays, maxUses int, shareLinkID *int, codeExpiresAt *time.Time, note, codeTarget string) (*models.RenewalCode, error) { code := NormalizeCode(codeRaw) if code == "" { gen, err := GenerateCode(10) if err != nil { return nil, err } code = gen } if len(code) < 6 || len(code) > 32 { return nil, errors.New("Код: от 6 до 32 символов (A-Z, 0-9).") } if addDays < 1 || addDays > 3650 { return nil, errors.New("Дней продления: от 1 до 3650.") } if maxUses < 1 || maxUses > 100000 { return nil, errors.New("Лимит активаций: от 1 до 100000.") } var exists bool if err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM share_renewal_codes WHERE code=$1)`, code).Scan(&exists); err != nil { return nil, err } if exists { return nil, errors.New("Такой код уже существует.") } if codeTarget != "member" { codeTarget = "guest" } if codeTarget == "member" { shareLinkID = nil } else if shareLinkID != nil { if *shareLinkID <= 0 { return nil, errors.New("Укажите корректный id ссылки или оставьте поле пустым.") } link, err := r.LinkByID(ctx, *shareLinkID) if err != nil { return nil, err } if link == nil { return nil, fmt.Errorf("ссылка #%d не найдена", *shareLinkID) } } note = strings.TrimSpace(note) var noteVal *string if note != "" { if len(note) > 255 { note = note[:255] } noteVal = ¬e } var id int err := r.Pool.QueryRow(ctx, ` INSERT INTO share_renewal_codes (code, add_days, max_uses, share_link_id, code_expires_at, note, code_target) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, code, addDays, maxUses, shareLinkID, codeExpiresAt, noteVal, codeTarget, ).Scan(&id) if err != nil { return nil, fmt.Errorf("не удалось создать код: %w", err) } return &models.RenewalCode{ ID: id, Code: code, AddDays: addDays, MaxUses: maxUses, ShareLinkID: shareLinkID, CodeExpiresAt: codeExpiresAt, Note: noteVal, CodeTarget: codeTarget, }, nil } // ListCodes returns all renewal codes, newest first. func (r *Repo) ListCodes(ctx context.Context) ([]models.RenewalCode, error) { rows, err := r.Pool.Query(ctx, `SELECT `+renewalCodeColumns+` FROM share_renewal_codes ORDER BY id DESC`) if err != nil { return nil, err } defer rows.Close() var out []models.RenewalCode for rows.Next() { c, err := scanRenewalCode(rows) if err != nil { return nil, err } out = append(out, *c) } return out, rows.Err() } // DeleteCode deletes a renewal code (its redemption history cascades via FK). func (r *Repo) DeleteCode(ctx context.Context, codeID int) error { if codeID <= 0 { return errors.New("Некорректный код.") } _, err := r.Pool.Exec(ctx, `DELETE FROM share_renewal_codes WHERE id=$1`, codeID) return err } // RedeemResult is returned by TryRedeemGuest on success. type RedeemResult struct { AddDays int Link *models.ShareLink } // TryRedeemGuest applies a "guest"-targeted renewal code to the link identified by // token: validates the code (target, expiry, use-count, link binding, one // redemption per code+link), extends the link by add_days, and records the // redemption — all inside a single transaction. func (r *Repo) TryRedeemGuest(ctx context.Context, token, codeRaw string) (*RedeemResult, error) { link, err := r.LinkByToken(ctx, token) if err != nil { return nil, err } if link == nil { return nil, errors.New("Ссылка не найдена.") } code := NormalizeCode(codeRaw) if len(code) < 6 || len(code) > 32 { return nil, errors.New("Код должен содержать от 6 до 32 латинских букв или цифр.") } row := r.Pool.QueryRow(ctx, `SELECT `+renewalCodeColumns+` FROM share_renewal_codes WHERE code=$1 LIMIT 1`, code) codeRow, err := scanRenewalCode(row) if errors.Is(err, pgx.ErrNoRows) { return nil, errors.New("Неверный код продления.") } if err != nil { return nil, err } if codeRow.CodeTarget == "member" { return nil, errors.New("Этот код для личного кабинета. Войдите на главную страницу сайта.") } if codeRow.AddDays < 1 { return nil, errors.New("Код настроен некорректно.") } if codeRow.UseCount >= codeRow.MaxUses { return nil, errors.New("Этот код уже исчерпан.") } if codeRow.CodeExpiresAt != nil && codeRow.CodeExpiresAt.Before(time.Now()) { return nil, errors.New("Срок действия кода истёк.") } if codeRow.ShareLinkID != nil && *codeRow.ShareLinkID != link.ID { return nil, errors.New("Этот код не подходит к данной ссылке.") } var existingID int64 err = r.Pool.QueryRow(ctx, `SELECT id FROM share_renewal_redemptions WHERE renewal_code_id=$1 AND share_link_id=$2 LIMIT 1`, codeRow.ID, link.ID).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.extendLinkByDaysTx(ctx, tx, link.ID, codeRow.AddDays); err != nil { return nil, err } if _, err := tx.Exec(ctx, `INSERT INTO share_renewal_redemptions (renewal_code_id, share_link_id, add_days) VALUES ($1,$2,$3)`, codeRow.ID, link.ID, codeRow.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`, codeRow.ID); err != nil { return nil, errors.New("не удалось применить код. Попробуйте позже") } if err := tx.Commit(ctx); err != nil { return nil, errors.New("не удалось применить код. Попробуйте позже") } fresh, err := r.LinkByID(ctx, link.ID) if err != nil || fresh == nil { fresh = link } return &RedeemResult{AddDays: codeRow.AddDays, Link: fresh}, nil } // extendLinkByDaysTx is the transactional core shared by ExtendLinkByDays and // TryRedeemGuest. It mirrors the PHP share_link_extend_by_days semantics. func (r *Repo) extendLinkByDaysTx(ctx context.Context, tx pgx.Tx, linkID, days int) error { if days < 1 || days > 3650 { return errors.New("Число дней должно быть от 1 до 3650.") } var expiresAt *time.Time var validityDays *int var subscriptionUntil *time.Time err := tx.QueryRow(ctx, `SELECT expires_at, validity_days, subscription_until FROM share_links WHERE id=$1 FOR UPDATE`, linkID). Scan(&expiresAt, &validityDays, &subscriptionUntil) if errors.Is(err, pgx.ErrNoRows) { return errors.New("Ссылка не найдена.") } if err != nil { return err } 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 share_links SET expires_at=$1 WHERE id=$2`, newExp, linkID); err != nil { return errors.New("не удалось обновить срок ссылки") } case validityDays != nil && *validityDays > 0: newVd := *validityDays + days if _, err := tx.Exec(ctx, `UPDATE share_links SET validity_days=$1 WHERE id=$2`, newVd, linkID); err != nil { return errors.New("не удалось обновить срок ссылки") } default: newExp := now.AddDate(0, 0, days) if _, err := tx.Exec(ctx, `UPDATE share_links SET expires_at=$1 WHERE id=$2`, newExp, linkID); err != nil { return errors.New("не удалось обновить срок ссылки") } } subBase := now if subscriptionUntil != nil && subscriptionUntil.After(now) { subBase = *subscriptionUntil } newSub := subBase.AddDate(0, 0, days) _, _ = tx.Exec(ctx, `UPDATE share_links SET subscription_until=$1 WHERE id=$2`, newSub, linkID) _, _ = tx.Exec(ctx, `UPDATE share_links SET cleaned_at=NULL WHERE id=$1 AND cleaned_at IS NOT NULL`, linkID) return nil }