diff --git a/client/html/index.htm b/client/html/index.htm index 00728903..34895d1e 100644 --- a/client/html/index.htm +++ b/client/html/index.htm @@ -22,6 +22,7 @@ +
diff --git a/client/html/user_registration.tpl b/client/html/user_registration.tpl index a6d291f4..d2b008f2 100644 --- a/client/html/user_registration.tpl +++ b/client/html/user_registration.tpl @@ -38,6 +38,7 @@
+ <% if(ctx.enableRecaptcha) print(`

`); %>
diff --git a/client/js/api.js b/client/js/api.js index 5bde6d81..2df1fdf1 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -100,6 +100,10 @@ class Api extends events.EventTarget { return remoteConfig.contactEmail; } + getRecaptchaSiteKey() { + return remoteConfig.recaptchaSiteKey; + } + canSendMails() { return !!remoteConfig.canSendMails; } @@ -108,6 +112,10 @@ class Api extends events.EventTarget { return !!remoteConfig.enableSafety; } + recaptchaEnabled() { + return !this.isLoggedIn() && !!remoteConfig.enableRecaptcha; + } + hasPrivilege(lookup) { let minViableRank = null; for (let p of Object.keys(remoteConfig.privileges)) { diff --git a/client/js/controllers/user_registration_controller.js b/client/js/controllers/user_registration_controller.js index 89cfd8cd..72a52b5e 100644 --- a/client/js/controllers/user_registration_controller.js +++ b/client/js/controllers/user_registration_controller.js @@ -30,7 +30,7 @@ class UserRegistrationController { user.email = e.detail.email; user.password = e.detail.password; const isLoggedIn = api.isLoggedIn(); - user.save() + user.save(e.detail.recaptchaToken) .then(() => { if (isLoggedIn) { return Promise.resolve(); diff --git a/client/js/models/user.js b/client/js/models/user.js index 28dc3efe..5efbf3ec 100644 --- a/client/js/models/user.js +++ b/client/js/models/user.js @@ -107,7 +107,7 @@ class User extends events.EventTarget { }); } - save() { + save(recaptchaToken) { const files = []; const detail = { version: this._version }; const transient = this._orig._name; @@ -131,13 +131,16 @@ class User extends events.EventTarget { if (this._password) { detail.password = this._password; } + if (api.recaptchaEnabled) { + detail.recaptchaToken = recaptchaToken; + } let promise = this._orig._name ? api.put( - uri.formatApiLink("user", this._orig._name), - detail, - files - ) + uri.formatApiLink("user", this._orig._name), + detail, + files + ) : api.post(uri.formatApiLink("users"), detail, files); return promise.then((response) => { diff --git a/client/js/views/registration_view.js b/client/js/views/registration_view.js index 0a08de23..5c495b30 100644 --- a/client/js/views/registration_view.js +++ b/client/js/views/registration_view.js @@ -15,11 +15,27 @@ class RegistrationView extends events.EventTarget { template({ userNamePattern: api.getUserNameRegex(), passwordPattern: api.getPasswordRegex(), + enableRecaptcha: api.recaptchaEnabled(), }) ); views.syncScrollPosition(); views.decorateValidator(this._formNode); this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); + this.setRecaptchaToken = this.setRecaptchaToken.bind(this); + + if (api.recaptchaEnabled()) + this.renderRecaptcha(); + } + + renderRecaptcha() { + grecaptcha.render(this._recaptchaNode, { + "callback": this.setRecaptchaToken, + "sitekey": api.getRecaptchaSiteKey(), + }); + } + + setRecaptchaToken(token) { + this.recaptchaToken = token; } clearMessages() { @@ -46,6 +62,7 @@ class RegistrationView extends events.EventTarget { name: this._userNameFieldNode.value, password: this._passwordFieldNode.value, email: this._emailFieldNode.value, + recaptchaToken: this.recaptchaToken, }, }) ); @@ -66,6 +83,10 @@ class RegistrationView extends events.EventTarget { get _emailFieldNode() { return this._formNode.querySelector("[name=email]"); } + + get _recaptchaNode() { + return this._formNode.querySelector("#recaptcha"); + } } module.exports = RegistrationView; diff --git a/server/Dockerfile b/server/Dockerfile index 50cd700c..dfdb8176 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -24,7 +24,7 @@ RUN \ alembic \ "coloredlogs==5.0" \ youtube-dl \ - && apk --no-cache del py3-pip + requests ARG PUID=1000 ARG PGID=1000 diff --git a/server/config.yaml.dist b/server/config.yaml.dist index e3799a35..5dd9d276 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -8,6 +8,15 @@ domain: # example: http://example.com # used to salt the users' password hashes and generate filenames for static content secret: change +# Whether solving a captcha is required for registration for anonymous users. +enable_recaptcha: no +# The reCAPTCHA site key. +recaptcha_site_key: change +# A reCAPTCHA v2 secret token. +# https://developers.google.com/recaptcha/intro +# https://developers.google.com/recaptcha/docs/display +recaptcha_secret: change + # Delete thumbnails and source files on post delete # Original functionality is no, to mitigate the impacts of admins going # on unchecked post purges. diff --git a/server/requirements.txt b/server/requirements.txt index d80ec060..f176d659 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -10,3 +10,4 @@ pynacl>=1.2.1 pytz>=2018.3 pyRFC3339>=1.0 youtube-dl +requests \ No newline at end of file diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py index 757b09cf..667b2c5d 100644 --- a/server/szurubooru/api/info_api.py +++ b/server/szurubooru/api/info_api.py @@ -49,6 +49,8 @@ def get_info(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: "privileges": util.snake_case_to_lower_camel_case_keys( config.config["privileges"] ), + "enableRecaptcha": config.config["enable_recaptcha"], + "recaptchaSiteKey": config.config["recaptcha_site_key"], }, } if auth.has_privilege(ctx.user, "posts:view:featured"): diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index a6196cb8..588bcf6c 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -1,6 +1,8 @@ from typing import Any, Dict -from szurubooru import model, rest, search +import requests + +from szurubooru import config, model, rest, search from szurubooru.func import auth, serialization, users, versions _search_executor = search.Executor(search.configs.UserSearchConfig()) @@ -31,11 +33,28 @@ def get_users( def create_user( ctx: rest.Context, _params: Dict[str, str] = {} ) -> rest.Response: + expect_recaptcha = False + if ctx.user.user_id is None: + expect_recaptcha = True auth.verify_privilege(ctx.user, "users:create:self") else: auth.verify_privilege(ctx.user, "users:create:any") + # Verify if the recaptcha was correct. + if expect_recaptcha and config.config["enable_recaptcha"]: + resp = requests.post("https://www.google.com/recaptcha/api/siteverify", data={ + "secret": config.config["recaptcha_secret"], + "response": ctx.get_param_as_string("recaptchaToken", default=""), + }) + + # Raise a 400 error if the recaptcha wasn't OK. + if not resp.json()["success"]: + raise rest.errors.HttpBadRequest( + "ValidationError", + "Recaptcha response was invalid." + ) + name = ctx.get_param_as_string("name") password = ctx.get_param_as_string("password") email = ctx.get_param_as_string("email", default="")