From 99a27f00be686cc4fed8355964ac7d0c7baafefa Mon Sep 17 00:00:00 2001 From: orohi Date: Mon, 27 Jul 2026 08:21:21 +0300 Subject: [PATCH] Add Go rewrite with Postgres 17 and Dokploy Docker Compose Co-authored-by: Cursor --- Go/.dockerignore | 7 + Go/.env.example | 21 + Go/.gitignore | 7 + Go/DOKPLOY.md | 7 + Go/Dockerfile | 35 + Go/README.md | 150 + Go/cmd/cleanup/main.go | 59 + Go/cmd/server/main.go | 60 + Go/docker-compose.yml | 110 + Go/go.mod | 19 + Go/go.sum | 90 + Go/internal/auth/auth.go | 168 + Go/internal/auth/oidc.go | 289 ++ Go/internal/config/config.go | 71 + Go/internal/db/db.go | 91 + Go/internal/i18n/i18n.go | 401 ++ Go/internal/member/repo.go | 665 +++ Go/internal/models/models.go | 131 + Go/internal/panel/client.go | 341 ++ Go/internal/settings/store.go | 223 + Go/internal/share/downloads.go | 361 ++ Go/internal/share/renewal.go | 294 ++ Go/internal/share/repo.go | 833 +++ Go/internal/share/servers.go | 136 + Go/internal/web/app.go | 341 ++ Go/internal/web/handlers/admin.go | 40 + Go/internal/web/handlers/admin_configs.go | 114 + Go/internal/web/handlers/admin_index.go | 53 + Go/internal/web/handlers/admin_links.go | 202 + Go/internal/web/handlers/admin_renewal.go | 78 + Go/internal/web/handlers/admin_servers.go | 255 + Go/internal/web/handlers/admin_settings.go | 122 + Go/internal/web/handlers/cabinet.go | 252 + Go/internal/web/handlers/common.go | 138 + Go/internal/web/handlers/faq.go | 79 + Go/internal/web/handlers/health.go | 34 + Go/internal/web/handlers/install.go | 71 + Go/internal/web/handlers/login.go | 100 + Go/internal/web/handlers/oidc.go | 65 + Go/internal/web/handlers/render_smoke_test.go | 186 + Go/internal/web/handlers/share.go | 277 + Go/internal/web/json.go | 27 + Go/internal/web/routes.go | 101 + Go/internal/web/templates_smoke_test.go | 39 + Go/migrations/001_schema.sql | 132 + Go/web/static/country_flag_emojis_data.json | 1267 +++++ Go/web/static/css/admin-cyber.css | 619 +++ Go/web/static/css/admin.css | 4572 +++++++++++++++++ Go/web/static/css/login.css | 259 + Go/web/static/css/portal.css | 465 ++ Go/web/static/css/share.css | 2212 ++++++++ Go/web/templates/admin/configs.html | 56 + Go/web/templates/admin/index.html | 39 + Go/web/templates/admin/links.html | 180 + Go/web/templates/admin/renewal.html | 81 + Go/web/templates/admin/servers.html | 82 + Go/web/templates/admin/settings.html | 80 + Go/web/templates/auth/install.html | 47 + Go/web/templates/auth/login.html | 42 + Go/web/templates/cabinet/cabinet.html | 128 + Go/web/templates/cabinet/login.html | 57 + Go/web/templates/layouts/admin.html | 67 + Go/web/templates/layouts/base.html | 56 + Go/web/templates/share/faq.html | 23 + Go/web/templates/share/rules.html | 47 + Go/web/templates/share/share.html | 173 + Go/web/templates/share/status.html | 37 + 67 files changed, 17864 insertions(+) create mode 100644 Go/.dockerignore create mode 100644 Go/.env.example create mode 100644 Go/.gitignore create mode 100644 Go/DOKPLOY.md create mode 100644 Go/Dockerfile create mode 100644 Go/README.md create mode 100644 Go/cmd/cleanup/main.go create mode 100644 Go/cmd/server/main.go create mode 100644 Go/docker-compose.yml create mode 100644 Go/go.mod create mode 100644 Go/go.sum create mode 100644 Go/internal/auth/auth.go create mode 100644 Go/internal/auth/oidc.go create mode 100644 Go/internal/config/config.go create mode 100644 Go/internal/db/db.go create mode 100644 Go/internal/i18n/i18n.go create mode 100644 Go/internal/member/repo.go create mode 100644 Go/internal/models/models.go create mode 100644 Go/internal/panel/client.go create mode 100644 Go/internal/settings/store.go create mode 100644 Go/internal/share/downloads.go create mode 100644 Go/internal/share/renewal.go create mode 100644 Go/internal/share/repo.go create mode 100644 Go/internal/share/servers.go create mode 100644 Go/internal/web/app.go create mode 100644 Go/internal/web/handlers/admin.go create mode 100644 Go/internal/web/handlers/admin_configs.go create mode 100644 Go/internal/web/handlers/admin_index.go create mode 100644 Go/internal/web/handlers/admin_links.go create mode 100644 Go/internal/web/handlers/admin_renewal.go create mode 100644 Go/internal/web/handlers/admin_servers.go create mode 100644 Go/internal/web/handlers/admin_settings.go create mode 100644 Go/internal/web/handlers/cabinet.go create mode 100644 Go/internal/web/handlers/common.go create mode 100644 Go/internal/web/handlers/faq.go create mode 100644 Go/internal/web/handlers/health.go create mode 100644 Go/internal/web/handlers/install.go create mode 100644 Go/internal/web/handlers/login.go create mode 100644 Go/internal/web/handlers/oidc.go create mode 100644 Go/internal/web/handlers/render_smoke_test.go create mode 100644 Go/internal/web/handlers/share.go create mode 100644 Go/internal/web/json.go create mode 100644 Go/internal/web/routes.go create mode 100644 Go/internal/web/templates_smoke_test.go create mode 100644 Go/migrations/001_schema.sql create mode 100644 Go/web/static/country_flag_emojis_data.json create mode 100644 Go/web/static/css/admin-cyber.css create mode 100644 Go/web/static/css/admin.css create mode 100644 Go/web/static/css/login.css create mode 100644 Go/web/static/css/portal.css create mode 100644 Go/web/static/css/share.css create mode 100644 Go/web/templates/admin/configs.html create mode 100644 Go/web/templates/admin/index.html create mode 100644 Go/web/templates/admin/links.html create mode 100644 Go/web/templates/admin/renewal.html create mode 100644 Go/web/templates/admin/servers.html create mode 100644 Go/web/templates/admin/settings.html create mode 100644 Go/web/templates/auth/install.html create mode 100644 Go/web/templates/auth/login.html create mode 100644 Go/web/templates/cabinet/cabinet.html create mode 100644 Go/web/templates/cabinet/login.html create mode 100644 Go/web/templates/layouts/admin.html create mode 100644 Go/web/templates/layouts/base.html create mode 100644 Go/web/templates/share/faq.html create mode 100644 Go/web/templates/share/rules.html create mode 100644 Go/web/templates/share/share.html create mode 100644 Go/web/templates/share/status.html diff --git a/Go/.dockerignore b/Go/.dockerignore new file mode 100644 index 0000000..575b3de --- /dev/null +++ b/Go/.dockerignore @@ -0,0 +1,7 @@ +.env +.git +*.md +tmp/ +**/*_test.go +.idea/ +.vscode/ diff --git a/Go/.env.example b/Go/.env.example new file mode 100644 index 0000000..2d34230 --- /dev/null +++ b/Go/.env.example @@ -0,0 +1,21 @@ +# Copy to .env before docker compose / Dokploy deploy + +POSTGRES_DB=vpn_admin +POSTGRES_USER=vpn_admin +POSTGRES_PASSWORD=change_me_strong_password + +# Required: random 32+ bytes (hex or base64) +APP_SESSION_SECRET=change_me_to_a_long_random_secret_at_least_32_chars + +# Public URL prefix if behind subpath (empty = domain root). Example: vpn +APP_BASE_URL= + +# Max seconds for Amnezia panel HTTP inside one request +APP_HTTP_BUDGET_SEC=52 + +# Optional overrides (also configurable in admin UI) +AMNEZIA_PANEL_URL= +AMNEZIA_API_TOKEN= +AMNEZIA_SERVER_LABELS_JSON= + +TZ=UTC diff --git a/Go/.gitignore b/Go/.gitignore new file mode 100644 index 0000000..b4ccf1e --- /dev/null +++ b/Go/.gitignore @@ -0,0 +1,7 @@ +.env +*.exe +/bin/ +/tmp/ +.idea/ +.vscode/ +*.log diff --git a/Go/DOKPLOY.md b/Go/DOKPLOY.md new file mode 100644 index 0000000..e63f9d5 --- /dev/null +++ b/Go/DOKPLOY.md @@ -0,0 +1,7 @@ +# Dokploy: deploy from the Go/ directory as a Docker Compose project. +# Domain → container port 30000 (service: app). +# Set secrets in Dokploy Environment: +# POSTGRES_PASSWORD, APP_SESSION_SECRET +# Optional: AMNEZIA_PANEL_URL, AMNEZIA_API_TOKEN, APP_BASE_URL, TZ +# +# Do not expose Postgres publicly — db has no published ports. diff --git a/Go/Dockerfile b/Go/Dockerfile new file mode 100644 index 0000000..e8c154b --- /dev/null +++ b/Go/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# Multi-stage build optimized for Dokploy / docker compose. + +FROM golang:1.22-alpine AS builder +RUN apk add --no-cache git ca-certificates tzdata +WORKDIR /src + +COPY go.mod go.sum* ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server \ + && go build -trimpath -ldflags="-s -w" -o /out/cleanup ./cmd/cleanup + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata wget \ + && addgroup -S app && adduser -S -G app app +WORKDIR /app + +COPY --from=builder /out/server /app/server +COPY --from=builder /out/cleanup /app/cleanup +COPY migrations /app/migrations +COPY web /app/web + +ENV APP_PORT=30000 \ + TZ=UTC + +USER app +EXPOSE 30000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:30000/health || exit 1 + +ENTRYPOINT ["/app/server"] diff --git a/Go/README.md b/Go/README.md new file mode 100644 index 0000000..77018b2 --- /dev/null +++ b/Go/README.md @@ -0,0 +1,150 @@ +# Amnezia VPN Share Panel (Go) + +Полный переезд панели совместного доступа к Amnezia VPN с PHP на Go +(модуль `amnezia-share`). Приложение — единый статически собранный бинарник: +HTTP-сервер, сессии, CSRF, шаблоны и вся бизнес-логика (гостевые +share-ссылки, личный кабинет, админка) внутри одного процесса + PostgreSQL. + +## Состав репозитория + +``` +Go/ +├── cmd/ +│ ├── server/ — основной HTTP-сервер (слушает APP_PORT) +│ └── cleanup/ — разовая задача очистки просроченных share-ссылок +├── internal/ +│ ├── config/ — чтение переменных окружения +│ ├── db/ — подключение к БД + миграции +│ ├── models/ — доменные структуры (ShareLink, Member, ServerInfo, ...) +│ ├── panel/ — клиент Amnezia Web Panel API +│ ├── settings/ — key-value настройки в БД (URL панели, токен, лейблы и т.д.) +│ ├── auth/ — вход администратора, Pocket ID (OIDC) +│ ├── member/ — личный кабинет (регистрация, подписка, конфиги) +│ ├── share/ — гостевые share-ссылки, коды продления, сборка бандлов +│ ├── i18n/ — переводы RU/EN +│ └── web/ — HTTP-слой: App, роуты, middleware, шаблоны +│ └── handlers/ — обработчики, сгруппированные по разделам +├── web/ +│ ├── templates/ — HTML-шаблоны (layouts, share, cabinet, admin, auth) +│ └── static/ — CSS/иконки (Bootstrap 5 подключается через CDN) +├── migrations/ — SQL-схема (идемпотентные CREATE TABLE IF NOT EXISTS) +├── Dockerfile +├── docker-compose.yml +└── .env.example +``` + +## Запуск локально (без Docker) + +Требуется Go 1.22+ и PostgreSQL 14+. + +```bash +cd Go +cp .env.example .env # заполните пароли/секреты +go mod tidy +go build ./... +DATABASE_URL="postgres://vpn_admin:vpn_admin@127.0.0.1:5432/vpn_admin?sslmode=disable" \ + APP_SESSION_SECRET="локальный-дев-секрет-минимум-32-символа" \ + go run ./cmd/server +``` + +Сервер поднимется на `http://127.0.0.1:30000`. При первом запуске +(таблица администраторов пуста) корень `/` перенаправит на `/install` — +форму создания единственного администратора. Дальше вход через `/login`. + +Миграции (`migrations/001_schema.sql`) применяются автоматически при +старте `cmd/server` и `cmd/cleanup` — отдельно накатывать их не нужно. + +## Запуск через Docker Compose / Dokploy + +```bash +cd Go +cp .env.example .env +# отредактируйте .env: POSTGRES_PASSWORD, APP_SESSION_SECRET (32+ символов) +docker compose up -d --build +``` + +Сервисы: + +- **app** — веб-приложение, слушает `30000:30000` (в Dokploy пробросьте + этот порт через ваш прокси/домен). +- **db** — PostgreSQL 17, доступен только внутри docker-сети `amnezia` + (порт 5432 наружу не публикуется; не открывайте его в проде). +- **cleanup** — тот же образ, в цикле раз в 5 минут запускает + `/app/cleanup`, который удаляет истёкшие share-ссылки и их конфиги + на панели Amnezia. + +Проверка живости: `GET /health` → `{"ok":true,"db":true,"time":"..."}`. +Этот же путь используют healthcheck-и в docker-compose. + +### Переменные окружения + +| Переменная | Назначение | По умолчанию | +|---|---|---| +| `APP_PORT` | порт HTTP-сервера | `30000` | +| `APP_BASE_URL` | префикс пути, если приложение висит не на корне домена (например `vpn`) | пусто | +| `APP_SESSION_SECRET` | секрет сессий (используется как соль для CSRF/токенов); задайте случайную строку 32+ символов | — | +| `APP_HTTP_BUDGET_SEC` | таймаут на HTTP-запросы к панели Amnezia в рамках одного запроса | `52` | +| `DATABASE_URL` | строка подключения к PostgreSQL | `postgres://vpn_admin:vpn_admin@127.0.0.1:5432/vpn_admin?sslmode=disable` | +| `AMNEZIA_PANEL_URL`, `AMNEZIA_API_TOKEN` | адрес и токен панели Amnezia (можно также задать в `/admin/settings`) | пусто | +| `AMNEZIA_SERVER_LABELS_JSON` | JSON `{"1":"Германия"}` — переопределение названий серверов через окружение | пусто | +| `MIGRATIONS_DIR` | путь к папке с SQL-миграциями | `migrations` | +| `WEB_DIR` | путь к папке `templates/` и `static/` | `web` | + +Настройки панели, Pocket ID, режим техработ, лейблы/флаги/скорости +серверов и лимиты личного кабинета по умолчанию удобнее менять прямо в +админке (`/admin/settings`, `/admin/servers`) — они хранятся в таблице +`site_settings` и переживают перезапуск контейнера. + +## Основные маршруты + +Гостевые: + +- `GET /` — редирект на `/admin`, `/install` или `/login` в зависимости от состояния. +- `GET/POST /install` — создание единственного администратора (доступно, пока админов нет). +- `GET/POST /login`, `GET /login?oidc=1`, `GET /oidc/callback`, `GET /logout` — вход администратора (пароль или Pocket ID SSO). +- `GET/POST /share?k=ТОКЕН` — гостевая страница share-ссылки: выбор сервера/протокола, создание/перенос/продление конфига, скачивание. POST с заголовком `X-Share-Async: 1` отвечает JSON. +- `GET /share/servers?k=`, `GET /share/download?k=&cre=&part=conf|vpn|zip`. +- `GET /faq`, `GET /rules`, `GET /status` — статические страницы + пинг серверов. +- `GET/POST /cabinet`, `/cabinet/login`, `/cabinet/register`, `/cabinet/logout`, `/cabinet/servers`, `/cabinet/download` — личный кабинет. + +Администрирование (требует входа): + +- `GET /admin` — дашборд. +- `GET/POST /admin/links` — управление share-ссылками (создание, продление, список серверов, удаление конфигов). +- `GET/POST /admin/renewal` — коды продления (для гостевых ссылок и/или личного кабинета). +- `GET/POST /admin/configs` — обзор всех выданных конфигов с фильтрами и удалением. +- `GET/POST /admin/servers` — метки, протоколы, флаги, скорость и отключение серверов панели. +- `GET/POST /admin/settings` — URL/токен панели, Pocket ID, техработы, лимиты кабинета, проверка соединения. + +Статика: `/static/*` раздаётся из `WEB_DIR/static`. + +## Миграция с PHP-версии + +- Схема БД совместима: `migrations/001_schema.sql` использует + `CREATE TABLE IF NOT EXISTS` — можно указать Go-приложению ту же базу, + на которой уже работала PHP-панель, без потери данных. +- Ключи в таблице настроек (`site_settings`) переиспользованы 1:1 + (`amnezia_panel_url`, `amnezia_api_token`, `amnezia_server_labels_json`, + `share_maintenance_mode`, `pocket_id_*` и т.д.) — значения, заданные в + PHP-админке, подхватятся автоматически. +- Cookie для языка (`share_lang`) и сессии называются иначе + (`amnezia_session`), поэтому после переключения все пользователи один + раз залогинятся заново — это нормально. +- Разовый крон-скрипт очистки истёкших ссылок (`cleanup_share.php`) + заменён на бинарник `cmd/cleanup`, который в docker-compose запускается + в цикле каждые 5 минут отдельным контейнером. +- Файлы конфигов больше не пишутся на диск — тело ответа панели + сохраняется в БД (`share_creations.response_json`) и конфиги + собираются "на лету" при скачивании, как и в PHP-версии. + +## Тесты + +```bash +go build ./... +go test ./... +``` + +`internal/web` и `internal/web/handlers` содержат smoke-тесты, которые +парсят все HTML-шаблоны и рендерят каждую страницу с реалистичными +данными без подключения к базе — это ловит опечатки в шаблонах ещё на +этапе CI, до реального деплоя. diff --git a/Go/cmd/cleanup/main.go b/Go/cmd/cleanup/main.go new file mode 100644 index 0000000..45ecb1f --- /dev/null +++ b/Go/cmd/cleanup/main.go @@ -0,0 +1,59 @@ +// Command cleanup runs one pass of expired-share-link cleanup and exits; meant +// to be invoked periodically (cron, systemd timer, or the docker-compose +// "cleanup" service loop). +package main + +import ( + "context" + "log" + "time" + + "github.com/joho/godotenv" + + "amnezia-share/internal/config" + "amnezia-share/internal/db" + "amnezia-share/internal/panel" + "amnezia-share/internal/settings" + "amnezia-share/internal/share" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + pool, err := db.Connect(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("подключение к БД: %v", err) + } + defer pool.Close() + + if err := db.Migrate(ctx, pool, cfg.MigrationsDir); err != nil { + log.Fatalf("миграции: %v", err) + } + + st := &settings.Store{ + Pool: pool, + EnvPanelURL: cfg.PanelURL, + EnvToken: cfg.PanelToken, + EnvLabelsJSON: cfg.ServerLabelsJSON, + } + pc := panel.New(cfg.HTTPBudget()) + repo := share.New(pool) + + runCtx, runCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer runCancel() + + res, err := repo.CleanupExpired(runCtx, pc, st) + if err != nil { + log.Fatalf("очистка не выполнена: %v", err) + } + + log.Printf("очистка завершена: всего=%d (без конфигов=%d, снято с панели=%d), ошибок=%d", + res.Count, res.DBCount, res.PanelCount, len(res.Failures)) + for _, f := range res.Failures { + log.Printf(" - %s", f) + } +} diff --git a/Go/cmd/server/main.go b/Go/cmd/server/main.go new file mode 100644 index 0000000..40dbc9f --- /dev/null +++ b/Go/cmd/server/main.go @@ -0,0 +1,60 @@ +// Command server is the Amnezia VPN Share Panel HTTP application entrypoint. +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + + "amnezia-share/internal/config" + "amnezia-share/internal/web" + _ "amnezia-share/internal/web/handlers" +) + +func main() { + _ = godotenv.Load() + + cfg := config.Load() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + app, err := web.New(ctx, cfg) + cancel() + if err != nil { + log.Fatalf("не удалось запустить приложение: %v", err) + } + defer app.Close() + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: app.Handler(), + ReadHeaderTimeout: 15 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 3 * time.Minute, + IdleTimeout: 90 * time.Second, + } + + go func() { + log.Printf("amnezia-share слушает на порту %s (base url: %q)", cfg.Port, cfg.BaseURL) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("сервер остановлен с ошибкой: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + log.Println("получен сигнал остановки, завершаем работу...") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("ошибка при остановке сервера: %v", err) + } + log.Println("сервер остановлен.") +} diff --git a/Go/docker-compose.yml b/Go/docker-compose.yml new file mode 100644 index 0000000..3dd80a9 --- /dev/null +++ b/Go/docker-compose.yml @@ -0,0 +1,110 @@ +# Amnezia VPN Share Panel (Go) — Dokploy / Docker Compose +# App listens on host port 30000. Postgres 17 is internal-only. + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: amnezia-share-go:latest + container_name: amnezia-share-app + restart: unless-stopped + ports: + - "30000:30000" + environment: + APP_PORT: "30000" + APP_BASE_URL: ${APP_BASE_URL:-} + APP_HTTP_BUDGET_SEC: ${APP_HTTP_BUDGET_SEC:-52} + APP_SESSION_SECRET: ${APP_SESSION_SECRET:?set APP_SESSION_SECRET} + DATABASE_URL: postgres://${POSTGRES_USER:-vpn_admin}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-vpn_admin}?sslmode=disable + TZ: ${TZ:-UTC} + AMNEZIA_PANEL_URL: ${AMNEZIA_PANEL_URL:-} + AMNEZIA_API_TOKEN: ${AMNEZIA_API_TOKEN:-} + AMNEZIA_SERVER_LABELS_JSON: ${AMNEZIA_SERVER_LABELS_JSON:-} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:30000/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 25s + networks: + - amnezia + # Dokploy: keep logs bounded + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + cleanup: + image: amnezia-share-go:latest + container_name: amnezia-share-cleanup + restart: unless-stopped + entrypoint: ["/bin/sh", "-c"] + command: + - | + while true; do + /app/cleanup || true + sleep 300 + done + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-vpn_admin}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-vpn_admin}?sslmode=disable + APP_HTTP_BUDGET_SEC: ${APP_HTTP_BUDGET_SEC:-52} + AMNEZIA_PANEL_URL: ${AMNEZIA_PANEL_URL:-} + AMNEZIA_API_TOKEN: ${AMNEZIA_API_TOKEN:-} + TZ: ${TZ:-UTC} + depends_on: + db: + condition: service_healthy + app: + condition: service_started + healthcheck: + disable: true + networks: + - amnezia + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + + db: + image: postgres:17-alpine + container_name: amnezia-share-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-vpn_admin} + POSTGRES_USER: ${POSTGRES_USER:-vpn_admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + TZ: ${TZ:-UTC} + volumes: + - postgres_data:/var/lib/postgresql/data + # Do NOT publish 5432 publicly in Dokploy — app reaches db via internal network. + # Uncomment only for local debugging: + # ports: + # - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + networks: + - amnezia + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + postgres_data: + name: amnezia-share-pg17 + +networks: + amnezia: + name: amnezia-share-net + driver: bridge diff --git a/Go/go.mod b/Go/go.mod new file mode 100644 index 0000000..cfe5320 --- /dev/null +++ b/Go/go.mod @@ -0,0 +1,19 @@ +module amnezia-share + +go 1.22 + +require ( + github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de + github.com/alexedwards/scs/v2 v2.8.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.32.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/Go/go.sum b/Go/go.sum new file mode 100644 index 0000000..b412ac7 --- /dev/null +++ b/Go/go.sum @@ -0,0 +1,90 @@ +github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de h1:wNJVpr0ag/BL2nRGBIESdLe1qoljXIolF/qPi1gleRA= +github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de/go.mod h1:hwveArYcjyOK66EViVgVU5Iqj7zyEsWjKXMQhDJrTLI= +github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= +github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/Go/internal/auth/auth.go b/Go/internal/auth/auth.go new file mode 100644 index 0000000..c34452b --- /dev/null +++ b/Go/internal/auth/auth.go @@ -0,0 +1,168 @@ +// Package auth implements admin authentication: password hashing/verification, +// login, and (single) first-admin self-registration. See oidc.go for the Pocket ID +// SSO flow, which also creates the first admin automatically. +package auth + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "amnezia-share/internal/models" +) + +var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + +// BcryptCost is the cost factor used for admin password hashes. +const BcryptCost = bcrypt.DefaultCost + +// HashPassword hashes password with bcrypt at BcryptCost. +func HashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return "", fmt.Errorf("хеширование пароля: %w", err) + } + return string(b), nil +} + +// VerifyPassword reports whether password matches the bcrypt hash. +func VerifyPassword(hash, password string) bool { + if hash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// ValidateUsername enforces the admin username policy: 3-64 chars, latin letters, +// digits, dot, dash, underscore only. +func ValidateUsername(username string) error { + if len(username) < 3 || len(username) > 64 { + return errors.New("Логин: от 3 до 64 символов.") + } + if !usernameRe.MatchString(username) { + return errors.New("Логин: только латиница, цифры, точка, дефис, подчёркивание.") + } + return nil +} + +// ValidatePassword enforces the admin password policy: at least 10 characters. +func ValidatePassword(password string) error { + if len(password) < 10 { + return errors.New("Пароль не короче 10 символов.") + } + return nil +} + +// Repo is the PostgreSQL-backed repository for admin accounts (the "users" table). +type Repo struct { + Pool *pgxpool.Pool +} + +// New builds a Repo bound to pool. +func New(pool *pgxpool.Pool) *Repo { + return &Repo{Pool: pool} +} + +// AdminCount returns the number of registered admin accounts. +func (r *Repo) AdminCount(ctx context.Context) (int, error) { + var c int + if err := r.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&c); err != nil { + return 0, err + } + return c, nil +} + +// GetAdminByID loads an admin by id, or (nil, nil) if not found. +func (r *Repo) GetAdminByID(ctx context.Context, id int) (*models.User, error) { + if id <= 0 { + return nil, nil + } + var u models.User + err := r.Pool.QueryRow(ctx, `SELECT id, username, password_hash, role, created_at FROM users WHERE id=$1 LIMIT 1`, id). + Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +// GetAdminByUsername loads an admin by exact username match, or (nil, nil) if not +// found. +func (r *Repo) GetAdminByUsername(ctx context.Context, username string) (*models.User, error) { + var u models.User + err := r.Pool.QueryRow(ctx, `SELECT id, username, password_hash, role, created_at FROM users WHERE username=$1 LIMIT 1`, username). + Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +// LoginAdmin verifies username/password and returns the matching admin user. +func (r *Repo) LoginAdmin(ctx context.Context, username, password string) (*models.User, error) { + username = strings.TrimSpace(username) + u, err := r.GetAdminByUsername(ctx, username) + if err != nil { + return nil, errors.New("не удалось выполнить вход") + } + if u == nil || !VerifyPassword(u.PasswordHash, password) { + return nil, errors.New("Неверный логин или пароль.") + } + return u, nil +} + +// RegisterFirstAdmin creates the single administrator account. It fails if any +// admin already exists (use the login form instead). +func (r *Repo) RegisterFirstAdmin(ctx context.Context, username, password string) (*models.User, error) { + count, err := r.AdminCount(ctx) + if err != nil { + return nil, errors.New("не удалось проверить администраторов") + } + if count > 0 { + return nil, errors.New("Администратор уже зарегистрирован. Войдите через форму входа.") + } + + username = strings.TrimSpace(username) + if err := ValidateUsername(username); err != nil { + return nil, err + } + if err := ValidatePassword(password); err != nil { + return nil, err + } + + hash, err := HashPassword(password) + if err != nil { + return nil, errors.New("Не удалось сформировать хеш пароля.") + } + + var id int + err = r.Pool.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,'admin') RETURNING id`, username, hash).Scan(&id) + if err != nil { + return nil, errors.New("Ошибка БД: возможно, логин уже занят.") + } + return r.GetAdminByID(ctx, id) +} + +// registerAdminWithHash inserts a new admin with a pre-computed password hash. +// Used by the OIDC auto-provisioning flow (oidc.go) where the local password is +// random and never used to sign in directly. +func (r *Repo) registerAdminWithHash(ctx context.Context, username, passwordHash string) (*models.User, error) { + var id int + err := r.Pool.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,'admin') RETURNING id`, username, passwordHash).Scan(&id) + if err != nil { + return nil, fmt.Errorf("создание администратора: %w", err) + } + return r.GetAdminByID(ctx, id) +} diff --git a/Go/internal/auth/oidc.go b/Go/internal/auth/oidc.go new file mode 100644 index 0000000..9b7d585 --- /dev/null +++ b/Go/internal/auth/oidc.go @@ -0,0 +1,289 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "amnezia-share/internal/models" +) + +// OIDCConfig holds the Pocket ID SSO settings (see settings.KeyPocketURL / +// KeyPocketClientID / KeyPocketSecret in internal/settings). +type OIDCConfig struct { + URL string + ClientID string + ClientSecret string +} + +// Configured reports whether all three OIDC settings are present. +func (c OIDCConfig) Configured() bool { + return strings.TrimSpace(c.URL) != "" && strings.TrimSpace(c.ClientID) != "" && strings.TrimSpace(c.ClientSecret) != "" +} + +func (c OIDCConfig) baseURL() string { + return strings.TrimRight(strings.TrimSpace(c.URL), "/") +} + +// GenerateState returns a random hex CSRF state value for the OIDC authorize +// request. +func GenerateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return fmt.Sprintf("%x", b), nil +} + +// GeneratePKCE returns a PKCE code_verifier and its S256 code_challenge +// (RFC 7636), both base64url-encoded without padding. +func GeneratePKCE() (verifier, challenge string, err error) { + raw := make([]byte, 32) + if _, err = rand.Read(raw); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +// AuthURL builds the Pocket ID /authorize URL for the PKCE S256 authorization-code +// flow. +func AuthURL(cfg OIDCConfig, redirectURI, state, codeChallenge string) string { + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", redirectURI) + q.Set("scope", "openid profile email") + q.Set("state", state) + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + return cfg.baseURL() + "/authorize?" + q.Encode() +} + +// UserInfo is the subset of the OIDC userinfo response we care about. +type UserInfo struct { + PreferredUsername string + Name string + Email string + Sub string +} + +// LookupName picks the identifier used to match against the local users table: +// preferred_username, then name, then email, then sub. +func (u UserInfo) LookupName() string { + switch { + case u.PreferredUsername != "": + return u.PreferredUsername + case u.Name != "": + return u.Name + case u.Email != "": + return u.Email + default: + return u.Sub + } +} + +var httpClientOIDC = &http.Client{Timeout: 15 * time.Second} + +// ExchangeAndUserinfo exchanges an authorization code for an access token and +// fetches the user profile. It mirrors the Pocket ID quirks handled by the PHP +// implementation: +// - token endpoint tried at /api/oidc/token first, then /token, +// - for each endpoint, Basic auth (client_id:client_secret) is tried first, then +// falls back to client_id/client_secret in the POST body, +// - userinfo endpoint tried at /api/oidc/userinfo first, then /userinfo. +func ExchangeAndUserinfo(ctx context.Context, cfg OIDCConfig, code, redirectURI, codeVerifier string) (UserInfo, error) { + base := cfg.baseURL() + tokenEndpoints := []string{base + "/api/oidc/token", base + "/token"} + basicAuth := base64.StdEncoding.EncodeToString([]byte(cfg.ClientID + ":" + cfg.ClientSecret)) + + var accessToken string + var lastTokenErr error + for _, endpoint := range tokenEndpoints { + fields := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + if codeVerifier != "" { + fields.Set("code_verifier", codeVerifier) + } + if tok, err := postTokenRequest(ctx, endpoint, fields, map[string]string{"Authorization": "Basic " + basicAuth}); err == nil && tok != "" { + accessToken = tok + break + } else if err != nil { + lastTokenErr = err + } + + fieldsSecret := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + "client_id": {cfg.ClientID}, + "client_secret": {cfg.ClientSecret}, + } + if codeVerifier != "" { + fieldsSecret.Set("code_verifier", codeVerifier) + } + if tok, err := postTokenRequest(ctx, endpoint, fieldsSecret, nil); err == nil && tok != "" { + accessToken = tok + break + } else if err != nil { + lastTokenErr = err + } + } + if accessToken == "" { + if lastTokenErr != nil { + return UserInfo{}, fmt.Errorf("Pocket ID не вернул access_token: %w", lastTokenErr) + } + return UserInfo{}, errors.New("Pocket ID не вернул access_token.") + } + + userinfoEndpoints := []string{base + "/api/oidc/userinfo", base + "/userinfo"} + var raw map[string]any + var lastErr error + for _, endpoint := range userinfoEndpoints { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + lastErr = err + continue + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + resp, err := httpClientOIDC.Do(req) + if err != nil { + lastErr = err + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + lastErr = fmt.Errorf("userinfo HTTP %d", resp.StatusCode) + continue + } + if err := json.Unmarshal(body, &raw); err != nil { + lastErr = err + continue + } + lastErr = nil + break + } + if raw == nil { + if lastErr == nil { + lastErr = errors.New("нет ответа") + } + return UserInfo{}, fmt.Errorf("не удалось получить профиль от Pocket ID: %w", lastErr) + } + + info := UserInfo{ + PreferredUsername: strings.TrimSpace(stringAny(raw["preferred_username"])), + Name: strings.TrimSpace(stringAny(raw["name"])), + Email: strings.TrimSpace(stringAny(raw["email"])), + Sub: strings.TrimSpace(stringAny(raw["sub"])), + } + if info.Sub == "" && info.PreferredUsername == "" && info.Name == "" && info.Email == "" { + return UserInfo{}, errors.New("Pocket ID не вернул идентификатор пользователя.") + } + return info, nil +} + +func stringAny(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func postTokenRequest(ctx context.Context, endpoint string, fields url.Values, headers map[string]string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(fields.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := httpClientOIDC.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("token HTTP %d", resp.StatusCode) + } + var dec map[string]any + if err := json.Unmarshal(body, &dec); err != nil { + return "", err + } + tok, _ := dec["access_token"].(string) + tok = strings.TrimSpace(tok) + if tok == "" { + return "", errors.New("нет access_token в ответе") + } + return tok, nil +} + +// FindOrCreateAdminFromOIDC matches the Pocket ID profile to an existing admin by +// username (preferred_username/name/email/sub, in that priority) or by e-mail. +// If no admin exists yet at all, the very first successful OIDC login +// auto-provisions the sole admin account (with a random, never-used local +// password). Otherwise, an unmatched profile is rejected. +func (r *Repo) FindOrCreateAdminFromOIDC(ctx context.Context, info UserInfo) (*models.User, error) { + lookupName := info.LookupName() + if lookupName == "" { + return nil, errors.New("Pocket ID не вернул идентификатор пользователя.") + } + + admin, err := r.GetAdminByUsername(ctx, lookupName) + if err != nil { + return nil, err + } + if admin == nil && info.Email != "" { + admin, err = r.GetAdminByUsername(ctx, info.Email) + if err != nil { + return nil, err + } + } + + if admin == nil { + count, err := r.AdminCount(ctx) + if err != nil { + return nil, err + } + if count != 0 { + return nil, fmt.Errorf("пользователь %q не зарегистрирован как администратор", lookupName) + } + + randomPass := make([]byte, 32) + if _, err := rand.Read(randomPass); err != nil { + return nil, err + } + hash, err := HashPassword(fmt.Sprintf("%x", randomPass)) + if err != nil { + return nil, err + } + admin, err = r.registerAdminWithHash(ctx, lookupName, hash) + if err != nil { + return nil, err + } + } + + if admin.Role != "admin" { + return nil, errors.New("Учётная запись не является администратором.") + } + return admin, nil +} diff --git a/Go/internal/config/config.go b/Go/internal/config/config.go new file mode 100644 index 0000000..38ea29c --- /dev/null +++ b/Go/internal/config/config.go @@ -0,0 +1,71 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Port string + BaseURL string + HTTPBudgetSec int + SessionSecret string + DatabaseURL string + PanelURL string + PanelToken string + ServerLabelsJSON string + MigrationsDir string + WebDir string +} + +func Load() Config { + budget := envInt("APP_HTTP_BUDGET_SEC", 52) + return Config{ + Port: env("APP_PORT", "30000"), + BaseURL: strings.Trim(env("APP_BASE_URL", ""), "/"), + HTTPBudgetSec: budget, + SessionSecret: env("APP_SESSION_SECRET", "dev-insecure-change-me-please-32chars"), + DatabaseURL: env("DATABASE_URL", "postgres://vpn_admin:vpn_admin@127.0.0.1:5432/vpn_admin?sslmode=disable"), + PanelURL: env("AMNEZIA_PANEL_URL", ""), + PanelToken: env("AMNEZIA_API_TOKEN", ""), + ServerLabelsJSON: env("AMNEZIA_SERVER_LABELS_JSON", ""), + MigrationsDir: env("MIGRATIONS_DIR", "migrations"), + WebDir: env("WEB_DIR", "web"), + } +} + +func (c Config) HTTPBudget() time.Duration { + if c.HTTPBudgetSec <= 0 { + return 0 + } + return time.Duration(c.HTTPBudgetSec) * time.Second +} + +func (c Config) AppURL(path string) string { + path = strings.TrimLeft(path, "/") + if c.BaseURL == "" { + return "/" + path + } + return "/" + c.BaseURL + "/" + path +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envInt(k string, def int) int { + v := os.Getenv(k) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} diff --git a/Go/internal/db/db.go b/Go/internal/db/db.go new file mode 100644 index 0000000..9b4c9b5 --- /dev/null +++ b/Go/internal/db/db.go @@ -0,0 +1,91 @@ +package db + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse database url: %w", err) + } + cfg.MaxConns = 20 + cfg.MinConns = 2 + cfg.MaxConnLifetime = time.Hour + cfg.MaxConnIdleTime = 15 * time.Minute + cfg.HealthCheckPeriod = time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping: %w", err) + } + return pool, nil +} + +func Migrate(ctx context.Context, pool *pgxpool.Pool, dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + var files []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + files = append(files, e.Name()) + } + sort.Strings(files) + + _, err = pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`) + if err != nil { + return err + } + + for _, name := range files { + var exists bool + if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename=$1)`, name).Scan(&exists); err != nil { + return err + } + if exists { + continue + } + body, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return err + } + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (filename) VALUES ($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + } + return nil +} diff --git a/Go/internal/i18n/i18n.go b/Go/internal/i18n/i18n.go new file mode 100644 index 0000000..f20ee4c --- /dev/null +++ b/Go/internal/i18n/i18n.go @@ -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": { + "Технические работы. Новые конфиги и перенос на другой сервер временно недоступны. Уже выданные конфиги можно скачать и скопировать в блоке ниже.", + "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 +} diff --git a/Go/internal/member/repo.go b/Go/internal/member/repo.go new file mode 100644 index 0000000..e7c267b --- /dev/null +++ b/Go/internal/member/repo.go @@ -0,0 +1,665 @@ +// Package member implements the member self-service portal domain: registration, +// login, subscription bootstrap/extension, and per-member panel connections +// ("configs"), mirroring the guest share-link flows in internal/share but scoped +// to a logged-in member account instead of an anonymous token link. +package member + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "amnezia-share/internal/auth" + "amnezia-share/internal/models" + "amnezia-share/internal/panel" + "amnezia-share/internal/settings" + "amnezia-share/internal/share" +) + +var ( + usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) +) + +// Config is one panel connection created for a member (the "member_configs" row). +type Config struct { + ID int64 + MemberID int + ServerID int + Protocol string + ClientID string + ConnectionName string + ResponseJSON *string + CreatedAt time.Time + DeletedAt *time.Time +} + +// Repo is the PostgreSQL-backed repository for member accounts, subscriptions and +// their created panel connections. +type Repo struct { + Pool *pgxpool.Pool +} + +// New builds a Repo bound to pool. +func New(pool *pgxpool.Pool) *Repo { + return &Repo{Pool: pool} +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// Register creates a new member account. email is optional. +func (r *Repo) Register(ctx context.Context, username, password, email string) (*models.Member, error) { + username = strings.TrimSpace(username) + email = strings.TrimSpace(email) + + if len(username) < 3 || len(username) > 64 { + return nil, errors.New("Логин: от 3 до 64 символов.") + } + if !usernameRe.MatchString(username) { + return nil, errors.New("Логин: только латиница, цифры, точка, дефис, подчёркивание.") + } + if len(password) < 8 { + return nil, errors.New("Пароль не короче 8 символов.") + } + if email != "" && !emailRe.MatchString(email) { + return nil, errors.New("Некорректный e-mail.") + } + + hash, err := auth.HashPassword(password) + if err != nil { + return nil, errors.New("Не удалось сохранить пароль.") + } + + var emailVal *string + if email != "" { + emailVal = &email + } + + var id int + err = r.Pool.QueryRow(ctx, `INSERT INTO members (username, email, password_hash) VALUES ($1,$2,$3) RETURNING id`, username, emailVal, hash).Scan(&id) + if err != nil { + return nil, errors.New("Логин или e-mail уже занят.") + } + return r.GetByID(ctx, id) +} + +// Login verifies username/password and returns the matching member, bumping +// last_login_at. +func (r *Repo) Login(ctx context.Context, username, password string) (*models.Member, error) { + username = strings.TrimSpace(username) + var m models.Member + err := r.Pool.QueryRow(ctx, `SELECT id, username, email, password_hash, created_at, last_login_at FROM members WHERE username=$1 LIMIT 1`, username). + Scan(&m.ID, &m.Username, &m.Email, &m.PasswordHash, &m.CreatedAt, &m.LastLoginAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("Неверный логин или пароль.") + } + if err != nil { + return nil, err + } + if !auth.VerifyPassword(m.PasswordHash, password) { + return nil, errors.New("Неверный логин или пароль.") + } + _, _ = r.Pool.Exec(ctx, `UPDATE members SET last_login_at=NOW() WHERE id=$1`, m.ID) + return &m, nil +} + +// GetByID loads a member by id, or (nil, nil) if not found. +func (r *Repo) GetByID(ctx context.Context, id int) (*models.Member, error) { + if id <= 0 { + return nil, nil + } + var m models.Member + err := r.Pool.QueryRow(ctx, `SELECT id, username, email, password_hash, created_at, last_login_at FROM members WHERE id=$1 LIMIT 1`, id). + Scan(&m.ID, &m.Username, &m.Email, &m.PasswordHash, &m.CreatedAt, &m.LastLoginAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +// Subscription loads a member's subscription row, or (nil, nil) if it has not +// been bootstrapped yet. +func (r *Repo) Subscription(ctx context.Context, memberID int) (*models.MemberSubscription, error) { + if memberID <= 0 { + return nil, nil + } + var s models.MemberSubscription + err := r.Pool.QueryRow(ctx, `SELECT member_id, expires_at, validity_days, max_configs, config_count, cleaned_at FROM member_subscriptions WHERE member_id=$1 LIMIT 1`, memberID). + Scan(&s.MemberID, &s.ExpiresAt, &s.ValidityDays, &s.MaxConfigs, &s.ConfigCount, &s.CleanedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +func subscriptionDefaults(ctx context.Context, st *settings.Store) (days, maxConfigs int) { + days, maxConfigs = 30, 5 + if st == nil { + return + } + if v, _ := st.Get(ctx, settings.KeyMemberDays); strings.TrimSpace(v) != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + days = n + } + } + if v, _ := st.Get(ctx, settings.KeyMemberMaxUses); strings.TrimSpace(v) != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + maxConfigs = n + } + } + days = clampInt(days, 1, 3650) + maxConfigs = clampInt(maxConfigs, 1, 1000) + return +} + +// EnsureSubscription returns the member's existing subscription, or bootstraps a +// new one using site-wide defaults (settings keys member_default_days / +// member_default_max_uses, falling back to 30 days / 5 configs). +func (r *Repo) EnsureSubscription(ctx context.Context, st *settings.Store, memberID int) (*models.MemberSubscription, error) { + if memberID <= 0 { + return nil, errors.New("Некорректный пользователь.") + } + existing, err := r.Subscription(ctx, memberID) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + + days, maxConfigs := subscriptionDefaults(ctx, st) + _, err = r.Pool.Exec(ctx, `INSERT INTO member_subscriptions (member_id, expires_at, validity_days, max_configs, config_count) VALUES ($1, NULL, $2, $3, 0)`, + memberID, days, maxConfigs) + if err != nil { + return nil, fmt.Errorf("не удалось создать подписку: %w", err) + } + return r.Subscription(ctx, memberID) +} + +func subscriptionExpired(sub models.MemberSubscription) bool { + if sub.CleanedAt != nil { + return true + } + vd := 0 + if sub.ValidityDays != nil { + vd = *sub.ValidityDays + } + if vd > 0 && sub.ExpiresAt == nil { + return false + } + if sub.ExpiresAt == nil { + return true + } + return sub.ExpiresAt.Before(time.Now()) +} + +// SubscriptionExpired reports whether sub has expired (exported wrapper for +// callers outside this package, e.g. handlers building the portal view). +func SubscriptionExpired(sub models.MemberSubscription) bool { + return subscriptionExpired(sub) +} + +// SubscriptionExpiresLabel formats a human label for the subscription's validity: +// an explicit date, "from first config — N days" for floating subscriptions, or +// "—" if unknown. +func SubscriptionExpiresLabel(sub models.MemberSubscription) string { + if sub.ExpiresAt != nil { + return sub.ExpiresAt.Format("2006-01-02 15:04:05") + } + if sub.ValidityDays != nil && *sub.ValidityDays > 0 { + return fmt.Sprintf("с первого конфига — %d дн.", *sub.ValidityDays) + } + return "—" +} + +func (r *Repo) extendSubscriptionByDaysTx(ctx context.Context, tx pgx.Tx, memberID, days int) error { + if days < 1 || days > 3650 { + return errors.New("Число дней должно быть от 1 до 3650.") + } + var expiresAt *time.Time + var validityDays *int + var cleanedAt *time.Time + err := tx.QueryRow(ctx, `SELECT expires_at, validity_days, cleaned_at FROM member_subscriptions WHERE member_id=$1 FOR UPDATE`, memberID). + Scan(&expiresAt, &validityDays, &cleanedAt) + if errors.Is(err, pgx.ErrNoRows) { + return errors.New("Подписка не найдена.") + } + if err != nil { + return err + } + if cleanedAt != nil { + return errors.New("Доступ закрыт — продление невозможно.") + } + + 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 member_subscriptions SET expires_at=$1 WHERE member_id=$2`, newExp, memberID); err != nil { + return errors.New("не удалось продлить подписку") + } + case validityDays != nil && *validityDays > 0: + newVd := *validityDays + days + if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET validity_days=$1 WHERE member_id=$2`, newVd, memberID); err != nil { + return errors.New("не удалось продлить подписку") + } + default: + newExp := now.AddDate(0, 0, days) + if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET expires_at=$1 WHERE member_id=$2`, newExp, memberID); err != nil { + return errors.New("не удалось продлить подписку") + } + } + return nil +} + +// ExtendSubscriptionByDays extends a member's subscription validity by days +// (mirrors share.Repo.ExtendLinkByDays, applied to member_subscriptions instead). +func (r *Repo) ExtendSubscriptionByDays(ctx context.Context, memberID, days int) error { + if memberID <= 0 { + return errors.New("Некорректные параметры продления.") + } + tx, err := r.Pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := r.extendSubscriptionByDaysTx(ctx, tx, memberID, days); err != nil { + return err + } + return tx.Commit(ctx) +} + +// RedeemResult is returned by TryRedeemRenewal on success. +type RedeemResult struct { + AddDays int + Sub *models.MemberSubscription +} + +// TryRedeemRenewal applies a "member"-targeted renewal code (code_target=member, +// not bound to any share_link_id) to the member's subscription: validates the +// code, extends the subscription, and records a one-time redemption per +// code+member — all inside a single transaction. +func (r *Repo) TryRedeemRenewal(ctx context.Context, memberID int, codeRaw string) (*RedeemResult, error) { + sub, err := r.Subscription(ctx, memberID) + if err != nil { + return nil, err + } + if sub == nil { + return nil, errors.New("Подписка не найдена.") + } + if sub.CleanedAt != nil { + return nil, errors.New("Доступ закрыт — продление невозможно.") + } + + code := share.NormalizeCode(codeRaw) + if len(code) < 6 || len(code) > 32 { + return nil, errors.New("Код должен содержать от 6 до 32 латинских букв или цифр.") + } + + var codeID, addDays, maxUses, useCount int + var shareLinkID *int + var codeExpiresAt *time.Time + var codeTarget string + err = r.Pool.QueryRow(ctx, `SELECT id, add_days, max_uses, use_count, share_link_id, code_expires_at, code_target FROM share_renewal_codes WHERE code=$1 LIMIT 1`, code). + Scan(&codeID, &addDays, &maxUses, &useCount, &shareLinkID, &codeExpiresAt, &codeTarget) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("Неверный код продления.") + } + if err != nil { + return nil, err + } + + if codeTarget != "member" { + return nil, errors.New("Этот код предназначен для гостевой ссылки, не для кабинета.") + } + if shareLinkID != nil { + return nil, errors.New("Этот код привязан к гостевой ссылке.") + } + if addDays < 1 { + return nil, errors.New("Код настроен некорректно.") + } + if useCount >= maxUses { + return nil, errors.New("Этот код уже исчерпан.") + } + if codeExpiresAt != nil && codeExpiresAt.Before(time.Now()) { + return nil, errors.New("Срок действия кода истёк.") + } + + var existingID int64 + err = r.Pool.QueryRow(ctx, `SELECT id FROM member_renewal_redemptions WHERE renewal_code_id=$1 AND member_id=$2 LIMIT 1`, codeID, memberID).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.extendSubscriptionByDaysTx(ctx, tx, memberID, addDays); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, `INSERT INTO member_renewal_redemptions (renewal_code_id, member_id, add_days) VALUES ($1,$2,$3)`, codeID, memberID, 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`, codeID); err != nil { + return nil, errors.New("не удалось применить код. Попробуйте позже") + } + if err := tx.Commit(ctx); err != nil { + return nil, errors.New("не удалось применить код. Попробуйте позже") + } + + fresh, err := r.Subscription(ctx, memberID) + if err != nil || fresh == nil { + fresh = sub + } + return &RedeemResult{AddDays: addDays, Sub: fresh}, nil +} + +const configColumns = `id, member_id, server_id, protocol, client_id, connection_name, response_json, created_at, deleted_at` + +func scanConfig(row interface { + Scan(dest ...any) error +}) (*Config, error) { + var c Config + err := row.Scan(&c.ID, &c.MemberID, &c.ServerID, &c.Protocol, &c.ClientID, &c.ConnectionName, &c.ResponseJSON, &c.CreatedAt, &c.DeletedAt) + if err != nil { + return nil, err + } + return &c, nil +} + +// ListConfigs returns a member's active (not soft-deleted) configs, oldest first. +func (r *Repo) ListConfigs(ctx context.Context, memberID int) ([]Config, error) { + rows, err := r.Pool.Query(ctx, `SELECT `+configColumns+` FROM member_configs WHERE member_id=$1 AND deleted_at IS NULL ORDER BY id ASC`, memberID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Config + for rows.Next() { + c, err := scanConfig(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func (r *Repo) configByID(ctx context.Context, memberID int, configID int64) (*Config, error) { + row := r.Pool.QueryRow(ctx, `SELECT `+configColumns+` FROM member_configs WHERE id=$1 AND member_id=$2 AND deleted_at IS NULL LIMIT 1`, configID, memberID) + c, err := scanConfig(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return c, nil +} + +// Bundles returns download bundles for all of a member's active configs, sorted +// WireGuard-first (see share.SortBundlesWireguardFirst). +func (r *Repo) Bundles(ctx context.Context, memberID int) ([]share.Bundle, error) { + rows, err := r.ListConfigs(ctx, memberID) + if err != nil { + return nil, err + } + out := make([]share.Bundle, 0, len(rows)) + for _, row := range rows { + if row.ResponseJSON == nil || strings.TrimSpace(*row.ResponseJSON) == "" { + continue + } + if row.Protocol == "" || row.ConnectionName == "" { + continue + } + jsonBody := share.SlimPanelJSON(row.Protocol, *row.ResponseJSON) + bundle := share.BundleFromResponseJSON(row.Protocol, row.ConnectionName, jsonBody) + bundle.CreationID = row.ID + bundle.CreatedAt = row.CreatedAt + bundle.ServerID = row.ServerID + out = append(out, bundle) + } + return share.SortBundlesWireguardFirst(out), nil +} + +// DownloadPayload resolves a single download slot ("conf" | "vpn" | "zip") for one +// of a member's configs. +func (r *Repo) DownloadPayload(ctx context.Context, memberID int, configID int64, part string) (*share.FilePart, error) { + c, err := r.configByID(ctx, memberID, configID) + if err != nil { + return nil, err + } + if c == nil || c.ResponseJSON == nil { + return nil, nil + } + return share.DownloadPayloadForPart(c.Protocol, c.ConnectionName, *c.ResponseJSON, part), nil +} + +// TryAddConfig creates a new panel connection for a member and records it. Like +// share.Repo.TryAddConnection, the (slow) panel HTTP call happens before opening a +// DB transaction. +func (r *Repo) TryAddConfig(ctx context.Context, pc *panel.Client, st *settings.Store, memberID, serverID int, protocol string) (*Config, error) { + if !share.GuestProtocolAllowed(protocol) { + return nil, errors.New("Недопустимый протокол.") + } + if serverID < 0 { + return nil, errors.New("Выберите сервер.") + } + if memberID <= 0 { + return nil, errors.New("Требуется вход в кабинет.") + } + + sub, err := r.Subscription(ctx, memberID) + if err != nil { + return nil, err + } + if sub == nil { + return nil, errors.New("Подписка не найдена.") + } + if sub.CleanedAt != nil || subscriptionExpired(*sub) { + return nil, errors.New("Срок доступа истёк. Продлите по коду.") + } + if sub.ConfigCount >= sub.MaxConfigs { + return nil, fmt.Errorf("лимит конфигов (%d) исчерпан", sub.MaxConfigs) + } + + prefixSuffix, err := randomHex(2) + if err != nil { + return nil, err + } + nextNum := sub.ConfigCount + 1 + nameSuffix, err := randomHex(3) + if err != nil { + return nil, err + } + name := fmt.Sprintf("u%d_%s_%d_%s", memberID, prefixSuffix, nextNum, nameSuffix) + + baseURL := st.PanelURL(ctx) + apiToken := st.PanelToken(ctx) + + 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 lockedConfigCount, lockedMaxConfigs int + var lockedExpiresAt *time.Time + var lockedValidityDays *int + err = tx.QueryRow(ctx, `SELECT cleaned_at, config_count, max_configs, expires_at, validity_days FROM member_subscriptions WHERE member_id=$1 FOR UPDATE`, memberID). + Scan(&lockedCleanedAt, &lockedConfigCount, &lockedMaxConfigs, &lockedExpiresAt, &lockedValidityDays) + if err != nil { + return nil, errors.New("Доступ недействителен.") + } + lockedSub := models.MemberSubscription{MemberID: memberID, CleanedAt: lockedCleanedAt, ConfigCount: lockedConfigCount, MaxConfigs: lockedMaxConfigs, ExpiresAt: lockedExpiresAt, ValidityDays: lockedValidityDays} + if lockedSub.CleanedAt != nil { + return nil, errors.New("Доступ недействителен.") + } + if subscriptionExpired(lockedSub) { + return nil, errors.New("Срок доступа истёк.") + } + if lockedSub.ConfigCount >= lockedSub.MaxConfigs { + return nil, errors.New("Лимит конфигов исчерпан.") + } + + jsonToStore := share.SlimPanelJSON(protocol, addResult.RawJSON) + var configID int64 + err = tx.QueryRow(ctx, ` + INSERT INTO member_configs (member_id, server_id, protocol, client_id, connection_name, response_json) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + memberID, serverID, protocol, addResult.ClientID, name, jsonToStore, + ).Scan(&configID) + if err != nil { + return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") + } + + if _, err := tx.Exec(ctx, `UPDATE member_subscriptions SET config_count = config_count + 1 WHERE member_id=$1`, memberID); err != nil { + return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") + } + if _, err := tx.Exec(ctx, ` + UPDATE member_subscriptions SET expires_at = NOW() + make_interval(days => validity_days) + WHERE member_id=$1 AND expires_at IS NULL AND validity_days IS NOT NULL AND validity_days > 0`, memberID); err != nil { + _ = err // non-critical + } + + if err := tx.Commit(ctx); err != nil { + return nil, errors.New("не удалось сохранить конфиг. Попробуйте ещё раз") + } + + return &Config{ID: configID, MemberID: memberID, ServerID: serverID, Protocol: protocol, ClientID: addResult.ClientID, ConnectionName: name, ResponseJSON: &jsonToStore}, nil +} + +// TryMigrate is the free server/protocol migration for a member's config: creates +// the new connection first, persists it, then removes the old one. It does not +// change config_count. +func (r *Repo) TryMigrate(ctx context.Context, pc *panel.Client, st *settings.Store, memberID int, configID int64, newServerID int, newProtocol string) error { + if !share.GuestProtocolAllowed(newProtocol) || newServerID < 0 { + return errors.New("Укажите сервер и протокол.") + } + + creation, err := r.configByID(ctx, memberID, configID) + if err != nil { + return err + } + if creation == nil { + return errors.New("Конфигурация не найдена.") + } + + sub, err := r.Subscription(ctx, memberID) + if err != nil { + return err + } + if sub == nil || sub.CleanedAt != nil || subscriptionExpired(*sub) { + return errors.New("Срок доступа истёк.") + } + + if creation.ServerID == newServerID && creation.Protocol == newProtocol { + return errors.New("Конфиг уже на этом сервере с этим протоколом.") + } + + suffix, err := randomHex(3) + if err != nil { + return err + } + name := fmt.Sprintf("m%d_%s", memberID, 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 := share.SlimPanelJSON(newProtocol, addResult.RawJSON) + _, err = r.Pool.Exec(ctx, ` + UPDATE member_configs SET server_id=$1, protocol=$2, client_id=$3, connection_name=$4, response_json=$5, created_at=NOW() + WHERE id=$6 AND member_id=$7`, + newServerID, newProtocol, addResult.ClientID, name, jsonToStore, configID, memberID) + 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("не удалось удалить старый конфиг: %w", err) + } + } + return nil +} + +// SoftDelete removes a member's panel connection: it is first removed from the +// Amnezia panel (all known protocol aliases attempted, 404 = success), then +// marked deleted_at in the DB. config_count is left untouched (deleting a config +// does not refund quota), matching the guest-side share.Repo.SoftDeleteCreation +// behaviour. +func (r *Repo) SoftDelete(ctx context.Context, pc *panel.Client, st *settings.Store, memberID int, configID int64) error { + row := r.Pool.QueryRow(ctx, `SELECT `+configColumns+` FROM member_configs WHERE id=$1 AND member_id=$2 LIMIT 1`, configID, memberID) + c, err := scanConfig(row) + if errors.Is(err, pgx.ErrNoRows) { + return errors.New("Конфигурация не найдена.") + } + if err != nil { + return err + } + if c.DeletedAt != nil { + return nil + } + + baseURL := st.PanelURL(ctx) + apiToken := st.PanelToken(ctx) + if err := pc.RemoveClientSmart(ctx, baseURL, apiToken, c.ServerID, c.Protocol, c.ClientID); err != nil { + return fmt.Errorf("панель: %w", err) + } + + _, err = r.Pool.Exec(ctx, `UPDATE member_configs SET deleted_at=NOW() WHERE id=$1 AND member_id=$2 AND deleted_at IS NULL`, configID, memberID) + return err +} diff --git a/Go/internal/models/models.go b/Go/internal/models/models.go new file mode 100644 index 0000000..bc7309b --- /dev/null +++ b/Go/internal/models/models.go @@ -0,0 +1,131 @@ +package models + +import ( + "encoding/json" + "time" +) + +type User struct { + ID int + Username string + PasswordHash string + Role string + CreatedAt time.Time +} + +type ShareLink struct { + ID int + Token string + ServerID *int + ExpiresAt *time.Time + MaxUses int + ValidityDays *int + UseCount int + CreatedAt time.Time + CleanedAt *time.Time + TrafficQuotaGB *float64 + TrafficUsedGB float64 + SubscriptionUntil *time.Time + AllowedServerIDs json.RawMessage +} + +type ShareCreation struct { + ID int64 + ShareLinkID int + ServerID int + Protocol string + ClientID string + ConnectionName string + ResponseJSON *string + CreatedAt time.Time + DeletedAt *time.Time +} + +type RenewalCode struct { + ID int + Code string + AddDays int + MaxUses int + UseCount int + ShareLinkID *int + CodeExpiresAt *time.Time + Note *string + CodeTarget string + CreatedAt time.Time +} + +type Member struct { + ID int + Username string + Email *string + PasswordHash string + CreatedAt time.Time + LastLoginAt *time.Time +} + +type MemberSubscription struct { + MemberID int + ExpiresAt *time.Time + ValidityDays *int + MaxConfigs int + ConfigCount int + CleanedAt *time.Time +} + +type ServerInfo struct { + ID int `json:"id"` + Label string `json:"label"` + Name string `json:"name,omitempty"` + Protocols []string `json:"protocols"` + Disabled bool `json:"disabled"` + Flag string `json:"flag,omitempty"` + FlagURL string `json:"flag_url,omitempty"` + Speed string `json:"speed,omitempty"` + Notice string `json:"notice,omitempty"` +} + +func (l ShareLink) IsFloating() bool { + return l.ValidityDays != nil && *l.ValidityDays > 0 && l.ExpiresAt == nil +} + +func (l ShareLink) IsExpired() bool { + if l.IsFloating() { + return false + } + if l.ExpiresAt == nil { + return true + } + return l.ExpiresAt.Before(time.Now()) +} + +func (l ShareLink) ExpiresLabel() string { + if l.ExpiresAt != nil { + return l.ExpiresAt.Format("2006-01-02 15:04:05") + } + if l.ValidityDays != nil && *l.ValidityDays > 0 { + return "с первого конфига — " + itoa(*l.ValidityDays) + " дн." + } + return "—" +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/Go/internal/panel/client.go b/Go/internal/panel/client.go new file mode 100644 index 0000000..e270d39 --- /dev/null +++ b/Go/internal/panel/client.go @@ -0,0 +1,341 @@ +package panel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +var tokenRe = regexp.MustCompile(`^awp_[A-Za-z0-9_-]+$`) + +type Client struct { + HTTPBudget time.Duration + httpClient *http.Client + cacheMu sync.Mutex + serverCache map[string]serverCacheEntry +} + +type serverCacheEntry struct { + servers []Server + until time.Time +} + +type Server struct { + ID int `json:"id"` + Name string `json:"name"` + Host string `json:"host"` +} + +type Response struct { + Code int + Body []byte + Err error +} + +type AddResult struct { + ClientID string + RawJSON string +} + +func New(budget time.Duration) *Client { + return &Client{ + HTTPBudget: budget, + httpClient: &http.Client{Timeout: 120 * time.Second}, + serverCache: map[string]serverCacheEntry{}, + } +} + +func NormalizeToken(raw string) string { + t := strings.TrimSpace(raw) + t = strings.TrimPrefix(t, "\ufeff") + t = strings.Map(func(r rune) rune { + switch r { + case '\u200b', '\u200c', '\u200d', '\ufeff', '\u2060', + '\u202a', '\u202b', '\u202c', '\u202d', '\u202e': + return -1 + default: + return r + } + }, t) + t = strings.TrimSpace(t) + if strings.HasPrefix(strings.ToLower(t), "bearer ") { + t = strings.TrimSpace(t[7:]) + } + return strings.Trim(t, " \t\n\r\"'") +} + +func TokenOK(t string) bool { + return len(t) >= 24 && tokenRe.MatchString(t) +} + +func APIProtocol(protocol string) string { + if protocol == "vless" { + return "xray" + } + return protocol +} + +func CreateTimeout(protocol string, budget time.Duration) time.Duration { + var d time.Duration + switch protocol { + case "vless", "xray": + d = 100 * time.Second + default: + d = 75 * time.Second + } + if budget > 0 && d > budget { + d = budget + } + if d < 5*time.Second { + d = 5 * time.Second + } + return d +} + +func (c *Client) Request(ctx context.Context, method, baseURL, path, token string, jsonBody []byte, timeout time.Duration) Response { + if timeout <= 0 { + timeout = 60 * time.Second + } + if c.HTTPBudget > 0 && timeout > c.HTTPBudget { + timeout = c.HTTPBudget + } + url := strings.TrimRight(baseURL, "/") + path + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var body io.Reader + if jsonBody != nil { + body = bytes.NewReader(jsonBody) + } + req, err := http.NewRequestWithContext(reqCtx, method, url, body) + if err != nil { + return Response{Err: err} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "AmneziaSiteBridge/1.0 (+Go; Dokploy)") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if jsonBody != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return Response{Err: err} + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + return Response{Code: resp.StatusCode, Body: b} +} + +func (c *Client) AddConnection(ctx context.Context, baseURL, token string, serverID int, protocol, name string) (AddResult, error) { + payload, _ := json.Marshal(map[string]string{ + "protocol": APIProtocol(protocol), + "name": name, + }) + timeout := CreateTimeout(protocol, c.HTTPBudget) + resp := c.Request(ctx, http.MethodPost, baseURL, fmt.Sprintf("/api/servers/%d/connections/add", serverID), token, payload, timeout) + if resp.Err != nil { + if strings.Contains(strings.ToLower(resp.Err.Error()), "timeout") || strings.Contains(strings.ToLower(resp.Err.Error()), "deadline") { + return AddResult{}, fmt.Errorf("Сервер создаёт ключ слишком долго. Подождите минуту и попробуйте снова.") + } + return AddResult{}, fmt.Errorf("Не удалось создать подключение.") + } + if resp.Code < 200 || resp.Code >= 300 { + return AddResult{}, fmt.Errorf("Не удалось создать подключение. Выберите другой пункт или тип VPN.") + } + var dec map[string]any + _ = json.Unmarshal(resp.Body, &dec) + clientID := "" + if v, ok := dec["client_id"].(string); ok { + clientID = strings.TrimSpace(v) + } + if clientID == "" { + if v, ok := dec["clientId"].(string); ok { + clientID = strings.TrimSpace(v) + } + } + if clientID == "" { + return AddResult{}, fmt.Errorf("Сервис не выдал ключ подключения.") + } + return AddResult{ClientID: clientID, RawJSON: string(resp.Body)}, nil +} + +func (c *Client) RemoveConnection(ctx context.Context, baseURL, token string, serverID int, protocol, clientID string, treat404 bool) error { + clientID = strings.TrimSpace(clientID) + protocol = strings.TrimSpace(protocol) + if clientID == "" || protocol == "" { + return fmt.Errorf("неполные параметры удаления") + } + payload, _ := json.Marshal(map[string]string{ + "protocol": APIProtocol(protocol), + "client_id": clientID, + }) + resp := c.Request(ctx, http.MethodPost, baseURL, fmt.Sprintf("/api/servers/%d/connections/remove", serverID), token, payload, 45*time.Second) + if resp.Err != nil { + return resp.Err + } + if resp.Code >= 200 && resp.Code < 300 { + return nil + } + if resp.Code == 404 && treat404 { + return nil + } + return fmt.Errorf("HTTP %d", resp.Code) +} + +func (c *Client) RemoveClientSmart(ctx context.Context, baseURL, token string, serverID int, protocol, clientID string) error { + protos := uniqueStrings([]string{APIProtocol(protocol), protocol, "vless", "xray", "awg2", "wireguard", "awg", "awg_legacy"}) + var last error + for _, p := range protos { + if err := c.RemoveConnection(ctx, baseURL, token, serverID, p, clientID, true); err == nil { + return nil + } else { + last = err + } + } + if last == nil { + last = fmt.Errorf("не удалось удалить") + } + return last +} + +func (c *Client) ListServers(ctx context.Context, baseURL, token string) ([]Server, error) { + key := strings.TrimRight(baseURL, "/") + c.cacheMu.Lock() + if e, ok := c.serverCache[key]; ok && time.Now().Before(e.until) { + out := append([]Server(nil), e.servers...) + c.cacheMu.Unlock() + return out, nil + } + c.cacheMu.Unlock() + + paths := []string{"/api/servers", "/api/servers/", "/api/v1/servers"} + var servers []Server + for _, p := range paths { + resp := c.Request(ctx, http.MethodGet, baseURL, p, token, nil, 20*time.Second) + if resp.Err != nil || resp.Code < 200 || resp.Code >= 300 { + continue + } + servers = parseServers(resp.Body) + if len(servers) > 0 { + break + } + } + c.cacheMu.Lock() + c.serverCache[key] = serverCacheEntry{servers: servers, until: time.Now().Add(2 * time.Minute)} + c.cacheMu.Unlock() + return servers, nil +} + +func (c *Client) ClearCache() { + c.cacheMu.Lock() + c.serverCache = map[string]serverCacheEntry{} + c.cacheMu.Unlock() +} + +func (c *Client) Ping(ctx context.Context, baseURL, token string, serverID int) (alive bool, ms int, errMsg string) { + resp := c.Request(ctx, http.MethodGet, baseURL, fmt.Sprintf("/api/servers/%d/ping", serverID), token, nil, 15*time.Second) + if resp.Err != nil { + return false, 0, resp.Err.Error() + } + var dec map[string]any + _ = json.Unmarshal(resp.Body, &dec) + if v, ok := dec["alive"].(bool); ok { + alive = v + } + switch v := dec["ms"].(type) { + case float64: + ms = int(v) + case int: + ms = v + } + if e, ok := dec["error"].(string); ok { + errMsg = e + } + return +} + +func parseServers(body []byte) []Server { + var root any + if err := json.Unmarshal(body, &root); err != nil { + return nil + } + var list []any + switch t := root.(type) { + case []any: + list = t + case map[string]any: + for _, k := range []string{"servers", "items", "results", "list"} { + if arr, ok := t[k].([]any); ok { + list = arr + break + } + } + if list == nil { + if data, ok := t["data"].(map[string]any); ok { + if arr, ok := data["servers"].([]any); ok { + list = arr + } + } + } + } + out := make([]Server, 0, len(list)) + for _, item := range list { + m, ok := item.(map[string]any) + if !ok { + continue + } + id := anyInt(m["id"]) + name, _ := m["name"].(string) + host, _ := m["host"].(string) + if host == "" { + host, _ = m["ip"].(string) + } + out = append(out, Server{ID: id, Name: name, Host: host}) + } + return out +} + +func anyInt(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case json.Number: + n, _ := t.Int64() + return int(n) + case string: + var n int + fmt.Sscanf(t, "%d", &n) + return n + default: + return 0 + } +} + +func uniqueStrings(in []string) []string { + seen := map[string]struct{}{} + var out []string + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} diff --git a/Go/internal/settings/store.go b/Go/internal/settings/store.go new file mode 100644 index 0000000..740ec9f --- /dev/null +++ b/Go/internal/settings/store.go @@ -0,0 +1,223 @@ +package settings + +import ( + "context" + "encoding/json" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "amnezia-share/internal/panel" +) + +const ( + KeyPanelURL = "amnezia_panel_url" + KeyAPIToken = "amnezia_api_token" + KeyServerLabelsJSON = "amnezia_server_labels_json" + KeyTrafficNotices = "amnezia_share_server_traffic_notices_json" + KeyProtocolsJSON = "amnezia_server_protocols_json" + KeyFlagsJSON = "amnezia_server_flags_json" + KeyDisabledServers = "amnezia_disabled_servers_json" + KeySpeedsJSON = "amnezia_server_speeds_json" + KeyMaintenance = "share_maintenance_mode" + KeyPocketURL = "pocket_id_url" + KeyPocketClientID = "pocket_id_client_id" + KeyPocketSecret = "pocket_id_client_secret" + KeyMemberDays = "member_default_days" + KeyMemberMaxUses = "member_default_max_uses" +) + +type Store struct { + Pool *pgxpool.Pool + EnvPanelURL string + EnvToken string + EnvLabelsJSON string +} + +func (s *Store) Get(ctx context.Context, key string) (string, error) { + var v *string + err := s.Pool.QueryRow(ctx, `SELECT svalue FROM site_settings WHERE skey=$1`, key).Scan(&v) + if err != nil { + return "", nil + } + if v == nil { + return "", nil + } + return *v, nil +} + +func (s *Store) Set(ctx context.Context, key, value string) error { + _, err := s.Pool.Exec(ctx, ` + INSERT INTO site_settings (skey, svalue, updated_at) VALUES ($1,$2,NOW()) + ON CONFLICT (skey) DO UPDATE SET svalue=EXCLUDED.svalue, updated_at=NOW()`, key, value) + return err +} + +func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) { + out := map[string]string{} + for _, k := range keys { + out[k] = "" + } + rows, err := s.Pool.Query(ctx, `SELECT skey, svalue FROM site_settings WHERE skey = ANY($1)`, keys) + if err != nil { + return out, nil + } + defer rows.Close() + for rows.Next() { + var k string + var v *string + if err := rows.Scan(&k, &v); err != nil { + continue + } + if v != nil { + out[k] = *v + } + } + return out, nil +} + +func (s *Store) PanelURL(ctx context.Context) string { + v, _ := s.Get(ctx, KeyPanelURL) + v = strings.TrimSpace(v) + if v != "" { + return v + } + return strings.TrimSpace(s.EnvPanelURL) +} + +func (s *Store) PanelToken(ctx context.Context) string { + v, _ := s.Get(ctx, KeyAPIToken) + v = panel.NormalizeToken(v) + if v != "" { + return v + } + return panel.NormalizeToken(s.EnvToken) +} + +func (s *Store) Maintenance(ctx context.Context) bool { + v, _ := s.Get(ctx, KeyMaintenance) + return v == "1" +} + +func (s *Store) ServerLabels(ctx context.Context) map[int]string { + out := map[int]string{} + merge := func(raw string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return + } + var m map[string]any + if json.Unmarshal([]byte(raw), &m) != nil { + return + } + for k, v := range m { + var id int + fmtSscanf(k, &id) + switch t := v.(type) { + case string: + if strings.TrimSpace(t) != "" { + out[id] = strings.TrimSpace(t) + } + } + } + } + merge(s.EnvLabelsJSON) + dbJSON, _ := s.Get(ctx, KeyServerLabelsJSON) + merge(dbJSON) + + rows, err := s.Pool.Query(ctx, `SELECT panel_server_id, title FROM panel_server_labels`) + if err == nil { + defer rows.Close() + for rows.Next() { + var id int + var title string + if rows.Scan(&id, &title) == nil && strings.TrimSpace(title) != "" { + out[id] = strings.TrimSpace(title) + } + } + } + return out +} + +func (s *Store) JSONMapString(ctx context.Context, key string) map[int]string { + out := map[int]string{} + raw, _ := s.Get(ctx, key) + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + var m map[string]any + if json.Unmarshal([]byte(raw), &m) != nil { + return out + } + for k, v := range m { + var id int + fmtSscanf(k, &id) + if str, ok := v.(string); ok { + out[id] = str + } + } + return out +} + +func (s *Store) JSONMapStringSlice(ctx context.Context, key string) map[int][]string { + out := map[int][]string{} + raw, _ := s.Get(ctx, key) + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + var m map[string]any + if json.Unmarshal([]byte(raw), &m) != nil { + return out + } + for k, v := range m { + var id int + fmtSscanf(k, &id) + arr, ok := v.([]any) + if !ok { + continue + } + var list []string + for _, x := range arr { + if str, ok := x.(string); ok { + list = append(list, str) + } + } + out[id] = list + } + return out +} + +func (s *Store) DisabledServers(ctx context.Context) map[int]bool { + out := map[int]bool{} + raw, _ := s.Get(ctx, KeyDisabledServers) + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + var arr []any + if json.Unmarshal([]byte(raw), &arr) != nil { + return out + } + for _, x := range arr { + switch t := x.(type) { + case float64: + out[int(t)] = true + case int: + out[t] = true + } + } + return out +} + +func fmtSscanf(s string, id *int) { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + break + } + n = n*10 + int(r-'0') + } + *id = n +} diff --git a/Go/internal/share/downloads.go b/Go/internal/share/downloads.go new file mode 100644 index 0000000..7e71735 --- /dev/null +++ b/Go/internal/share/downloads.go @@ -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 diff --git a/Go/internal/share/renewal.go b/Go/internal/share/renewal.go new file mode 100644 index 0000000..3d3bd71 --- /dev/null +++ b/Go/internal/share/renewal.go @@ -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 +} diff --git a/Go/internal/share/repo.go b/Go/internal/share/repo.go new file mode 100644 index 0000000..d6d82d0 --- /dev/null +++ b/Go/internal/share/repo.go @@ -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) +} diff --git a/Go/internal/share/servers.go b/Go/internal/share/servers.go new file mode 100644 index 0000000..e0f64b1 --- /dev/null +++ b/Go/internal/share/servers.go @@ -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) +} diff --git a/Go/internal/web/app.go b/Go/internal/web/app.go new file mode 100644 index 0000000..9284443 --- /dev/null +++ b/Go/internal/web/app.go @@ -0,0 +1,341 @@ +// Package web wires the HTTP application: routing, sessions, CSRF, templates +// and request handlers, on top of the domain packages (panel, settings, share, +// auth, member, i18n). +package web + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "html/template" + "io/fs" + "log" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/alexedwards/scs/pgxstore" + "github.com/alexedwards/scs/v2" + "github.com/jackc/pgx/v5/pgxpool" + + "amnezia-share/internal/auth" + "amnezia-share/internal/config" + "amnezia-share/internal/db" + "amnezia-share/internal/i18n" + "amnezia-share/internal/member" + "amnezia-share/internal/models" + "amnezia-share/internal/panel" + "amnezia-share/internal/settings" + "amnezia-share/internal/share" +) + +// Session keys used across the app. +const ( + SessionAdminID = "admin_id" + SessionMemberID = "member_id" + sessionCSRFKey = "_csrf" +) + +// App holds every shared dependency needed by the HTTP handlers. +type App struct { + Cfg config.Config + Pool *pgxpool.Pool + Sessions *scs.SessionManager + Panel *panel.Client + Settings *settings.Store + Share *share.Repo + Auth *auth.Repo + Member *member.Repo + Templates *template.Template +} + +// New connects to the database, runs migrations, and builds a ready-to-serve App. +func New(ctx context.Context, cfg config.Config) (*App, error) { + pool, err := db.Connect(ctx, cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("подключение к БД: %w", err) + } + if err := db.Migrate(ctx, pool, cfg.MigrationsDir); err != nil { + pool.Close() + return nil, fmt.Errorf("миграции: %w", err) + } + + sm := scs.New() + sm.Store = pgxstore.New(pool) + sm.Lifetime = 30 * 24 * time.Hour + sm.Cookie.Name = "amnezia_session" + sm.Cookie.HttpOnly = true + sm.Cookie.SameSite = http.SameSiteLaxMode + sm.Cookie.Persist = true + + st := &settings.Store{ + Pool: pool, + EnvPanelURL: cfg.PanelURL, + EnvToken: cfg.PanelToken, + EnvLabelsJSON: cfg.ServerLabelsJSON, + } + + tmpl, err := loadTemplates(cfg) + if err != nil { + pool.Close() + return nil, fmt.Errorf("шаблоны: %w", err) + } + + app := &App{ + Cfg: cfg, + Pool: pool, + Sessions: sm, + Panel: panel.New(cfg.HTTPBudget()), + Settings: st, + Share: share.New(pool), + Auth: auth.New(pool), + Member: member.New(pool), + Templates: tmpl, + } + return app, nil +} + +// Close releases the database pool. +func (a *App) Close() { + if a.Pool != nil { + a.Pool.Close() + } +} + +// LoadTemplates parses every template under cfg.WebDir/templates. It is +// exported so tests (e.g. in the handlers package) can render pages against +// realistic view structs without needing a live database. +func LoadTemplates(cfg config.Config) (*template.Template, error) { + return loadTemplates(cfg) +} + +func loadTemplates(cfg config.Config) (*template.Template, error) { + root := template.New("root").Funcs(baseFuncs(cfg)) + dir := filepath.Join(cfg.WebDir, "templates") + var matches []string + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".html") { + matches = append(matches, path) + } + return nil + }) + if err != nil { + return nil, err + } + if len(matches) == 0 { + return nil, fmt.Errorf("шаблоны не найдены (%s)", dir) + } + return root.ParseFiles(matches...) +} + +func baseFuncs(cfg config.Config) template.FuncMap { + return template.FuncMap{ + "appURL": cfg.AppURL, + "htmlSafe": func(s string) template.HTML { return template.HTML(s) }, + "dict": dictFunc, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b int) int { return a * b }, + "eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) }, + "year": func() int { return time.Now().Year() }, + "csrf": func() string { return "" }, + "t": func(key string, _ ...string) string { return key }, + "lang": func() string { return string(i18n.RU) }, + "shareDL": func(token string, creationID int64, part string) string { + return cfg.AppURL("share/download") + "?k=" + url.QueryEscape(token) + "&cre=" + strconv.FormatInt(creationID, 10) + "&part=" + part + }, + "intIn": func(list []int, id int) bool { + for _, v := range list { + if v == id { + return true + } + } + return false + }, + "strIn": func(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false + }, + } +} + +func dictFunc(pairs ...any) (map[string]any, error) { + if len(pairs)%2 != 0 { + return nil, fmt.Errorf("dict: нечётное число аргументов") + } + out := make(map[string]any, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + key, ok := pairs[i].(string) + if !ok { + return nil, fmt.Errorf("dict: ключ должен быть строкой") + } + out[key] = pairs[i+1] + } + return out, nil +} + +// Render executes the named template with request-scoped helper funcs (csrf, t, +// lang) bound in, writing directly to w. name is the template's file base name, +// e.g. "share.html". +func (a *App) Render(w http.ResponseWriter, r *http.Request, name string, data any) { + clone, err := a.Templates.Clone() + if err != nil { + http.Error(w, "ошибка шаблона", http.StatusInternalServerError) + log.Printf("template clone: %v", err) + return + } + lang := i18n.Resolve(r, a.Sessions) + token := a.CSRFToken(r) + clone = clone.Funcs(template.FuncMap{ + "csrf": func() string { return token }, + "t": func(key string, args ...string) string { + if len(args)%2 != 0 { + return i18n.T(lang, key, nil) + } + repl := map[string]string{} + for i := 0; i < len(args); i += 2 { + repl[args[i]] = args[i+1] + } + return i18n.T(lang, key, repl) + }, + "lang": func() string { return string(lang) }, + }) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := clone.ExecuteTemplate(w, name, data); err != nil { + log.Printf("render %s: %v", name, err) + } +} + +// CSRFToken returns the current session's CSRF token, generating and storing one +// on first use. +func (a *App) CSRFToken(r *http.Request) string { + ctx := r.Context() + if tok := a.Sessions.GetString(ctx, sessionCSRFKey); tok != "" { + return tok + } + tok := randomToken() + a.Sessions.Put(ctx, sessionCSRFKey, tok) + return tok +} + +// ValidateCSRF checks the request's CSRF token (from the "_csrf" form field or +// the "X-CSRF-Token" header) against the session's token. +func (a *App) ValidateCSRF(r *http.Request) bool { + want := a.Sessions.GetString(r.Context(), sessionCSRFKey) + if want == "" { + return false + } + got := r.Header.Get("X-CSRF-Token") + if got == "" { + got = r.FormValue("_csrf") + } + if got == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1 +} + +func randomToken() string { + b := make([]byte, 24) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// AbsoluteURL builds an absolute URL (scheme + host + app-prefixed path) for path, +// used by the OIDC redirect_uri. +func (a *App) AbsoluteURL(r *http.Request, path string) string { + scheme := "http" + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + scheme = "https" + } + host := r.Host + if h := r.Header.Get("X-Forwarded-Host"); h != "" { + host = h + } + return scheme + "://" + host + a.Cfg.AppURL(path) +} + +// CurrentAdmin returns the logged-in admin user for the request, or nil if none. +func (a *App) CurrentAdmin(r *http.Request) *models.User { + id := a.Sessions.GetInt(r.Context(), SessionAdminID) + if id <= 0 { + return nil + } + u, err := a.Auth.GetAdminByID(r.Context(), id) + if err != nil || u == nil { + return nil + } + return u +} + +// CurrentMember returns the logged-in cabinet member for the request, or nil if +// none. +func (a *App) CurrentMember(r *http.Request) *models.Member { + id := a.Sessions.GetInt(r.Context(), SessionMemberID) + if id <= 0 { + return nil + } + m, err := a.Member.GetByID(r.Context(), id) + if err != nil || m == nil { + return nil + } + return m +} + +// RequireAdmin is middleware that redirects anonymous visitors to the login page. +func (a *App) RequireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.CurrentAdmin(r) == nil { + http.Redirect(w, r, a.Cfg.AppURL("login"), http.StatusSeeOther) + return + } + next(w, r) + } +} + +// RequireMember is middleware that redirects anonymous visitors to the cabinet +// login page. +func (a *App) RequireMember(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.CurrentMember(r) == nil { + http.Redirect(w, r, a.Cfg.AppURL("cabinet/login"), http.StatusSeeOther) + return + } + next(w, r) + } +} + +// CSRFProtect is middleware for POST endpoints that rejects requests with a +// missing/invalid CSRF token. +func (a *App) CSRFProtect(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + _ = r.ParseMultipartForm(32 << 20) + if !a.ValidateCSRF(r) { + if wantsJSON(r) { + writeJSONError(w, http.StatusBadRequest, "Сессия устарела. Обновите страницу.") + return + } + http.Error(w, "Сессия устарела. Обновите страницу и попробуйте снова.", http.StatusBadRequest) + return + } + } + next(w, r) + } +} + +func wantsJSON(r *http.Request) bool { + return r.Header.Get("X-Share-Async") == "1" || strings.Contains(r.Header.Get("Accept"), "application/json") +} diff --git a/Go/internal/web/handlers/admin.go b/Go/internal/web/handlers/admin.go new file mode 100644 index 0000000..bea6a40 --- /dev/null +++ b/Go/internal/web/handlers/admin.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "amnezia-share/internal/models" + "amnezia-share/internal/web" +) + +func registerAdmin(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /admin", a.RequireAdmin(AdminIndex(a))) + + mux.HandleFunc("GET /admin/links", a.RequireAdmin(AdminLinksGet(a))) + mux.HandleFunc("POST /admin/links", a.RequireAdmin(a.CSRFProtect(AdminLinksPost(a)))) + + mux.HandleFunc("GET /admin/renewal", a.RequireAdmin(AdminRenewalGet(a))) + mux.HandleFunc("POST /admin/renewal", a.RequireAdmin(a.CSRFProtect(AdminRenewalPost(a)))) + + mux.HandleFunc("GET /admin/configs", a.RequireAdmin(AdminConfigsGet(a))) + mux.HandleFunc("POST /admin/configs", a.RequireAdmin(a.CSRFProtect(AdminConfigsPost(a)))) + + mux.HandleFunc("GET /admin/servers", a.RequireAdmin(AdminServersGet(a))) + mux.HandleFunc("POST /admin/servers", a.RequireAdmin(a.CSRFProtect(AdminServersPost(a)))) + + mux.HandleFunc("GET /admin/settings", a.RequireAdmin(AdminSettingsGet(a))) + mux.HandleFunc("POST /admin/settings", a.RequireAdmin(a.CSRFProtect(AdminSettingsPost(a)))) +} + +// adminLayout carries the data every admin page template needs for the shared +// cyber-dark shell (sidebar, topbar, flash banners). +type adminLayout struct { + Admin *models.User + Active string + OK string + Error string +} + +func newAdminLayout(a *web.App, r *http.Request, active string) adminLayout { + return adminLayout{Admin: a.CurrentAdmin(r), Active: active} +} diff --git a/Go/internal/web/handlers/admin_configs.go b/Go/internal/web/handlers/admin_configs.go new file mode 100644 index 0000000..41d3ac2 --- /dev/null +++ b/Go/internal/web/handlers/admin_configs.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "net/http" + "net/url" + + "amnezia-share/internal/models" + "amnezia-share/internal/web" +) + +type configRow struct { + models.ShareCreation + LinkID int + LinkToken string +} + +type adminConfigsView struct { + adminLayout + Rows []configRow + LinkIDFilter int + ServerIDFilter int + Truncated bool +} + +const adminConfigsMax = 300 + +// AdminConfigsGet lists guest-created panel connections (optionally scoped to +// one link or server) with a delete action (admin/server_configs.php). +func AdminConfigsGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ok, errText := flashFromQuery(r) + view := adminConfigsView{ + adminLayout: newAdminLayout(a, r, "configs"), + LinkIDFilter: queryInt(r, "link_id", 0), + ServerIDFilter: queryInt(r, "server_id", 0), + } + view.OK, view.Error = ok, errText + + add := func(linkID int, token string, creations []models.ShareCreation) { + for _, c := range creations { + if view.ServerIDFilter > 0 && c.ServerID != view.ServerIDFilter { + continue + } + if len(view.Rows) >= adminConfigsMax { + view.Truncated = true + return + } + view.Rows = append(view.Rows, configRow{ShareCreation: c, LinkID: linkID, LinkToken: token}) + } + } + + if view.LinkIDFilter > 0 { + if link, err := a.Share.LinkByID(ctx, view.LinkIDFilter); err == nil && link != nil { + creations, _ := a.Share.ListCreationsForLink(ctx, link.ID) + add(link.ID, link.Token, creations) + } + } else { + links, err := a.Share.ListLinks(ctx, 1, 50, "") + if err != nil { + view.Error = err.Error() + } + for _, l := range links { + creations, _ := a.Share.ListCreationsForLink(ctx, l.ID) + add(l.ID, l.Token, creations) + if view.Truncated { + break + } + } + } + + a.Render(w, r, "page_admin_configs", view) + } +} + +// AdminConfigsPost deletes a single guest connection (from the panel, then the +// DB). +func AdminConfigsPost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + var okMsg string + + switch r.FormValue("action") { + case "delete_creation": + linkID := formInt(r, "link_id", 0) + creationID := formInt64(r, "creation_id", 0) + err = a.Share.SoftDeleteCreation(ctx, a.Panel, a.Settings, linkID, creationID) + if err == nil { + okMsg = "Конфигурация удалена." + } + default: + err = errUnknownAction + } + + back := "admin/configs" + q := url.Values{} + if v := r.FormValue("link_id"); v != "" { + q.Set("link_id", v) + } + if v := r.FormValue("server_id_filter"); v != "" { + q.Set("server_id", v) + } + if len(q) > 0 { + back += "?" + q.Encode() + } + + errText := "" + if err != nil { + errText = err.Error() + } + redirectWithFlash(w, r, a, back, okMsg, errText) + } +} diff --git a/Go/internal/web/handlers/admin_index.go b/Go/internal/web/handlers/admin_index.go new file mode 100644 index 0000000..233f5fa --- /dev/null +++ b/Go/internal/web/handlers/admin_index.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + + "amnezia-share/internal/web" +) + +type adminIndexView struct { + adminLayout + TotalLinks int + ActiveLinks int + ExpiredLinks int + CleanedLinks int + LimitLinks int + CodesCount int + PanelURL string + PanelOK bool + PanelErr string + Maintenance bool +} + +// AdminIndex renders the admin dashboard (admin/index.php). +func AdminIndex(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + view := adminIndexView{adminLayout: newAdminLayout(a, r, "index")} + + view.TotalLinks, _ = a.Share.CountLinks(ctx, "") + view.ActiveLinks, _ = a.Share.CountLinks(ctx, "active") + view.ExpiredLinks, _ = a.Share.CountLinks(ctx, "expired") + view.CleanedLinks, _ = a.Share.CountLinks(ctx, "cleaned") + view.LimitLinks, _ = a.Share.CountLinks(ctx, "limit") + if codes, err := a.Share.ListCodes(ctx); err == nil { + view.CodesCount = len(codes) + } + view.Maintenance = a.Settings.Maintenance(ctx) + + view.PanelURL = a.Settings.PanelURL(ctx) + token := a.Settings.PanelToken(ctx) + if view.PanelURL != "" && token != "" { + servers, err := a.Panel.ListServers(ctx, view.PanelURL, token) + if err != nil { + view.PanelErr = err.Error() + } else { + view.PanelOK = true + _ = servers + } + } + + a.Render(w, r, "page_admin_index", view) + } +} diff --git a/Go/internal/web/handlers/admin_links.go b/Go/internal/web/handlers/admin_links.go new file mode 100644 index 0000000..192db1c --- /dev/null +++ b/Go/internal/web/handlers/admin_links.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "net/http" + "net/url" + "strings" + "time" + + "amnezia-share/internal/models" + "amnezia-share/internal/share" + "amnezia-share/internal/web" +) + +func flashFromQuery(r *http.Request) (ok, errText string) { + return r.URL.Query().Get("ok"), r.URL.Query().Get("err") +} + +func redirectWithFlash(w http.ResponseWriter, r *http.Request, a *web.App, path string, ok, errText string) { + u, _ := url.Parse(a.Cfg.AppURL(path)) + q := u.Query() + if ok != "" { + q.Set("ok", ok) + } + if errText != "" { + q.Set("err", errText) + } + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +const adminPerPage = 20 + +type linkRow struct { + models.ShareLink + Status share.LinkStatus + ActiveConfigs int +} + +type adminLinksView struct { + adminLayout + Links []linkRow + StatusFilter string + Page int + Total int + PerPage int + Detail *models.ShareLink + DetailStatus share.LinkStatus + Creations []models.ShareCreation + DeletedCreations []models.ShareCreation + AllowedDurations []int + AllowedServerIDs []int + AllServers []models.ServerInfo +} + +// AdminLinksGet lists share links (paginated, filterable by status) and, when +// ?id= is present, shows one link's detail with its creations (share_links.php). +func AdminLinksGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ok, errText := flashFromQuery(r) + view := adminLinksView{ + adminLayout: newAdminLayout(a, r, "links"), + StatusFilter: r.URL.Query().Get("status"), + Page: queryInt(r, "page", 1), + PerPage: adminPerPage, + AllowedDurations: share.AllowedDurations, + } + view.OK, view.Error = ok, errText + if view.Page < 1 { + view.Page = 1 + } + + links, err := a.Share.ListLinks(ctx, view.Page, view.PerPage, view.StatusFilter) + if err != nil { + view.Error = err.Error() + } + view.Total, _ = a.Share.CountLinks(ctx, view.StatusFilter) + for _, l := range links { + creations, _ := a.Share.ListCreationsForLink(ctx, l.ID) + view.Links = append(view.Links, linkRow{ + ShareLink: l, + Status: share.ClassifyLinkStatus(l, len(creations) > 0), + ActiveConfigs: len(creations), + }) + } + + if id := queryInt(r, "id", 0); id > 0 { + if link, err := a.Share.LinkByID(ctx, id); err == nil && link != nil { + view.Detail = link + creations, _ := a.Share.ListCreationsForLink(ctx, id) + view.DetailStatus = share.ClassifyLinkStatus(*link, len(creations) > 0) + view.Creations = creations + view.DeletedCreations, _ = a.Share.ListDeletedCreationsForLink(ctx, id) + view.AllowedServerIDs = share.ParseAllowedServerIDs(link.AllowedServerIDs) + view.AllServers = share.BuildGuestServers(ctx, a.Settings, a.Settings.ServerLabels(ctx)) + } + } + + a.Render(w, r, "page_admin_links", view) + } +} + +func parseFormDate(v string) *time.Time { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + layouts := []string{"2006-01-02T15:04", "2006-01-02", time.RFC3339} + for _, layout := range layouts { + if t, err := time.Parse(layout, v); err == nil { + return &t + } + } + return nil +} + +// AdminLinksPost handles link create/update/delete/extend/allowed-servers and +// per-creation deletion. +func AdminLinksPost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + action := r.FormValue("action") + back := "admin/links" + if id := r.FormValue("back_id"); id != "" { + back += "?id=" + url.QueryEscape(id) + } + + var err error + var okMsg string + + switch action { + case "create": + days := formInt(r, "days", 30) + maxUses := formInt(r, "max_uses", 5) + subUntil := parseFormDate(r.FormValue("subscription_until")) + link, cerr := a.Share.CreateLink(ctx, days, maxUses, subUntil) + err = cerr + if err == nil { + okMsg = "Ссылка создана: #" + itoaHelper(link.ID) + } + + case "update": + linkID := formInt(r, "link_id", 0) + validityDays := formInt(r, "validity_days", 30) + floating := r.FormValue("floating_expiry") == "1" + expiresAt := parseFormDate(r.FormValue("expires_at")) + subUntil := parseFormDate(r.FormValue("subscription_until")) + maxUses := formInt(r, "max_uses", 5) + err = a.Share.UpdateLink(ctx, linkID, validityDays, floating, expiresAt, subUntil, maxUses) + if err == nil { + okMsg = "Срок ссылки обновлён." + } + + case "delete": + linkID := formInt(r, "link_id", 0) + err = a.Share.DeleteLink(ctx, linkID) + back = "admin/links" + if err == nil { + okMsg = "Ссылка удалена." + } + + case "extend": + linkID := formInt(r, "link_id", 0) + days := formInt(r, "days", 30) + err = a.Share.ExtendLinkByDays(ctx, linkID, days) + if err == nil { + okMsg = "Ссылка продлена." + } + + case "set_allowed_servers": + linkID := formInt(r, "link_id", 0) + _ = r.ParseForm() + var ids []int + for _, v := range r.Form["server_ids[]"] { + if n := formIntFromString(v, -1); n >= 0 { + ids = append(ids, n) + } + } + err = a.Share.SetAllowedServerIDs(ctx, linkID, ids) + if err == nil { + okMsg = "Список серверов обновлён." + } + + case "delete_creation": + linkID := formInt(r, "link_id", 0) + creationID := formInt64(r, "creation_id", 0) + err = a.Share.SoftDeleteCreation(ctx, a.Panel, a.Settings, linkID, creationID) + if err == nil { + okMsg = "Конфигурация удалена." + } + + default: + err = errUnknownAction + } + + errText := "" + if err != nil { + errText = err.Error() + } + redirectWithFlash(w, r, a, back, okMsg, errText) + } +} diff --git a/Go/internal/web/handlers/admin_renewal.go b/Go/internal/web/handlers/admin_renewal.go new file mode 100644 index 0000000..2d2cec4 --- /dev/null +++ b/Go/internal/web/handlers/admin_renewal.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "net/http" + "strings" + + "amnezia-share/internal/models" + "amnezia-share/internal/web" +) + +type adminRenewalView struct { + adminLayout + Codes []models.RenewalCode +} + +// AdminRenewalGet lists renewal codes (admin/renewal_codes.php GET). +func AdminRenewalGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ok, errText := flashFromQuery(r) + view := adminRenewalView{adminLayout: newAdminLayout(a, r, "renewal")} + view.OK, view.Error = ok, errText + codes, err := a.Share.ListCodes(r.Context()) + if err != nil { + view.Error = err.Error() + } + view.Codes = codes + a.Render(w, r, "page_admin_renewal", view) + } +} + +// AdminRenewalPost handles renewal code create/delete (admin/renewal_codes.php +// POST). +func AdminRenewalPost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + var okMsg string + + switch r.FormValue("action") { + case "create": + code := r.FormValue("code") + addDays := formInt(r, "add_days", 30) + maxUses := formInt(r, "max_uses", 1) + target := strings.TrimSpace(r.FormValue("code_target")) + note := r.FormValue("note") + expiresAt := parseFormDate(r.FormValue("code_expires_at")) + + var linkID *int + if target != "member" { + if v := formInt(r, "share_link_id", 0); v > 0 { + linkID = &v + } + } + + created, cerr := a.Share.CreateRenewalCode(ctx, code, addDays, maxUses, linkID, expiresAt, note, target) + err = cerr + if err == nil { + okMsg = "Код создан: " + created.Code + } + + case "delete": + id := formInt(r, "code_id", 0) + err = a.Share.DeleteCode(ctx, id) + if err == nil { + okMsg = "Код удалён." + } + + default: + err = errUnknownAction + } + + errText := "" + if err != nil { + errText = err.Error() + } + redirectWithFlash(w, r, a, "admin/renewal", okMsg, errText) + } +} diff --git a/Go/internal/web/handlers/admin_servers.go b/Go/internal/web/handlers/admin_servers.go new file mode 100644 index 0000000..af4d481 --- /dev/null +++ b/Go/internal/web/handlers/admin_servers.go @@ -0,0 +1,255 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "amnezia-share/internal/settings" + "amnezia-share/internal/share" + "amnezia-share/internal/web" +) + +type serverRow struct { + ID int + Title string + Protocols []string + Flag string + Speed string + Disabled bool + PanelName string + PanelHost string +} + +type adminServersView struct { + adminLayout + Rows []serverRow + AllProtocols []string + PanelReachable bool + PanelErr string +} + +// AdminServersGet lists the known panel servers with their editable labels, +// allowed protocols, flag emoji, speed badge and disabled state +// (admin/server_labels.php). +func AdminServersGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ok, errText := flashFromQuery(r) + view := adminServersView{ + adminLayout: newAdminLayout(a, r, "servers"), + AllProtocols: share.GuestProtocols, + } + view.OK, view.Error = ok, errText + + labels := a.Settings.ServerLabels(ctx) + protosMap := a.Settings.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON) + flagsMap := a.Settings.JSONMapString(ctx, settings.KeyFlagsJSON) + speedsMap := a.Settings.JSONMapString(ctx, settings.KeySpeedsJSON) + disabledMap := a.Settings.DisabledServers(ctx) + + baseURL := a.Settings.PanelURL(ctx) + token := a.Settings.PanelToken(ctx) + panelServers := map[int]struct{ Name, Host string }{} + if baseURL != "" && token != "" { + list, err := a.Panel.ListServers(ctx, baseURL, token) + if err != nil { + view.PanelErr = err.Error() + } else { + view.PanelReachable = true + for _, s := range list { + panelServers[s.ID] = struct{ Name, Host string }{s.Name, s.Host} + if _, known := labels[s.ID]; !known { + labels[s.ID] = s.Name + } + } + } + } + + ids := make([]int, 0, len(labels)) + for id := range labels { + ids = append(ids, id) + } + sort.Ints(ids) + + for _, id := range ids { + row := serverRow{ + ID: id, + Title: labels[id], + Protocols: protosMap[id], + Flag: flagsMap[id], + Speed: speedsMap[id], + Disabled: disabledMap[id], + } + if len(row.Protocols) == 0 { + row.Protocols = append([]string(nil), share.DefaultServerProtocols...) + } + if p, ok := panelServers[id]; ok { + row.PanelName, row.PanelHost = p.Name, p.Host + } + view.Rows = append(view.Rows, row) + } + + a.Render(w, r, "page_admin_servers", view) + } +} + +func protocolSelected(list []string, proto string) bool { + for _, p := range list { + if p == proto { + return true + } + } + return false +} + +func upsertServerLabel(ctx context.Context, a *web.App, id int, title string) error { + title = strings.TrimSpace(title) + if title == "" { + return nil + } + _, err := a.Pool.Exec(ctx, ` + INSERT INTO panel_server_labels (panel_server_id, title, updated_at) + VALUES ($1,$2,NOW()) + ON CONFLICT (panel_server_id) DO UPDATE SET title=EXCLUDED.title, updated_at=NOW()`, + id, title) + return err +} + +func intMapToJSON[T any](m map[int]T) string { + out := make(map[string]T, len(m)) + for k, v := range m { + out[strconv.Itoa(k)] = v + } + b, _ := json.Marshal(out) + return string(b) +} + +func intSetToJSON(m map[int]bool) string { + ids := make([]int, 0, len(m)) + for k, v := range m { + if v { + ids = append(ids, k) + } + } + sort.Ints(ids) + b, _ := json.Marshal(ids) + return string(b) +} + +func saveServerJSONSettings(ctx context.Context, a *web.App, protosMap map[int][]string, flagsMap, speedsMap map[int]string, disabledSet map[int]bool) error { + if err := a.Settings.Set(ctx, settings.KeyProtocolsJSON, intMapToJSON(protosMap)); err != nil { + return err + } + if err := a.Settings.Set(ctx, settings.KeyFlagsJSON, intMapToJSON(flagsMap)); err != nil { + return err + } + if err := a.Settings.Set(ctx, settings.KeySpeedsJSON, intMapToJSON(speedsMap)); err != nil { + return err + } + return a.Settings.Set(ctx, settings.KeyDisabledServers, intSetToJSON(disabledSet)) +} + +// AdminServersPost saves the whole server table, adds a new server id, or +// removes one server's label row. +func AdminServersPost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + _ = r.ParseForm() + var err error + var okMsg string + + switch r.FormValue("action") { + case "save": + protosMap := a.Settings.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON) + flagsMap := a.Settings.JSONMapString(ctx, settings.KeyFlagsJSON) + speedsMap := a.Settings.JSONMapString(ctx, settings.KeySpeedsJSON) + disabledSet := a.Settings.DisabledServers(ctx) + + for _, idStr := range r.Form["server_ids"] { + id, perr := strconv.Atoi(strings.TrimSpace(idStr)) + if perr != nil { + continue + } + title := r.FormValue(fmt.Sprintf("servers[%d][title]", id)) + if uerr := upsertServerLabel(ctx, a, id, title); uerr != nil { + err = uerr + } + protos := r.Form[fmt.Sprintf("servers[%d][protocols][]", id)] + if len(protos) > 0 { + protosMap[id] = protos + } else { + delete(protosMap, id) + } + flag := strings.TrimSpace(r.FormValue(fmt.Sprintf("servers[%d][flag]", id))) + if flag != "" { + flagsMap[id] = flag + } else { + delete(flagsMap, id) + } + speed := strings.TrimSpace(r.FormValue(fmt.Sprintf("servers[%d][speed]", id))) + if speed != "" { + speedsMap[id] = speed + } else { + delete(speedsMap, id) + } + if r.FormValue(fmt.Sprintf("servers[%d][disabled]", id)) == "1" { + disabledSet[id] = true + } else { + delete(disabledSet, id) + } + } + + if err == nil { + err = saveServerJSONSettings(ctx, a, protosMap, flagsMap, speedsMap, disabledSet) + } + if err == nil { + okMsg = "Настройки серверов сохранены." + } + + case "add": + id := formInt(r, "new_server_id", 0) + title := r.FormValue("new_server_title") + if id <= 0 || strings.TrimSpace(title) == "" { + err = errUnknownAction + break + } + err = upsertServerLabel(ctx, a, id, title) + if err == nil { + okMsg = "Сервер добавлен." + } + + case "delete_label": + id := formInt(r, "server_id", 0) + _, derr := a.Pool.Exec(ctx, `DELETE FROM panel_server_labels WHERE panel_server_id=$1`, id) + err = derr + if err == nil { + protosMap := a.Settings.JSONMapStringSlice(ctx, settings.KeyProtocolsJSON) + flagsMap := a.Settings.JSONMapString(ctx, settings.KeyFlagsJSON) + speedsMap := a.Settings.JSONMapString(ctx, settings.KeySpeedsJSON) + disabledSet := a.Settings.DisabledServers(ctx) + delete(protosMap, id) + delete(flagsMap, id) + delete(speedsMap, id) + delete(disabledSet, id) + err = saveServerJSONSettings(ctx, a, protosMap, flagsMap, speedsMap, disabledSet) + } + if err == nil { + okMsg = "Сервер удалён из списка." + } + + default: + err = errUnknownAction + } + + errText := "" + if err != nil { + errText = err.Error() + } + redirectWithFlash(w, r, a, "admin/servers", okMsg, errText) + } +} diff --git a/Go/internal/web/handlers/admin_settings.go b/Go/internal/web/handlers/admin_settings.go new file mode 100644 index 0000000..c796acd --- /dev/null +++ b/Go/internal/web/handlers/admin_settings.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + "amnezia-share/internal/panel" + "amnezia-share/internal/settings" + "amnezia-share/internal/web" +) + +type adminSettingsView struct { + adminLayout + PanelURL string + APIToken string + ServerLabelsJSON string + TrafficNotices string + Maintenance bool + PocketURL string + PocketClientID string + PocketSecret string + MemberDays string + MemberMaxUses string + TestResult string + TestError string +} + +func loadAdminSettingsView(a *web.App, r *http.Request) adminSettingsView { + ctx := r.Context() + vals, _ := a.Settings.GetMany(ctx, + settings.KeyPanelURL, settings.KeyAPIToken, settings.KeyServerLabelsJSON, + settings.KeyTrafficNotices, settings.KeyMaintenance, + settings.KeyPocketURL, settings.KeyPocketClientID, settings.KeyPocketSecret, + settings.KeyMemberDays, settings.KeyMemberMaxUses, + ) + return adminSettingsView{ + adminLayout: newAdminLayout(a, r, "settings"), + PanelURL: vals[settings.KeyPanelURL], + APIToken: vals[settings.KeyAPIToken], + ServerLabelsJSON: vals[settings.KeyServerLabelsJSON], + TrafficNotices: vals[settings.KeyTrafficNotices], + Maintenance: vals[settings.KeyMaintenance] == "1", + PocketURL: vals[settings.KeyPocketURL], + PocketClientID: vals[settings.KeyPocketClientID], + PocketSecret: vals[settings.KeyPocketSecret], + MemberDays: vals[settings.KeyMemberDays], + MemberMaxUses: vals[settings.KeyMemberMaxUses], + } +} + +// AdminSettingsGet renders the settings form (admin/settings.php GET). +func AdminSettingsGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + view := loadAdminSettingsView(a, r) + view.OK, view.Error = flashFromQuery(r) + a.Render(w, r, "page_admin_settings", view) + } +} + +// AdminSettingsPost saves settings, or runs a connectivity test against the +// Amnezia panel without saving (admin/settings.php POST). +func AdminSettingsPost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if r.FormValue("action") == "test" { + view := loadAdminSettingsView(a, r) + baseURL := strings.TrimSpace(r.FormValue("panel_url")) + token := panel.NormalizeToken(r.FormValue("api_token")) + if baseURL == "" || token == "" { + view.TestError = "Укажите URL панели и токен." + } else if !panel.TokenOK(token) { + view.TestError = "Токен имеет неверный формат (ожидается awp_...)." + } else { + servers, err := a.Panel.ListServers(ctx, baseURL, token) + if err != nil { + view.TestError = err.Error() + } else { + view.TestResult = fmt.Sprintf("Соединение установлено. Серверов обнаружено: %d.", len(servers)) + } + } + view.PanelURL = baseURL + view.APIToken = r.FormValue("api_token") + a.Render(w, r, "page_admin_settings", view) + return + } + + set := func(key, value string) error { return a.Settings.Set(ctx, key, value) } + maintenance := "0" + if r.FormValue("maintenance") == "1" { + maintenance = "1" + } + + var err error + for _, kv := range []struct{ key, value string }{ + {settings.KeyPanelURL, strings.TrimSpace(r.FormValue("panel_url"))}, + {settings.KeyAPIToken, panel.NormalizeToken(r.FormValue("api_token"))}, + {settings.KeyServerLabelsJSON, r.FormValue("server_labels_json")}, + {settings.KeyTrafficNotices, r.FormValue("traffic_notices_json")}, + {settings.KeyMaintenance, maintenance}, + {settings.KeyPocketURL, strings.TrimSpace(r.FormValue("pocket_url"))}, + {settings.KeyPocketClientID, strings.TrimSpace(r.FormValue("pocket_client_id"))}, + {settings.KeyPocketSecret, strings.TrimSpace(r.FormValue("pocket_secret"))}, + {settings.KeyMemberDays, strings.TrimSpace(r.FormValue("member_days"))}, + {settings.KeyMemberMaxUses, strings.TrimSpace(r.FormValue("member_max_uses"))}, + } { + if serr := set(kv.key, kv.value); serr != nil { + err = serr + } + } + a.Panel.ClearCache() + + okMsg := "Настройки сохранены." + errText := "" + if err != nil { + okMsg = "" + errText = err.Error() + } + redirectWithFlash(w, r, a, "admin/settings", okMsg, errText) + } +} diff --git a/Go/internal/web/handlers/cabinet.go b/Go/internal/web/handlers/cabinet.go new file mode 100644 index 0000000..9c5f3ea --- /dev/null +++ b/Go/internal/web/handlers/cabinet.go @@ -0,0 +1,252 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "amnezia-share/internal/i18n" + "amnezia-share/internal/member" + "amnezia-share/internal/models" + "amnezia-share/internal/share" + "amnezia-share/internal/web" +) + +func registerCabinet(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /cabinet", a.RequireMember(CabinetGet(a))) + mux.HandleFunc("POST /cabinet", a.RequireMember(a.CSRFProtect(CabinetPost(a)))) + mux.HandleFunc("GET /cabinet/login", CabinetLoginForm(a)) + mux.HandleFunc("POST /cabinet/login", a.CSRFProtect(CabinetLoginSubmit(a))) + mux.HandleFunc("GET /cabinet/register", CabinetRegisterForm(a)) + mux.HandleFunc("POST /cabinet/register", a.CSRFProtect(CabinetRegisterSubmit(a))) + mux.HandleFunc("GET /cabinet/logout", CabinetLogout(a)) + mux.HandleFunc("GET /cabinet/servers", CabinetServers(a)) + mux.HandleFunc("GET /cabinet/download", CabinetDownload(a)) +} + +type cabinetView struct { + Member *models.Member + Sub *models.MemberSubscription + SubLabel string + ConfigsRemaining int + Servers []models.ServerInfo + Bundles []share.Bundle + Maintenance bool + Expired bool + LimitReached bool + OK string + Error string +} + +func renderCabinet(a *web.App, w http.ResponseWriter, r *http.Request, okMsg, errText string) { + ctx := r.Context() + m := a.CurrentMember(r) + sub, err := a.Member.EnsureSubscription(ctx, a.Settings, m.ID) + if err != nil { + errText = err.Error() + } + bundles, _ := a.Member.Bundles(ctx, m.ID) + servers := share.BuildGuestServers(ctx, a.Settings, a.Settings.ServerLabels(ctx)) + + view := cabinetView{ + Member: m, + Sub: sub, + Servers: servers, + Bundles: bundles, + Maintenance: a.Settings.Maintenance(ctx), + OK: okMsg, + Error: errText, + } + if sub != nil { + view.Expired = member.SubscriptionExpired(*sub) + view.LimitReached = sub.ConfigCount >= sub.MaxConfigs + view.SubLabel = member.SubscriptionExpiresLabel(*sub) + view.ConfigsRemaining = sub.MaxConfigs - sub.ConfigCount + } + a.Render(w, r, "page_cabinet", view) +} + +// CabinetGet renders the member portal dashboard (cabinet.php GET). +func CabinetGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + renderCabinet(a, w, r, "", "") + } +} + +// CabinetPost handles member actions: create / migrate / renew / delete +// (cabinet.php POST). +func CabinetPost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + m := a.CurrentMember(r) + lang := i18n.Resolve(r, a.Sessions) + maintenance := a.Settings.Maintenance(ctx) + + var actionErr error + var okMsg string + switch r.FormValue("action") { + case "create": + if maintenance { + actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil)) + break + } + serverID := formInt(r, "server_id", -1) + protocol := strings.TrimSpace(r.FormValue("protocol")) + _, err := a.Member.TryAddConfig(ctx, a.Panel, a.Settings, m.ID, serverID, protocol) + if err != nil { + actionErr = err + break + } + okMsg = "Готово! Конфигурация создана." + case "migrate": + if maintenance { + actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil)) + break + } + configID := formInt64(r, "config_id", 0) + newServerID := formInt(r, "new_server_id", -1) + newProtocol := strings.TrimSpace(r.FormValue("new_protocol")) + if err := a.Member.TryMigrate(ctx, a.Panel, a.Settings, m.ID, configID, newServerID, newProtocol); err != nil { + actionErr = err + break + } + okMsg = i18n.T(lang, "ok_migrated", nil) + case "renew": + code := r.FormValue("code") + res, err := a.Member.TryRedeemRenewal(ctx, m.ID, code) + if err != nil { + actionErr = err + break + } + until := "" + if res.Sub != nil { + until = member.SubscriptionExpiresLabel(*res.Sub) + } + okMsg = i18n.T(lang, "ok_renewed", map[string]string{"days": fmt.Sprint(res.AddDays), "until": until}) + case "delete": + configID := formInt64(r, "config_id", 0) + if err := a.Member.SoftDelete(ctx, a.Panel, a.Settings, m.ID, configID); err != nil { + actionErr = err + break + } + okMsg = "Конфигурация удалена." + default: + actionErr = errors.New("Неизвестное действие.") + } + + errText := "" + if actionErr != nil { + errText = i18n.TranslateGuestError(lang, actionErr.Error()) + } + if wantsJSON(r) { + if errText != "" { + writeJSONError(w, http.StatusBadRequest, errText) + return + } + writeJSONOK(w, map[string]any{"message": okMsg}) + return + } + renderCabinet(a, w, r, okMsg, errText) + } +} + +type cabinetAuthView struct { + Error string +} + +// CabinetLoginForm renders the member login form. +func CabinetLoginForm(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.CurrentMember(r) != nil { + redirectTo(w, r, a, "cabinet") + return + } + a.Render(w, r, "page_cabinet_auth", cabinetAuthView{}) + } +} + +// CabinetLoginSubmit verifies member credentials and signs them in. +func CabinetLoginSubmit(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + m, err := a.Member.Login(ctx, username, password) + if err != nil { + a.Render(w, r, "page_cabinet_auth", cabinetAuthView{Error: err.Error()}) + return + } + _ = a.Sessions.RenewToken(ctx) + a.Sessions.Put(ctx, web.SessionMemberID, m.ID) + redirectTo(w, r, a, "cabinet") + } +} + +// CabinetRegisterForm renders the member self-registration form. +func CabinetRegisterForm(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.CurrentMember(r) != nil { + redirectTo(w, r, a, "cabinet") + return + } + a.Render(w, r, "page_cabinet_auth", cabinetAuthView{}) + } +} + +// CabinetRegisterSubmit creates a new member account and signs them in. +func CabinetRegisterSubmit(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + email := strings.TrimSpace(r.FormValue("email")) + m, err := a.Member.Register(ctx, username, password, email) + if err != nil { + a.Render(w, r, "page_cabinet_auth", cabinetAuthView{Error: err.Error()}) + return + } + if _, err := a.Member.EnsureSubscription(ctx, a.Settings, m.ID); err != nil { + a.Render(w, r, "page_cabinet_auth", cabinetAuthView{Error: err.Error()}) + return + } + _ = a.Sessions.RenewToken(ctx) + a.Sessions.Put(ctx, web.SessionMemberID, m.ID) + redirectTo(w, r, a, "cabinet") + } +} + +// CabinetLogout clears the member session. +func CabinetLogout(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a.Sessions.Remove(r.Context(), web.SessionMemberID) + _ = a.Sessions.RenewToken(r.Context()) + redirectTo(w, r, a, "cabinet/login") + } +} + +// CabinetServers returns the server catalog as JSON for the member portal. +func CabinetServers(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + servers := share.BuildGuestServers(ctx, a.Settings, a.Settings.ServerLabels(ctx)) + writeJSONOK(w, map[string]any{"servers": servers}) + } +} + +// CabinetDownload streams a single downloadable artifact for one of the +// member's configs. +func CabinetDownload(a *web.App) http.HandlerFunc { + return a.RequireMember(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + m := a.CurrentMember(r) + configID := int64(queryInt(r, "cre", 0)) + part := r.URL.Query().Get("part") + fp, err := a.Member.DownloadPayload(ctx, m.ID, configID, part) + if err != nil || fp == nil { + http.Error(w, "Файл не найден.", http.StatusNotFound) + return + } + serveFilePart(w, fp) + }) +} diff --git a/Go/internal/web/handlers/common.go b/Go/internal/web/handlers/common.go new file mode 100644 index 0000000..ec36316 --- /dev/null +++ b/Go/internal/web/handlers/common.go @@ -0,0 +1,138 @@ +// Package handlers implements the HTTP handler functions for the Amnezia Share +// Panel web application. Handlers are grouped by domain across several files +// (health.go, install.go, login.go, oidc.go, share.go, cabinet.go, faq.go, +// admin_*.go) but all live in this single package so they can freely share +// small helpers declared in this file. +// +// The package registers itself onto internal/web via web.RegisterRoutes from +// init(), rather than internal/web importing this package directly — that +// would create an import cycle, since this package needs *web.App. +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "amnezia-share/internal/web" +) + +// errUnknownAction is returned by admin/guest POST dispatchers for an +// unrecognized "action" form value. +var errUnknownAction = errors.New("Неизвестное действие.") + +func itoaHelper(n int) string { + return strconv.Itoa(n) +} + +func formIntFromString(v string, def int) int { + v = strings.TrimSpace(v) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func init() { + web.RegisterRoutes(register) +} + +// register wires every route of the application onto mux. +func register(a *web.App, mux *http.ServeMux) { + registerHealth(a, mux) + registerInstall(a, mux) + registerLogin(a, mux) + registerOIDC(a, mux) + registerShare(a, mux) + registerCabinet(a, mux) + registerFAQ(a, mux) + registerAdmin(a, mux) + + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + if a.CurrentAdmin(r) != nil { + http.Redirect(w, r, a.Cfg.AppURL("admin"), http.StatusSeeOther) + return + } + count, err := a.Auth.AdminCount(r.Context()) + if err != nil || count == 0 { + http.Redirect(w, r, a.Cfg.AppURL("install"), http.StatusSeeOther) + return + } + http.Redirect(w, r, a.Cfg.AppURL("login"), http.StatusSeeOther) + }) +} + +func redirectTo(w http.ResponseWriter, r *http.Request, a *web.App, path string) { + http.Redirect(w, r, a.Cfg.AppURL(path), http.StatusSeeOther) +} + +func wantsJSON(r *http.Request) bool { + return r.Header.Get("X-Share-Async") == "1" || strings.Contains(r.Header.Get("Accept"), "application/json") +} + +func formInt(r *http.Request, key string, def int) int { + v := strings.TrimSpace(r.FormValue(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func formInt64(r *http.Request, key string, def int64) int64 { + v := strings.TrimSpace(r.FormValue(key)) + if v == "" { + return def + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return def + } + return n +} + +func queryInt(r *http.Request, key string, def int) int { + v := strings.TrimSpace(r.URL.Query().Get(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func errMsg(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"ok": false, "error": msg}) +} + +func writeJSONOK(w http.ResponseWriter, extra map[string]any) { + if extra == nil { + extra = map[string]any{} + } + extra["ok"] = true + writeJSON(w, http.StatusOK, extra) +} diff --git a/Go/internal/web/handlers/faq.go b/Go/internal/web/handlers/faq.go new file mode 100644 index 0000000..993bc74 --- /dev/null +++ b/Go/internal/web/handlers/faq.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "net/http" + + "amnezia-share/internal/i18n" + "amnezia-share/internal/models" + "amnezia-share/internal/share" + "amnezia-share/internal/web" +) + +func registerFAQ(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /faq", FAQ(a)) + mux.HandleFunc("GET /rules", Rules(a)) + mux.HandleFunc("GET /status", Status(a)) +} + +type faqEntry struct { + Question string + Answer string +} + +type faqView struct { + Items []faqEntry +} + +// FAQ renders the guest-facing FAQ page (faq.php). +func FAQ(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + lang := i18n.Resolve(r, a.Sessions) + items := make([]faqEntry, 0, len(i18n.FAQItems)) + for _, it := range i18n.FAQItems { + items = append(items, faqEntry{ + Question: i18n.T(lang, it.QuestionKey, nil), + Answer: i18n.T(lang, it.AnswerKey, nil), + }) + } + a.Render(w, r, "page_faq", faqView{Items: items}) + } +} + +// Rules renders the acceptable-use rules page (rules.php). +func Rules(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a.Render(w, r, "page_rules", nil) + } +} + +type serverStatus struct { + models.ServerInfo + Alive bool + Ms int + Err string +} + +type statusView struct { + Servers []serverStatus +} + +// Status pings every configured server and reports online/offline (status.php). +func Status(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + labels := a.Settings.ServerLabels(ctx) + servers := share.BuildGuestServers(ctx, a.Settings, labels) + baseURL := a.Settings.PanelURL(ctx) + token := a.Settings.PanelToken(ctx) + + list := make([]serverStatus, 0, len(servers)) + for _, s := range servers { + st := serverStatus{ServerInfo: s} + if baseURL != "" && token != "" { + st.Alive, st.Ms, st.Err = a.Panel.Ping(ctx, baseURL, token, s.ID) + } + list = append(list, st) + } + a.Render(w, r, "page_status", statusView{Servers: list}) + } +} diff --git a/Go/internal/web/handlers/health.go b/Go/internal/web/handlers/health.go new file mode 100644 index 0000000..a745d69 --- /dev/null +++ b/Go/internal/web/handlers/health.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "amnezia-share/internal/web" +) + +func registerHealth(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /health", Health(a)) +} + +// Health reports basic liveness plus a DB ping, mirroring health.php. +func Health(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + dbOK := true + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if err := a.Pool.Ping(ctx); err != nil { + dbOK = false + } + status := http.StatusOK + if !dbOK { + status = http.StatusServiceUnavailable + } + writeJSON(w, status, map[string]any{ + "ok": dbOK, + "db": dbOK, + "time": time.Now().UTC().Format(time.RFC3339), + }) + } +} diff --git a/Go/internal/web/handlers/install.go b/Go/internal/web/handlers/install.go new file mode 100644 index 0000000..7a23db7 --- /dev/null +++ b/Go/internal/web/handlers/install.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "net/http" + "strings" + + "amnezia-share/internal/web" +) + +func registerInstall(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /install", InstallForm(a)) + mux.HandleFunc("POST /install", a.CSRFProtect(InstallSubmit(a))) +} + +type installView struct { + Error string + Installed bool + DBError string +} + +// InstallForm renders the single-admin registration form (install.php). +func InstallForm(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.CurrentAdmin(r) != nil { + redirectTo(w, r, a, "admin") + return + } + count, err := a.Auth.AdminCount(r.Context()) + view := installView{} + if err != nil { + view.DBError = "Нет связи с базой данных. Проверьте DATABASE_URL и миграции." + } else if count > 0 { + view.Installed = true + } + a.Render(w, r, "page_install", view) + } +} + +// InstallSubmit creates the sole administrator account (install.php POST). +func InstallSubmit(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + count, err := a.Auth.AdminCount(ctx) + if err != nil { + a.Render(w, r, "page_install", installView{DBError: "Нет связи с базой данных."}) + return + } + if count > 0 { + a.Render(w, r, "page_install", installView{Installed: true}) + return + } + + username := strings.TrimSpace(r.FormValue("username")) + p1 := r.FormValue("password") + p2 := r.FormValue("password2") + if p1 != p2 { + a.Render(w, r, "page_install", installView{Error: "Пароли не совпадают."}) + return + } + + admin, err := a.Auth.RegisterFirstAdmin(ctx, username, p1) + if err != nil { + a.Render(w, r, "page_install", installView{Error: err.Error()}) + return + } + + _ = a.Sessions.RenewToken(ctx) + a.Sessions.Put(ctx, web.SessionAdminID, admin.ID) + redirectTo(w, r, a, "admin") + } +} diff --git a/Go/internal/web/handlers/login.go b/Go/internal/web/handlers/login.go new file mode 100644 index 0000000..fa6ef39 --- /dev/null +++ b/Go/internal/web/handlers/login.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "net/http" + "strings" + + "amnezia-share/internal/auth" + "amnezia-share/internal/settings" + "amnezia-share/internal/web" +) + +func registerLogin(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /login", LoginForm(a)) + mux.HandleFunc("POST /login", a.CSRFProtect(LoginSubmit(a))) + mux.HandleFunc("GET /logout", Logout(a)) +} + +// oidcConfigFromSettings loads the Pocket ID OIDC settings (shared by login.go +// and oidc.go). +func oidcConfigFromSettings(a *web.App, r *http.Request) auth.OIDCConfig { + ctx := r.Context() + vals, _ := a.Settings.GetMany(ctx, settings.KeyPocketURL, settings.KeyPocketClientID, settings.KeyPocketSecret) + return auth.OIDCConfig{ + URL: vals[settings.KeyPocketURL], + ClientID: vals[settings.KeyPocketClientID], + ClientSecret: vals[settings.KeyPocketSecret], + } +} + +type loginView struct { + Error string + PocketEnabled bool + NoAdmins bool +} + +// LoginForm renders the admin login page, or starts the Pocket ID SSO flow when +// called as GET /login?oidc=1 (login.php). +func LoginForm(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if a.CurrentAdmin(r) != nil { + redirectTo(w, r, a, "admin") + return + } + + cfg := oidcConfigFromSettings(a, r) + pocketEnabled := cfg.Configured() + + if pocketEnabled && r.URL.Query().Has("oidc") { + state, err := auth.GenerateState() + if err != nil { + http.Error(w, "не удалось начать вход", http.StatusInternalServerError) + return + } + verifier, challenge, err := auth.GeneratePKCE() + if err != nil { + http.Error(w, "не удалось начать вход", http.StatusInternalServerError) + return + } + a.Sessions.Put(ctx, "oidc_state", state) + a.Sessions.Put(ctx, "oidc_verifier", verifier) + redirectURI := a.AbsoluteURL(r, "oidc/callback") + http.Redirect(w, r, auth.AuthURL(cfg, redirectURI, state, challenge), http.StatusSeeOther) + return + } + + count, _ := a.Auth.AdminCount(ctx) + a.Render(w, r, "page_login", loginView{PocketEnabled: pocketEnabled, NoAdmins: count == 0}) + } +} + +// LoginSubmit verifies username/password and signs the admin in (login.php POST). +func LoginSubmit(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + + admin, err := a.Auth.LoginAdmin(ctx, username, password) + if err != nil { + cfg := oidcConfigFromSettings(a, r) + count, _ := a.Auth.AdminCount(ctx) + a.Render(w, r, "page_login", loginView{Error: err.Error(), PocketEnabled: cfg.Configured(), NoAdmins: count == 0}) + return + } + + _ = a.Sessions.RenewToken(ctx) + a.Sessions.Put(ctx, web.SessionAdminID, admin.ID) + redirectTo(w, r, a, "admin") + } +} + +// Logout clears the admin session (logout.php). +func Logout(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a.Sessions.Remove(r.Context(), web.SessionAdminID) + _ = a.Sessions.RenewToken(r.Context()) + redirectTo(w, r, a, "login") + } +} diff --git a/Go/internal/web/handlers/oidc.go b/Go/internal/web/handlers/oidc.go new file mode 100644 index 0000000..a7aaf55 --- /dev/null +++ b/Go/internal/web/handlers/oidc.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "net/http" + + "amnezia-share/internal/auth" + "amnezia-share/internal/web" +) + +func registerOIDC(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /oidc/callback", OIDCCallback(a)) +} + +// OIDCCallback completes the Pocket ID authorization-code + PKCE flow, matching +// or auto-provisioning the local admin account (oidc_callback.php). +func OIDCCallback(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + q := r.URL.Query() + + if errParam := q.Get("error"); errParam != "" { + renderOIDCError(a, w, r, "Pocket ID вернул ошибку: "+errParam) + return + } + + state := q.Get("state") + code := q.Get("code") + wantState := a.Sessions.GetString(ctx, "oidc_state") + verifier := a.Sessions.GetString(ctx, "oidc_verifier") + a.Sessions.Remove(ctx, "oidc_state") + a.Sessions.Remove(ctx, "oidc_verifier") + + if code == "" || wantState == "" || state != wantState { + renderOIDCError(a, w, r, "Недействительный ответ от Pocket ID. Попробуйте войти ещё раз.") + return + } + + cfg := oidcConfigFromSettings(a, r) + if !cfg.Configured() { + renderOIDCError(a, w, r, "Вход через Pocket ID не настроен.") + return + } + + redirectURI := a.AbsoluteURL(r, "oidc/callback") + info, err := auth.ExchangeAndUserinfo(ctx, cfg, code, redirectURI, verifier) + if err != nil { + renderOIDCError(a, w, r, err.Error()) + return + } + + admin, err := a.Auth.FindOrCreateAdminFromOIDC(ctx, info) + if err != nil { + renderOIDCError(a, w, r, err.Error()) + return + } + + _ = a.Sessions.RenewToken(ctx) + a.Sessions.Put(ctx, web.SessionAdminID, admin.ID) + redirectTo(w, r, a, "admin") + } +} + +func renderOIDCError(a *web.App, w http.ResponseWriter, r *http.Request, msg string) { + a.Render(w, r, "page_login", loginView{Error: msg, PocketEnabled: oidcConfigFromSettings(a, r).Configured()}) +} diff --git a/Go/internal/web/handlers/render_smoke_test.go b/Go/internal/web/handlers/render_smoke_test.go new file mode 100644 index 0000000..0ffdd5e --- /dev/null +++ b/Go/internal/web/handlers/render_smoke_test.go @@ -0,0 +1,186 @@ +package handlers + +import ( + "encoding/json" + "io" + "testing" + "time" + + "amnezia-share/internal/config" + "amnezia-share/internal/models" + "amnezia-share/internal/share" + "amnezia-share/internal/web" +) + +// TestRenderAllPages executes every page template against a realistic view +// struct (as built by the real handlers) without needing a database. It +// catches execution-time template errors (bad field/method names, nil +// dereferences) that plain `go build` and template parsing cannot catch. +func TestRenderAllPages(t *testing.T) { + tmpl, err := web.LoadTemplates(config.Config{WebDir: "../../../web"}) + if err != nil { + t.Fatalf("LoadTemplates: %v", err) + } + + now := time.Now() + future := now.Add(48 * time.Hour) + note := "test note" + linkID := 7 + email := "user@example.com" + validityDays := 30 + + serverInfo := models.ServerInfo{ID: 1, Label: "Germany", Protocols: []string{"wireguard", "awg2"}, Flag: "de", Speed: "1 Gbps"} + + sampleLink := models.ShareLink{ + ID: 1, Token: "abc123", MaxUses: 5, UseCount: 2, CreatedAt: now, + ExpiresAt: &future, ValidityDays: &validityDays, + AllowedServerIDs: json.RawMessage(`[1,2]`), + } + + bundle := share.Bundle{ + Base: "peer1", + Conf: &share.FilePart{Filename: "peer1.conf", Body: "conf-body", Mime: "text/plain"}, + Vpn: &share.FilePart{Filename: "peer1.vpn", Body: "vpn-body", Mime: "text/plain"}, + CreationID: 42, + CreatedAt: now, + Protocol: "wireguard", + ConnectionName: "peer1", + ServerID: 1, + } + + adminUser := &models.User{ID: 1, Username: "admin"} + + cases := []struct { + name string + data any + }{ + {"page_install", installView{}}, + {"page_install", installView{Installed: true}}, + {"page_install", installView{Error: "test error"}}, + {"page_login", loginView{PocketEnabled: true, NoAdmins: false}}, + {"page_login", loginView{Error: "bad creds", NoAdmins: true}}, + {"page_cabinet_auth", cabinetAuthView{}}, + {"page_cabinet_auth", cabinetAuthView{Error: "bad creds"}}, + {"page_cabinet", cabinetView{ + Member: &models.Member{ID: 1, Username: "member1", Email: &email, CreatedAt: now}, + Sub: &models.MemberSubscription{MemberID: 1, ExpiresAt: &future, MaxConfigs: 5, ConfigCount: 2}, + SubLabel: "до " + future.Format("2006-01-02"), + ConfigsRemaining: 3, + Servers: []models.ServerInfo{serverInfo}, + Bundles: []share.Bundle{bundle}, + OK: "ok message", + }}, + {"page_cabinet", cabinetView{ + Member: &models.Member{ID: 1, Username: "member1", CreatedAt: now}, + Error: "some error", + }}, + {"page_share", shareView{Token: "abc123", NotFound: true}}, + {"page_share", shareView{ + Token: "abc123", + Link: &sampleLink, + Servers: []models.ServerInfo{serverInfo}, + Bundles: []share.Bundle{bundle}, + OK: "created", + }}, + {"page_share", shareView{ + Token: "abc123", + Link: &sampleLink, + Servers: []models.ServerInfo{serverInfo}, + Expired: true, + LimitReached: true, + Error: "some error", + }}, + {"page_faq", faqView{Items: []faqEntry{{Question: "Q1", Answer: "A1"}}}}, + {"page_rules", nil}, + {"page_status", statusView{Servers: []serverStatus{{ServerInfo: serverInfo, Alive: true, Ms: 42}, {ServerInfo: serverInfo, Alive: false, Err: "timeout"}}}}, + {"page_admin_index", adminIndexView{ + adminLayout: adminLayout{Admin: adminUser, Active: "index"}, + TotalLinks: 10, ActiveLinks: 5, ExpiredLinks: 2, CleanedLinks: 1, LimitLinks: 2, + CodesCount: 3, PanelURL: "https://panel.example.com", PanelOK: true, Maintenance: false, + }}, + {"page_admin_index", adminIndexView{ + adminLayout: adminLayout{Admin: adminUser, Active: "index"}, + PanelErr: "connection refused", + }}, + {"page_admin_links", adminLinksView{ + adminLayout: adminLayout{Admin: adminUser, Active: "links", OK: "saved"}, + StatusFilter: "active", + Page: 1, + Total: 1, + PerPage: 20, + AllowedDurations: []int{7, 30, 90}, + Links: []linkRow{ + {ShareLink: sampleLink, Status: share.StatusActive, ActiveConfigs: 2}, + }, + Detail: &sampleLink, + DetailStatus: share.StatusActive, + Creations: []models.ShareCreation{{ID: 42, ShareLinkID: 1, ServerID: 1, Protocol: "wireguard", ConnectionName: "peer1", CreatedAt: now}}, + DeletedCreations: []models.ShareCreation{{ID: 41, ShareLinkID: 1, ServerID: 1, Protocol: "wireguard", ConnectionName: "peer0", CreatedAt: now}}, + AllowedServerIDs: []int{1, 2}, + AllServers: []models.ServerInfo{serverInfo}, + }}, + {"page_admin_links", adminLinksView{ + adminLayout: adminLayout{Admin: adminUser, Active: "links"}, + PerPage: 20, + }}, + {"page_admin_renewal", adminRenewalView{ + adminLayout: adminLayout{Admin: adminUser, Active: "renewal"}, + Codes: []models.RenewalCode{ + {ID: 1, Code: "ABCD1234", AddDays: 30, MaxUses: 1, UseCount: 0, ShareLinkID: &linkID, CodeExpiresAt: &future, Note: ¬e, CodeTarget: "guest", CreatedAt: now}, + {ID: 2, Code: "WXYZ9999", AddDays: 30, MaxUses: 5, UseCount: 1, CodeTarget: "member", CreatedAt: now}, + }, + }}, + {"page_admin_renewal", adminRenewalView{adminLayout: adminLayout{Admin: adminUser, Active: "renewal"}}}, + {"page_admin_configs", adminConfigsView{ + adminLayout: adminLayout{Admin: adminUser, Active: "configs"}, + Rows: []configRow{ + {ShareCreation: models.ShareCreation{ID: 42, ShareLinkID: 1, ServerID: 1, Protocol: "wireguard", ConnectionName: "peer1", CreatedAt: now}, LinkID: 1, LinkToken: "abc123"}, + }, + LinkIDFilter: 1, + Truncated: true, + }}, + {"page_admin_configs", adminConfigsView{adminLayout: adminLayout{Admin: adminUser, Active: "configs"}}}, + {"page_admin_servers", adminServersView{ + adminLayout: adminLayout{Admin: adminUser, Active: "servers"}, + AllProtocols: share.GuestProtocols, + Rows: []serverRow{ + {ID: 1, Title: "Germany", Protocols: []string{"wireguard", "awg2"}, Flag: "🇩🇪", Speed: "1 Gbps", Disabled: false, PanelName: "de-1", PanelHost: "1.2.3.4"}, + {ID: 2, Title: "France", Protocols: nil, Disabled: true}, + }, + PanelReachable: true, + }}, + {"page_admin_servers", adminServersView{ + adminLayout: adminLayout{Admin: adminUser, Active: "servers"}, + AllProtocols: share.GuestProtocols, + PanelErr: "connection refused", + }}, + {"page_admin_settings", adminSettingsView{ + adminLayout: adminLayout{Admin: adminUser, Active: "settings"}, + PanelURL: "https://panel.example.com", + APIToken: "awp_secret", + ServerLabelsJSON: `{"1":"Germany"}`, + TrafficNotices: `{}`, + Maintenance: true, + PocketURL: "https://id.example.com", + PocketClientID: "client-id", + PocketSecret: "secret", + MemberDays: "30", + MemberMaxUses: "5", + TestResult: "Соединение установлено. Серверов обнаружено: 3.", + }}, + {"page_admin_settings", adminSettingsView{ + adminLayout: adminLayout{Admin: adminUser, Active: "settings"}, + TestError: "connection refused", + }}, + } + + for i, c := range cases { + clone, err := tmpl.Clone() + if err != nil { + t.Fatalf("clone: %v", err) + } + if err := clone.ExecuteTemplate(io.Discard, c.name, c.data); err != nil { + t.Errorf("case %d (%s): execute failed: %v", i, c.name, err) + } + } +} diff --git a/Go/internal/web/handlers/share.go b/Go/internal/web/handlers/share.go new file mode 100644 index 0000000..0d45ed8 --- /dev/null +++ b/Go/internal/web/handlers/share.go @@ -0,0 +1,277 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "amnezia-share/internal/i18n" + "amnezia-share/internal/models" + "amnezia-share/internal/share" + "amnezia-share/internal/web" +) + +func registerShare(a *web.App, mux *http.ServeMux) { + mux.HandleFunc("GET /share", ShareGet(a)) + mux.HandleFunc("POST /share", a.CSRFProtect(SharePost(a))) + mux.HandleFunc("GET /share/servers", ShareServers(a)) + mux.HandleFunc("GET /share/download", ShareDownload(a)) +} + +type shareView struct { + Token string + Link *models.ShareLink + Servers []models.ServerInfo + Bundles []share.Bundle + Maintenance bool + Expired bool + LimitReached bool + NotFound bool + OK string + Error string +} + +func buildBundles(creations []models.ShareCreation) []share.Bundle { + out := make([]share.Bundle, 0, len(creations)) + for _, c := range creations { + if c.ResponseJSON == nil || strings.TrimSpace(*c.ResponseJSON) == "" { + continue + } + b := share.BundleFromResponseJSON(c.Protocol, c.ConnectionName, *c.ResponseJSON) + b.CreationID = c.ID + b.CreatedAt = c.CreatedAt + b.ServerID = c.ServerID + out = append(out, b) + } + return share.SortBundlesWireguardFirst(out) +} + +func guestServersForLink(a *web.App, r *http.Request, link *models.ShareLink) []models.ServerInfo { + ctx := r.Context() + labels := a.Settings.ServerLabels(ctx) + servers := share.BuildGuestServers(ctx, a.Settings, labels) + if link != nil { + servers = share.FilterServersForLink(*link, servers) + } + return servers +} + +// renderSharePage re-loads the link fresh (so post-action state is current) and +// renders the guest share page. +func renderSharePage(a *web.App, w http.ResponseWriter, r *http.Request, token, okMsg, errText string) { + ctx := r.Context() + view := shareView{Token: token, OK: okMsg, Error: errText} + + link, err := a.Share.LinkByToken(ctx, token) + if err != nil || link == nil { + view.NotFound = true + a.Render(w, r, "page_share", view) + return + } + view.Link = link + view.Expired = link.IsExpired() || link.CleanedAt != nil + view.LimitReached = link.MaxUses > 0 && link.UseCount >= link.MaxUses + view.Maintenance = a.Settings.Maintenance(ctx) + view.Servers = guestServersForLink(a, r, link) + + creations, _ := a.Share.ListCreationsForLink(ctx, link.ID) + view.Bundles = buildBundles(creations) + + a.Render(w, r, "page_share", view) +} + +// ShareGet renders the guest share page (share.php GET). +func ShareGet(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if lg := i18n.LangFromRequest(r); lg != "" { + i18n.Persist(w, r, a.Sessions, i18n.NormalizeLang(lg), false) + } + token := r.URL.Query().Get("k") + renderSharePage(a, w, r, token, "", "") + } +} + +func findActiveCreation(creations []models.ShareCreation, id int64) *models.ShareCreation { + for i := range creations { + if creations[i].ID == id { + return &creations[i] + } + } + return nil +} + +// SharePost handles the guest actions on the share page: create / migrate / +// renew (share.php POST). Responds JSON when X-Share-Async:1 is set, otherwise +// re-renders the full page (share.php's classic form-post behaviour). +func SharePost(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + token := r.FormValue("k") + lang := i18n.Resolve(r, a.Sessions) + + link, err := a.Share.LinkByToken(ctx, token) + if err != nil || link == nil { + finishShareAction(a, w, r, token, "", i18n.T(lang, "err_link_not_found", nil)) + return + } + + var actionErr error + var okMsg string + action := r.FormValue("action") + maintenance := a.Settings.Maintenance(ctx) + + switch action { + case "create": + if maintenance { + actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil)) + break + } + serverID := formInt(r, "server_id", -1) + protocol := strings.TrimSpace(r.FormValue("protocol")) + if !share.LinkServerAllowed(*link, serverID) { + actionErr = errors.New(i18n.T(lang, "err_pick_server", nil)) + break + } + if a.Settings.DisabledServers(ctx)[serverID] { + actionErr = errors.New(i18n.T(lang, "err_server_disabled", nil)) + break + } + if !share.ServerProtocolAllowed(ctx, a.Settings, serverID, protocol) { + actionErr = errors.New(i18n.T(lang, "proto_unavailable", nil)) + break + } + _, addErr := a.Share.TryAddConnection(ctx, a.Panel, a.Settings, link, serverID, protocol) + if addErr != nil { + actionErr = addErr + break + } + fresh, _ := a.Share.LinkByID(ctx, link.ID) + remaining, max := 0, 0 + if fresh != nil { + max = fresh.MaxUses + remaining = fresh.MaxUses - fresh.UseCount + } + okMsg = i18n.T(lang, "ok_created", map[string]string{"remaining": fmt.Sprint(remaining), "max": fmt.Sprint(max)}) + + case "migrate": + if maintenance { + actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil)) + break + } + creationID := formInt64(r, "creation_id", 0) + newServerID := formInt(r, "new_server_id", -1) + newProtocol := strings.TrimSpace(r.FormValue("new_protocol")) + if !share.LinkServerAllowed(*link, newServerID) { + actionErr = errors.New(i18n.T(lang, "err_pick_migrate_server", nil)) + break + } + if a.Settings.DisabledServers(ctx)[newServerID] { + actionErr = errors.New(i18n.T(lang, "err_server_disabled", nil)) + break + } + if !share.ServerProtocolAllowed(ctx, a.Settings, newServerID, newProtocol) { + actionErr = errors.New(i18n.T(lang, "proto_unavailable", nil)) + break + } + if err := a.Share.TryMigrateConnection(ctx, a.Panel, a.Settings, link, creationID, newServerID, newProtocol); err != nil { + actionErr = err + break + } + okMsg = i18n.T(lang, "ok_migrated", nil) + + case "renew": + code := r.FormValue("code") + res, err := a.Share.TryRedeemGuest(ctx, token, code) + if err != nil { + actionErr = err + break + } + until := "" + if res.Link != nil { + until = res.Link.ExpiresLabel() + } + okMsg = i18n.T(lang, "ok_renewed", map[string]string{"days": fmt.Sprint(res.AddDays), "until": until}) + + default: + actionErr = errors.New("Неизвестное действие.") + } + + errText := "" + if actionErr != nil { + errText = i18n.TranslateGuestError(lang, actionErr.Error()) + } + finishShareAction(a, w, r, token, okMsg, errText) + } +} + +func finishShareAction(a *web.App, w http.ResponseWriter, r *http.Request, token, okMsg, errText string) { + if wantsJSON(r) { + if errText != "" { + writeJSONError(w, http.StatusBadRequest, errText) + return + } + writeJSONOK(w, map[string]any{"message": okMsg}) + return + } + renderSharePage(a, w, r, token, okMsg, errText) +} + +// ShareServers returns the guest-visible server catalog for a link as JSON, +// used by the page's async refresh (share_servers.php). +func ShareServers(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + token := r.URL.Query().Get("k") + link, err := a.Share.LinkByToken(ctx, token) + if err != nil || link == nil { + writeJSONError(w, http.StatusNotFound, "Ссылка не найдена.") + return + } + writeJSONOK(w, map[string]any{"servers": guestServersForLink(a, r, link)}) + } +} + +// ShareDownload streams a single downloadable artifact (.conf / .vpn / .zip) +// for one of the link's created connections (share_downloads.php). +func ShareDownload(a *web.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + q := r.URL.Query() + token := q.Get("k") + creationID := queryInt(r, "cre", 0) + part := q.Get("part") + + link, err := a.Share.LinkByToken(ctx, token) + if err != nil || link == nil { + http.Error(w, "Ссылка не найдена.", http.StatusNotFound) + return + } + creations, err := a.Share.ListCreationsForLink(ctx, link.ID) + if err != nil { + http.Error(w, "Ошибка сервера.", http.StatusInternalServerError) + return + } + creation := findActiveCreation(creations, int64(creationID)) + if creation == nil || creation.ResponseJSON == nil { + http.Error(w, "Файл не найден.", http.StatusNotFound) + return + } + fp := share.DownloadPayloadForPart(creation.Protocol, creation.ConnectionName, *creation.ResponseJSON, part) + if fp == nil { + http.Error(w, "Файл не найден.", http.StatusNotFound) + return + } + serveFilePart(w, fp) + } +} + +func serveFilePart(w http.ResponseWriter, fp *share.FilePart) { + mime := fp.Mime + if mime == "" { + mime = "text/plain; charset=utf-8" + } + w.Header().Set("Content-Type", mime) + w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(fp.Filename, `"`, "")+`"`) + _, _ = w.Write([]byte(fp.Body)) +} diff --git a/Go/internal/web/json.go b/Go/internal/web/json.go new file mode 100644 index 0000000..7f1e8ca --- /dev/null +++ b/Go/internal/web/json.go @@ -0,0 +1,27 @@ +package web + +import ( + "encoding/json" + "net/http" +) + +// writeJSON writes v as a JSON response with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeJSONError writes {"ok":false,"error":msg} with the given status code. +func writeJSONError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"ok": false, "error": msg}) +} + +// writeJSONOK writes {"ok":true, ...extra} with HTTP 200. +func writeJSONOK(w http.ResponseWriter, extra map[string]any) { + if extra == nil { + extra = map[string]any{} + } + extra["ok"] = true + writeJSON(w, http.StatusOK, extra) +} diff --git a/Go/internal/web/routes.go b/Go/internal/web/routes.go new file mode 100644 index 0000000..2309fce --- /dev/null +++ b/Go/internal/web/routes.go @@ -0,0 +1,101 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "log" + "net/http" + "net/url" + "path/filepath" + "runtime/debug" + "strings" +) + +// registerRoutes is set by internal/web/handlers's init() (via RegisterRoutes), +// avoiding an import cycle: handlers imports web for *App, so web cannot import +// handlers back — it calls into this hook instead. +var registerRoutes func(*App, *http.ServeMux) + +// RegisterRoutes installs the application's route-registration callback. Called +// once from the handlers package's init(). +func RegisterRoutes(fn func(*App, *http.ServeMux)) { + registerRoutes = fn +} + +// Handler builds the full HTTP handler: routing, static assets, session +// load/save, panic recovery, request-id tagging and base-URL prefix handling. +func (a *App) Handler() http.Handler { + mux := http.NewServeMux() + if registerRoutes != nil { + registerRoutes(a, mux) + } + + staticDir := filepath.Join(a.Cfg.WebDir, "static") + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) + + var h http.Handler = mux + h = a.recoverer(h) + h = a.requestID(h) + h = a.Sessions.LoadAndSave(h) + h = a.stripBasePrefix(h) + return h +} + +// recoverer converts panics in handlers into a 500 response instead of crashing +// the server. +func (a *App) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("panic: %v\n%s", rec, debug.Stack()) + http.Error(w, "Внутренняя ошибка сервера.", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +// requestID tags every response with a short correlation id (X-Request-Id), +// useful for grepping logs; purely optional/best-effort. +func (a *App) requestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-Id") + if id == "" { + b := make([]byte, 8) + _, _ = rand.Read(b) + id = hex.EncodeToString(b) + } + w.Header().Set("X-Request-Id", id) + next.ServeHTTP(w, r) + }) +} + +// stripBasePrefix strips the configured APP_BASE_URL path prefix (if any) from +// incoming requests before they reach the router, so route patterns stay +// prefix-free. Requests that arrive without the prefix (e.g. an internal +// container health check hitting 127.0.0.1 directly) are passed through +// unchanged rather than 404ing. +func (a *App) stripBasePrefix(next http.Handler) http.Handler { + prefix := strings.Trim(a.Cfg.BaseURL, "/") + if prefix == "" { + return next + } + p := "/" + prefix + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == p || strings.HasPrefix(r.URL.Path, p+"/") { + trimmed := strings.TrimPrefix(r.URL.Path, p) + if trimmed == "" { + trimmed = "/" + } + r2 := new(http.Request) + *r2 = *r + u2 := new(url.URL) + *u2 = *r.URL + u2.Path = trimmed + r2.URL = u2 + next.ServeHTTP(w, r2) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/Go/internal/web/templates_smoke_test.go b/Go/internal/web/templates_smoke_test.go new file mode 100644 index 0000000..0f563f4 --- /dev/null +++ b/Go/internal/web/templates_smoke_test.go @@ -0,0 +1,39 @@ +package web + +import ( + "testing" + + "amnezia-share/internal/config" +) + +// TestLoadTemplatesParses is a smoke test that ensures every template file +// under web/templates parses cleanly (balanced defines, known functions, +// valid Go template syntax). It does not require a database connection. +func TestLoadTemplatesParses(t *testing.T) { + cfg := config.Config{WebDir: "../../web"} + tmpl, err := loadTemplates(cfg) + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + names := []string{ + "page_install", + "page_login", + "page_cabinet_auth", + "page_cabinet", + "page_share", + "page_faq", + "page_rules", + "page_status", + "page_admin_index", + "page_admin_links", + "page_admin_renewal", + "page_admin_configs", + "page_admin_servers", + "page_admin_settings", + } + for _, n := range names { + if tmpl.Lookup(n) == nil { + t.Errorf("template %q not found after parsing", n) + } + } +} diff --git a/Go/migrations/001_schema.sql b/Go/migrations/001_schema.sql new file mode 100644 index 0000000..bf12127 --- /dev/null +++ b/Go/migrations/001_schema.sql @@ -0,0 +1,132 @@ +-- PostgreSQL 17 schema for Amnezia VPN Share Panel (Go) + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS site_settings ( + skey VARCHAR(64) PRIMARY KEY, + svalue TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS share_links ( + id SERIAL PRIMARY KEY, + token CHAR(32) NOT NULL UNIQUE, + server_id INT NULL DEFAULT NULL, + expires_at TIMESTAMPTZ NULL DEFAULT NULL, + max_uses SMALLINT NOT NULL DEFAULT 5, + validity_days SMALLINT NULL DEFAULT NULL, + use_count SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cleaned_at TIMESTAMPTZ NULL DEFAULT NULL, + traffic_quota_gb NUMERIC(12,3) NULL DEFAULT NULL, + traffic_used_gb NUMERIC(12,3) NOT NULL DEFAULT 0, + traffic_quota_blocked_at TIMESTAMPTZ NULL DEFAULT NULL, + subscription_until DATE NULL DEFAULT NULL, + allowed_server_ids JSONB NULL DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_share_expires ON share_links (expires_at); + +CREATE TABLE IF NOT EXISTS share_link_creations ( + id BIGSERIAL PRIMARY KEY, + share_link_id INT NOT NULL REFERENCES share_links(id) ON DELETE CASCADE, + server_id INT NOT NULL, + protocol VARCHAR(32) NOT NULL, + client_id VARCHAR(512) NOT NULL, + connection_name VARCHAR(128) NOT NULL, + response_json TEXT NULL DEFAULT NULL, + traffic_bytes_baseline BIGINT NOT NULL DEFAULT 0, + panel_traffic_bytes_total BIGINT NULL DEFAULT NULL, + panel_traffic_synced_at TIMESTAMPTZ NULL DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_share_link ON share_link_creations (share_link_id); +CREATE INDEX IF NOT EXISTS idx_share_creations_deleted ON share_link_creations (share_link_id, deleted_at); + +CREATE TABLE IF NOT EXISTS panel_server_labels ( + panel_server_id INT PRIMARY KEY, + title VARCHAR(512) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_panel_server_labels_updated ON panel_server_labels (updated_at); + +CREATE TABLE IF NOT EXISTS share_renewal_codes ( + id SERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + add_days SMALLINT NOT NULL DEFAULT 30, + max_uses INT NOT NULL DEFAULT 1, + use_count INT NOT NULL DEFAULT 0, + share_link_id INT NULL DEFAULT NULL, + code_expires_at TIMESTAMPTZ NULL DEFAULT NULL, + note VARCHAR(255) NULL DEFAULT NULL, + code_target TEXT NOT NULL DEFAULT 'guest' CHECK (code_target IN ('guest', 'member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_share_renewal_link ON share_renewal_codes (share_link_id); + +CREATE TABLE IF NOT EXISTS share_renewal_redemptions ( + id BIGSERIAL PRIMARY KEY, + renewal_code_id INT NOT NULL REFERENCES share_renewal_codes(id) ON DELETE CASCADE, + share_link_id INT NOT NULL REFERENCES share_links(id) ON DELETE CASCADE, + add_days SMALLINT NOT NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (renewal_code_id, share_link_id) +); +CREATE INDEX IF NOT EXISTS idx_redemption_link ON share_renewal_redemptions (share_link_id); + +CREATE TABLE IF NOT EXISTS members ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + email VARCHAR(255) NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ NULL DEFAULT NULL +); + +CREATE TABLE IF NOT EXISTS member_subscriptions ( + member_id INT PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NULL DEFAULT NULL, + validity_days SMALLINT NULL DEFAULT NULL, + max_configs SMALLINT NOT NULL DEFAULT 5, + config_count SMALLINT NOT NULL DEFAULT 0, + cleaned_at TIMESTAMPTZ NULL DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS member_configs ( + id BIGSERIAL PRIMARY KEY, + member_id INT NOT NULL REFERENCES members(id) ON DELETE CASCADE, + server_id INT NOT NULL, + protocol VARCHAR(32) NOT NULL, + client_id VARCHAR(512) NOT NULL, + connection_name VARCHAR(128) NOT NULL, + response_json TEXT NULL DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS idx_member_configs_member ON member_configs (member_id); + +CREATE TABLE IF NOT EXISTS member_renewal_redemptions ( + id BIGSERIAL PRIMARY KEY, + renewal_code_id INT NOT NULL REFERENCES share_renewal_codes(id) ON DELETE CASCADE, + member_id INT NOT NULL REFERENCES members(id) ON DELETE CASCADE, + add_days SMALLINT NOT NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (renewal_code_id, member_id) +); + +-- scs session store +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + data BYTEA NOT NULL, + expiry TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry); diff --git a/Go/web/static/country_flag_emojis_data.json b/Go/web/static/country_flag_emojis_data.json new file mode 100644 index 0000000..227137d --- /dev/null +++ b/Go/web/static/country_flag_emojis_data.json @@ -0,0 +1,1267 @@ +[ + { + "emoji": "🇦🇺", + "name": "Австралия", + "name_en": "" + }, + { + "emoji": "🇦🇹", + "name": "Австрия", + "name_en": "" + }, + { + "emoji": "🇦🇿", + "name": "Азербайджан", + "name_en": "" + }, + { + "emoji": "🇦🇽", + "name": "Аландские острова", + "name_en": "" + }, + { + "emoji": "🇦🇱", + "name": "Албания", + "name_en": "" + }, + { + "emoji": "🇩🇿", + "name": "Алжир", + "name_en": "" + }, + { + "emoji": "🇦🇸", + "name": "Американское Самоа", + "name_en": "" + }, + { + "emoji": "🇦🇮", + "name": "Ангилья", + "name_en": "" + }, + { + "emoji": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "name": "Англия", + "name_en": "" + }, + { + "emoji": "🇦🇴", + "name": "Ангола", + "name_en": "" + }, + { + "emoji": "🇦🇩", + "name": "Андорра", + "name_en": "" + }, + { + "emoji": "🇦🇶", + "name": "Антарктида", + "name_en": "" + }, + { + "emoji": "🇦🇬", + "name": "Антигуа и Барбуда", + "name_en": "" + }, + { + "emoji": "🇦🇷", + "name": "Аргентина", + "name_en": "" + }, + { + "emoji": "🇦🇲", + "name": "Армения", + "name_en": "" + }, + { + "emoji": "🇦🇼", + "name": "Аруба", + "name_en": "" + }, + { + "emoji": "🇦🇫", + "name": "Афганистан", + "name_en": "" + }, + { + "emoji": "🇧🇸", + "name": "Багамские Острова", + "name_en": "" + }, + { + "emoji": "🇧🇩", + "name": "Бангладеш", + "name_en": "" + }, + { + "emoji": "🇧🇧", + "name": "Барбадос", + "name_en": "" + }, + { + "emoji": "🇧🇭", + "name": "Бахрейн", + "name_en": "" + }, + { + "emoji": "🇧🇿", + "name": "Белиз", + "name_en": "" + }, + { + "emoji": "🇧🇾", + "name": "Белоруссия", + "name_en": "" + }, + { + "emoji": "🇧🇪", + "name": "Бельгия", + "name_en": "" + }, + { + "emoji": "🇧🇯", + "name": "Бенин", + "name_en": "" + }, + { + "emoji": "🇧🇲", + "name": "Бермудские Острова", + "name_en": "" + }, + { + "emoji": "🇧🇬", + "name": "Болгария", + "name_en": "" + }, + { + "emoji": "🇧🇴", + "name": "Боливия", + "name_en": "" + }, + { + "emoji": "🇧🇶", + "name": "Бонайре, Синт-Эстатиус и Саба", + "name_en": "" + }, + { + "emoji": "🇧🇦", + "name": "Босния и Герцеговина", + "name_en": "" + }, + { + "emoji": "🇧🇼", + "name": "Ботсвана", + "name_en": "" + }, + { + "emoji": "🇧🇷", + "name": "Бразилия", + "name_en": "" + }, + { + "emoji": "🇮🇴", + "name": "Британская Территория в Индийском Океане", + "name_en": "" + }, + { + "emoji": "🇧🇳", + "name": "Бруней", + "name_en": "" + }, + { + "emoji": "🇧🇫", + "name": "Буркина-Фасо", + "name_en": "" + }, + { + "emoji": "🇧🇮", + "name": "Бурунди", + "name_en": "" + }, + { + "emoji": "🇧🇹", + "name": "Бутан", + "name_en": "" + }, + { + "emoji": "🇻🇺", + "name": "Вануату", + "name_en": "" + }, + { + "emoji": "🇻🇦", + "name": "Ватикан", + "name_en": "" + }, + { + "emoji": "🇬🇧", + "name": "Великобритания", + "name_en": "" + }, + { + "emoji": "🇭🇺", + "name": "Венгрия", + "name_en": "" + }, + { + "emoji": "🇻🇪", + "name": "Венесуэла", + "name_en": "" + }, + { + "emoji": "🇻🇬", + "name": "Виргинские Острова (Великобритания)", + "name_en": "" + }, + { + "emoji": "🇻🇮", + "name": "Виргинские Острова (США)", + "name_en": "" + }, + { + "emoji": "🇺🇲", + "name": "Внешние малые острова США", + "name_en": "" + }, + { + "emoji": "🇹🇱", + "name": "Восточный Тимор", + "name_en": "" + }, + { + "emoji": "🇻🇳", + "name": "Вьетнам", + "name_en": "" + }, + { + "emoji": "🇬🇦", + "name": "Габон", + "name_en": "" + }, + { + "emoji": "🇭🇹", + "name": "Республика Гаити", + "name_en": "" + }, + { + "emoji": "🇬🇾", + "name": "Гайана", + "name_en": "" + }, + { + "emoji": "🇬🇲", + "name": "Гамбия", + "name_en": "" + }, + { + "emoji": "🇬🇭", + "name": "Гана", + "name_en": "" + }, + { + "emoji": "🇬🇵", + "name": "Гваделупа", + "name_en": "" + }, + { + "emoji": "🇬🇹", + "name": "Гватемала", + "name_en": "" + }, + { + "emoji": "🇬🇫", + "name": "Гвиана", + "name_en": "" + }, + { + "emoji": "🇬🇳", + "name": "Гвинея", + "name_en": "" + }, + { + "emoji": "🇬🇼", + "name": "Гвинея-Бисау", + "name_en": "" + }, + { + "emoji": "🇩🇪", + "name": "Германия", + "name_en": "" + }, + { + "emoji": "🇬🇬", + "name": "Гернси", + "name_en": "" + }, + { + "emoji": "🇬🇮", + "name": "Гибралтар", + "name_en": "" + }, + { + "emoji": "🇭🇳", + "name": "Гондурас", + "name_en": "" + }, + { + "emoji": "🇭🇰", + "name": "Гонконг", + "name_en": "" + }, + { + "emoji": "🇬🇩", + "name": "Гренада", + "name_en": "" + }, + { + "emoji": "🇬🇱", + "name": "Гренландия", + "name_en": "" + }, + { + "emoji": "🇬🇷", + "name": "Греция", + "name_en": "" + }, + { + "emoji": "🇬🇪", + "name": "Грузия", + "name_en": "" + }, + { + "emoji": "🇬🇺", + "name": "Гуам", + "name_en": "" + }, + { + "emoji": "🇩🇰", + "name": "Дания", + "name_en": "" + }, + { + "emoji": "🇨🇩", + "name": "Демократическая Республика Конго", + "name_en": "" + }, + { + "emoji": "🇯🇪", + "name": "Джерси", + "name_en": "" + }, + { + "emoji": "🇩🇯", + "name": "Джибути", + "name_en": "" + }, + { + "emoji": "🇩🇲", + "name": "Доминика", + "name_en": "" + }, + { + "emoji": "🇩🇴", + "name": "Доминиканская Республика", + "name_en": "" + }, + { + "emoji": "🇪🇬", + "name": "Египет", + "name_en": "" + }, + { + "emoji": "🇿🇲", + "name": "Замбия", + "name_en": "" + }, + { + "emoji": "🇪🇭", + "name": "Западная Сахара", + "name_en": "" + }, + { + "emoji": "🇿🇼", + "name": "Зимбабве", + "name_en": "" + }, + { + "emoji": "🇮🇱", + "name": "Израиль", + "name_en": "" + }, + { + "emoji": "🇮🇳", + "name": "Индия", + "name_en": "" + }, + { + "emoji": "🇮🇩", + "name": "Индонезия", + "name_en": "" + }, + { + "emoji": "🇯🇴", + "name": "Иордания", + "name_en": "" + }, + { + "emoji": "🇮🇶", + "name": "Ирак", + "name_en": "" + }, + { + "emoji": "🇮🇷", + "name": "Иран", + "name_en": "" + }, + { + "emoji": "🇮🇪", + "name": "Ирландия", + "name_en": "" + }, + { + "emoji": "🇮🇸", + "name": "Исландия", + "name_en": "" + }, + { + "emoji": "🇪🇸", + "name": "Испания", + "name_en": "" + }, + { + "emoji": "🇮🇹", + "name": "Италия", + "name_en": "" + }, + { + "emoji": "🇾🇪", + "name": "Йемен", + "name_en": "" + }, + { + "emoji": "🇨🇻", + "name": "Кабо-Верде", + "name_en": "" + }, + { + "emoji": "🇰🇿", + "name": "Казахстан", + "name_en": "" + }, + { + "emoji": "🇰🇭", + "name": "Камбоджа", + "name_en": "" + }, + { + "emoji": "🇨🇲", + "name": "Камерун", + "name_en": "" + }, + { + "emoji": "🇨🇦", + "name": "Канада", + "name_en": "" + }, + { + "emoji": "🇶🇦", + "name": "Катар", + "name_en": "" + }, + { + "emoji": "🇰🇪", + "name": "Кения", + "name_en": "" + }, + { + "emoji": "🇨🇾", + "name": "Республика Кипр", + "name_en": "" + }, + { + "emoji": "🇰🇬", + "name": "Киргизия", + "name_en": "" + }, + { + "emoji": "🇰🇮", + "name": "Кирибати", + "name_en": "" + }, + { + "emoji": "🇨🇳", + "name": "Китай", + "name_en": "" + }, + { + "emoji": "🇰🇵", + "name": "Корейская Народно-Демократическая Республика", + "name_en": "" + }, + { + "emoji": "🇨🇨", + "name": "Кокосовые острова", + "name_en": "" + }, + { + "emoji": "🇨🇴", + "name": "Колумбия", + "name_en": "" + }, + { + "emoji": "🇰🇲", + "name": "Коморы", + "name_en": "" + }, + { + "emoji": "🇽🇰", + "name": "Республика Косово", + "name_en": "" + }, + { + "emoji": "🇨🇷", + "name": "Коста-Рика", + "name_en": "" + }, + { + "emoji": "🇨🇮", + "name": "Кот-д’Ивуар", + "name_en": "" + }, + { + "emoji": "🇨🇺", + "name": "Куба", + "name_en": "" + }, + { + "emoji": "🇰🇼", + "name": "Кувейт", + "name_en": "" + }, + { + "emoji": "🇨🇼", + "name": "Кюрасао", + "name_en": "" + }, + { + "emoji": "🇱🇦", + "name": "Лаос", + "name_en": "" + }, + { + "emoji": "🇱🇻", + "name": "Латвия", + "name_en": "" + }, + { + "emoji": "🇱🇸", + "name": "Лесото", + "name_en": "" + }, + { + "emoji": "🇱🇷", + "name": "Либерия", + "name_en": "" + }, + { + "emoji": "🇱🇧", + "name": "Ливан", + "name_en": "" + }, + { + "emoji": "🇱🇾", + "name": "Ливия", + "name_en": "" + }, + { + "emoji": "🇱🇹", + "name": "Литва", + "name_en": "" + }, + { + "emoji": "🇱🇮", + "name": "Лихтенштейн", + "name_en": "" + }, + { + "emoji": "🇱🇺", + "name": "Люксембург", + "name_en": "" + }, + { + "emoji": "🇲🇺", + "name": "Маврикий", + "name_en": "" + }, + { + "emoji": "🇲🇷", + "name": "Мавритания", + "name_en": "" + }, + { + "emoji": "🇲🇬", + "name": "Мадагаскар", + "name_en": "" + }, + { + "emoji": "🇾🇹", + "name": "Майотта", + "name_en": "" + }, + { + "emoji": "🇲🇴", + "name": "Макао", + "name_en": "" + }, + { + "emoji": "🇲🇼", + "name": "Малави", + "name_en": "" + }, + { + "emoji": "🇲🇾", + "name": "Малайзия", + "name_en": "" + }, + { + "emoji": "🇲🇱", + "name": "Мали", + "name_en": "" + }, + { + "emoji": "🇲🇻", + "name": "Мальдивы", + "name_en": "" + }, + { + "emoji": "🇲🇹", + "name": "Мальта", + "name_en": "" + }, + { + "emoji": "🇲🇦", + "name": "Марокко", + "name_en": "" + }, + { + "emoji": "🇲🇶", + "name": "Мартиника", + "name_en": "" + }, + { + "emoji": "🇲🇭", + "name": "Маршалловы Острова", + "name_en": "" + }, + { + "emoji": "🇲🇽", + "name": "Мексика", + "name_en": "" + }, + { + "emoji": "🇲🇿", + "name": "Мозамбик", + "name_en": "" + }, + { + "emoji": "🇲🇩", + "name": "Молдавия", + "name_en": "" + }, + { + "emoji": "🇲🇨", + "name": "Монако", + "name_en": "" + }, + { + "emoji": "🇲🇳", + "name": "Монголия", + "name_en": "" + }, + { + "emoji": "🇲🇸", + "name": "Монтсеррат", + "name_en": "" + }, + { + "emoji": "🇲🇲", + "name": "Мьянма", + "name_en": "" + }, + { + "emoji": "🇳🇦", + "name": "Намибия", + "name_en": "" + }, + { + "emoji": "🇳🇷", + "name": "Науру", + "name_en": "" + }, + { + "emoji": "🇳🇵", + "name": "Непал", + "name_en": "" + }, + { + "emoji": "🇳🇪", + "name": "Нигер", + "name_en": "" + }, + { + "emoji": "🇳🇬", + "name": "Нигерия", + "name_en": "" + }, + { + "emoji": "🇳🇱", + "name": "Королевство Нидерландов", + "name_en": "" + }, + { + "emoji": "🇳🇮", + "name": "Никарагуа", + "name_en": "" + }, + { + "emoji": "🇳🇺", + "name": "Ниуэ", + "name_en": "" + }, + { + "emoji": "🇳🇿", + "name": "Новая Зеландия", + "name_en": "" + }, + { + "emoji": "🇳🇨", + "name": "Новая Каледония", + "name_en": "" + }, + { + "emoji": "🇳🇴", + "name": "Норвегия", + "name_en": "" + }, + { + "emoji": "🇦🇪", + "name": "Объединённые Арабские Эмираты", + "name_en": "" + }, + { + "emoji": "🇴🇲", + "name": "Оман", + "name_en": "" + }, + { + "emoji": "🇧🇻", + "name": "Остров Буве", + "name_en": "" + }, + { + "emoji": "🇮🇲", + "name": "Остров Мэн", + "name_en": "" + }, + { + "emoji": "🇳🇫", + "name": "Остров Норфолк", + "name_en": "" + }, + { + "emoji": "🇨🇽", + "name": "Остров Рождества", + "name_en": "" + }, + { + "emoji": "🇭🇲", + "name": "Остров Херд и острова Макдональд", + "name_en": "" + }, + { + "emoji": "🇰🇾", + "name": "Острова Кайман", + "name_en": "" + }, + { + "emoji": "🇨🇰", + "name": "Острова Кука", + "name_en": "" + }, + { + "emoji": "🇵🇳", + "name": "Острова Питкэрн", + "name_en": "" + }, + { + "emoji": "🇸🇭", + "name": "Острова Святой Елены, Вознесения и Тристан-да-Кунья", + "name_en": "" + }, + { + "emoji": "🇵🇰", + "name": "Пакистан", + "name_en": "" + }, + { + "emoji": "🇵🇼", + "name": "Палау", + "name_en": "" + }, + { + "emoji": "🇵🇸", + "name": "Государство Палестина", + "name_en": "" + }, + { + "emoji": "🇵🇦", + "name": "Панама", + "name_en": "" + }, + { + "emoji": "🇵🇬", + "name": "Папуа — Новая Гвинея", + "name_en": "" + }, + { + "emoji": "🇵🇾", + "name": "Парагвай", + "name_en": "" + }, + { + "emoji": "🇵🇪", + "name": "Перу", + "name_en": "" + }, + { + "emoji": "🇵🇱", + "name": "Польша", + "name_en": "" + }, + { + "emoji": "🇵🇹", + "name": "Португалия", + "name_en": "" + }, + { + "emoji": "🇵🇷", + "name": "Пуэрто-Рико", + "name_en": "" + }, + { + "emoji": "🇨🇬", + "name": "Республика Конго", + "name_en": "" + }, + { + "emoji": "🇰🇷", + "name": "Республика Корея", + "name_en": "" + }, + { + "emoji": "🇲🇰", + "name": "Северная Македония", + "name_en": "" + }, + { + "emoji": "🇷🇪", + "name": "Реюньон", + "name_en": "" + }, + { + "emoji": "🇷🇺", + "name": "Россия", + "name_en": "" + }, + { + "emoji": "🇷🇼", + "name": "Руанда", + "name_en": "" + }, + { + "emoji": "🇷🇴", + "name": "Румыния", + "name_en": "" + }, + { + "emoji": "🇸🇻", + "name": "Сальвадор", + "name_en": "" + }, + { + "emoji": "🇼🇸", + "name": "Самоа", + "name_en": "" + }, + { + "emoji": "🇸🇲", + "name": "Сан-Марино", + "name_en": "" + }, + { + "emoji": "🇸🇹", + "name": "Сан-Томе и Принсипи", + "name_en": "" + }, + { + "emoji": "🇸🇦", + "name": "Саудовская Аравия", + "name_en": "" + }, + { + "emoji": "🇸🇿", + "name": "Эсватини", + "name_en": "" + }, + { + "emoji": "🇲🇵", + "name": "Северные Марианские Острова", + "name_en": "" + }, + { + "emoji": "🇸🇨", + "name": "Сейшельские Острова", + "name_en": "" + }, + { + "emoji": "🇧🇱", + "name": "Сен-Бартелеми", + "name_en": "" + }, + { + "emoji": "🇲🇫", + "name": "Сен-Мартен", + "name_en": "" + }, + { + "emoji": "🇵🇲", + "name": "Сен-Пьер и Микелон", + "name_en": "" + }, + { + "emoji": "🇸🇳", + "name": "Сенегал", + "name_en": "" + }, + { + "emoji": "🇻🇨", + "name": "Сент-Винсент и Гренадины", + "name_en": "" + }, + { + "emoji": "🇰🇳", + "name": "Сент-Китс и Невис", + "name_en": "" + }, + { + "emoji": "🇱🇨", + "name": "Сент-Люсия", + "name_en": "" + }, + { + "emoji": "🇷🇸", + "name": "Сербия", + "name_en": "" + }, + { + "emoji": "🇸🇬", + "name": "Сингапур", + "name_en": "" + }, + { + "emoji": "🇸🇽", + "name": "Синт-Мартен", + "name_en": "" + }, + { + "emoji": "🇸🇾", + "name": "Сирия", + "name_en": "" + }, + { + "emoji": "🇸🇰", + "name": "Словакия", + "name_en": "" + }, + { + "emoji": "🇸🇮", + "name": "Словения", + "name_en": "" + }, + { + "emoji": "🇺🇸", + "name": "Соединённые Штаты Америки", + "name_en": "" + }, + { + "emoji": "🇸🇧", + "name": "Соломоновы Острова", + "name_en": "" + }, + { + "emoji": "🇸🇴", + "name": "Сомали", + "name_en": "" + }, + { + "emoji": "🇸🇩", + "name": "Судан", + "name_en": "" + }, + { + "emoji": "🇸🇷", + "name": "Суринам", + "name_en": "" + }, + { + "emoji": "🇸🇱", + "name": "Сьерра-Леоне", + "name_en": "" + }, + { + "emoji": "🇹🇯", + "name": "Таджикистан", + "name_en": "" + }, + { + "emoji": "🇹🇭", + "name": "Таиланд", + "name_en": "" + }, + { + "emoji": "🇹🇼", + "name": "Китайская Республика (Тайвань)", + "name_en": "" + }, + { + "emoji": "🇹🇿", + "name": "Танзания", + "name_en": "" + }, + { + "emoji": "🇹🇨", + "name": "Теркс и Кайкос", + "name_en": "" + }, + { + "emoji": "🇹🇬", + "name": "Того", + "name_en": "" + }, + { + "emoji": "🇹🇰", + "name": "Токелау", + "name_en": "" + }, + { + "emoji": "🇹🇴", + "name": "Тонга", + "name_en": "" + }, + { + "emoji": "🇹🇹", + "name": "Тринидад и Тобаго", + "name_en": "" + }, + { + "emoji": "🇹🇻", + "name": "Тувалу", + "name_en": "" + }, + { + "emoji": "🇹🇳", + "name": "Тунис", + "name_en": "" + }, + { + "emoji": "🇹🇲", + "name": "Туркменистан", + "name_en": "" + }, + { + "emoji": "🇹🇷", + "name": "Турция", + "name_en": "" + }, + { + "emoji": "🇺🇬", + "name": "Уганда", + "name_en": "" + }, + { + "emoji": "🇺🇿", + "name": "Узбекистан", + "name_en": "" + }, + { + "emoji": "🇺🇦", + "name": "Украина", + "name_en": "" + }, + { + "emoji": "🇼🇫", + "name": "Уоллис и Футуна", + "name_en": "" + }, + { + "emoji": "🇺🇾", + "name": "Уругвай", + "name_en": "" + }, + { + "emoji": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + "name": "Уэльс", + "name_en": "" + }, + { + "emoji": "🇫🇴", + "name": "Фарерские острова", + "name_en": "" + }, + { + "emoji": "🇫🇲", + "name": "Федеративные Штаты Микронезии", + "name_en": "" + }, + { + "emoji": "🇫🇯", + "name": "Фиджи", + "name_en": "" + }, + { + "emoji": "🇵🇭", + "name": "Филиппины", + "name_en": "" + }, + { + "emoji": "🇫🇮", + "name": "Финляндия", + "name_en": "" + }, + { + "emoji": "🇫🇰", + "name": "Фолклендские острова", + "name_en": "" + }, + { + "emoji": "🇫🇷", + "name": "Франция", + "name_en": "" + }, + { + "emoji": "🇵🇫", + "name": "Французская Полинезия", + "name_en": "" + }, + { + "emoji": "🇹🇫", + "name": "Французские Южные и Антарктические территории", + "name_en": "" + }, + { + "emoji": "🇭🇷", + "name": "Хорватия", + "name_en": "" + }, + { + "emoji": "🇨🇫", + "name": "Центральноафриканская Республика", + "name_en": "" + }, + { + "emoji": "🇹🇩", + "name": "Чад", + "name_en": "" + }, + { + "emoji": "🇲🇪", + "name": "Черногория", + "name_en": "" + }, + { + "emoji": "🇨🇿", + "name": "Чехия", + "name_en": "" + }, + { + "emoji": "🇨🇱", + "name": "Чили", + "name_en": "" + }, + { + "emoji": "🇨🇭", + "name": "Швейцария", + "name_en": "" + }, + { + "emoji": "🇸🇪", + "name": "Швеция", + "name_en": "" + }, + { + "emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "name": "Шотландия", + "name_en": "" + }, + { + "emoji": "🇸🇯", + "name": "Шпицберген и Ян-Майен", + "name_en": "" + }, + { + "emoji": "🇱🇰", + "name": "Шри-Ланка", + "name_en": "" + }, + { + "emoji": "🇪🇨", + "name": "Эквадор", + "name_en": "" + }, + { + "emoji": "🇬🇶", + "name": "Экваториальная Гвинея", + "name_en": "" + }, + { + "emoji": "🇪🇷", + "name": "Эритрея", + "name_en": "" + }, + { + "emoji": "🇪🇪", + "name": "Эстония", + "name_en": "" + }, + { + "emoji": "🇪🇹", + "name": "Эфиопия", + "name_en": "" + }, + { + "emoji": "🇿🇦", + "name": "Южно-Африканская Республика", + "name_en": "" + }, + { + "emoji": "🇬🇸", + "name": "Южная Георгия и Южные Сандвичевы Острова", + "name_en": "" + }, + { + "emoji": "🇸🇸", + "name": "Южный Судан", + "name_en": "" + }, + { + "emoji": "🇯🇲", + "name": "Ямайка", + "name_en": "" + }, + { + "emoji": "🇯🇵", + "name": "Япония", + "name_en": "" + } +] diff --git a/Go/web/static/css/admin-cyber.css b/Go/web/static/css/admin-cyber.css new file mode 100644 index 0000000..31509eb --- /dev/null +++ b/Go/web/static/css/admin-cyber.css @@ -0,0 +1,619 @@ +/** + * Amnezia Admin — Cyberpunk theme overrides + */ + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #0b0d1a; } +::-webkit-scrollbar-thumb { background: #252b55; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--admin-accent); } + +/* Panels & cards */ +.admin-content .card, +.dash-panel, +.sl-panel { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.12); + background: linear-gradient(160deg, rgba(13, 16, 37, 0.95) 0%, rgba(17, 22, 51, 0.88) 100%); + box-shadow: var(--admin-shadow); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.admin-content .card:hover, +.dash-panel:hover, +.sl-panel:hover { + border-color: rgba(0, 240, 255, 0.22); +} + +.admin-content .card-header h2, +.dash-panel-head h2, +.sl-panel__head h2 { + font-family: var(--admin-font-display); + font-size: 0.88rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #e0e6ff; +} + +.dash-panel-head i, +.sl-panel__head i, +.admin-content .card-header > i { + color: var(--admin-accent); + filter: drop-shadow(0 0 6px var(--admin-accent-glow)); +} + +/* Stats tiles */ +.dash-stat { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.12); + background: linear-gradient(160deg, rgba(13, 16, 37, 0.95) 0%, rgba(17, 22, 51, 0.85) 100%); +} + +.dash-stat:hover { + border-color: rgba(0, 240, 255, 0.35); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 20px rgba(0, 240, 255, 0.08); +} + +.dash-stat-icon { + border-radius: var(--admin-radius); +} + +.dash-stat-label { + font-family: var(--admin-font-mono); + letter-spacing: 0.1em; + color: #555d80; +} + +/* Quick actions */ +.dash-action { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.1); + background: rgba(17, 22, 51, 0.6); +} + +.dash-action:hover { + border-color: rgba(0, 240, 255, 0.35); + background: rgba(0, 240, 255, 0.06); + box-shadow: 0 0 20px rgba(0, 240, 255, 0.08); +} + +.dash-action--primary { + border-color: rgba(0, 240, 255, 0.4); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.12) 0%, rgba(255, 45, 123, 0.06) 100%); +} + +.dash-action--primary:hover { + border-color: rgba(0, 240, 255, 0.6); + box-shadow: 0 0 28px rgba(0, 240, 255, 0.15); +} + +.dash-action-icon { + border-radius: var(--admin-radius); + background: rgba(0, 240, 255, 0.1); + color: var(--admin-accent); + border: 1px solid rgba(0, 240, 255, 0.2); +} + +.dash-action-text strong { + font-family: var(--admin-font-display); + font-size: 0.82rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #e0e6ff; +} + +/* Alerts */ +.dash-alert { + border-radius: var(--admin-radius); + border-color: rgba(255, 226, 61, 0.35); + background: rgba(255, 226, 61, 0.06); + color: #ffe23d; +} + +/* Code & mono */ +.admin-content code, +.code-inline, +.dash-info-user code, +.sl-url-result__text, +.sl-id, +.sl-creation__cid, +.mono { + font-family: var(--admin-font-mono) !important; +} + +.code-inline, +.admin-page-links .code-inline, +.admin-page-settings .code-inline, +.dash-info-user code { + background: rgba(0, 240, 255, 0.08); + color: var(--admin-accent); + border: 1px solid rgba(0, 240, 255, 0.15); + border-radius: 2px; +} + +/* Tables */ +.sl-table-wrap { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.15); + box-shadow: inset 0 0 0 1px rgba(0, 240, 255, 0.04); +} + +.sl-table thead th { + background: rgba(13, 16, 37, 0.98); + color: var(--admin-accent) !important; + font-family: var(--admin-font-mono); + letter-spacing: 0.1em; + border-bottom-color: rgba(0, 240, 255, 0.2); +} + +.sl-table tbody td { + color: #c8d0f0 !important; + border-bottom-color: rgba(26, 31, 61, 0.8); +} + +.sl-table tbody tr:hover td { + background: rgba(0, 240, 255, 0.04); +} + +.sl-id { + color: var(--admin-accent-2); + font-weight: 600; +} + +.sl-url-link { + color: #8892b8; +} + +.sl-url-link:hover { + color: var(--admin-accent); + text-shadow: 0 0 8px var(--admin-accent-glow); +} + +/* Inputs */ +.sl-input, +.admin-content .form-control, +.admin-content .form-select, +.admin-content input[type="text"], +.admin-content input[type="number"], +.admin-content input[type="date"], +.admin-content input[type="datetime-local"], +.admin-content input[type="search"], +.admin-content textarea, +.admin-content select { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.18); + background: rgba(5, 6, 15, 0.8); + color: #e0e6ff; + font-family: var(--admin-font-body); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.sl-input:focus, +.admin-content .form-control:focus, +.admin-content .form-select:focus, +.admin-content input:focus, +.admin-content textarea:focus, +.admin-content select:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 2px rgba(0, 240, 255, 0.15), 0 0 16px rgba(0, 240, 255, 0.1); + background: rgba(5, 6, 15, 0.95); + color: #e0e6ff; +} + +.sl-search__bar { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.18); + background: rgba(5, 6, 15, 0.7); +} + +.sl-search__bar:focus-within { + border-color: rgba(0, 240, 255, 0.45); + box-shadow: 0 0 20px rgba(0, 240, 255, 0.1); +} + +.sl-search__btn { + background: rgba(0, 240, 255, 0.15); + color: var(--admin-accent); + border-radius: var(--admin-radius); + font-family: var(--admin-font-mono); + font-size: 0.78rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.sl-search__btn:hover { + background: rgba(0, 240, 255, 0.25); + box-shadow: 0 0 16px var(--admin-accent-glow); +} + +/* Buttons */ +.sl-btn-generate, +.sl-btn-save-timing, +.sl-btn-cleanup, +.sl-btn-migrate, +.admin-content .btn-primary { + border-radius: var(--admin-radius); + font-family: var(--admin-font-display); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid rgba(0, 240, 255, 0.45); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.18) 0%, rgba(0, 240, 255, 0.06) 100%); + color: var(--admin-accent); + box-shadow: 0 0 16px rgba(0, 240, 255, 0.1); + transition: box-shadow 0.15s, border-color 0.15s, transform 0.12s; +} + +.sl-btn-generate:hover, +.sl-btn-save-timing:hover, +.sl-btn-cleanup:hover:not(:disabled), +.sl-btn-migrate:hover, +.admin-content .btn-primary:hover { + border-color: var(--admin-accent); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.28) 0%, rgba(255, 45, 123, 0.1) 100%); + color: #fff; + box-shadow: 0 0 24px var(--admin-accent-glow); + transform: translateY(-1px); +} + +.sl-btn-copy, +.admin-content .btn-outline-secondary { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.25); + color: #8892b8; + font-family: var(--admin-font-mono); + font-size: 0.78rem; +} + +.sl-btn-copy:hover, +.admin-content .btn-outline-secondary:hover { + border-color: var(--admin-accent); + color: var(--admin-accent); + background: rgba(0, 240, 255, 0.06); +} + +.sl-chip { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.2); + font-family: var(--admin-font-mono); + font-size: 0.75rem; +} + +.sl-chip.active, +.sl-chip:hover { + border-color: var(--admin-accent); + background: rgba(0, 240, 255, 0.12); + color: var(--admin-accent); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.15); +} + +/* Config cards */ +.sl-creation { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.12); + background: rgba(5, 6, 15, 0.5); +} + +.sl-creation:hover { + border-color: rgba(0, 240, 255, 0.25); +} + +.sl-creation--deleted { + border-color: rgba(255, 56, 96, 0.2); + opacity: 0.75; +} + +.sl-configs-details summary { + font-family: var(--admin-font-mono); + color: #8892b8; +} + +.sl-configs-details[open] summary { + color: var(--admin-accent); +} + +/* Server sidebar (configs page) */ +.sl-sidebar { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.15); + background: rgba(13, 16, 37, 0.9); +} + +.sl-sidebar__item.is-active, +.sl-sidebar__item:hover { + border-color: rgba(0, 240, 255, 0.35); + background: rgba(0, 240, 255, 0.08); + box-shadow: inset 3px 0 0 var(--admin-accent); +} + +.sl-sidebar__item.is-active .sl-sidebar__name { + color: var(--admin-accent); + text-shadow: 0 0 8px var(--admin-accent-glow); +} + +/* Pagination */ +.sl-pager__btn { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.18); + background: rgba(5, 6, 15, 0.7); + font-family: var(--admin-font-mono); +} + +.sl-pager__btn:hover:not(.is-disabled):not(.is-active) { + border-color: var(--admin-accent); + color: var(--admin-accent); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.12); +} + +.sl-pager__page.is-active { + background: var(--admin-accent); + color: #05060f; + border-color: var(--admin-accent); + box-shadow: 0 0 16px var(--admin-accent-glow); + font-weight: 700; +} + +/* Timing form modal */ +.sl-timing-form { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.25); + background: linear-gradient(160deg, #0d1025 0%, #111633 100%); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 1px rgba(0, 240, 255, 0.3); +} + +.sl-timing-form__head strong { + font-family: var(--admin-font-display); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--admin-accent); +} + +.sl-timing-form__section { + font-family: var(--admin-font-mono); + color: var(--admin-accent-2); + letter-spacing: 0.1em; +} + +.sl-form-module { + border-color: rgba(0, 240, 255, 0.12); + background: rgba(8, 12, 28, 0.5); +} + +.sl-form-module__head { + font-family: var(--admin-font-mono); + color: var(--admin-accent-2); + letter-spacing: 0.1em; + background: rgba(0, 240, 255, 0.04); + border-bottom-color: rgba(0, 240, 255, 0.1); +} + +.sl-switch input:checked + .sl-switch__track { + background: rgba(0, 240, 255, 0.85); + border-color: var(--admin-accent); + box-shadow: 0 0 14px var(--admin-accent-glow); +} + +.sl-switch-row--master { + background: rgba(0, 240, 255, 0.05); + border-color: rgba(0, 240, 255, 0.18); +} + +.sl-server-toolbar__btn { + font-family: var(--admin-font-mono); + letter-spacing: 0.04em; +} + +.sl-server-toolbar__count { + font-family: var(--admin-font-mono); + color: var(--admin-accent); + border-color: rgba(0, 240, 255, 0.2); + background: rgba(0, 240, 255, 0.06); +} + +.sl-server-pick:has(.js-server-pick:checked) { + background: rgba(0, 240, 255, 0.07); +} + +.sl-btn-save-timing { + font-family: var(--admin-font-display); + letter-spacing: 0.08em; + text-transform: uppercase; + background: transparent; + color: var(--admin-accent); + border: 1px solid var(--admin-accent); + box-shadow: 0 0 16px rgba(0, 240, 255, 0.15); +} + +.sl-btn-save-timing:hover { + background: rgba(0, 240, 255, 0.12); + color: #fff; +} + +/* URL result */ +.sl-url-result { + border-radius: var(--admin-radius); + border-color: rgba(0, 255, 157, 0.3); + background: rgba(0, 255, 157, 0.05); +} + +.sl-url-result__label { + color: #00ff9d; + font-family: var(--admin-font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Status filter pills */ +.sl-status-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.65rem; + width: 100%; +} + +.sl-status-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.38rem 0.72rem; + border-radius: var(--admin-radius); + border: 1px solid rgba(0, 240, 255, 0.18); + background: rgba(5, 6, 15, 0.7); + color: #8892b8; + font-family: var(--admin-font-mono); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-decoration: none; + text-transform: uppercase; + transition: border-color 0.15s, color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.sl-status-pill__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.35rem; + padding: 0.1rem 0.35rem; + border-radius: 2px; + background: rgba(255, 255, 255, 0.06); + font-size: 0.68rem; + color: inherit; +} + +.sl-status-pill:hover { + border-color: rgba(0, 240, 255, 0.4); + color: var(--admin-accent); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.1); +} + +.sl-status-pill.is-active { + color: #05060f; + border-color: transparent; + box-shadow: 0 0 16px rgba(0, 240, 255, 0.2); +} + +.sl-status-pill.is-active .sl-status-pill__count { + background: rgba(0, 0, 0, 0.2); + color: inherit; +} + +.sl-status-pill--active.is-active { + background: #00ff9d; + box-shadow: 0 0 16px rgba(0, 255, 157, 0.35); +} + +.sl-status-pill--expired.is-active { + background: #ffe23d; + color: #1a1400; + box-shadow: 0 0 16px rgba(255, 226, 61, 0.35); +} + +.sl-status-pill--expired.is-active .sl-status-pill__count { + color: #1a1400; +} + +.sl-status-pill--cleaned.is-active { + background: #ff3860; + box-shadow: 0 0 16px rgba(255, 56, 96, 0.35); +} + +.sl-status-pill:not(.sl-status-pill--active):not(.sl-status-pill--expired):not(.sl-status-pill--cleaned).is-active { + background: var(--admin-accent); + box-shadow: 0 0 16px var(--admin-accent-glow); +} + +.sl-controls-bar .sl-toolbar { + margin-left: auto; +} + +@media (min-width: 768px) { + .sl-controls-bar { + align-items: flex-start; + } + + .sl-status-filters { + margin-bottom: 0; + width: auto; + flex: 1; + } +} + +/* Controls bar */ +.sl-controls-bar { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.12); + background: rgba(13, 16, 37, 0.6); +} + +.sl-toggle span { + font-family: var(--admin-font-mono); + font-size: 0.78rem; + color: #8892b8; +} + +/* Renewal codes page */ +.admin-page-renewal .rc-code-display, +.admin-page-renewal .rc-code-pill { + color: var(--admin-accent); + font-family: var(--admin-font-mono); + text-shadow: 0 0 8px var(--admin-accent-glow); +} + +.admin-page-renewal .rc-days-badge { + color: #00ff9d; + background: rgba(0, 255, 157, 0.1); + border: 1px solid rgba(0, 255, 157, 0.25); + border-radius: var(--admin-radius); + font-family: var(--admin-font-mono); +} + +.admin-page-renewal .rc-usage-fill { + background: linear-gradient(90deg, var(--admin-accent), var(--admin-accent-3)); + box-shadow: 0 0 8px var(--admin-accent-glow); +} + +/* Settings form */ +.admin-page-settings .form-label, +.sl-field__label { + font-family: var(--admin-font-mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #8892b8; +} + +/* Server labels page */ +.sl-server-row { + border-radius: var(--admin-radius); + border-color: rgba(0, 240, 255, 0.1); +} + +.sl-server-row:hover { + border-color: rgba(0, 240, 255, 0.25); + background: rgba(0, 240, 255, 0.03); +} + +.sl-hint a { + color: var(--admin-accent); +} + +.sl-hint--warn { + color: #ffe23d; + border-color: rgba(255, 226, 61, 0.25); + background: rgba(255, 226, 61, 0.06); +} + +/* Icons glow on hover */ +.admin-content .fa-solid, +.admin-content .fa-regular { + transition: filter 0.15s; +} + +.sl-panel__head:hover i, +.dash-panel-head:hover i { + filter: drop-shadow(0 0 8px var(--admin-accent-glow)); +} diff --git a/Go/web/static/css/admin.css b/Go/web/static/css/admin.css new file mode 100644 index 0000000..d616161 --- /dev/null +++ b/Go/web/static/css/admin.css @@ -0,0 +1,4572 @@ +/** + * Amnezia Admin — Cyberpunk theme (Bootstrap 5 + Font Awesome 6) + */ +:root, +[data-bs-theme="dark"] { + --bs-body-bg: #05060f; + --bs-body-color: #e0e6ff; + --bs-border-color: #1a1f3d; + --bs-primary: #00f0ff; + --bs-primary-rgb: 0, 240, 255; + --bs-success: #00ff9d; + --bs-danger: #ff3860; + --bs-warning: #ffe23d; + --bs-secondary-color: #8892b8; + --admin-sidebar-w: 260px; + --admin-accent: #00f0ff; + --admin-accent-2: #ff2d7b; + --admin-accent-3: #b24dff; + --admin-accent-glow: rgba(0, 240, 255, 0.35); + --admin-accent-pink-glow: rgba(255, 45, 123, 0.35); + --admin-surface: #0d1025; + --admin-surface-2: #111633; + --admin-surface-3: #161b3d; + --admin-font-display: "Orbitron", sans-serif; + --admin-font-mono: "JetBrains Mono", ui-monospace, monospace; + --admin-font-body: "Inter", system-ui, sans-serif; + --admin-radius: 4px; + --admin-shadow: 0 8px 40px rgba(0, 0, 0, 0.55), 0 0 1px rgba(0, 240, 255, 0.15); +} + +::selection { + background: rgba(0, 240, 255, 0.25); + color: #fff; +} + +body.admin-app { + font-family: var(--admin-font-body); + background: #05060f; + min-height: 100vh; + overflow-x: hidden; + color: var(--bs-body-color); +} + +body.admin-app::before { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; + background: + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 240, 255, 0.12), transparent 55%), + radial-gradient(ellipse 60% 40% at 100% 50%, rgba(178, 77, 255, 0.08), transparent 50%), + radial-gradient(ellipse 50% 35% at 0% 80%, rgba(255, 45, 123, 0.06), transparent 45%), + linear-gradient(180deg, #05060f 0%, #0b0d1a 50%, #05060f 100%); +} + +body.admin-app::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + opacity: 0.45; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 240, 255, 0.018) 2px, + rgba(0, 240, 255, 0.018) 4px + ); +} + +/* Sidebar */ +.admin-sidebar { + width: var(--admin-sidebar-w); + background: linear-gradient(180deg, rgba(13, 16, 37, 0.98) 0%, rgba(5, 6, 15, 0.99) 100%); + border-right: 1px solid rgba(0, 240, 255, 0.18); + box-shadow: 4px 0 32px rgba(0, 0, 0, 0.5), inset -1px 0 0 rgba(0, 240, 255, 0.08); + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1040; + display: flex; + flex-direction: column; +} + +.admin-brand { + padding: 1.25rem 1.1rem; + border-bottom: 1px solid rgba(0, 240, 255, 0.12); + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; + color: #e0e6ff; + flex-shrink: 0; + position: relative; +} + +.admin-brand::after { + content: ""; + position: absolute; + bottom: -1px; + left: 1.1rem; + right: 1.1rem; + height: 1px; + background: linear-gradient(90deg, transparent, var(--admin-accent), var(--admin-accent-2), transparent); + opacity: 0.6; +} + +.admin-brand:hover { + color: var(--admin-accent); +} + +.admin-brand-icon { + width: 42px; + height: 42px; + border-radius: var(--admin-radius); + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 240, 255, 0.08); + color: var(--admin-accent); + font-size: 1.15rem; + border: 1px solid rgba(0, 240, 255, 0.35); + box-shadow: 0 0 20px var(--admin-accent-glow), inset 0 0 12px rgba(0, 240, 255, 0.06); +} + +.admin-brand-text { + font-family: var(--admin-font-display); + font-weight: 700; + font-size: 0.88rem; + letter-spacing: 0.12em; + line-height: 1.2; + text-transform: uppercase; + text-shadow: 0 0 12px var(--admin-accent-glow); +} + +.admin-brand-sub { + display: block; + font-family: var(--admin-font-mono); + font-size: 0.62rem; + font-weight: 500; + color: var(--admin-accent-2); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.admin-nav { + flex: 1; + padding: 0.75rem; + overflow-y: auto; +} + +.admin-nav .nav-link { + color: #8892b8; + border-radius: var(--admin-radius); + padding: 0.65rem 0.85rem; + margin-bottom: 0.2rem; + display: flex; + align-items: center; + gap: 0.65rem; + font-weight: 500; + font-size: 0.85rem; + border: 1px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; + font-family: var(--admin-font-body); + letter-spacing: 0.02em; +} + +.admin-nav .nav-link i { + width: 1.25rem; + text-align: center; + font-size: 1rem; + flex-shrink: 0; + filter: drop-shadow(0 0 4px transparent); + transition: filter 0.15s; +} + +.admin-nav .nav-link:hover { + background: rgba(0, 240, 255, 0.06); + color: var(--admin-accent); + border-color: rgba(0, 240, 255, 0.2); +} + +.admin-nav .nav-link:hover i { + filter: drop-shadow(0 0 6px var(--admin-accent-glow)); +} + +.admin-nav .nav-link.active { + background: linear-gradient(90deg, rgba(0, 240, 255, 0.12) 0%, rgba(255, 45, 123, 0.06) 100%); + color: var(--admin-accent); + border-color: rgba(0, 240, 255, 0.35); + font-weight: 600; + box-shadow: inset 3px 0 0 var(--admin-accent), 0 0 16px rgba(0, 240, 255, 0.08); +} + +.admin-nav .nav-link.active i { + filter: drop-shadow(0 0 8px var(--admin-accent-glow)); + color: var(--admin-accent); +} + +.admin-sidebar-footer { + padding: 1rem; + border-top: 1px solid rgba(0, 240, 255, 0.12); + flex-shrink: 0; +} + +.admin-sidebar-footer .btn-outline-danger { + border-color: rgba(255, 56, 96, 0.45); + color: #ff3860; + font-family: var(--admin-font-mono); + font-size: 0.78rem; + letter-spacing: 0.06em; + text-transform: uppercase; + border-radius: var(--admin-radius); +} + +.admin-sidebar-footer .btn-outline-danger:hover { + background: rgba(255, 56, 96, 0.12); + border-color: #ff3860; + box-shadow: 0 0 16px rgba(255, 56, 96, 0.25); + color: #ff6b8a; +} + +/* Main area */ +.admin-main { + margin-left: var(--admin-sidebar-w); + min-height: 100vh; + width: calc(100% - var(--admin-sidebar-w)); + display: flex; + flex-direction: column; +} + +/* Burger — only mobile */ +#adminSidebarToggle { + display: none; + flex-shrink: 0; + border-color: rgba(0, 240, 255, 0.3); + color: var(--admin-accent); + border-radius: var(--admin-radius); +} + +.admin-topbar { + position: sticky; + top: 0; + z-index: 1030; + background: rgba(5, 6, 15, 0.88); + backdrop-filter: blur(16px); + border-bottom: 1px solid rgba(0, 240, 255, 0.15); + padding: 1rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35), 0 1px 0 rgba(0, 240, 255, 0.06); +} + +.admin-topbar-title { + min-width: 0; + flex: 1; +} + +.admin-topbar-title h1 { + margin: 0; + font-family: var(--admin-font-display); + font-size: 1.05rem; + font-weight: 700; + color: #e0e6ff; + line-height: 1.3; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 0 20px rgba(0, 240, 255, 0.2); +} + +.admin-topbar-title h1 .text-primary, +.admin-topbar-title h1 i { + color: var(--admin-accent) !important; + filter: drop-shadow(0 0 6px var(--admin-accent-glow)); +} + +.admin-topbar-meta { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-family: var(--admin-font-mono); +} + +.admin-topbar-meta .badge-protocol { + background: rgba(0, 255, 157, 0.08); + color: #00ff9d; + border: 1px solid rgba(0, 255, 157, 0.35); + font-weight: 600; + font-size: 0.68rem; + padding: 0.35rem 0.55rem; + border-radius: var(--admin-radius); + letter-spacing: 0.08em; + text-transform: uppercase; + box-shadow: 0 0 12px rgba(0, 255, 157, 0.15); +} + +.admin-content { + flex: 1; + width: 100%; + max-width: none; + padding: 1.25rem 1.5rem 2rem; +} + +/* Legacy cards */ +.admin-content .card { + background: var(--admin-surface); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + padding: 1.25rem; + margin-bottom: 1.25rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.admin-content .card-header { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.admin-content .card-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: #f1f5f9; +} + +.admin-content .card-header > i.fa-solid, +.admin-content .card-header > i.fa-regular { + color: var(--admin-accent); + font-size: 1.1rem; + width: 1.25rem; + text-align: center; + flex-shrink: 0; +} + +.admin-content .card-header svg { + width: 1.1rem; + height: 1.1rem; + color: var(--admin-accent); + flex-shrink: 0; +} + +/* SVG без width/height в summary и кнопках растягиваются на весь блок */ +.admin-content svg { + width: 1.15rem; + height: 1.15rem; + max-width: 2rem; + max-height: 2rem; + flex-shrink: 0; + vertical-align: middle; +} + +.admin-content .btn svg { + width: 1rem; + height: 1rem; +} + +.admin-content details { + margin: 0; +} + +.admin-content details summary { + cursor: pointer; + list-style: none; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--admin-accent); + padding: 0.25rem 0; +} + +.admin-content details summary::-webkit-details-marker { + display: none; +} + +.admin-content details[open] > summary { + margin-bottom: 0.35rem; +} + +.admin-content details summary > .fa-chevron-right { + font-size: 0.65rem; + transition: transform 0.15s ease; +} + +.admin-content details[open] summary > .fa-chevron-right { + transform: rotate(90deg); +} + +.admin-content .timing-details summary.timing-summary { + font-size: 0.82rem; +} + +.admin-content .btn .fa-solid, +.admin-content .btn .fa-regular { + font-size: 0.9em; +} + +.admin-cleanup-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.admin-cleanup-row .btn-danger { + max-width: 100%; +} + +.days-preset.active, +.uses-preset.active { + border-color: rgba(56, 189, 248, 0.5) !important; + color: var(--admin-accent) !important; + background: rgba(56, 189, 248, 0.12) !important; +} + +/* Stat tiles */ +.stat-grid { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 1rem; + margin-bottom: 1.25rem; +} + +@media (min-width: 576px) { + .stat-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1200px) { + .stat-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.stat-grid-compact { + margin-bottom: 0.75rem; + gap: 0.65rem; +} + +.stat-grid-compact .stat-tile { + min-height: 72px; + padding: 0.75rem 0.85rem; +} + +.stat-grid-compact .stat-val { + font-size: 1.2rem; +} + +.stat-tile { + background: var(--admin-surface); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + padding: 1.1rem 1.15rem; + display: flex; + align-items: flex-start; + gap: 0.85rem; + min-height: 88px; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s; +} + +.stat-tile:hover { + border-color: rgba(56, 189, 248, 0.4); + box-shadow: 0 0 24px rgba(56, 189, 248, 0.08); + transform: translateY(-1px); +} + +.stat-tile > i.fa-solid, +.stat-tile > i.fa-regular { + font-size: 1.4rem; + color: var(--admin-accent); + flex-shrink: 0; + margin-top: 0.1rem; + width: 1.5rem; + text-align: center; +} + +.stat-tile > svg { + width: 1.4rem; + height: 1.4rem; + color: var(--admin-accent); + flex-shrink: 0; +} + +.stat-val { + display: block; + font-size: 1.45rem; + font-weight: 700; + line-height: 1.2; + color: #f8fafc; +} + +.stat-label { + display: block; + font-size: 0.72rem; + color: var(--bs-secondary-color); + margin-top: 0.35rem; + text-transform: uppercase; + letter-spacing: 0.05em; + line-height: 1.35; +} + +/* Legacy buttons */ +.admin-content .btn.btn-primary, +.admin-content a.btn.btn-primary { + background: #0ea5e9; + border-color: #0ea5e9; + color: #fff; +} + +.admin-content .btn.btn-primary:hover, +.admin-content a.btn.btn-primary:hover { + background: #0284c7; + border-color: #0284c7; + color: #fff; +} + +.admin-content .btn.btn-danger, +.admin-content a.btn.btn-danger { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.45); + color: #fca5a5; +} + +.admin-content .btn.btn-ghost, +.admin-content a.btn.btn-ghost { + background: var(--admin-surface-2); + border-color: var(--bs-border-color); + color: #e2e8f0; +} + +.admin-content .btn.btn-ghost:hover, +.admin-content a.btn.btn-ghost:hover { + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.35); + color: #f8fafc; +} + +.admin-content .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: background 0.15s, border-color 0.15s, color 0.15s; + line-height: 1.4; +} + +.admin-content .btn-sm { + padding: 0.32rem 0.65rem; + font-size: 0.78rem; +} + +.admin-content .btn-copy { + background: var(--admin-surface-2); + border-color: var(--bs-border-color); + color: var(--admin-accent); + font-size: 0.78rem; + padding: 0.28rem 0.55rem; +} + +/* Legacy alerts only */ +.alert-ok, +.alert-err { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.85rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + font-size: 0.9rem; + border: 1px solid transparent; +} + +.alert-ok { + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.35); + color: #6ee7b7; +} + +.alert-err { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.35); + color: #fca5a5; +} + +/* Tables */ +.tbl-wrap { + overflow-x: auto; + margin-top: 0.5rem; + border-radius: 0.5rem; + border: 1px solid var(--bs-border-color); +} + +.tbl-wrap table { + width: 100%; + margin-bottom: 0; + font-size: 0.85rem; +} + +.tbl-wrap th { + background: var(--admin-surface-2); + color: #94a3b8; + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.tbl-wrap td { + padding: 0.6rem 0.75rem; + vertical-align: top; + border-bottom: 1px solid rgba(42, 51, 82, 0.6); + color: #e2e8f0; +} + +.tbl-wrap tbody tr:hover td { + background: rgba(56, 189, 248, 0.04); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + border-radius: 0.35rem; + font-size: 0.72rem; + font-weight: 600; + border: 1px solid transparent; +} + +.badge-ok { background: rgba(0, 255, 157, 0.1); color: #00ff9d; border-color: rgba(0, 255, 157, 0.35); } +.badge-err { background: rgba(255, 56, 96, 0.1); color: #ff6b8a; border-color: rgba(255, 56, 96, 0.35); } +.badge-danger { background: rgba(255, 56, 96, 0.1); color: #ff3860; border-color: rgba(255, 56, 96, 0.3); } +.badge-warn { background: rgba(255, 226, 61, 0.1); color: #ffe23d; border-color: rgba(255, 226, 61, 0.3); } +.badge-info { background: rgba(0, 240, 255, 0.1); color: #00f0ff; border-color: rgba(0, 240, 255, 0.3); } + +.hint { + color: var(--bs-secondary-color); + font-size: 0.85rem; + line-height: 1.55; + margin: 0.35rem 0 0.75rem; +} + +.mono { + font-family: ui-monospace, "JetBrains Mono", monospace; + font-size: 0.8rem; + word-break: break-all; +} + +.url-box { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + padding: 0.6rem 0.75rem; + margin-top: 0.75rem; +} + +.url-box .mono, +.url-box .url-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: #7dd3fc; +} + +.url-box--created { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.1); +} + +.url-box--created .url-text { + color: #e0f2fe; + word-break: break-all; + white-space: normal; +} + +.url-box .text-info { + color: #38bdf8 !important; +} + +/* Компактная форма ссылок */ +.card-compact { + padding: 1rem; +} + +.card-compact .card-header { + margin-bottom: 0.65rem; + padding-bottom: 0.5rem; +} + +.hint-tight { + margin: 0 0 0.35rem; + font-size: 0.75rem; + line-height: 1.35; +} + +.form-compact .form-label-tight { + margin: 0 0 0.15rem; + font-size: 0.8rem; +} + +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem 1.25rem; + margin-bottom: 0.65rem; +} + +@media (max-width: 767.98px) { + .form-row-2 { + grid-template-columns: 1fr; + } +} + +.form-row-actions .form-col-btn { + display: flex; + align-items: flex-end; + justify-content: flex-start; +} + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.35rem; +} + +.preset-row .btn { + padding: 0.2rem 0.45rem; + font-size: 0.72rem; +} + +.input-inline { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.admin-content .input-narrow { + max-width: 5.5rem; + padding: 0.35rem 0.5rem; + font-size: 0.85rem; +} + +.admin-content .input-narrow-date { + max-width: 11rem; + padding: 0.35rem 0.5rem; + font-size: 0.85rem; +} + +.admin-content .form-compact label:not(.form-label-tight) { + margin-top: 0.5rem; +} + +.links-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + margin-bottom: 0.75rem; + font-size: 0.8rem; + color: var(--bs-secondary-color); +} + +.links-filter-check { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0; + cursor: pointer; + user-select: none; +} + +.links-filter-check input { + accent-color: var(--admin-accent); +} + +.link-row--cleaned td { + opacity: 0.88; +} + +.link-configs-cell { + padding: 0.35rem 0.65rem 0.5rem !important; + background: rgba(15, 23, 42, 0.5); +} + +.config-section-title { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.config-section-title--deleted { + color: #f87171; +} + +.creation-card { + background: var(--admin-surface-2); + border-color: var(--bs-border-color) !important; +} + +.creation-card--deleted { + border-style: dashed !important; + border-color: rgba(239, 68, 68, 0.35) !important; + background: rgba(239, 68, 68, 0.06); +} + +body.hide-deleted-configs .config-section-title--deleted, +body.hide-deleted-configs .creation-card--deleted { + display: none !important; +} + +.mono.text-light, +.creation-card .mono.text-light { + color: #e2e8f0; +} + +/* Страница серверов (server_labels.php) */ +.admin-page-servers .card { + margin-bottom: 0.85rem; +} + +.admin-page-servers .tbl-wrap th, +.admin-page-servers .tbl-wrap td { + padding: 0.4rem 0.55rem; + font-size: 0.82rem; +} + +.admin-page-servers .tbl-wrap td.text-muted-col { + color: #94a3b8; +} + +.admin-page-servers .code-inline { + background: var(--admin-surface-2); + color: #7dd3fc; + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.82em; +} + +.admin-page-servers .form-panel { + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + padding: 0.65rem 0.75rem; + margin-bottom: 0.65rem; +} + +.admin-page-servers .form-inline-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: flex-end; +} + +.admin-page-servers .form-inline-grid .field { + display: flex; + flex-direction: column; + gap: 0.12rem; +} + +.admin-page-servers .form-inline-grid .field-grow { + flex: 1; + min-width: 10rem; +} + +.admin-page-servers .form-inline-grid label { + margin: 0; + font-size: 0.78rem; + color: #cbd5e1; +} + +.admin-page-servers .checkbox-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin: 0.25rem 0 0.35rem; +} + +.admin-page-servers .checkbox-row label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.85rem; + color: #e2e8f0; + cursor: pointer; +} + +.admin-page-servers .checkbox-row input[type="checkbox"] { + accent-color: var(--admin-accent); +} + +.admin-page-servers .server-ids-row { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.admin-page-servers .server-ids-row .badge { + font-size: 0.7rem; +} + +.admin-page-servers .row-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; +} + +.admin-page-servers .flag-cell { + font-size: 1.35rem; + line-height: 1; + vertical-align: middle; +} + +.admin-page-servers .flag-preview-img { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--bs-border-color); + background: rgba(15, 23, 42, 0.6); +} + +.admin-page-servers .flag-preview-emoji { + font-size: 1.75rem; + line-height: 1; +} + +.admin-page-servers .flag-preview-emoji--twemoji { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.admin-page-servers .flag-preview-emoji__raw { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.admin-page-servers .flag-emoji-input-wrap { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.admin-page-servers .flag-emoji-input-preview { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid var(--bs-border-color); + background: rgba(15, 23, 42, 0.5); + font-size: 1.25rem; + line-height: 1; +} + +.admin-page-servers .flag-emoji-input-preview img { + width: 28px; + height: 28px; + object-fit: contain; + display: block; +} + +.admin-page-servers .flag-emoji-picker { + margin-top: 0.75rem; + padding: 0.75rem; + border-radius: 10px; + border: 1px solid var(--bs-border-color); + background: rgba(15, 23, 42, 0.35); +} + +.admin-page-servers .flag-emoji-picker__toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.65rem; +} + +.admin-page-servers .flag-emoji-picker__search { + flex: 1; + min-width: 0; +} + +.admin-page-servers .flag-emoji-picker__count { + flex-shrink: 0; + font-size: 0.78rem; + color: var(--bs-secondary-color); + font-variant-numeric: tabular-nums; +} + +.admin-page-servers .flag-emoji-picker__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); + gap: 0.4rem; + max-height: 16rem; + overflow-y: auto; + padding-right: 0.15rem; +} + +.admin-page-servers .flag-emoji-pick { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + padding: 0.45rem 0.35rem; + border-radius: 8px; + border: 1px solid transparent; + background: rgba(30, 41, 59, 0.55); + color: inherit; + cursor: pointer; + text-align: center; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.admin-page-servers .flag-emoji-pick:hover { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(30, 41, 59, 0.85); +} + +.admin-page-servers .flag-emoji-pick.is-active { + border-color: rgba(56, 189, 248, 0.65); + background: rgba(14, 116, 144, 0.2); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.25); +} + +.admin-page-servers .flag-emoji-pick[hidden] { + display: none; +} + +.admin-page-servers .flag-emoji-pick__img { + width: 28px; + height: 28px; + object-fit: contain; + display: block; +} + +.admin-page-servers .flag-emoji-pick__raw { + display: none; +} + +.admin-page-servers .flag-emoji-pick__name { + font-size: 0.68rem; + line-height: 1.25; + color: var(--bs-secondary-color); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-page-servers .form-inline-grid--flags { + align-items: end; +} + +.admin-page-servers .field-grow { + flex: 1; + min-width: 12rem; +} + +.admin-content .btn.btn-warn, +.admin-content .btn.btn-warn:hover { + background: rgba(245, 158, 11, 0.15); + border-color: rgba(245, 158, 11, 0.45); + color: #fbbf24; +} + +.admin-page-servers tr.row-disabled td { + opacity: 0.65; +} + +.admin-page-servers .input-flag { + max-width: 4.5rem; + font-size: 1.35rem; + text-align: center; + padding: 0.25rem 0.35rem; +} + +.admin-page-servers .text-light { + color: #f1f5f9 !important; +} + +/* Уведомления о трафике (admin/server_traffic_notices.php) */ +.admin-page-traffic { + max-width: 960px; +} + +.admin-page-traffic .sl-input, +.admin-page-traffic textarea.sl-input { + color: #f1f5f9 !important; + -webkit-text-fill-color: #f1f5f9; +} + +.tn-hero { + margin-bottom: 1.25rem; + padding: 1.15rem 1.25rem; + border-radius: 0.85rem; + border: 1px solid rgba(245, 158, 11, 0.22); + background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(17, 24, 39, 0.55) 100%); +} + +.tn-hero__kicker { + margin: 0 0 0.2rem; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #fbbf24; +} + +.tn-hero__title { + margin: 0 0 0.3rem; + font-size: 1.3rem; + font-weight: 700; + color: #f8fafc; +} + +.tn-hero__sub { + margin: 0; + font-size: 0.88rem; + color: #94a3b8; + line-height: 1.45; +} + +.tn-hero__sub code { + color: #7dd3fc; +} + +.tn-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +@media (min-width: 576px) { + .tn-stats { + grid-template-columns: repeat(2, 1fr); + max-width: 420px; + } +} + +.tn-stat-mini { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-radius: 0.6rem; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface); +} + +.tn-stat-mini i { + font-size: 1.15rem; + color: #fbbf24; +} + +.tn-stat-mini__val { + display: block; + font-size: 1.35rem; + font-weight: 700; + color: #f8fafc; + line-height: 1.1; +} + +.tn-stat-mini__label { + font-size: 0.68rem; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tn-form-grid { + display: grid; + grid-template-columns: 6.5rem 1fr; + gap: 0.75rem 1rem; + align-items: start; +} + +@media (max-width: 575.98px) { + .tn-form-grid { + grid-template-columns: 1fr; + } +} + +.tn-form-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding-top: 0.25rem; +} + +.tn-servers { + margin-bottom: 0.85rem; +} + +.tn-servers__label { + display: block; + font-size: 0.72rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.35rem; +} + +.tn-servers__scroll { + display: flex; + gap: 0.35rem; + overflow-x: auto; + padding-bottom: 0.2rem; + scrollbar-width: thin; +} + +.tn-id-pill { + flex-shrink: 0; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface-2); + color: #94a3b8; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.55rem; + border-radius: 999px; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.tn-id-pill:hover, +.tn-id-pill.active { + border-color: rgba(245, 158, 11, 0.5); + background: rgba(245, 158, 11, 0.12); + color: #fcd34d; +} + +.tn-textarea { + min-height: 4.5rem; + resize: vertical; +} + +.admin-page-traffic .st-form label.sl-field__label { + margin: 0 0 0.15rem; +} + +.tn-empty { + text-align: center; + padding: 2rem 1rem; + color: #64748b; +} + +.tn-empty i { + font-size: 2rem; + color: #475569; + margin-bottom: 0.65rem; +} + +.tn-empty p { + margin: 0; + font-size: 0.9rem; +} + +.tn-table .sl-td-text { + font-size: 0.88rem; + line-height: 1.5; + color: #f1f5f9 !important; + word-break: break-word; +} + +/* Алерты Bootstrap — контраст на тёмном фоне */ +.admin-content .alert.alert-success { + background: rgba(16, 185, 129, 0.18); + border-color: rgba(52, 211, 153, 0.5); + color: #6ee7b7; +} + +.admin-content .alert.alert-danger { + background: rgba(239, 68, 68, 0.18); + border-color: rgba(248, 113, 113, 0.45); + color: #fca5a5; +} + +.cfg-block { + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + padding: 0.65rem 0.75rem; + margin: 0.5rem 0; +} + +.cfg-block pre { + margin: 0; + font-size: 0.78rem; + color: #94a3b8; + white-space: pre-wrap; + word-break: break-all; + max-height: 220px; + overflow-y: auto; + font-family: ui-monospace, monospace; +} + +/* Forms in admin content */ +.admin-content label { + display: block; + font-weight: 500; + font-size: 0.85rem; + color: #cbd5e1; + margin: 0.75rem 0 0.35rem; +} + +.admin-content .form-compact > label { + margin: 0; +} + +.admin-content input[type="text"], +.admin-content input[type="password"], +.admin-content input[type="number"], +.admin-content input[type="date"], +.admin-content input[type="datetime-local"], +.admin-content select, +.admin-content textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--bs-border-color); + background: #0f172a; + color: #f1f5f9; + font-size: 0.9rem; +} + +.admin-content input:focus, +.admin-content select:focus, +.admin-content textarea:focus { + outline: none; + border-color: var(--admin-accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); +} + +.admin-content textarea { + min-height: 5rem; + resize: vertical; +} + +details .detail-body { + padding-top: 0.5rem; +} + +.admin-content .tbl-wrap details .detail-body { + padding: 0.5rem 0 0.25rem; +} + +/* Mobile sidebar */ +@media (max-width: 991.98px) { + #adminSidebarToggle { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .admin-sidebar { + transform: translateX(-100%); + transition: transform 0.25s ease; + box-shadow: none; + } + + .admin-sidebar.show { + transform: translateX(0); + box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5); + } + + .admin-main { + margin-left: 0; + width: 100%; + } + + .admin-sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1035; + } + + .admin-sidebar-backdrop.show { + display: block; + } + + .admin-topbar { + padding: 0.85rem 1rem; + } + + .admin-content { + padding: 1rem; + } +} + +/* ——— Dashboard (admin/index.php) ——— */ +.admin-dashboard { + max-width: 1200px; +} + +.dash-alert { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.85rem 1rem; + margin-bottom: 1.25rem; + border-radius: 0.65rem; + font-size: 0.88rem; + line-height: 1.45; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.35); + color: #fcd34d; +} + +.dash-alert a { + color: #fde68a; + font-weight: 600; +} + +.dash-alert i { + font-size: 1.1rem; + margin-top: 0.1rem; + flex-shrink: 0; +} + +.dash-hero { + margin-bottom: 1.5rem; + padding: 1.25rem 1.35rem; + border-radius: var(--admin-radius); + border: 1px solid rgba(0, 240, 255, 0.25); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.08) 0%, rgba(13, 16, 37, 0.9) 45%, rgba(255, 45, 123, 0.04) 100%); + box-shadow: var(--admin-shadow), inset 0 1px 0 rgba(0, 240, 255, 0.08); + position: relative; + overflow: hidden; +} + +.dash-hero::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--admin-accent), var(--admin-accent-3), var(--admin-accent-2)); +} + +.dash-hero-kicker { + margin: 0 0 0.25rem; + font-family: var(--admin-font-mono); + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--admin-accent-2); +} + +.dash-hero-title { + margin: 0 0 0.35rem; + font-family: var(--admin-font-display); + font-size: 1.35rem; + font-weight: 700; + color: #e0e6ff; + line-height: 1.25; + letter-spacing: 0.06em; + text-transform: uppercase; + text-shadow: 0 0 20px rgba(0, 240, 255, 0.15); +} + +.dash-hero-sub { + margin: 0; + font-size: 0.9rem; + color: #94a3b8; + max-width: 36rem; +} + +.dash-stats { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} + +@media (min-width: 576px) { + .dash-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1100px) { + .dash-stats { + grid-template-columns: repeat(4, 1fr); + } +} + +.dash-stat { + position: relative; + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1.15rem 1.2rem; + border-radius: 0.75rem; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface); + overflow: hidden; + transition: transform 0.15s ease, border-color 0.2s, box-shadow 0.2s; +} + +.dash-stat::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--dash-accent, var(--admin-accent)), transparent); + opacity: 0.85; +} + +.dash-stat:hover { + transform: translateY(-2px); + border-color: rgba(0, 240, 255, 0.35); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 20px rgba(0, 240, 255, 0.08); +} + +.dash-stat-icon { + flex-shrink: 0; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.65rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.15rem; + background: rgba(56, 189, 248, 0.12); + color: var(--admin-accent); + border: 1px solid rgba(56, 189, 248, 0.2); +} + +.dash-stat--links { --dash-accent: #00f0ff; } +.dash-stat--configs { --dash-accent: #b24dff; } +.dash-stat--servers { --dash-accent: #00ff9d; } +.dash-stat--panel { --dash-accent: #ffe23d; } + +.dash-stat--links .dash-stat-icon { background: rgba(0, 240, 255, 0.1); color: #00f0ff; border-color: rgba(0, 240, 255, 0.3); box-shadow: 0 0 12px rgba(0, 240, 255, 0.15); } +.dash-stat--configs .dash-stat-icon { background: rgba(178, 77, 255, 0.1); color: #b24dff; border-color: rgba(178, 77, 255, 0.3); box-shadow: 0 0 12px rgba(178, 77, 255, 0.15); } +.dash-stat--servers .dash-stat-icon { background: rgba(0, 255, 157, 0.1); color: #00ff9d; border-color: rgba(0, 255, 157, 0.3); box-shadow: 0 0 12px rgba(0, 255, 157, 0.15); } +.dash-stat--panel .dash-stat-icon { background: rgba(255, 226, 61, 0.1); color: #ffe23d; border-color: rgba(255, 226, 61, 0.3); box-shadow: 0 0 12px rgba(255, 226, 61, 0.15); } + +.dash-stat--panel.is-ok { --dash-accent: #00ff9d; } +.dash-stat--panel.is-ok .dash-stat-icon { background: rgba(0, 255, 157, 0.1); color: #00ff9d; border-color: rgba(0, 255, 157, 0.35); } +.dash-stat--panel.is-fail { --dash-accent: #ff3860; } +.dash-stat--panel.is-fail .dash-stat-icon { background: rgba(255, 56, 96, 0.1); color: #ff3860; border-color: rgba(255, 56, 96, 0.35); } + +.dash-stat-body { + min-width: 0; + flex: 1; +} + +.dash-stat-value { + display: block; + font-family: var(--admin-font-display); + font-size: 1.65rem; + font-weight: 700; + line-height: 1.15; + color: #e0e6ff; + letter-spacing: 0.04em; +} + +.dash-stat-value .dash-stat-muted { + font-size: 1rem; + font-weight: 500; + color: #555d80; +} + +.dash-stat-value .dash-stat-ok { + color: #00ff9d; + text-shadow: 0 0 12px rgba(0, 255, 157, 0.4); +} + +.dash-stat-value .dash-stat-fail { + color: #ff3860; + text-shadow: 0 0 12px rgba(255, 56, 96, 0.4); +} + +.dash-stat-label { + display: block; + margin-top: 0.3rem; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.dash-layout { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; +} + +@media (min-width: 900px) { + .dash-layout { + grid-template-columns: 1.15fr 1fr; + } +} + +.dash-panel { + background: var(--admin-surface); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + padding: 1.2rem 1.25rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.18); +} + +.dash-panel-head { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.dash-panel-head i { + color: var(--admin-accent); + font-size: 1rem; +} + +.dash-panel-head h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: #f1f5f9; +} + +.dash-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.dash-action { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.85rem 1rem; + border-radius: 0.6rem; + text-decoration: none; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface-2); + transition: border-color 0.15s, background 0.15s, transform 0.12s; +} + +.dash-action:hover { + border-color: rgba(56, 189, 248, 0.4); + background: rgba(56, 189, 248, 0.08); + transform: translateX(3px); + color: inherit; +} + +.dash-action--primary { + border-color: rgba(14, 165, 233, 0.45); + background: linear-gradient(135deg, rgba(14, 165, 233, 0.2) 0%, rgba(56, 189, 248, 0.08) 100%); +} + +.dash-action--primary:hover { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 24px rgba(56, 189, 248, 0.12); +} + +.dash-action-icon { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + background: rgba(56, 189, 248, 0.15); + color: var(--admin-accent); +} + +.dash-action--primary .dash-action-icon { + background: rgba(14, 165, 233, 0.35); + color: #e0f2fe; +} + +.dash-action-text strong { + display: block; + font-size: 0.92rem; + font-weight: 600; + color: #f1f5f9; +} + +.dash-action-text span { + display: block; + font-size: 0.78rem; + color: #94a3b8; + margin-top: 0.1rem; +} + +.dash-action > i.fa-chevron-right { + margin-left: auto; + font-size: 0.7rem; + color: #64748b; +} + +.dash-info-list { + margin: 0; + padding: 0; + list-style: none; +} + +.dash-info-list li { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.55rem 0; + font-size: 0.88rem; + line-height: 1.5; + color: #cbd5e1; + border-bottom: 1px solid rgba(42, 51, 82, 0.5); +} + +.dash-info-list li:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.dash-info-list li i { + color: var(--admin-accent); + margin-top: 0.2rem; + flex-shrink: 0; + width: 1rem; + text-align: center; +} + +.dash-info-user { + margin: 0 0 0.85rem; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); + font-size: 0.88rem; + color: #94a3b8; +} + +.dash-info-user strong { + color: #f1f5f9; +} + +.dash-info-user code { + color: #7dd3fc; + background: rgba(56, 189, 248, 0.1); + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.82em; +} + +/* ——— Share links (admin/share_links.php) ——— */ +.admin-page-links { + max-width: 1280px; +} + +.admin-page-links .code-inline, +.admin-page-settings .code-inline { + background: rgba(56, 189, 248, 0.1); + color: #7dd3fc; + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.82em; +} + +.sl-panel { + background: var(--admin-surface); + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + padding: 1.2rem 1.25rem; + margin-bottom: 1.25rem; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); + color: #e2e8f0; +} + +.sl-hero { + display: flex; + align-items: flex-start; + gap: 0.85rem; + margin-bottom: 1rem; + padding: 1rem 1.15rem; + border-radius: var(--admin-radius); + border: 1px solid rgba(0, 240, 255, 0.28); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.1) 0%, rgba(13, 16, 37, 0.92) 55%, rgba(255, 45, 123, 0.04) 100%); + box-shadow: var(--admin-shadow), inset 0 1px 0 rgba(0, 240, 255, 0.08); + position: relative; + overflow: hidden; +} + +.sl-hero::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--admin-accent), var(--admin-accent-3), var(--admin-accent-2)); +} + +.sl-hero > i, +.sl-hero > .fa-solid { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + margin-top: 0.1rem; + font-size: 1.05rem; + color: var(--admin-accent); + background: rgba(0, 240, 255, 0.1); + border: 1px solid rgba(0, 240, 255, 0.4); + border-radius: var(--admin-radius); + box-shadow: 0 0 16px var(--admin-accent-glow); +} + +.sl-hero__title { + margin: 0 0 0.4rem; + font-family: var(--admin-font-display); + font-size: 1.15rem; + font-weight: 700; + color: #e0e6ff; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.sl-hero__sub { + margin: 0; + font-size: 0.92rem; + line-height: 1.55; + color: #cbd5e1; +} + +.sl-hero--compact { + padding: 0.9rem 1rem; +} + +.sl-hero--compact > i, +.sl-hero--compact > .fa-solid { + width: 2.15rem; + height: 2.15rem; + font-size: 0.95rem; +} + +.sl-hero--compact .sl-hero__title { + font-size: 1.22rem; +} + +.sl-hero--compact .sl-hero__sub { + font-size: 0.88rem; +} + +.sl-panel__head { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.sl-panel__head i { + color: var(--admin-accent); +} + +.sl-panel__head h2 { + margin: 0; + font-size: 1.08rem; + font-weight: 600; + color: #f1f5f9; +} + +.sl-count-badge { + margin-left: 0; + font-size: 0.72rem; + font-weight: 700; + padding: 0.2rem 0.55rem; + border-radius: 999px; + background: rgba(56, 189, 248, 0.15); + color: #7dd3fc; + border: 1px solid rgba(56, 189, 248, 0.3); +} + +.sl-panel__head-link { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + font-weight: 500; + color: var(--admin-accent); + text-decoration: none; + white-space: nowrap; +} + +.sl-panel__head-link:hover { + color: #e0f2fe; + text-decoration: underline; +} + +.sl-hint { + margin: 0 0 0.75rem; + font-size: 0.85rem; + color: #94a3b8; + line-height: 1.45; +} + +.sl-hint a { + color: #7dd3fc; + font-weight: 500; +} + +.sl-hint--inline { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.sl-hint--inline strong { + color: #e2e8f0; +} + +.sl-hint--warn { + padding: 0.5rem 0.65rem; + border-radius: 0.45rem; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.25); + color: #fcd34d; +} + +.sl-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem 1.25rem; + margin-bottom: 1rem; +} + +@media (max-width: 767.98px) { + .sl-form-grid { + grid-template-columns: 1fr; + } +} + +.sl-field__label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: #e2e8f0; + margin-bottom: 0.15rem; +} + +.sl-field__hint { + display: block; + font-size: 0.72rem; + color: #64748b; + margin-bottom: 0.45rem; +} + +.sl-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.45rem; +} + +.sl-chip { + border: 1px solid var(--bs-border-color); + background: var(--admin-surface-2); + color: #cbd5e1; + font-size: 0.72rem; + font-weight: 500; + padding: 0.28rem 0.55rem; + border-radius: 999px; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.sl-chip:hover { + border-color: rgba(56, 189, 248, 0.4); + color: #e2e8f0; +} + +.sl-chip.active { + border-color: rgba(56, 189, 248, 0.55); + background: rgba(56, 189, 248, 0.15); + color: #7dd3fc; + box-shadow: 0 0 12px rgba(56, 189, 248, 0.12); +} + +.sl-input-row { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.sl-input, +.admin-page-links .sl-timing-form .sl-input, +.admin-page-settings .sl-input { + width: 100%; + box-sizing: border-box; + padding: 0.5rem 0.65rem; + font-size: 0.88rem; + color: #f1f5f9 !important; + -webkit-text-fill-color: #f1f5f9; + background: #0f172a !important; + border: 1px solid #475569; + border-radius: 0.45rem; +} + +.sl-input--num { + max-width: 5.5rem; +} + +.sl-input:focus { + outline: none; + border-color: var(--admin-accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); +} + +.sl-input-suffix { + font-size: 0.8rem; + color: #94a3b8; +} + +.sl-form-footer { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; + padding-top: 0.25rem; + border-top: 1px solid rgba(42, 51, 82, 0.6); +} + +.sl-field--sub { + flex: 1; + min-width: 12rem; +} + +.sl-btn-generate { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.65rem 1.25rem; + font-size: 0.9rem; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, #0ea5e9, #0284c7); + border: none; + border-radius: 0.5rem; + cursor: pointer; + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.35); + transition: filter 0.15s, transform 0.12s; + white-space: nowrap; +} + +.sl-btn-generate:hover { + filter: brightness(1.08); +} + +.sl-btn-generate:active { + transform: scale(0.98); +} + +.sl-url-result { + margin-top: 1rem; + padding: 0.85rem 1rem; + border-radius: 0.6rem; + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(52, 211, 153, 0.35); +} + +.sl-url-result__label { + font-size: 0.78rem; + font-weight: 600; + color: #6ee7b7; + margin-bottom: 0.5rem; +} + +.sl-url-result__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.sl-url-result__text { + flex: 1; + min-width: 0; + font-size: 0.8rem; + color: #e0f2fe; + word-break: break-all; + background: rgba(15, 23, 42, 0.6); + padding: 0.45rem 0.55rem; + border-radius: 0.35rem; +} + +.sl-btn-copy { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + font-weight: 500; + color: #7dd3fc; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.35); + border-radius: 0.4rem; + cursor: pointer; + font-family: inherit; +} + +.sl-btn-copy:hover { + background: rgba(56, 189, 248, 0.18); +} + +.sl-search-block { + margin-bottom: 1rem; + padding: 0.85rem 1rem; + background: rgba(15, 23, 42, 0.35); + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; +} + +.sl-search { + width: 100%; + margin: 0; +} + +.sl-search__bar { + display: flex; + align-items: stretch; + width: 100%; + min-height: 2.5rem; + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.sl-search__bar:focus-within { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.12); +} + +.sl-search__icon { + flex-shrink: 0; + display: flex; + align-items: center; + padding-left: 0.85rem; + font-size: 0.82rem; + color: #64748b; + pointer-events: none; +} + +.sl-search__input { + flex: 1 1 auto; + min-width: 0; + width: 100%; + padding: 0.55rem 0.4rem; + font-size: 0.85rem; + color: #e2e8f0; + background: transparent; + border: none; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +.sl-search__input:focus { + outline: none; +} + +.sl-search__input::placeholder { + color: #64748b; + font-family: inherit; + letter-spacing: normal; +} + +.sl-search__clear { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + padding: 0; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; +} + +.sl-search__clear:hover { + color: #e2e8f0; + background: rgba(148, 163, 184, 0.12); +} + +.sl-search__btn { + flex-shrink: 0; + padding: 0 1.1rem; + font-size: 0.82rem; + font-weight: 600; + color: #e0f2fe; + background: rgba(56, 189, 248, 0.22); + border: none; + border-left: 1px solid rgba(56, 189, 248, 0.28); + cursor: pointer; + font-family: inherit; + white-space: nowrap; + transition: background 0.15s; +} + +.sl-search__btn:hover { + background: rgba(56, 189, 248, 0.32); +} + +.sl-search__hint { + margin: 0.55rem 0 0; + font-size: 0.78rem; + color: #94a3b8; + line-height: 1.45; +} + +.sl-search__hint a { + color: var(--admin-accent); + text-decoration: none; +} + +.sl-search__hint a:hover { + text-decoration: underline; +} + +.sl-search__empty { + margin: 0.5rem 0 0; + padding: 0.45rem 0.65rem; + font-size: 0.82rem; + color: #fbbf24; + background: rgba(251, 191, 36, 0.08); + border-radius: 0.4rem; + border: 1px solid rgba(251, 191, 36, 0.2); +} + +.link-row--search-hidden { + display: none !important; +} + +.sl-controls-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + margin-bottom: 1rem; + padding: 0.65rem 0.85rem; + background: rgba(15, 23, 42, 0.28); + border: 1px solid var(--bs-border-color); + border-radius: 0.55rem; +} + +.sl-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1.25rem; + margin-bottom: 0; +} + +.sl-toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin: 0; + font-size: 0.82rem; + color: #94a3b8; + cursor: pointer; + user-select: none; +} + +.sl-toggle input { + accent-color: var(--admin-accent); +} + +.sl-cleanup { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem 0.85rem; + margin-bottom: 0; + margin-left: auto; + padding: 0.45rem 0.65rem; + border-radius: 0.45rem; + background: rgba(239, 68, 68, 0.07); + border: 1px solid rgba(248, 113, 113, 0.22); +} + +.sl-btn-cleanup { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + font-size: 0.82rem; + font-weight: 500; + color: #fca5a5; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 0.4rem; + cursor: pointer; + font-family: inherit; +} + +.sl-btn-cleanup:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.2); +} + +.sl-btn-cleanup:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sl-btn-cleanup--sm { + padding: 0.28rem 0.55rem; + font-size: 0.74rem; +} + +.sl-cleanup__meta { + font-size: 0.82rem; + color: #94a3b8; +} + +.sl-cleanup__meta strong { + color: #f1f5f9; +} + +.sl-empty { + text-align: center; + padding: 2.5rem 1rem; + color: #64748b; +} + +.sl-empty i { + font-size: 2rem; + color: #475569; + margin-bottom: 0.75rem; +} + +.sl-empty p { + margin: 0; + font-size: 0.9rem; +} + +.sl-table-wrap { + overflow-x: auto; + border-radius: 0.6rem; + border: 1px solid var(--bs-border-color); +} + +.sl-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.4rem; + margin-top: 1rem; + padding-top: 0.85rem; +} + +.sl-pager__pages { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.35rem; +} + +.sl-pager__btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 2.25rem; + padding: 0.42rem 0.72rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.45); + color: #94a3b8; + font-size: 0.84rem; + font-weight: 600; + line-height: 1; + text-decoration: none; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.sl-pager__page { + justify-content: center; +} + +.sl-pager__btn:hover:not(.is-disabled):not(.is-active) { + color: #e2e8f0; + border-color: var(--admin-accent); + background: rgba(56, 189, 248, 0.08); +} + +.sl-pager__page.is-active { + color: #0f172a; + background: var(--admin-accent); + border-color: var(--admin-accent); + pointer-events: none; +} + +.sl-pager__btn.is-disabled { + opacity: 0.45; + cursor: default; +} + +.sl-pager__dots { + padding: 0 0.15rem; + color: #64748b; + font-size: 0.84rem; + user-select: none; +} + +.sl-table { + width: 100%; + margin: 0; + font-size: 0.84rem; + border-collapse: collapse; +} + +.sl-table thead th { + background: #1a2332; + color: #e2e8f0 !important; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid var(--bs-border-color); + white-space: nowrap; +} + +.sl-table tbody td { + padding: 0.55rem 0.65rem; + color: #e2e8f0 !important; + border-bottom: 1px solid rgba(42, 51, 82, 0.55); + vertical-align: middle; +} + +.sl-table tbody tr:hover td { + background: rgba(56, 189, 248, 0.04); +} + +.sl-tr--configs td { + background: rgba(15, 23, 42, 0.45); + padding: 0.4rem 0.65rem 0.55rem; +} + +.sl-th-id, +.sl-td-id { + width: 3rem; +} + +.sl-id { + font-weight: 700; + color: #7dd3fc; +} + +.sl-muted { + color: #64748b; +} + +.sl-td-nowrap { + white-space: nowrap; + font-size: 0.8rem; +} + +.sl-uses { + font-weight: 600; + color: #7dd3fc; +} + +.sl-td-url { + max-width: 220px; +} + +.sl-url-cell { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.sl-url-link { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.78rem; + color: #7dd3fc !important; + text-decoration: none; +} + +.sl-url-link:hover { + text-decoration: underline; + color: #e0f2fe !important; +} + +.sl-icon-btn { + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface-2); + color: #94a3b8; + border-radius: 0.35rem; + cursor: pointer; + font-size: 0.8rem; +} + +.sl-icon-btn:hover { + border-color: rgba(56, 189, 248, 0.4); + color: #7dd3fc; +} + +.sl-td-actions { + position: static; + width: 2.5rem; + text-align: center; +} + +.sl-timing-details { + position: static; +} + +.sl-timing-summary { + list-style: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.35rem; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface-2); + color: #7dd3fc; + font-size: 0.75rem; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.sl-timing-summary:hover { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.08); + color: #e0f2fe; +} + +.sl-td-traffic { + max-width: 10rem; + font-size: 0.76rem; + color: #94a3b8; +} + +.sl-traffic-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sl-creation__traffic { + color: #7dd3fc; + font-size: 0.76rem; + white-space: nowrap; +} + +.sl-timing-summary::-webkit-details-marker { + display: none; +} + +.sl-timing-backdrop { + display: none; +} + +.sl-timing-details[open] .sl-timing-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 1040; + background: rgba(2, 6, 23, 0.62); + backdrop-filter: blur(2px); + cursor: pointer; +} + +body.sl-editor-open { + overflow: hidden; +} + +.sl-timing-form { + position: fixed; + top: 50%; + left: 50%; + right: auto; + transform: translate(-50%, -50%); + z-index: 1050; + display: flex; + flex-direction: column; + width: min(520px, calc(100vw - 1.5rem)); + max-height: min(88vh, 720px); + margin: 0; + padding: 0; + background: #1e293b; + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 0.65rem; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55); + overflow: hidden; +} + +.sl-timing-details:not([open]) .sl-timing-form { + display: none; +} + +.sl-timing-form__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + flex-shrink: 0; + padding: 0.9rem 1rem 0.75rem; + border-bottom: 1px solid rgba(42, 51, 82, 0.7); + background: #1a2433; +} + +.sl-timing-form__head strong { + display: block; + font-size: 0.92rem; + color: #f1f5f9; +} + +.sl-timing-form__sub { + display: block; + margin-top: 0.15rem; + font-size: 0.75rem; + color: #94a3b8; +} + +.sl-timing-form__close { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + font-size: 0.95rem; + color: #94a3b8; + background: rgba(15, 23, 42, 0.5); + border: 1px solid rgba(100, 116, 139, 0.35); + border-radius: 0.4rem; + cursor: pointer; + transition: color 0.12s, border-color 0.12s, background 0.12s; +} + +.sl-timing-form__close:hover { + color: #f1f5f9; + border-color: rgba(56, 189, 248, 0.4); + background: rgba(56, 189, 248, 0.08); +} + +.sl-timing-form__body { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + padding: 0.75rem 1rem 0.5rem; + scrollbar-width: thin; + scrollbar-color: #475569 transparent; +} + +.sl-timing-form__foot { + flex-shrink: 0; + padding: 0.65rem 1rem 1rem; + border-top: 1px solid rgba(42, 51, 82, 0.7); + background: #1a2433; +} + +.sl-form-module { + margin-bottom: 0.65rem; + border: 1px solid rgba(51, 65, 85, 0.65); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.35); + overflow: hidden; +} + +.sl-form-module:last-child { + margin-bottom: 0; +} + +.sl-form-module__head { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.65rem; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #7dd3fc; + background: rgba(30, 41, 59, 0.55); + border-bottom: 1px solid rgba(51, 65, 85, 0.5); +} + +.sl-form-module__head i { + opacity: 0.85; + font-size: 0.78rem; +} + +.sl-form-module__body { + padding: 0.55rem 0.65rem 0.65rem; +} + +.sl-form-module__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem 0.65rem; + margin-top: 0.5rem; +} + +.sl-form-module__grid .sl-timing-form__label { + margin-top: 0; +} + +.sl-timing-form__label { + display: block; + margin: 0.5rem 0 0.25rem; + font-size: 0.75rem; + font-weight: 500; + color: #94a3b8; +} + +.sl-form-module__body > .sl-timing-form__label:first-child { + margin-top: 0; +} + +.sl-timing-form__section { + margin: 0.65rem 0 0.15rem; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #7dd3fc; +} + +.sl-timing-form__hint { + margin: 0.25rem 0 0; + font-size: 0.72rem; + line-height: 1.4; + color: #94a3b8; +} + +.sl-timing-form__hint--block { + margin: 0.5rem 1rem; +} + +.sl-timing-form__hint strong { + color: #e2e8f0; +} + +/* Toggle switch */ +.sl-switch { + position: relative; + display: inline-flex; + flex-shrink: 0; + align-items: center; +} + +.admin-content .sl-timing-form label.sl-switch-row { + display: flex !important; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + margin: 0; + font-weight: inherit; + font-size: inherit; + text-align: left; +} + +.sl-switch input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sl-switch__track { + position: relative; + display: block; + width: 2.35rem; + height: 1.25rem; + border-radius: 999px; + background: rgba(51, 65, 85, 0.9); + border: 1px solid rgba(100, 116, 139, 0.55); + transition: background 0.18s, border-color 0.18s, box-shadow 0.18s; + cursor: pointer; +} + +.sl-switch__track::after { + content: ''; + position: absolute; + top: 50%; + left: 0.14rem; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + transform: translateY(-50%); + transition: left 0.18s, background 0.18s; +} + +.sl-switch input:checked + .sl-switch__track { + background: #0ea5e9; + border-color: #38bdf8; + box-shadow: 0 0 10px rgba(14, 165, 233, 0.35); +} + +.sl-switch input:checked + .sl-switch__track::after { + left: calc(100% - 1.04rem); + background: #fff; +} + +.sl-switch input:focus-visible + .sl-switch__track { + outline: 2px solid rgba(56, 189, 248, 0.55); + outline-offset: 2px; +} + +.sl-switch input:disabled + .sl-switch__track { + opacity: 0.45; + cursor: not-allowed; +} + +.sl-switch-row { + width: 100%; + box-sizing: border-box; + padding: 0.42rem 0.5rem; + border-radius: 0.35rem; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} + +.sl-switch-row:hover { + background: rgba(56, 189, 248, 0.06); +} + +.sl-switch-row--inline { + margin-top: 0.55rem; + padding: 0.45rem 0.5rem; + background: rgba(15, 23, 42, 0.4); + border: 1px solid rgba(51, 65, 85, 0.5); +} + +.sl-switch-row--master { + margin-bottom: 0.55rem; + padding: 0.5rem 0.6rem; + background: rgba(56, 189, 248, 0.06); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 0.4rem; +} + +.sl-switch-row--master .sl-switch-row__label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.8rem; + font-weight: 600; + color: #e2e8f0; +} + +.sl-switch-row--master .sl-switch-row__label i { + color: #7dd3fc; + font-size: 0.85rem; +} + +.sl-switch-row__label { + flex: 1 1 auto; + min-width: 0; + font-size: 0.78rem; + line-height: 1.3; + color: #e2e8f0; +} + +.sl-td-servers { + max-width: 8.5rem; +} + +.sl-servers-badge { + display: inline-block; + max-width: 100%; + padding: 0.18rem 0.45rem; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.35; + color: #cbd5e1; + background: rgba(51, 65, 85, 0.55); + border: 1px solid rgba(100, 116, 139, 0.35); + border-radius: 999px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +.sl-servers-badge--all { + color: #7dd3fc; + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.28); +} + +.sl-server-picks-wrap { + margin-top: 0; +} + +.sl-server-picks-wrap.is-disabled { + opacity: 0.45; + pointer-events: none; +} + +.sl-server-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.5rem; + margin-bottom: 0.45rem; +} + +.sl-server-picks-search { + flex: 1 1 8rem; + min-width: 0; + margin: 0; + padding: 0.38rem 0.55rem; + font-size: 0.76rem; +} + +.sl-server-toolbar__actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.sl-server-toolbar__btn { + padding: 0.28rem 0.55rem; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.2; + color: #cbd5e1; + background: rgba(30, 41, 59, 0.8); + border: 1px solid rgba(100, 116, 139, 0.45); + border-radius: 0.3rem; + cursor: pointer; + transition: color 0.12s, border-color 0.12s, background 0.12s; +} + +.sl-server-toolbar__btn:hover:not(:disabled) { + color: #7dd3fc; + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.08); +} + +.sl-server-toolbar__btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sl-server-toolbar__count { + min-width: 2.75rem; + padding: 0.2rem 0.35rem; + font-size: 0.68rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #7dd3fc; + text-align: center; + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.18); + border-radius: 0.3rem; +} + +.sl-server-picks { + display: flex; + flex-direction: column; + gap: 0; + max-height: 11.5rem; + overflow-y: auto; + padding: 0.2rem; + border-radius: 0.4rem; + background: rgba(15, 23, 42, 0.55); + border: 1px solid var(--bs-border-color); + scrollbar-width: thin; + scrollbar-color: #475569 transparent; +} + +.sl-server-picks::-webkit-scrollbar { + width: 6px; +} + +.sl-server-picks::-webkit-scrollbar-track { + background: transparent; +} + +.sl-server-picks::-webkit-scrollbar-thumb { + background: #475569; + border-radius: 999px; +} + +.sl-server-picks::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +.sl-server-pick { + margin: 0; + padding: 0.32rem 0.45rem; + border-bottom: 1px solid rgba(51, 65, 85, 0.45); +} + +.sl-server-pick:last-child { + border-bottom: none; +} + +.sl-server-pick:hover { + background: rgba(56, 189, 248, 0.05); +} + +.sl-server-pick:has(.js-server-pick:checked) { + background: rgba(56, 189, 248, 0.08); +} + +.sl-server-pick[hidden] { + display: none; +} + +.sl-server-pick__text { + display: flex; + align-items: baseline; + gap: 0.4rem; + min-width: 0; +} + +.sl-server-pick__id { + flex-shrink: 0; + font-size: 0.68rem; + font-weight: 700; + color: #7dd3fc; + letter-spacing: 0.02em; +} + +.sl-server-pick__name { + flex: 1 1 auto; + min-width: 0; + font-size: 0.74rem; + line-height: 1.25; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Protocol editor (admin/server_labels.php) */ +.sl-proto-wrap { + margin-top: 0.75rem; +} + +.sl-proto-picker__lead { + margin: 0 0 0.65rem; + font-size: 0.82rem; + color: #94a3b8; +} + +.sl-proto-toolbar { + margin-bottom: 0.55rem; +} + +.sl-proto-toolbar__count { + flex-shrink: 0; + font-size: 0.72rem; + font-weight: 600; + color: #64748b; + padding: 0.28rem 0.5rem; + border-radius: 0.3rem; + background: rgba(30, 41, 59, 0.6); + border: 1px solid rgba(51, 65, 85, 0.5); +} + +.sl-proto-servers { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: 14rem; + overflow: auto; + padding: 0.35rem; + margin-bottom: 0.55rem; + border: 1px solid rgba(51, 65, 85, 0.55); + border-radius: 0.45rem; + background: rgba(15, 23, 42, 0.35); +} + +.sl-proto-server { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + min-height: 2.65rem; + padding: 0.45rem 0.6rem; + border: 1px solid rgba(51, 65, 85, 0.65); + border-radius: 0.4rem; + background: rgba(30, 41, 59, 0.55); + color: #e2e8f0; + text-align: left; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.sl-proto-server:hover { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.06); +} + +.sl-proto-server.is-active { + border-color: rgba(56, 189, 248, 0.55); + background: rgba(56, 189, 248, 0.1); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.15); +} + +.sl-proto-server.is-server-off { + opacity: 0.72; +} + +.sl-proto-server[hidden] { + display: none !important; +} + +.sl-proto-server__id { + flex-shrink: 0; + width: 2rem; + font-size: 0.72rem; + font-weight: 700; + color: #7dd3fc; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.sl-proto-server__body { + flex: 1 1 auto; + min-width: 0; +} + +.sl-proto-server__name { + display: block; + font-size: 0.8rem; + font-weight: 600; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sl-proto-server__badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.2rem; + margin-top: 0.15rem; +} + +.sl-proto-server__badges .badge { + font-size: 0.6rem; + padding: 0.1rem 0.32rem; +} + +.sl-proto-server__default { + font-size: 0.65rem; + color: #64748b; +} + +.sl-proto-server__off { + flex-shrink: 0; + font-size: 0.6rem; +} + +.sl-proto-server__chev { + flex-shrink: 0; + font-size: 0.72rem; + color: #64748b; + opacity: 0.85; +} + +.sl-proto-manual { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + padding-top: 0.15rem; +} + +.sl-proto-editor { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 32rem; +} + +.sl-proto-selected { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + padding: 0.75rem 0.85rem; + border: 1px solid rgba(56, 189, 248, 0.4); + border-radius: 0.5rem; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.1), rgba(15, 23, 42, 0.55)); + box-shadow: 0 0 18px rgba(56, 189, 248, 0.08); +} + +.sl-proto-selected__main { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 0; + flex: 1 1 auto; +} + +.sl-proto-selected__id { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.4rem; + padding: 0.35rem 0.45rem; + font-size: 0.78rem; + font-weight: 700; + color: #7dd3fc; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + border-radius: 0.35rem; + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.28); +} + +.sl-proto-selected__info { + min-width: 0; +} + +.sl-proto-selected__info strong { + display: block; + font-size: 0.92rem; + color: #f1f5f9; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sl-proto-selected__speed { + display: block; + margin-top: 0.2rem; + font-size: 0.76rem; + font-weight: 600; + color: #fbbf24; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.sl-proto-selected__speed:empty { + display: none; +} + +.sl-proto-selected__badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.sl-proto-selected__badges .badge { + font-size: 0.62rem; + padding: 0.12rem 0.35rem; +} + +.sl-proto-change-btn { + flex-shrink: 0; +} + +.sl-proto-panel { + padding: 0.9rem 1rem; + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.55); +} + +.sl-proto-panel--speed { + border-color: rgba(251, 191, 36, 0.28); + background: rgba(251, 191, 36, 0.04); +} + +.sl-proto-panel--speed .sl-proto-panel__kicker { + color: #fbbf24; +} + +.sl-proto-speed-form { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.sl-proto-speed-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0; +} + +.sl-proto-speed-field__label { + font-size: 0.72rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sl-proto-speed-form__hint { + margin: 0; +} + +.sl-proto-speed-badge i { + margin-right: 0.15rem; +} + +.sl-proto-panel__head { + margin-bottom: 0.65rem; + padding-bottom: 0.55rem; + border-bottom: 1px solid rgba(51, 65, 85, 0.55); +} + +.sl-proto-panel__kicker { + display: block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #7dd3fc; +} + +.sl-proto-form { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.admin-content .sl-proto-form label.sl-proto-toggle-row { + display: flex !important; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + margin: 0; + padding: 0.55rem 0.65rem; + border-radius: 0.4rem; + border: 1px solid rgba(51, 65, 85, 0.45); + background: rgba(30, 41, 59, 0.4); + cursor: pointer; +} + +.sl-proto-toggle-row:hover { + border-color: rgba(56, 189, 248, 0.28); + background: rgba(56, 189, 248, 0.06); +} + +.sl-proto-toggle-row:has(.js-proto-toggle:checked) { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.08); +} + +.sl-proto-toggle-row .sl-switch-row__label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.84rem; + font-weight: 600; +} + +.sl-proto-toggle-row .sl-switch-row__label i { + color: #7dd3fc; + width: 1rem; + text-align: center; +} + +.sl-proto-form__hint { + margin: 0.4rem 0 0; +} + +.sl-proto-reset-form { + margin-top: 0.75rem; + padding-top: 0.65rem; + border-top: 1px solid rgba(51, 65, 85, 0.45); +} + +.sl-proto-wrap.is-editing .sl-proto-picker { + display: none; +} + +.sl-check--all-servers { + margin-top: 0.25rem; +} + +.sl-check { + display: flex; + align-items: flex-start; + gap: 0.4rem; + margin-top: 0.5rem; + font-size: 0.75rem; + color: #cbd5e1; + cursor: pointer; +} + +.sl-btn-save-timing { + width: 100%; + padding: 0.55rem; + font-size: 0.82rem; + font-weight: 600; + color: #fff; + background: #0ea5e9; + border: none; + border-radius: 0.4rem; + cursor: pointer; + box-shadow: 0 4px 14px rgba(14, 165, 233, 0.25); +} + +.sl-btn-save-timing:hover { + background: #38bdf8; +} + +@media (max-width: 720px) { + .sl-controls-bar { + flex-direction: column; + align-items: stretch; + } + + .sl-cleanup { + margin-left: 0; + justify-content: space-between; + } + + .sl-timing-form { + width: calc(100vw - 1rem); + max-height: 90vh; + } + + .sl-form-module__grid { + grid-template-columns: 1fr; + } +} + +.sl-configs-details summary { + cursor: pointer; + list-style: none; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + font-weight: 500; + color: var(--admin-accent); + padding: 0.2rem 0; +} + +.sl-configs-details summary::-webkit-details-marker { + display: none; +} + +.sl-configs-details[open] summary > .fa-chevron-right { + transform: rotate(90deg); +} + +.sl-configs-details summary > .fa-chevron-right { + font-size: 0.65rem; + transition: transform 0.15s; +} + +.sl-configs-body { + padding-top: 0.5rem; +} + +.sl-configs-section { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #94a3b8; + margin: 0 0 0.35rem; +} + +.sl-configs-section--deleted { + color: #f87171; + margin-top: 0.65rem; +} + +.sl-creation { + padding: 0.55rem 0.65rem; + margin-bottom: 0.35rem; + border-radius: 0.45rem; + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); +} + +.sl-creation--deleted { + border-style: dashed; + border-color: rgba(248, 113, 113, 0.3); + background: rgba(239, 68, 68, 0.05); +} + +.sl-creation__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.25rem; +} + +.sl-creation__server { + font-size: 0.82rem; + font-weight: 600; + color: #f1f5f9; +} + +.sl-creation__date { + font-size: 0.72rem; + color: #64748b; +} + +.sl-creation__meta { + font-size: 0.75rem; + color: #94a3b8; +} + +.sl-creation__meta .mono { + color: #cbd5e1; +} + +.sl-creation__cid { + margin-left: 0.35rem; + font-family: ui-monospace, monospace; + font-size: 0.72rem; + color: #64748b; +} + +.sl-creation__details summary { + font-size: 0.75rem; + color: var(--admin-accent); + cursor: pointer; +} + +.sl-creation__delete { + margin-top: 0; +} + +.sl-creation__actions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem 0.75rem; + margin-top: 0.5rem; +} + +.sl-creation__migrate summary { + font-size: 0.78rem; + color: #fbbf24; + cursor: pointer; + list-style: none; +} + +.sl-creation__migrate summary::-webkit-details-marker { + display: none; +} + +.sl-migrate-form { + margin-top: 0.55rem; + padding: 0.65rem; + border-radius: 0.45rem; + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(251, 191, 36, 0.25); + max-width: 320px; +} + +.sl-migrate-form__label { + display: block; + margin: 0.35rem 0 0.2rem; + font-size: 0.72rem; + color: #94a3b8; +} + +.sl-migrate-form__select { + width: 100%; +} + +.sl-migrate-protos { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.75rem; + margin: 0.35rem 0 0.55rem; +} + +.sl-migrate-proto { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.78rem; + color: #cbd5e1; + cursor: pointer; +} + +.sl-btn-migrate { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.65rem; + font-size: 0.78rem; + font-weight: 600; + color: #fef3c7; + background: rgba(251, 191, 36, 0.15); + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 0.35rem; + cursor: pointer; + font-family: inherit; +} + +.sl-btn-migrate:hover { + background: rgba(251, 191, 36, 0.25); +} + +.sl-creation__link { + font-size: 0.75rem; + color: var(--admin-accent); + text-decoration: none; +} + +.sl-creation__link:hover { + text-decoration: underline; +} + +.sl-server-group { + margin-bottom: 0.75rem; +} + +.sl-server-group__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.65rem; + margin-bottom: 0.4rem; + padding: 0.35rem 0.5rem; + border-radius: 0.35rem; + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.2); + font-size: 0.82rem; + color: #e2e8f0; +} + +.sl-server-group__head i { + color: #7dd3fc; +} + +.sl-server-group__count { + margin-left: auto; + font-size: 0.72rem; + color: #94a3b8; +} + +.admin-page-configs .sl-stats-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + margin-bottom: 1rem; +} + +.sl-stat-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.75rem; + font-size: 0.82rem; + font-weight: 500; + color: #e2e8f0; + background: rgba(30, 41, 59, 0.95); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 999px; +} + +.sl-stat-pill > i { + color: #7dd3fc; + font-size: 0.78rem; +} + +.sl-stat-pill strong { + color: #f8fafc; + font-weight: 700; +} + +.sl-stat-pill--link { + color: #7dd3fc; + text-decoration: none; + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.1); +} + +.sl-stat-pill--link:hover { + color: #e0f2fe; + background: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.5); +} + +.admin-page-configs .sl-panel { + max-width: none; +} + +.admin-page-configs .sl-hint { + color: #cbd5e1; +} + +.admin-page-configs .sl-hero--compact { + margin-bottom: 0.85rem; +} + +.sl-configs-picker { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sl-configs-picker--wide { + flex-direction: row; + align-items: stretch; + gap: 1.15rem; + min-height: 420px; +} + +.sl-configs-picker__sidebar { + flex: 0 0 min(320px, 34%); + display: flex; + flex-direction: column; + min-width: 260px; + max-height: calc(100vh - 220px); + border: 1px solid rgba(56, 189, 248, 0.28); + border-radius: 0.75rem; + background: linear-gradient(180deg, rgba(30, 41, 59, 0.95) 0%, rgba(17, 24, 39, 0.92) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.sl-configs-picker__sidebar-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.85rem 1rem 0.65rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.sl-configs-picker__sidebar-title { + margin: 0; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #e2e8f0; +} + +.sl-configs-picker__sidebar-title .fa-server { + margin-right: 0.35rem; + color: #7dd3fc; +} + +.sl-configs-picker__sidebar-total { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + padding: 0.15rem 0.45rem; + font-size: 0.72rem; + font-weight: 700; + color: #e0f2fe; + background: rgba(56, 189, 248, 0.18); + border: 1px solid rgba(56, 189, 248, 0.28); + border-radius: 999px; +} + +.sl-configs-picker__search { + padding: 0.65rem 0.85rem 0.45rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.sl-configs-picker__search-bar { + min-height: 2.35rem; + background: rgba(15, 23, 42, 0.65); + border-color: rgba(148, 163, 184, 0.4); +} + +.sl-configs-picker__search-bar .sl-search__icon { + color: #94a3b8; +} + +.sl-configs-picker__search-bar .sl-search__input { + font-family: inherit; + letter-spacing: normal; + color: #f1f5f9; +} + +.sl-configs-picker__search-bar .sl-search__input::placeholder { + color: #94a3b8; +} + +.sl-configs-picker__search-meta { + margin: 0.45rem 0 0; + font-size: 0.75rem; + color: #94a3b8; +} + +.sl-configs-picker__search-empty { + margin: 0.45rem 0 0; + padding: 0.45rem 0.55rem; + font-size: 0.78rem; + color: #fbbf24; + background: rgba(251, 191, 36, 0.08); + border: 1px solid rgba(251, 191, 36, 0.18); + border-radius: 0.4rem; +} + +.sl-configs-picker__search-empty[hidden] { + display: none !important; +} + +.sl-configs-picker__nav { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.sl-configs-picker--wide .sl-configs-picker__nav { + flex: 1 1 auto; + flex-direction: column; + flex-wrap: nowrap; + gap: 0.35rem; + min-height: 0; + padding: 0.55rem; + overflow-y: auto; + overflow-x: hidden; + border: none; + border-radius: 0; + background: transparent; +} + +.sl-configs-picker__tab { + display: flex; + align-items: flex-start; + gap: 0.55rem; + width: 100%; + padding: 0.65rem 0.75rem; + font-size: 0.82rem; + color: #e2e8f0; + text-align: left; + background: rgba(15, 23, 42, 0.72); + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 0.5rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s; +} + +.sl-configs-picker__tab[hidden], +.sl-configs-picker__tab.is-filtered-out { + display: none !important; +} + +.sl-configs-picker:not(.sl-configs-picker--wide) .sl-configs-picker__tab { + width: auto; + flex: 0 1 auto; + align-items: center; + max-width: 100%; +} + +.sl-configs-picker__tab:hover { + color: #f1f5f9; + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.08); +} + +.sl-configs-picker__tab.is-active { + color: #f8fafc; + border-color: rgba(56, 189, 248, 0.55); + background: linear-gradient(135deg, rgba(56, 189, 248, 0.2) 0%, rgba(15, 23, 42, 0.55) 100%); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.12); +} + +.sl-configs-picker__tab-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.65rem; + height: 1.65rem; + margin-top: 0.05rem; + font-size: 0.72rem; + color: #7dd3fc; + background: rgba(56, 189, 248, 0.12); + border-radius: 0.4rem; +} + +.sl-configs-picker__tab.is-active .sl-configs-picker__tab-icon { + color: #e0f2fe; + background: rgba(56, 189, 248, 0.28); +} + +.sl-configs-picker__tab-body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.sl-configs-picker__tab-id { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #94a3b8; +} + +.sl-configs-picker__tab.is-active .sl-configs-picker__tab-id { + color: #bae6fd; +} + +.sl-configs-picker__tab-name { + display: block; + font-size: 0.86rem; + font-weight: 600; + line-height: 1.35; + color: #f1f5f9; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; +} + +.sl-configs-picker:not(.sl-configs-picker--wide) .sl-configs-picker__tab-body { + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.35rem; +} + +.sl-configs-picker:not(.sl-configs-picker--wide) .sl-configs-picker__tab-id::after { + content: '·'; + margin-left: 0.35rem; + color: #475569; + font-weight: 400; +} + +.sl-configs-picker__tab-count { + flex-shrink: 0; + align-self: flex-start; + min-width: 1.55rem; + padding: 0.2rem 0.4rem; + font-size: 0.72rem; + font-weight: 700; + color: #cbd5e1; + text-align: center; + background: rgba(15, 23, 42, 0.75); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 999px; +} + +.sl-configs-picker__tab.is-active .sl-configs-picker__tab-count { + color: #f0f9ff; + background: rgba(14, 165, 233, 0.35); + border-color: rgba(56, 189, 248, 0.45); +} + +.sl-configs-picker__content { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; +} + +.sl-configs-picker--wide .sl-configs-picker__content { + padding: 0; + border: 1px solid rgba(56, 189, 248, 0.28); + border-radius: 0.75rem; + background: rgba(17, 24, 39, 0.88); + overflow: hidden; +} + +.sl-configs-picker__content-head { + padding: 1rem 1.15rem 0.85rem; + border-bottom: 1px solid var(--bs-border-color); + background: linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(15, 23, 42, 0.35) 100%); +} + +.sl-configs-picker__content-head[hidden] { + display: none !important; +} + +.sl-configs-picker__content-badge { + display: inline-block; + margin-bottom: 0.35rem; + padding: 0.12rem 0.45rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + color: #93c5fd; + background: rgba(56, 189, 248, 0.14); + border: 1px solid rgba(56, 189, 248, 0.25); + border-radius: 999px; +} + +.sl-configs-picker__pane-title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + line-height: 1.3; + color: #f8fafc; + word-break: break-word; + overflow-wrap: anywhere; +} + +.sl-configs-picker__pane-sub { + margin: 0.35rem 0 0; + font-size: 0.82rem; + color: #cbd5e1; +} + +.sl-configs-picker__empty { + margin: 0; + padding: 3rem 1.5rem; + font-size: 0.92rem; + color: #94a3b8; + text-align: center; +} + +.sl-configs-picker--wide .sl-configs-picker__empty { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.sl-configs-picker__empty[hidden] { + display: none !important; +} + +.sl-configs-picker__empty .fa-hand-pointer { + display: block; + margin-bottom: 0.65rem; + font-size: 1.5rem; + color: #64748b; +} + +.sl-configs-picker__pane { + flex: 1 1 auto; + min-height: 0; + padding: 0.85rem 1rem 1rem; + overflow-y: auto; +} + +.sl-configs-picker__pane[hidden] { + display: none !important; +} + +.sl-configs-picker--wide .sl-configs-picker__pane .sl-creation { + margin-bottom: 0.65rem; +} + +.sl-configs-picker--wide .sl-configs-picker__pane .sl-creation:last-child { + margin-bottom: 0; +} + +.sl-configs-body .sl-configs-picker { + margin-top: 0.35rem; +} + +@media (max-width: 991.98px) { + .sl-configs-picker--wide { + flex-direction: column; + min-height: 0; + } + + .sl-configs-picker__sidebar { + flex: 0 0 auto; + max-width: none; + max-height: none; + } + + .sl-configs-picker--wide .sl-configs-picker__nav { + max-height: 280px; + } +} + +@media (max-width: 767.98px) { + .sl-configs-picker--wide .sl-configs-picker__nav { + flex-direction: column; + flex-wrap: nowrap; + } + + .sl-configs-picker--wide .sl-configs-picker__tab { + width: 100%; + } +} + +body.hide-deleted-configs .sl-configs-section--deleted, +body.hide-deleted-configs .sl-creation--deleted { + display: none !important; +} + +.link-row--cleaned { + opacity: 0.72; +} + +/* ——— Settings (admin/settings.php) ——— */ +.admin-page-settings { + max-width: 900px; +} + +.st-hero { + margin-bottom: 1.35rem; + padding: 1.15rem 1.25rem; + border-radius: 0.85rem; + border: 1px solid rgba(56, 189, 248, 0.18); + background: linear-gradient(135deg, rgba(56, 189, 248, 0.08) 0%, rgba(17, 24, 39, 0.5) 100%); +} + +.st-hero__kicker { + margin: 0 0 0.2rem; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--admin-accent); +} + +.st-hero__title { + margin: 0 0 0.3rem; + font-size: 1.35rem; + font-weight: 700; + color: #f8fafc; +} + +.st-hero__sub { + margin: 0; + font-size: 0.88rem; + color: #94a3b8; + line-height: 1.45; +} + +.st-status { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 0.65rem; + margin-bottom: 1.25rem; +} + +@media (min-width: 576px) { + .st-status { + grid-template-columns: repeat(3, 1fr); + } +} + +.st-status-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + padding: 0.85rem 1rem; + border-radius: 0.6rem; + border: 1px solid var(--bs-border-color); + background: var(--admin-surface); +} + +.st-status-card i { + font-size: 1.1rem; + color: #64748b; + margin-bottom: 0.15rem; +} + +.st-status-card.is-ok { + border-color: rgba(52, 211, 153, 0.35); + background: rgba(16, 185, 129, 0.06); +} + +.st-status-card.is-ok i { + color: #34d399; +} + +.st-status-card.is-warn { + border-color: rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.06); +} + +.st-status-card.is-warn i { + color: #fbbf24; +} + +.st-status-card__label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.st-status-card__val { + font-size: 0.9rem; + font-weight: 600; + color: #f1f5f9; +} + +.st-test-result { + margin-bottom: 1.25rem; + border-radius: 0.65rem; + overflow: hidden; + border: 1px solid var(--bs-border-color); +} + +.st-test-result--ok { + border-color: rgba(52, 211, 153, 0.4); + background: rgba(16, 185, 129, 0.06); +} + +.st-test-result--err { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(239, 68, 68, 0.06); +} + +.st-test-result__head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 0.85rem; + font-size: 0.88rem; + font-weight: 600; +} + +.st-test-result--ok .st-test-result__head { + color: #6ee7b7; +} + +.st-test-result--err .st-test-result__head { + color: #fca5a5; +} + +.st-test-result__body { + margin: 0; + padding: 0.75rem 0.85rem; + font-size: 0.78rem; + line-height: 1.45; + color: #cbd5e1; + background: rgba(15, 23, 42, 0.6); + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "JetBrains Mono", monospace; + max-height: 280px; + overflow-y: auto; +} + +.st-panel--main { + margin-bottom: 1.25rem; +} + +.st-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.st-form--nested { + margin-top: 0.75rem; + padding: 1rem; + border-radius: 0.55rem; + background: var(--admin-surface-2); + border: 1px solid var(--bs-border-color); +} + +.st-field { + display: flex; + flex-direction: column; +} + +.admin-page-settings .st-textarea { + min-height: 5rem; + resize: vertical; + font-family: ui-monospace, monospace; + font-size: 0.82rem; +} + +.st-check { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.85rem; + color: #cbd5e1; + cursor: pointer; + margin: 0; +} + +.st-check input { + accent-color: var(--admin-accent); + width: 1rem; + height: 1rem; +} + +.st-badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + padding: 0.1rem 0.4rem; + border-radius: 0.3rem; + vertical-align: middle; +} + +.st-badge--ok { + color: #6ee7b7; + background: rgba(16, 185, 129, 0.15); +} + +.st-badge--err { + color: #fca5a5; + background: rgba(239, 68, 68, 0.12); +} + +.sl-btn-generate--secondary { + background: linear-gradient(135deg, #6366f1, #4f46e5); + box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3); +} + +.st-panel--maintenance { + margin-bottom: 1.25rem; +} + +.st-panel--maintenance-on { + border-color: rgba(251, 191, 36, 0.45); + background: rgba(251, 191, 36, 0.05); +} + +.st-panel--maintenance-on .sl-panel__head i { + color: #fbbf24; +} + +.sl-btn-generate--warn { + background: linear-gradient(135deg, #f59e0b, #d97706); + box-shadow: 0 4px 20px rgba(245, 158, 11, 0.28); +} + +.st-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; +} + +@media (min-width: 768px) { + .st-grid { + grid-template-columns: 1fr 1fr; + } +} + +.st-btn-test { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 1rem; + font-size: 0.88rem; + font-weight: 600; + color: #7dd3fc; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.35); + border-radius: 0.5rem; + cursor: pointer; + font-family: inherit; + transition: background 0.15s, border-color 0.15s; +} + +.st-btn-test:hover { + background: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.5); +} + +.st-callback-box { + margin-bottom: 0.75rem; + padding: 0.65rem 0.85rem; + border-radius: 0.45rem; + background: rgba(15, 23, 42, 0.5); + border: 1px dashed rgba(56, 189, 248, 0.25); +} + +.st-callback-box__label { + display: block; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 0.35rem; +} + +.st-callback-box__url { + display: block; + font-size: 0.78rem; + color: #7dd3fc; + word-break: break-all; + line-height: 1.4; +} + +.admin-page-settings .sl-hint a { + color: #7dd3fc; +} + +.admin-page-settings .st-form label.sl-field__label { + margin: 0 0 0.15rem; +} + +/* ——— Renewal codes (admin/renewal_codes.php) ——— */ +.admin-page-renewal .rc-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem 1.25rem; + margin-bottom: 1rem; +} + +@media (max-width: 767.98px) { + .admin-page-renewal .rc-form-grid { + grid-template-columns: 1fr; + } +} + +.admin-page-renewal .rc-code-row { + display: flex; + gap: 0.5rem; +} + +.admin-page-renewal .rc-code-input { + flex: 1 1 auto; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.admin-page-renewal .rc-btn-random { + flex-shrink: 0; + min-width: 2.5rem; +} + +.admin-page-renewal .rc-code-display, +.admin-page-renewal .rc-code-pill { + font-size: 0.92rem; + font-weight: 600; + letter-spacing: 0.06em; + color: #7dd3fc; +} + +.admin-page-renewal .rc-days-badge { + display: inline-block; + padding: 0.15rem 0.45rem; + font-size: 0.78rem; + font-weight: 700; + color: #34d399; + background: rgba(52, 211, 153, 0.12); + border-radius: 999px; +} + +.admin-page-renewal .rc-usage-bar { + height: 0.35rem; + margin-bottom: 0.25rem; + background: rgba(15, 23, 42, 0.55); + border-radius: 999px; + overflow: hidden; +} + +.admin-page-renewal .rc-usage-fill { + display: block; + height: 100%; + background: linear-gradient(90deg, #38bdf8, #34d399); +} + +.admin-page-renewal .rc-row--exhausted { + opacity: 0.55; +} + +.admin-page-renewal .rc-foot-hint { + margin: 0.85rem 0 0; + font-size: 0.82rem; + color: #94a3b8; +} + +.admin-page-renewal .rc-sql-snippet { + display: block; + margin: 0.5rem 0; + padding: 0.55rem 0.75rem; + font-size: 0.82rem; + color: #7dd3fc; + background: rgba(15, 23, 42, 0.55); + border-radius: 0.45rem; +} diff --git a/Go/web/static/css/login.css b/Go/web/static/css/login.css new file mode 100644 index 0000000..899013f --- /dev/null +++ b/Go/web/static/css/login.css @@ -0,0 +1,259 @@ +/** + * Amnezia Admin — страница входа (изолирована от admin.css) + */ +.login-page { + --login-bg: #070b14; + --login-card: rgba(17, 24, 39, 0.92); + --login-border: rgba(56, 189, 248, 0.22); + --login-text: #f1f5f9; + --login-muted: #94a3b8; + --login-accent: #38bdf8; + --login-accent-deep: #0ea5e9; + --login-input-bg: #0f172a; + --login-input-border: #334155; + --login-glow: rgba(56, 189, 248, 0.35); + + margin: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + font-family: "Inter", system-ui, sans-serif; + color: var(--login-text); + background: var(--login-bg); + position: relative; + overflow: hidden; +} + +.login-page::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 80% 50% at 50% -10%, rgba(56, 189, 248, 0.18), transparent 55%), + radial-gradient(ellipse 40% 35% at 100% 80%, rgba(99, 102, 241, 0.12), transparent 50%), + radial-gradient(ellipse 35% 30% at 0% 90%, rgba(14, 165, 233, 0.1), transparent 45%), + linear-gradient(165deg, #070b14 0%, #0c1222 45%, #070b14 100%); +} + +.login-page::after { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.35; + background-image: + linear-gradient(rgba(56, 189, 248, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.04) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%); +} + +.login-wrap { + position: relative; + z-index: 1; + width: 100%; + max-width: 400px; +} + +.login-card { + background: var(--login-card); + border: 1px solid var(--login-border); + border-radius: 1.125rem; + padding: 2rem 1.75rem 1.75rem; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 24px 64px rgba(0, 0, 0, 0.55), + 0 0 48px rgba(56, 189, 248, 0.08); + backdrop-filter: blur(12px); +} + +.login-brand { + text-align: center; + margin-bottom: 1.75rem; +} + +.login-brand-icon { + width: 64px; + height: 64px; + margin: 0 auto 1rem; + border-radius: 1rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + color: var(--login-accent); + background: linear-gradient(145deg, rgba(56, 189, 248, 0.2), rgba(14, 165, 233, 0.08)); + border: 1px solid rgba(56, 189, 248, 0.35); + box-shadow: 0 0 28px var(--login-glow); +} + +.login-title { + margin: 0 0 0.35rem; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--login-text); +} + +.login-subtitle { + margin: 0; + font-size: 0.875rem; + color: var(--login-muted); +} + +.login-alert { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.65rem 0.85rem; + margin-bottom: 1.25rem; + border-radius: 0.5rem; + font-size: 0.875rem; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(248, 113, 113, 0.4); + color: #fecaca; +} + +.login-field { + margin-bottom: 1rem; +} + +.login-field label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--login-muted); +} + +.login-field label i { + color: var(--login-accent); + margin-right: 0.25rem; + width: 1rem; + text-align: center; +} + +.login-input { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0.65rem 0.85rem; + font-size: 0.95rem; + line-height: 1.4; + color: var(--login-text) !important; + -webkit-text-fill-color: var(--login-text); + background: var(--login-input-bg) !important; + border: 1px solid var(--login-input-border); + border-radius: 0.5rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.login-input::placeholder { + color: #64748b; + -webkit-text-fill-color: #64748b; +} + +.login-input:hover { + border-color: #475569; +} + +.login-input:focus { + outline: none; + border-color: var(--login-accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2); +} + +.login-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + margin-top: 0.25rem; + padding: 0.7rem 1rem; + font-size: 0.95rem; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, var(--login-accent-deep) 0%, #0284c7 100%); + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: transform 0.12s, box-shadow 0.15s, filter 0.15s; + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.35); +} + +.login-btn:hover { + filter: brightness(1.08); + box-shadow: 0 6px 28px rgba(14, 165, 233, 0.45); + color: #fff; +} + +.login-btn:active { + transform: scale(0.98); +} + +.login-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.25rem 0; + color: var(--login-muted); + font-size: 0.75rem; +} + +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--login-input-border); +} + +.login-btn-oauth { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.6rem 1rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--login-text); + text-decoration: none; + background: transparent; + border: 1px solid var(--login-input-border); + border-radius: 0.5rem; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.login-btn-oauth:hover { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.08); + color: var(--login-accent); +} + +.login-footer { + margin: 1.25rem 0 0; + text-align: center; + font-size: 0.8rem; + color: var(--login-muted); +} + +.login-footer a { + color: var(--login-accent); + text-decoration: none; +} + +.login-footer a:hover { + text-decoration: underline; +} + +.login-footer code { + color: #7dd3fc; + font-size: 0.78em; +} diff --git a/Go/web/static/css/portal.css b/Go/web/static/css/portal.css new file mode 100644 index 0000000..e4b6ec1 --- /dev/null +++ b/Go/web/static/css/portal.css @@ -0,0 +1,465 @@ +/** + * Secure Link — портал пользователя (index, cabinet) + */ +.portal-page { + --portal-bg: #070b14; + --portal-card: rgba(17, 24, 39, 0.94); + --portal-border: rgba(56, 189, 248, 0.22); + --portal-accent: #38bdf8; + --portal-accent-2: #a78bfa; + --portal-text: #f1f5f9; + --portal-muted: #94a3b8; + --portal-ok: #34d399; + --portal-warn: #fbbf24; + --portal-err: #f87171; + + margin: 0; + min-height: 100vh; + font-family: "Inter", system-ui, sans-serif; + color: var(--portal-text); + background: var(--portal-bg); + position: relative; +} + +.portal-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 70% 45% at 20% -5%, rgba(56, 189, 248, 0.14), transparent 55%), + radial-gradient(ellipse 50% 40% at 100% 100%, rgba(139, 92, 246, 0.12), transparent 50%), + linear-gradient(165deg, #070b14 0%, #0c1222 50%, #070b14 100%); +} + +.portal-bg::after { + content: ""; + position: absolute; + inset: 0; + opacity: 0.3; + background-image: + linear-gradient(rgba(56, 189, 248, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.04) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 15%, transparent 70%); +} + +.portal-wrap { + position: relative; + z-index: 1; + max-width: 420px; + margin: 0 auto; + padding: 2rem 1.25rem 3rem; +} + +.portal-wrap--wide { + max-width: 920px; +} + +.portal-hero-icon { + width: 64px; + height: 64px; + margin: 0 auto 1rem; + border-radius: 1rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + color: var(--portal-accent); + background: rgba(56, 189, 248, 0.12); + border: 1px solid var(--portal-border); + box-shadow: 0 0 32px rgba(56, 189, 248, 0.2); +} + +.portal-brand { + font-family: "Orbitron", sans-serif; + font-size: 0.75rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--portal-accent); + margin: 0 0 0.35rem; +} + +.portal-title { + font-family: "Orbitron", sans-serif; + font-weight: 700; + margin: 0 0 0.5rem; + color: #fff; +} + +.portal-lead { + color: var(--portal-muted); + font-size: 0.92rem; + line-height: 1.5; + margin: 0; +} + +.portal-auth-card { + background: var(--portal-card); + border: 1px solid var(--portal-border); + border-radius: 1rem; + padding: 0; + overflow: hidden; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(10px); +} + +.portal-tabs { + display: flex; + border-bottom: 1px solid rgba(42, 51, 82, 0.8); +} + +.portal-tab { + flex: 1; + text-align: center; + padding: 0.85rem 1rem; + font-weight: 600; + font-size: 0.88rem; + color: var(--portal-muted); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +.portal-tab:hover { + color: var(--portal-text); + background: rgba(56, 189, 248, 0.06); +} + +.portal-tab.is-active { + color: var(--portal-accent); + background: rgba(56, 189, 248, 0.1); + box-shadow: inset 0 -2px 0 var(--portal-accent); +} + +.portal-form { + padding: 1.35rem 1.25rem 1.5rem; +} + +.portal-label { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: #cbd5e1; + margin: 0 0 0.35rem; +} + +.portal-optional { + font-weight: 400; + color: var(--portal-muted); +} + +.portal-input { + width: 100%; + box-sizing: border-box; + padding: 0.55rem 0.75rem; + margin-bottom: 0.85rem; + font-size: 0.9rem; + color: #f8fafc; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.45rem; +} + +.portal-input:focus { + outline: none; + border-color: var(--portal-accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2); +} + +.portal-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 1.1rem; + font-size: 0.88rem; + font-weight: 600; + border-radius: 0.5rem; + border: none; + cursor: pointer; + text-decoration: none; + font-family: inherit; + transition: filter 0.15s, transform 0.1s; +} + +.portal-btn--primary { + color: #fff; + background: linear-gradient(135deg, #0ea5e9, #0284c7); + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.35); +} + +.portal-btn--accent { + color: #fff; + background: linear-gradient(135deg, #8b5cf6, #6366f1); + box-shadow: 0 4px 18px rgba(139, 92, 246, 0.35); +} + +.portal-btn--outline { + color: var(--portal-accent); + background: transparent; + border: 1px solid rgba(56, 189, 248, 0.4); +} + +.portal-btn--ghost { + color: var(--portal-muted); + background: rgba(30, 41, 59, 0.6); + border: 1px solid #334155; +} + +.portal-btn:hover { + filter: brightness(1.08); +} + +.portal-form-note { + margin: 0.75rem 0 0; + font-size: 0.75rem; + color: var(--portal-muted); + line-height: 1.4; +} + +.portal-alert { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; + font-size: 0.85rem; + margin-bottom: 1rem; +} + +.portal-alert--err { + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + color: #fecaca; +} + +.portal-alert--ok { + background: rgba(52, 211, 153, 0.1); + border: 1px solid rgba(52, 211, 153, 0.35); + color: #a7f3d0; +} + +.portal-alert--warn { + background: rgba(251, 191, 36, 0.08); + border: 1px solid rgba(251, 191, 36, 0.3); + color: #fde68a; +} + +.portal-footer { + text-align: center; + margin-top: 1.5rem; + font-size: 0.82rem; +} + +.portal-footer a { + color: var(--portal-muted); + text-decoration: none; +} + +.portal-footer a:hover { + color: var(--portal-accent); +} + +/* Cabinet */ +.portal-cabinet-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.portal-user-line { + font-size: 0.85rem; + color: var(--portal-muted); + margin: 0.25rem 0 0; +} + +.portal-head-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-panel { + background: var(--portal-card); + border: 1px solid rgba(42, 51, 82, 0.9); + border-radius: 0.85rem; + padding: 1.15rem 1.2rem; + margin-bottom: 1rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +} + +.portal-panel--renew { + border-color: rgba(167, 139, 250, 0.35); +} + +.portal-panel--warn { + display: flex; + align-items: center; + gap: 0.65rem; + border-color: rgba(251, 191, 36, 0.35); + color: #fde68a; +} + +.portal-panel--empty { + text-align: center; + color: var(--portal-muted); +} + +.portal-panel__title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.65rem; + display: flex; + align-items: center; + gap: 0.45rem; +} + +.portal-panel__title i { + color: var(--portal-accent); +} + +.portal-muted { + color: var(--portal-muted); +} + +.portal-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 767.98px) { + .portal-grid-2 { + grid-template-columns: 1fr; + } +} + +.portal-links-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.65rem; +} + +.portal-link-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0.85rem; + border-radius: 0.55rem; + border: 1px solid #334155; + background: rgba(15, 23, 42, 0.6); + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.portal-link-card:hover { + border-color: rgba(56, 189, 248, 0.45); +} + +.portal-link-card.is-active { + border-color: var(--portal-accent); + box-shadow: 0 0 20px rgba(56, 189, 248, 0.15); +} + +.portal-link-card__id { + font-weight: 700; + color: #7dd3fc; + font-size: 0.9rem; +} + +.portal-link-card__meta, +.portal-link-card__uses { + font-size: 0.75rem; + color: var(--portal-muted); +} + +.portal-badge { + align-self: flex-start; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.15rem 0.45rem; + border-radius: 999px; +} + +.portal-badge--ok { + background: rgba(52, 211, 153, 0.15); + color: #6ee7b7; + border: 1px solid rgba(52, 211, 153, 0.35); +} + +.portal-badge--warn { + background: rgba(251, 191, 36, 0.12); + color: #fcd34d; + border: 1px solid rgba(251, 191, 36, 0.35); +} + +.portal-badge--muted { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; + border: 1px solid rgba(100, 116, 139, 0.4); +} + +.portal-migrate-card { + border: 1px solid rgba(42, 51, 82, 0.8); + border-radius: 0.55rem; + padding: 0.65rem 0.85rem; + margin-bottom: 0.5rem; + background: rgba(15, 23, 42, 0.5); +} + +.portal-migrate-card summary { + cursor: pointer; + font-weight: 500; + font-size: 0.88rem; +} + +.portal-page--cabinet .server-item { + display: block; + width: 100%; + text-align: left; + padding: 0.55rem 0.75rem; + margin-bottom: 0.35rem; + border: 1px solid #334155; + border-radius: 0.45rem; + background: #0f172a; + color: #e2e8f0; + cursor: pointer; + font-family: inherit; + font-size: 0.88rem; +} + +.portal-page--cabinet .server-item.is-selected { + border-color: var(--portal-accent); + background: rgba(56, 189, 248, 0.12); +} + +.portal-renew-form .portal-input { + margin-bottom: 0; +} + +.portal-sub-card .portal-stat-num { + font-family: "Orbitron", sans-serif; + font-size: 1.75rem; + font-weight: 700; + color: #7dd3fc; +} + +.portal-feature-list { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.85rem; + color: var(--portal-muted); +} + +.portal-feature-list li { + margin-bottom: 0.35rem; +} + +.w-100 { width: 100%; } diff --git a/Go/web/static/css/share.css b/Go/web/static/css/share.css new file mode 100644 index 0000000..4688af7 --- /dev/null +++ b/Go/web/static/css/share.css @@ -0,0 +1,2212 @@ +/** + * Guest share page — Bootstrap 5 + Font Awesome 6 + */ +:root, +[data-bs-theme="dark"] { + --bs-body-bg: #05060f; + --bs-body-color: #e0e6ff; + --bs-border-color: #1a1f3d; + --bs-primary: #00d4e8; + --bs-primary-rgb: 0, 212, 232; + --bs-success: #00ff9d; + --bs-danger: #ff3860; + --bs-warning: #ffe23d; + --bs-secondary-color: #8892b8; +} + + :root { + --bg: #05060f; + --bg2: #0b0d1a; + --card: #0d1025; + --card-edge: #1a1f3d; + --card-edge2: #252b55; + --text: #e0e6ff; + --muted: #8892b8; + --text3: #555d80; + --neon-cyan: #00f0ff; + --neon-cyan-dim: rgba(0, 240, 255, 0.08); + --neon-cyan-glow: rgba(0, 240, 255, 0.35); + --neon-pink: #ff2d7b; + --neon-pink-glow: rgba(255, 45, 123, 0.35); + --neon-purple: #b24dff; + --neon-purple-dim: rgba(178, 77, 255, 0.12); + --neon-yellow: #ffe23d; + --neon-yellow-dim: rgba(255, 226, 61, 0.1); + --accent: var(--neon-cyan); + --accent-dim: var(--neon-cyan-dim); + --ok: #00ff9d; + --ok-bg: rgba(0, 255, 157, 0.08); + --err: #ff3860; + --err-bg: rgba(255, 56, 96, 0.1); + --radius: 6px; + --shadow: 0 8px 40px rgba(0, 0, 0, 0.55), 0 0 1px rgba(0, 240, 255, 0.15); + --font-display: 'Orbitron', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; + --font-body: 'Inter', system-ui, sans-serif; + } + * { box-sizing: border-box; } + ::selection { background: rgba(0, 240, 255, 0.25); color: #fff; } + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { background: var(--bg2); } + ::-webkit-scrollbar-thumb { background: var(--card-edge2); border-radius: 3px; } + ::-webkit-scrollbar-thumb:hover { background: var(--neon-cyan); } + body.share-app { + font-family: var(--font-body); + margin: 0; + min-height: 100vh; + color: var(--text); + background: var(--bg); + padding: 1.25rem 1rem 3rem; + line-height: 1.5; + } + body.share-app::before { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 240, 255, 0.12), transparent 55%), + radial-gradient(ellipse 60% 40% at 100% 50%, rgba(178, 77, 255, 0.08), transparent 50%), + radial-gradient(ellipse 50% 35% at 0% 80%, rgba(255, 45, 123, 0.06), transparent 45%), + linear-gradient(180deg, transparent 0%, rgba(0, 240, 255, 0.015) 50%, transparent 100%), + repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 240, 255, 0.018) 2px, rgba(0, 240, 255, 0.018) 4px); + } + .wrap { max-width: 500px; margin: 0 auto; position: relative; } + .wrap svg:not(.qr-box svg) { + filter: drop-shadow(0 0 4px var(--neon-cyan-glow)); + } + .hero { + text-align: center; + margin-bottom: 1.5rem; + padding-top: 0.25rem; + } + .hero-icon { + width: 64px; height: 64px; + margin: 0 auto 0.85rem; + border-radius: var(--radius); + background: var(--neon-cyan-dim); + border: 1px solid rgba(0, 240, 255, 0.35); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 24px rgba(0, 240, 255, 0.15), inset 0 0 20px rgba(0, 240, 255, 0.05); + animation: hero-pulse 3s ease-in-out infinite; + } + @keyframes hero-pulse { + 0%, 100% { box-shadow: 0 0 24px rgba(0, 240, 255, 0.15), inset 0 0 20px rgba(0, 240, 255, 0.05); } + 50% { box-shadow: 0 0 32px rgba(0, 240, 255, 0.28), inset 0 0 28px rgba(0, 240, 255, 0.08); } + } + .hero-icon svg { width: 30px; height: 30px; color: var(--neon-cyan); filter: drop-shadow(0 0 8px var(--neon-cyan-glow)); } + .hero-brand { + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--neon-pink); + margin-bottom: 0.35rem; + text-shadow: 0 0 10px var(--neon-pink-glow); + } + h1 { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 700; + margin: 0 0 0.35rem 0; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--neon-cyan); + text-shadow: 0 0 16px var(--neon-cyan-glow); + } + .lead { color: var(--muted); font-size: 0.9rem; margin: 0; max-width: 34ch; margin-left: auto; margin-right: auto; line-height: 1.55; } + + .configs-anchor-wrap { + text-align: center; + margin: 0 0 1rem 0; + } + .configs-anchor { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.55rem 1rem; + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--neon-cyan); + text-decoration: none; + border: 1px solid rgba(0, 240, 255, 0.35); + background: var(--neon-cyan-dim); + transition: border-color 0.2s, box-shadow 0.2s, color 0.2s; + } + .configs-anchor:hover { + border-color: var(--neon-cyan); + color: #fff; + box-shadow: 0 0 16px rgba(0, 240, 255, 0.2); + text-shadow: 0 0 8px var(--neon-cyan-glow); + } + .dl-bundle { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid var(--card-edge); + } + .dl-bundle__name { + font-family: var(--font-display); + font-size: 0.9rem; + font-weight: 700; + color: var(--neon-cyan); + margin: 0 0 0.4rem 0; + line-height: 1.35; + letter-spacing: 0.06em; + text-transform: uppercase; + text-shadow: 0 0 10px var(--neon-cyan-glow); + } + .dl-bundle__meta { + font-size: 0.78rem; + color: var(--muted); + margin: 0 0 1rem 0; + line-height: 1.45; + } + .dl-bundle[hidden] { display: none !important; } + .dl-bundle--primary { + margin-top: 0; + padding-top: 0; + border-top: none; + } + + .configs-more { + margin-top: 0.25rem; + } + .configs-more__toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + padding: 0.8rem 1rem; + border: 1px solid rgba(0, 240, 255, 0.22); + border-radius: var(--radius); + background: rgba(0, 240, 255, 0.05); + color: var(--neon-cyan); + font-family: var(--font-display); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + cursor: pointer; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; + } + .configs-more__toggle:hover { + border-color: var(--neon-cyan); + background: rgba(0, 240, 255, 0.1); + box-shadow: 0 0 14px rgba(0, 240, 255, 0.15); + } + .configs-more__toggle.is-open { + border-color: rgba(255, 45, 149, 0.45); + color: #ffb8dc; + background: rgba(255, 45, 149, 0.06); + } + .configs-more__chevron { + transition: transform 0.2s ease; + font-size: 0.72rem; + } + .configs-more__toggle.is-open .configs-more__chevron { + transform: rotate(180deg); + } + .configs-more__hint { + margin: 0.55rem 0 0; + font-size: 0.76rem; + text-align: center; + } + .configs-more__body { + margin-top: 0.85rem; + } + .configs-more__body[hidden] { + display: none !important; + } + + .dl-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.4rem; + margin-top: 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--card-edge); + } + .dl-pager__pages { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.35rem; + } + .dl-pager__btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 2.25rem; + padding: 0.4rem 0.7rem; + border: 1px solid var(--card-edge); + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + font-size: 0.85rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; + } + .dl-pager__page { + justify-content: center; + } + .dl-pager__btn:hover:not(:disabled) { + color: #fff; + border-color: var(--neon-cyan); + background: rgba(255, 255, 255, 0.06); + } + .dl-pager__page.is-active { + color: #061018; + background: var(--neon-cyan); + border-color: var(--neon-cyan); + box-shadow: 0 0 12px var(--neon-cyan-glow); + } + .dl-pager__btn:disabled { + opacity: 0.4; + cursor: default; + } + + .stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + margin-bottom: 1rem; + } + .stat { + background: var(--card); + border: 1px solid var(--card-edge); + border-radius: var(--radius); + padding: 0.65rem 0.75rem; + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8rem; + position: relative; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); + } + .stat::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--neon-cyan), transparent); + opacity: 0.35; + } + .stat svg { flex-shrink: 0; width: 18px; height: 18px; color: var(--neon-cyan); margin-top: 2px; } + .stat strong { display: block; color: var(--text); font-size: 1rem; font-weight: 600; font-family: var(--font-mono); } + .stat span.muted { color: var(--text3); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; font-family: var(--font-mono); } + + .card { + background: var(--card); + border: 1px solid var(--card-edge); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1rem; + box-shadow: var(--shadow); + position: relative; + overflow: hidden; + } + .card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 5%, var(--neon-cyan) 50%, var(--neon-pink) 95%); + opacity: 0.35; + } + .card--download { padding-bottom: 1.5rem; } + + .alert { display: flex; gap: 0.65rem; align-items: flex-start; padding: 0.85rem 1rem; border-radius: var(--radius); margin-bottom: 1rem; font-size: 0.88rem; } + .alert svg { flex-shrink: 0; width: 20px; height: 20px; margin-top: 2px; } + .alert--err { background: var(--err-bg); border: 1px solid rgba(255, 56, 96, 0.35); color: #ffb3c1; } + .alert--err svg { color: var(--err); filter: drop-shadow(0 0 6px rgba(255, 56, 96, 0.5)); } + .alert--ok { background: var(--ok-bg); border: 1px solid rgba(0, 255, 157, 0.3); color: #a7f3d0; } + .alert--ok svg { color: var(--ok); filter: drop-shadow(0 0 6px rgba(0, 255, 157, 0.4)); } + .alert--warn { background: rgba(255, 170, 0, 0.08); border: 1px solid rgba(255, 170, 0, 0.35); color: #ffe4a8; } + .alert--warn i { color: #ffb84d; filter: drop-shadow(0 0 6px rgba(255, 170, 0, 0.35)); } + .maintenance-banner { margin-top: 0.25rem; } + + .field { margin-bottom: 1.15rem; } + .field:last-of-type { margin-bottom: 0; } + .field-label { + display: flex; + align-items: center; + gap: 0.45rem; + font-family: var(--font-mono); + font-weight: 600; + font-size: 0.72rem; + margin-bottom: 0.5rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.1em; + } + .field-label svg { width: 16px; height: 16px; color: var(--neon-cyan); flex-shrink: 0; } + + select.share-select { + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: var(--bg2); + color: var(--text); + font-family: var(--font-body); + font-size: 0.92rem; + cursor: pointer; + appearance: none; + transition: border-color 0.2s, box-shadow 0.2s; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2300f0ff'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.65rem center; + background-size: 1rem; + padding-right: 2.25rem; + } + select.share-select:focus { + outline: none; + border-color: rgba(0, 240, 255, 0.5); + box-shadow: 0 0 0 2px rgba(0, 240, 255, 0.12); + } + select.share-select:disabled { opacity: 0.5; cursor: not-allowed; } + + .server-search-wrap { + display: none; + margin-bottom: 0.55rem; + } + .server-search-wrap.is-visible { display: block; } + .server-search-field { + position: relative; + } + .server-search-field svg { + position: absolute; + left: 0.7rem; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--neon-cyan); + pointer-events: none; + opacity: 0.85; + } + .server-search { + width: 100%; + padding: 0.62rem 2rem 0.62rem 2.35rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: var(--bg2); + color: var(--text); + font-family: var(--font-body); + font-size: 0.92rem; + transition: border-color 0.2s, box-shadow 0.2s; + } + .server-search::placeholder { color: var(--text3); } + .server-search:focus { + outline: none; + border-color: rgba(0, 240, 255, 0.5); + box-shadow: 0 0 0 2px rgba(0, 240, 255, 0.12); + } + .server-search-clear { + position: absolute; + right: 0.45rem; + top: 50%; + transform: translateY(-50%); + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--muted); + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + } + .server-search-clear:hover { color: var(--neon-cyan); background: var(--neon-cyan-dim); } + .server-search-clear.is-visible { display: flex; } + .server-search-clear svg { width: 14px; height: 14px; position: static; transform: none; } + .server-search-empty { + display: none; + margin: 0.35rem 0 0.5rem; + padding: 0.65rem 0.75rem; + font-size: 0.82rem; + color: var(--muted); + text-align: center; + font-family: var(--font-mono); + border: 1px dashed var(--card-edge); + border-radius: var(--radius); + } + .server-search-empty.is-visible { display: block; } + + .server-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 320px; + overflow-y: auto; + padding-right: 4px; + scroll-behavior: smooth; + } + .server-list::-webkit-scrollbar { width: 6px; } + .server-list::-webkit-scrollbar-track { background: transparent; } + .server-list::-webkit-scrollbar-thumb { + background: var(--card-edge2); + border-radius: 3px; + } + .server-list::-webkit-scrollbar-thumb:hover { background: var(--neon-cyan); } + .server-item { + position: relative; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: var(--bg2); + cursor: pointer; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; + } + .server-item:hover { + border-color: rgba(0, 240, 255, 0.35); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.06); + } + .server-item.is-selected { + border-color: var(--neon-cyan); + background: var(--neon-cyan-dim); + box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.4), 0 0 20px rgba(0, 240, 255, 0.1); + } + .server-item input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; } + .server-flag { + font-size: 1.65rem; + line-height: 1; + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: rgba(148, 163, 184, 0.08); + } + .server-flag--empty { + font-size: 1.1rem; + color: var(--muted); + } + .server-flag img { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 6px; + display: block; + box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.15); + } + .server-flag--emoji .server-flag__twemoji { + object-fit: contain; + border-radius: 4px; + box-shadow: none; + } + .server-flag__raw { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .server-info { flex: 1; min-width: 0; } + .server-name { + font-weight: 600; + font-size: 0.92rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .server-speed { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.18rem; + font-size: 0.74rem; + font-weight: 600; + color: var(--neon-yellow); + font-family: var(--font-mono); + letter-spacing: 0.02em; + line-height: 1.3; + } + .server-speed i { + font-size: 0.68rem; + color: #fbbf24; + filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.35)); + } + .server-speed__text { + color: #fde68a; + } + .server-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-top: 0.2rem; + } + .server-badge { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.15rem 0.45rem; + border-radius: 6px; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + line-height: 1.4; + } + .server-badge--wg { background: rgba(0, 255, 157, 0.08); color: var(--ok); border: 1px solid rgba(0, 255, 157, 0.3); font-family: var(--font-mono); } + .server-badge--awg { background: var(--neon-purple-dim); color: var(--neon-purple); border: 1px solid rgba(178, 77, 255, 0.35); font-family: var(--font-mono); } + .server-badge--vless { background: rgba(56, 189, 248, 0.1); color: #7dd3fc; border: 1px solid rgba(56, 189, 248, 0.35); font-family: var(--font-mono); } + .server-badge--off { background: rgba(148,163,184,0.08); color: var(--muted); border: 1px solid rgba(148,163,184,0.15); text-decoration: line-through; opacity: 0.5; } + .server-notice { + font-size: 0.72rem; + color: var(--neon-yellow); + margin-top: 0.15rem; + line-height: 1.3; + font-family: var(--font-mono); + } + .server-check { + flex-shrink: 0; + width: 22px; height: 22px; + border-radius: 50%; + border: 2px solid var(--card-edge); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, background 0.15s; + } + .server-item.is-selected .server-check { + border-color: var(--neon-cyan); + background: var(--neon-cyan); + box-shadow: 0 0 10px var(--neon-cyan-glow); + } + .server-check svg { width: 14px; height: 14px; color: #fff; opacity: 0; } + .server-item.is-selected .server-check svg { opacity: 1; } + .server-item.is-disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; + border-style: dashed; + } + .server-off-label { + display: inline-block; + font-size: 0.68rem; + font-weight: 600; + color: #f87171; + background: rgba(220,38,38,0.12); + padding: 0.08rem 0.35rem; + border-radius: 4px; + margin-left: 0.35rem; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.02em; + } + + .load-line { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--muted); + font-size: 0.88rem; + margin-top: 0.5rem; + } + .spinner { + width: 18px; height: 18px; + border: 2px solid rgba(0, 240, 255, 0.15); + border-top-color: var(--neon-cyan); + border-radius: 50%; + animation: spin 0.7s linear infinite; + box-shadow: 0 0 8px rgba(0, 240, 255, 0.2); + } + @keyframes spin { to { transform: rotate(360deg); } } + + .proto-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); + gap: 0.6rem; + } + .proto-card { + position: relative; + display: block; + margin: 0; + padding: 0.75rem 0.65rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: var(--bg2); + cursor: pointer; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; + } + .proto-card:hover { border-color: rgba(0, 240, 255, 0.35); } + .proto-card input { position: absolute; opacity: 0; pointer-events: none; } + .proto-card input:checked + .proto-inner { color: var(--text); } + .proto-card:has(input:checked) { + border-color: var(--neon-cyan); + background: var(--neon-cyan-dim); + box-shadow: 0 0 16px rgba(0, 240, 255, 0.08); + } + #proto-card-awg:has(input:checked) { + border-color: var(--neon-purple); + background: var(--neon-purple-dim); + box-shadow: 0 0 16px rgba(178, 77, 255, 0.12); + } + #proto-card-vless:has(input:checked) { + border-color: #38bdf8; + background: rgba(56, 189, 248, 0.08); + box-shadow: 0 0 16px rgba(56, 189, 248, 0.12); + } + .proto-inner { display: flex; flex-direction: column; gap: 0.15rem; } + .proto-title { + font-weight: 600; + font-size: 0.88rem; + letter-spacing: 0.02em; + display: flex; + align-items: center; + gap: 0.35rem; + } + .proto-ico { width: 16px; height: 16px; color: var(--neon-cyan); flex-shrink: 0; } + .proto-ico--awg { color: var(--neon-purple); } + .proto-ico--vless { color: #38bdf8; } + .proto-hint { font-size: 0.7rem; color: var(--text3); line-height: 1.35; font-family: var(--font-mono); } + + .proto-vless-notice { + margin-top: 0.65rem; + padding: 0.7rem 0.85rem; + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.4); + border-radius: var(--radius); + font-size: 0.82rem; + line-height: 1.5; + color: #bae6fd; + box-shadow: inset 0 0 20px rgba(56, 189, 248, 0.05); + } + .proto-vless-notice strong { color: #e0f2fe; font-family: var(--font-display); font-size: 0.75rem; letter-spacing: 0.05em; } + .proto-vless-notice p { margin: 0.45rem 0 0; } + .proto-vless-notice p:first-of-type { margin-top: 0.35rem; } + + .proto-awg-notice { + margin-top: 0.65rem; + padding: 0.7rem 0.85rem; + background: var(--neon-purple-dim); + border: 1px solid rgba(178, 77, 255, 0.4); + border-radius: var(--radius); + font-size: 0.82rem; + line-height: 1.5; + color: #d4b8ff; + box-shadow: inset 0 0 20px rgba(178, 77, 255, 0.05); + } + .proto-awg-notice strong { color: #e9d5ff; font-family: var(--font-display); font-size: 0.75rem; letter-spacing: 0.05em; } + .proto-awg-notice p { margin: 0.45rem 0 0; } + .proto-awg-notice p:first-of-type { margin-top: 0.35rem; } + + .btn-primary { + width: 100%; + margin-top: 1.15rem; + padding: 0.9rem 1rem; + border: 1px solid rgba(0, 240, 255, 0.5); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(0, 240, 255, 0.25) 0%, rgba(0, 200, 221, 0.15) 100%); + color: #fff; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.82rem; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + box-shadow: 0 0 24px rgba(0, 240, 255, 0.2), inset 0 0 20px rgba(0, 240, 255, 0.06); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: box-shadow 0.2s, border-color 0.2s, transform 0.12s; + text-shadow: 0 0 12px var(--neon-cyan-glow); + } + .btn-primary:hover:not(:disabled) { + border-color: var(--neon-cyan); + box-shadow: 0 0 32px rgba(0, 240, 255, 0.35), inset 0 0 24px rgba(0, 240, 255, 0.1); + } + .btn-primary:active:not(:disabled) { transform: scale(0.98); } + .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; border-color: var(--card-edge); } + .btn-primary svg { width: 18px; height: 18px; filter: drop-shadow(0 0 4px var(--neon-cyan-glow)); } + + .hint { color: var(--muted); font-size: 0.78rem; margin-top: 0.65rem; line-height: 1.45; } + + .meta { color: var(--muted); font-size: 0.88rem; line-height: 1.55; } + a.dl { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--accent); + font-weight: 600; + text-decoration: none; + padding: 0.35rem 0; + } + a.dl:hover { text-decoration: underline; } + a.dl svg { width: 16px; height: 16px; } + + .download-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + margin: 0 0 1rem 0; + } + .download-title svg { width: 22px; height: 22px; color: var(--ok); } + + .dl-block { margin-bottom: 1.25rem; } + .dl-block:last-child { margin-bottom: 0; } + .dl-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.65rem; + align-items: flex-start; + } + .dl-side { flex: 1; min-width: 0; } + .qr-box { + background: #fff; + padding: 10px; + border-radius: var(--radius); + border: 2px solid var(--neon-cyan); + box-shadow: 0 0 24px rgba(0, 240, 255, 0.25), 0 4px 20px rgba(0, 0, 0, 0.4); + max-width: 370px; + } + .qr-box, .qr-box * { filter: none !important; } + .qr-box img { max-width: 100%; height: auto; } + .qr-box canvas { max-width: 100%; height: auto !important; } + #qr-c, #qr-v { display: inline-block; } + pre { + flex: 1; + min-width: 0; + background: rgba(2, 6, 23, 0.65); + color: #cbd5e1; + padding: 0.75rem; + border-radius: 10px; + font-size: 0.72rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + margin: 0; + border: 1px solid var(--card-edge); + } + .badge-file { font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; } + + .guest-metrics { + display: grid; + gap: 0.65rem; + margin-bottom: 1.25rem; + } + .guest-metrics--2 { grid-template-columns: 1fr 1fr; } + @media (max-width: 420px) { + .guest-metrics--2 { grid-template-columns: 1fr; } + } + .metric-tile { + background: var(--bg2); + border: 1px solid var(--card-edge); + border-radius: var(--radius); + padding: 0.85rem 1rem; + display: flex; + gap: 0.65rem; + align-items: flex-start; + position: relative; + overflow: hidden; + } + .metric-tile::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--neon-cyan), transparent); + opacity: 0.3; + } + .metric-tile svg { flex-shrink: 0; width: 22px; height: 22px; margin-top: 2px; color: var(--neon-cyan); } + .metric-tile .metric-val { display: block; font-size: 1.05rem; font-weight: 600; color: var(--text); } + .metric-tile .metric-sub { font-size: 0.78rem; color: var(--muted); margin-top: 0.2rem; line-height: 1.35; } + .progress-track { + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); + overflow: hidden; + margin-top: 0.55rem; + } + .progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--neon-cyan), var(--neon-purple)); + box-shadow: 0 0 10px var(--neon-cyan-glow); + transition: width 0.35s ease; + } + .metric-tile__body { flex: 1; min-width: 0; } + .dl-bundle__traffic { + margin: 0.35rem 0 0.65rem; + font-size: 0.78rem; + color: var(--muted); + } + .dl-bundle__traffic .traffic-key-label { color: #b8f4ff; } + details.tech-details { margin-top: 0.5rem; font-size: 0.8rem; } + details.tech-details summary { + cursor: pointer; + color: var(--muted); + user-select: none; + list-style: none; + } + details.tech-details summary::-webkit-details-marker { display: none; } + details.tech-details pre { margin-top: 0.45rem; max-height: 130px; } + + .empty-state { + text-align: center; + padding: 1.5rem 0.5rem; + } + .empty-state svg { width: 48px; height: 48px; color: var(--muted); opacity: 0.6; margin-bottom: 0.75rem; } + + .hint-wait { + border-left: 2px solid var(--neon-cyan); + padding: 0.5rem 0 0.5rem 0.75rem; + margin: 0.75rem 0 0; + font-size: 0.8rem; + color: var(--muted); + line-height: 1.45; + font-family: var(--font-mono); + box-shadow: -4px 0 12px rgba(0, 240, 255, 0.08); + } + + .dl-actions { display: flex; flex-direction: column; gap: 0.65rem; margin: 0.85rem 0 1rem; } + .btn-dl { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.95rem 1rem; + border-radius: 12px; + font-weight: 700; + font-size: 1.05rem; + text-decoration: none; + border: none; + cursor: pointer; + transition: transform 0.12s ease, filter 0.12s ease; + } + .btn-dl:active { transform: scale(0.98); } + .btn-dl--primary { + border: 1px solid rgba(0, 255, 157, 0.45); + background: linear-gradient(180deg, rgba(0, 255, 157, 0.22) 0%, rgba(0, 200, 120, 0.12) 100%); + color: #fff; + font-family: var(--font-display); + font-size: 0.85rem; + letter-spacing: 0.06em; + text-transform: uppercase; + box-shadow: 0 0 24px rgba(0, 255, 157, 0.15); + } + .btn-dl--primary:hover { box-shadow: 0 0 32px rgba(0, 255, 157, 0.28); border-color: var(--ok); } + .btn-dl--secondary { + border: 1px solid var(--card-edge2); + background: var(--bg2); + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.8rem; + letter-spacing: 0.04em; + } + .btn-dl--secondary:hover { border-color: var(--neon-cyan); color: var(--neon-cyan); } + .btn-dl--zip { + border: 1px solid rgba(178, 77, 255, 0.45); + background: linear-gradient(180deg, rgba(178, 77, 255, 0.22) 0%, rgba(120, 40, 200, 0.12) 100%); + color: #fff; + font-family: var(--font-display); + letter-spacing: 0.06em; + text-transform: uppercase; + box-shadow: 0 0 24px rgba(178, 77, 255, 0.15); + } + .btn-dl--zip:hover { box-shadow: 0 0 32px rgba(178, 77, 255, 0.28); } + .btn-dl svg { width: 22px; height: 22px; flex-shrink: 0; } + + .conf-editor-wrap { margin-top: 0.75rem; } + .conf-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.45rem; } + .btn-copy { + padding: 0.45rem 0.85rem; + border-radius: var(--radius); + border: 1px solid rgba(0, 240, 255, 0.35); + background: var(--neon-cyan-dim); + color: var(--neon-cyan); + font-family: var(--font-mono); + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: box-shadow 0.2s, border-color 0.2s; + } + .btn-copy:hover { box-shadow: 0 0 12px rgba(0, 240, 255, 0.15); border-color: var(--neon-cyan); } + .btn-copy.is-done { color: var(--ok); border-color: rgba(0, 255, 157, 0.45); } + textarea.conf-raw { + width: 100%; + min-height: 200px; + max-height: 360px; + padding: 0.85rem 1rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: var(--bg); + color: var(--neon-cyan); + font-family: var(--font-mono); + font-size: 0.8rem; + line-height: 1.5; + resize: vertical; + tab-size: 4; + } + + .guide-card { + margin-top: 1.25rem; + padding: 1rem 1rem 1rem 1.15rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: var(--bg2); + } + .guide-card summary { + cursor: pointer; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--neon-cyan); + list-style: none; + } + .guide-card summary::-webkit-details-marker { display: none; } + .guide-steps { + margin: 0.85rem 0 0 0; + padding-left: 1.15rem; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.55; + } + .guide-steps li { margin-bottom: 0.5rem; } + .guide-steps strong { color: #cbd5e1; } + + .share-busy { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + } + .share-busy[hidden] { display: none !important; } + .share-busy__backdrop { + position: absolute; + inset: 0; + background: rgba(5, 6, 15, 0.92); + backdrop-filter: blur(8px); + } + .share-busy__panel { + position: relative; + max-width: 360px; + padding: 1.75rem 1.5rem; + border-radius: var(--radius); + background: var(--card); + border: 1px solid rgba(0, 240, 255, 0.35); + box-shadow: 0 0 40px rgba(0, 240, 255, 0.12), var(--shadow); + text-align: center; + } + .share-busy__panel::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, var(--neon-cyan), var(--neon-pink)); + opacity: 0.5; + } + .share-busy__ring { + width: 52px; + height: 52px; + margin: 0 auto 1rem; + border-radius: 50%; + border: 3px solid rgba(0, 240, 255, 0.15); + border-top-color: var(--neon-cyan); + animation: busy-spin 0.85s linear infinite; + box-shadow: 0 0 16px rgba(0, 240, 255, 0.2); + } + @keyframes busy-spin { to { transform: rotate(360deg); } } + .share-busy__dots { + display: flex; + justify-content: center; + gap: 6px; + margin: 1rem 0 0.5rem; + } + .share-busy__dots span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--neon-cyan); + box-shadow: 0 0 8px var(--neon-cyan-glow); + animation: busy-bounce 1.2s ease-in-out infinite; + } + .share-busy__dots span:nth-child(2) { animation-delay: 0.15s; } + .share-busy__dots span:nth-child(3) { animation-delay: 0.3s; } + @keyframes busy-bounce { + 0%, 80%, 100% { transform: translateY(0); opacity: 0.5; } + 40% { transform: translateY(-8px); opacity: 1; } + } + .share-busy__title { + margin: 0; + font-family: var(--font-display); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--neon-cyan); + text-shadow: 0 0 12px var(--neon-cyan-glow); + } + .share-busy__step { margin: 0.5rem 0 0; font-size: 0.85rem; font-family: var(--font-mono); color: var(--neon-cyan); min-height: 1.35em; } + .share-busy__hint { margin: 0.85rem 0 0; font-size: 0.8rem; color: var(--muted); line-height: 1.45; } + + .migrate-card { + margin: 1rem 0 1.25rem; + border: 1px solid rgba(255, 226, 61, 0.35); + border-radius: var(--radius); + background: linear-gradient(135deg, var(--neon-yellow-dim) 0%, var(--bg2) 100%); + overflow: hidden; + transition: border-color .25s, box-shadow .25s; + } + .migrate-card:hover { + border-color: rgba(255, 226, 61, 0.6); + box-shadow: 0 0 24px rgba(255, 226, 61, 0.1); + } + .migrate-toggle { + cursor: pointer; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.9rem 1rem; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--neon-yellow); + list-style: none; + user-select: none; + transition: background .2s, color .2s; + text-shadow: 0 0 10px rgba(255, 226, 61, 0.35); + } + .migrate-toggle::-webkit-details-marker { display: none; } + .migrate-toggle svg { width: 20px; height: 20px; flex-shrink: 0; color: var(--neon-yellow); filter: drop-shadow(0 0 6px rgba(255, 226, 61, 0.5)); } + .migrate-toggle:hover { background: var(--neon-yellow-dim); color: #fff; } + .migrate-toggle::after { + content: '\25B6'; margin-left: auto; font-size: .65rem; color: rgba(255, 226, 61, 0.6); + transition: transform .2s; + } + .migrate-card[open] .migrate-toggle::after { transform: rotate(90deg); } + .migrate-card[open] .migrate-toggle { + border-bottom: 1px solid rgba(255, 226, 61, 0.25); + } + .migrate-form { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .migrate-step { + padding: 0.75rem 0.8rem; + border: 1px solid rgba(51, 65, 85, 0.55); + border-radius: var(--radius); + background: rgba(8, 12, 28, 0.4); + } + + .migrate-step__num { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.35rem; + height: 1.35rem; + padding: 0 0.25rem; + border-radius: 0.3rem; + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + color: var(--neon-yellow); + background: rgba(255, 226, 61, 0.12); + border: 1px solid rgba(255, 226, 61, 0.35); + } + + .migrate-step--proto.is-locked .migrate-proto-body { + opacity: 0.38; + pointer-events: none; + filter: grayscale(0.35); + } + + .migrate-step--proto.is-locked .migrate-step__lock-hint { + display: block; + } + + .migrate-step__lock-hint { + display: none; + margin: 0 0 0.55rem; + font-size: 0.76rem; + color: var(--neon-yellow); + font-family: var(--font-mono); + } + + .migrate-pick-hint { + margin: 0 0 0.45rem; + font-size: 0.72rem; + color: var(--text3); + font-family: var(--font-mono); + } + + .server-list--migrate { + max-height: 11rem; + gap: 0.35rem; + } + + .server-item--btn { + width: 100%; + margin: 0; + padding: 0.65rem 0.75rem; + font: inherit; + color: inherit; + text-align: left; + appearance: none; + -webkit-appearance: none; + } + + .migrate-picked { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem 0.75rem; + margin-bottom: 0.35rem; + } + + .migrate-picked__card { + flex: 1 1 12rem; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0.65rem; + border-radius: var(--radius); + border: 1px solid rgba(0, 240, 255, 0.35); + background: rgba(0, 240, 255, 0.06); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.08); + } + + .migrate-picked__info { + flex: 1; + min-width: 0; + } + + .migrate-picked__name { + display: block; + font-weight: 600; + font-size: 0.88rem; + color: #e2e8f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .migrate-picked__badges { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.2rem; + } + + .migrate-picked__change { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.7rem; + border-radius: var(--radius); + border: 1px solid rgba(255, 226, 61, 0.35); + background: rgba(255, 226, 61, 0.08); + color: var(--neon-yellow); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + } + + .migrate-picked__change:hover { + border-color: rgba(255, 226, 61, 0.55); + background: rgba(255, 226, 61, 0.14); + color: #fff; + } + + .migrate-step--server.is-picked .migrate-pick-panel, + .migrate-step--server.is-picked .migrate-pick-hint, + .migrate-step--server.is-picked .migrate-servers-load { + display: none !important; + } + + .migrate-warning { + display: flex; + align-items: flex-start; + gap: 0.45rem; + margin: 0 0 0.65rem; + line-height: 1.45; + } + + .migrate-warning i { + flex-shrink: 0; + margin-top: 0.1rem; + color: var(--neon-yellow); + } + + .migrate-empty { + margin: 0; + padding: 0.85rem 0.5rem; + text-align: center; + font-size: 0.82rem; + color: var(--muted); + } + + .migrate-step--submit { + padding-top: 0.65rem; + border: none; + background: transparent; + padding-left: 0; + padding-right: 0; + } + + .migrate-step--submit .migrate-submit { + margin-top: 0; + } + + #proto-warn { + display: none; + margin-top: 0.5rem; + padding: 0.55rem 0.75rem; + background: var(--neon-yellow-dim); + border: 1px solid rgba(255, 226, 61, 0.35); + border-radius: var(--radius); + font-size: 0.82rem; + font-family: var(--font-mono); + color: var(--neon-yellow); + } + .badge-file--awg { + display: inline-block; + padding: 0.35rem 0.6rem; + background: var(--neon-purple-dim) !important; + color: var(--neon-purple) !important; + border: 1px solid rgba(178, 77, 255, 0.4) !important; + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .hint--warn { color: var(--neon-yellow) !important; font-family: var(--font-mono); font-size: 0.78rem !important; } + + .lang-switch { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.35rem; + margin-bottom: 1rem; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .lang-switch__label { color: var(--text3); margin-right: 0.25rem; } + .lang-switch a { + padding: 0.35rem 0.65rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + color: var(--muted); + text-decoration: none; + transition: border-color 0.2s, color 0.2s, box-shadow 0.2s; + } + .lang-switch a:hover { + border-color: rgba(0, 240, 255, 0.4); + color: var(--neon-cyan); + } + .lang-switch a.is-active { + border-color: var(--neon-cyan); + color: var(--neon-cyan); + background: var(--neon-cyan-dim); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.12); + } + + .status-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem 0.75rem; + margin: 0 0 1.1rem; + padding: 0.55rem 0.85rem; + border-radius: var(--radius); + border: 1px solid rgba(56, 189, 248, 0.28); + background: rgba(56, 189, 248, 0.06); + font-size: 0.84rem; + } + .status-banner a { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #7dd3fc; + font-weight: 600; + text-decoration: none; + } + .status-banner a:hover { color: var(--neon-cyan); text-decoration: underline; } + .status-banner svg { width: 16px; height: 16px; flex-shrink: 0; } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + +/* Bootstrap 5 + Font Awesome integration */ +.share-app .share-icon { + width: 1.1em; + text-align: center; + flex-shrink: 0; +} +.share-app .hero-icon i { + font-size: 1.75rem; + color: var(--neon-cyan); + filter: drop-shadow(0 0 8px var(--neon-cyan-glow)); +} +.share-app .field-label i, +.share-app .stat > i, +.share-app .alert i.share-icon-lg { + color: var(--neon-cyan); + font-size: 1.1rem; +} +.share-app .alert i.share-icon-lg { margin-top: 0.1rem; } +.share-app .card.share-card { + background: var(--card); + border: 1px solid var(--card-edge); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.share-app .alert.alert--err { + background: var(--err-bg); + border-color: rgba(255, 56, 96, 0.45); + color: #fecdd3; +} +.share-app .alert.alert--ok { + background: var(--ok-bg); + border-color: rgba(0, 255, 157, 0.35); + color: #a7f3d0; +} +.share-app #share-submit.btn-primary, +.share-app .btn-primary.share-btn-submit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + font-family: var(--font-display); + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid rgba(0, 240, 255, 0.5); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(178, 77, 255, 0.15)); + color: var(--neon-cyan); + box-shadow: 0 0 20px rgba(0, 240, 255, 0.15); +} +.share-app #share-submit.btn-primary:hover:not(:disabled) { + border-color: var(--neon-cyan); + color: #fff; + box-shadow: 0 0 28px rgba(0, 240, 255, 0.3); +} +.share-app .btn-dl { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.share-app .proto-ico.fa-solid { font-size: 1.15rem; } +.share-app .proto-ico--awg { color: var(--neon-yellow); } +.share-app .proto-ico--vless { color: #38bdf8; } +.share-app .server-search-field > i { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + pointer-events: none; +} +.share-app .lang-switch .btn, +.share-app .lang-switch__btn { font-size: 0.78rem; padding: 0.32rem 0.6rem; } +.share-app .status-banner .fa-solid { color: #7dd3fc; } +.share-app .empty-state > i { + font-size: 2.5rem; + color: var(--muted); + margin-bottom: 0.75rem; + display: block; +} +.share-app .share-busy__panel .fa-spinner { font-size: 2rem; color: var(--neon-cyan); } +.share-app .server-flag i { font-size: 1.25rem; color: var(--muted); } +.share-app .server-check i { font-size: 0.85rem; } +.share-app .configs-anchor i { font-size: 0.75rem; } +.share-app textarea.conf-raw.form-control, +.share-app .conf-raw { + font-family: var(--font-mono); +} + +.share-renewal { + border: 1px solid rgba(56, 189, 248, 0.28); + background: linear-gradient(135deg, rgba(56, 189, 248, 0.08) 0%, rgba(15, 23, 42, 0.55) 100%); +} + +.share-renewal--compact { + margin: 0 0 1.25rem; + padding: 1rem 0 0; + border: none; + border-top: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 0; + background: transparent; +} + +.share-renewal--compact .h6 { + font-size: 0.92rem; + margin-bottom: 0.35rem; +} + +.share-renewal-form__row { + flex-wrap: wrap; +} + +@media (min-width: 576px) { + .share-renewal-form__row { + flex-wrap: nowrap; + } +} + +.share-renewal .h6 { + color: #e2e8f0; + font-weight: 600; +} + +.share-renewal-form .form-control { + letter-spacing: 0.08em; +} + +.share-renewal-form .btn-primary { + white-space: nowrap; +} + +/* ── Cyber shell & panels ── */ +.cyber-bg { + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; + background: + radial-gradient(ellipse 70% 45% at 50% -15%, rgba(0, 240, 255, 0.14), transparent 58%), + radial-gradient(ellipse 45% 35% at 100% 40%, rgba(255, 45, 123, 0.07), transparent 52%), + radial-gradient(ellipse 40% 30% at 0% 85%, rgba(178, 77, 255, 0.08), transparent 48%); +} + +.cyber-shell { + position: relative; + padding: 1rem 1.1rem 1.5rem; + border: 1px solid rgba(0, 240, 255, 0.14); + border-radius: 8px; + background: linear-gradient(165deg, rgba(13, 16, 37, 0.92) 0%, rgba(8, 10, 24, 0.96) 100%); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 1px rgba(0, 240, 255, 0.25); +} + +.cyber-corner { + position: absolute; + width: 14px; + height: 14px; + border-color: var(--neon-cyan); + border-style: solid; + opacity: 0.55; + pointer-events: none; +} +.cyber-corner--tl { top: 6px; left: 6px; border-width: 2px 0 0 2px; } +.cyber-corner--tr { top: 6px; right: 6px; border-width: 2px 2px 0 0; } +.cyber-corner--bl { bottom: 6px; left: 6px; border-width: 0 0 2px 2px; } +.cyber-corner--br { bottom: 6px; right: 6px; border-width: 0 2px 2px 0; } + +.cyber-hero { text-align: center; margin-bottom: 1.35rem; } + +.share-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + margin-bottom: 1rem; +} + +.stat__value--sm { + font-size: 0.85rem !important; + line-height: 1.25; +} + +.share-panel { + padding: 0; + margin-bottom: 1rem; + overflow: hidden; + border-color: rgba(0, 240, 255, 0.18); + background: linear-gradient(160deg, rgba(13, 16, 37, 0.98) 0%, rgba(17, 22, 51, 0.9) 100%); +} + +.share-panel__head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.85rem; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--neon-cyan); + background: rgba(0, 240, 255, 0.05); + border-bottom: 1px solid rgba(0, 240, 255, 0.12); +} + +.share-panel__head i { + color: var(--neon-cyan); + filter: drop-shadow(0 0 6px var(--neon-cyan-glow)); +} + +.share-panel__head--pink { + color: var(--neon-pink); + background: rgba(255, 45, 123, 0.05); + border-bottom-color: rgba(255, 45, 123, 0.15); +} + +.share-panel__head--pink i { + color: var(--neon-pink); + filter: drop-shadow(0 0 6px var(--neon-pink-glow)); +} + +.share-panel__body { + padding: 1rem 1rem 1.1rem; +} + +.share-panel__intro { + margin: 0 0 1rem; + font-size: 0.84rem; +} + +.share-panel.card--download { + margin-top: 0.5rem; +} + +.dl-bundle { + padding: 0.85rem 0.9rem; + margin-bottom: 0.75rem; + border: 1px solid rgba(0, 240, 255, 0.1); + border-radius: var(--radius); + background: rgba(8, 12, 28, 0.55); +} + +.dl-bundle:last-child { + margin-bottom: 0; +} + +.share-footer { + margin-top: 1.5rem; + text-align: center; +} + +.share-footer__line { + display: block; + height: 1px; + margin-bottom: 0.65rem; + background: linear-gradient(90deg, transparent, rgba(0, 240, 255, 0.35), rgba(255, 45, 123, 0.35), transparent); +} + +.share-footer__text { + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text3); +} + +.cyber-spinner { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid rgba(0, 240, 255, 0.15); + border-top-color: var(--neon-cyan); + border-radius: 50%; + animation: spin 0.7s linear infinite; + box-shadow: 0 0 8px rgba(0, 240, 255, 0.2); + flex-shrink: 0; +} + +/* Lang switch — без Bootstrap */ +.lang-switch { + justify-content: flex-end; + margin-bottom: 1.1rem; +} + +.lang-switch__label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-right: 0.15rem; + color: var(--text3); +} + +.lang-switch__label i { + color: var(--neon-cyan); + opacity: 0.85; +} + +.lang-switch__btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.1rem; + padding: 0.32rem 0.6rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + color: var(--muted); + text-decoration: none; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + transition: border-color 0.2s, color 0.2s, box-shadow 0.2s, background 0.2s; +} + +.lang-switch__btn:hover { + border-color: rgba(0, 240, 255, 0.45); + color: var(--neon-cyan); + background: rgba(0, 240, 255, 0.06); +} + +.lang-switch__btn.is-active { + border-color: var(--neon-cyan); + color: var(--neon-cyan); + background: var(--neon-cyan-dim); + box-shadow: 0 0 14px rgba(0, 240, 255, 0.15); +} + +/* Bootstrap neutralize inside guest app */ +.share-app .form-control, +.share-app .form-select, +.share-app textarea.conf-raw { + background: var(--bg2) !important; + border: 1px solid var(--card-edge) !important; + color: var(--text) !important; + border-radius: var(--radius); + box-shadow: none; +} + +.share-app .form-control:focus, +.share-app .form-select:focus { + border-color: rgba(0, 240, 255, 0.5) !important; + box-shadow: 0 0 0 2px rgba(0, 240, 255, 0.12) !important; +} + +.share-app .btn-outline-secondary, +.share-app .btn-outline-info { + border-color: rgba(0, 240, 255, 0.3); + color: var(--neon-cyan); + background: transparent; + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.04em; +} + +.share-app .btn-outline-secondary:hover, +.share-app .btn-outline-info:hover { + border-color: var(--neon-cyan); + color: #fff; + background: rgba(0, 240, 255, 0.08); +} + +.share-app .text-secondary { color: var(--muted) !important; } +.share-app .text-info { color: var(--neon-cyan) !important; } + +.share-app .share-renewal .h6 { + font-family: var(--font-display); + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--neon-cyan); +} + +.share-app .share-renewal .form-label { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text3); +} + +.share-app .share-renewal-form .btn-primary { + font-family: var(--font-display); + letter-spacing: 0.06em; + text-transform: uppercase; + border: 1px solid rgba(0, 240, 255, 0.45); + background: linear-gradient(135deg, rgba(0, 240, 255, 0.18), rgba(178, 77, 255, 0.12)); + color: var(--neon-cyan); +} + +.share-app .share-renewal-form .btn-primary:hover { + color: #fff; + border-color: var(--neon-cyan); + box-shadow: 0 0 20px rgba(0, 240, 255, 0.2); +} + +@media (max-width: 420px) { + .cyber-shell { + padding: 0.85rem 0.75rem 1.25rem; + } + + .share-stats { + grid-template-columns: 1fr; + } +} + +/* ── Downloads & FAQ footer ── */ +.share-downloads, +.share-faq-page { + margin-top: 1.25rem; +} + +.guest-back-link { + margin: 0 0 1rem; +} + +.guest-back-link a { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + color: #7dd3fc; + text-decoration: none; +} + +.guest-back-link a:hover { + color: var(--neon-cyan); +} + +.cyber-hero--compact { + margin-bottom: 1rem; +} + +.hero-icon--sm { + width: 52px; + height: 52px; + margin-bottom: 0.65rem; +} + +.hero-icon--sm i { + font-size: 1.35rem !important; +} + +.share-faq-page .share-panel__body { + padding-top: 0.85rem; +} + +.share-faq-teaser { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + margin: 1rem 0 0; + padding-top: 0.85rem; + border-top: 1px solid rgba(51, 65, 85, 0.55); +} + +.share-faq-teaser__text { + font-size: 0.8rem; + color: var(--muted); +} + +.share-faq-teaser__btn { + margin: 0; + font-size: 0.72rem; + padding: 0.45rem 0.85rem; +} + +.dl-apps { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.dl-app-card { + padding: 0.85rem 0.9rem; + border-radius: var(--radius); + border: 1px solid var(--card-edge); + background: rgba(8, 12, 28, 0.55); +} + +.dl-app-card--wg { + border-color: rgba(0, 255, 157, 0.2); + background: linear-gradient(135deg, rgba(0, 255, 157, 0.04) 0%, rgba(8, 12, 28, 0.55) 100%); +} + +.dl-app-card--amnezia { + border-color: rgba(178, 77, 255, 0.25); + background: linear-gradient(135deg, rgba(178, 77, 255, 0.06) 0%, rgba(8, 12, 28, 0.55) 100%); +} + +.dl-app-card--vless { + border-color: rgba(56, 189, 248, 0.22); + background: linear-gradient(135deg, rgba(56, 189, 248, 0.05) 0%, rgba(8, 12, 28, 0.55) 100%); +} + +.dl-app-card__icon { + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.45rem; + border-radius: var(--radius); + background: rgba(0, 240, 255, 0.08); + border: 1px solid rgba(0, 240, 255, 0.15); + color: var(--neon-cyan); + font-size: 0.95rem; +} + +.dl-app-card--wg .dl-app-card__icon { + color: var(--ok); + border-color: rgba(0, 255, 157, 0.25); + background: rgba(0, 255, 157, 0.08); +} + +.dl-app-card--amnezia .dl-app-card__icon { + color: var(--neon-purple); + border-color: rgba(178, 77, 255, 0.3); + background: var(--neon-purple-dim); +} + +.dl-app-card--vless .dl-app-card__icon { + color: #7dd3fc; + border-color: rgba(56, 189, 248, 0.3); + background: rgba(56, 189, 248, 0.08); +} + +.dl-app-card__title { + margin: 0 0 0.35rem; + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #e2e8f0; +} + +.dl-app-card__desc { + margin: 0 0 0.65rem; + font-size: 0.8rem; + line-height: 1.45; + color: var(--muted); +} + +.dl-app-card__links { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.dl-app-link { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.42rem 0.65rem; + border-radius: var(--radius); + border: 1px solid rgba(0, 240, 255, 0.22); + background: rgba(0, 240, 255, 0.05); + color: var(--neon-cyan); + font-family: var(--font-mono); + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.03em; + text-decoration: none; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.dl-app-link:hover { + color: #fff; + border-color: var(--neon-cyan); + background: rgba(0, 240, 255, 0.1); + box-shadow: 0 0 14px rgba(0, 240, 255, 0.12); +} + +.dl-app-link--muted { + border-color: rgba(100, 116, 139, 0.35); + background: transparent; + color: #94a3b8; +} + +.dl-app-link--muted:hover { + color: var(--neon-cyan); + border-color: rgba(0, 240, 255, 0.35); +} + +.dl-app-link i { + width: 1rem; + text-align: center; + flex-shrink: 0; +} + +.faq-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.faq-item { + border: 1px solid rgba(51, 65, 85, 0.65); + border-radius: var(--radius); + background: rgba(8, 12, 28, 0.45); + overflow: hidden; + transition: border-color 0.15s; +} + +.faq-item[open] { + border-color: rgba(0, 240, 255, 0.22); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.05); +} + +.faq-item__q { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.65rem 0.75rem; + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + line-height: 1.4; + color: #e2e8f0; + cursor: pointer; + list-style: none; + user-select: none; +} + +.faq-item__q::-webkit-details-marker { + display: none; +} + +.faq-item__chev { + flex-shrink: 0; + margin-top: 0.15rem; + font-size: 0.65rem; + color: var(--neon-pink); + transition: transform 0.2s; +} + +.faq-item[open] .faq-item__chev { + transform: rotate(90deg); +} + +.faq-item__a { + padding: 0 0.75rem 0.75rem 1.9rem; + font-size: 0.8rem; + line-height: 1.55; + color: var(--muted); +} + +.faq-item__a a { + color: var(--neon-cyan); + text-decoration: underline; + text-underline-offset: 2px; +} + +.faq-item__a a:hover { + color: #fff; +} + +.faq-item__a strong { + color: #cbd5e1; +} + +/* ── Rules / AUP page ── */ +.share-app--rules .wrap, +.share-app--faq .wrap { + max-width: 560px; +} + +.guest-subnav { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: -0.35rem 0 1rem; +} + +.guest-subnav a { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.65rem; + border-radius: var(--radius); + border: 1px solid rgba(100, 116, 139, 0.35); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + color: #94a3b8; + text-decoration: none; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.guest-subnav a:hover { + color: var(--neon-cyan); + border-color: rgba(0, 240, 255, 0.35); + background: rgba(0, 240, 255, 0.06); +} + +.share-rules-page .share-panel__body { + padding-top: 0.85rem; +} + +.rules-doc__notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0 0 1rem; + padding: 0.65rem 0.75rem; + border-radius: var(--radius); + border: 1px solid rgba(255, 56, 96, 0.35); + background: rgba(255, 56, 96, 0.08); + font-size: 0.82rem; + line-height: 1.45; + color: #fecdd3; +} + +.rules-doc__notice i { + flex-shrink: 0; + margin-top: 0.1rem; + color: #fb7185; +} + +.rules-block { + margin-bottom: 0.85rem; + padding: 0.85rem 0.9rem; + border-radius: var(--radius); + border: 1px solid rgba(51, 65, 85, 0.65); + background: rgba(8, 12, 28, 0.45); +} + +.rules-block:last-of-type { + margin-bottom: 0.5rem; +} + +.rules-block--cyan { border-color: rgba(0, 240, 255, 0.18); } +.rules-block--pink { border-color: rgba(255, 45, 123, 0.22); } +.rules-block--yellow { border-color: rgba(255, 226, 61, 0.28); } +.rules-block--purple { border-color: rgba(178, 77, 255, 0.22); } + +.rules-block__title { + display: flex; + align-items: center; + gap: 0.45rem; + margin: 0 0 0.55rem; + font-family: var(--font-display); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #e2e8f0; +} + +.rules-block--cyan .rules-block__title i { color: var(--neon-cyan); } +.rules-block--pink .rules-block__title i { color: var(--neon-pink); } +.rules-block--yellow .rules-block__title i { color: var(--neon-yellow); } +.rules-block--purple .rules-block__title i { color: var(--neon-purple); } + +.rules-block__body { + font-size: 0.8rem; + line-height: 1.55; + color: var(--muted); +} + +.rules-block__body p { + margin: 0 0 0.55rem; +} + +.rules-block__body p:last-child { + margin-bottom: 0; +} + +.rules-list { + margin: 0.35rem 0 0; + padding-left: 1.1rem; +} + +.rules-list li { + margin-bottom: 0.45rem; + padding-left: 0.15rem; +} + +.rules-list li:last-child { + margin-bottom: 0; +} + +.rules-list strong { + color: #e2e8f0; +} + +.rules-block__body strong { + color: #cbd5e1; +} + +.rules-doc__updated { + margin: 0.85rem 0 0; + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text3); + text-align: center; +} + +.share-faq-teaser--rules { + margin-top: 0.55rem; + padding-top: 0.55rem; + border-top: 1px dashed rgba(51, 65, 85, 0.55); +} + +.share-faq-teaser__btn--rules { + border-color: rgba(255, 45, 123, 0.35); + color: var(--neon-pink); +} + +.share-faq-teaser__btn--rules:hover { + border-color: var(--neon-pink); + color: #fff; + box-shadow: 0 0 14px rgba(255, 45, 123, 0.15); +} diff --git a/Go/web/templates/admin/configs.html b/Go/web/templates/admin/configs.html new file mode 100644 index 0000000..f04d7e0 --- /dev/null +++ b/Go/web/templates/admin/configs.html @@ -0,0 +1,56 @@ +{{define "page_admin_configs"}} +{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Конфигурации"}} +{{template "admin_flash" .}} + +
+

Фильтр

+
+
+ + +
+
+ + +
+ + Сбросить +
+
+ +
+

Конфигурации ({{len .Rows}}{{if .Truncated}}+{{end}})

+ {{if .Truncated}}

Показаны не все записи — сузьте фильтр по ссылке или серверу.

{{end}} + {{if not .Rows}} +

Ничего не найдено.

+ {{else}} +
+ + + + {{range .Rows}} + + + + + + + + + + {{end}} + +
IDСсылкаСерверПротоколИмяСоздана
#{{.ID}}#{{.LinkID}}{{.ServerID}}{{.Protocol}}{{.ConnectionName}}{{.CreatedAt.Format "2006-01-02 15:04"}} +
+ {{template "admin_csrf_field"}} + + + + +
+
+
+ {{end}} +
+{{template "admin_shell_close" .}} +{{end}} diff --git a/Go/web/templates/admin/index.html b/Go/web/templates/admin/index.html new file mode 100644 index 0000000..ca232ea --- /dev/null +++ b/Go/web/templates/admin/index.html @@ -0,0 +1,39 @@ +{{define "page_admin_index"}} +{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Дашборд"}} +{{template "admin_flash" .}} + +{{if .Maintenance}}
Включён режим технических работ. Отключить в настройках
{{end}} + +
+
{{.TotalLinks}}Всего ссылок
+
{{.ActiveLinks}}Активные
+
{{.ExpiredLinks}}Истёкшие
+
{{.CleanedLinks}}Очищенные
+
+
+
{{.LimitLinks}}Лимит исчерпан
+
{{.CodesCount}}Кодов продления
+
+ +
+ {{if .PanelOK}}OK{{else if .PanelURL}}Ошибка{{else}}—{{end}} + Панель Amnezia +
+
+
+ +{{if .PanelErr}}
Панель недоступна: {{.PanelErr}}
{{end}} +{{if not .PanelURL}}
URL панели и токен не заданы. Перейти в настройки
{{end}} + + +{{template "admin_shell_close" .}} +{{end}} diff --git a/Go/web/templates/admin/links.html b/Go/web/templates/admin/links.html new file mode 100644 index 0000000..4ef4a5a --- /dev/null +++ b/Go/web/templates/admin/links.html @@ -0,0 +1,180 @@ +{{define "page_admin_links"}} +{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Ссылки"}} +{{template "admin_flash" .}} + +
+

Новая ссылка

+
+ {{template "admin_csrf_field"}} + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Список ссылок ({{.Total}})

+ + + {{if not .Links}} +

Ссылок не найдено.

+ {{else}} +
+ + + + {{range .Links}} + + + + + + + + + + {{end}} + +
IDТокенСтатусИспользованоСрокКонфигов
#{{.ID}}{{.Token}} + {{if eq2 .Status "active"}}активна{{end}} + {{if eq2 .Status "expired"}}истекла{{end}} + {{if eq2 .Status "limit"}}лимит{{end}} + {{if eq2 .Status "cleaned"}}очищена{{end}} + {{.UseCount}} / {{.MaxUses}}{{.ExpiresLabel}}{{.ActiveConfigs}} + +
+ {{template "admin_csrf_field"}} + + + +
+
+
+ +
+ {{if gt .Page 1}}« Назад{{end}} + Страница {{.Page}} + {{if gt .Total (mul .Page .PerPage)}}Вперёд »{{end}} +
+ {{end}} +
+ +{{if .Detail}} +
+

Ссылка #{{.Detail.ID}}

+

{{appURL "share"}}?k={{.Detail.Token}}

+ +
+ {{template "admin_csrf_field"}} + + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+ +
+
+ +
+ {{template "admin_csrf_field"}} + + + +
+ + +
+ +
+ + {{if .AllServers}} +
+ {{template "admin_csrf_field"}} + + + + +
+ {{$allowed := .AllowedServerIDs}} + {{range .AllServers}} +
+ + +
+ {{end}} +
+ +
+ {{end}} + +

Активные конфигурации ({{len .Creations}})

+ {{if not .Creations}}

Пока нет.

{{end}} + {{range .Creations}} +
+
+ #{{.ID}} · {{.ConnectionName}} · сервер {{.ServerID}} · {{.Protocol}} +
+ {{template "admin_csrf_field"}} + + + + + +
+
+
+ {{end}} + + {{if .DeletedCreations}} +

Удалённые ({{len .DeletedCreations}})

+ {{range .DeletedCreations}} +
+ #{{.ID}} · {{.ConnectionName}} · сервер {{.ServerID}} · {{.Protocol}} +
+ {{end}} + {{end}} +
+{{end}} +{{template "admin_shell_close" .}} +{{end}} diff --git a/Go/web/templates/admin/renewal.html b/Go/web/templates/admin/renewal.html new file mode 100644 index 0000000..ef297b0 --- /dev/null +++ b/Go/web/templates/admin/renewal.html @@ -0,0 +1,81 @@ +{{define "page_admin_renewal"}} +{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Коды продления"}} +{{template "admin_flash" .}} + +
+

Новый код

+
+ {{template "admin_csrf_field"}} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Список кодов ({{len .Codes}})

+ {{if not .Codes}} +

Кодов пока нет.

+ {{else}} +
+ + + + {{range .Codes}} + + + + + + + + + + + {{end}} + +
КодДнейАктивацийНазначениеСсылкаСрок кодаЗаметка
{{.Code}}{{.AddDays}}{{.UseCount}} / {{.MaxUses}}{{if eq2 .CodeTarget "member"}}кабинет{{else}}гость{{end}}{{if .ShareLinkID}}#{{.ShareLinkID}}{{else}}—{{end}}{{if .CodeExpiresAt}}{{.CodeExpiresAt.Format "2006-01-02"}}{{else}}—{{end}}{{if .Note}}{{.Note}}{{else}}—{{end}} +
+ {{template "admin_csrf_field"}} + + + +
+
+
+ {{end}} +
+{{template "admin_shell_close" .}} +{{end}} diff --git a/Go/web/templates/admin/servers.html b/Go/web/templates/admin/servers.html new file mode 100644 index 0000000..594c280 --- /dev/null +++ b/Go/web/templates/admin/servers.html @@ -0,0 +1,82 @@ +{{define "page_admin_servers"}} +{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Серверы"}} +{{template "admin_flash" .}} + +{{if .PanelErr}}
Панель недоступна: {{.PanelErr}}
{{end}} +{{if not .PanelReachable}}

Панель Amnezia сейчас не отвечает — список серверов основан на сохранённых названиях (panel_server_labels) и настройках.

{{end}} + +
+

Добавить сервер вручную

+
+ {{template "admin_csrf_field"}} + +
+ + +
+
+ + +
+ +
+
+ +
+

Серверы ({{len .Rows}})

+ {{if not .Rows}} +

Серверов пока нет. Задайте URL панели и токен в настройках, либо добавьте сервер вручную.

+ {{else}} +
+ {{template "admin_csrf_field"}} + + +
+ +
+ {{template "admin_csrf_field"}} + + {{range .Rows}}{{end}} + +
+ + + + {{range .Rows}} + {{$row := .}} + + + + + + + + + + + {{end}} + +
IDНазваниеПротоколыФлагСкоростьОткл.Панель
#{{.ID}} + {{range $.AllProtocols}} +
+ + +
+ {{end}} +
{{if .PanelName}}{{.PanelName}}{{else}}—{{end}} + +
+
+ +
+ +
+
+ {{end}} +
+{{template "admin_shell_close" .}} +{{end}} diff --git a/Go/web/templates/admin/settings.html b/Go/web/templates/admin/settings.html new file mode 100644 index 0000000..3e37333 --- /dev/null +++ b/Go/web/templates/admin/settings.html @@ -0,0 +1,80 @@ +{{define "page_admin_settings"}} +{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Настройки"}} +{{template "admin_flash" .}} + +{{if .TestResult}}
{{.TestResult}}
{{end}} +{{if .TestError}}
{{.TestError}}
{{end}} + +
+ {{template "admin_csrf_field"}} + +
+

Панель Amnezia

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Режим работы

+
+ + +
+
+ +
+

Вход через Pocket ID (OIDC)

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Личный кабинет — значения по умолчанию

+
+
+ + +
+
+ + +
+
+
+ +
+

Дополнительно (JSON)

+
+ + +
+
+ + +
+
+ + +
+{{template "admin_shell_close" .}} +{{end}} diff --git a/Go/web/templates/auth/install.html b/Go/web/templates/auth/install.html new file mode 100644 index 0000000..271f69a --- /dev/null +++ b/Go/web/templates/auth/install.html @@ -0,0 +1,47 @@ +{{define "page_install"}} +{{template "guest_shell_open" dict "Title" "Установка администратора" "Lang" "ru"}} +
+ + +
+
+
Cyber // Access
+

Установка

+

Создание единственной учётной записи администратора.

+
+ + {{if .DBError}} +
{{.DBError}}
+

Проверьте DATABASE_URL и что миграции применены.

+ {{else if .Installed}} +
Администратор уже создан.
+ Перейти ко входу + {{else}} + {{if .Error}}
{{.Error}}
{{end}} + +

После установки ограничьте доступ к /install на продакшене.

+ {{end}} +
+{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/auth/login.html b/Go/web/templates/auth/login.html new file mode 100644 index 0000000..46aa335 --- /dev/null +++ b/Go/web/templates/auth/login.html @@ -0,0 +1,42 @@ +{{define "page_login"}} +{{template "guest_shell_open" dict "Title" "Вход — Amnezia Admin" "Lang" "ru"}} +
+ + +
+
+
Cyber // Access
+

Amnezia Admin

+

Вход в панель управления

+
+ + {{if .Error}}
{{.Error}}
{{end}} + + + + {{if .NoAdmins}}

Нет учётных записей — установка

{{end}} +
+{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/cabinet/cabinet.html b/Go/web/templates/cabinet/cabinet.html new file mode 100644 index 0000000..2c08568 --- /dev/null +++ b/Go/web/templates/cabinet/cabinet.html @@ -0,0 +1,128 @@ +{{define "page_cabinet"}} +{{template "guest_shell_open" dict "Title" "Личный кабинет" "Lang" (lang)}} +
+ + +
+
+
{{t "hero_brand"}}
+

{{.Member.Username}}

+

Личный кабинет VPN

+
+ + + + {{template "guest_flash" .}} + + {{if .Maintenance}}
{{htmlSafe (t "maintenance_banner")}}
{{end}} + + + + {{if .Expired}} + + {{else}} + + {{if .LimitReached}} +
{{t "limit_reached" "max" (printf "%d" .Sub.MaxConfigs)}}
+ {{else}} + + {{end}} + {{end}} + + {{if .Bundles}} + + {{end}} + + {{if not .Expired}} + + {{end}} +
+{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/cabinet/login.html b/Go/web/templates/cabinet/login.html new file mode 100644 index 0000000..790f3d7 --- /dev/null +++ b/Go/web/templates/cabinet/login.html @@ -0,0 +1,57 @@ +{{define "page_cabinet_auth"}} +{{template "guest_shell_open" dict "Title" "Личный кабинет — вход" "Lang" (lang)}} +
+ + +
+
+
{{t "hero_brand"}}
+

Личный кабинет

+

Войдите или зарегистрируйтесь, чтобы получить свою подписку VPN.

+
+ + {{if .Error}}
{{.Error}}
{{end}} + + + + +
+{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/layouts/admin.html b/Go/web/templates/layouts/admin.html new file mode 100644 index 0000000..0153802 --- /dev/null +++ b/Go/web/templates/layouts/admin.html @@ -0,0 +1,67 @@ +{{/* Shared cyber-dark admin shell. Call "admin_shell_open" with a dict of + Active/Admin/Title, write page content, then "admin_shell_close". */}} +{{define "admin_shell_open"}} + + + + +{{.Title}} — Amnezia Admin + + + + + + + + + + + +
+
+
+

{{.Title}}

+
+
+ Go +
+
+
+{{end}} + +{{define "admin_shell_close"}} +
+
+ + + +{{end}} + +{{define "admin_flash"}} +{{if .OK}}
{{.OK}}
{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{end}} + +{{define "admin_csrf_field"}}{{end}} diff --git a/Go/web/templates/layouts/base.html b/Go/web/templates/layouts/base.html new file mode 100644 index 0000000..001372b --- /dev/null +++ b/Go/web/templates/layouts/base.html @@ -0,0 +1,56 @@ +{{/* Shared shell for guest-facing pages: share, faq, rules, status, login, + install, cabinet. Each page calls "guest_shell_open" with a dict of + Title/BodyClass/Lang, writes its own content, then "guest_shell_close". */}} +{{define "guest_shell_open"}} + + + + +{{.Title}} + + + + + + + + + + +
+{{end}} + +{{define "guest_shell_close"}} +
+ + + +{{end}} + +{{define "guest_flash"}} +{{if .OK}}{{end}} +{{if .Error}}{{end}} +{{end}} + +{{define "guest_lang_switch"}} +
+ + RU + EN +
+{{end}} + +{{define "guest_subnav"}} + +{{end}} + +{{define "guest_footer"}} + +{{end}} diff --git a/Go/web/templates/share/faq.html b/Go/web/templates/share/faq.html new file mode 100644 index 0000000..370f961 --- /dev/null +++ b/Go/web/templates/share/faq.html @@ -0,0 +1,23 @@ +{{define "page_faq"}} +{{template "guest_shell_open" dict "Title" (t "faq_page_title") "Lang" (lang)}} + +{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/share/rules.html b/Go/web/templates/share/rules.html new file mode 100644 index 0000000..5b7acf1 --- /dev/null +++ b/Go/web/templates/share/rules.html @@ -0,0 +1,47 @@ +{{define "page_rules"}} +{{template "guest_shell_open" dict "Title" (t "rules_page_title") "Lang" (lang)}} + +{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/share/share.html b/Go/web/templates/share/share.html new file mode 100644 index 0000000..b1d734c --- /dev/null +++ b/Go/web/templates/share/share.html @@ -0,0 +1,173 @@ +{{define "page_share"}} +{{template "guest_shell_open" dict "Title" (t "page_title") "Lang" (lang)}} +{{if .NotFound}} +
+
+

{{t "err_link_not_found"}}

+

{{t "err_bad_link"}}

+
+{{else}} +
+ + RU + EN +
+ +
+ + +
+
+
{{t "hero_brand"}}
+

{{t "hero_title"}}

+

{{t "hero_lead"}}

+
+ + {{template "guest_flash" .}} + + {{if .Maintenance}}
{{htmlSafe (t "maintenance_banner")}}
{{end}} + + + + {{if .Expired}} + + {{else}} + + {{if .Maintenance}} + + {{else if .LimitReached}} +
{{t "limit_reached" "max" (printf "%d" .Link.MaxUses)}}
+ {{else}} + + {{end}} + {{end}} + + {{if .Bundles}} + + {{end}} + + {{if not .Expired}} + + {{end}} + + {{template "guest_subnav" .}} +
+{{template "guest_footer" .}} +{{end}} +{{template "guest_shell_close" .}} +{{end}} diff --git a/Go/web/templates/share/status.html b/Go/web/templates/share/status.html new file mode 100644 index 0000000..fd79735 --- /dev/null +++ b/Go/web/templates/share/status.html @@ -0,0 +1,37 @@ +{{define "page_status"}} +{{template "guest_shell_open" dict "Title" (t "status_page_title") "Lang" (lang)}} + +{{template "guest_footer" .}} +{{template "guest_shell_close" .}} +{{end}}