Add Go rewrite with Postgres 17 and Dokploy Docker Compose
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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 = ¬e
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user