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,294 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user