Add Go rewrite with Postgres 17 and Dokploy Docker Compose
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
tmp/
|
||||
**/*_test.go
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
*.exe
|
||||
/bin/
|
||||
/tmp/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
+150
@@ -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, до реального деплоя.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("сервер остановлен.")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Package i18n provides bilingual (ru/en) string translation for the guest share
|
||||
// page and portal, plus helpers to resolve/persist the visitor's chosen language
|
||||
// via query string, session and cookie — mirroring includes/share_i18n.php.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
// Lang is a supported UI language.
|
||||
type Lang string
|
||||
|
||||
const (
|
||||
RU Lang = "ru"
|
||||
EN Lang = "en"
|
||||
)
|
||||
|
||||
// CookieName is the persistent language-preference cookie name (matches the PHP
|
||||
// implementation, so switching between the PHP and Go deployments keeps the same
|
||||
// preference).
|
||||
const CookieName = "share_lang"
|
||||
|
||||
// SessionKey is the scs session key used to persist the language for the current
|
||||
// session.
|
||||
const SessionKey = "share_lang"
|
||||
|
||||
// NormalizeLang maps an arbitrary string to a supported Lang, defaulting to
|
||||
// Russian for anything unrecognized (including empty).
|
||||
func NormalizeLang(raw string) Lang {
|
||||
if strings.EqualFold(strings.TrimSpace(raw), string(EN)) {
|
||||
return EN
|
||||
}
|
||||
return RU
|
||||
}
|
||||
|
||||
// LangFromRequest reads an explicit "lang" query/form parameter from r, if any.
|
||||
func LangFromRequest(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if v := r.URL.Query().Get("lang"); v != "" {
|
||||
return v
|
||||
}
|
||||
if r.Form != nil {
|
||||
if v := r.Form.Get("lang"); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CookieLang reads the persisted language cookie from r, if present.
|
||||
func CookieLang(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
c, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// SetCookie writes the language-preference cookie, valid for one year.
|
||||
func SetCookie(w http.ResponseWriter, lang Lang, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: string(lang),
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(365 * 24 * time.Hour),
|
||||
Secure: secure,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// FromSession reads the language stored in the scs session, if any (sm may be
|
||||
// nil, in which case "" is returned).
|
||||
func FromSession(ctx context.Context, sm *scs.SessionManager) string {
|
||||
if sm == nil {
|
||||
return ""
|
||||
}
|
||||
return sm.GetString(ctx, SessionKey)
|
||||
}
|
||||
|
||||
// SaveToSession stores lang in the scs session (sm may be nil, in which case this
|
||||
// is a no-op).
|
||||
func SaveToSession(ctx context.Context, sm *scs.SessionManager, lang Lang) {
|
||||
if sm == nil {
|
||||
return
|
||||
}
|
||||
sm.Put(ctx, SessionKey, string(lang))
|
||||
}
|
||||
|
||||
// Resolve picks the effective language for a request: explicit query/form
|
||||
// parameter first, then the scs session, then the persistent cookie, defaulting
|
||||
// to Russian. It does not write anything; call Persist afterwards to remember the
|
||||
// choice for subsequent requests.
|
||||
func Resolve(r *http.Request, sm *scs.SessionManager) Lang {
|
||||
if q := LangFromRequest(r); q != "" {
|
||||
return NormalizeLang(q)
|
||||
}
|
||||
if sess := FromSession(r.Context(), sm); sess != "" {
|
||||
return NormalizeLang(sess)
|
||||
}
|
||||
if c := CookieLang(r); c != "" {
|
||||
return NormalizeLang(c)
|
||||
}
|
||||
return RU
|
||||
}
|
||||
|
||||
// Persist stores lang in both the scs session and the cookie, so it "sticks"
|
||||
// across requests exactly like the PHP implementation's session+cookie combo.
|
||||
func Persist(w http.ResponseWriter, r *http.Request, sm *scs.SessionManager, lang Lang, secureCookie bool) {
|
||||
SaveToSession(r.Context(), sm, lang)
|
||||
SetCookie(w, lang, secureCookie)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
ru string
|
||||
en string
|
||||
}
|
||||
|
||||
// catalog holds the bilingual guest-facing strings for the share page, the
|
||||
// renewal-code form, the maintenance banner, the FAQ section titles, and a few
|
||||
// shared portal/status strings. Keys mirror includes/share_i18n.php so existing
|
||||
// front-end code/data can be ported without renaming.
|
||||
var catalog = map[string]entry{
|
||||
"page_title": {"SECURE LINK — VPN", "SECURE LINK — VPN"},
|
||||
"lang_ru": {"Русский", "Russian"},
|
||||
"lang_en": {"English", "English"},
|
||||
"lang_label": {"Язык", "Language"},
|
||||
|
||||
"err_bad_link": {"Некорректная ссылка.", "Invalid link."},
|
||||
"err_link_not_found": {"Ссылка не найдена.", "Link not found."},
|
||||
"err_service_unavailable": {"Сервис временно недоступен.", "Service is temporarily unavailable."},
|
||||
"err_link_expired_plain": {"Ссылка недействительна или срок истёк.", "Link is invalid or has expired."},
|
||||
"err_file_not_found": {"Файл не найден.", "File not found."},
|
||||
"err_csrf": {"Обновите страницу и попробуйте снова.", "Refresh the page and try again."},
|
||||
"err_config_not_found": {"Конфигурация не найдена.", "Configuration not found."},
|
||||
"err_pick_migrate_server": {"Выберите сервер для переноса.", "Select a server to migrate to."},
|
||||
"err_server_disabled": {"Этот сервер временно отключён. Выберите другой.", "This server is temporarily disabled. Choose another one."},
|
||||
"err_pick_server": {"Выберите место подключения из списка или обновите страницу.", "Select a location from the list or refresh the page."},
|
||||
"err_maintenance_mode": {
|
||||
"Сейчас ведутся технические работы. Создание и перенос конфигов временно недоступны — вы можете скачать или скопировать уже выданные конфиги ниже.",
|
||||
"Maintenance in progress. Creating and migrating configs is temporarily disabled — you can still download or copy your existing configs below.",
|
||||
},
|
||||
|
||||
"maintenance_banner": {
|
||||
"<strong>Технические работы.</strong> Новые конфиги и перенос на другой сервер временно недоступны. Уже выданные конфиги можно скачать и скопировать в блоке ниже.",
|
||||
"<strong>Maintenance.</strong> New configs and server migration are temporarily unavailable. You can still download and copy your existing configs below.",
|
||||
},
|
||||
"maintenance_card_title": {"Технические работы", "Maintenance"},
|
||||
"maintenance_card_body": {
|
||||
"Создание новых конфигов и перенос на другой сервер временно отключены. Если у вас уже есть конфигурации — прокрутите страницу вниз, чтобы скачать или скопировать их.",
|
||||
"Creating new configs and migrating to another server are temporarily disabled. If you already have configurations, scroll down to download or copy them.",
|
||||
},
|
||||
|
||||
"ok_created": {"Готово! Осталось созданий: {remaining} из {max}.", "Done! Configs remaining: {remaining} of {max}."},
|
||||
"ok_migrated": {"Конфигурация перенесена на новый сервер. Скачайте обновлённый файл ниже.", "Configuration migrated to a new server. Download the updated file below."},
|
||||
"ok_renewed": {"Подписка продлена на {days} дн. Новый срок: {until}.", "Subscription extended by {days} days. New validity: {until}."},
|
||||
|
||||
"renewal_title": {"Продлить подписку кодом", "Extend subscription with a code"},
|
||||
"renewal_hint": {"Введите одноразовый код от администратора. Каждый код можно активировать только один раз.", "Enter a one-time code from your administrator. Each code can only be used once."},
|
||||
"renewal_hint_short": {"Одноразовый код продлевает подписку.", "One-time code extends your subscription."},
|
||||
"renewal_code_label": {"Код продления", "Renewal code"},
|
||||
"renewal_code_ph": {"НАПРИМЕР ABCD1234", "E.G. ABCD1234"},
|
||||
"renewal_btn": {"Активировать", "Activate"},
|
||||
"renewal_err_invalid": {"Неверный код продления.", "Invalid renewal code."},
|
||||
"renewal_err_exhausted": {"Этот код уже использован.", "This code has already been used."},
|
||||
"renewal_err_code_expired": {"Срок действия кода истёк.", "This code has expired."},
|
||||
"renewal_err_wrong_link": {"Этот код не подходит к данной ссылке.", "This code does not apply to this link."},
|
||||
"renewal_err_already_used": {"Этот код уже был активирован для этой ссылки.", "This code was already activated for this link."},
|
||||
"renewal_err_member_only": {"Этот код для личного кабинета.", "This code is for the member portal."},
|
||||
"renewal_err_unavailable": {"Продление по коду временно недоступно.", "Code renewal is temporarily unavailable."},
|
||||
|
||||
"hero_brand": {"Cyber // Access", "Cyber // Access"},
|
||||
"hero_title": {"Ваш VPN", "Your VPN"},
|
||||
"hero_lead": {"Выберите узел и протокол — получите конфиг или QR для Amnezia VPN.", "Choose a node and protocol — get a config file or QR for Amnezia VPN."},
|
||||
|
||||
"all_configs": {"Все мои конфигурации ({count})", "All my configurations ({count})"},
|
||||
"sub_active": {"подписка до {until} · осталось {days} дн.", "subscription until {until} · {days} days left"},
|
||||
"sub_expired": {"срок подписки истёк ({until})", "subscription expired ({until})"},
|
||||
"expired_card": {"Срок ссылки истёк или доступ закрыт. Если у вас есть код продления — введите его ниже.", "This link has expired or access was closed. If you have a renewal code, enter it below."},
|
||||
"limit_reached": {"Создано максимально допустимое число конфигураций ({max}). Новый конфиг по этой ссылке создать нельзя.", "Maximum number of configurations created ({max}). You cannot create another config with this link."},
|
||||
"term_label": {"Срок:", "Valid until:"},
|
||||
"stat_remaining": {"Осталось", "Remaining"},
|
||||
"stat_of": {"из", "of"},
|
||||
"stat_valid_until": {"Действует до", "Valid until"},
|
||||
"stat_validity": {"Срок действия", "Validity"},
|
||||
"expires_from_first": {"с первого конфига — {days} дн.", "from first config — {days} days"},
|
||||
|
||||
"field_location": {"Где подключаться", "Where to connect"},
|
||||
"servers_loading": {"Подгружаем доступные точки…", "Loading available nodes…"},
|
||||
"field_protocol": {"Тип подключения", "Connection type"},
|
||||
"proto_wg_hint": {"Классический режим, быстро и стабильно", "Classic mode, fast and stable"},
|
||||
"proto_awg_hint": {"Если обычный VPN режут по пути", "When regular VPN is blocked en route"},
|
||||
"proto_vless_hint": {"Обход блокировок через Xray/VLESS", "Bypass blocks via Xray/VLESS"},
|
||||
"proto_unavailable": {"Недоступен на этом сервере", "Not available on this server"},
|
||||
|
||||
"proto_awg_notice_title": {"AmneziaWG 2.0 — требования к приложению", "AmneziaWG 2.0 — app requirements"},
|
||||
"proto_awg_notice_body": {
|
||||
"На сервере используется протокол <strong>AmneziaWG 2.0</strong>. Подключение работает только в приложении <strong>Amnezia VPN</strong> версии <strong>4.8.12.9</strong> или новее.",
|
||||
"This server uses <strong>AmneziaWG 2.0</strong>. Connection works only in <strong>Amnezia VPN</strong> version <strong>4.8.12.9</strong> or newer.",
|
||||
},
|
||||
"proto_vless_notice_title": {"VLESS — Happ, v2rayTun, Hiddify…", "VLESS — Happ, v2rayTun, Hiddify…"},
|
||||
"proto_vless_notice_body": {
|
||||
"Используйте ссылку <strong>vless://</strong> (файл <strong>.txt</strong>, QR или копирование). Формат <strong>vpn://</strong> с длинной base64-строкой <strong>не подходит</strong> для этих клиентов.",
|
||||
"Use the <strong>vless://</strong> link (.txt file, QR, or copy). The <strong>vpn://</strong> base64 wrapper format does <strong>not</strong> work with these clients.",
|
||||
},
|
||||
|
||||
"hint_wait": {
|
||||
"После нажатия кнопки страница может «висеть» <strong>от 10 секунд до пары минут</strong> — так устроено: сервер в это время создаёт ключ. Не закрывайте вкладку.",
|
||||
"After you click the button, the page may stay busy for <strong>10 seconds up to a couple of minutes</strong> while the server creates your key. Do not close the tab.",
|
||||
},
|
||||
"btn_get_config": {"Получить конфигурацию", "Get configuration"},
|
||||
|
||||
"configs_title": {"Ваши конфигурации", "Your configurations"},
|
||||
"configs_intro": {
|
||||
"Сохранены по этой ссылке: <strong>{count}</strong>. Скачивание и QR работают и после обновления страницы.",
|
||||
"Saved on this link: <strong>{count}</strong>. Downloads and QR work after refresh.",
|
||||
},
|
||||
"configs_show_more": {"Показать ещё {count} конфиг.", "Show {count} more config(s)."},
|
||||
"configs_hide_more": {"Скрыть остальные конфиги", "Hide other configurations"},
|
||||
|
||||
"migrate_title": {"Перенести на другой сервер", "Migrate to another server"},
|
||||
"migrate_new_server": {"Новый сервер", "New server"},
|
||||
"migrate_protocol": {"Протокол", "Protocol"},
|
||||
"migrate_warning": {"Старый конфиг будет удалён с текущего сервера и заменён новым. Не считается как новое создание.", "The old config will be removed from the current server and replaced. This does not count as a new creation."},
|
||||
"migrate_submit": {"Перенести", "Migrate"},
|
||||
|
||||
"btn_download": {"Скачать", "Download"},
|
||||
"btn_download_zip": {"Скачать архив .zip (тот же конфиг внутри)", "Download .zip archive (same config inside)"},
|
||||
"copy_all": {"Копировать весь текст", "Copy all text"},
|
||||
"copy_vpn_link": {"Копировать ссылку vpn://", "Copy vpn:// link"},
|
||||
"copy_vless_link": {"Копировать ссылку vless://", "Copy vless:// link"},
|
||||
"copied": {"Скопировано", "Copied"},
|
||||
"qr_import": {"Отсканируйте QR в приложении — импорт без файла.", "Scan the QR in the app — import without a file."},
|
||||
"qr_too_large": {"Конфиг слишком большой для QR-кода. Используйте файл для импорта.", "Config is too large for a QR code. Use the file to import."},
|
||||
"qr_failed": {"Не удалось создать QR. Используйте файл.", "Could not create QR. Use the file."},
|
||||
|
||||
"busy_create_title": {"Создаём конфигурацию…", "Creating configuration…"},
|
||||
"busy_step_connect": {"Связь с сервером…", "Connecting to server…"},
|
||||
"busy_step_key": {"Создаём ключ на сервере…", "Creating key on server…"},
|
||||
"busy_step_file": {"Готовим файл для скачивания…", "Preparing download file…"},
|
||||
"busy_hint": {"Обычно 10–60 секунд, иногда до пары минут. Не закрывайте вкладку.", "Usually 10–60 seconds, sometimes up to a couple of minutes. Do not close the tab."},
|
||||
"busy_migrate_title": {"Переносим конфигурацию…", "Migrating configuration…"},
|
||||
|
||||
"downloads_title": {"Скачать приложения", "Download apps"},
|
||||
"downloads_intro": {
|
||||
"Установите клиент <strong>до</strong> импорта конфигурации. Для WireGuard — официальное приложение; для AmneziaWG 2 и файлов <strong>.vpn</strong> — <strong>Amnezia VPN</strong> версии 4.8.12.9+.",
|
||||
"Install a client <strong>before</strong> importing your config. Use the official WireGuard app for <strong>.conf</strong>; use <strong>Amnezia VPN</strong> 4.8.12.9+ for AmneziaWG 2 and <strong>.vpn</strong> files.",
|
||||
},
|
||||
"downloads_wg_title": {"WireGuard", "WireGuard"},
|
||||
"downloads_amnezia_title": {"Amnezia VPN", "Amnezia VPN"},
|
||||
"downloads_vless_title": {"VLESS / Xray", "VLESS / Xray"},
|
||||
|
||||
"faq_title": {"Частые вопросы", "Frequently asked questions"},
|
||||
"faq_page_title": {"FAQ — Cyber // Access", "FAQ — Cyber // Access"},
|
||||
"faq_link_short": {"Все ответы в разделе FAQ", "Full answers in the FAQ section"},
|
||||
"faq_open": {"Открыть FAQ", "Open FAQ"},
|
||||
|
||||
"rules_title": {"Правила использования", "Acceptable Use Policy"},
|
||||
"rules_page_title": {"Правила — Cyber // Access", "Rules — Cyber // Access"},
|
||||
"rules_link_short": {"Обязательные правила и запреты", "Mandatory rules and prohibitions"},
|
||||
"rules_open": {"Правила", "Rules"},
|
||||
|
||||
"status_link": {"Статус серверов", "Server status"},
|
||||
"status_page_title": {"Статус серверов", "Server status"},
|
||||
"status_up": {"Онлайн", "Online"},
|
||||
"status_down": {"Офлайн", "Offline"},
|
||||
|
||||
"server_ready": {"готов", "ready"},
|
||||
"server_speed_prefix": {"до {speed}", "up to {speed}"},
|
||||
"js_server_off": {"недоступен", "unavailable"},
|
||||
"js_servers_empty": {"Список серверов пуст", "Server list is empty"},
|
||||
"js_servers_load_fail": {"Не удалось загрузить список серверов.", "Could not load server list."},
|
||||
"js_network_retry": {"Проверьте подключение к интернету и обновите страницу.", "Check your internet connection and refresh the page."},
|
||||
}
|
||||
|
||||
// FAQItem is a single FAQ question/answer pair, referencing catalog keys (their
|
||||
// title/short text — see catalog). Handlers can look up full bodies elsewhere;
|
||||
// this list captures the canonical question order and titles for the FAQ page.
|
||||
type FAQItem struct {
|
||||
QuestionKey string
|
||||
AnswerKey string
|
||||
}
|
||||
|
||||
// FAQItems lists the FAQ question/answer key pairs, in display order, mirroring
|
||||
// SHARE_FAQ_ITEMS from includes/share_i18n.php. Title text for the question keys
|
||||
// is registered in catalog below (faq_q_*); answer bodies are intentionally left
|
||||
// to be supplied by the content layer (they are long, mostly-static HTML blocks
|
||||
// outside the scope of this core string catalog).
|
||||
var FAQItems = []FAQItem{
|
||||
{"faq_q_how_page", "faq_a_how_page"},
|
||||
{"faq_q_which_app", "faq_a_which_app"},
|
||||
{"faq_q_wg_vs_awg", "faq_a_wg_vs_awg"},
|
||||
{"faq_q_import_wg", "faq_a_import_wg"},
|
||||
{"faq_q_import_amnezia", "faq_a_import_amnezia"},
|
||||
{"faq_q_slow_create", "faq_a_slow_create"},
|
||||
{"faq_q_limit", "faq_a_limit"},
|
||||
{"faq_q_expired", "faq_a_expired"},
|
||||
{"faq_q_migrate", "faq_a_migrate"},
|
||||
{"faq_q_no_connect", "faq_a_no_connect"},
|
||||
{"faq_q_amnezia_version", "faq_a_amnezia_version"},
|
||||
{"faq_q_vless", "faq_a_vless"},
|
||||
{"faq_q_qr", "faq_a_qr"},
|
||||
{"faq_q_zip", "faq_a_zip"},
|
||||
{"faq_q_file_where", "faq_a_file_where"},
|
||||
{"faq_q_site_blocked", "faq_a_site_blocked"},
|
||||
{"faq_q_share_link", "faq_a_share_link"},
|
||||
}
|
||||
|
||||
func init() {
|
||||
faqTitles := map[string]entry{
|
||||
"faq_q_how_page": {"Как пользоваться этой страницей?", "How do I use this page?"},
|
||||
"faq_q_which_app": {"Какое приложение установить?", "Which app should I install?"},
|
||||
"faq_q_wg_vs_awg": {"Чем WireGuard отличается от AmneziaWG 2?", "What is the difference between WireGuard and AmneziaWG 2?"},
|
||||
"faq_q_import_wg": {"Как импортировать .conf в WireGuard?", "How do I import .conf into WireGuard?"},
|
||||
"faq_q_import_amnezia": {"Как добавить конфиг в Amnezia VPN?", "How do I add a config in Amnezia VPN?"},
|
||||
"faq_q_slow_create": {"Почему долго создаётся конфигурация?", "Why does creating a config take so long?"},
|
||||
"faq_q_limit": {"Сколько конфигов можно создать?", "How many configs can I create?"},
|
||||
"faq_q_expired": {"Ссылка истекла — что делать?", "The link expired — what now?"},
|
||||
"faq_q_migrate": {"Что такое «Перенести на другой сервер»?", "What is “Migrate to another server”?"},
|
||||
"faq_q_no_connect": {"VPN не подключается — что проверить?", "VPN won’t connect — what should I check?"},
|
||||
"faq_q_amnezia_version": {"Какая версия Amnezia нужна для AmneziaWG 2?", "Which Amnezia version is required for AmneziaWG 2?"},
|
||||
"faq_q_vless": {"Как подключить VLESS?", "How do I connect with VLESS?"},
|
||||
"faq_q_qr": {"QR-код не сканируется", "QR code won’t scan"},
|
||||
"faq_q_zip": {"Зачем архив .zip с тем же конфигом?", "Why is there a .zip with the same config?"},
|
||||
"faq_q_file_where": {"Скачал файл — где он на телефоне?", "I downloaded a file — where is it on my phone?"},
|
||||
"faq_q_site_blocked": {"Сайт amnezia.org не открывается", "amnezia.org is blocked in my region"},
|
||||
"faq_q_share_link": {"Можно ли переслать эту ссылку другим?", "Can I share this link with others?"},
|
||||
}
|
||||
for k, v := range faqTitles {
|
||||
catalog[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// T translates key into lang, substituting {name} placeholders from replace.
|
||||
// Unknown keys are returned verbatim (helps surface missing translations instead
|
||||
// of crashing).
|
||||
func T(lang Lang, key string, replace map[string]string) string {
|
||||
e, ok := catalog[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
text := e.ru
|
||||
if lang == EN {
|
||||
text = e.en
|
||||
}
|
||||
for k, v := range replace {
|
||||
text = strings.ReplaceAll(text, "{"+k+"}", v)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// JSMap exports the full catalog for lang as a flat map, ready to serialize as
|
||||
// JSON for client-side script use (mirrors share_i18n_js()).
|
||||
func JSMap(lang Lang) map[string]string {
|
||||
out := make(map[string]string, len(catalog))
|
||||
for k, e := range catalog {
|
||||
if lang == EN {
|
||||
out[k] = e.en
|
||||
} else {
|
||||
out[k] = e.ru
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TranslateGuestError best-effort translates a handful of well-known Russian
|
||||
// backend error strings to English (mirrors share_translate_guest_error()); any
|
||||
// other message, or when lang is Russian, is returned unchanged.
|
||||
func TranslateGuestError(lang Lang, msg string) string {
|
||||
if lang != EN {
|
||||
return msg
|
||||
}
|
||||
knownErrors := map[string]string{
|
||||
"Неполные параметры": "Incomplete parameters",
|
||||
"Ссылка не найдена": "Link not found",
|
||||
"Достигнут лимит использований": "Usage limit reached",
|
||||
"Срок ссылки истёк": "Link has expired",
|
||||
"Ссылка не найдена.": "Link not found.",
|
||||
"Срок действия ссылки истёк.": "The link has expired.",
|
||||
"Недопустимый протокол.": "Invalid protocol.",
|
||||
"Выберите сервер.": "Select a server.",
|
||||
"Конфигурация не найдена.": "Configuration not found.",
|
||||
"Неверный код продления.": "Invalid renewal code.",
|
||||
"Этот код уже исчерпан.": "This code has already been used.",
|
||||
"Срок действия кода истёк.": "This code has expired.",
|
||||
}
|
||||
if v, ok := knownErrors[msg]; ok {
|
||||
return v
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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%; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
{{define "page_admin_configs"}}
|
||||
{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Конфигурации"}}
|
||||
{{template "admin_flash" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-filter"></i> Фильтр</h2></div>
|
||||
<form method="get" action="{{appURL "admin/configs"}}" class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<div>
|
||||
<label class="form-label">ID ссылки</label>
|
||||
<input class="form-control input-inline" type="number" name="link_id" value="{{if .LinkIDFilter}}{{.LinkIDFilter}}{{end}}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">ID сервера</label>
|
||||
<input class="form-control input-inline" type="number" name="server_id" value="{{if .ServerIDFilter}}{{.ServerIDFilter}}{{end}}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-ghost"><i class="fa-solid fa-magnifying-glass"></i> Показать</button>
|
||||
<a class="btn btn-ghost" href="{{appURL "admin/configs"}}">Сбросить</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-key"></i> Конфигурации ({{len .Rows}}{{if .Truncated}}+{{end}})</h2></div>
|
||||
{{if .Truncated}}<p class="hint">Показаны не все записи — сузьте фильтр по ссылке или серверу.</p>{{end}}
|
||||
{{if not .Rows}}
|
||||
<p class="hint">Ничего не найдено.</p>
|
||||
{{else}}
|
||||
<div class="tbl-wrap">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>ID</th><th>Ссылка</th><th>Сервер</th><th>Протокол</th><th>Имя</th><th>Создана</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td>#{{.ID}}</td>
|
||||
<td><a href="{{appURL "admin/links"}}?id={{.LinkID}}" class="mono">#{{.LinkID}}</a></td>
|
||||
<td>{{.ServerID}}</td>
|
||||
<td>{{.Protocol}}</td>
|
||||
<td class="mono">{{.ConnectionName}}</td>
|
||||
<td class="sl-muted">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<form method="post" action="{{appURL "admin/configs"}}" onsubmit="return confirm('Удалить конфигурацию #{{.ID}}?');">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="delete_creation">
|
||||
<input type="hidden" name="link_id" value="{{.LinkID}}">
|
||||
<input type="hidden" name="creation_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "admin_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{define "page_admin_index"}}
|
||||
{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Дашборд"}}
|
||||
{{template "admin_flash" .}}
|
||||
|
||||
{{if .Maintenance}}<div class="alert-err"><i class="fa-solid fa-screwdriver-wrench"></i> Включён режим технических работ. <a href="{{appURL "admin/settings"}}">Отключить в настройках</a></div>{{end}}
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-tile"><i class="fa-solid fa-link"></i><div><span class="stat-val">{{.TotalLinks}}</span><span class="stat-label">Всего ссылок</span></div></div>
|
||||
<div class="stat-tile"><i class="fa-solid fa-circle-check"></i><div><span class="stat-val">{{.ActiveLinks}}</span><span class="stat-label">Активные</span></div></div>
|
||||
<div class="stat-tile"><i class="fa-solid fa-hourglass-end"></i><div><span class="stat-val">{{.ExpiredLinks}}</span><span class="stat-label">Истёкшие</span></div></div>
|
||||
<div class="stat-tile"><i class="fa-solid fa-broom"></i><div><span class="stat-val">{{.CleanedLinks}}</span><span class="stat-label">Очищенные</span></div></div>
|
||||
</div>
|
||||
<div class="stat-grid stat-grid-compact">
|
||||
<div class="stat-tile"><i class="fa-solid fa-ban"></i><div><span class="stat-val">{{.LimitLinks}}</span><span class="stat-label">Лимит исчерпан</span></div></div>
|
||||
<div class="stat-tile"><i class="fa-solid fa-rotate"></i><div><span class="stat-val">{{.CodesCount}}</span><span class="stat-label">Кодов продления</span></div></div>
|
||||
<div class="stat-tile">
|
||||
<i class="fa-solid fa-server"></i>
|
||||
<div>
|
||||
<span class="stat-val">{{if .PanelOK}}OK{{else if .PanelURL}}Ошибка{{else}}—{{end}}</span>
|
||||
<span class="stat-label">Панель Amnezia</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .PanelErr}}<div class="alert-err"><i class="fa-solid fa-triangle-exclamation"></i> Панель недоступна: {{.PanelErr}}</div>{{end}}
|
||||
{{if not .PanelURL}}<div class="alert-err"><i class="fa-solid fa-triangle-exclamation"></i> URL панели и токен не заданы. <a href="{{appURL "admin/settings"}}">Перейти в настройки</a></div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-bolt"></i> Быстрые действия</h2></div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-primary" href="{{appURL "admin/links"}}"><i class="fa-solid fa-plus"></i> Ссылки</a>
|
||||
<a class="btn btn-ghost" href="{{appURL "admin/configs"}}"><i class="fa-solid fa-key"></i> Конфигурации</a>
|
||||
<a class="btn btn-ghost" href="{{appURL "admin/renewal"}}"><i class="fa-solid fa-rotate"></i> Коды продления</a>
|
||||
<a class="btn btn-ghost" href="{{appURL "admin/servers"}}"><i class="fa-solid fa-server"></i> Серверы</a>
|
||||
<a class="btn btn-ghost" href="{{appURL "admin/settings"}}"><i class="fa-solid fa-gear"></i> Настройки</a>
|
||||
</div>
|
||||
</div>
|
||||
{{template "admin_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,180 @@
|
||||
{{define "page_admin_links"}}
|
||||
{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Ссылки"}}
|
||||
{{template "admin_flash" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-plus"></i> Новая ссылка</h2></div>
|
||||
<form method="post" action="{{appURL "admin/links"}}" class="form-row-2">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div>
|
||||
<label class="form-label">Срок (дней от первого конфига)</label>
|
||||
<select class="form-select" name="days">
|
||||
{{range .AllowedDurations}}<option value="{{.}}">{{.}} дн.</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Лимит конфигов</label>
|
||||
<input class="form-control" type="number" name="max_uses" value="5" min="1" max="1000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Подписка до (необязательно)</label>
|
||||
<input class="form-control" type="date" name="subscription_until">
|
||||
</div>
|
||||
<div class="d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-plus"></i> Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-filter"></i> Список ссылок ({{.Total}})</h2></div>
|
||||
<form method="get" action="{{appURL "admin/links"}}" class="links-toolbar mb-2">
|
||||
<select class="form-select form-select-sm" name="status" style="max-width:220px;" onchange="this.form.submit()">
|
||||
<option value="" {{if eq2 .StatusFilter ""}}selected{{end}}>Все статусы</option>
|
||||
<option value="active" {{if eq2 .StatusFilter "active"}}selected{{end}}>Активные</option>
|
||||
<option value="expired" {{if eq2 .StatusFilter "expired"}}selected{{end}}>Истёкшие</option>
|
||||
<option value="limit" {{if eq2 .StatusFilter "limit"}}selected{{end}}>Лимит исчерпан</option>
|
||||
<option value="cleaned" {{if eq2 .StatusFilter "cleaned"}}selected{{end}}>Очищенные</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{{if not .Links}}
|
||||
<p class="hint">Ссылок не найдено.</p>
|
||||
{{else}}
|
||||
<div class="tbl-wrap">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>ID</th><th>Токен</th><th>Статус</th><th>Использовано</th><th>Срок</th><th>Конфигов</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Links}}
|
||||
<tr>
|
||||
<td class="sl-td-id">#{{.ID}}</td>
|
||||
<td><a class="mono sl-url-link" href="{{appURL "share"}}?k={{.Token}}" target="_blank">{{.Token}}</a></td>
|
||||
<td>
|
||||
{{if eq2 .Status "active"}}<span class="badge badge-ok">активна</span>{{end}}
|
||||
{{if eq2 .Status "expired"}}<span class="badge badge-warn">истекла</span>{{end}}
|
||||
{{if eq2 .Status "limit"}}<span class="badge badge-info">лимит</span>{{end}}
|
||||
{{if eq2 .Status "cleaned"}}<span class="badge badge-err">очищена</span>{{end}}
|
||||
</td>
|
||||
<td>{{.UseCount}} / {{.MaxUses}}</td>
|
||||
<td class="sl-muted">{{.ExpiresLabel}}</td>
|
||||
<td>{{.ActiveConfigs}}</td>
|
||||
<td class="sl-td-actions">
|
||||
<a class="btn btn-sm btn-ghost" href="{{appURL "admin/links"}}?id={{.ID}}"><i class="fa-solid fa-eye"></i></a>
|
||||
<form method="post" action="{{appURL "admin/links"}}" style="display:inline" onsubmit="return confirm('Удалить ссылку #{{.ID}}?');">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="link_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="sl-pager mt-2">
|
||||
{{if gt .Page 1}}<a class="btn btn-sm btn-ghost" href="{{appURL "admin/links"}}?status={{.StatusFilter}}&page={{sub .Page 1}}">« Назад</a>{{end}}
|
||||
<span class="hint d-inline-block mx-2">Страница {{.Page}}</span>
|
||||
{{if gt .Total (mul .Page .PerPage)}}<a class="btn btn-sm btn-ghost" href="{{appURL "admin/links"}}?status={{.StatusFilter}}&page={{add .Page 1}}">Вперёд »</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Detail}}
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-link"></i> Ссылка #{{.Detail.ID}}</h2></div>
|
||||
<p class="mono">{{appURL "share"}}?k={{.Detail.Token}}</p>
|
||||
|
||||
<form method="post" action="{{appURL "admin/links"}}" class="form-row-2">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="hidden" name="link_id" value="{{.Detail.ID}}">
|
||||
<div>
|
||||
<label class="form-label">Срок (дней)</label>
|
||||
<input class="form-control" type="number" name="validity_days" value="{{if .Detail.ValidityDays}}{{.Detail.ValidityDays}}{{else}}30{{end}}" min="1" max="3650">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Лимит конфигов</label>
|
||||
<input class="form-control" type="number" name="max_uses" value="{{.Detail.MaxUses}}" min="1" max="1000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Явная дата истечения (необязательно)</label>
|
||||
<input class="form-control" type="date" name="expires_at">
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" name="floating_expiry" value="1" id="floating_expiry" checked>
|
||||
<label class="form-check-label hint" for="floating_expiry">Плавающий срок (от первого конфига)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Подписка до</label>
|
||||
<input class="form-control" type="date" name="subscription_until">
|
||||
</div>
|
||||
<div class="d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-floppy-disk"></i> Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{appURL "admin/links"}}" class="d-flex gap-2 align-items-end mt-3">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="extend">
|
||||
<input type="hidden" name="link_id" value="{{.Detail.ID}}">
|
||||
<input type="hidden" name="back_id" value="{{.Detail.ID}}">
|
||||
<div>
|
||||
<label class="form-label">Продлить на, дней</label>
|
||||
<input class="form-control input-inline" type="number" name="days" value="30" min="1" max="3650">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-ghost"><i class="fa-solid fa-rotate"></i> Продлить</button>
|
||||
</form>
|
||||
|
||||
{{if .AllServers}}
|
||||
<form method="post" action="{{appURL "admin/links"}}" class="mt-3">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="set_allowed_servers">
|
||||
<input type="hidden" name="link_id" value="{{.Detail.ID}}">
|
||||
<input type="hidden" name="back_id" value="{{.Detail.ID}}">
|
||||
<label class="form-label d-block">Разрешённые серверы (пусто = все)</label>
|
||||
<div class="tn-servers__scroll d-flex flex-wrap gap-2 mb-2">
|
||||
{{$allowed := .AllowedServerIDs}}
|
||||
{{range .AllServers}}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="server_ids[]" value="{{.ID}}" id="srv{{.ID}}" {{if intIn $allowed .ID}}checked{{end}}>
|
||||
<label class="form-check-label" for="srv{{.ID}}">{{.Label}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-ghost"><i class="fa-solid fa-floppy-disk"></i> Сохранить список серверов</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<h3 class="config-section-title mt-4">Активные конфигурации ({{len .Creations}})</h3>
|
||||
{{if not .Creations}}<p class="hint">Пока нет.</p>{{end}}
|
||||
{{range .Creations}}
|
||||
<div class="creation-card">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="mono">#{{.ID}} · {{.ConnectionName}} · сервер {{.ServerID}} · {{.Protocol}}</span>
|
||||
<form method="post" action="{{appURL "admin/links"}}" onsubmit="return confirm('Удалить конфигурацию #{{.ID}}?');">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="delete_creation">
|
||||
<input type="hidden" name="link_id" value="{{$.Detail.ID}}">
|
||||
<input type="hidden" name="creation_id" value="{{.ID}}">
|
||||
<input type="hidden" name="back_id" value="{{$.Detail.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .DeletedCreations}}
|
||||
<h3 class="config-section-title config-section-title--deleted mt-3">Удалённые ({{len .DeletedCreations}})</h3>
|
||||
{{range .DeletedCreations}}
|
||||
<div class="creation-card creation-card--deleted">
|
||||
<span class="mono">#{{.ID}} · {{.ConnectionName}} · сервер {{.ServerID}} · {{.Protocol}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "admin_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{define "page_admin_renewal"}}
|
||||
{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Коды продления"}}
|
||||
{{template "admin_flash" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-plus"></i> Новый код</h2></div>
|
||||
<form method="post" action="{{appURL "admin/renewal"}}" class="form-row-2">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div>
|
||||
<label class="form-label">Код (пусто = сгенерировать)</label>
|
||||
<input class="form-control mono" name="code" placeholder="ABCD1234" maxlength="32">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Дней продления</label>
|
||||
<input class="form-control" type="number" name="add_days" value="30" min="1" max="3650">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Лимит активаций</label>
|
||||
<input class="form-control" type="number" name="max_uses" value="1" min="1" max="100000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Назначение</label>
|
||||
<select class="form-select" name="code_target">
|
||||
<option value="guest">Гостевая ссылка</option>
|
||||
<option value="member">Личный кабинет</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">ID гостевой ссылки (необязательно)</label>
|
||||
<input class="form-control" type="number" name="share_link_id" min="1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Срок действия кода (необязательно)</label>
|
||||
<input class="form-control" type="date" name="code_expires_at">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Заметка</label>
|
||||
<input class="form-control" name="note" maxlength="255">
|
||||
</div>
|
||||
<div class="d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-plus"></i> Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-list"></i> Список кодов ({{len .Codes}})</h2></div>
|
||||
{{if not .Codes}}
|
||||
<p class="hint">Кодов пока нет.</p>
|
||||
{{else}}
|
||||
<div class="tbl-wrap">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Код</th><th>Дней</th><th>Активаций</th><th>Назначение</th><th>Ссылка</th><th>Срок кода</th><th>Заметка</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Codes}}
|
||||
<tr>
|
||||
<td class="mono">{{.Code}}</td>
|
||||
<td>{{.AddDays}}</td>
|
||||
<td>{{.UseCount}} / {{.MaxUses}}</td>
|
||||
<td>{{if eq2 .CodeTarget "member"}}<span class="badge badge-info">кабинет</span>{{else}}<span class="badge badge-ok">гость</span>{{end}}</td>
|
||||
<td>{{if .ShareLinkID}}#{{.ShareLinkID}}{{else}}—{{end}}</td>
|
||||
<td class="sl-muted">{{if .CodeExpiresAt}}{{.CodeExpiresAt.Format "2006-01-02"}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="{{appURL "admin/renewal"}}" onsubmit="return confirm('Удалить код {{.Code}}?');">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="code_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "admin_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,82 @@
|
||||
{{define "page_admin_servers"}}
|
||||
{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Серверы"}}
|
||||
{{template "admin_flash" .}}
|
||||
|
||||
{{if .PanelErr}}<div class="alert-err"><i class="fa-solid fa-triangle-exclamation"></i> Панель недоступна: {{.PanelErr}}</div>{{end}}
|
||||
{{if not .PanelReachable}}<p class="hint">Панель Amnezia сейчас не отвечает — список серверов основан на сохранённых названиях (panel_server_labels) и настройках.</p>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-plus"></i> Добавить сервер вручную</h2></div>
|
||||
<form method="post" action="{{appURL "admin/servers"}}" class="d-flex flex-wrap gap-2 align-items-end">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div>
|
||||
<label class="form-label">ID сервера в панели</label>
|
||||
<input class="form-control input-inline" type="number" name="new_server_id" min="0" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Название</label>
|
||||
<input class="form-control" name="new_server_title" required placeholder="🇩🇪 Германия">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-ghost"><i class="fa-solid fa-plus"></i> Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-server"></i> Серверы ({{len .Rows}})</h2></div>
|
||||
{{if not .Rows}}
|
||||
<p class="hint">Серверов пока нет. Задайте URL панели и токен в настройках, либо добавьте сервер вручную.</p>
|
||||
{{else}}
|
||||
<form id="deleteServerForm" method="post" action="{{appURL "admin/servers"}}" class="d-none">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="delete_label">
|
||||
<input type="hidden" id="delServerId" name="server_id" value="">
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{appURL "admin/servers"}}">
|
||||
{{template "admin_csrf_field"}}
|
||||
<input type="hidden" name="action" value="save">
|
||||
{{range .Rows}}<input type="hidden" name="server_ids" value="{{.ID}}">{{end}}
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead><tr><th>ID</th><th>Название</th><th>Протоколы</th><th>Флаг</th><th>Скорость</th><th>Откл.</th><th>Панель</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
{{$row := .}}
|
||||
<tr>
|
||||
<td class="mono">#{{.ID}}</td>
|
||||
<td><input class="form-control form-control-sm" name="servers[{{.ID}}][title]" value="{{.Title}}"></td>
|
||||
<td>
|
||||
{{range $.AllProtocols}}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="servers[{{$row.ID}}][protocols][]" value="{{.}}" id="p{{$row.ID}}_{{.}}" {{if strIn $row.Protocols .}}checked{{end}}>
|
||||
<label class="form-check-label hint" for="p{{$row.ID}}_{{.}}">{{.}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><input class="form-control form-control-sm input-inline" name="servers[{{.ID}}][flag]" value="{{.Flag}}" placeholder="🇩🇪"></td>
|
||||
<td><input class="form-control form-control-sm input-inline" name="servers[{{.ID}}][speed]" value="{{.Speed}}" placeholder="1 Gbps"></td>
|
||||
<td class="text-center"><input class="form-check-input" type="checkbox" name="servers[{{.ID}}][disabled]" value="1" {{if .Disabled}}checked{{end}}></td>
|
||||
<td class="sl-muted">{{if .PanelName}}{{.PanelName}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<button type="submit" form="deleteServerForm" formnovalidate
|
||||
onclick="document.getElementById('delServerId').value='{{.ID}}'; return confirm('Удалить сервер #{{.ID}} из списка?');"
|
||||
class="btn btn-sm btn-danger">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Сохранить всё</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "admin_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "page_admin_settings"}}
|
||||
{{template "admin_shell_open" dict "Active" .Active "Admin" .Admin "Title" "Настройки"}}
|
||||
{{template "admin_flash" .}}
|
||||
|
||||
{{if .TestResult}}<div class="alert-ok"><i class="fa-solid fa-circle-check"></i> {{.TestResult}}</div>{{end}}
|
||||
{{if .TestError}}<div class="alert-err"><i class="fa-solid fa-triangle-exclamation"></i> {{.TestError}}</div>{{end}}
|
||||
|
||||
<form method="post" action="{{appURL "admin/settings"}}">
|
||||
{{template "admin_csrf_field"}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-server"></i> Панель Amnezia</h2></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL панели</label>
|
||||
<input class="form-control" name="panel_url" value="{{.PanelURL}}" placeholder="https://panel.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API-токен</label>
|
||||
<input class="form-control mono" name="api_token" value="{{.APIToken}}" placeholder="awp_...">
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" name="action" value="save" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Сохранить</button>
|
||||
<button type="submit" name="action" value="test" formnovalidate class="btn btn-ghost"><i class="fa-solid fa-plug-circle-check"></i> Проверить соединение</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-screwdriver-wrench"></i> Режим работы</h2></div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="maintenance" name="maintenance" value="1" {{if .Maintenance}}checked{{end}}>
|
||||
<label class="form-check-label" for="maintenance">Технические работы (запретить создание/перенос конфигов)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-key"></i> Вход через Pocket ID (OIDC)</h2></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL Pocket ID</label>
|
||||
<input class="form-control" name="pocket_url" value="{{.PocketURL}}" placeholder="https://id.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input class="form-control mono" name="pocket_client_id" value="{{.PocketClientID}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<input class="form-control mono" type="password" name="pocket_secret" value="{{.PocketSecret}}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-address-card"></i> Личный кабинет — значения по умолчанию</h2></div>
|
||||
<div class="form-row-2">
|
||||
<div>
|
||||
<label class="form-label">Дней подписки по умолчанию</label>
|
||||
<input class="form-control" type="number" name="member_days" value="{{.MemberDays}}" min="1" max="3650" placeholder="30">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Лимит конфигов по умолчанию</label>
|
||||
<input class="form-control" type="number" name="member_max_uses" value="{{.MemberMaxUses}}" min="1" max="1000" placeholder="5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-compact">
|
||||
<div class="card-header"><h2><i class="fa-solid fa-code"></i> Дополнительно (JSON)</h2></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label hint-tight">Метки серверов, переопределение (server_labels_json)</label>
|
||||
<textarea class="form-control tn-textarea mono" name="server_labels_json" rows="3" placeholder='{"1":"Германия"}'>{{.ServerLabelsJSON}}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label hint-tight">Уведомления о трафике (traffic_notices_json)</label>
|
||||
<textarea class="form-control tn-textarea mono" name="traffic_notices_json" rows="3" placeholder='{"1":"Ограничение 100GB/мес"}'>{{.TrafficNotices}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="action" value="save" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> Сохранить все настройки</button>
|
||||
</form>
|
||||
{{template "admin_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{define "page_install"}}
|
||||
{{template "guest_shell_open" dict "Title" "Установка администратора" "Lang" "ru"}}
|
||||
<div class="cyber-shell">
|
||||
<span class="cyber-corner cyber-corner--tl"></span><span class="cyber-corner cyber-corner--tr"></span>
|
||||
<span class="cyber-corner cyber-corner--bl"></span><span class="cyber-corner cyber-corner--br"></span>
|
||||
<div class="cyber-hero">
|
||||
<div class="hero-icon"><i class="fa-solid fa-user-shield"></i></div>
|
||||
<div class="hero-brand">Cyber // Access</div>
|
||||
<h1>Установка</h1>
|
||||
<p class="lead">Создание единственной учётной записи администратора.</p>
|
||||
</div>
|
||||
|
||||
{{if .DBError}}
|
||||
<div class="alert alert-danger">{{.DBError}}</div>
|
||||
<p class="hint">Проверьте <code>DATABASE_URL</code> и что миграции применены.</p>
|
||||
{{else if .Installed}}
|
||||
<div class="alert alert-info">Администратор уже создан.</div>
|
||||
<a class="btn btn-primary w-100" href="{{appURL "login"}}"><i class="fa-solid fa-right-to-bracket"></i> Перейти ко входу</a>
|
||||
{{else}}
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-user-plus"></i> Регистрация администратора</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "install"}}" autocomplete="off">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Логин</label>
|
||||
<input class="form-control" id="username" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9._-]+" placeholder="admin">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Пароль</label>
|
||||
<input class="form-control" id="password" name="password" type="password" required minlength="10" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password2">Пароль ещё раз</label>
|
||||
<input class="form-control" id="password2" name="password2" type="password" required minlength="10" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-check"></i> Создать администратора и войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint text-center">После установки ограничьте доступ к <code>/install</code> на продакшене.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "page_login"}}
|
||||
{{template "guest_shell_open" dict "Title" "Вход — Amnezia Admin" "Lang" "ru"}}
|
||||
<div class="cyber-shell">
|
||||
<span class="cyber-corner cyber-corner--tl"></span><span class="cyber-corner cyber-corner--tr"></span>
|
||||
<span class="cyber-corner cyber-corner--bl"></span><span class="cyber-corner cyber-corner--br"></span>
|
||||
<div class="cyber-hero">
|
||||
<div class="hero-icon"><i class="fa-solid fa-shield-halved"></i></div>
|
||||
<div class="hero-brand">Cyber // Access</div>
|
||||
<h1>Amnezia Admin</h1>
|
||||
<p class="lead">Вход в панель управления</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger"><i class="fa-solid fa-triangle-exclamation"></i> {{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-lock"></i> Вход</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "login"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username"><i class="fa-solid fa-user"></i> Логин</label>
|
||||
<input class="form-control" id="username" name="username" type="text" required autocomplete="username" placeholder="admin">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password"><i class="fa-solid fa-lock"></i> Пароль</label>
|
||||
<input class="form-control" id="password" name="password" type="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-right-to-bracket"></i> Войти</button>
|
||||
</form>
|
||||
|
||||
{{if .PocketEnabled}}
|
||||
<div class="text-center text-secondary my-3">или</div>
|
||||
<a href="{{appURL "login"}}?oidc=1" class="btn btn-outline-info w-100"><i class="fa-solid fa-key"></i> Войти через Pocket ID</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .NoAdmins}}<p class="hint text-center">Нет учётных записей — <a href="{{appURL "install"}}">установка</a></p>{{end}}
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,128 @@
|
||||
{{define "page_cabinet"}}
|
||||
{{template "guest_shell_open" dict "Title" "Личный кабинет" "Lang" (lang)}}
|
||||
<div class="cyber-shell">
|
||||
<span class="cyber-corner cyber-corner--tl"></span><span class="cyber-corner cyber-corner--tr"></span>
|
||||
<span class="cyber-corner cyber-corner--bl"></span><span class="cyber-corner cyber-corner--br"></span>
|
||||
<div class="cyber-hero">
|
||||
<div class="hero-icon"><i class="fa-solid fa-user-shield"></i></div>
|
||||
<div class="hero-brand">{{t "hero_brand"}}</div>
|
||||
<h1>{{.Member.Username}}</h1>
|
||||
<p class="lead">Личный кабинет VPN</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{appURL "cabinet/logout"}}"><i class="fa-solid fa-right-from-bracket"></i> Выйти</a>
|
||||
</div>
|
||||
|
||||
{{template "guest_flash" .}}
|
||||
|
||||
{{if .Maintenance}}<div class="alert alert-warning">{{htmlSafe (t "maintenance_banner")}}</div>{{end}}
|
||||
|
||||
<div class="share-stats">
|
||||
<div class="card share-panel"><div class="share-panel__body text-center">
|
||||
<div class="stat__value--sm fw-bold">{{if .Sub}}{{.SubLabel}}{{else}}—{{end}}</div>
|
||||
<div class="hint mb-0">{{t "stat_valid_until"}}</div>
|
||||
</div></div>
|
||||
<div class="card share-panel"><div class="share-panel__body text-center">
|
||||
<div class="stat__value--sm fw-bold">{{if .Sub}}{{.ConfigsRemaining}} {{t "stat_of"}} {{.Sub.MaxConfigs}}{{else}}—{{end}}</div>
|
||||
<div class="hint mb-0">{{t "stat_remaining"}}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
{{if .Expired}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head share-panel__head--pink"><i class="fa-solid fa-hourglass-end"></i> {{t "expired_card"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "cabinet"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="renew">
|
||||
<div class="mb-2"><input class="form-control" name="code" placeholder="{{t "renewal_code_ph"}}" required></div>
|
||||
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-key"></i> {{t "renewal_btn"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
{{if .LimitReached}}
|
||||
<div class="alert alert-warning">{{t "limit_reached" "max" (printf "%d" .Sub.MaxConfigs)}}</div>
|
||||
{{else}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-plus"></i> {{t "field_location"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "cabinet"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{t "field_location"}}</label>
|
||||
<select class="form-select" name="server_id" required {{if not .Servers}}disabled{{end}}>
|
||||
{{range .Servers}}<option value="{{.ID}}" {{if .Disabled}}disabled{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label d-block">{{t "field_protocol"}}</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="protocol" id="proto_wg" value="wireguard" checked>
|
||||
<label class="form-check-label" for="proto_wg">WireGuard</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="protocol" id="proto_awg" value="awg2">
|
||||
<label class="form-check-label" for="proto_awg">AmneziaWG 2</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="protocol" id="proto_vless" value="vless">
|
||||
<label class="form-check-label" for="proto_vless">VLESS</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">{{htmlSafe (t "hint_wait")}}</p>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-bolt"></i> {{t "btn_get_config"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .Bundles}}
|
||||
<div class="card share-panel card--download">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-list"></i> {{t "configs_title"}}</div>
|
||||
<div class="share-panel__body">
|
||||
{{range .Bundles}}
|
||||
<div class="dl-bundle">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong class="mono">{{.ConnectionName}}</strong>
|
||||
<span class="badge text-bg-secondary">{{.Protocol}}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{{if .Conf}}<a class="btn btn-sm btn-outline-info" href="{{appURL "cabinet/download"}}?cre={{.CreationID}}&part=conf"><i class="fa-solid fa-download"></i> {{.Conf.Filename}}</a>{{end}}
|
||||
{{if .Vpn}}<a class="btn btn-sm btn-outline-info" href="{{appURL "cabinet/download"}}?cre={{.CreationID}}&part=vpn"><i class="fa-solid fa-download"></i> {{.Vpn.Filename}}</a>{{end}}
|
||||
{{if .Zip}}<a class="btn btn-sm btn-outline-secondary" href="{{appURL "cabinet/download"}}?cre={{.CreationID}}&part=zip"><i class="fa-solid fa-file-zipper"></i> .zip</a>{{end}}
|
||||
</div>
|
||||
<form method="post" action="{{appURL "cabinet"}}" class="mt-2" onsubmit="return confirm('Удалить эту конфигурацию?');">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="config_id" value="{{.CreationID}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .Expired}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-key"></i> {{t "renewal_title"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<p class="hint">{{t "renewal_hint"}}</p>
|
||||
<form method="post" action="{{appURL "cabinet"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="renew">
|
||||
<div class="mb-2"><input class="form-control" name="code" placeholder="{{t "renewal_code_ph"}}" required></div>
|
||||
<button class="btn btn-outline-info w-100" type="submit"><i class="fa-solid fa-arrow-rotate-right"></i> {{t "renewal_btn"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,57 @@
|
||||
{{define "page_cabinet_auth"}}
|
||||
{{template "guest_shell_open" dict "Title" "Личный кабинет — вход" "Lang" (lang)}}
|
||||
<div class="cyber-shell">
|
||||
<span class="cyber-corner cyber-corner--tl"></span><span class="cyber-corner cyber-corner--tr"></span>
|
||||
<span class="cyber-corner cyber-corner--bl"></span><span class="cyber-corner cyber-corner--br"></span>
|
||||
<div class="cyber-hero">
|
||||
<div class="hero-icon"><i class="fa-solid fa-user-lock"></i></div>
|
||||
<div class="hero-brand">{{t "hero_brand"}}</div>
|
||||
<h1>Личный кабинет</h1>
|
||||
<p class="lead">Войдите или зарегистрируйтесь, чтобы получить свою подписку VPN.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger"><i class="fa-solid fa-triangle-exclamation"></i> {{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-right-to-bracket"></i> Вход</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "cabinet/login"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Логин</label>
|
||||
<input class="form-control" name="username" required autocomplete="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль</label>
|
||||
<input class="form-control" name="password" type="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-right-to-bracket"></i> Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head share-panel__head--pink"><i class="fa-solid fa-user-plus"></i> Регистрация</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "cabinet/register"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Логин</label>
|
||||
<input class="form-control" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9._-]+" autocomplete="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">E-mail (необязательно)</label>
|
||||
<input class="form-control" name="email" type="email" autocomplete="email">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль</label>
|
||||
<input class="form-control" name="password" type="password" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-info w-100"><i class="fa-solid fa-user-plus"></i> Создать аккаунт</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -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"}}<!doctype html>
|
||||
<html lang="ru" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} — Amnezia Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=JetBrains+Mono:wght@500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet" referrerpolicy="no-referrer">
|
||||
<link href="{{appURL "static/css/admin.css"}}" rel="stylesheet">
|
||||
<link href="{{appURL "static/css/admin-cyber.css"}}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="admin-app">
|
||||
<button id="adminSidebarToggle" class="btn btn-sm position-fixed m-2" type="button" aria-label="Меню" onclick="document.body.classList.toggle('admin-sidebar-open')">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
<aside class="admin-sidebar">
|
||||
<a class="admin-brand" href="{{appURL "admin"}}">
|
||||
<span class="admin-brand-icon"><i class="fa-solid fa-shield-halved"></i></span>
|
||||
<span>
|
||||
<span class="admin-brand-text d-block">Amnezia Admin</span>
|
||||
<span class="admin-brand-sub">Share Panel</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="admin-nav nav flex-column">
|
||||
<a class="nav-link {{if eq2 .Active "index"}}active{{end}}" href="{{appURL "admin"}}"><i class="fa-solid fa-gauge-high"></i> Дашборд</a>
|
||||
<a class="nav-link {{if eq2 .Active "links"}}active{{end}}" href="{{appURL "admin/links"}}"><i class="fa-solid fa-link"></i> Ссылки</a>
|
||||
<a class="nav-link {{if eq2 .Active "configs"}}active{{end}}" href="{{appURL "admin/configs"}}"><i class="fa-solid fa-key"></i> Конфигурации</a>
|
||||
<a class="nav-link {{if eq2 .Active "renewal"}}active{{end}}" href="{{appURL "admin/renewal"}}"><i class="fa-solid fa-rotate"></i> Коды продления</a>
|
||||
<a class="nav-link {{if eq2 .Active "servers"}}active{{end}}" href="{{appURL "admin/servers"}}"><i class="fa-solid fa-server"></i> Серверы</a>
|
||||
<a class="nav-link {{if eq2 .Active "settings"}}active{{end}}" href="{{appURL "admin/settings"}}"><i class="fa-solid fa-gear"></i> Настройки</a>
|
||||
</nav>
|
||||
<div class="admin-sidebar-footer">
|
||||
{{if .Admin}}<div class="hint mb-2"><i class="fa-solid fa-user"></i> {{.Admin.Username}}</div>{{end}}
|
||||
<a class="btn btn-outline-danger btn-sm w-100" href="{{appURL "logout"}}"><i class="fa-solid fa-right-from-bracket"></i> Выйти</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<div class="admin-topbar-title">
|
||||
<h1><i class="fa-solid fa-angle-right text-primary"></i> {{.Title}}</h1>
|
||||
</div>
|
||||
<div class="admin-topbar-meta">
|
||||
<span class="badge-protocol"><i class="fa-solid fa-circle-nodes"></i> Go</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="admin-content">
|
||||
{{end}}
|
||||
|
||||
{{define "admin_shell_close"}}
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_flash"}}
|
||||
{{if .OK}}<div class="alert-ok"><i class="fa-solid fa-circle-check"></i><div>{{.OK}}</div></div>{{end}}
|
||||
{{if .Error}}<div class="alert-err"><i class="fa-solid fa-triangle-exclamation"></i><div>{{.Error}}</div></div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "admin_csrf_field"}}<input type="hidden" name="_csrf" value="{{csrf}}">{{end}}
|
||||
@@ -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"}}<!doctype html>
|
||||
<html lang="{{.Lang}}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=JetBrains+Mono:wght@500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet" referrerpolicy="no-referrer">
|
||||
<link href="{{appURL "static/css/portal.css"}}" rel="stylesheet">
|
||||
<link href="{{appURL "static/css/share.css"}}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="share-app">
|
||||
<div class="cyber-bg" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
{{end}}
|
||||
|
||||
{{define "guest_shell_close"}}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "guest_flash"}}
|
||||
{{if .OK}}<div class="alert alert-success d-flex align-items-center gap-2" role="alert"><i class="fa-solid fa-circle-check"></i><div>{{.OK}}</div></div>{{end}}
|
||||
{{if .Error}}<div class="alert alert-danger d-flex align-items-center gap-2" role="alert"><i class="fa-solid fa-triangle-exclamation"></i><div>{{.Error}}</div></div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "guest_lang_switch"}}
|
||||
<div class="d-flex lang-switch">
|
||||
<span class="lang-switch__label"><i class="fa-solid fa-globe"></i></span>
|
||||
<a class="lang-switch__btn {{if eq2 (lang) "ru"}}active{{end}}" href="?{{.QS}}lang=ru">RU</a>
|
||||
<a class="lang-switch__btn {{if eq2 (lang) "en"}}active{{end}}" href="?{{.QS}}lang=en">EN</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "guest_subnav"}}
|
||||
<div class="guest-subnav d-flex justify-content-center gap-3 flex-wrap">
|
||||
<a href="{{appURL "faq"}}"><i class="fa-solid fa-circle-question"></i> {{t "faq_link_short"}}</a>
|
||||
<a href="{{appURL "rules"}}"><i class="fa-solid fa-scale-balanced"></i> {{t "rules_link_short"}}</a>
|
||||
<a href="{{appURL "status"}}"><i class="fa-solid fa-tower-broadcast"></i> {{t "status_link"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "guest_footer"}}
|
||||
<div class="share-footer">
|
||||
<span class="share-footer__line"></span>
|
||||
<span class="share-footer__text">Amnezia VPN Share © {{year}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "page_faq"}}
|
||||
{{template "guest_shell_open" dict "Title" (t "faq_page_title") "Lang" (lang)}}
|
||||
<div class="cyber-shell share-faq-page">
|
||||
<div class="cyber-hero cyber-hero--compact">
|
||||
<div class="hero-icon hero-icon--sm"><i class="fa-solid fa-circle-question"></i></div>
|
||||
<h1>{{t "faq_title"}}</h1>
|
||||
</div>
|
||||
|
||||
<div class="faq-list">
|
||||
{{range .Items}}
|
||||
<div class="faq-item">
|
||||
<div class="faq-item__q">{{.Question}}</div>
|
||||
<div class="faq-item__a">{{.Answer}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "guest_subnav" .}}
|
||||
<p class="text-center mt-3"><a class="guest-back-link" href="{{appURL "share"}}"><i class="fa-solid fa-arrow-left"></i> Назад</a></p>
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{define "page_rules"}}
|
||||
{{template "guest_shell_open" dict "Title" (t "rules_page_title") "Lang" (lang)}}
|
||||
<div class="cyber-shell share-faq-page">
|
||||
<div class="cyber-hero cyber-hero--compact">
|
||||
<div class="hero-icon hero-icon--sm"><i class="fa-solid fa-scale-balanced"></i></div>
|
||||
<h1>{{t "rules_title"}}</h1>
|
||||
</div>
|
||||
|
||||
<div class="rules-block rules-block--cyan">
|
||||
<div class="rules-block__title"><i class="fa-solid fa-circle-check"></i> Разрешено</div>
|
||||
<div class="rules-block__body">
|
||||
<ul class="rules-list">
|
||||
<li>Личное использование сервиса для защиты своего трафика.</li>
|
||||
<li>Создание нескольких конфигураций в рамках лимита вашей ссылки/подписки.</li>
|
||||
<li>Перенос своей конфигурации на другой узел при необходимости.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-block rules-block--pink">
|
||||
<div class="rules-block__title"><i class="fa-solid fa-ban"></i> Запрещено</div>
|
||||
<div class="rules-block__body">
|
||||
<ul class="rules-list">
|
||||
<li>Передача ссылки/доступа третьим лицам для коммерческого использования.</li>
|
||||
<li>Массовая автоматическая генерация конфигураций (боты, скрипты).</li>
|
||||
<li>Использование сервиса для рассылки спама, DDoS-атак или иной незаконной деятельности.</li>
|
||||
<li>Попытки обойти лимиты создания конфигураций или скомпрометировать сервис.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-block rules-block--yellow">
|
||||
<div class="rules-block__title"><i class="fa-solid fa-triangle-exclamation"></i> Ответственность</div>
|
||||
<div class="rules-block__body">
|
||||
<p>Администрация оставляет за собой право отозвать доступ без предупреждения при нарушении данных правил.
|
||||
Сервис предоставляется «как есть», без гарантий непрерывной доступности.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="rules-doc__updated text-center hint">Обновлено: {{year}}</p>
|
||||
|
||||
{{template "guest_subnav" .}}
|
||||
<p class="text-center mt-3"><a class="guest-back-link" href="{{appURL "share"}}"><i class="fa-solid fa-arrow-left"></i> Назад</a></p>
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,173 @@
|
||||
{{define "page_share"}}
|
||||
{{template "guest_shell_open" dict "Title" (t "page_title") "Lang" (lang)}}
|
||||
{{if .NotFound}}
|
||||
<div class="cyber-shell text-center">
|
||||
<div class="hero-icon"><i class="fa-solid fa-link-slash"></i></div>
|
||||
<h1>{{t "err_link_not_found"}}</h1>
|
||||
<p class="lead">{{t "err_bad_link"}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="d-flex justify-content-end lang-switch">
|
||||
<span class="lang-switch__label"><i class="fa-solid fa-globe"></i></span>
|
||||
<a class="lang-switch__btn {{if eq2 (lang) "ru"}}active{{end}}" href="?k={{.Token}}&lang=ru">RU</a>
|
||||
<a class="lang-switch__btn {{if eq2 (lang) "en"}}active{{end}}" href="?k={{.Token}}&lang=en">EN</a>
|
||||
</div>
|
||||
|
||||
<div class="cyber-shell">
|
||||
<span class="cyber-corner cyber-corner--tl"></span><span class="cyber-corner cyber-corner--tr"></span>
|
||||
<span class="cyber-corner cyber-corner--bl"></span><span class="cyber-corner cyber-corner--br"></span>
|
||||
<div class="cyber-hero">
|
||||
<div class="hero-icon"><i class="fa-solid fa-shield-halved"></i></div>
|
||||
<div class="hero-brand">{{t "hero_brand"}}</div>
|
||||
<h1>{{t "hero_title"}}</h1>
|
||||
<p class="lead">{{t "hero_lead"}}</p>
|
||||
</div>
|
||||
|
||||
{{template "guest_flash" .}}
|
||||
|
||||
{{if .Maintenance}}<div class="alert alert-warning">{{htmlSafe (t "maintenance_banner")}}</div>{{end}}
|
||||
|
||||
<div class="share-stats">
|
||||
<div class="card share-panel"><div class="share-panel__body text-center">
|
||||
<div class="stat__value--sm fw-bold">{{.Link.ExpiresLabel}}</div>
|
||||
<div class="hint mb-0">{{t "term_label"}}</div>
|
||||
</div></div>
|
||||
<div class="card share-panel"><div class="share-panel__body text-center">
|
||||
<div class="stat__value--sm fw-bold">{{sub .Link.MaxUses .Link.UseCount}} {{t "stat_of"}} {{.Link.MaxUses}}</div>
|
||||
<div class="hint mb-0">{{t "stat_remaining"}}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
{{if .Expired}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head share-panel__head--pink"><i class="fa-solid fa-hourglass-end"></i> {{t "expired_card"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<p class="hint">{{t "renewal_hint"}}</p>
|
||||
<form method="post" action="{{appURL "share"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="renew">
|
||||
<input type="hidden" name="k" value="{{.Token}}">
|
||||
<div class="mb-2"><input class="form-control" name="code" placeholder="{{t "renewal_code_ph"}}" required></div>
|
||||
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-key"></i> {{t "renewal_btn"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
{{if .Maintenance}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head share-panel__head--pink"><i class="fa-solid fa-screwdriver-wrench"></i> {{t "maintenance_card_title"}}</div>
|
||||
<div class="share-panel__body"><p class="hint mb-0">{{t "maintenance_card_body"}}</p></div>
|
||||
</div>
|
||||
{{else if .LimitReached}}
|
||||
<div class="alert alert-warning">{{t "limit_reached" "max" (printf "%d" .Link.MaxUses)}}</div>
|
||||
{{else}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-plus"></i> {{t "field_location"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<form method="post" action="{{appURL "share"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<input type="hidden" name="k" value="{{.Token}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{t "field_location"}}</label>
|
||||
<select class="form-select" name="server_id" required {{if not .Servers}}disabled{{end}}>
|
||||
{{range .Servers}}<option value="{{.ID}}" {{if .Disabled}}disabled{{end}}>{{if .Flag}}{{.Flag}} {{end}}{{.Label}}{{if .Speed}} ({{t "server_speed_prefix" "speed" .Speed}}){{end}}</option>{{end}}
|
||||
</select>
|
||||
{{if not .Servers}}<div class="hint">{{t "js_servers_empty"}}</div>{{end}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label d-block">{{t "field_protocol"}}</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="protocol" id="proto_wg" value="wireguard" checked>
|
||||
<label class="form-check-label" for="proto_wg">WireGuard</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="protocol" id="proto_awg" value="awg2">
|
||||
<label class="form-check-label" for="proto_awg">AmneziaWG 2</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="protocol" id="proto_vless" value="vless">
|
||||
<label class="form-check-label" for="proto_vless">VLESS</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">{{htmlSafe (t "hint_wait")}}</p>
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-bolt"></i> {{t "btn_get_config"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .Bundles}}
|
||||
<div class="card share-panel card--download">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-list"></i> {{t "configs_title"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<p class="share-panel__intro">{{htmlSafe (t "configs_intro" "count" (printf "%d" (len .Bundles)))}}</p>
|
||||
{{range .Bundles}}
|
||||
<div class="dl-bundle">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong class="mono">{{.ConnectionName}}</strong>
|
||||
<span class="badge text-bg-secondary">{{.Protocol}}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
{{if .Conf}}<a class="btn btn-sm btn-outline-info" href="{{shareDL $.Token .CreationID "conf"}}"><i class="fa-solid fa-download"></i> {{.Conf.Filename}}</a>{{end}}
|
||||
{{if .Vpn}}<a class="btn btn-sm btn-outline-info" href="{{shareDL $.Token .CreationID "vpn"}}"><i class="fa-solid fa-download"></i> {{.Vpn.Filename}}</a>{{end}}
|
||||
{{if .Zip}}<a class="btn btn-sm btn-outline-secondary" href="{{shareDL $.Token .CreationID "zip"}}"><i class="fa-solid fa-file-zipper"></i> {{t "btn_download_zip"}}</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not $.Maintenance}}
|
||||
<details class="mb-2">
|
||||
<summary class="hint" style="cursor:pointer;">{{t "migrate_title"}}</summary>
|
||||
<form method="post" action="{{appURL "share"}}" class="mt-2">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="migrate">
|
||||
<input type="hidden" name="k" value="{{$.Token}}">
|
||||
<input type="hidden" name="creation_id" value="{{.CreationID}}">
|
||||
<div class="row g-2">
|
||||
<div class="col-7">
|
||||
<select class="form-select form-select-sm" name="new_server_id" required>
|
||||
{{range $.Servers}}<option value="{{.ID}}" {{if .Disabled}}disabled{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<select class="form-select form-select-sm" name="new_protocol">
|
||||
<option value="wireguard">WireGuard</option>
|
||||
<option value="awg2">AmneziaWG 2</option>
|
||||
<option value="vless">VLESS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">{{t "migrate_warning"}}</p>
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning w-100"><i class="fa-solid fa-arrows-turn-right"></i> {{t "migrate_submit"}}</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .Expired}}
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__head"><i class="fa-solid fa-key"></i> {{t "renewal_title"}}</div>
|
||||
<div class="share-panel__body">
|
||||
<p class="hint">{{t "renewal_hint"}}</p>
|
||||
<form method="post" action="{{appURL "share"}}">
|
||||
<input type="hidden" name="_csrf" value="{{csrf}}">
|
||||
<input type="hidden" name="action" value="renew">
|
||||
<input type="hidden" name="k" value="{{.Token}}">
|
||||
<div class="mb-2"><input class="form-control" name="code" placeholder="{{t "renewal_code_ph"}}" required></div>
|
||||
<button class="btn btn-outline-info w-100" type="submit"><i class="fa-solid fa-arrow-rotate-right"></i> {{t "renewal_btn"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "guest_subnav" .}}
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{end}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "page_status"}}
|
||||
{{template "guest_shell_open" dict "Title" (t "status_page_title") "Lang" (lang)}}
|
||||
<div class="cyber-shell share-faq-page">
|
||||
<div class="cyber-hero cyber-hero--compact">
|
||||
<div class="hero-icon hero-icon--sm"><i class="fa-solid fa-tower-broadcast"></i></div>
|
||||
<h1>{{t "status_link"}}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card share-panel">
|
||||
<div class="share-panel__body">
|
||||
{{if not .Servers}}
|
||||
<p class="hint mb-0">{{t "js_servers_empty"}}</p>
|
||||
{{else}}
|
||||
<div class="tbl-wrap">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Сервер</th><th>Статус</th><th>Задержка</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Servers}}
|
||||
<tr>
|
||||
<td>{{if .Flag}}{{.Flag}} {{end}}{{.Label}}</td>
|
||||
<td>{{if .Alive}}<span class="badge-ok badge"><i class="fa-solid fa-circle-check"></i> {{t "status_up"}}</span>{{else}}<span class="badge-err badge"><i class="fa-solid fa-circle-xmark"></i> {{t "status_down"}}</span>{{end}}</td>
|
||||
<td>{{if .Alive}}{{.Ms}} ms{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "guest_subnav" .}}
|
||||
<p class="text-center mt-3"><a class="guest-back-link" href="{{appURL "share"}}"><i class="fa-solid fa-arrow-left"></i> Назад</a></p>
|
||||
</div>
|
||||
{{template "guest_footer" .}}
|
||||
{{template "guest_shell_close" .}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user