diff --git a/Gruntfile.coffee b/Gruntfile.coffee index f11582ef9..43a8425db 100755 --- a/Gruntfile.coffee +++ b/Gruntfile.coffee @@ -61,6 +61,7 @@ module.exports = (grunt) -> 'src/Filtering/**/*.coffee' 'src/Quotelinks/**/*.coffee' 'src/Posting/QR.coffee' + 'src/Posting/Captcha.coffee' 'src/Posting/**/*.coffee' 'src/Images/**/*.coffee' 'src/Linkification/**/*.coffee' diff --git a/src/General/Config.coffee b/src/General/Config.coffee index 36d913beb..ed0a784a1 100755 --- a/src/General/Config.coffee +++ b/src/General/Config.coffee @@ -371,6 +371,10 @@ Config = true 'Show notifications on successful post creation or file uploading.' ] + 'Use Recaptcha v1': [ + false + 'Use the old version of Recaptcha before the introduction of the checkbox.' + ] 'Captcha Warning Notifications': [ true 'When disabled, shows a red border on the CAPTCHA input until a key is pressed instead of a notification.' diff --git a/src/General/css/style.css b/src/General/css/style.css index c0d90b3cd..792bd3a70 100755 --- a/src/General/css/style.css +++ b/src/General/css/style.css @@ -10,6 +10,7 @@ #thread-watcher { box-shadow: -1px 2px 2px rgba(0, 0, 0, 0.25); } +.captcha-img, .field { background-color: #FFF; border: 1px solid #CCC; @@ -27,6 +28,7 @@ font-size: 13px !important; opacity: 1.0 !important; } +.captch-img:hover, .field:hover { border-color: #999; } @@ -969,7 +971,8 @@ span.hide-announcement { #qr select, #dump-button, #url-button, -.remove { +.remove, +.captcha-img { cursor: pointer; } #qr { @@ -1062,6 +1065,23 @@ input.field.tripped:not(:hover):not(:focus) { #qr textarea { resize: both; } +/* Recaptcha v1 */ +.captcha-img { + margin: 0px; + text-align: center; + background-image: #fff; + font-size: 0px; + min-height: 59px; + min-width: 302px; +} +.captcha-input{ + width: 100%; + margin: 1px 0 0; +} +.captcha-input.error:focus { + border-color: rgb(255,0,0) !important; +} +/* Recaptcha v2 */ #qr .captcha-root { position: relative; } diff --git a/src/Posting/Captcha.coffee b/src/Posting/Captcha.coffee new file mode 100644 index 000000000..5bcab40bb --- /dev/null +++ b/src/Posting/Captcha.coffee @@ -0,0 +1 @@ +Captcha = {} diff --git a/src/Posting/Captcha.v1.coffee b/src/Posting/Captcha.v1.coffee new file mode 100644 index 000000000..fa9822543 --- /dev/null +++ b/src/Posting/Captcha.v1.coffee @@ -0,0 +1,176 @@ +Captcha.v1 = + init: -> + return if d.cookie.indexOf('pass_enabled=1') >= 0 + return unless @isEnabled = !!$.id 'g-recaptcha' + + script = $.el 'script', + src: '//www.google.com/recaptcha/api/js/recaptcha_ajax.js' + $.add d.head, script + captchaContainer = $.el 'div', + id: 'captchaContainer' + hidden: true + $.add d.body, captchaContainer + + @setup() if Conf['Auto-load captcha'] + + imgContainer = $.el 'div', + className: 'captcha-img' + title: 'Reload reCAPTCHA' + $.extend imgContainer, <%= html('') %> + input = $.el 'input', + className: 'captcha-input field' + title: 'Verification' + autocomplete: 'off' + spellcheck: false + @nodes = + img: imgContainer.firstChild + input: input + + $.on input, 'blur', QR.focusout + $.on input, 'focus', QR.focusin + $.on input, 'keydown', QR.captcha.keydown.bind QR.captcha + $.on @nodes.img.parentNode, 'click', QR.captcha.reload.bind QR.captcha + + $.addClass QR.nodes.el, 'has-captcha' + $.after QR.nodes.com.parentNode, [imgContainer, input] + + @captchas = [] + $.get 'captchas', [], ({captchas}) -> + QR.captcha.sync captchas + QR.captcha.clear() + $.sync 'captchas', @sync + + new MutationObserver(@afterSetup).observe $.id('captchaContainer'), childList: true + + @beforeSetup() + @afterSetup() # reCAPTCHA might have loaded before the QR. + beforeSetup: -> + {img, input} = @nodes + img.parentNode.hidden = true + input.value = '' + input.placeholder = 'Focus to load reCAPTCHA' + @count() + $.on input, 'focus', @setup + setup: -> + $.globalEval ''' + (function() { + var captchaContainer = document.getElementById("captchaContainer"); + if (captchaContainer.firstChild) return; + function setup() { + if (window.Recaptcha) { + Recaptcha.create(recaptchaKey, captchaContainer, {theme: "clean"}); + } else { + setTimeout(setup, 25); + } + } + setup(); + })() + ''' + afterSetup: -> + return unless challenge = $.id 'recaptcha_challenge_field_holder' + return if challenge is QR.captcha.nodes.challenge + + setLifetime = (e) -> QR.captcha.lifetime = e.detail + $.on window, 'captcha:timeout', setLifetime + $.globalEval 'window.dispatchEvent(new CustomEvent("captcha:timeout", {detail: RecaptchaState.timeout}))' + $.off window, 'captcha:timeout', setLifetime + + {img, input} = QR.captcha.nodes + img.parentNode.hidden = false + input.placeholder = 'Verification' + QR.captcha.count() + $.off input, 'focus', QR.captcha.setup + + QR.captcha.nodes.challenge = challenge + new MutationObserver(QR.captcha.load.bind QR.captcha).observe challenge, + childList: true + subtree: true + attributes: true + QR.captcha.load() + + if QR.nodes.el.getBoundingClientRect().bottom > doc.clientHeight + QR.nodes.el.style.top = null + QR.nodes.el.style.bottom = '0px' + destroy: -> + $.globalEval 'Recaptcha.destroy()' + @beforeSetup() + + sync: (captchas) -> + QR.captcha.captchas = captchas + QR.captcha.count() + + getOne: -> + @clear() + if captcha = @captchas.shift() + {challenge, response} = captcha + @count() + $.set 'captchas', @captchas + else + challenge = @nodes.img.alt + if response = @nodes.input.value + if Conf['Auto-load captcha'] then @reload() else @destroy() + # Duplicate one-word captchas. + # Don't duplicate street numbers for now (needs testing). + if response and !/\s|^\d$/.test response + response = "#{response} #{response}" + {challenge, response} + + save: -> + return unless /\S/.test(response = @nodes.input.value) + @nodes.input.value = '' + @captchas.push + challenge: @nodes.img.alt + response: response + timeout: @timeout + @count() + @reload() + $.set 'captchas', @captchas + + clear: -> + return unless @captchas.length + now = Date.now() + for captcha, i in @captchas + break if captcha.timeout > now + return unless i + @captchas = @captchas[i..] + @count() + $.set 'captchas', @captchas + + load: -> + return unless @nodes.challenge.firstChild + return unless challenge_image = $.id 'recaptcha_challenge_image' + # -1 minute to give upload some time. + @timeout = Date.now() + @lifetime * $.SECOND - $.MINUTE + challenge = @nodes.challenge.firstChild.value + @nodes.img.alt = challenge + @nodes.img.src = challenge_image.src + @nodes.input.value = null + @clear() + + count: -> + count = if @captchas then @captchas.length else 0 + placeholder = @nodes.input.placeholder.replace /\ \(.*\)$/, '' + placeholder += switch count + when 0 + if placeholder is 'Verification' then ' (Shift + Enter to cache)' else '' + when 1 + ' (1 cached captcha)' + else + " (#{count} cached captchas)" + @nodes.input.placeholder = placeholder + @nodes.input.alt = count # For XTRM RICE. + + reload: (focus) -> + # Hack to prevent the input from being focused + $.globalEval 'Recaptcha.reload(); Recaptcha.should_focus = false;' + # Focus if we meant to. + @nodes.input.focus() if focus + + keydown: (e) -> + if e.keyCode is 8 and not @nodes.input.value + @reload() + else if e.keyCode is 13 and e.shiftKey + @save() + else + return + e.preventDefault() diff --git a/src/Posting/QR.captcha.coffee b/src/Posting/Captcha.v2.coffee similarity index 98% rename from src/Posting/QR.captcha.coffee rename to src/Posting/Captcha.v2.coffee index 4135efc26..a66cccd14 100644 --- a/src/Posting/QR.captcha.coffee +++ b/src/Posting/Captcha.v2.coffee @@ -1,4 +1,4 @@ -QR.captcha = +Captcha.v2 = init: -> return if d.cookie.indexOf('pass_enabled=1') >= 0 return unless @isEnabled = !!$.id 'g-recaptcha' @@ -117,9 +117,9 @@ QR.captcha = if captcha = @captchas.shift() @count() $.set 'captchas', @captchas - captcha.response + {response: captcha.response} else - null + {} save: (pasted) -> reload = QR.cooldown.auto and @needed() diff --git a/src/Posting/QR.coffee b/src/Posting/QR.coffee index 88cf9865e..efdfaa3d1 100644 --- a/src/Posting/QR.coffee +++ b/src/Posting/QR.coffee @@ -7,6 +7,8 @@ QR = @db = new DataBoard 'yourPosts' @posts = [] + @captcha = Captcha[if Conf['Use Recaptcha v1'] then 'v1' else 'v2'] + if Conf['QR Shortcut'] sc = $.el 'a', className: "qr-shortcut fa fa-comment-o #{unless Conf['Persistent QR'] then 'disabled' else ''}" @@ -95,7 +97,7 @@ QR = if QR.nodes QR.nodes.el.hidden = false QR.unhide() - QR.captcha.setup() + QR.captcha.setup() unless Conf['Use Recaptcha v1'] return try QR.dialog() @@ -112,6 +114,8 @@ QR = QR.cleanNotifications() d.activeElement.blur() $.rmClass QR.nodes.el, 'dump' + if Conf['Use Recaptcha v1'] and !Conf['Captcha Warning Notifications'] + $.rmClass QR.captcha.nodes.input, 'error' if QR.captcha.isEnabled if Conf['QR Shortcut'] $.toggleClass $('.qr-shortcut'), 'disabled' new QR.post true @@ -119,7 +123,8 @@ QR = post.delete() QR.cooldown.auto = false QR.status() - QR.captcha.destroy() + if !Conf['Use Recaptcha v1'] or (QR.captcha.isEnabled and not Conf['Auto-load captcha']) + QR.captcha.destroy() focusin: -> $.addClass QR.nodes.el, 'focus' focusout: -> @@ -144,9 +149,21 @@ QR = else el = err el.removeAttribute 'style' - if QR.captcha.isEnabled and /captcha|verification/i.test el.textContent - QR.captcha.setup true - QR.notify el + captchaErr = QR.captcha.isEnabled and /captcha|verification/i.test el.textContent + if captchaErr and Conf['Use Recaptcha v1'] + if QR.captcha.captchas.length is 0 + # Focus the captcha input on captcha error. + QR.captcha.nodes.input.focus() + QR.captcha.setup() + if Conf['Captcha Warning Notifications'] and !d.hidden + QR.notify el + else + $.addClass QR.captcha.nodes.input, 'error' + $.on QR.captcha.nodes.input, 'keydown', -> + $.rmClass QR.captcha.nodes.input, 'error' + else + QR.captcha.setup true if captchaErr and !Conf['Use Recaptcha v1'] + QR.notify el alert el.textContent if d.hidden notify: (el) -> @@ -549,7 +566,7 @@ QR = QR.cooldown.init() QR.captcha.init() $.add d.body, dialog - QR.captcha.setup() + QR.captcha.setup() unless Conf['Use Recaptcha v1'] # Create a custom event when the QR dialog is first initialized. # Use it to extend the QR's functionalities, or for XTRM RICE. @@ -640,7 +657,7 @@ QR = err = 'Max limit of image replies has been reached.' if QR.captcha.isEnabled and !err - response = QR.captcha.getOne() + {challenge, response} = QR.captcha.getOne() err = 'No valid captcha.' unless response QR.cleanNotifications() @@ -674,6 +691,7 @@ QR = textonly: textOnly mode: 'regist' pwd: QR.persona.pwd + recaptcha_challenge_field: challenge 'g-recaptcha-response': response options = @@ -803,6 +821,7 @@ QR = icon: Favicon.logo notif.onclick = -> QR.open() + QR.captcha.nodes.input.focus() if Conf['Use Recaptcha v1'] window.focus() notif.onshow = -> setTimeout -> @@ -812,8 +831,10 @@ QR = unless Conf['Persistent QR'] or postsCount QR.close() else + if Conf['Use Recaptcha v1'] and QR.posts.length > 1 and QR.captcha.isEnabled and QR.captcha.captchas.length is 0 + QR.captcha.setup() post.rm() - QR.captcha.setup true + QR.captcha.setup true unless Conf['Use Recaptcha v1'] QR.cooldown.add req.uploadEndTime, threadID, postID diff --git a/src/Posting/QR.post.coffee b/src/Posting/QR.post.coffee index ef7a4da22..91c7dc115 100644 --- a/src/Posting/QR.post.coffee +++ b/src/Posting/QR.post.coffee @@ -70,7 +70,7 @@ QR.post = class @select() if select @unlock() # Post count temporarily off by 1 when called from QR.post.rm - $.queueTask -> QR.captcha.setup() + $.queueTask -> QR.captcha.setup() unless Conf['Use Recaptcha v1'] rm: -> @delete() @@ -133,7 +133,7 @@ QR.post = class QR.status() when 'com' @nodes.span.textContent = @com - QR.captcha.onPostChange() + QR.captcha.onPostChange() unless Conf['Use Recaptcha v1'] QR.characterCount() # Disable auto-posting if you're typing in the first post # during the last 5 seconds of the cooldown. @@ -162,7 +162,7 @@ QR.post = class @filename = file.name @filesize = $.bytesToString file.size @nodes.label.hidden = false if QR.spoiler - QR.captcha.onPostChange() + QR.captcha.onPostChange() unless Conf['Use Recaptcha v1'] URL.revokeObjectURL @URL if @ is QR.selected @showFileData()