blog.go (10693B)
1 package web 2 3 import ( 4 "errors" 5 "net/http" 6 "net/url" 7 "strings" 8 "time" 9 10 "git.taler.net/taler-merchant-demos/internal/backend" 11 ) 12 13 type blogIndexContent struct { 14 Articles []Article 15 BankURL string 16 Price string 17 } 18 19 type articleContent struct { 20 Article Article 21 Refundable bool 22 RefundURL string 23 } 24 25 type confirmRefundContent struct { 26 ArticleName string 27 OrderID string 28 } 29 30 type refundedContent struct { 31 ArticleName string 32 OrderID string 33 } 34 35 func (a *App) registerBlogRoutes() { 36 a.handleGet("/{lang}/{$}", a.blogIndex) 37 a.handleGet("/{lang}/essay/{article}", a.article) 38 a.handleGet("/{lang}/essay/{article}/data/{file}", a.articleData) 39 a.handleGet("/{lang}/confirm-refund/{order_id}", a.confirmRefund) 40 a.handlePost("/{lang}/refund/{order_id}", a.refund) 41 } 42 43 func (a *App) blogLanguage(w http.ResponseWriter, r *http.Request) (string, bool) { 44 lang := r.PathValue("lang") 45 if !validLocale(lang) { 46 a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil) 47 return "", false 48 } 49 return lang, true 50 } 51 52 func (a *App) blogIndex(w http.ResponseWriter, r *http.Request) { 53 lang, ok := a.blogLanguage(w, r) 54 if !ok { 55 return 56 } 57 articles := sortedArticles(a.articles[lang]) 58 content := blogIndexContent{ 59 Articles: articles, 60 BankURL: configuredURL(a.opts.PublicURLs.Bank), 61 Price: a.opts.Currency + ":" + articlePriceUnits, 62 } 63 a.render(w, "blog-index", a.makePage(r, lang, "GNU Taler Demo: Essay Shop", content), http.StatusOK) 64 } 65 66 func (a *App) article(w http.ResponseWriter, r *http.Request) { 67 lang, ok := a.blogLanguage(w, r) 68 if !ok { 69 return 70 } 71 articleName := r.PathValue("article") 72 article, exists := a.articles[lang][articleName] 73 if !exists { 74 a.renderError(w, r, http.StatusNotFound, lang, "Page not found", nil) 75 return 76 } 77 session := sessionID(r) 78 expectState := r.URL.Query().Get("expect_state") == "yes" 79 if session == "" { 80 if expectState { 81 a.renderError(w, r, http.StatusPreconditionFailed, lang, "Please enable cookies.", nil) 82 return 83 } 84 var err error 85 session, err = newSessionID() 86 if err != nil { 87 a.renderError(w, r, http.StatusInternalServerError, lang, "Could not create a browser session", err) 88 return 89 } 90 setSessionCookie(w, r, session) 91 target := queryRedirect(r, func(query url.Values) { query.Set("expect_state", "yes") }) 92 http.Redirect(w, r, target, http.StatusFound) 93 return 94 } 95 if expectState { 96 target := queryRedirect(r, func(query url.Values) { query.Del("expect_state") }) 97 http.Redirect(w, r, target, http.StatusFound) 98 return 99 } 100 101 articleURL := externalURL(r) 102 currentOrderID := orderID(r) 103 if currentOrderID != "" { 104 var current backend.OrderStatusResponse 105 err := a.opts.BlogBackend.Get(r.Context(), "private/orders/"+url.PathEscape(currentOrderID), url.Values{"session_id": {session}}, ¤t) 106 if err == nil { 107 if current.OrderStatus == "paid" && !current.Refunded { 108 if current.ContractTerms.Extra.ArticleName != articleName { 109 a.renderError(w, r, http.StatusPaymentRequired, lang, "The paid order does not match this article", nil) 110 return 111 } 112 _, canRefund := a.refundAmount(current) 113 a.renderArticle(w, r, lang, article, currentOrderID, canRefund) 114 return 115 } 116 if current.AlreadyPaidOrderID != "" { 117 setOrderCookie(w, r, lang, articleName, current.AlreadyPaidOrderID) 118 http.Redirect(w, r, forwardedPrefix(r)+r.URL.Path, http.StatusFound) 119 return 120 } 121 if current.Refunded && r.URL.Query().Get("new_if_refunded") != "yes" { 122 content := refundedContent{ArticleName: articleName, OrderID: currentOrderID} 123 a.render(w, "blog-refunded", a.makePage(r, lang, "GNU Taler Demo: Refunded", content), http.StatusOK) 124 return 125 } 126 } else if !backendStatus(err, http.StatusNotFound) { 127 a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err) 128 return 129 } 130 } 131 132 var history backend.OrderHistory 133 if err := a.opts.BlogBackend.Get(r.Context(), "private/orders", url.Values{ 134 "session_id": {session}, "fulfillment_url": {articleURL}, "refunded": {"no"}, 135 }, &history); err != nil { 136 a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err) 137 return 138 } 139 for _, previous := range history.Orders { 140 if previous.Paid && previous.OrderID != "" { 141 setOrderCookie(w, r, lang, articleName, previous.OrderID) 142 http.Redirect(w, r, forwardedPrefix(r)+r.URL.Path, http.StatusFound) 143 return 144 } 145 } 146 147 created, err := a.postArticleOrder(r, article, articleURL, session, lang) 148 if err != nil { 149 a.renderError(w, r, http.StatusBadGateway, lang, "Backend could not create the order", err) 150 return 151 } 152 if created.OrderID == "" || created.Token == "" { 153 a.renderError(w, r, http.StatusBadGateway, lang, "Backend response did not contain an order ID and token", nil) 154 return 155 } 156 paymentURL := a.opts.BlogBackend.URL("orders/"+url.PathEscape(created.OrderID), url.Values{"token": {created.Token}, "session_id": {session}}) 157 setOrderCookie(w, r, lang, articleName, created.OrderID) 158 http.Redirect(w, r, paymentURL, http.StatusFound) 159 } 160 161 func (a *App) postArticleOrder(r *http.Request, article Article, articleURL, session, lang string) (backend.PostOrderResponse, error) { 162 now := time.Now() 163 choices := []backend.OrderChoice{{ 164 // Translators: Merchant order description shown in the customer's wallet. 165 Amount: a.opts.Currency + ":" + articlePriceUnits, Description: a.catalogs.translate(lang, "Buy an individual article"), 166 }} 167 if a.opts.EnableTokens { 168 slug := subscriptionPrefix + lang 169 choices = append(choices, 170 backend.OrderChoice{ 171 // Translators: Merchant order description shown in the customer's wallet. 172 Amount: a.opts.Currency + ":" + subscriptionUnits, Description: a.catalogs.translate(lang, "Buy one month of unlimited access"), 173 Outputs: []backend.OrderOutput{{Type: "token", TokenFamilySlug: slug}}, 174 }, 175 backend.OrderChoice{ 176 Amount: a.opts.Currency + ":0", 177 Inputs: []backend.OrderInput{{Type: "token", TokenFamilySlug: slug}}, 178 Outputs: []backend.OrderOutput{{Type: "token", TokenFamilySlug: slug}}, 179 }, 180 ) 181 } 182 request := backend.PostOrderRequest{ 183 Order: backend.Order{ 184 Version: 1, 185 Extra: backend.OrderExtra{ArticleName: article.Slug}, 186 FulfillmentURL: articleURL, 187 PublicReorderURL: articleURL, 188 // Translators: Merchant order summary shown in the wallet; {title} is the essay title. 189 Summary: formatNamed(a.catalogs.translate(lang, "Essay: {title}"), "title", article.Title), 190 Choices: choices, 191 PayDeadline: backend.Timestamp{Seconds: now.Add(5 * time.Minute).Unix()}, 192 WireTransferDeadline: backend.Timestamp{Seconds: now.Add(2 * time.Hour).Unix()}, 193 }, 194 SessionID: session, 195 CreateToken: true, 196 RefundDelay: backend.RelativeTime{Microseconds: time.Hour.Microseconds()}, 197 } 198 var response backend.PostOrderResponse 199 err := a.opts.BlogBackend.Post(r.Context(), "private/orders", request, &response) 200 return response, err 201 } 202 203 func (a *App) renderArticle(w http.ResponseWriter, r *http.Request, lang string, article Article, requestedOrderID string, canRefund bool) { 204 content := articleContent{ 205 Article: article, Refundable: canRefund, 206 RefundURL: forwardedPrefix(r) + "/" + url.PathEscape(lang) + "/confirm-refund/" + url.PathEscape(requestedOrderID), 207 } 208 a.render(w, "blog-article", a.makePage(r, lang, "GNU Taler Demo: Article", content), http.StatusOK) 209 } 210 211 func (a *App) confirmRefund(w http.ResponseWriter, r *http.Request) { 212 lang, ok := a.blogLanguage(w, r) 213 if !ok { 214 return 215 } 216 requestedOrderID := r.PathValue("order_id") 217 var order backend.OrderStatusResponse 218 if err := a.opts.BlogBackend.Get(r.Context(), "private/orders/"+url.PathEscape(requestedOrderID), url.Values{"session_id": {sessionID(r)}}, &order); err != nil { 219 a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err) 220 return 221 } 222 if order.OrderStatus != "paid" { 223 a.renderError(w, r, http.StatusBadRequest, lang, "Cannot refund unpaid article", nil) 224 return 225 } 226 if _, ok := a.refundAmount(order); !ok { 227 a.renderError(w, r, http.StatusForbidden, lang, "This article can no longer be refunded.", nil) 228 return 229 } 230 content := confirmRefundContent{ArticleName: order.ContractTerms.Extra.ArticleName, OrderID: requestedOrderID} 231 a.render(w, "blog-confirm-refund", a.makePage(r, lang, "GNU Taler Demo: Confirm refund", content), http.StatusOK) 232 } 233 234 func (a *App) refund(w http.ResponseWriter, r *http.Request) { 235 lang, ok := a.blogLanguage(w, r) 236 if !ok { 237 return 238 } 239 requestedOrderID := r.PathValue("order_id") 240 if requestedOrderID == "" { 241 a.renderError(w, r, http.StatusBadRequest, lang, "Aborting refund: order unknown", nil) 242 return 243 } 244 var order backend.OrderStatusResponse 245 if err := a.opts.BlogBackend.Get(r.Context(), "private/orders/"+url.PathEscape(requestedOrderID), url.Values{"session_id": {sessionID(r)}}, &order); err != nil { 246 a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err) 247 return 248 } 249 if order.OrderStatus != "paid" { 250 a.renderError(w, r, http.StatusPaymentRequired, lang, "You did not pay for this article (nice try!)", nil) 251 return 252 } 253 amount, ok := a.refundAmount(order) 254 if !ok { 255 a.renderError(w, r, http.StatusForbidden, lang, "This article can no longer be refunded.", nil) 256 return 257 } 258 if err := a.opts.BlogBackend.Post(r.Context(), "private/orders/"+url.PathEscape(requestedOrderID)+"/refund", backend.RefundRequest{ 259 // Translators: Refund reason shown in the customer's wallet. 260 Reason: a.catalogs.translate(lang, "Demo reimbursement"), Refund: amount, 261 }, nil); err != nil { 262 a.renderError(w, r, http.StatusBadGateway, lang, "Backend could not refund the order", err) 263 return 264 } 265 if order.OrderStatusURL == "" { 266 a.renderError(w, r, http.StatusBadGateway, lang, "Backend response did not contain an order status URL", nil) 267 return 268 } 269 http.Redirect(w, r, order.OrderStatusURL, http.StatusFound) 270 } 271 272 func backendStatus(err error, status int) bool { 273 var backendError *backend.Error 274 return errors.As(err, &backendError) && backendError.Status == status 275 } 276 277 func (a *App) refundAmount(order backend.OrderStatusResponse) (string, bool) { 278 if order.Refunded || order.ContractTerms.RefundDeadline.Seconds <= time.Now().Unix() { 279 return "", false 280 } 281 amount := order.ContractTerms.Amount 282 if order.ContractTerms.Version == 1 { 283 if order.ChoiceIndex == nil || *order.ChoiceIndex < 0 || *order.ChoiceIndex >= len(order.ContractTerms.Choices) { 284 return "", false 285 } 286 amount = order.ContractTerms.Choices[*order.ChoiceIndex].Amount 287 } 288 value, ok := strings.CutPrefix(amount, a.opts.Currency+":") 289 if !ok || value == "" || strings.Trim(value, "0.") == "" { 290 return "", false 291 } 292 return amount, true 293 }