834 lines
26 KiB
Go
834 lines
26 KiB
Go
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)
|
|
}
|