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,401 @@
|
||||
// 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": {
|
||||
"<strong>Технические работы.</strong> Новые конфиги и перенос на другой сервер временно недоступны. Уже выданные конфиги можно скачать и скопировать в блоке ниже.",
|
||||
"<strong>Maintenance.</strong> 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": {
|
||||
"На сервере используется протокол <strong>AmneziaWG 2.0</strong>. Подключение работает только в приложении <strong>Amnezia VPN</strong> версии <strong>4.8.12.9</strong> или новее.",
|
||||
"This server uses <strong>AmneziaWG 2.0</strong>. Connection works only in <strong>Amnezia VPN</strong> version <strong>4.8.12.9</strong> or newer.",
|
||||
},
|
||||
"proto_vless_notice_title": {"VLESS — Happ, v2rayTun, Hiddify…", "VLESS — Happ, v2rayTun, Hiddify…"},
|
||||
"proto_vless_notice_body": {
|
||||
"Используйте ссылку <strong>vless://</strong> (файл <strong>.txt</strong>, QR или копирование). Формат <strong>vpn://</strong> с длинной base64-строкой <strong>не подходит</strong> для этих клиентов.",
|
||||
"Use the <strong>vless://</strong> link (.txt file, QR, or copy). The <strong>vpn://</strong> base64 wrapper format does <strong>not</strong> work with these clients.",
|
||||
},
|
||||
|
||||
"hint_wait": {
|
||||
"После нажатия кнопки страница может «висеть» <strong>от 10 секунд до пары минут</strong> — так устроено: сервер в это время создаёт ключ. Не закрывайте вкладку.",
|
||||
"After you click the button, the page may stay busy for <strong>10 seconds up to a couple of minutes</strong> while the server creates your key. Do not close the tab.",
|
||||
},
|
||||
"btn_get_config": {"Получить конфигурацию", "Get configuration"},
|
||||
|
||||
"configs_title": {"Ваши конфигурации", "Your configurations"},
|
||||
"configs_intro": {
|
||||
"Сохранены по этой ссылке: <strong>{count}</strong>. Скачивание и QR работают и после обновления страницы.",
|
||||
"Saved on this link: <strong>{count}</strong>. 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": {
|
||||
"Установите клиент <strong>до</strong> импорта конфигурации. Для WireGuard — официальное приложение; для AmneziaWG 2 и файлов <strong>.vpn</strong> — <strong>Amnezia VPN</strong> версии 4.8.12.9+.",
|
||||
"Install a client <strong>before</strong> importing your config. Use the official WireGuard app for <strong>.conf</strong>; use <strong>Amnezia VPN</strong> 4.8.12.9+ for AmneziaWG 2 and <strong>.vpn</strong> 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
|
||||
}
|
||||
Reference in New Issue
Block a user