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 }