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