taler-merchant-demos

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

app.go (16635B)


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