Files
wg/internal/handlers/handlers.go
T

410 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") || r.URL.Path == "/healthz" {
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)
var total int
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM wg_configs`).Scan(&total)
rows, err := a.DB.Query(r.Context(), `
SELECT id, COALESCE(token,''), original_filename, created_at
FROM wg_configs
ORDER BY created_at DESC`)
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,
"Total": total,
"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)
}