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
+833
View File
@@ -0,0 +1,833 @@
package share
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"amnezia-share/internal/models"
"amnezia-share/internal/panel"
"amnezia-share/internal/settings"
)
// AllowedDurations are the guest link validity choices (in days) offered by the
// admin UI when creating a new share link.
var AllowedDurations = []int{30, 60, 90, 120, 180, 270, 365}
// GuestProtocols are the protocol ids a guest link may create/migrate configs for.
var GuestProtocols = []string{"wireguard", "awg2", "vless"}
// DefaultServerProtocols is used when a server has no explicit protocol whitelist.
var DefaultServerProtocols = []string{"wireguard", "awg2"}
// GuestProtocolAllowed reports whether protocol is one of GuestProtocols.
func GuestProtocolAllowed(protocol string) bool {
for _, p := range GuestProtocols {
if p == protocol {
return true
}
}
return false
}
// LinkStatus is the admin-facing classification of a share link.
type LinkStatus string
const (
StatusActive LinkStatus = "active"
StatusExpired LinkStatus = "expired"
StatusCleaned LinkStatus = "cleaned"
StatusLimit LinkStatus = "limit"
)
// ClassifyLinkStatus mirrors the PHP admin status logic, plus an extra "limit"
// status (active link that has exhausted its max_uses but is not yet expired).
func ClassifyLinkStatus(link models.ShareLink, hasActiveCreations bool) LinkStatus {
if link.CleanedAt != nil || (link.IsExpired() && !hasActiveCreations) {
return StatusCleaned
}
if link.IsExpired() {
return StatusExpired
}
if link.MaxUses > 0 && link.UseCount >= link.MaxUses {
return StatusLimit
}
return StatusActive
}
func normalizeStatusFilter(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
switch s {
case "active", "expired", "cleaned", "limit":
return s
default:
return ""
}
}
// Repo is the PostgreSQL-backed repository for guest share links, their created
// panel connections, and renewal codes (see renewal.go).
type Repo struct {
Pool *pgxpool.Pool
}
// New builds a Repo bound to pool.
func New(pool *pgxpool.Pool) *Repo {
return &Repo{Pool: pool}
}
const shareLinkColumns = `id, token, server_id, expires_at, max_uses, validity_days, use_count, created_at, cleaned_at, traffic_quota_gb, traffic_used_gb, subscription_until, allowed_server_ids`
type rowScanner interface {
Scan(dest ...any) error
}
func scanShareLink(row rowScanner) (*models.ShareLink, error) {
var l models.ShareLink
var allowedRaw []byte
err := row.Scan(
&l.ID, &l.Token, &l.ServerID, &l.ExpiresAt, &l.MaxUses, &l.ValidityDays,
&l.UseCount, &l.CreatedAt, &l.CleanedAt, &l.TrafficQuotaGB, &l.TrafficUsedGB,
&l.SubscriptionUntil, &allowedRaw,
)
if err != nil {
return nil, err
}
if len(allowedRaw) > 0 {
l.AllowedServerIDs = json.RawMessage(allowedRaw)
}
return &l, nil
}
const creationColumns = `id, share_link_id, server_id, protocol, client_id, connection_name, response_json, created_at, deleted_at`
func scanCreation(row rowScanner) (*models.ShareCreation, error) {
var c models.ShareCreation
err := row.Scan(
&c.ID, &c.ShareLinkID, &c.ServerID, &c.Protocol, &c.ClientID,
&c.ConnectionName, &c.ResponseJSON, &c.CreatedAt, &c.DeletedAt,
)
if err != nil {
return nil, err
}
return &c, nil
}
var tokenStripRe = regexp.MustCompile(`[^a-f0-9]`)
// LinkByToken loads a share link by its 32-char hex token (extra characters, if
// any, are stripped first, mirroring the PHP preg_replace guard).
func (r *Repo) LinkByToken(ctx context.Context, token string) (*models.ShareLink, error) {
token = tokenStripRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(token)), "")
if len(token) != 32 {
return nil, nil
}
row := r.Pool.QueryRow(ctx, `SELECT `+shareLinkColumns+` FROM share_links WHERE token=$1 LIMIT 1`, token)
l, err := scanShareLink(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return l, nil
}
// LinkByID loads a share link by numeric id.
func (r *Repo) LinkByID(ctx context.Context, id int) (*models.ShareLink, error) {
if id <= 0 {
return nil, nil
}
row := r.Pool.QueryRow(ctx, `SELECT `+shareLinkColumns+` FROM share_links WHERE id=$1 LIMIT 1`, id)
l, err := scanShareLink(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return l, nil
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// CreateLink creates a new server-less guest link (the guest picks a server on the
// share page). days sets validity_days (link becomes "floating": it starts
// counting down from the first created config); maxUses caps how many configs may
// be created on it; subscriptionUntil is an optional cosmetic "subscription ends"
// date shown to the guest.
func (r *Repo) CreateLink(ctx context.Context, days, maxUses int, subscriptionUntil *time.Time) (*models.ShareLink, error) {
if days < 1 || days > 3650 {
return nil, errors.New("Срок должен быть от 1 до 3650 дней.")
}
if maxUses < 1 || maxUses > 1000 {
return nil, errors.New("Лимит конфигов должен быть от 1 до 1000.")
}
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("сгенерировать токен: %w", err)
}
token := hex.EncodeToString(tokenBytes)
var id int
err := r.Pool.QueryRow(ctx, `
INSERT INTO share_links (token, server_id, expires_at, max_uses, validity_days, use_count, subscription_until)
VALUES ($1, NULL, NULL, $2, $3, 0, $4)
RETURNING id`,
token, maxUses, days, subscriptionUntil,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("не удалось сохранить ссылку: %w", err)
}
return r.LinkByID(ctx, id)
}
func (r *Repo) allLinksOrdered(ctx context.Context) ([]models.ShareLink, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+shareLinkColumns+` FROM share_links ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ShareLink
for rows.Next() {
l, err := scanShareLink(rows)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
func (r *Repo) activeCreationCounts(ctx context.Context) (map[int]int, error) {
rows, err := r.Pool.Query(ctx, `SELECT share_link_id, COUNT(*) FROM share_link_creations WHERE deleted_at IS NULL GROUP BY share_link_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]int{}
for rows.Next() {
var id, cnt int
if err := rows.Scan(&id, &cnt); err != nil {
return nil, err
}
out[id] = cnt
}
return out, rows.Err()
}
func filterLinksByStatus(links []models.ShareLink, counts map[int]int, status string) []models.ShareLink {
out := make([]models.ShareLink, 0, len(links))
for _, l := range links {
hasActive := counts[l.ID] > 0
if string(ClassifyLinkStatus(l, hasActive)) == status {
out = append(out, l)
}
}
return out
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// ListLinks returns a page of links (newest first), optionally filtered by status
// ("", "active", "expired", "cleaned" or "limit").
func (r *Repo) ListLinks(ctx context.Context, page, perPage int, statusFilter string) ([]models.ShareLink, error) {
perPage = clampInt(perPage, 1, 100)
if page < 1 {
page = 1
}
offset := (page - 1) * perPage
statusFilter = normalizeStatusFilter(statusFilter)
if statusFilter == "" {
rows, err := r.Pool.Query(ctx, `SELECT `+shareLinkColumns+` FROM share_links ORDER BY id DESC LIMIT $1 OFFSET $2`, perPage, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ShareLink
for rows.Next() {
l, err := scanShareLink(rows)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
all, err := r.allLinksOrdered(ctx)
if err != nil {
return nil, err
}
counts, err := r.activeCreationCounts(ctx)
if err != nil {
return nil, err
}
filtered := filterLinksByStatus(all, counts, statusFilter)
if offset >= len(filtered) {
return nil, nil
}
end := offset + perPage
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end], nil
}
// CountLinks returns the total number of links matching statusFilter ("" = all).
func (r *Repo) CountLinks(ctx context.Context, statusFilter string) (int, error) {
statusFilter = normalizeStatusFilter(statusFilter)
if statusFilter == "" {
var c int
err := r.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM share_links`).Scan(&c)
return c, err
}
all, err := r.allLinksOrdered(ctx)
if err != nil {
return 0, err
}
counts, err := r.activeCreationCounts(ctx)
if err != nil {
return 0, err
}
return len(filterLinksByStatus(all, counts, statusFilter)), nil
}
// UpdateLink updates the admin-editable timing/limits of a link: validityDays,
// either a floating expiry (floatingExpiry=true, expiresAt ignored) or an explicit
// expiresAt, an optional cosmetic subscriptionUntil, and maxUses.
func (r *Repo) UpdateLink(ctx context.Context, linkID, validityDays int, floatingExpiry bool, expiresAt, subscriptionUntil *time.Time, maxUses int) error {
if validityDays < 1 || validityDays > 3650 {
return errors.New("Число дней должно быть от 1 до 3650.")
}
if maxUses < 1 || maxUses > 1000 {
return errors.New("Лимит конфигов: от 1 до 1000.")
}
link, err := r.LinkByID(ctx, linkID)
if err != nil {
return err
}
if link == nil {
return errors.New("Ссылка не найдена.")
}
if link.CleanedAt != nil {
return errors.New("Ссылка очищена — срок не меняют.")
}
var exp *time.Time
if !floatingExpiry {
exp = expiresAt
}
_, err = r.Pool.Exec(ctx, `UPDATE share_links SET validity_days=$1, expires_at=$2, subscription_until=$3, max_uses=$4 WHERE id=$5`,
validityDays, exp, subscriptionUntil, maxUses, linkID)
if err != nil {
return errors.New("Не удалось сохранить срок ссылки.")
}
return nil
}
// DeleteLink deletes a share link (its creations cascade via FK).
func (r *Repo) DeleteLink(ctx context.Context, linkID int) error {
if linkID <= 0 {
return errors.New("Некорректная ссылка.")
}
_, err := r.Pool.Exec(ctx, `DELETE FROM share_links WHERE id=$1`, linkID)
return err
}
// ListCreationsForLink returns the active (not soft-deleted) creations of a link,
// oldest first.
func (r *Repo) ListCreationsForLink(ctx context.Context, linkID int) ([]models.ShareCreation, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+creationColumns+` FROM share_link_creations WHERE share_link_id=$1 AND deleted_at IS NULL ORDER BY id ASC`, linkID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ShareCreation
for rows.Next() {
c, err := scanCreation(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
// ListDeletedCreationsForLink returns the soft-deleted creations of a link (most
// recently deleted first) — useful for admin history views.
func (r *Repo) ListDeletedCreationsForLink(ctx context.Context, linkID int) ([]models.ShareCreation, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+creationColumns+` FROM share_link_creations WHERE share_link_id=$1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, id ASC`, linkID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ShareCreation
for rows.Next() {
c, err := scanCreation(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
func (r *Repo) creationForLink(ctx context.Context, linkID int, creationID int64) (*models.ShareCreation, error) {
row := r.Pool.QueryRow(ctx, `SELECT `+creationColumns+` FROM share_link_creations WHERE id=$1 AND share_link_id=$2 LIMIT 1`, creationID, linkID)
c, err := scanCreation(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
func (r *Repo) creationForLinkActive(ctx context.Context, linkID int, creationID int64) (*models.ShareCreation, error) {
row := r.Pool.QueryRow(ctx, `SELECT `+creationColumns+` FROM share_link_creations WHERE id=$1 AND share_link_id=$2 AND deleted_at IS NULL LIMIT 1`, creationID, linkID)
c, err := scanCreation(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
func (r *Repo) syncCleanedStatus(ctx context.Context, linkID int) {
link, err := r.LinkByID(ctx, linkID)
if err != nil || link == nil || link.CleanedAt != nil {
return
}
if !link.IsExpired() {
return
}
creations, err := r.ListCreationsForLink(ctx, linkID)
if err != nil || len(creations) > 0 {
return
}
_, _ = r.Pool.Exec(ctx, `UPDATE share_links SET cleaned_at=NOW() WHERE id=$1 AND cleaned_at IS NULL`, linkID)
}
// SoftDeleteCreation removes a single guest connection: it is first removed from
// the Amnezia panel (trying all known protocol aliases, 404 treated as success),
// then marked deleted_at in the DB. If the link is now expired with no active
// creations left, it is also marked cleaned_at.
func (r *Repo) SoftDeleteCreation(ctx context.Context, pc *panel.Client, st *settings.Store, linkID int, creationID int64) error {
creation, err := r.creationForLink(ctx, linkID, creationID)
if err != nil {
return err
}
if creation == nil {
return errors.New("Конфигурация не найдена.")
}
if creation.DeletedAt != nil {
r.syncCleanedStatus(ctx, linkID)
return nil
}
baseURL := st.PanelURL(ctx)
apiToken := st.PanelToken(ctx)
if err := pc.RemoveClientSmart(ctx, baseURL, apiToken, creation.ServerID, creation.Protocol, creation.ClientID); err != nil {
return fmt.Errorf("панель: %w", err)
}
_, err = r.Pool.Exec(ctx, `UPDATE share_link_creations SET deleted_at=NOW() WHERE id=$1 AND share_link_id=$2 AND deleted_at IS NULL`, creationID, linkID)
if err != nil {
return err
}
r.syncCleanedStatus(ctx, linkID)
return nil
}
// TryAddConnection creates a new guest connection on the panel and records it.
// The (slow) panel HTTP call happens *before* opening a DB transaction; the
// transaction only re-validates + persists, avoiding minute-long row locks.
func (r *Repo) TryAddConnection(ctx context.Context, pc *panel.Client, st *settings.Store, link *models.ShareLink, serverID int, protocol string) (*models.ShareCreation, error) {
if !GuestProtocolAllowed(protocol) {
return nil, errors.New("Недопустимый протокол.")
}
if serverID < 0 {
return nil, errors.New("Выберите сервер.")
}
if link == nil || link.ID <= 0 {
return nil, errors.New("Ссылка недействительна.")
}
fresh, err := r.LinkByID(ctx, link.ID)
if err != nil {
return nil, errors.New("Не удалось проверить ссылку.")
}
if fresh == nil || fresh.CleanedAt != nil {
return nil, errors.New("Ссылка недействительна.")
}
if fresh.IsExpired() {
return nil, errors.New("Срок действия ссылки истёк.")
}
if fresh.UseCount >= fresh.MaxUses {
return nil, fmt.Errorf("лимит созданий (%d) исчерпан", fresh.MaxUses)
}
tokenShort := fresh.Token
if len(tokenShort) > 8 {
tokenShort = tokenShort[:8]
}
nextNum := fresh.UseCount + 1
suffix, err := randomHex(3)
if err != nil {
return nil, err
}
name := fmt.Sprintf("s%s_%d_%s", tokenShort, nextNum, suffix)
baseURL := st.PanelURL(ctx)
apiToken := st.PanelToken(ctx)
// Panel call happens outside of any DB transaction: it can take up to ~100s.
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 lockedUseCount, lockedMaxUses int
var lockedExpiresAt *time.Time
var lockedValidityDays *int
err = tx.QueryRow(ctx, `SELECT cleaned_at, use_count, max_uses, expires_at, validity_days FROM share_links WHERE id=$1 FOR UPDATE`, link.ID).
Scan(&lockedCleanedAt, &lockedUseCount, &lockedMaxUses, &lockedExpiresAt, &lockedValidityDays)
if err != nil {
return nil, errors.New("Ссылка недействительна.")
}
lockedLink := models.ShareLink{ID: link.ID, CleanedAt: lockedCleanedAt, UseCount: lockedUseCount, MaxUses: lockedMaxUses, ExpiresAt: lockedExpiresAt, ValidityDays: lockedValidityDays}
if lockedLink.CleanedAt != nil {
return nil, errors.New("Ссылка недействительна.")
}
if lockedLink.IsExpired() {
return nil, errors.New("Срок действия ссылки истёк.")
}
if lockedLink.UseCount >= lockedLink.MaxUses {
return nil, fmt.Errorf("лимит созданий (%d) исчерпан", lockedLink.MaxUses)
}
jsonToStore := SlimPanelJSON(protocol, addResult.RawJSON)
var creationID int64
err = tx.QueryRow(ctx, `
INSERT INTO share_link_creations (share_link_id, server_id, protocol, client_id, connection_name, response_json)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
link.ID, serverID, protocol, addResult.ClientID, name, jsonToStore,
).Scan(&creationID)
if err != nil {
return nil, errors.New("не удалось сохранить. Обновите страницу и попробуйте ещё раз")
}
if _, err := tx.Exec(ctx, `UPDATE share_links SET use_count = use_count + 1 WHERE id=$1`, link.ID); err != nil {
return nil, errors.New("не удалось сохранить. Обновите страницу и попробуйте ещё раз")
}
// Floating-expiry links: start the countdown from the first created config.
if _, err := tx.Exec(ctx, `
UPDATE share_links SET expires_at = NOW() + make_interval(days => validity_days)
WHERE id=$1 AND expires_at IS NULL AND validity_days IS NOT NULL AND validity_days > 0`, link.ID); err != nil {
// Non-critical: leave expires_at untouched if this update fails for any reason.
_ = err
}
if err := tx.Commit(ctx); err != nil {
return nil, errors.New("не удалось сохранить. Обновите страницу и попробуйте ещё раз")
}
return &models.ShareCreation{
ID: creationID, ShareLinkID: link.ID, ServerID: serverID, Protocol: protocol,
ClientID: addResult.ClientID, ConnectionName: name, ResponseJSON: &jsonToStore,
}, nil
}
// TryMigrateConnection moves an existing guest connection to a different
// server/protocol: creates the new connection on the panel first, persists it,
// then removes the old one. It does NOT change use_count (a migration is not a
// new creation).
func (r *Repo) TryMigrateConnection(ctx context.Context, pc *panel.Client, st *settings.Store, link *models.ShareLink, creationID int64, newServerID int, newProtocol string) error {
if !GuestProtocolAllowed(newProtocol) {
return errors.New("Недопустимый протокол.")
}
if newServerID < 0 {
return errors.New("Выберите сервер.")
}
if link == nil {
return errors.New("Ссылка недействительна.")
}
creation, err := r.creationForLinkActive(ctx, link.ID, creationID)
if err != nil {
return err
}
if creation == nil {
return errors.New("Конфигурация не найдена.")
}
if creation.ServerID == newServerID && creation.Protocol == newProtocol {
return errors.New("Конфиг уже на этом сервере с этим протоколом.")
}
tokenShort := link.Token
if len(tokenShort) > 8 {
tokenShort = tokenShort[:8]
}
suffix, err := randomHex(3)
if err != nil {
return err
}
name := fmt.Sprintf("m%s_%s", tokenShort, 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 := SlimPanelJSON(newProtocol, addResult.RawJSON)
_, err = r.Pool.Exec(ctx, `
UPDATE share_link_creations
SET server_id=$1, protocol=$2, client_id=$3, connection_name=$4, response_json=$5, created_at=NOW(),
traffic_bytes_baseline=0, panel_traffic_bytes_total=NULL, panel_traffic_synced_at=NULL
WHERE id=$6 AND share_link_id=$7`,
newServerID, newProtocol, addResult.ClientID, name, jsonToStore, creationID, link.ID)
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("не удалось удалить старый конфиг на сервере %d: %w", creation.ServerID, err)
}
}
return nil
}
// CleanupResult summarizes one CleanupExpired run.
type CleanupResult struct {
Count int
DBCount int
PanelCount int
Failures []string
}
// CleanupExpired scans uncleaned links: expired links with no active creations are
// marked cleaned_at directly; expired links that still have active creations get
// their panel clients removed (all protocol aliases attempted, 404 = success)
// before being marked cleaned_at. Any per-link panel failures are collected in
// Failures without aborting the rest of the run.
func (r *Repo) CleanupExpired(ctx context.Context, pc *panel.Client, st *settings.Store) (CleanupResult, error) {
baseURL := st.PanelURL(ctx)
apiToken := st.PanelToken(ctx)
if baseURL == "" || apiToken == "" {
return CleanupResult{}, errors.New("задайте URL панели и API-токен в настройках")
}
rows, err := r.Pool.Query(ctx, `SELECT `+shareLinkColumns+` FROM share_links WHERE cleaned_at IS NULL ORDER BY id ASC LIMIT 3000`)
if err != nil {
return CleanupResult{}, err
}
var links []models.ShareLink
for rows.Next() {
l, err := scanShareLink(rows)
if err != nil {
rows.Close()
return CleanupResult{}, err
}
links = append(links, *l)
}
rows.Close()
if err := rows.Err(); err != nil {
return CleanupResult{}, err
}
var res CleanupResult
for _, link := range links {
if !link.IsExpired() {
continue
}
creations, err := r.ListCreationsForLink(ctx, link.ID)
if err != nil {
res.Failures = append(res.Failures, fmt.Sprintf("ссылка #%d: %v", link.ID, err))
continue
}
if len(creations) == 0 {
if _, err := r.Pool.Exec(ctx, `UPDATE share_links SET cleaned_at=NOW() WHERE id=$1 AND cleaned_at IS NULL`, link.ID); err == nil {
res.DBCount++
}
continue
}
allOK := true
for _, c := range creations {
if err := pc.RemoveClientSmart(ctx, baseURL, apiToken, c.ServerID, c.Protocol, c.ClientID); err != nil {
allOK = false
res.Failures = append(res.Failures, fmt.Sprintf("ссылка #%d, запись #%d (сервер %d, %s): %v", link.ID, c.ID, c.ServerID, c.Protocol, err))
}
}
if allOK {
_, _ = r.Pool.Exec(ctx, `UPDATE share_link_creations SET deleted_at=NOW() WHERE share_link_id=$1 AND deleted_at IS NULL`, link.ID)
_, _ = r.Pool.Exec(ctx, `UPDATE share_links SET cleaned_at=NOW() WHERE id=$1`, link.ID)
res.PanelCount++
}
}
res.Count = res.DBCount + res.PanelCount
return res, nil
}
// ParseAllowedServerIDs decodes the allowed_server_ids JSONB column into a sorted
// slice of ids. A nil/empty result means "all servers allowed".
func ParseAllowedServerIDs(raw json.RawMessage) []int {
if len(raw) == 0 {
return nil
}
var arr []int
if err := json.Unmarshal(raw, &arr); err != nil {
return nil
}
if len(arr) == 0 {
return nil
}
out := make([]int, 0, len(arr))
seen := map[int]bool{}
for _, id := range arr {
if id < 0 || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
if len(out) == 0 {
return nil
}
sort.Ints(out)
return out
}
// EncodeAllowedServerIDs encodes a server id whitelist back to JSON for storage
// (nil/empty slice = no restriction = NULL in the DB).
func EncodeAllowedServerIDs(serverIDs []int) json.RawMessage {
seen := map[int]bool{}
ids := make([]int, 0, len(serverIDs))
for _, id := range serverIDs {
if id < 0 || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
sort.Ints(ids)
out, _ := json.Marshal(ids)
return out
}
// LinkServerAllowed reports whether serverID is permitted for link (nil whitelist
// means all servers are allowed).
func LinkServerAllowed(link models.ShareLink, serverID int) bool {
if serverID < 0 {
return false
}
allowed := ParseAllowedServerIDs(link.AllowedServerIDs)
if allowed == nil {
return true
}
for _, id := range allowed {
if id == serverID {
return true
}
}
return false
}
// SetAllowedServerIDs persists the server whitelist for a link (empty/nil = all
// servers).
func (r *Repo) SetAllowedServerIDs(ctx context.Context, linkID int, serverIDs []int) error {
if linkID <= 0 {
return errors.New("Некорректная ссылка.")
}
link, err := r.LinkByID(ctx, linkID)
if err != nil {
return err
}
if link == nil {
return errors.New("Ссылка не найдена.")
}
if link.CleanedAt != nil {
return errors.New("Ссылка очищена — серверы не меняют.")
}
encoded := EncodeAllowedServerIDs(serverIDs)
var arg any
if encoded != nil {
arg = encoded
}
_, err = r.Pool.Exec(ctx, `UPDATE share_links SET allowed_server_ids=$1 WHERE id=$2`, arg, linkID)
if err != nil {
return errors.New("не удалось сохранить список серверов")
}
return nil
}
// ExtendLinkByDays extends a link's validity by days: if it has an explicit
// expires_at, that date is pushed forward (from max(now, current) — never
// shrinks); if it is a floating link, validity_days is incremented instead;
// otherwise a brand new expires_at is set. The cosmetic subscription_until is
// extended the same way, and cleaned_at is cleared (a renewed link is active
// again).
func (r *Repo) ExtendLinkByDays(ctx context.Context, linkID, days int) error {
if linkID <= 0 {
return errors.New("Некорректная ссылка.")
}
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if err := r.extendLinkByDaysTx(ctx, tx, linkID, days); err != nil {
return err
}
return tx.Commit(ctx)
}