// Package i18n provides bilingual (ru/en) string translation for the guest share // page and portal, plus helpers to resolve/persist the visitor's chosen language // via query string, session and cookie — mirroring includes/share_i18n.php. package i18n import ( "context" "net/http" "strings" "time" "github.com/alexedwards/scs/v2" ) // Lang is a supported UI language. type Lang string const ( RU Lang = "ru" EN Lang = "en" ) // CookieName is the persistent language-preference cookie name (matches the PHP // implementation, so switching between the PHP and Go deployments keeps the same // preference). const CookieName = "share_lang" // SessionKey is the scs session key used to persist the language for the current // session. const SessionKey = "share_lang" // NormalizeLang maps an arbitrary string to a supported Lang, defaulting to // Russian for anything unrecognized (including empty). func NormalizeLang(raw string) Lang { if strings.EqualFold(strings.TrimSpace(raw), string(EN)) { return EN } return RU } // LangFromRequest reads an explicit "lang" query/form parameter from r, if any. func LangFromRequest(r *http.Request) string { if r == nil { return "" } if v := r.URL.Query().Get("lang"); v != "" { return v } if r.Form != nil { if v := r.Form.Get("lang"); v != "" { return v } } return "" } // CookieLang reads the persisted language cookie from r, if present. func CookieLang(r *http.Request) string { if r == nil { return "" } c, err := r.Cookie(CookieName) if err != nil { return "" } return c.Value } // SetCookie writes the language-preference cookie, valid for one year. func SetCookie(w http.ResponseWriter, lang Lang, secure bool) { http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: string(lang), Path: "/", Expires: time.Now().Add(365 * 24 * time.Hour), Secure: secure, HttpOnly: false, SameSite: http.SameSiteLaxMode, }) } // FromSession reads the language stored in the scs session, if any (sm may be // nil, in which case "" is returned). func FromSession(ctx context.Context, sm *scs.SessionManager) string { if sm == nil { return "" } return sm.GetString(ctx, SessionKey) } // SaveToSession stores lang in the scs session (sm may be nil, in which case this // is a no-op). func SaveToSession(ctx context.Context, sm *scs.SessionManager, lang Lang) { if sm == nil { return } sm.Put(ctx, SessionKey, string(lang)) } // Resolve picks the effective language for a request: explicit query/form // parameter first, then the scs session, then the persistent cookie, defaulting // to Russian. It does not write anything; call Persist afterwards to remember the // choice for subsequent requests. func Resolve(r *http.Request, sm *scs.SessionManager) Lang { if q := LangFromRequest(r); q != "" { return NormalizeLang(q) } if sess := FromSession(r.Context(), sm); sess != "" { return NormalizeLang(sess) } if c := CookieLang(r); c != "" { return NormalizeLang(c) } return RU } // Persist stores lang in both the scs session and the cookie, so it "sticks" // across requests exactly like the PHP implementation's session+cookie combo. func Persist(w http.ResponseWriter, r *http.Request, sm *scs.SessionManager, lang Lang, secureCookie bool) { SaveToSession(r.Context(), sm, lang) SetCookie(w, lang, secureCookie) } type entry struct { ru string en string } // catalog holds the bilingual guest-facing strings for the share page, the // renewal-code form, the maintenance banner, the FAQ section titles, and a few // shared portal/status strings. Keys mirror includes/share_i18n.php so existing // front-end code/data can be ported without renaming. var catalog = map[string]entry{ "page_title": {"SECURE LINK — VPN", "SECURE LINK — VPN"}, "lang_ru": {"Русский", "Russian"}, "lang_en": {"English", "English"}, "lang_label": {"Язык", "Language"}, "err_bad_link": {"Некорректная ссылка.", "Invalid link."}, "err_link_not_found": {"Ссылка не найдена.", "Link not found."}, "err_service_unavailable": {"Сервис временно недоступен.", "Service is temporarily unavailable."}, "err_link_expired_plain": {"Ссылка недействительна или срок истёк.", "Link is invalid or has expired."}, "err_file_not_found": {"Файл не найден.", "File not found."}, "err_csrf": {"Обновите страницу и попробуйте снова.", "Refresh the page and try again."}, "err_config_not_found": {"Конфигурация не найдена.", "Configuration not found."}, "err_pick_migrate_server": {"Выберите сервер для переноса.", "Select a server to migrate to."}, "err_server_disabled": {"Этот сервер временно отключён. Выберите другой.", "This server is temporarily disabled. Choose another one."}, "err_pick_server": {"Выберите место подключения из списка или обновите страницу.", "Select a location from the list or refresh the page."}, "err_maintenance_mode": { "Сейчас ведутся технические работы. Создание и перенос конфигов временно недоступны — вы можете скачать или скопировать уже выданные конфиги ниже.", "Maintenance in progress. Creating and migrating configs is temporarily disabled — you can still download or copy your existing configs below.", }, "maintenance_banner": { "Технические работы. Новые конфиги и перенос на другой сервер временно недоступны. Уже выданные конфиги можно скачать и скопировать в блоке ниже.", "Maintenance. New configs and server migration are temporarily unavailable. You can still download and copy your existing configs below.", }, "maintenance_card_title": {"Технические работы", "Maintenance"}, "maintenance_card_body": { "Создание новых конфигов и перенос на другой сервер временно отключены. Если у вас уже есть конфигурации — прокрутите страницу вниз, чтобы скачать или скопировать их.", "Creating new configs and migrating to another server are temporarily disabled. If you already have configurations, scroll down to download or copy them.", }, "ok_created": {"Готово! Осталось созданий: {remaining} из {max}.", "Done! Configs remaining: {remaining} of {max}."}, "ok_migrated": {"Конфигурация перенесена на новый сервер. Скачайте обновлённый файл ниже.", "Configuration migrated to a new server. Download the updated file below."}, "ok_renewed": {"Подписка продлена на {days} дн. Новый срок: {until}.", "Subscription extended by {days} days. New validity: {until}."}, "renewal_title": {"Продлить подписку кодом", "Extend subscription with a code"}, "renewal_hint": {"Введите одноразовый код от администратора. Каждый код можно активировать только один раз.", "Enter a one-time code from your administrator. Each code can only be used once."}, "renewal_hint_short": {"Одноразовый код продлевает подписку.", "One-time code extends your subscription."}, "renewal_code_label": {"Код продления", "Renewal code"}, "renewal_code_ph": {"НАПРИМЕР ABCD1234", "E.G. ABCD1234"}, "renewal_btn": {"Активировать", "Activate"}, "renewal_err_invalid": {"Неверный код продления.", "Invalid renewal code."}, "renewal_err_exhausted": {"Этот код уже использован.", "This code has already been used."}, "renewal_err_code_expired": {"Срок действия кода истёк.", "This code has expired."}, "renewal_err_wrong_link": {"Этот код не подходит к данной ссылке.", "This code does not apply to this link."}, "renewal_err_already_used": {"Этот код уже был активирован для этой ссылки.", "This code was already activated for this link."}, "renewal_err_member_only": {"Этот код для личного кабинета.", "This code is for the member portal."}, "renewal_err_unavailable": {"Продление по коду временно недоступно.", "Code renewal is temporarily unavailable."}, "hero_brand": {"Cyber // Access", "Cyber // Access"}, "hero_title": {"Ваш VPN", "Your VPN"}, "hero_lead": {"Выберите узел и протокол — получите конфиг или QR для Amnezia VPN.", "Choose a node and protocol — get a config file or QR for Amnezia VPN."}, "all_configs": {"Все мои конфигурации ({count})", "All my configurations ({count})"}, "sub_active": {"подписка до {until} · осталось {days} дн.", "subscription until {until} · {days} days left"}, "sub_expired": {"срок подписки истёк ({until})", "subscription expired ({until})"}, "expired_card": {"Срок ссылки истёк или доступ закрыт. Если у вас есть код продления — введите его ниже.", "This link has expired or access was closed. If you have a renewal code, enter it below."}, "limit_reached": {"Создано максимально допустимое число конфигураций ({max}). Новый конфиг по этой ссылке создать нельзя.", "Maximum number of configurations created ({max}). You cannot create another config with this link."}, "term_label": {"Срок:", "Valid until:"}, "stat_remaining": {"Осталось", "Remaining"}, "stat_of": {"из", "of"}, "stat_valid_until": {"Действует до", "Valid until"}, "stat_validity": {"Срок действия", "Validity"}, "expires_from_first": {"с первого конфига — {days} дн.", "from first config — {days} days"}, "field_location": {"Где подключаться", "Where to connect"}, "servers_loading": {"Подгружаем доступные точки…", "Loading available nodes…"}, "field_protocol": {"Тип подключения", "Connection type"}, "proto_wg_hint": {"Классический режим, быстро и стабильно", "Classic mode, fast and stable"}, "proto_awg_hint": {"Если обычный VPN режут по пути", "When regular VPN is blocked en route"}, "proto_vless_hint": {"Обход блокировок через Xray/VLESS", "Bypass blocks via Xray/VLESS"}, "proto_unavailable": {"Недоступен на этом сервере", "Not available on this server"}, "proto_awg_notice_title": {"AmneziaWG 2.0 — требования к приложению", "AmneziaWG 2.0 — app requirements"}, "proto_awg_notice_body": { "На сервере используется протокол AmneziaWG 2.0. Подключение работает только в приложении Amnezia VPN версии 4.8.12.9 или новее.", "This server uses AmneziaWG 2.0. Connection works only in Amnezia VPN version 4.8.12.9 or newer.", }, "proto_vless_notice_title": {"VLESS — Happ, v2rayTun, Hiddify…", "VLESS — Happ, v2rayTun, Hiddify…"}, "proto_vless_notice_body": { "Используйте ссылку vless:// (файл .txt, QR или копирование). Формат vpn:// с длинной base64-строкой не подходит для этих клиентов.", "Use the vless:// link (.txt file, QR, or copy). The vpn:// base64 wrapper format does not work with these clients.", }, "hint_wait": { "После нажатия кнопки страница может «висеть» от 10 секунд до пары минут — так устроено: сервер в это время создаёт ключ. Не закрывайте вкладку.", "After you click the button, the page may stay busy for 10 seconds up to a couple of minutes while the server creates your key. Do not close the tab.", }, "btn_get_config": {"Получить конфигурацию", "Get configuration"}, "configs_title": {"Ваши конфигурации", "Your configurations"}, "configs_intro": { "Сохранены по этой ссылке: {count}. Скачивание и QR работают и после обновления страницы.", "Saved on this link: {count}. Downloads and QR work after refresh.", }, "configs_show_more": {"Показать ещё {count} конфиг.", "Show {count} more config(s)."}, "configs_hide_more": {"Скрыть остальные конфиги", "Hide other configurations"}, "migrate_title": {"Перенести на другой сервер", "Migrate to another server"}, "migrate_new_server": {"Новый сервер", "New server"}, "migrate_protocol": {"Протокол", "Protocol"}, "migrate_warning": {"Старый конфиг будет удалён с текущего сервера и заменён новым. Не считается как новое создание.", "The old config will be removed from the current server and replaced. This does not count as a new creation."}, "migrate_submit": {"Перенести", "Migrate"}, "btn_download": {"Скачать", "Download"}, "btn_download_zip": {"Скачать архив .zip (тот же конфиг внутри)", "Download .zip archive (same config inside)"}, "copy_all": {"Копировать весь текст", "Copy all text"}, "copy_vpn_link": {"Копировать ссылку vpn://", "Copy vpn:// link"}, "copy_vless_link": {"Копировать ссылку vless://", "Copy vless:// link"}, "copied": {"Скопировано", "Copied"}, "qr_import": {"Отсканируйте QR в приложении — импорт без файла.", "Scan the QR in the app — import without a file."}, "qr_too_large": {"Конфиг слишком большой для QR-кода. Используйте файл для импорта.", "Config is too large for a QR code. Use the file to import."}, "qr_failed": {"Не удалось создать QR. Используйте файл.", "Could not create QR. Use the file."}, "busy_create_title": {"Создаём конфигурацию…", "Creating configuration…"}, "busy_step_connect": {"Связь с сервером…", "Connecting to server…"}, "busy_step_key": {"Создаём ключ на сервере…", "Creating key on server…"}, "busy_step_file": {"Готовим файл для скачивания…", "Preparing download file…"}, "busy_hint": {"Обычно 10–60 секунд, иногда до пары минут. Не закрывайте вкладку.", "Usually 10–60 seconds, sometimes up to a couple of minutes. Do not close the tab."}, "busy_migrate_title": {"Переносим конфигурацию…", "Migrating configuration…"}, "downloads_title": {"Скачать приложения", "Download apps"}, "downloads_intro": { "Установите клиент до импорта конфигурации. Для WireGuard — официальное приложение; для AmneziaWG 2 и файлов .vpnAmnezia VPN версии 4.8.12.9+.", "Install a client before importing your config. Use the official WireGuard app for .conf; use Amnezia VPN 4.8.12.9+ for AmneziaWG 2 and .vpn files.", }, "downloads_wg_title": {"WireGuard", "WireGuard"}, "downloads_amnezia_title": {"Amnezia VPN", "Amnezia VPN"}, "downloads_vless_title": {"VLESS / Xray", "VLESS / Xray"}, "faq_title": {"Частые вопросы", "Frequently asked questions"}, "faq_page_title": {"FAQ — Cyber // Access", "FAQ — Cyber // Access"}, "faq_link_short": {"Все ответы в разделе FAQ", "Full answers in the FAQ section"}, "faq_open": {"Открыть FAQ", "Open FAQ"}, "rules_title": {"Правила использования", "Acceptable Use Policy"}, "rules_page_title": {"Правила — Cyber // Access", "Rules — Cyber // Access"}, "rules_link_short": {"Обязательные правила и запреты", "Mandatory rules and prohibitions"}, "rules_open": {"Правила", "Rules"}, "status_link": {"Статус серверов", "Server status"}, "status_page_title": {"Статус серверов", "Server status"}, "status_up": {"Онлайн", "Online"}, "status_down": {"Офлайн", "Offline"}, "server_ready": {"готов", "ready"}, "server_speed_prefix": {"до {speed}", "up to {speed}"}, "js_server_off": {"недоступен", "unavailable"}, "js_servers_empty": {"Список серверов пуст", "Server list is empty"}, "js_servers_load_fail": {"Не удалось загрузить список серверов.", "Could not load server list."}, "js_network_retry": {"Проверьте подключение к интернету и обновите страницу.", "Check your internet connection and refresh the page."}, } // FAQItem is a single FAQ question/answer pair, referencing catalog keys (their // title/short text — see catalog). Handlers can look up full bodies elsewhere; // this list captures the canonical question order and titles for the FAQ page. type FAQItem struct { QuestionKey string AnswerKey string } // FAQItems lists the FAQ question/answer key pairs, in display order, mirroring // SHARE_FAQ_ITEMS from includes/share_i18n.php. Title text for the question keys // is registered in catalog below (faq_q_*); answer bodies are intentionally left // to be supplied by the content layer (they are long, mostly-static HTML blocks // outside the scope of this core string catalog). var FAQItems = []FAQItem{ {"faq_q_how_page", "faq_a_how_page"}, {"faq_q_which_app", "faq_a_which_app"}, {"faq_q_wg_vs_awg", "faq_a_wg_vs_awg"}, {"faq_q_import_wg", "faq_a_import_wg"}, {"faq_q_import_amnezia", "faq_a_import_amnezia"}, {"faq_q_slow_create", "faq_a_slow_create"}, {"faq_q_limit", "faq_a_limit"}, {"faq_q_expired", "faq_a_expired"}, {"faq_q_migrate", "faq_a_migrate"}, {"faq_q_no_connect", "faq_a_no_connect"}, {"faq_q_amnezia_version", "faq_a_amnezia_version"}, {"faq_q_vless", "faq_a_vless"}, {"faq_q_qr", "faq_a_qr"}, {"faq_q_zip", "faq_a_zip"}, {"faq_q_file_where", "faq_a_file_where"}, {"faq_q_site_blocked", "faq_a_site_blocked"}, {"faq_q_share_link", "faq_a_share_link"}, } func init() { faqTitles := map[string]entry{ "faq_q_how_page": {"Как пользоваться этой страницей?", "How do I use this page?"}, "faq_q_which_app": {"Какое приложение установить?", "Which app should I install?"}, "faq_q_wg_vs_awg": {"Чем WireGuard отличается от AmneziaWG 2?", "What is the difference between WireGuard and AmneziaWG 2?"}, "faq_q_import_wg": {"Как импортировать .conf в WireGuard?", "How do I import .conf into WireGuard?"}, "faq_q_import_amnezia": {"Как добавить конфиг в Amnezia VPN?", "How do I add a config in Amnezia VPN?"}, "faq_q_slow_create": {"Почему долго создаётся конфигурация?", "Why does creating a config take so long?"}, "faq_q_limit": {"Сколько конфигов можно создать?", "How many configs can I create?"}, "faq_q_expired": {"Ссылка истекла — что делать?", "The link expired — what now?"}, "faq_q_migrate": {"Что такое «Перенести на другой сервер»?", "What is “Migrate to another server”?"}, "faq_q_no_connect": {"VPN не подключается — что проверить?", "VPN won’t connect — what should I check?"}, "faq_q_amnezia_version": {"Какая версия Amnezia нужна для AmneziaWG 2?", "Which Amnezia version is required for AmneziaWG 2?"}, "faq_q_vless": {"Как подключить VLESS?", "How do I connect with VLESS?"}, "faq_q_qr": {"QR-код не сканируется", "QR code won’t scan"}, "faq_q_zip": {"Зачем архив .zip с тем же конфигом?", "Why is there a .zip with the same config?"}, "faq_q_file_where": {"Скачал файл — где он на телефоне?", "I downloaded a file — where is it on my phone?"}, "faq_q_site_blocked": {"Сайт amnezia.org не открывается", "amnezia.org is blocked in my region"}, "faq_q_share_link": {"Можно ли переслать эту ссылку другим?", "Can I share this link with others?"}, } for k, v := range faqTitles { catalog[k] = v } } // T translates key into lang, substituting {name} placeholders from replace. // Unknown keys are returned verbatim (helps surface missing translations instead // of crashing). func T(lang Lang, key string, replace map[string]string) string { e, ok := catalog[key] if !ok { return key } text := e.ru if lang == EN { text = e.en } for k, v := range replace { text = strings.ReplaceAll(text, "{"+k+"}", v) } return text } // JSMap exports the full catalog for lang as a flat map, ready to serialize as // JSON for client-side script use (mirrors share_i18n_js()). func JSMap(lang Lang) map[string]string { out := make(map[string]string, len(catalog)) for k, e := range catalog { if lang == EN { out[k] = e.en } else { out[k] = e.ru } } return out } // TranslateGuestError best-effort translates a handful of well-known Russian // backend error strings to English (mirrors share_translate_guest_error()); any // other message, or when lang is Russian, is returned unchanged. func TranslateGuestError(lang Lang, msg string) string { if lang != EN { return msg } knownErrors := map[string]string{ "Неполные параметры": "Incomplete parameters", "Ссылка не найдена": "Link not found", "Достигнут лимит использований": "Usage limit reached", "Срок ссылки истёк": "Link has expired", "Ссылка не найдена.": "Link not found.", "Срок действия ссылки истёк.": "The link has expired.", "Недопустимый протокол.": "Invalid protocol.", "Выберите сервер.": "Select a server.", "Конфигурация не найдена.": "Configuration not found.", "Неверный код продления.": "Invalid renewal code.", "Этот код уже исчерпан.": "This code has already been used.", "Срок действия кода истёк.": "This code has expired.", } if v, ok := knownErrors[msg]; ok { return v } return msg }