taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | README | LICENSE

app.go (17963B)


      1 // Package web contains the HTTP applications for the Taler merchant demos.
      2 package web
      3 
      4 import (
      5 	"bytes"
      6 	"crypto/rand"
      7 	"encoding/hex"
      8 	"encoding/json"
      9 	"errors"
     10 	"fmt"
     11 	"html"
     12 	"html/template"
     13 	"io/fs"
     14 	"log"
     15 	"net/http"
     16 	"net/url"
     17 	"path"
     18 	"regexp"
     19 	"sort"
     20 	"strconv"
     21 	"strings"
     22 
     23 	"git.taler.net/taler-merchant-demos/internal/backend"
     24 )
     25 
     26 const (
     27 	sessionCookieName  = "taler_demo_session"
     28 	orderCookieName    = "order_id"
     29 	articlePriceUnits  = "0.5"
     30 	subscriptionUnits  = "10"
     31 	subscriptionPrefix = "blog_abo_"
     32 )
     33 
     34 // PublicURLs contains the externally visible entry points linked by the demos.
     35 type PublicURLs struct {
     36 	Landing   string
     37 	Bank      string
     38 	Blog      string
     39 	Donations string
     40 }
     41 
     42 // Options configures one of the three demo HTTP applications.
     43 type Options struct {
     44 	Shop             string
     45 	Currency         string
     46 	PublicURLs       PublicURLs
     47 	BlogBackend      *backend.Client
     48 	DonationBackends map[string]*backend.Client
     49 	DonauURL         string
     50 	EnableTokens     bool
     51 }
     52 
     53 // App serves one configured demo.
     54 type App struct {
     55 	opts      Options
     56 	catalogs  catalogs
     57 	articles  articleLibrary
     58 	templates map[string]*template.Template
     59 	static    http.Handler
     60 	mux       *http.ServeMux
     61 	routes    *http.ServeMux
     62 }
     63 
     64 type language struct {
     65 	Code string
     66 	Name template.HTML
     67 }
     68 
     69 var languages = []language{
     70 	{"en", "English [en]"}, {"de", "Deutsch [de]"},
     71 	{"fr", "Français [fr]"}, {"it", "Italiano [it]"},
     72 	{"pt", "Português [pt]"}, {"es", "Español [es]"},
     73 	{"ru", "Русский [ru]"}, {"tr", "Türkçe [tr]"},
     74 	{"uk", "Українська [uk]"},
     75 }
     76 
     77 var supportedLocales = func() map[string]bool {
     78 	result := make(map[string]bool, len(languages))
     79 	for _, language := range languages {
     80 		result[language.Code] = true
     81 	}
     82 	return result
     83 }()
     84 
     85 var localePattern = regexp.MustCompile(`^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$`)
     86 
     87 // resolveLocale returns a supported locale or its closest supported parent.
     88 // Separators and case do not affect matching, but the returned spelling is
     89 // always the one used by the application.
     90 func resolveLocale(requested string, supported map[string]bool) string {
     91 	if !localePattern.MatchString(requested) {
     92 		return ""
     93 	}
     94 	normalize := func(value string) string {
     95 		return strings.ToLower(strings.ReplaceAll(value, "_", "-"))
     96 	}
     97 	for candidate := normalize(requested); candidate != ""; {
     98 		for locale, enabled := range supported {
     99 			if enabled && normalize(locale) == candidate {
    100 				return locale
    101 			}
    102 		}
    103 		index := strings.LastIndexByte(candidate, '-')
    104 		if index < 0 {
    105 			break
    106 		}
    107 		candidate = candidate[:index]
    108 	}
    109 	return ""
    110 }
    111 
    112 type links struct {
    113 	Landing   string
    114 	Bank      string
    115 	Blog      string
    116 	Donations string
    117 }
    118 
    119 type page struct {
    120 	Lang         string
    121 	Title        string
    122 	Active       string
    123 	HeaderTitle  string
    124 	HeaderURL    string
    125 	HeaderText   template.HTML
    126 	Styles       []string
    127 	Prefix       string
    128 	StaticPrefix string
    129 	Links        links
    130 	Languages    []language
    131 	LanguageName template.HTML
    132 	Content      any
    133 }
    134 
    135 type errorContent struct {
    136 	Message string
    137 	Details string
    138 	Status  int
    139 	JSON    string
    140 }
    141 
    142 // New loads embedded resources and constructs an application.
    143 func New(opts Options) (*App, error) {
    144 	if opts.Currency == "" {
    145 		return nil, errors.New("currency is required")
    146 	}
    147 	if opts.Shop != "landing" && opts.Shop != "blog" && opts.Shop != "donations" {
    148 		return nil, fmt.Errorf("unknown shop %q", opts.Shop)
    149 	}
    150 	if opts.Shop == "blog" && opts.BlogBackend == nil {
    151 		return nil, errors.New("blog backend is required")
    152 	}
    153 	if opts.Shop == "donations" {
    154 		for _, receiver := range []string{"gnunet", "taler", "tor"} {
    155 			if opts.DonationBackends[receiver] == nil {
    156 				return nil, fmt.Errorf("donation backend %q is required", receiver)
    157 			}
    158 		}
    159 	}
    160 	cats, err := loadCatalogs(supportedLocales)
    161 	if err != nil {
    162 		return nil, err
    163 	}
    164 	a := &App{
    165 		opts: opts, catalogs: cats, templates: make(map[string]*template.Template),
    166 		mux: http.NewServeMux(), routes: http.NewServeMux(),
    167 	}
    168 	if opts.Shop == "blog" {
    169 		a.articles, err = loadArticles()
    170 		if err != nil {
    171 			return nil, err
    172 		}
    173 	}
    174 	staticFS, err := fs.Sub(Assets, "assets/static")
    175 	if err != nil {
    176 		return nil, err
    177 	}
    178 	a.static = http.FileServer(http.FS(staticFS))
    179 	if err := a.loadTemplates(); err != nil {
    180 		return nil, err
    181 	}
    182 	a.registerRoutes()
    183 	return a, nil
    184 }
    185 
    186 func (a *App) loadTemplates() error {
    187 	base, err := Assets.ReadFile("templates/base.gohtml")
    188 	if err != nil {
    189 		return err
    190 	}
    191 	names := []string{"error", "landing", "donations-index", "donations-checkout", "donations-provider", "donations-fulfillment", "blog-index", "blog-article", "blog-confirm-refund", "blog-refunded"}
    192 	funcs := template.FuncMap{
    193 		"tr": func(lang, message string) string { return a.catalogs.translate(lang, message) },
    194 		"trHTML": func(lang, message string) template.HTML {
    195 			return template.HTML(a.catalogs.translate(lang, message))
    196 		},
    197 		"trf": func(lang, message string, values ...any) string {
    198 			return formatNamed(a.catalogs.translate(lang, message), values...)
    199 		},
    200 		"trfHTML": func(lang, message string, values ...any) template.HTML {
    201 			escaped := make([]any, len(values))
    202 			copy(escaped, values)
    203 			for i := 1; i < len(escaped); i += 2 {
    204 				escaped[i] = html.EscapeString(fmt.Sprint(escaped[i]))
    205 			}
    206 			return template.HTML(formatNamed(a.catalogs.translate(lang, message), escaped...))
    207 		},
    208 		"pathEscape": url.PathEscape,
    209 		"talerURL":   func(lang string) string { return "https://taler.net/" + url.PathEscape(lang) + "/" },
    210 		"localIndex": func(prefix, lang string) string { return prefix + "/" + url.PathEscape(lang) + "/" },
    211 	}
    212 	for _, name := range names {
    213 		body, err := Assets.ReadFile("templates/" + name + ".gohtml")
    214 		if err != nil {
    215 			return err
    216 		}
    217 		t, err := template.New("base.gohtml").Funcs(funcs).Parse(string(base) + "\n" + string(body))
    218 		if err != nil {
    219 			return fmt.Errorf("parse template %s: %w", name, err)
    220 		}
    221 		a.templates[name] = t
    222 	}
    223 	return nil
    224 }
    225 
    226 // ServeHTTP adds common recovery and response tracking around the standard
    227 // library request multiplexer.
    228 func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    229 	tracked := &trackingResponseWriter{ResponseWriter: w}
    230 	defer func() {
    231 		if recovered := recover(); recovered != nil {
    232 			log.Printf("request panic: %v", recovered)
    233 			if !tracked.wroteHeader {
    234 				a.renderError(tracked, r, http.StatusInternalServerError, requestLang(r), "Internal error", nil)
    235 			}
    236 		}
    237 	}()
    238 	a.mux.ServeHTTP(tracked, r)
    239 }
    240 
    241 func (a *App) registerRoutes() {
    242 	a.mux.HandleFunc("GET /{$}", a.redirectRoot)
    243 	a.mux.HandleFunc("/{$}", a.methodNotAllowed)
    244 	a.mux.HandleFunc("GET /static/", a.serveStatic)
    245 	a.mux.HandleFunc("/static/", a.methodNotAllowed)
    246 	a.mux.HandleFunc("GET /{lang}", a.redirectLanguage)
    247 	a.mux.HandleFunc("/{lang}", a.methodNotAllowed)
    248 
    249 	switch a.opts.Shop {
    250 	case "landing":
    251 		a.registerLandingRoutes()
    252 	case "donations":
    253 		a.registerDonationRoutes()
    254 	case "blog":
    255 		a.registerBlogRoutes()
    256 	}
    257 	a.routes.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    258 		a.renderError(w, r, http.StatusNotFound, requestLang(r), "Page not found", nil)
    259 	})
    260 	a.mux.HandleFunc("/{lang}/{path...}", func(w http.ResponseWriter, r *http.Request) {
    261 		if a.redirectLocale(w, r) {
    262 			return
    263 		}
    264 		a.routes.ServeHTTP(w, r)
    265 	})
    266 	a.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    267 		a.renderError(w, r, http.StatusNotFound, requestLang(r), "Page not found", nil)
    268 	})
    269 }
    270 
    271 func (a *App) handleGet(pattern string, handler http.HandlerFunc) {
    272 	a.routes.HandleFunc("GET "+pattern, handler)
    273 	a.routes.HandleFunc(pattern, a.methodNotAllowed)
    274 }
    275 
    276 func (a *App) handlePost(pattern string, handler http.HandlerFunc) {
    277 	a.routes.HandleFunc("POST "+pattern, handler)
    278 	a.routes.HandleFunc(pattern, a.methodNotAllowed)
    279 }
    280 
    281 func (a *App) methodNotAllowed(w http.ResponseWriter, r *http.Request) {
    282 	a.renderError(w, r, http.StatusMethodNotAllowed, requestLang(r), "HTTP method not allowed for this page", nil)
    283 }
    284 
    285 func (a *App) serveStatic(w http.ResponseWriter, r *http.Request) {
    286 	w.Header().Set("Cache-Control", "public, max-age=3600")
    287 	http.StripPrefix("/static/", a.static).ServeHTTP(w, r)
    288 }
    289 
    290 func (a *App) redirectLanguage(w http.ResponseWriter, r *http.Request) {
    291 	if a.redirectLocale(w, r) {
    292 		return
    293 	}
    294 	target := forwardedPrefix(r) + "/" + url.PathEscape(r.PathValue("lang")) + "/"
    295 	if r.URL.RawQuery != "" {
    296 		target += "?" + r.URL.RawQuery
    297 	}
    298 	http.Redirect(w, r, target, http.StatusPermanentRedirect)
    299 }
    300 
    301 // redirectLocale handles language aliases before any page or payment handler
    302 // runs. It reports whether it has already answered the request.
    303 func (a *App) redirectLocale(w http.ResponseWriter, r *http.Request) bool {
    304 	requested := r.PathValue("lang")
    305 	if validLocale(requested) {
    306 		return false
    307 	}
    308 	if !localePattern.MatchString(requested) {
    309 		a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil)
    310 		return true
    311 	}
    312 	lang := resolveLocale(requested, supportedLocales)
    313 	if lang == "" {
    314 		lang = "en"
    315 	}
    316 	// Split the escaped path to retain encoded separators in article/file IDs.
    317 	_, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
    318 	target := forwardedPrefix(r) + "/" + url.PathEscape(lang) + "/" + rest
    319 	if r.URL.RawQuery != "" {
    320 		target += "?" + r.URL.RawQuery
    321 	}
    322 	http.Redirect(w, r, target, http.StatusTemporaryRedirect)
    323 	return true
    324 }
    325 
    326 type trackingResponseWriter struct {
    327 	http.ResponseWriter
    328 	wroteHeader bool
    329 }
    330 
    331 func (w *trackingResponseWriter) WriteHeader(status int) {
    332 	if !w.wroteHeader {
    333 		w.wroteHeader = true
    334 		w.ResponseWriter.WriteHeader(status)
    335 	}
    336 }
    337 
    338 func (w *trackingResponseWriter) Write(data []byte) (int, error) {
    339 	if !w.wroteHeader {
    340 		w.WriteHeader(http.StatusOK)
    341 	}
    342 	return w.ResponseWriter.Write(data)
    343 }
    344 
    345 func requestLang(r *http.Request) string {
    346 	if lang := r.PathValue("lang"); validLocale(lang) {
    347 		return lang
    348 	}
    349 	first, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
    350 	if validLocale(first) {
    351 		return first
    352 	}
    353 	return "en"
    354 }
    355 
    356 func validLocale(lang string) bool {
    357 	return supportedLocales[lang]
    358 }
    359 
    360 func (a *App) redirectRoot(w http.ResponseWriter, r *http.Request) {
    361 	lang := a.bestLanguage(r.Header.Get("Accept-Language"))
    362 	http.Redirect(w, r, forwardedPrefix(r)+"/"+lang+"/", http.StatusFound)
    363 }
    364 
    365 func (a *App) bestLanguage(header string) string {
    366 	type preference struct {
    367 		lang  string
    368 		q     float64
    369 		order int
    370 	}
    371 	var preferences []preference
    372 	for order, weighted := range strings.Split(header, ",") {
    373 		parts := strings.Split(weighted, ";")
    374 		candidate := strings.TrimSpace(parts[0])
    375 		candidate = strings.ReplaceAll(candidate, "-", "_")
    376 		quality := 1.0
    377 		for _, parameter := range parts[1:] {
    378 			key, value, ok := strings.Cut(strings.TrimSpace(parameter), "=")
    379 			if ok && strings.EqualFold(key, "q") {
    380 				if parsed, err := strconv.ParseFloat(value, 64); err == nil {
    381 					quality = parsed
    382 				}
    383 			}
    384 		}
    385 		if candidate == "" || candidate == "*" || quality <= 0 {
    386 			continue
    387 		}
    388 		preferences = append(preferences, preference{candidate, quality, order})
    389 	}
    390 	sort.SliceStable(preferences, func(i, j int) bool {
    391 		if preferences[i].q == preferences[j].q {
    392 			return preferences[i].order < preferences[j].order
    393 		}
    394 		return preferences[i].q > preferences[j].q
    395 	})
    396 	for _, preferred := range preferences {
    397 		if lang := resolveLocale(preferred.lang, supportedLocales); lang != "" {
    398 			return lang
    399 		}
    400 	}
    401 	return "en"
    402 }
    403 
    404 func (a *App) makePage(r *http.Request, lang, title string, content any) page {
    405 	prefix := forwardedPrefix(r)
    406 	p := page{
    407 		Lang: lang, Title: a.catalogs.translate(lang, title), Active: a.opts.Shop,
    408 		Prefix: prefix, StaticPrefix: prefix + "/static/", Languages: languages,
    409 		LanguageName: template.HTML("en"), Content: content,
    410 		Links: links{
    411 			Landing:   appLanguageURL(a.opts.PublicURLs.Landing, lang),
    412 			Bank:      appLanguageURL(a.opts.PublicURLs.Bank, lang),
    413 			Blog:      appLanguageURL(a.opts.PublicURLs.Blog, lang),
    414 			Donations: appLanguageURL(a.opts.PublicURLs.Donations, lang),
    415 		},
    416 	}
    417 	for _, language := range languages {
    418 		if language.Code == lang {
    419 			p.LanguageName = language.Name
    420 			break
    421 		}
    422 	}
    423 	switch a.opts.Shop {
    424 	case "landing":
    425 		p.HeaderTitle = a.catalogs.translate(lang, "Introduction")
    426 		p.HeaderURL = configuredURL(a.opts.PublicURLs.Landing)
    427 		p.HeaderText = a.translatedHTML(lang, "Try GNU Taler with a toy currency.")
    428 	case "donations":
    429 		p.HeaderTitle = a.catalogs.translate(lang, "Donations")
    430 		p.HeaderURL = configuredURL(a.opts.PublicURLs.Donations)
    431 		p.HeaderText = a.translatedHTML(lang, "Support Free Software projects with a toy currency.")
    432 		p.Styles = []string{"colors-donations.css"}
    433 	case "blog":
    434 		p.HeaderTitle = a.catalogs.translate(lang, "Essay Shop")
    435 		p.HeaderURL = configuredURL(a.opts.PublicURLs.Blog)
    436 		p.HeaderText = a.translatedHTML(lang, "Buy chapters from <cite>Free Software, Free Society</cite> with a toy currency.")
    437 		p.Styles = []string{"blog.css", "colors-blog.css"}
    438 	}
    439 	return p
    440 }
    441 
    442 func (a *App) translatedHTML(lang, message string) template.HTML {
    443 	return template.HTML(a.catalogs.translate(lang, message))
    444 }
    445 
    446 func (a *App) translatedHTMLf(lang, message string, values ...any) template.HTML {
    447 	escaped := make([]any, len(values))
    448 	copy(escaped, values)
    449 	for i := 1; i < len(escaped); i += 2 {
    450 		escaped[i] = html.EscapeString(fmt.Sprint(escaped[i]))
    451 	}
    452 	return template.HTML(formatNamed(a.catalogs.translate(lang, message), escaped...))
    453 }
    454 
    455 func (a *App) render(w http.ResponseWriter, name string, p page, status int) {
    456 	var output bytes.Buffer
    457 	if err := a.templates[name].ExecuteTemplate(&output, "base", p); err != nil {
    458 		log.Printf("render %s: %v", name, err)
    459 		http.Error(w, a.catalogs.translate(p.Lang, "Internal server error"), http.StatusInternalServerError)
    460 		return
    461 	}
    462 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
    463 	w.Header().Set("Cache-Control", "private, no-store")
    464 	w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https://taler.net; style-src 'self' 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
    465 	w.Header().Set("X-Content-Type-Options", "nosniff")
    466 	w.WriteHeader(status)
    467 	_, _ = w.Write(output.Bytes())
    468 }
    469 
    470 func (a *App) renderError(w http.ResponseWriter, r *http.Request, status int, lang, message string, err error) {
    471 	a.renderErrorf(w, r, status, lang, message, err)
    472 }
    473 
    474 func (a *App) renderErrorf(w http.ResponseWriter, r *http.Request, status int, lang, message string, err error, values ...any) {
    475 	content := errorContent{Message: formatNamed(a.catalogs.translate(lang, message), values...)}
    476 	var be *backend.Error
    477 	if errors.As(err, &be) {
    478 		content.Status = be.Status
    479 		if be.Body != nil {
    480 			encoded, _ := json.MarshalIndent(be.Body, "", "  ")
    481 			content.JSON = string(encoded)
    482 		}
    483 	}
    484 	if err != nil {
    485 		content.Details = err.Error()
    486 		log.Printf("%s: %v", message, err)
    487 	}
    488 	a.render(w, "error", a.makePage(r, lang, "GNU Taler Demo: Error", content), status)
    489 }
    490 
    491 func configuredURL(value string) string {
    492 	if value = strings.TrimSpace(value); value != "" {
    493 		return value
    494 	}
    495 	return "#"
    496 }
    497 
    498 func appLanguageURL(base, lang string) string {
    499 	base = configuredURL(base)
    500 	if base == "#" {
    501 		return "#"
    502 	}
    503 	target, err := url.Parse(base)
    504 	if err != nil {
    505 		return "#"
    506 	}
    507 	return target.JoinPath(url.PathEscape(lang) + "/").String()
    508 }
    509 
    510 func forwardedPrefix(r *http.Request) string {
    511 	value := strings.TrimSpace(strings.SplitN(r.Header.Get("X-Forwarded-Prefix"), ",", 2)[0])
    512 	if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
    513 		return ""
    514 	}
    515 	raw := strings.TrimRight(value, "/")
    516 	value = path.Clean(value)
    517 	if value == "/" || value != raw {
    518 		return ""
    519 	}
    520 	return strings.TrimRight(value, "/")
    521 }
    522 
    523 func externalURL(r *http.Request) string {
    524 	scheme := "http"
    525 	if r.TLS != nil {
    526 		scheme = "https"
    527 	}
    528 	if forwarded := strings.TrimSpace(strings.SplitN(r.Header.Get("X-Forwarded-Proto"), ",", 2)[0]); forwarded == "http" || forwarded == "https" {
    529 		scheme = forwarded
    530 	}
    531 	host := r.Host
    532 	if forwarded := strings.TrimSpace(strings.SplitN(r.Header.Get("X-Forwarded-Host"), ",", 2)[0]); forwarded != "" {
    533 		host = forwarded
    534 	}
    535 	return (&url.URL{Scheme: scheme, Host: host, Path: forwardedPrefix(r) + r.URL.Path}).String()
    536 }
    537 
    538 func externalOrigin(r *http.Request) string {
    539 	parsed, _ := url.Parse(externalURL(r))
    540 	return parsed.Scheme + "://" + parsed.Host
    541 }
    542 
    543 func externalURLWithQuery(r *http.Request) string {
    544 	result := externalURL(r)
    545 	if r.URL.RawQuery != "" {
    546 		result += "?" + r.URL.RawQuery
    547 	}
    548 	return result
    549 }
    550 
    551 func newSessionID() (string, error) {
    552 	var raw [16]byte
    553 	if _, err := rand.Read(raw[:]); err != nil {
    554 		return "", err
    555 	}
    556 	return hex.EncodeToString(raw[:]), nil
    557 }
    558 
    559 func cookieSecure(r *http.Request) bool {
    560 	return r.TLS != nil || strings.EqualFold(strings.TrimSpace(strings.SplitN(r.Header.Get("X-Forwarded-Proto"), ",", 2)[0]), "https")
    561 }
    562 
    563 func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
    564 	http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: sessionID, Path: forwardedPrefix(r) + "/", HttpOnly: true, Secure: cookieSecure(r), SameSite: http.SameSiteLaxMode, MaxAge: 86400})
    565 }
    566 
    567 func sessionID(r *http.Request) string {
    568 	cookie, err := r.Cookie(sessionCookieName)
    569 	if err != nil {
    570 		return ""
    571 	}
    572 	return cookie.Value
    573 }
    574 
    575 func setOrderCookie(w http.ResponseWriter, r *http.Request, lang, article, orderID string) {
    576 	http.SetCookie(w, &http.Cookie{Name: orderCookieName, Value: orderID, Path: forwardedPrefix(r) + "/" + url.PathEscape(lang) + "/essay/" + url.PathEscape(article), HttpOnly: true, Secure: cookieSecure(r), SameSite: http.SameSiteLaxMode, MaxAge: 86400})
    577 }
    578 
    579 func orderID(r *http.Request) string {
    580 	cookie, err := r.Cookie(orderCookieName)
    581 	if err != nil {
    582 		return ""
    583 	}
    584 	return cookie.Value
    585 }
    586 
    587 func queryRedirect(r *http.Request, mutate func(url.Values)) string {
    588 	target := *r.URL
    589 	query := target.Query()
    590 	mutate(query)
    591 	target.RawQuery = query.Encode()
    592 	return forwardedPrefix(r) + target.RequestURI()
    593 }