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
+361
View File
@@ -0,0 +1,361 @@
// Package share implements the guest share-link domain: link/creation repository,
// renewal codes, guest server catalog building and panel-response bundling for
// downloads (config files, vpn:// / vless:// links, zip archives).
package share
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// VpnLinkMaxStoreLen caps the size of vpn_link kept in the DB response_json blob
// (protects against a bloated page / storage row).
const VpnLinkMaxStoreLen = 524288
var (
importURIRe = regexp.MustCompile(`(?i)^(vless|vmess|trojan)://`)
vpnWrapperRe = regexp.MustCompile(`(?i)^vpn://`)
zipNameBadRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
)
// IsAWGFamily reports whether protocol is one of the AmneziaWG variants.
func IsAWGFamily(protocol string) bool {
switch protocol {
case "awg", "awg2", "awg_legacy":
return true
default:
return false
}
}
// IsVLESSFamily reports whether protocol is VLESS/Xray.
func IsVLESSFamily(protocol string) bool {
return protocol == "vless" || protocol == "xray"
}
// UsesImportSlot reports whether the protocol has a dedicated "import link" download
// slot (AWG uses vpn://, VLESS uses vless://); WireGuard only has the .conf slot.
func UsesImportSlot(protocol string) bool {
return IsAWGFamily(protocol) || IsVLESSFamily(protocol)
}
// ProtocolAPI maps a guest-facing protocol id to the Amnezia Web Panel API protocol
// name used when calling /api/servers/{id}/connections/add|remove (vless -> xray).
func ProtocolAPI(protocol string) string {
if protocol == "vless" {
return "xray"
}
return protocol
}
// ProtocolSortRank orders protocols for display: WireGuard first, then AWG family,
// then VLESS/Xray, then anything else.
func ProtocolSortRank(protocol string) int {
switch {
case protocol == "wireguard":
return 0
case IsAWGFamily(protocol):
return 1
case IsVLESSFamily(protocol):
return 2
default:
return 3
}
}
// IsAmneziaVPNWrapper reports whether link is the Amnezia "vpn://base64(...)" wrapper.
func IsAmneziaVPNWrapper(link string) bool {
link = strings.TrimSpace(link)
return link != "" && vpnWrapperRe.MatchString(link)
}
// decodeLooseBase64 tries strict standard base64 first, then falls back to the
// URL-safe alphabet (mirrors PHP's base64_decode(strict) + strtr('-_','+/') dance).
func decodeLooseBase64(payload string) (string, bool) {
if b, err := base64.StdEncoding.DecodeString(payload); err == nil && len(b) > 0 {
return string(b), true
}
if b, err := base64.RawStdEncoding.DecodeString(payload); err == nil && len(b) > 0 {
return string(b), true
}
swapped := strings.NewReplacer("-", "+", "_", "/").Replace(payload)
if b, err := base64.StdEncoding.DecodeString(swapped); err == nil && len(b) > 0 {
return string(b), true
}
if b, err := base64.RawStdEncoding.DecodeString(swapped); err == nil && len(b) > 0 {
return string(b), true
}
return "", false
}
func decodeVpnWrapperPayload(vpnLink string) (string, bool) {
payload := vpnWrapperRe.ReplaceAllString(strings.TrimSpace(vpnLink), "")
if payload == "" {
return "", false
}
return decodeLooseBase64(payload)
}
// VlessImportURI returns the native vless:// (or vmess:// / trojan://) link suitable
// for import into Happ / v2rayTun / Hiddify style clients. It never returns the
// Amnezia vpn:// wrapper — only a plain URI, or "" if none could be resolved.
func VlessImportURI(configText, vpnLink string) string {
configText = strings.TrimSpace(configText)
vpnLink = strings.TrimSpace(vpnLink)
if configText != "" && importURIRe.MatchString(configText) {
return configText
}
if vpnLink != "" && importURIRe.MatchString(vpnLink) {
return vpnLink
}
if IsAmneziaVPNWrapper(vpnLink) {
if decoded, ok := decodeVpnWrapperPayload(vpnLink); ok {
decoded = strings.TrimSpace(decoded)
if decoded != "" && importURIRe.MatchString(decoded) {
return decoded
}
}
}
return ""
}
// AWGImportURI returns the AmneziaWG vpn:// import link from the panel response.
func AWGImportURI(vpnLink, configText string) string {
vpnLink = strings.TrimSpace(vpnLink)
if vpnLink != "" {
return vpnLink
}
return strings.TrimSpace(configText)
}
// ImportDownloadFilename returns the file name used for the "import" download slot
// (vless family -> .txt, AWG/others -> .vpn).
func ImportDownloadFilename(protocol, connectionName string) string {
if IsVLESSFamily(protocol) {
return connectionName + ".txt"
}
return connectionName + ".vpn"
}
// ConfExtension guesses the file extension for the raw "config" text returned by the
// panel, based on protocol and content sniffing.
func ConfExtension(protocol, configText string) string {
hasIface := strings.Contains(configText, "[Interface]")
hasPeer := strings.Contains(configText, "[Peer]")
if protocol == "wireguard" && (hasIface || hasPeer) {
return "conf"
}
if IsAWGFamily(protocol) && hasIface {
return "conf"
}
if IsVLESSFamily(protocol) {
t := strings.TrimLeft(configText, " \t\r\n")
if t != "" && (t[0] == '{' || t[0] == '[') {
return "json"
}
}
return "txt"
}
func vlessURIFromPanelJSON(configText, vpnLink string) string {
// Identical rules to VlessImportURI; kept as a separate name to mirror the PHP
// split between share_panel_json.php and share_downloads.php.
return VlessImportURI(configText, vpnLink)
}
// SlimPanelJSON prepares the panel's raw connection-add JSON response for storage:
// - drops an oversized vpn_link (defensive cap),
// - for VLESS/Xray, replaces "config" with the resolved vless:// URI and strips
// the vpn:// wrapper entirely (only vless:// is ever persisted for VLESS).
//
// Any other protocol JSON is normalized (re-marshalled) but left otherwise intact.
// If jsonBody cannot be parsed as an object, it is returned unchanged.
func SlimPanelJSON(protocol, jsonBody string) string {
var dec map[string]any
if err := json.Unmarshal([]byte(jsonBody), &dec); err != nil {
return jsonBody
}
if v, ok := dec["vpn_link"]; ok {
if s, ok := v.(string); ok && len(s) > VpnLinkMaxStoreLen {
delete(dec, "vpn_link")
}
}
if IsVLESSFamily(protocol) {
cfg, _ := dec["config"].(string)
vpn, _ := dec["vpn_link"].(string)
uri := vlessURIFromPanelJSON(strings.TrimSpace(cfg), strings.TrimSpace(vpn))
if uri != "" {
dec["config"] = uri
}
delete(dec, "vpn_link")
}
out, err := json.Marshal(dec)
if err != nil {
return jsonBody
}
return string(out)
}
// FilePart is a single downloadable artifact (filename + raw body).
type FilePart struct {
Filename string
Body string
Mime string
}
// Bundle groups all downloadable artifacts derived from one panel connection-add
// response: the raw config file, the "import" link file (vpn:// / vless://), and
// (for WireGuard) a convenience .zip archive wrapping the .conf.
type Bundle struct {
Base string
Conf *FilePart
Vpn *FilePart
Zip *FilePart
CreationID int64
CreatedAt time.Time
Protocol string
ConnectionName string
ServerID int
}
// BundleFromResponseJSON extracts "config" / "vpn_link" from a (possibly already
// slimmed) panel JSON response and builds the full download bundle: which files to
// offer, under what names, and — for WireGuard — an in-memory .zip archive.
func BundleFromResponseJSON(protocol, connectionName, jsonBody string) Bundle {
var dec map[string]any
_ = json.Unmarshal([]byte(jsonBody), &dec)
cfgText, _ := dec["config"].(string)
vpnLink, _ := dec["vpn_link"].(string)
isAwg := IsAWGFamily(protocol)
isVless := IsVLESSFamily(protocol)
useImportSlot := UsesImportSlot(protocol)
importBody := ""
switch {
case isVless:
importBody = VlessImportURI(cfgText, vpnLink)
case isAwg:
importBody = AWGImportURI(vpnLink, cfgText)
}
bundle := Bundle{Base: connectionName, Protocol: protocol, ConnectionName: connectionName}
if cfgText != "" {
ext := ConfExtension(protocol, cfgText)
if isAwg && ext == "txt" {
ext = "conf"
}
skipConfDup := isVless && importBody != "" && strings.TrimSpace(cfgText) == importBody
if !skipConfDup {
bundle.Conf = &FilePart{Filename: connectionName + "." + ext, Body: cfgText}
}
}
if useImportSlot && importBody != "" {
bundle.Vpn = &FilePart{Filename: ImportDownloadFilename(protocol, connectionName), Body: importBody}
} else if bundle.Conf == nil && importBody != "" {
ext := ConfExtension(protocol, importBody)
bundle.Conf = &FilePart{Filename: connectionName + "." + ext, Body: importBody}
}
if protocol == "wireguard" && bundle.Conf != nil && bundle.Conf.Body != "" {
if zipBytes, err := WireGuardZipBytes(bundle.Conf.Filename, bundle.Conf.Body); err == nil && len(zipBytes) > 0 {
bundle.Zip = &FilePart{Filename: connectionName + ".zip", Body: string(zipBytes), Mime: "application/zip"}
}
}
return bundle
}
// DownloadPayloadForPart resolves a single download slot ("conf" | "vpn" | "zip")
// for an existing stored connection, given its protocol/connection name/response JSON.
func DownloadPayloadForPart(protocol, connectionName, jsonBody, part string) *FilePart {
part = strings.ToLower(strings.TrimSpace(part))
if part != "conf" && part != "vpn" && part != "zip" {
return nil
}
if strings.TrimSpace(jsonBody) == "" {
return nil
}
bundle := BundleFromResponseJSON(protocol, connectionName, jsonBody)
switch part {
case "conf":
return bundle.Conf
case "vpn":
return bundle.Vpn
case "zip":
return bundle.Zip
default:
return nil
}
}
// WireGuardZipBytes builds an in-memory .zip archive containing a single file
// (the WireGuard .conf) — handy for sending through messengers that mangle bare
// .conf attachments.
func WireGuardZipBytes(innerFilename, confBody string) ([]byte, error) {
name := sanitizeZipEntryName(innerFilename)
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write([]byte(confBody)); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sanitizeZipEntryName(name string) string {
base := filepath.Base(strings.ReplaceAll(name, "\\", "/"))
base = zipNameBadRe.ReplaceAllString(base, "_")
if base == "" || base == "_" {
base = "amnezia.conf"
}
return base
}
// SortBundlesWireguardFirst orders bundles WireGuard -> AWG -> VLESS -> other, then
// newest first within the same protocol group.
func SortBundlesWireguardFirst(bundles []Bundle) []Bundle {
sort.SliceStable(bundles, func(i, j int) bool {
ri, rj := ProtocolSortRank(bundles[i].Protocol), ProtocolSortRank(bundles[j].Protocol)
if ri != rj {
return ri < rj
}
return bundles[i].CreatedAt.After(bundles[j].CreatedAt)
})
return bundles
}
// DisplayBody truncates a config body for inline display on the guest page (full
// text remains available via download).
func DisplayBody(body string, maxLen int) string {
if maxLen < 256 {
maxLen = 256
}
if len(body) <= maxLen {
return body
}
return body[:maxLen] + "\n\n… (сокращено для отображения; скачайте файл целиком)"
}
// QRMaxPayloadLen is the maximum string length considered safe to render as a QR
// code on the guest page.
const QRMaxPayloadLen = 1800
+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
}
+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)
}
+136
View File
@@ -0,0 +1,136 @@
package share
import (
"context"
"sort"
"strings"
"amnezia-share/internal/models"
"amnezia-share/internal/settings"
)
func containsStr(list []string, needle string) bool {
for _, s := range list {
if s == needle {
return true
}
}
return false
}
// ServerSortRank ranks a server for guest-page display: WireGuard-capable first,
// then AWG2-capable, then VLESS-capable, then everything else; disabled servers
// always sort last.
func ServerSortRank(s models.ServerInfo) int {
if s.Disabled {
return 3
}
protos := s.Protocols
if len(protos) == 0 {
protos = DefaultServerProtocols
}
switch {
case containsStr(protos, "wireguard"):
return 0
case containsStr(protos, "awg2"):
return 1
case containsStr(protos, "vless"):
return 2
default:
return 3
}
}
// SortServersWireguardFirst orders servers by ServerSortRank, then by label
// (case-insensitive) within the same rank.
func SortServersWireguardFirst(servers []models.ServerInfo) []models.ServerInfo {
sort.SliceStable(servers, func(i, j int) bool {
ri, rj := ServerSortRank(servers[i]), ServerSortRank(servers[j])
if ri != rj {
return ri < rj
}
li := servers[i].Label
if li == "" {
li = servers[i].Name
}
lj := servers[j].Label
if lj == "" {
lj = servers[j].Name
}
return strings.ToLower(li) < strings.ToLower(lj)
})
return servers
}
// FilterServersForLink drops servers not present in the link's allowed_server_ids
// whitelist (nil whitelist = no filtering).
func FilterServersForLink(link models.ShareLink, servers []models.ServerInfo) []models.ServerInfo {
allowed := ParseAllowedServerIDs(link.AllowedServerIDs)
if allowed == nil {
return servers
}
set := make(map[int]bool, len(allowed))
for _, id := range allowed {
set[id] = true
}
out := make([]models.ServerInfo, 0, len(servers))
for _, s := range servers {
if set[s.ID] {
out = append(out, s)
}
}
return out
}
// ServerProtocolAllowed reports whether protocol may be used on serverID, per the
// admin-configured per-server protocol map. Servers absent from that map default
// to allowing only wireguard/awg2 (matches amnezia_server_protocol_allowed()).
func ServerProtocolAllowed(ctx context.Context, st *settings.Store, serverID int, protocol string) bool {
protosMap := st.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON)
if list, ok := protosMap[serverID]; ok {
return containsStr(list, protocol)
}
return protocol == "wireguard" || protocol == "awg2"
}
// BuildGuestServers assembles the guest-facing server catalog from admin settings:
// labels (id -> display name, normally pre-merged from env/DB/panel_server_labels),
// per-server protocol whitelist (default: wireguard+awg2), flags, speeds, traffic
// notices and the disabled-server list. The result is sorted WireGuard-first.
func BuildGuestServers(ctx context.Context, st *settings.Store, labels map[int]string) []models.ServerInfo {
protosMap := st.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON)
flagsMap := st.JSONMapString(ctx, settings.KeyFlagsJSON)
speedsMap := st.JSONMapString(ctx, settings.KeySpeedsJSON)
noticesMap := st.JSONMapString(ctx, settings.KeyTrafficNotices)
disabled := st.DisabledServers(ctx)
ids := make([]int, 0, len(labels))
for id := range labels {
ids = append(ids, id)
}
sort.Ints(ids)
out := make([]models.ServerInfo, 0, len(ids))
for _, id := range ids {
name := strings.TrimSpace(labels[id])
if name == "" {
continue
}
protocols := protosMap[id]
if len(protocols) == 0 {
protocols = append([]string(nil), DefaultServerProtocols...)
}
out = append(out, models.ServerInfo{
ID: id,
Label: name,
Name: name,
Protocols: protocols,
Disabled: disabled[id],
Flag: flagsMap[id],
Speed: speedsMap[id],
Notice: noticesMap[id],
})
}
return SortServersWireguardFirst(out)
}