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, // short — первые n путей и «ещё N», чтобы категория etc не растягивала таблицу "short": func(paths []string, n int) string { if len(paths) <= n { return strings.Join(paths, ", ") } return strings.Join(paths[:n], ", ") + fmt.Sprintf(" и ещё %d", len(paths)-n) }, } 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) }