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 }