mirror of
https://git.evilfox.cc/test2/wg.git
synced 2026-07-31 11:53:22 +00:00
Add Go WireGuard panel with PostgreSQL 17 and Dokploy compose.
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
bin/
|
||||||
|
tmp/
|
||||||
|
archivem3tAG/
|
||||||
|
archivem3tAG.tar.gz
|
||||||
|
*.exe
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Dokploy / Docker Compose env (copy to .env and edit)
|
||||||
|
|
||||||
|
APP_URL=https://wg.example.com
|
||||||
|
SITE_NAME=Evilfox.cc
|
||||||
|
SECURE_COOKIES=true
|
||||||
|
SESSION_SECRET=replace-with-long-random-secret
|
||||||
|
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_EMAIL=admin@evilfox.cc
|
||||||
|
ADMIN_PASSWORD=replace-with-strong-admin-password
|
||||||
|
|
||||||
|
POSTGRES_DB=wg
|
||||||
|
POSTGRES_USER=wg
|
||||||
|
POSTGRES_PASSWORD=replace-with-strong-db-password
|
||||||
|
|
||||||
|
AUTO_IMPORT_MYSQL=true
|
||||||
|
APP_PUBLISH_PORT=8080
|
||||||
|
TZ=Europe/Moscow
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.exe
|
||||||
|
bin/
|
||||||
|
tmp/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/wg-panel ./cmd/server
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata curl
|
||||||
|
COPY --from=builder /out/wg-panel /app/wg-panel
|
||||||
|
COPY migrations /app/migrations
|
||||||
|
COPY web /app/web
|
||||||
|
COPY wg.sql /data/wg.sql
|
||||||
|
ENV APP_ROOT=/app \
|
||||||
|
MIGRATIONS_DIR=/app/migrations \
|
||||||
|
TEMPLATES_DIR=/app/web/templates \
|
||||||
|
STATIC_DIR=/app/web/static \
|
||||||
|
MYSQL_DUMP_PATH=/data/wg.sql \
|
||||||
|
APP_PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8080/login >/dev/null || exit 1
|
||||||
|
USER nobody
|
||||||
|
CMD ["/app/wg-panel"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/evilfox/wg-panel/internal/auth"
|
||||||
|
"github.com/evilfox/wg-panel/internal/config"
|
||||||
|
"github.com/evilfox/wg-panel/internal/db"
|
||||||
|
"github.com/evilfox/wg-panel/internal/handlers"
|
||||||
|
"github.com/evilfox/wg-panel/internal/mysqlimport"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
root := envOr("APP_ROOT", ".")
|
||||||
|
migrationsDir := envOr("MIGRATIONS_DIR", filepath.Join(root, "migrations"))
|
||||||
|
templatesDir := envOr("TEMPLATES_DIR", filepath.Join(root, "web", "templates"))
|
||||||
|
staticDir := envOr("STATIC_DIR", filepath.Join(root, "web", "static"))
|
||||||
|
|
||||||
|
pool, err := waitForDB(ctx, cfg.DatabaseURL, 60*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := db.Migrate(ctx, pool, migrationsDir); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AutoImportDump {
|
||||||
|
if _, err := os.Stat(cfg.MySQLDumpPath); err == nil {
|
||||||
|
if err := mysqlimport.ImportFile(ctx, pool, cfg.MySQLDumpPath); err != nil {
|
||||||
|
log.Fatalf("mysql import: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("mysql dump not found at %s, skip import", cfg.MySQLDumpPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authStore := auth.NewStore(cfg, pool)
|
||||||
|
if err := authStore.EnsureAdmin(ctx); err != nil {
|
||||||
|
log.Fatalf("ensure admin: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("admin ready: username=%s email=%s", cfg.AdminUsername, cfg.AdminEmail)
|
||||||
|
|
||||||
|
app, err := handlers.New(pool, authStore, cfg, templatesDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("handlers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(app.OfflineMiddleware)
|
||||||
|
|
||||||
|
fileServer(r, "/static/", http.Dir(staticDir))
|
||||||
|
|
||||||
|
r.Get("/", app.Home)
|
||||||
|
r.Get("/login", app.LoginPage)
|
||||||
|
r.Post("/login", app.LoginPost)
|
||||||
|
r.Get("/logout", app.Logout)
|
||||||
|
r.Get("/wg", app.PublicWG)
|
||||||
|
r.Get("/wg_config.php", app.PublicWG)
|
||||||
|
|
||||||
|
r.Route("/admin", func(r chi.Router) {
|
||||||
|
r.Use(authStore.RequireAdmin)
|
||||||
|
r.Get("/", app.AdminDashboard)
|
||||||
|
r.Post("/status", app.AdminStatusPost)
|
||||||
|
r.Get("/wg", app.AdminWG)
|
||||||
|
r.Post("/wg/upload", app.AdminWGUpload)
|
||||||
|
r.Post("/wg/delete", app.AdminWGDelete)
|
||||||
|
r.Post("/wg/settings", app.AdminWGSettings)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.AppPort,
|
||||||
|
Handler: r,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("listening on :%s (%s)", cfg.AppPort, cfg.AppURL)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-stop
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDB(ctx context.Context, databaseURL string, timeout time.Duration) (*pgxpool.Pool, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
var last error
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
pool, err := db.Connect(ctx, databaseURL)
|
||||||
|
if err == nil {
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
last = err
|
||||||
|
log.Printf("waiting for postgres: %v", err)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
return nil, last
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileServer(r chi.Router, pathPrefix string, root http.FileSystem) {
|
||||||
|
r.Handle(pathPrefix+"*", http.StripPrefix(pathPrefix, http.FileServer(root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-wg}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-wg}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
networks:
|
||||||
|
- wg
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${APP_PUBLISH_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
APP_PORT: "8080"
|
||||||
|
APP_URL: ${APP_URL:-http://localhost:8080}
|
||||||
|
SITE_NAME: ${SITE_NAME:-Evilfox.cc}
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-wg}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-wg}?sslmode=disable
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@evilfox.cc}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD}
|
||||||
|
AUTO_IMPORT_MYSQL: ${AUTO_IMPORT_MYSQL:-true}
|
||||||
|
MYSQL_DUMP_PATH: /data/wg.sql
|
||||||
|
SECURE_COOKIES: ${SECURE_COOKIES:-false}
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
networks:
|
||||||
|
- wg
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
wg:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module github.com/evilfox/wg-panel
|
||||||
|
|
||||||
|
go 1.26.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
|
github.com/gorilla/sessions v1.4.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
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.22.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/gob"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/evilfox/wg-panel/internal/config"
|
||||||
|
"github.com/evilfox/wg-panel/internal/models"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(int64(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionName = "wg_session"
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
Sessions *sessions.CookieStore
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
Cfg config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(cfg config.Config, db *pgxpool.Pool) *Store {
|
||||||
|
store := sessions.NewCookieStore([]byte(cfg.SessionSecret))
|
||||||
|
store.Options = &sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cfg.SecureCookies,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 0,
|
||||||
|
}
|
||||||
|
return &Store{Sessions: store, DB: db, Cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) EnsureAdmin(ctx context.Context) error {
|
||||||
|
if s.Cfg.AdminPassword == "" {
|
||||||
|
return errors.New("ADMIN_PASSWORD is required in environment")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(s.Cfg.AdminPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err = s.DB.QueryRow(ctx, `SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1`).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
_, err = s.DB.Exec(ctx, `
|
||||||
|
INSERT INTO users (username, email, password_hash, role, is_approved)
|
||||||
|
VALUES ($1, $2, $3, 'admin', TRUE)
|
||||||
|
ON CONFLICT (username) DO UPDATE
|
||||||
|
SET email = EXCLUDED.email,
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
role = 'admin',
|
||||||
|
is_approved = TRUE,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
s.Cfg.AdminUsername, s.Cfg.AdminEmail, string(hash),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.DB.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET username = $1,
|
||||||
|
email = $2,
|
||||||
|
password_hash = $3,
|
||||||
|
role = 'admin',
|
||||||
|
is_approved = TRUE,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
s.Cfg.AdminUsername, s.Cfg.AdminEmail, string(hash), id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Authenticate(ctx context.Context, login, password string) (*models.User, error) {
|
||||||
|
var u models.User
|
||||||
|
err := s.DB.QueryRow(ctx, `
|
||||||
|
SELECT id, username, email, password_hash, role, is_approved, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE username = $1 OR email = $1
|
||||||
|
LIMIT 1`, login,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.Role, &u.IsApproved, &u.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !u.IsApproved {
|
||||||
|
return nil, errors.New("account pending approval")
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Login(w http.ResponseWriter, r *http.Request, u *models.User, remember bool) error {
|
||||||
|
sess, _ := s.Sessions.Get(r, SessionName)
|
||||||
|
sess.Values["user_id"] = u.ID
|
||||||
|
sess.Values["username"] = u.Username
|
||||||
|
sess.Values["role"] = u.Role
|
||||||
|
sess.Values["remember"] = remember
|
||||||
|
if remember {
|
||||||
|
sess.Options.MaxAge = int(s.Cfg.RememberDuration().Seconds())
|
||||||
|
} else {
|
||||||
|
sess.Options.MaxAge = 0
|
||||||
|
}
|
||||||
|
return sess.Save(r, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Logout(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
sess, _ := s.Sessions.Get(r, SessionName)
|
||||||
|
sess.Options.MaxAge = -1
|
||||||
|
sess.Values = map[interface{}]interface{}{}
|
||||||
|
return sess.Save(r, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CurrentUser(r *http.Request) *models.SessionUser {
|
||||||
|
sess, err := s.Sessions.Get(r, SessionName)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id, ok := sess.Values["user_id"].(int64)
|
||||||
|
if !ok {
|
||||||
|
// gorilla may decode numbers as int
|
||||||
|
switch v := sess.Values["user_id"].(type) {
|
||||||
|
case int:
|
||||||
|
id = int64(v)
|
||||||
|
ok = true
|
||||||
|
case float64:
|
||||||
|
id = int64(v)
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok || id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
username, _ := sess.Values["username"].(string)
|
||||||
|
role, _ := sess.Values["role"].(string)
|
||||||
|
return &models.SessionUser{ID: id, Username: username, Role: role}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RequireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.CurrentUser(r)
|
||||||
|
if u == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Role != "admin" {
|
||||||
|
http.Error(w, "Доступ запрещён", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatDuration(d time.Duration) string {
|
||||||
|
return fmt.Sprintf("%d", int(d.Seconds()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AppPort string
|
||||||
|
AppURL string
|
||||||
|
SiteName string
|
||||||
|
DatabaseURL string
|
||||||
|
SessionSecret string
|
||||||
|
AdminUsername string
|
||||||
|
AdminEmail string
|
||||||
|
AdminPassword string
|
||||||
|
MySQLDumpPath string
|
||||||
|
AutoImportDump bool
|
||||||
|
SecureCookies bool
|
||||||
|
RememberDays int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
AppPort: getenv("APP_PORT", "8080"),
|
||||||
|
AppURL: strings.TrimRight(getenv("APP_URL", "http://localhost:8080"), "/"),
|
||||||
|
SiteName: getenv("SITE_NAME", "Evilfox.cc"),
|
||||||
|
DatabaseURL: getenv("DATABASE_URL", "postgres://wg:wg@localhost:5432/wg?sslmode=disable"),
|
||||||
|
SessionSecret: getenv("SESSION_SECRET", "change-me-session-secret-32chars!!"),
|
||||||
|
AdminUsername: getenv("ADMIN_USERNAME", "admin"),
|
||||||
|
AdminEmail: getenv("ADMIN_EMAIL", "admin@evilfox.cc"),
|
||||||
|
AdminPassword: getenv("ADMIN_PASSWORD", ""),
|
||||||
|
MySQLDumpPath: getenv("MYSQL_DUMP_PATH", "/data/wg.sql"),
|
||||||
|
AutoImportDump: getenvBool("AUTO_IMPORT_MYSQL", true),
|
||||||
|
SecureCookies: getenvBool("SECURE_COOKIES", false),
|
||||||
|
RememberDays: getenvInt("REMEMBER_DAYS", 30),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) RememberDuration() time.Duration {
|
||||||
|
return time.Duration(c.RememberDays) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, def string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvBool(key string, def bool) bool {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvInt(key string, def int) int {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
||||||
|
path := filepath.Join(migrationsDir, "001_schema.sql")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(data)); err != nil {
|
||||||
|
return fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("database schema applied")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/evilfox/wg-panel/internal/auth"
|
||||||
|
"github.com/evilfox/wg-panel/internal/config"
|
||||||
|
"github.com/evilfox/wg-panel/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
Auth *auth.Store
|
||||||
|
Cfg config.Config
|
||||||
|
Templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *pgxpool.Pool, a *auth.Store, cfg config.Config, templatesDir string) (*App, error) {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"appURL": func() string { return cfg.AppURL },
|
||||||
|
"siteName": func() string { return cfg.SiteName },
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("").Funcs(funcs).ParseGlob(filepath.Join(templatesDir, "*.html"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &App{DB: db, Auth: a, Cfg: cfg, Templates: tmpl}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) render(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := a.Templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
log.Printf("template %s: %v", name, err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SiteOnline(ctx context.Context) (models.SiteStatus, error) {
|
||||||
|
var s models.SiteStatus
|
||||||
|
err := a.DB.QueryRow(ctx, `SELECT id, is_online, message FROM site_status WHERE id = 1`).Scan(&s.ID, &s.IsOnline, &s.Message)
|
||||||
|
if err != nil {
|
||||||
|
return models.SiteStatus{IsOnline: true, Message: ""}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) OfflineMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/login") || strings.HasPrefix(r.URL.Path, "/admin") || strings.HasPrefix(r.URL.Path, "/static") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := a.Auth.CurrentUser(r)
|
||||||
|
if u != nil && u.Role == "admin" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := a.SiteOnline(r.Context())
|
||||||
|
if err == nil && !st.IsOnline {
|
||||||
|
a.render(w, "offline.html", map[string]any{"Message": st.Message, "SiteName": a.Cfg.SiteName})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := a.Auth.CurrentUser(r)
|
||||||
|
a.render(w, "home.html", map[string]any{"User": u})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if u := a.Auth.CurrentUser(r); u != nil {
|
||||||
|
if u.Role == "admin" {
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, "login.html", map[string]any{"Error": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.render(w, "login.html", map[string]any{"Error": "Некорректная форма"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
remember := r.FormValue("remember_me") == "1"
|
||||||
|
u, err := a.Auth.Authenticate(r.Context(), username, password)
|
||||||
|
if err != nil {
|
||||||
|
msg := "Неверное имя пользователя или пароль."
|
||||||
|
if err.Error() == "account pending approval" {
|
||||||
|
msg = "Ваш аккаунт ожидает одобрения администратором."
|
||||||
|
}
|
||||||
|
a.render(w, "login.html", map[string]any{"Error": msg, "Username": username})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Auth.Login(w, r, u, remember); err != nil {
|
||||||
|
a.render(w, "login.html", map[string]any{"Error": "Ошибка сессии"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Role == "admin" {
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = a.Auth.Logout(w, r)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := a.Auth.CurrentUser(r)
|
||||||
|
var totalConfigs, totalUsers int
|
||||||
|
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM wg_configs`).Scan(&totalConfigs)
|
||||||
|
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM users`).Scan(&totalUsers)
|
||||||
|
st, _ := a.SiteOnline(r.Context())
|
||||||
|
a.render(w, "admin_dashboard.html", map[string]any{
|
||||||
|
"User": u,
|
||||||
|
"TotalConfigs": totalConfigs,
|
||||||
|
"TotalUsers": totalUsers,
|
||||||
|
"SiteOnline": st.IsOnline,
|
||||||
|
"SiteMessage": st.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminStatusPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
online := r.FormValue("is_online") == "1"
|
||||||
|
message := strings.TrimSpace(r.FormValue("message"))
|
||||||
|
if message == "" {
|
||||||
|
message = "Сайт временно недоступен."
|
||||||
|
}
|
||||||
|
_, _ = a.DB.Exec(r.Context(), `
|
||||||
|
UPDATE site_status SET is_online = $1, message = $2, updated_at = NOW() WHERE id = 1`, online, message)
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(n int) (string, error) {
|
||||||
|
const chars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b[i] = chars[v.Int64()]
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminWG(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := a.Auth.CurrentUser(r)
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
const perPage = 10
|
||||||
|
var total int
|
||||||
|
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM wg_configs`).Scan(&total)
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
rows, err := a.DB.Query(r.Context(), `
|
||||||
|
SELECT id, COALESCE(token,''), original_filename, created_at
|
||||||
|
FROM wg_configs ORDER BY created_at DESC LIMIT $1 OFFSET $2`, perPage, offset)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var configs []models.WGConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.WGConfig
|
||||||
|
if err := rows.Scan(&c.ID, &c.Token, &c.OriginalFilename, &c.CreatedAt); err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configs = append(configs, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := r.URL.Query().Get("msg")
|
||||||
|
msgType := r.URL.Query().Get("type")
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
settings := a.wgSettings(r.Context())
|
||||||
|
a.render(w, "admin_wg.html", map[string]any{
|
||||||
|
"User": u,
|
||||||
|
"Configs": configs,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"Message": msg,
|
||||||
|
"MessageType": msgType,
|
||||||
|
"NewToken": token,
|
||||||
|
"PublicURL": a.Cfg.AppURL + "/wg?token=",
|
||||||
|
"ShowZipFull": settings["show_zip_full"] == "1",
|
||||||
|
"ShowZipConf": settings["show_zip_conf"] == "1",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminWGUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Ошибка формы")+"&type=error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, hdr, err := r.FormFile("wg_conf")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Файл не выбран")+"&type=error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(hdr.Filename), ".conf") {
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Разрешены только .conf")+"&type=error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(io.LimitReader(file, 10000))
|
||||||
|
if err != nil || len(content) == 0 {
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Не удалось прочитать файл")+"&type=error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
for {
|
||||||
|
token, err = generateToken(5)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Ошибка токена")+"&type=error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
_ = a.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM wg_configs WHERE token=$1)`, token).Scan(&exists)
|
||||||
|
if !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orig := path.Base(hdr.Filename)
|
||||||
|
_, err = a.DB.Exec(r.Context(), `
|
||||||
|
INSERT INTO wg_configs (token, config_content, original_filename)
|
||||||
|
VALUES ($1, $2, $3)`, token, string(content), orig)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Ошибка сохранения")+"&type=error", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Конфиг сохранён")+"&type=success&token="+token, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminWGDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
|
if id > 0 {
|
||||||
|
_, _ = a.DB.Exec(r.Context(), `DELETE FROM wg_configs WHERE id=$1`, id)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/wg", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PublicWG(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := sanitizeToken(r.URL.Query().Get("token"))
|
||||||
|
if len(token) < 5 {
|
||||||
|
http.Error(w, "Неверный токен", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
download := r.URL.Query().Get("download")
|
||||||
|
|
||||||
|
var cfg models.WGConfig
|
||||||
|
err := a.DB.QueryRow(r.Context(), `
|
||||||
|
SELECT id, COALESCE(token,''), config_content, original_filename
|
||||||
|
FROM wg_configs WHERE token=$1`, token,
|
||||||
|
).Scan(&cfg.ID, &cfg.Token, &cfg.ConfigContent, &cfg.OriginalFilename)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
http.Error(w, "Конфиг не найден", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileName := sanitizeFilename(cfg.OriginalFilename)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(fileName), ".conf") {
|
||||||
|
fileName += ".conf"
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := a.wgSettings(r.Context())
|
||||||
|
|
||||||
|
if download == "full" || download == "conf" {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
fw, err := zw.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "zip error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fw.Write([]byte(cfg.ConfigContent))
|
||||||
|
if download == "full" {
|
||||||
|
png, err := qrcode.Encode(cfg.ConfigContent, qrcode.Medium, 300)
|
||||||
|
if err == nil {
|
||||||
|
qrw, _ := zw.Create("qr_code.png")
|
||||||
|
_, _ = qrw.Write(png)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = zw.Close()
|
||||||
|
name := fmt.Sprintf("wg_config_%s.zip", token)
|
||||||
|
if download == "conf" {
|
||||||
|
name = fmt.Sprintf("wg_config_%s_only.conf.zip", token)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
png, err := qrcode.Encode(cfg.ConfigContent, qrcode.Medium, 300)
|
||||||
|
qrData := ""
|
||||||
|
if err == nil {
|
||||||
|
qrData = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, "wg_public.html", map[string]any{
|
||||||
|
"FileName": fileName,
|
||||||
|
"Token": token,
|
||||||
|
"QR": qrData,
|
||||||
|
"Config": cfg.ConfigContent,
|
||||||
|
"ShowZipFull": settings["show_zip_full"] == "1",
|
||||||
|
"ShowZipConf": settings["show_zip_conf"] == "1",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) wgSettings(ctx context.Context) map[string]string {
|
||||||
|
out := map[string]string{"show_zip_full": "0", "show_zip_conf": "1"}
|
||||||
|
rows, err := a.DB.Query(ctx, `SELECT setting_key, setting_value FROM wg_config_settings`)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if rows.Scan(&k, &v) == nil {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminWGSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
full := "0"
|
||||||
|
conf := "0"
|
||||||
|
if r.FormValue("show_zip_full") == "1" {
|
||||||
|
full = "1"
|
||||||
|
}
|
||||||
|
if r.FormValue("show_zip_conf") == "1" {
|
||||||
|
conf = "1"
|
||||||
|
}
|
||||||
|
_, _ = a.DB.Exec(r.Context(), `
|
||||||
|
INSERT INTO wg_config_settings (setting_key, setting_value, updated_at)
|
||||||
|
VALUES ('show_zip_full', $1, NOW())
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()`, full)
|
||||||
|
_, _ = a.DB.Exec(r.Context(), `
|
||||||
|
INSERT INTO wg_config_settings (setting_key, setting_value, updated_at)
|
||||||
|
VALUES ('show_zip_conf', $1, NOW())
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()`, conf)
|
||||||
|
http.Redirect(w, r, "/admin/wg", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeToken(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(s string) string {
|
||||||
|
s = path.Base(s)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return "wg_config.conf"
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlQ(s string) string {
|
||||||
|
return url.QueryEscape(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
Role string
|
||||||
|
IsApproved bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type WGConfig struct {
|
||||||
|
ID int64
|
||||||
|
Token string
|
||||||
|
UserID *int64
|
||||||
|
ConfigContent string
|
||||||
|
CreatedAt time.Time
|
||||||
|
OriginalFilename string
|
||||||
|
IsDemo bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteStatus struct {
|
||||||
|
ID int64
|
||||||
|
IsOnline bool
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionUser struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package mysqlimport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reInsert = regexp.MustCompile(`(?is)INSERT INTO\s+\x60?(\w+)\x60?\s+VALUES\s*(.+?);`)
|
||||||
|
reCreate = regexp.MustCompile(`(?is)CREATE TABLE\s+\x60?(\w+)\x60?`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportFile converts and loads a MySQL dump into PostgreSQL once.
|
||||||
|
func ImportFile(ctx context.Context, pool *pgxpool.Pool, dumpPath string) error {
|
||||||
|
var done string
|
||||||
|
err := pool.QueryRow(ctx, `SELECT value FROM app_meta WHERE key = 'mysql_import_done'`).Scan(&done)
|
||||||
|
if err == nil && done == "1" {
|
||||||
|
log.Println("mysql import already done, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(dumpPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read dump: %w", err)
|
||||||
|
}
|
||||||
|
if !utf8.Valid(raw) {
|
||||||
|
// try windows-1251/latin1 fallback: replace invalid
|
||||||
|
raw = []byte(strings.ToValidUTF8(string(raw), "?"))
|
||||||
|
}
|
||||||
|
text := string(raw)
|
||||||
|
|
||||||
|
tablesOrder := []string{
|
||||||
|
"users",
|
||||||
|
"site_status",
|
||||||
|
"servers",
|
||||||
|
"servers_simple",
|
||||||
|
"countries",
|
||||||
|
"wg_configs",
|
||||||
|
"wg_config_settings",
|
||||||
|
"demo_keys",
|
||||||
|
"awg_configs",
|
||||||
|
"amnezia_configs",
|
||||||
|
"vless_configs",
|
||||||
|
"admin_uploads",
|
||||||
|
"user_servers",
|
||||||
|
"license_keys",
|
||||||
|
"rate_limits",
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Temporarily disable FK checks via deferred constraints where possible
|
||||||
|
if _, err := tx.Exec(ctx, `SET session_replication_role = replica`); err != nil {
|
||||||
|
log.Printf("warn: cannot set session_replication_role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inserts := map[string][]string{}
|
||||||
|
for _, m := range reInsert.FindAllStringSubmatch(text, -1) {
|
||||||
|
table := strings.ToLower(m[1])
|
||||||
|
valuesPart := strings.TrimSpace(m[2])
|
||||||
|
rows, err := splitValueTuples(valuesPart)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse inserts for %s: %w", table, err)
|
||||||
|
}
|
||||||
|
inserts[table] = append(inserts[table], rows...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tablesOrder {
|
||||||
|
rows := inserts[table]
|
||||||
|
if len(rows) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("importing %s (%d rows)", table, len(rows))
|
||||||
|
if err := importTable(ctx, tx, table, rows); err != nil {
|
||||||
|
return fmt.Errorf("import %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix sequences
|
||||||
|
for _, table := range tablesOrder {
|
||||||
|
_, _ = tx.Exec(ctx, fmt.Sprintf(`
|
||||||
|
SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM %s), 1), true)
|
||||||
|
`, table, table))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO app_meta (key, value, updated_at)
|
||||||
|
VALUES ('mysql_import_done', '1', NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = '1', updated_at = NOW()`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `SET session_replication_role = DEFAULT`); err != nil {
|
||||||
|
log.Printf("warn: restore session_replication_role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Println("mysql dump imported successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importTable(ctx context.Context, tx pgx.Tx, table string, rows []string) error {
|
||||||
|
cols, err := tableColumns(table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
vals, err := parseRowValues(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(vals) != len(cols) {
|
||||||
|
// try truncate/pad carefully
|
||||||
|
if len(vals) < len(cols) {
|
||||||
|
return fmt.Errorf("column count mismatch for %s: got %d want %d value=%s", table, len(vals), len(cols), truncate(row, 120))
|
||||||
|
}
|
||||||
|
vals = vals[:len(cols)]
|
||||||
|
}
|
||||||
|
converted := make([]any, len(vals))
|
||||||
|
placeholders := make([]string, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
converted[i] = convertValue(table, cols[i], v)
|
||||||
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||||
|
}
|
||||||
|
sql := fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (%s) VALUES (%s) ON CONFLICT DO NOTHING`,
|
||||||
|
table,
|
||||||
|
strings.Join(quoteIdent(cols), ", "),
|
||||||
|
strings.Join(placeholders, ", "),
|
||||||
|
)
|
||||||
|
if _, err := tx.Exec(ctx, sql, converted...); err != nil {
|
||||||
|
return fmt.Errorf("insert failed (%s): %w; sample=%s", table, err, truncate(row, 160))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableColumns(table string) ([]string, error) {
|
||||||
|
m := map[string][]string{
|
||||||
|
"admin_uploads": {"id", "original_name", "stored_name", "file_path", "file_url", "file_size", "uploaded_at"},
|
||||||
|
"amnezia_configs": {"id", "config_name", "original_filename", "file_path", "file_url", "config_content", "host", "port", "username", "password", "description", "created_at"},
|
||||||
|
"awg_configs": {"id", "config_name", "config_content", "qr_code_url", "created_at"},
|
||||||
|
"countries": {"id", "code", "name_ru", "name_en", "flag", "sort_order"},
|
||||||
|
"demo_keys": {"id", "config_id", "token", "expires_at", "created_at"},
|
||||||
|
"license_keys": {"id", "key_value", "server_id", "protocol", "client_id", "email", "used_by", "created_at", "expires_at"},
|
||||||
|
"rate_limits": {"id", "user_id", "action", "created_at"},
|
||||||
|
"servers": {"id", "name", "ip", "port", "panel_username", "panel_password", "status", "created_at", "updated_at", "inbound_id"},
|
||||||
|
"servers_simple": {"id", "name", "host", "port", "country", "status", "created_at"},
|
||||||
|
"site_status": {"id", "is_online", "message", "updated_at"},
|
||||||
|
"user_servers": {"id", "user_id", "server_id", "inbound_id", "protocol", "client_id", "email", "created_at"},
|
||||||
|
"users": {"id", "username", "email", "password_hash", "role", "is_approved", "created_at", "updated_at"},
|
||||||
|
"vless_configs": {"id", "vless_uri", "subscription_url", "created_at"},
|
||||||
|
"wg_config_settings": {"setting_key", "setting_value", "updated_at"},
|
||||||
|
"wg_configs": {"id", "token", "user_id", "config_content", "created_at", "original_filename", "is_demo"},
|
||||||
|
}
|
||||||
|
cols, ok := m[table]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown table %s", table)
|
||||||
|
}
|
||||||
|
return cols, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertValue(table, col, raw string) any {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if strings.EqualFold(v, "NULL") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' {
|
||||||
|
v = unquoteMySQLString(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySQL dump has an empty country code for "Other"
|
||||||
|
if table == "countries" && col == "code" && strings.TrimSpace(v) == "" {
|
||||||
|
return "_"
|
||||||
|
}
|
||||||
|
|
||||||
|
boolCols := map[string]bool{
|
||||||
|
"is_approved": true,
|
||||||
|
"is_online": true,
|
||||||
|
"is_demo": true,
|
||||||
|
}
|
||||||
|
if boolCols[col] {
|
||||||
|
if v == "1" || strings.EqualFold(v, "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
intCols := map[string]bool{
|
||||||
|
"id": true, "user_id": true, "server_id": true, "config_id": true,
|
||||||
|
"port": true, "file_size": true, "inbound_id": true, "sort_order": true,
|
||||||
|
"used_by": true,
|
||||||
|
}
|
||||||
|
if intCols[col] {
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = table
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquoteMySQLString(s string) string {
|
||||||
|
s = s[1 : len(s)-1]
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
`\\`, `\`,
|
||||||
|
`\'`, `'`,
|
||||||
|
`\"`, `"`,
|
||||||
|
`\n`, "\n",
|
||||||
|
`\r`, "\r",
|
||||||
|
`\t`, "\t",
|
||||||
|
`\0`, "\x00",
|
||||||
|
`\Z`, "\x1a",
|
||||||
|
)
|
||||||
|
return replacer.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitValueTuples(s string) ([]string, error) {
|
||||||
|
var rows []string
|
||||||
|
depth := 0
|
||||||
|
inStr := false
|
||||||
|
esc := false
|
||||||
|
start := -1
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if inStr {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\'' {
|
||||||
|
// mysql '' escape
|
||||||
|
if i+1 < len(s) && s[i+1] == '\'' {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inStr = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\'' {
|
||||||
|
inStr = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '(' {
|
||||||
|
if depth == 0 {
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
depth++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ')' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 && start >= 0 {
|
||||||
|
rows = append(rows, s[start:i])
|
||||||
|
start = -1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth != 0 || inStr {
|
||||||
|
return nil, fmt.Errorf("unbalanced values clause")
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRowValues(row string) ([]string, error) {
|
||||||
|
var vals []string
|
||||||
|
inStr := false
|
||||||
|
esc := false
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(row); i++ {
|
||||||
|
c := row[i]
|
||||||
|
if inStr {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\'' {
|
||||||
|
if i+1 < len(row) && row[i+1] == '\'' {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inStr = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\'' {
|
||||||
|
inStr = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ',' {
|
||||||
|
vals = append(vals, strings.TrimSpace(row[start:i]))
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vals = append(vals, strings.TrimSpace(row[start:]))
|
||||||
|
return vals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteIdent(cols []string) []string {
|
||||||
|
out := make([]string, len(cols))
|
||||||
|
for i, c := range cols {
|
||||||
|
out[i] = `"` + c + `"`
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch for unused regex keep
|
||||||
|
var _ = reCreate
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package mysqlimport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDump(t *testing.T) {
|
||||||
|
path := `D:\Новая папка (10)\wg\wg.sql`
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
text := string(raw)
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, m := range reInsert.FindAllStringSubmatch(text, -1) {
|
||||||
|
table := m[1]
|
||||||
|
rows, err := splitValueTuples(m[2])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", table, err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
cols, err := tableColumns(table)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cols %s: %v", table, err)
|
||||||
|
}
|
||||||
|
vals, err := parseRowValues(row)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("row %s: %v", table, err)
|
||||||
|
}
|
||||||
|
if len(vals) != len(cols) {
|
||||||
|
t.Fatalf("%s col mismatch got=%d want=%d sample=%s", table, len(vals), len(cols), truncate(row, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts[table] += len(rows)
|
||||||
|
}
|
||||||
|
if counts["wg_configs"] < 1 {
|
||||||
|
t.Fatalf("expected wg_configs rows, got %+v", counts)
|
||||||
|
}
|
||||||
|
if counts["users"] < 1 {
|
||||||
|
t.Fatalf("expected users rows, got %+v", counts)
|
||||||
|
}
|
||||||
|
t.Logf("counts: %+v", counts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- PostgreSQL 17 schema (converted from MySQL wg.sql)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(16) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||||
|
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_status (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
is_online BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
ip VARCHAR(45) NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 54321,
|
||||||
|
panel_username VARCHAR(64) NOT NULL,
|
||||||
|
panel_password VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ,
|
||||||
|
inbound_id INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_simple (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
country VARCHAR(255) DEFAULT '?',
|
||||||
|
status VARCHAR(16) DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS countries (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
code VARCHAR(8) NOT NULL UNIQUE,
|
||||||
|
name_ru VARCHAR(128) NOT NULL,
|
||||||
|
name_en VARCHAR(128) NOT NULL,
|
||||||
|
flag VARCHAR(16) NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wg_configs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
token VARCHAR(32) UNIQUE,
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
config_content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
original_filename VARCHAR(255) NOT NULL DEFAULT 'wg_config.conf',
|
||||||
|
is_demo BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wg_configs_user_id ON wg_configs(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wg_config_settings (
|
||||||
|
setting_key VARCHAR(50) PRIMARY KEY,
|
||||||
|
setting_value VARCHAR(10) NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS demo_keys (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
config_id BIGINT NOT NULL REFERENCES wg_configs(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS awg_configs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
config_name VARCHAR(100) NOT NULL,
|
||||||
|
config_content TEXT NOT NULL,
|
||||||
|
qr_code_url VARCHAR(512) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS amnezia_configs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
config_name VARCHAR(255) NOT NULL,
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(512) NOT NULL,
|
||||||
|
file_url VARCHAR(512) NOT NULL,
|
||||||
|
config_content TEXT NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 51820,
|
||||||
|
username VARCHAR(255),
|
||||||
|
password VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vless_configs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
vless_uri TEXT NOT NULL,
|
||||||
|
subscription_url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_uploads (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
stored_name VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(512) NOT NULL,
|
||||||
|
file_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_servers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
inbound_id INTEGER NOT NULL,
|
||||||
|
protocol VARCHAR(20) NOT NULL,
|
||||||
|
client_id VARCHAR(64) NOT NULL,
|
||||||
|
email VARCHAR(128) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_servers_server ON user_servers(server_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS license_keys (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
key_value CHAR(36) NOT NULL UNIQUE,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
protocol VARCHAR(20) NOT NULL DEFAULT 'vless',
|
||||||
|
client_id VARCHAR(64) NOT NULL,
|
||||||
|
email VARCHAR(128) NOT NULL DEFAULT 'license@ultravpn.cc',
|
||||||
|
used_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rate_limits_user_action_time ON rate_limits(user_id, action, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
|
key VARCHAR(64) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO site_status (id, is_online, message)
|
||||||
|
VALUES (1, TRUE, 'Сайт работает в штатном режиме.')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO wg_config_settings (setting_key, setting_value) VALUES
|
||||||
|
('show_zip_full', '0'),
|
||||||
|
('show_zip_conf', '1')
|
||||||
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
:root {
|
||||||
|
--bg-deep: #050508;
|
||||||
|
--bg-card: rgba(12, 14, 22, 0.9);
|
||||||
|
--border: rgba(0, 212, 255, 0.15);
|
||||||
|
--border-bright: rgba(0, 212, 255, 0.4);
|
||||||
|
--accent: #00d4ff;
|
||||||
|
--accent-dim: #00a8cc;
|
||||||
|
--accent-glow: rgba(0, 212, 255, 0.25);
|
||||||
|
--text: #e8ecf4;
|
||||||
|
--text-muted: #8b95a8;
|
||||||
|
--error-bg: rgba(255, 82, 82, 0.12);
|
||||||
|
--error-border: rgba(255, 82, 82, 0.35);
|
||||||
|
--error-text: #ff9e9e;
|
||||||
|
--success-bg: rgba(0, 230, 118, 0.12);
|
||||||
|
--success-text: #00e676;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
--font-sans: 'Outfit', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.bg-pattern {
|
||||||
|
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 50%, transparent 100%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 212, 255, 0.03) 2px, rgba(0, 212, 255, 0.03) 4px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0, 212, 255, 0.03) 2px, rgba(0, 212, 255, 0.03) 4px);
|
||||||
|
}
|
||||||
|
.bg-glow {
|
||||||
|
position: fixed; width: 120%; height: 55vh; top: -10%; left: -10%; z-index: 0; pointer-events: none;
|
||||||
|
background: radial-gradient(ellipse at 50% 0%, var(--accent-glow) 0%, transparent 55%);
|
||||||
|
}
|
||||||
|
.wrap { position: relative; z-index: 1; max-width: 1000px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 1.5rem; margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1, .title { font-family: var(--font-mono); color: var(--accent); }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.btn {
|
||||||
|
display: inline-block; padding: 0.75rem 1.2rem; font-family: var(--font-mono); font-size: 0.9rem;
|
||||||
|
color: var(--bg-deep); background: var(--accent); border: 1px solid var(--accent); border-radius: 10px;
|
||||||
|
text-decoration: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--accent-dim); box-shadow: 0 0 20px var(--accent-glow); }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent; color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: rgba(0, 212, 255, 0.1); }
|
||||||
|
.btn-danger { background: transparent; color: var(--error-text); border-color: var(--error-border); }
|
||||||
|
input[type=text], input[type=password], input[type=email], textarea, input[type=file] {
|
||||||
|
width: 100%; padding: 0.85rem 1rem; color: var(--text); background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border); border-radius: 10px; outline: none; font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||||
|
label { display: block; font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.45rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.form-group { margin-bottom: 1.1rem; }
|
||||||
|
.alert { padding: 0.9rem 1rem; border-radius: 10px; margin-bottom: 1rem; }
|
||||||
|
.alert.error { background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text); }
|
||||||
|
.alert.success { background: var(--success-bg); border: 1px solid rgba(0,230,118,0.3); color: var(--success-text); }
|
||||||
|
.nav { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-bottom: 1.5rem; }
|
||||||
|
.nav a { color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 0.9rem; }
|
||||||
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
|
.table th, .table td { text-align: left; padding: 0.75rem 0.5rem; border-bottom: 1px solid var(--border); font-size: 0.92rem; vertical-align: top; }
|
||||||
|
.table th { color: var(--text-muted); font-family: var(--font-mono); font-weight: 500; }
|
||||||
|
code { font-family: var(--font-mono); color: var(--accent); }
|
||||||
|
.pager { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center; }
|
||||||
|
.login-box {
|
||||||
|
position: relative; z-index: 1; width: 100%; max-width: 420px; margin: 8vh auto;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 2.25rem;
|
||||||
|
}
|
||||||
|
.center { text-align: center; }
|
||||||
|
.qr img { border-radius: 12px; border: 1px solid var(--border); }
|
||||||
|
pre.config {
|
||||||
|
font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.5; white-space: pre-wrap; word-break: break-all;
|
||||||
|
background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; max-height: 320px; overflow: auto;
|
||||||
|
}
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||||
|
.stat { padding: 1.2rem; border: 1px solid var(--border); border-radius: 12px; background: rgba(0,0,0,0.25); }
|
||||||
|
.stat .n { font-family: var(--font-mono); font-size: 1.8rem; color: var(--accent); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { transition-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "admin_dashboard.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
{{template "layout_head" .}}
|
||||||
|
<title>Админка — {{siteName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-pattern"></div><div class="bg-glow"></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="nav">
|
||||||
|
<strong class="title">Админ · {{siteName}}</strong>
|
||||||
|
<a href="/admin/wg">WireGuard</a>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<span class="muted">{{.User.Username}}</span>
|
||||||
|
<a href="/logout">Выход</a>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="muted">WG конфиги</div><div class="n">{{.TotalConfigs}}</div></div>
|
||||||
|
<div class="stat"><div class="muted">Пользователи</div><div class="n">{{.TotalUsers}}</div></div>
|
||||||
|
<div class="stat"><div class="muted">Сайт</div><div class="n">{{if .SiteOnline}}ON{{else}}OFF{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:1.25rem">
|
||||||
|
<h1 style="font-size:1.1rem;margin-bottom:1rem">Статус сайта</h1>
|
||||||
|
<form method="POST" action="/admin/status">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Режим</label>
|
||||||
|
<select name="is_online" style="width:100%;padding:0.85rem 1rem;border-radius:10px;background:rgba(255,255,255,0.04);color:var(--text);border:1px solid var(--border)">
|
||||||
|
<option value="1" {{if .SiteOnline}}selected{{end}}>Онлайн</option>
|
||||||
|
<option value="0" {{if not .SiteOnline}}selected{{end}}>Оффлайн</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Сообщение оффлайна</label>
|
||||||
|
<textarea name="message" rows="3">{{.SiteMessage}}</textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{define "admin_wg.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
{{template "layout_head" .}}
|
||||||
|
<title>WireGuard — {{siteName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-pattern"></div><div class="bg-glow"></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/admin">← Админка</a>
|
||||||
|
<strong class="title">WireGuard .conf</strong>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<a href="/logout">Выход</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Message}}
|
||||||
|
<div class="alert {{.MessageType}}">{{.Message}}{{if .NewToken}} · <code>{{.PublicURL}}{{.NewToken}}</code>{{end}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="font-size:1.1rem;margin-bottom:1rem">Загрузить .conf</h1>
|
||||||
|
<form method="POST" action="/admin/wg/upload" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="wg_conf">Файл WireGuard (.conf)</label>
|
||||||
|
<input id="wg_conf" type="file" name="wg_conf" accept=".conf" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit" name="submit_form" value="1">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="font-size:1.1rem;margin-bottom:1rem">Настройки скачивания</h1>
|
||||||
|
<form method="POST" action="/admin/wg/settings">
|
||||||
|
<div class="form-group" style="display:flex;gap:0.6rem;align-items:center">
|
||||||
|
<input type="checkbox" id="show_zip_full" name="show_zip_full" value="1" {{if .ShowZipFull}}checked{{end}}>
|
||||||
|
<label for="show_zip_full" style="margin:0;text-transform:none;font-family:var(--font-sans)">ZIP (conf + QR)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;gap:0.6rem;align-items:center">
|
||||||
|
<input type="checkbox" id="show_zip_conf" name="show_zip_conf" value="1" {{if .ShowZipConf}}checked{{end}}>
|
||||||
|
<label for="show_zip_conf" style="margin:0;text-transform:none;font-family:var(--font-sans)">ZIP только conf</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" type="submit">Обновить настройки</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="font-size:1.1rem;margin-bottom:1rem">Конфиги</h1>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Токен</th><th>Файл</th><th>Дата</th><th>Ссылка</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Configs}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td><code>{{.Token}}</code></td>
|
||||||
|
<td>{{.OriginalFilename}}</td>
|
||||||
|
<td class="muted">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td><a style="color:var(--accent)" href="/wg?token={{.Token}}" target="_blank">открыть</a></td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/admin/wg/delete" onsubmit="return confirm('Удалить?')">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="muted">Пока нет конфигов</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pager">
|
||||||
|
{{if gt .Page 1}}<a class="btn btn-ghost" href="/admin/wg?page={{sub .Page 1}}">←</a>{{end}}
|
||||||
|
<span class="muted">стр. {{.Page}} / {{.TotalPages}}</span>
|
||||||
|
{{if lt .Page .TotalPages}}<a class="btn btn-ghost" href="/admin/wg?page={{add .Page 1}}">→</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "home.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
{{template "layout_head" .}}
|
||||||
|
<title>{{siteName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-pattern"></div><div class="bg-glow"></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="nav">
|
||||||
|
<strong class="title">{{siteName}}</strong>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/admin">Админка</a>
|
||||||
|
<a href="/logout">Выход</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login">Вход</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h1>{{siteName}}</h1>
|
||||||
|
<p class="muted" style="margin:1rem 0 1.5rem">WireGuard панель на Go + PostgreSQL 17</p>
|
||||||
|
<a class="btn" href="/login">Войти в админку</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "login.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
{{template "layout_head" .}}
|
||||||
|
<title>Вход — {{siteName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-pattern"></div><div class="bg-glow"></div>
|
||||||
|
<div class="login-box">
|
||||||
|
<h1 class="center">Вход в аккаунт</h1>
|
||||||
|
<p class="muted center" style="margin:0.4rem 0 1.5rem">{{siteName}}</p>
|
||||||
|
{{if .Error}}<div class="alert error">{{.Error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Имя пользователя или email</label>
|
||||||
|
<input id="username" name="username" type="text" required autofocus value="{{.Username}}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input id="password" name="password" type="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;gap:0.6rem;align-items:center">
|
||||||
|
<input type="checkbox" id="remember_me" name="remember_me" value="1">
|
||||||
|
<label for="remember_me" style="margin:0;text-transform:none;letter-spacing:normal;font-family:var(--font-sans)">Запомнить меня</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn" style="width:100%" type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
<p class="center muted" style="margin-top:1.2rem"><a href="/" style="color:var(--accent)">На главную</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{define "offline.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
{{template "layout_head" .}}
|
||||||
|
<title>Недоступен — {{.SiteName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-pattern"></div><div class="bg-glow"></div>
|
||||||
|
<div class="wrap" style="max-width:560px;margin-top:10vh">
|
||||||
|
<div class="card center">
|
||||||
|
<h1>Сайт временно недоступен</h1>
|
||||||
|
<p class="muted" style="margin-top:1rem">{{.Message}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{define "layout_head"}}
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#050508">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<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=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{define "wg_public.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
{{template "layout_head" .}}
|
||||||
|
<title>{{.FileName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-pattern"></div><div class="bg-glow"></div>
|
||||||
|
<div class="wrap" style="max-width:560px">
|
||||||
|
<div class="nav">
|
||||||
|
<h1 style="font-size:1.2rem">{{.FileName}}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="alert success">Конфиг готов.</div>
|
||||||
|
{{if .QR}}
|
||||||
|
<div class="card center qr">
|
||||||
|
<h1 style="font-size:1rem;margin-bottom:1rem">Отсканируйте QR-код</h1>
|
||||||
|
<img src="{{.QR}}" width="300" height="300" alt="QR">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="center" style="margin-bottom:1.25rem">
|
||||||
|
{{if .ShowZipFull}}<a class="btn" href="/wg?token={{.Token}}&download=full">ZIP (conf + QR)</a>{{end}}
|
||||||
|
{{if .ShowZipConf}}<a class="btn btn-ghost" href="/wg?token={{.Token}}&download=conf">ZIP только conf</a>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="font-size:1rem;margin-bottom:0.8rem">Текст конфигурации</h1>
|
||||||
|
<pre class="config" id="cfg">{{.Config}}</pre>
|
||||||
|
<button class="btn btn-ghost" style="margin-top:0.8rem" type="button" id="copyBtn">Копировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('copyBtn')?.addEventListener('click', async function() {
|
||||||
|
const t = document.getElementById('cfg')?.textContent || '';
|
||||||
|
try { await navigator.clipboard.writeText(t); this.textContent = 'Скопировано'; }
|
||||||
|
catch(e) { this.textContent = 'Ошибка'; }
|
||||||
|
setTimeout(() => this.textContent = 'Копировать', 2000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user