Add Go rewrite with Postgres 17 and Dokploy Docker Compose

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-27 08:21:21 +03:00
co-authored by Cursor
parent bac78dd3fc
commit 99a27f00be
67 changed files with 17864 additions and 0 deletions
+294
View File
@@ -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 = &note
}
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
}