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
|
||||
Reference in New Issue
Block a user