mirror of
https://git.evilfox.cc/test2/wg.git
synced 2026-07-31 20:03:22 +00:00
419 lines
12 KiB
Go
419 lines
12 KiB
Go
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)
|
|
}
|