// 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