Files

342 lines
9.4 KiB
Go

// 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")
}