From 52554a1dcd9101fff5bea67f883f2d1ebe57d8e4 Mon Sep 17 00:00:00 2001 From: "Jakob L. Kreuze" Date: Sat, 19 Nov 2022 12:26:21 -0500 Subject: [dynamic] Implement client-side of POW captcha --- haunt/jakob/dynamic/capabilities/comment-form.scm | 8 +- haunt/jakob/dynamic/capabilities/comments.scm | 26 +++-- haunt/jakob/dynamic/captcha.scm | 3 +- haunt/static/css/style.css | 8 +- haunt/static/js/proof-of-work.js | 117 +++++++++++++++++----- 5 files changed, 120 insertions(+), 42 deletions(-) diff --git a/haunt/jakob/dynamic/capabilities/comment-form.scm b/haunt/jakob/dynamic/capabilities/comment-form.scm index 1754dfd..f1cbd9e 100644 --- a/haunt/jakob/dynamic/capabilities/comment-form.scm +++ b/haunt/jakob/dynamic/capabilities/comment-form.scm @@ -21,6 +21,7 @@ #:use-module (jakob dynamic captcha) #:use-module (jakob dynamic util) #:use-module (jakob theme) + #:use-module (jakob utils sxml) #:use-module (json) #:use-module (srfi srfi-1) #:use-module (srfi srfi-11) @@ -56,7 +57,12 @@ (img (@ (src ,(format #f "data:image/jpeg;charset=utf-8;base64,~a" (base64-encode captcha-image))))) (input (@ (type "text") (id "captcha") (name "captcha") (size 24))) - (input (@ (type "submit") (id "submit-form") (value "Submit"))))))))) + (button (@ (id "pow-trigger") (hidden #t)) + "Too hard? (Or unable to see the challenge?) Click here.") + (input (@ (type "text") (id "captcha-alt") (name "captcha-alt") (hidden #t))) + (input (@ (type "text") (id "captcha-alt-id") (name "captcha-alt-id") (hidden #t))) + (input (@ (type "submit") (id "submit-form") (value "Submit"))))) + ,(script "proof-of-work.js"))))) (define (get-comment-form request body) "API endpoint handler for querying for the comments on a particular post diff --git a/haunt/jakob/dynamic/capabilities/comments.scm b/haunt/jakob/dynamic/capabilities/comments.scm index 72067b4..54e1846 100644 --- a/haunt/jakob/dynamic/capabilities/comments.scm +++ b/haunt/jakob/dynamic/capabilities/comments.scm @@ -80,11 +80,18 @@ This is a wrapper around `get-comments-by-slug'." (and (assoc "slug" form-data) (assoc "name" form-data) (assoc "comment" form-data) - (assoc "captcha" form-data) + (or (assoc "captcha" form-data) + (and (assoc "captcha-alt" form-data) + (assoc "captcha-alt-id" form-data))) (assoc "captcha-id" form-data) - (validate-captcha! - (assoc-value form-data "captcha") - (string->number (assoc-value form-data "captcha-id"))))) + (if (and (string? (assoc-value form-data "captcha-alt")) + (positive? (string-length (assoc-value form-data "captcha-alt")))) + (validate-proof-of-work! + (assoc-value form-data "captcha-alt") + (string->number (assoc-value form-data "captcha-alt-id"))) + (validate-captcha! + (assoc-value form-data "captcha") + (string->number (assoc-value form-data "captcha-id")))))) (define (insert-comment form-data) (exec-query conn "INSERT INTO comments (submitted, slug, name, subject, @@ -132,7 +139,7 @@ This is a wrapper around `get-comments-by-slug'." (form-data (if query-string (decode-form query-string) '()))) - (if (valid-reaction? form-data) + (if (valid-reaction? form-data) (let ((id (assoc-value form-data "id")) (reaction (assoc-value form-data "reaction")) (reactions (comment-reactions id))) @@ -149,12 +156,3 @@ This is a wrapper around `get-comments-by-slug'." (scm->json-string `((success . #f) (error . "missing `id', or `reaction'"))))))) - -;; (define (make-challenge request body) -;; (let-values (((uuid value image) (new-captcha))) -;; (hash-set! challenges uuid value) -;; (hash-for-each (lambda (x y) (display x) (newline)) challenges) -;; (values `((content-type . (application/base64)) -;; (access-control-allow-origin . "*") -;; (x-captcha-id . ,uuid)) -;; (base64-encode image)))) diff --git a/haunt/jakob/dynamic/captcha.scm b/haunt/jakob/dynamic/captcha.scm index ab90f55..0f6e0ba 100644 --- a/haunt/jakob/dynamic/captcha.scm +++ b/haunt/jakob/dynamic/captcha.scm @@ -255,7 +255,7 @@ internally-defined `time-to-live-seconds'." (define (validate-proof-of-work! prefix challenge-id) (define zero-prefix (string-join (map (lambda (_) "0") (iota %hardness)) "")) - (unless (member challenge-id (id-queue-free pow-challenge-id-queue)) + (when (member challenge-id (id-queue-free pow-challenge-id-queue)) (raise (condition (&message (message "No such challenge ID"))))) (let* ((challenge (hash-ref pow-challenges challenge-id)) @@ -269,7 +269,6 @@ internally-defined `time-to-live-seconds'." (when challenge (release-id! challenge-id pow-challenge-id-queue)) (and (= 32 (string-length prefix)) - (not (member challenge-id (id-queue-free pow-challenge-id-queue))) (string-prefix? zero-prefix hash-value)))) (define (make-pow-challenge! request body) diff --git a/haunt/static/css/style.css b/haunt/static/css/style.css index 215a08c..82bd8da 100644 --- a/haunt/static/css/style.css +++ b/haunt/static/css/style.css @@ -1,3 +1,5 @@ +[hidden] { display: none !important; } + html { font-family: "Cantarell", sans-serif; } @@ -257,7 +259,9 @@ ul#webmention-container .comment .comment-source-identifier { clear: left; } -#comment-form label, #comment-form input { +#comment-form label, +#comment-form input, +#comment-form button { margin-top: 1em; } @@ -342,7 +346,7 @@ tr:nth-child(even) { /* Buttons. */ -button { +.lang button { display: inline; padding: .3em .6em .3em; font-size: 75%; diff --git a/haunt/static/js/proof-of-work.js b/haunt/static/js/proof-of-work.js index 5207ac6..1a57c2a 100644 --- a/haunt/static/js/proof-of-work.js +++ b/haunt/static/js/proof-of-work.js @@ -1,34 +1,105 @@ -const text = 'An obscure body in the S-K System, your majesty. The inhabitants refer to it as the planet Earth.'; +/* + * proof-of-work.js -- Alternative captcha based on SHA-256 proof-of-work. + * Copyright © 2022 Jakob L. Kreuze + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * . + */ function makeid(length) { - var result = ''; - var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - var charactersLength = characters.length; - for ( var i = 0; i < length; i++ ) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; + let result = ''; + let alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + for (let i = 0; i < length; i++) { + result += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return result; } async function digestMessage(message) { - const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array - const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message - const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string + const msgUtf8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUtf8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); return hashHex; } -const HARDNESS = 4; - -async function findPrefix(challenge) { - while (true) { - let prefix = makeid(32); - let digestHex = await digestMessage(prefix + challenge); - if (digestHex.startsWith("0".repeat(HARDNESS))) { - return [prefix + challenge, digestHex]; - } +async function findPrefix(hardness, nonce) { + while (true) { + let prefix = makeid(32); + let digestHex = await digestMessage(prefix + nonce); + if (digestHex.startsWith("0".repeat(hardness))) { + return prefix; } + } +} + +function makeRequest (method, url) { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response); + } else { + reject({ + status: xhr.status, + statusText: xhr.statusText + }); + } + }; + xhr.onerror = function () { + reject({ + status: xhr.status, + statusText: xhr.statusText + }); + }; + xhr.send(); + }); } -const suffix = "p12IyPB2dZsmBHYZyaEMrR5OUxqNc5Z9Cijal+9/iuQ="; -findPrefix(suffix).then(console.log); + +function raceEndpoint() { + return new Promise(function (resolve, reject) { + makeRequest("GET", "/api/challenge") + .then(function (data) { + let challengeData = JSON.parse(data); + findPrefix(challengeData.hardness, challengeData.nonce) + .then((prefix) => { resolve([prefix, challengeData["challenge-id"]]) } ); + }) + .catch(reject); + }); +} + +let trigger = document.getElementById("pow-trigger"); +trigger.hidden = false; +trigger.addEventListener("click", (e) => { + trigger.innerHTML = "Please wait..."; + trigger.disabled = true; + raceEndpoint() + .then((result) => { + let resultField = document.getElementById("captcha-alt"); + let challengeIdField = document.getElementById("captcha-alt-id"); + resultField.value = result[0]; + challengeIdField.value = result[1]; + trigger.innerHTML = "Proof-of-Work completed successfully!"; + trigger.disabled = true; + }) + .catch((err) => { + console.log(err); + trigger.innerHTML = "Proof-of-Work failed!"; + trigger.disabled = true; + }); + e.preventDefault(); +}) + -- cgit v1.3