- список клиентов: поиск по домену и названию, фильтр по статусу (чипы со счётчиками), сортировка по любой колонке, пагинация 10/25/50/100; состояние в URL — отфильтрованный вид можно сохранить ссылкой - размеры последних бэкапов одним запросом вместо запроса на клиента - шапка выровнена по колонке контента; настройки на всю ширину: ntfy и Telegram двумя карточками, на узком экране — друг под другом Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427 lines
13 KiB
Go
427 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static
|
|
var staticRoot embed.FS
|
|
|
|
// URL /static/x совпадает с путём static/x внутри embed.FS
|
|
var staticFS fs.FS = staticRoot
|
|
|
|
type pageTemplate = template.Template
|
|
|
|
var funcs = template.FuncMap{
|
|
"ts": func(unix int64) string {
|
|
if unix == 0 {
|
|
return "—"
|
|
}
|
|
return time.Unix(unix, 0).Format("02.01.2006 15:04")
|
|
},
|
|
"ago": func(unix int64) string {
|
|
if unix == 0 {
|
|
return "никогда"
|
|
}
|
|
d := time.Since(time.Unix(unix, 0))
|
|
switch {
|
|
case d < time.Minute:
|
|
return "только что"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%d мин назад", int(d.Minutes()))
|
|
case d < 48*time.Hour:
|
|
return fmt.Sprintf("%d ч назад", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%d дн назад", int(d.Hours()/24))
|
|
}
|
|
},
|
|
"bytes": humanBytes,
|
|
"join": strings.Join,
|
|
}
|
|
|
|
func loadPages() (map[string]*pageTemplate, error) {
|
|
names, err := fs.Glob(templatesFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pages := map[string]*pageTemplate{}
|
|
for _, n := range names {
|
|
base := strings.TrimSuffix(strings.TrimPrefix(n, "templates/"), ".html")
|
|
if base == "layout" {
|
|
continue
|
|
}
|
|
t, err := template.New("layout.html").Funcs(funcs).ParseFS(templatesFS, "templates/layout.html", n)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("шаблон %s: %w", n, err)
|
|
}
|
|
pages[base] = t
|
|
}
|
|
return pages, nil
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
|
t, ok := s.pages[page]
|
|
if !ok {
|
|
http.Error(w, "нет шаблона "+page, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
data["CSRF"] = csrfFrom(r)
|
|
data["Page"] = page
|
|
data["PublicURL"] = s.cfg.PublicURL
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, data); err != nil {
|
|
log.Printf("render %s: %v", page, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "same-origin")
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
// clientStatus — сводный статус клиента для списка.
|
|
func clientStatus(c *Client) (string, string) {
|
|
switch {
|
|
case c.Disabled:
|
|
return "off", "отключён"
|
|
case c.LastErrorAt > c.LastBackupAt:
|
|
return "fail", "ошибка"
|
|
case c.LastBackupAt == 0:
|
|
return "never", "нет бэкапов"
|
|
case time.Since(time.Unix(c.LastBackupAt, 0)) > time.Duration(c.StaleHours)*time.Hour:
|
|
return "stale", "просрочен"
|
|
default:
|
|
return "ok", "ok"
|
|
}
|
|
}
|
|
|
|
type clientRow struct {
|
|
*Client
|
|
StatusClass, StatusText string
|
|
LastSize int64
|
|
}
|
|
|
|
func (s *Server) webClientNew(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, r, "client_new", nil)
|
|
}
|
|
|
|
var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
|
|
|
|
func (s *Server) webClientCreate(w http.ResponseWriter, r *http.Request) {
|
|
domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
if !domainRe.MatchString(domain) {
|
|
s.render(w, r, "client_new", map[string]any{"Error": "Недопустимый домен", "Domain": domain, "Name": name})
|
|
return
|
|
}
|
|
if name == "" {
|
|
name = domain
|
|
}
|
|
tok := newToken()
|
|
id, err := s.st.CreateClient(name, domain, hashToken(tok))
|
|
if err != nil {
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "UNIQUE") {
|
|
msg = "Клиент с таким доменом уже есть"
|
|
}
|
|
s.render(w, r, "client_new", map[string]any{"Error": msg, "Domain": domain, "Name": name})
|
|
return
|
|
}
|
|
c, _ := s.st.GetClient(id)
|
|
s.showAgentToken(w, r, c, tok)
|
|
}
|
|
|
|
func (s *Server) showAgentToken(w http.ResponseWriter, r *http.Request, c *Client, tok string) {
|
|
s.render(w, r, "token", map[string]any{
|
|
"Client": c,
|
|
"Title": "Токен агента",
|
|
"Note": "Токен показывается один раз. Выполните на сервере клиента:",
|
|
"Cmd": fmt.Sprintf("curl -fsSL %s/agent.sh | sudo bash -s -- --token %s --now", s.cfg.PublicURL, tok),
|
|
})
|
|
}
|
|
|
|
func (s *Server) clientFromPath(w http.ResponseWriter, r *http.Request) (*Client, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return nil, false
|
|
}
|
|
c, err := s.st.GetClient(id)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return nil, false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
func (s *Server) webClient(w http.ResponseWriter, r *http.Request) {
|
|
c, ok := s.clientFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.renderClient(w, r, c, "")
|
|
}
|
|
|
|
func (s *Server) renderClient(w http.ResponseWriter, r *http.Request, c *Client, formErr string) {
|
|
backups, err := s.st.ListBackups(c.ID, "")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := map[string]any{"Client": c, "Backups": backups, "Error": formErr}
|
|
data["StatusClass"], data["StatusText"] = clientStatus(c)
|
|
if latest, err := s.st.LatestComplete(c.ID); err == nil {
|
|
var m manifest.Manifest
|
|
if json.Unmarshal([]byte(latest.Manifest), &m) == nil {
|
|
data["Latest"] = &m
|
|
data["LatestID"] = latest.ID
|
|
}
|
|
}
|
|
s.render(w, r, "client", data)
|
|
}
|
|
|
|
func formInt(r *http.Request, key string, def int) int {
|
|
v, err := strconv.Atoi(strings.TrimSpace(r.FormValue(key)))
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (s *Server) webClientSettings(w http.ResponseWriter, r *http.Request) {
|
|
c, ok := s.clientFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.Name = strings.TrimSpace(r.FormValue("name"))
|
|
c.ConfigYAML = strings.ReplaceAll(r.FormValue("config_yaml"), "\r\n", "\n")
|
|
c.KeepDaily = formInt(r, "keep_daily", c.KeepDaily)
|
|
c.KeepWeekly = formInt(r, "keep_weekly", c.KeepWeekly)
|
|
c.KeepMonthly = formInt(r, "keep_monthly", c.KeepMonthly)
|
|
c.HeavyKeep = formInt(r, "heavy_keep", c.HeavyKeep)
|
|
c.HeavyEveryDays = formInt(r, "heavy_every_days", c.HeavyEveryDays)
|
|
c.StaleHours = formInt(r, "stale_hours", c.StaleHours)
|
|
c.Disabled = r.FormValue("disabled") == "1"
|
|
if strings.TrimSpace(c.ConfigYAML) != "" {
|
|
base, err := catalog.Default()
|
|
if err == nil {
|
|
var over *catalog.Config
|
|
over, err = catalog.Parse([]byte(c.ConfigYAML))
|
|
if err == nil {
|
|
err = catalog.Merge(base, over).Validate()
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.renderClient(w, r, c, "Конфиг не сохранён: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
if err := s.st.UpdateClientSettings(c); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) webClientRotateToken(w http.ResponseWriter, r *http.Request) {
|
|
c, ok := s.clientFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tok := newToken()
|
|
if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.render(w, r, "token", map[string]any{
|
|
"Client": c,
|
|
"Title": "Новый токен агента",
|
|
"Note": "Старый токен больше не работает. Обновите конфиг агента на сервере клиента:",
|
|
"Cmd": fmt.Sprintf("curl -fsSL %s/agent.sh | sudo bash -s -- --token %s", s.cfg.PublicURL, tok),
|
|
})
|
|
}
|
|
|
|
func (s *Server) webClientRestoreToken(w http.ResponseWriter, r *http.Request) {
|
|
c, ok := s.clientFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tok := newToken()
|
|
if err := s.st.CreateRestoreToken(hashToken(tok), c.ID, 24*time.Hour); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
cmd := fmt.Sprintf("curl -fsSL %s/restore.sh | sudo bash -s -- --token %s", s.cfg.PublicURL, tok)
|
|
if b := r.FormValue("backup"); b != "" {
|
|
cmd += " --backup " + b
|
|
}
|
|
s.render(w, r, "token", map[string]any{
|
|
"Client": c,
|
|
"Title": "Восстановление " + c.Domain,
|
|
"Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo):",
|
|
"Cmd": cmd,
|
|
})
|
|
}
|
|
|
|
func (s *Server) webClientDelete(w http.ResponseWriter, r *http.Request) {
|
|
c, ok := s.clientFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if r.FormValue("confirm") != c.Domain {
|
|
s.renderClient(w, r, c, "Для удаления введите домен клиента")
|
|
return
|
|
}
|
|
backups, _ := s.st.ListBackups(c.ID, "")
|
|
for _, b := range backups {
|
|
if err := s.deleteBackup(b); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
if err := s.st.DeleteClient(c.ID); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) backupFromPath(w http.ResponseWriter, r *http.Request) (*Backup, *Client, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return nil, nil, false
|
|
}
|
|
b, err := s.st.GetBackup(id)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return nil, nil, false
|
|
}
|
|
c, err := s.st.GetClient(b.ClientID)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return nil, nil, false
|
|
}
|
|
return b, c, true
|
|
}
|
|
|
|
func (s *Server) webBackup(w http.ResponseWriter, r *http.Request) {
|
|
b, c, ok := s.backupFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var m manifest.Manifest
|
|
_ = json.Unmarshal([]byte(b.Manifest), &m)
|
|
var pretty bytes.Buffer
|
|
_ = json.Indent(&pretty, []byte(b.Manifest), "", " ")
|
|
s.render(w, r, "backup", map[string]any{"Backup": b, "Client": c, "M": &m, "Raw": pretty.String()})
|
|
}
|
|
|
|
func (s *Server) webBackupDownload(w http.ResponseWriter, r *http.Request) {
|
|
b, _, ok := s.backupFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.servePart(w, r, b, r.PathValue("name"))
|
|
}
|
|
|
|
func (s *Server) webBackupDelete(w http.ResponseWriter, r *http.Request) {
|
|
b, c, ok := s.backupFromPath(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.deleteBackup(b); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) settingsData(r *http.Request) map[string]any {
|
|
return map[string]any{
|
|
"NtfyURL": s.st.Setting(settingNtfyURL),
|
|
"NtfyAuthSet": s.st.Setting(settingNtfyAuth) != "",
|
|
"TGTokenSet": s.st.Setting(settingTGToken) != "",
|
|
"TGChats": s.st.Setting(settingTGChats),
|
|
"TGThread": s.st.Setting(settingTGThread),
|
|
"TGAPIURL": s.st.Setting(settingTGAPIURL),
|
|
"NotifySuccess": s.st.Setting(settingNotifySuccess) == "1",
|
|
"Saved": r.URL.Query().Get("saved") != "",
|
|
}
|
|
}
|
|
|
|
func (s *Server) webSettings(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, r, "settings", s.settingsData(r))
|
|
}
|
|
|
|
// setSecret обновляет секретную настройку: пустое поле — не менять, галочка clear — удалить.
|
|
func (s *Server) setSecret(r *http.Request, key, field string) {
|
|
if r.FormValue(field+"_clear") == "1" {
|
|
_ = s.st.SetSetting(key, "")
|
|
} else if v := strings.TrimSpace(r.FormValue(field)); v != "" {
|
|
_ = s.st.SetSetting(key, v)
|
|
}
|
|
}
|
|
|
|
type testResult struct {
|
|
Channel string
|
|
Err string
|
|
}
|
|
|
|
func (s *Server) webSettingsSave(w http.ResponseWriter, r *http.Request) {
|
|
_ = s.st.SetSetting(settingNtfyURL, strings.TrimSpace(r.FormValue("ntfy_url")))
|
|
s.setSecret(r, settingNtfyAuth, "ntfy_auth")
|
|
s.setSecret(r, settingTGToken, "tg_token")
|
|
_ = s.st.SetSetting(settingTGChats, strings.Join(splitList(r.FormValue("tg_chats")), ","))
|
|
_ = s.st.SetSetting(settingTGThread, strings.TrimSpace(r.FormValue("tg_thread")))
|
|
_ = s.st.SetSetting(settingTGAPIURL, strings.TrimSpace(r.FormValue("tg_api_url")))
|
|
ns := "0"
|
|
if r.FormValue("notify_success") == "1" {
|
|
ns = "1"
|
|
}
|
|
_ = s.st.SetSetting(settingNotifySuccess, ns)
|
|
if r.FormValue("test") == "1" {
|
|
res := s.notifySync(r.Context(), notification{
|
|
Title: "backup-master: тест", Msg: "Тестовое уведомление", Priority: "default", Tags: "test_tube", Link: s.cfg.PublicURL,
|
|
})
|
|
var results []testResult
|
|
for _, name := range []string{"ntfy", "telegram"} {
|
|
if err, ok := res[name]; ok {
|
|
tr := testResult{Channel: name}
|
|
if err != nil {
|
|
tr.Err = err.Error()
|
|
}
|
|
results = append(results, tr)
|
|
}
|
|
}
|
|
data := s.settingsData(r)
|
|
data["Saved"] = true
|
|
data["Tested"] = true
|
|
data["TestResults"] = results
|
|
s.render(w, r, "settings", data)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
|
|
}
|