paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

commit 6f62ce0a0602aa1ea0757638e096f10265a5fd56
parent 8cb95d87e29ba63d66314a9f933a75ff7c2bedb2
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 22 Apr 2026 22:59:58 +0200

debug paivana

Diffstat:
Mcontrib/paywall.en.must | 65+++++++++++++++++++++++++++++++++--------------------------------
Msrc/backend/Makefile.am | 1+
Msrc/backend/paivana-httpd.c | 2+-
Msrc/backend/paivana-httpd_cookie.c | 49+++++++++++++++++++++++++++----------------------
Msrc/backend/paivana-httpd_daemon.c | 66+++++++++++++++++++++++++++++++++++-------------------------------
Msrc/backend/paivana-httpd_templates.c | 222++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/backend/paivana-httpd_templates.h | 11+++++++----
Msrc/backend/test.conf | 25++++++++++++++++++++-----
Asrc/backend/test.sh | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 368 insertions(+), 142 deletions(-)

diff --git a/contrib/paywall.en.must b/contrib/paywall.en.must @@ -2,6 +2,7 @@ <html lang="en"> <head> <title>Payment Required</title> + <meta http-equiv="content-type" content="text/html;CHARSET=utf-8"> <!-- FIXME: probably should serve this ourselves... --> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" @@ -13,8 +14,7 @@ <a href="https://wallet.taler.net">GNU Taler</a> wallet.</p> <div id="qrcode"></div> - <p><a id="talerlink" href="#">Generating payment link…</a></p> - <p id="statusmsg">Waiting for payment…</p> + <p><a id="talerlink" href="#">Generating payment link...</a></p> <script> (async () => { @@ -23,35 +23,31 @@ return new Promise(resolve => setTimeout(resolve, ms)); } - const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - - function crockford32(bytes) { - // Encode a Uint8Array as Crockford base32 (no padding). - let result = '', bits = 0, val = 0; - for (const byte of bytes) { - val = (val << 8) | byte; - bits += 8; - while (bits >= 5) { - bits -= 5; - result += CROCKFORD[(val >> bits) & 0x1f]; - } - } - if (bits > 0) result += CROCKFORD[(val << (5 - bits)) & 0x1f]; - return result; + // Note: good like this ONLY for short inputs... + function toBase64Url(bytes) { + let binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); // remove padding } - async function sha512b32(str) { + async function sha256b64(str) { const buf = await crypto.subtle.digest( - 'SHA-512', new TextEncoder().encode(str)); - return crockford32(new Uint8Array(buf)); + 'SHA-256', new TextEncoder().encode(str)); + return toBase64Url(new Uint8Array(buf)); } async function makePaivanaId(curTime, nonce, website) { - const hash = await sha512b32(nonce + website + String(curTime)); + const hash = await sha256b64(nonce + website + String(curTime)); return `${curTime}-${hash}`; } - const website = `${window.location.protocol}//${window.location.host}`; + const href = window.location.href; + // This is a policy decision: we include the query parameters + // in the website to support sites that need them, say if + // "?page=42" is being used instead of "/pages/42". + const website = href.split('#')[0]; const metaMerchant = '{{ merchant_backend }}'; const metaTemplate = '{{ template_id }}'; const maxPickupDelay = {{ max_pickup_delay }}; // in seconds @@ -59,9 +55,12 @@ const usePickupDelay = Math.min(maxPickupDelay, 60*60*24*356*100); // Strip trailing slash so we can append /templateId cleanly. - const merchantBase = metaMerchant.content.replace(/\/$/, ''); - const merchantProto = new URL(merchantBase).protocol; + const merchantBase = metaMerchant.replace(/\/$/, ''); + const merchantUrl = new URL(merchantBase); + const merchantProto = merchantUrl.protocol; const suffix = merchantProto === 'http:' ? '+http' : ''; + const merchantHost = merchantUrl.host; + const merchantPath = merchantUrl.path; const expTime = Math.floor(Date.now() / 1000) + maxPickupDelay; const nonceArr = new Uint8Array(32); @@ -73,10 +72,12 @@ const talerUri = [ `taler${suffix}://pay-template/`, - merchantBase, + merchantHost, + merchantPath, + `/`, metaTemplate, `?session_id=${encodeURIComponent(paivanaId)}`, - `&fulfillment_url=${encodeURIComponent(website)}` + `&fulfillment_url=${encodeURIComponent(href)}` ].join(''); const talerLink = document.getElementById('talerlink'); @@ -90,10 +91,9 @@ correctLevel: QRCode.CorrectLevel.M }); - const statusMsg = document.getElementById('statusmsg'); - async function confirmPayment(orderId) { - statusMsg.textContent = 'Payment confirmed! Activating access…'; + talerLink.textContent = 'Payment confirmed! Loading page...'; + talerLink.href = '#'; try { const res = await fetch(`${website}/.well-known/paivana`, { method: 'POST', @@ -106,7 +106,8 @@ location.href = dest; } catch (e) { console.warn('[paivana] confirm error:', e); - statusMsg.textContent = 'Could not reach the server — please reload.'; + talerLink.href = '#'; + talerLink.textContent = 'Could not reach the server!'; } } @@ -122,7 +123,6 @@ if (orderId) { await confirmPayment(orderId); } else { - statusMsg.textContent = 'Invalid response! Reloading anyway…'; // FIXME: for testing... await waitMs(400); location.href = website; @@ -131,7 +131,8 @@ } } catch (e) { console.warn('[paivana] poll error:', e); - statusMsg.textContent = 'Network error — retrying…'; + talerLink.href = '#'; + talerLink.textContent = 'Network error! Retrying...'; } const remMs = Math.round(30_000 - (performance.now() - start)); if (remMs > 0) await waitMs(remMs); diff --git a/src/backend/Makefile.am b/src/backend/Makefile.am @@ -28,6 +28,7 @@ paivana_httpd_LDADD = \ -lgnunetcurl \ -lgnunetutil \ -lmicrohttpd \ + -lgcrypt \ -lcurl \ -ljansson \ -lz diff --git a/src/backend/paivana-httpd.c b/src/backend/paivana-httpd.c @@ -255,7 +255,7 @@ run (void *cls, return; } GNUNET_asprintf (&auth_header, - "%s: %s", + "%s: Bearer %s", MHD_HTTP_HEADER_AUTHORIZATION, merchant_access_token); GNUNET_free (merchant_access_token); diff --git a/src/backend/paivana-httpd_cookie.c b/src/backend/paivana-httpd_cookie.c @@ -25,6 +25,7 @@ */ #include "platform.h" #include <curl/curl.h> +#include <gcrypt.h> #include <gnunet/gnunet_util_lib.h> #include <taler/taler_mhd_lib.h> #include "paivana-httpd_cookie.h" @@ -147,34 +148,38 @@ PAIVANA_HTTPD_compute_paivana_id (struct GNUNET_TIME_Timestamp cur_time, const struct PAIVANA_Nonce *nonce) { struct GNUNET_TIME_AbsoluteNBO e; - struct GNUNET_HashContext *hc; - struct GNUNET_HashCode h; - char *end; - char cstr[128]; char *res; + gcry_md_hd_t hd; + const void *sha256; + char *cstr; + size_t clen; e = GNUNET_TIME_absolute_hton (cur_time.abs_time); - hc = GNUNET_CRYPTO_hash_context_start (); - GNUNET_CRYPTO_hash_context_read (hc, - nonce, - sizeof (*nonce)); - GNUNET_CRYPTO_hash_context_read (hc, - website, - strlen (website) + 1); - GNUNET_CRYPTO_hash_context_read (hc, - &e, - sizeof (e)); - GNUNET_CRYPTO_hash_context_finish (hc, - &h); - end = GNUNET_STRINGS_data_to_string (&h, - sizeof (h), - cstr, - sizeof (cstr)); - *end = '\0'; + GNUNET_assert (0 == + gcry_md_open (&hd, + GCRY_MD_SHA256, + 0)); + gcry_md_write (hd, + nonce, + sizeof (*nonce)); + gcry_md_write (hd, + website, + strlen (website) + 1); + gcry_md_write (hd, + &e, + sizeof (e)); + sha256 = gcry_md_read (hd, + 0); + cstr = NULL; + clen = GNUNET_STRINGS_base64url_encode (sha256, + 256 / 8, + &cstr); GNUNET_asprintf ( &res, - "%llu-%s", + "%llu-%.*s", (unsigned long long) (cur_time.abs_time.abs_value_us / 1000LLU / 1000LLU), + (int) clen, cstr); + GNUNET_free (cstr); return res; } diff --git a/src/backend/paivana-httpd_daemon.c b/src/backend/paivana-httpd_daemon.c @@ -114,8 +114,14 @@ create_response (void *cls, { struct RequestContext *rc = *con_cls; const char *cookie; + bool ok = false; + struct GNUNET_Buffer buf; + char *website; (void) cls; + memset (&buf, + 0, + sizeof (buf)); if ( (! rc->is_paivana) && (0 == strcmp (url, ".well-known/paivana")) && @@ -149,6 +155,20 @@ create_response (void *cls, upload_data_size); } + if (! PAIVANA_HTTPD_get_base_url (con, + &buf)) + { + GNUNET_break (0); + return TALER_MHD_reply_with_error ( + con, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED, + "Host or X-Forwarded-Host required"); + } + GNUNET_buffer_write_str (&buf, + url); + website = GNUNET_buffer_reap_str (&buf); + cookie = MHD_lookup_connection_value (con, MHD_COOKIE_KIND, "Paivana-Cookie"); @@ -156,50 +176,34 @@ create_response (void *cls, { void *ca; size_t ca_len; - struct GNUNET_Buffer buf; - char *website; - bool ok; GNUNET_break (PAIVANA_HTTPD_get_client_address (con, &ca, &ca_len)); - if (! PAIVANA_HTTPD_get_base_url (con, - &buf)) - { - GNUNET_break (0); - return TALER_MHD_reply_with_error ( - con, - MHD_HTTP_BAD_REQUEST, - TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED, - "Host or X-Forwarded-Host required"); - } - GNUNET_buffer_write_str (&buf, - url); - website = GNUNET_buffer_reap_str (&buf); ok = PAIVANA_HTTPD_check_cookie (cookie, website, ca_len, ca); GNUNET_free (ca); - if (! ok) + } + if (! ok) + { + enum GNUNET_GenericReturnValue ret; + + ret = PAIVANA_HTTPD_search_templates (con, + website); + GNUNET_free (website); + if (GNUNET_SYSERR != ret) { - struct MHD_Response *paywall; - - paywall = PAIVANA_HTTPD_search_templates (website); - GNUNET_free (website); - if (NULL != paywall) - { - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Request denied\n"); - return MHD_queue_response (con, - MHD_HTTP_PAYMENT_REQUIRED, - paywall); - } + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Payment required, sending paywall page %s\n", + (GNUNET_OK == ret) ? "ok" : "failed"); + return (GNUNET_OK == ret) ? MHD_YES : MHD_NO; } - GNUNET_free (website); } + GNUNET_free (website); GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Request ok!\n"); + "Request OK, no paywall applies!\n"); rc->do_forward = true; /* FIXME: code for 100 continue suppression should go here! */ return MHD_YES; diff --git a/src/backend/paivana-httpd_templates.c b/src/backend/paivana-httpd_templates.c @@ -41,6 +41,46 @@ struct Template; #include <taler/merchant/get-private-templates-TEMPLATE_ID.h> #include <taler/merchant/get-private-templates.h> + +/** + * Entry in the cache of responses for a given template. + */ +struct ResponseCacheEntry +{ + + /** + * Kept in a DLL. + */ + struct ResponseCacheEntry *next; + + /** + * Kept in a DLL. + */ + struct ResponseCacheEntry *prev; + + /** + * Language of the response. + */ + char *lang; + + /** + * Accept-Encoding of the response. + */ + char *ae; + + /** + * Paywall response for these request parameters. + */ + struct MHD_Response *paywall; + + /** + * HTTP status to return with @e paywall. + */ + unsigned int http_status; + +}; + + /** * Information about a template in the merchant backend. */ @@ -83,9 +123,14 @@ struct Template struct TALER_MERCHANT_GetPrivateTemplateHandle *gt; /** - * Paywall response for this template. NULL for no paywall. + * Kept in a DLL. */ - struct MHD_Response *paywall; + struct ResponseCacheEntry *rce_head; + + /** + * Kept in a DLL. + */ + struct ResponseCacheEntry *rce_tail; }; @@ -100,7 +145,6 @@ static struct Template *t_head; */ static struct Template *t_tail; - /** * Handle to get all the templates. */ @@ -108,62 +152,113 @@ static struct TALER_MERCHANT_GetPrivateTemplatesHandle *gpt; /** + * Check if two strings are equal, including both being NULL + * + * @param s1 a string, possibly NULL + * @param s2 a string. possibly NULL + * @return true if both are equal + */ +static bool +eq (const char *s1, + const char *s2) +{ + if (s1 == s2) + return true; + if (NULL == s1) + return false; + if (NULL == s2) + return false; + return (0 == strcmp (s1, + s2)); +} + + +/** * Try to initialize the paywall response. * - * @param template_id template to use for the response - * @param max_pickup_delay how long does the user have to access the site - * (relative expiration time of the paivana cookie they pay for) - * @return HTTP response to return for matching websites + * @param conn connection to create the response for + * @param t template template to create the response for + * @return MHD status code to return */ -static struct MHD_Response * -load_paywall (const char *template_id, - struct GNUNET_TIME_Relative max_pickup_delay) +static enum MHD_Result +load_paywall (struct MHD_Connection *conn, + struct Template *t) { - void *result; - size_t result_size; + struct MHD_Response *reply; + const char *lang; + const char *ae; + unsigned int http_status = MHD_HTTP_PAYMENT_REQUIRED; + + lang = MHD_lookup_connection_value (conn, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_ACCEPT_LANGUAGE); + ae = MHD_lookup_connection_value (conn, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_ACCEPT_ENCODING); + for (struct ResponseCacheEntry *pos = t->rce_head; + NULL != pos; + pos = pos->next) + { + if ( (eq (lang, + pos->lang)) && + (eq (ae, + pos->ae) ) ) + return MHD_queue_response (conn, + pos->http_status, + pos->paywall); + } { + enum GNUNET_GenericReturnValue ret; json_t *data; data = GNUNET_JSON_PACK ( GNUNET_JSON_pack_string ( "template_id", - template_id), + t->template_id), GNUNET_JSON_pack_uint64 ( "max_pickup_delay", - max_pickup_delay.rel_value_us / 1000LLU / 1000LLU), + t->max_pickup_delay.rel_value_us / 1000LLU / 1000LLU), GNUNET_JSON_pack_string ( "merchant_backend", PH_merchant_base_url)); - if (0 != - TALER_TEMPLATING_fill ("paywall", - data, - &result, - &result_size)) + ret = TALER_TEMPLATING_build (conn, + &http_status, + "paywall", + NULL /* no instance */, + NULL, /* FIXME: no Taler URI!? */ + data, + &reply); + if (GNUNET_OK != ret) { GNUNET_break (0); json_decref (data); - return NULL; + return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; } json_decref (data); } + GNUNET_break (MHD_YES == + MHD_add_response_header (reply, + MHD_HTTP_HEADER_CONTENT_TYPE, + "text/html")); + // FIXME: set Vary and other cache control headers! { - struct MHD_Response *paywall; - - paywall = MHD_create_response_from_buffer_copy (result_size, - result); - if (NULL == paywall) - { - GNUNET_free (result); - return NULL; - } - GNUNET_free (result); - GNUNET_break (MHD_YES == - MHD_add_response_header (paywall, - MHD_HTTP_HEADER_CONTENT_TYPE, - "text/html")); - return paywall; + struct ResponseCacheEntry *rce; + + rce = GNUNET_new (struct ResponseCacheEntry); + if (NULL != lang) + rce->lang = GNUNET_strdup (lang); + if (NULL != ae) + rce->ae = GNUNET_strdup (ae); + rce->paywall = reply; + rce->http_status = http_status; + GNUNET_CONTAINER_DLL_insert (t->rce_head, + t->rce_tail, + rce); + return MHD_queue_response (conn, + rce->http_status, + reply); } } @@ -213,6 +308,10 @@ parse_template (struct Template *t, return; } t->regex = GNUNET_strdup (regex); + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Using payment template %s for `%s'\n", + t->template_id, + regex); } @@ -238,11 +337,6 @@ setup_template ( GNUNET_break (0); break; } - if (t->regex) - { - t->paywall = load_paywall (t->template_id, - t->max_pickup_delay); - } for (struct Template *p = t_head; NULL != p; p = p->next) if (NULL != p->gt) return; @@ -274,8 +368,18 @@ check_templates ( "No templates found, starting to serve requests\n"); PAIVANA_HTTPD_serve_requests (); return; + case MHD_HTTP_UNAUTHORIZED: + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Access to templates unauthorized: %s\n", + TALER_ErrorCode_get_hint (tgr->hr.ec)); + PH_global_ret = EXIT_FAILURE; + GNUNET_SCHEDULER_shutdown (); + return; default: - GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected HTTP status code %u on GET /private/templates (%d)\n", + tgr->hr.http_status, + (int) tgr->hr.ec); PH_global_ret = EXIT_FAILURE; GNUNET_SCHEDULER_shutdown (); return; @@ -325,9 +429,13 @@ PAIVANA_HTTPD_load_templates () } -struct MHD_Response * -PAIVANA_HTTPD_search_templates (const char *website) +enum GNUNET_GenericReturnValue +PAIVANA_HTTPD_search_templates (struct MHD_Connection *connection, + const char *website) { + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Searching templates for `%s'\n", + website); for (struct Template *t = t_head; NULL != t; t = t->next) { if (NULL == t->regex) @@ -336,9 +444,19 @@ PAIVANA_HTTPD_search_templates (const char *website) website, 0, NULL, 0)) - return t->paywall; + { + enum MHD_Result ret; + + ret = load_paywall (connection, + t); + return (MHD_YES == ret) ? GNUNET_OK : GNUNET_NO; + } + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Request for %s did not match template %s\n", + website, + t->template_id); } - return NULL; + return GNUNET_SYSERR; } @@ -352,13 +470,23 @@ PAIVANA_HTTPD_unload_templates () { struct Template *t = t_head; + while (NULL != t->rce_head) + { + struct ResponseCacheEntry *rce = t->rce_head; + + GNUNET_CONTAINER_DLL_remove (t->rce_head, + t->rce_tail, + rce); + MHD_destroy_response (rce->paywall); + GNUNET_free (rce->ae); + GNUNET_free (rce->lang); + GNUNET_free (rce); + } GNUNET_CONTAINER_DLL_remove (t_head, t_tail, t); if (NULL != t->gt) TALER_MERCHANT_get_private_template_cancel (t->gt); - if (NULL != t->paywall) - MHD_destroy_response (t->paywall); if (NULL != t->regex) { regfree (&t->ex); diff --git a/src/backend/paivana-httpd_templates.h b/src/backend/paivana-httpd_templates.h @@ -42,12 +42,15 @@ PAIVANA_HTTPD_load_templates (void); /** * Return the paywall page for the given @a website. * + * @param connection request to search paywall response for * @param website site to look for paywall templates for - * @return NULL if the @a url is not protected by the paywall, - * otherwise paywall response to return (do NOT free!) + * @return #GNUNET_OK on paywall returned, + * #GNUNET_NO to close the connection with error + * #GNUNET_SYSERR if there is no paywall for @a website */ -struct MHD_Response * -PAIVANA_HTTPD_search_templates (const char *website); +enum GNUNET_GenericReturnValue +PAIVANA_HTTPD_search_templates (struct MHD_Connection *connection, + const char *website); /** diff --git a/src/backend/test.conf b/src/backend/test.conf @@ -1,9 +1,25 @@ [paivana] DESTINATION_BASE_URL = https://grothoff.org/ SERVE = tcp -HTTP_PORT = 8888 +PORT = 8888 -MERCHANT_BACKEND_URL = https://backend.demo.taler.net/instances/sandbox/ -MERCHANT_ACCESS_TOKEN = secret-token:sandbox +BASE_URL = http://localhost:80/ +MERCHANT_BACKEND_URL = http://localhost:9966/ +MERCHANT_ACCESS_TOKEN = "secret-token:sandbox" -SECRET = 42 -\ No newline at end of file +SECRET = 42 + +[merchant] +CURRENCY = KUDOS +BASE_URL = http://localhost:8080/ +PORT = 9966 +SERVE = TCP +DEFAULT_REFUND_DELAY = 1s +DEFAULT_WIRE_TRANSFER_ROUNDING_INTERVAL = day + + +[merchantdb-postgres] +CONFIG = postgres:///talercheck + +[merchant-exchange-chf] +DISABLED = YES diff --git a/src/backend/test.sh b/src/backend/test.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Minimal script for testing, creates a first template +# that would work in combination with 'test.conf'. +# Run after starting taler-merchant-httpd and +# *before* starting paivana-httpd. + +# Exit, with status code "skip" (no 'real' failure) +function exit_fail() { + echo "$@" >&2 + exit 1 +} + +LAST_RESPONSE=$(mktemp /tmp/last-response-XXXXXX.json) + +echo -n "Configuring merchant instance ..." + +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H 'Authorization: Bearer secret-token:sandbox' \ + http://localhost:9966/management/instances \ + -d '{"auth":{"method":"external"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 50000000000},"default_pay_delay":{"d_us": 60000000000}}' \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "204" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected '204 No content' response. Got instead $STATUS" +fi +echo "OK" + +echo -n "Configuring merchant bank account ..." + +PAYTO="payto://x-taler-bank/bank.demo.taler.net/fortythree?receiver-name=43" +# add bank account address +STATUS=$(curl -H "Content-Type: application/json" \ + -X POST \ + -H 'Authorization: Bearer secret-token:sandbox' \ + http://localhost:9966/private/accounts \ + -d '{"payto_uri":"'"$PAYTO"'"}' \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +echo "Ok" + +echo -n "Creating Paivana template..." +TID="paivana" +STATUS=$(curl -H "Content-Type: application/json" \ + -X POST \ + 'http://localhost:9966/private/templates' \ + -H 'Authorization: Bearer secret-token:sandbox' \ + -d '{"template_id":"paivana","template_description":"A Paivana template","template_contract":{"template_type":"paivana","summary":"The summary","website_regex":".*","choices":[{"amount":"KUDOS:1"}]}}' \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "204" ] +then + jq < "$LAST_RESPONSE" + exit_fail "Expected 204, template created. got: $STATUS" +fi + +echo "OK"