Add Go rewrite with Postgres 17 and Dokploy Docker Compose

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-27 08:21:21 +03:00
co-authored by Cursor
parent bac78dd3fc
commit 99a27f00be
67 changed files with 17864 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.git
*.md
tmp/
**/*_test.go
.idea/
.vscode/
+21
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.env
*.exe
/bin/
/tmp/
.idea/
.vscode/
*.log
+7
View File
@@ -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.
+35
View File
@@ -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
View File
@@ -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, до реального деплоя.
+59
View File
@@ -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)
}
}
+60
View File
@@ -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("сервер остановлен.")
}
+110
View File
@@ -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
+19
View File
@@ -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
)
+90
View File
@@ -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=
+168
View File
@@ -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)
}
+289
View File
@@ -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
}
+71
View File
@@ -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
}
+91
View File
@@ -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
}
+401
View File
@@ -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 1060 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 wont 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 wont 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
}
+665
View File
@@ -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
}
+131
View File
@@ -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:])
}
+341
View File
@@ -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
}
+223
View File
@@ -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
}
+361
View File
@@ -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
+294
View File
@@ -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 = &note
}
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
}
+833
View File
@@ -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)
}
+136
View File
@@ -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)
}
+341
View File
@@ -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")
}
+40
View File
@@ -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}
}
+114
View File
@@ -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)
}
}
+53
View File
@@ -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)
}
}
+202
View File
@@ -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)
}
}
+78
View File
@@ -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)
}
}
+255
View File
@@ -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)
}
}
+122
View File
@@ -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)
}
}
+252
View File
@@ -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)
})
}
+138
View File
@@ -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)
}
+79
View File
@@ -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})
}
}
+34
View File
@@ -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),
})
}
}
+71
View File
@@ -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")
}
}
+100
View File
@@ -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")
}
}
+65
View File
@@ -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: &note, 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)
}
}
}
+277
View File
@@ -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))
}
+27
View File
@@ -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)
}
+101
View File
@@ -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)
})
}
+39
View File
@@ -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)
}
}
}
+132
View File
@@ -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
+619
View File
@@ -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
+259
View File
@@ -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;
}
+465
View File
@@ -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
+56
View File
@@ -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}}
+39
View File
@@ -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}}
+180
View File
@@ -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}}
+81
View File
@@ -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}}
+82
View File
@@ -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}}
+80
View File
@@ -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}}
+47
View File
@@ -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}}
+42
View File
@@ -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}}
+128
View File
@@ -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}}
+57
View File
@@ -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}}
+67
View File
@@ -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}}
+56
View File
@@ -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 &copy; {{year}}</span>
</div>
{{end}}
+23
View File
@@ -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}}
+47
View File
@@ -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}}
+173
View File
@@ -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}}&amp;lang=ru">RU</a>
<a class="lang-switch__btn {{if eq2 (lang) "en"}}active{{end}}" href="?k={{.Token}}&amp;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}}
+37
View File
@@ -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}}