// Package web wires the HTTP application: routing, sessions, CSRF, templates // and request handlers, on top of the domain packages (panel, settings, share, // auth, member, i18n). package web import ( "context" "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" "html/template" "io/fs" "log" "net/http" "net/url" "path/filepath" "strconv" "strings" "time" "github.com/alexedwards/scs/pgxstore" "github.com/alexedwards/scs/v2" "github.com/jackc/pgx/v5/pgxpool" "amnezia-share/internal/auth" "amnezia-share/internal/config" "amnezia-share/internal/db" "amnezia-share/internal/i18n" "amnezia-share/internal/member" "amnezia-share/internal/models" "amnezia-share/internal/panel" "amnezia-share/internal/settings" "amnezia-share/internal/share" ) // Session keys used across the app. const ( SessionAdminID = "admin_id" SessionMemberID = "member_id" sessionCSRFKey = "_csrf" ) // App holds every shared dependency needed by the HTTP handlers. type App struct { Cfg config.Config Pool *pgxpool.Pool Sessions *scs.SessionManager Panel *panel.Client Settings *settings.Store Share *share.Repo Auth *auth.Repo Member *member.Repo Templates *template.Template } // New connects to the database, runs migrations, and builds a ready-to-serve App. func New(ctx context.Context, cfg config.Config) (*App, error) { pool, err := db.Connect(ctx, cfg.DatabaseURL) if err != nil { return nil, fmt.Errorf("подключение к БД: %w", err) } if err := db.Migrate(ctx, pool, cfg.MigrationsDir); err != nil { pool.Close() return nil, fmt.Errorf("миграции: %w", err) } sm := scs.New() sm.Store = pgxstore.New(pool) sm.Lifetime = 30 * 24 * time.Hour sm.Cookie.Name = "amnezia_session" sm.Cookie.HttpOnly = true sm.Cookie.SameSite = http.SameSiteLaxMode sm.Cookie.Persist = true st := &settings.Store{ Pool: pool, EnvPanelURL: cfg.PanelURL, EnvToken: cfg.PanelToken, EnvLabelsJSON: cfg.ServerLabelsJSON, } tmpl, err := loadTemplates(cfg) if err != nil { pool.Close() return nil, fmt.Errorf("шаблоны: %w", err) } app := &App{ Cfg: cfg, Pool: pool, Sessions: sm, Panel: panel.New(cfg.HTTPBudget()), Settings: st, Share: share.New(pool), Auth: auth.New(pool), Member: member.New(pool), Templates: tmpl, } return app, nil } // Close releases the database pool. func (a *App) Close() { if a.Pool != nil { a.Pool.Close() } } // LoadTemplates parses every template under cfg.WebDir/templates. It is // exported so tests (e.g. in the handlers package) can render pages against // realistic view structs without needing a live database. func LoadTemplates(cfg config.Config) (*template.Template, error) { return loadTemplates(cfg) } func loadTemplates(cfg config.Config) (*template.Template, error) { root := template.New("root").Funcs(baseFuncs(cfg)) dir := filepath.Join(cfg.WebDir, "templates") var matches []string err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && strings.HasSuffix(path, ".html") { matches = append(matches, path) } return nil }) if err != nil { return nil, err } if len(matches) == 0 { return nil, fmt.Errorf("шаблоны не найдены (%s)", dir) } return root.ParseFiles(matches...) } func baseFuncs(cfg config.Config) template.FuncMap { return template.FuncMap{ "appURL": cfg.AppURL, "htmlSafe": func(s string) template.HTML { return template.HTML(s) }, "dict": dictFunc, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "mul": func(a, b int) int { return a * b }, "eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) }, "year": func() int { return time.Now().Year() }, "csrf": func() string { return "" }, "t": func(key string, _ ...string) string { return key }, "lang": func() string { return string(i18n.RU) }, "shareDL": func(token string, creationID int64, part string) string { return cfg.AppURL("share/download") + "?k=" + url.QueryEscape(token) + "&cre=" + strconv.FormatInt(creationID, 10) + "&part=" + part }, "intIn": func(list []int, id int) bool { for _, v := range list { if v == id { return true } } return false }, "strIn": func(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false }, } } func dictFunc(pairs ...any) (map[string]any, error) { if len(pairs)%2 != 0 { return nil, fmt.Errorf("dict: нечётное число аргументов") } out := make(map[string]any, len(pairs)/2) for i := 0; i < len(pairs); i += 2 { key, ok := pairs[i].(string) if !ok { return nil, fmt.Errorf("dict: ключ должен быть строкой") } out[key] = pairs[i+1] } return out, nil } // Render executes the named template with request-scoped helper funcs (csrf, t, // lang) bound in, writing directly to w. name is the template's file base name, // e.g. "share.html". func (a *App) Render(w http.ResponseWriter, r *http.Request, name string, data any) { clone, err := a.Templates.Clone() if err != nil { http.Error(w, "ошибка шаблона", http.StatusInternalServerError) log.Printf("template clone: %v", err) return } lang := i18n.Resolve(r, a.Sessions) token := a.CSRFToken(r) clone = clone.Funcs(template.FuncMap{ "csrf": func() string { return token }, "t": func(key string, args ...string) string { if len(args)%2 != 0 { return i18n.T(lang, key, nil) } repl := map[string]string{} for i := 0; i < len(args); i += 2 { repl[args[i]] = args[i+1] } return i18n.T(lang, key, repl) }, "lang": func() string { return string(lang) }, }) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := clone.ExecuteTemplate(w, name, data); err != nil { log.Printf("render %s: %v", name, err) } } // CSRFToken returns the current session's CSRF token, generating and storing one // on first use. func (a *App) CSRFToken(r *http.Request) string { ctx := r.Context() if tok := a.Sessions.GetString(ctx, sessionCSRFKey); tok != "" { return tok } tok := randomToken() a.Sessions.Put(ctx, sessionCSRFKey, tok) return tok } // ValidateCSRF checks the request's CSRF token (from the "_csrf" form field or // the "X-CSRF-Token" header) against the session's token. func (a *App) ValidateCSRF(r *http.Request) bool { want := a.Sessions.GetString(r.Context(), sessionCSRFKey) if want == "" { return false } got := r.Header.Get("X-CSRF-Token") if got == "" { got = r.FormValue("_csrf") } if got == "" { return false } return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1 } func randomToken() string { b := make([]byte, 24) _, _ = rand.Read(b) return hex.EncodeToString(b) } // AbsoluteURL builds an absolute URL (scheme + host + app-prefixed path) for path, // used by the OIDC redirect_uri. func (a *App) AbsoluteURL(r *http.Request, path string) string { scheme := "http" if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { scheme = "https" } host := r.Host if h := r.Header.Get("X-Forwarded-Host"); h != "" { host = h } return scheme + "://" + host + a.Cfg.AppURL(path) } // CurrentAdmin returns the logged-in admin user for the request, or nil if none. func (a *App) CurrentAdmin(r *http.Request) *models.User { id := a.Sessions.GetInt(r.Context(), SessionAdminID) if id <= 0 { return nil } u, err := a.Auth.GetAdminByID(r.Context(), id) if err != nil || u == nil { return nil } return u } // CurrentMember returns the logged-in cabinet member for the request, or nil if // none. func (a *App) CurrentMember(r *http.Request) *models.Member { id := a.Sessions.GetInt(r.Context(), SessionMemberID) if id <= 0 { return nil } m, err := a.Member.GetByID(r.Context(), id) if err != nil || m == nil { return nil } return m } // RequireAdmin is middleware that redirects anonymous visitors to the login page. func (a *App) RequireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if a.CurrentAdmin(r) == nil { http.Redirect(w, r, a.Cfg.AppURL("login"), http.StatusSeeOther) return } next(w, r) } } // RequireMember is middleware that redirects anonymous visitors to the cabinet // login page. func (a *App) RequireMember(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if a.CurrentMember(r) == nil { http.Redirect(w, r, a.Cfg.AppURL("cabinet/login"), http.StatusSeeOther) return } next(w, r) } } // CSRFProtect is middleware for POST endpoints that rejects requests with a // missing/invalid CSRF token. func (a *App) CSRFProtect(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { _ = r.ParseMultipartForm(32 << 20) if !a.ValidateCSRF(r) { if wantsJSON(r) { writeJSONError(w, http.StatusBadRequest, "Сессия устарела. Обновите страницу.") return } http.Error(w, "Сессия устарела. Обновите страницу и попробуйте снова.", http.StatusBadRequest) return } } next(w, r) } } func wantsJSON(r *http.Request) bool { return r.Header.Get("X-Share-Async") == "1" || strings.Contains(r.Header.Get("Accept"), "application/json") }