342 lines
9.4 KiB
Go
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")
|
|
}
|