From 3f3131beb00b4e21830513ba235fdaadbe8e8803 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Sun, 3 Mar 2024 02:06:09 -0800 Subject: [PATCH] Make birthdate optional and less precise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit or fuzzy, as it were - Reduces friction when signing up - Reduces amount of personal information stored by default - Keeps exact birthdate private in case people don’t realize that displaying their age is equivalent to displaying their birthdate, as long as the transition is recorded We still prevent users from displaying an age that’s inconsistent with anything they’ve done with ratings, and from changing their age once set. TODO: `asserted_adult` should be filled in for existing users just in case their birthdate is removed by site staff. --- assets/js/scripts.js | 23 ++-- assets/js/signup.js | 16 +++ assets/scss/_theme.scss | 1 + assets/scss/components/_date-picker.scss | 32 ++++- assets/scss/signup.scss | 1 - assets/scss/site.scss | 29 ++--- build.js | 1 + .../57171ee9e989_make_birthdate_optional.py | 34 ++++++ libweasyl/models/tables.py | 5 +- libweasyl/ratings.py | 3 + weasyl/character.py | 4 + weasyl/errorcode.py | 4 + weasyl/journal.py | 4 + weasyl/login.py | 11 +- weasyl/profile.py | 109 +++++++++++++++--- weasyl/submission.py | 4 + weasyl/templates/admincontrol/manageuser.html | 2 +- weasyl/templates/control/edit_profile.html | 29 ++++- weasyl/templates/edit/character.html | 2 + weasyl/templates/edit/journal.html | 2 + weasyl/templates/edit/submission.html | 2 + weasyl/templates/etc/signup.html | 54 +++------ weasyl/templates/submit/character.html | 2 + weasyl/templates/submit/journal.html | 2 + weasyl/templates/submit/literary.html | 2 + weasyl/templates/submit/multimedia.html | 2 + weasyl/templates/submit/visual.html | 2 + 27 files changed, 290 insertions(+), 92 deletions(-) create mode 100644 assets/js/signup.js create mode 100644 assets/scss/_theme.scss create mode 100644 libweasyl/alembic/versions/57171ee9e989_make_birthdate_optional.py diff --git a/assets/js/scripts.js b/assets/js/scripts.js index 05b6146ba..946a1276d 100644 --- a/assets/js/scripts.js +++ b/assets/js/scripts.js @@ -1136,23 +1136,32 @@ inputElement.parentNode.classList.toggle('disabled', disable); } - document.addEventListener('change', function (e) { - var disableId = e.target.dataset.disables; + function handleCheckState(target) { + var disableId = target.dataset.disables; if (disableId) { var disables = document.getElementById(disableId); - var disable = e.target.checked; + var disable = target.checked; disableWithLabel(disables, disable); } - }); - forEach(document.querySelectorAll('[data-disables]'), function (checkbox) { - var disables = document.getElementById(checkbox.dataset.disables); + var showId = target.dataset.shows; + + if (showId) { + var shows = document.getElementById(showId); + + shows.style.display = target.checked ? '' : 'none'; + } + } - disableWithLabel(disables, checkbox.checked); + document.addEventListener('change', function (e) { + handleCheckState(e.target); }); + forEach(document.querySelectorAll('[data-disables]'), handleCheckState); + forEach(document.querySelectorAll('[data-shows]'), handleCheckState); + (function () { function isOtherOption(option) { return option.hasAttribute('data-select-other'); diff --git a/assets/js/signup.js b/assets/js/signup.js new file mode 100644 index 000000000..e4e73b34f --- /dev/null +++ b/assets/js/signup.js @@ -0,0 +1,16 @@ +// `invalid-use-title` indicates that an element’s title is a good custom error message for when it doesn’t pass HTML form validation. +for (const element of document.getElementsByClassName('invalid-use-title')) { + const message = element.title; + element.title = ''; + + const updateCustomValidity = () => { + element.setCustomValidity(''); + + if (!element.checkValidity()) { + element.setCustomValidity(message); + } + }; + + element.addEventListener('change', updateCustomValidity); + updateCustomValidity(); +} diff --git a/assets/scss/_theme.scss b/assets/scss/_theme.scss new file mode 100644 index 000000000..021473c65 --- /dev/null +++ b/assets/scss/_theme.scss @@ -0,0 +1 @@ +$content-color-lighter: #777; diff --git a/assets/scss/components/_date-picker.scss b/assets/scss/components/_date-picker.scss index 8e0e2f05b..112000d67 100644 --- a/assets/scss/components/_date-picker.scss +++ b/assets/scss/components/_date-picker.scss @@ -1,10 +1,30 @@ -.form-date-day, .form-date-year { - width: 25%; - float: left; +@import '../theme'; + +.form-date { + display: flex; + gap: 4px; +} + +.form-date > * { + // `!important`s here override terrible global `label` style that needs refactoring + display: flex !important; + flex-direction: column; + padding: 0 !important; +} + +.form-date > * > .sublabel { + color: $content-color-lighter; + padding-top: 0.35em; + font-size: 12px; + font-size: 0.75rem; + font-weight: 400; } .form-date-month { - width: 46%; - float: left; - padding: 0 2%; + flex: 1; + max-width: 12em; +} + +.form-date > .form-date-year > input { + width: 6em; } diff --git a/assets/scss/signup.scss b/assets/scss/signup.scss index af17dfa6f..1f0b990f4 100644 --- a/assets/scss/signup.scss +++ b/assets/scss/signup.scss @@ -1,4 +1,3 @@ -@import 'components/date-picker'; @import 'components/password-meter'; #signup-terms { diff --git a/assets/scss/site.scss b/assets/scss/site.scss index 86c4548e7..c832f6051 100644 --- a/assets/scss/site.scss +++ b/assets/scss/site.scss @@ -1,4 +1,6 @@ +@import 'theme'; @import 'reset'; +@import 'components/date-picker'; @import 'components/option'; @import 'components/text-post-list'; @import 'components/user-tabs'; @@ -135,8 +137,6 @@ a.more span, #header-messages a, .page-title a { color: #943522; } -$content-color-lighter: #777; - .content .color-lighter { color: $content-color-lighter; } @@ -751,6 +751,8 @@ dl + h4, #uc-info { opacity: 0.6; } +$input-background: #e4e4e4; + .input { box-sizing: border-box; padding: 0 0.5em; @@ -758,9 +760,19 @@ dl + h4, #uc-info { border-width: 1px; border-color: #aaa #bbb #ccc; border-radius: 0; - background-color: #e4e4e4; + background-color: $input-background; box-shadow: 0 4px 0 #336979, inset 0 1px 2px #aaa; transition: all 0.15s linear; + + &:hover, + &:focus { + background-color: #eee; + } + + &:disabled { + background-color: $input-background; + box-shadow: 0 4px 0 #aaa; + } } .input::placeholder { @@ -778,10 +790,6 @@ select.input { line-height: 1.75rem; } -.input:hover, .input:focus { - background-color: #eee; -} - .input:focus { box-shadow: 0 4px 0 #069bc0; } @@ -1639,13 +1647,6 @@ $tag-reject-deemphasize-size: 11px; font-weight: 700; } -.form label.color-lighter:not(.input-checkbox) { - padding-top: 0.35em; - font-size: 12px; - font-size: 0.75rem; - font-weight: 400; -} - .optional-indicator { font-weight: normal; color: $content-color-lighter; diff --git a/build.js b/build.js index c82cd2865..1cc141176 100644 --- a/build.js +++ b/build.js @@ -292,6 +292,7 @@ const main = async () => { target: 'es6', banner: {}, }), + esbuildFile('js/signup.js', 'js/signup.js', touch, PRIVATE_FIELDS_ESM), copyStaticFiles('img/help', touch), copyUnversionedStaticFile('opensearch.xml', touch), copyImages, diff --git a/libweasyl/alembic/versions/57171ee9e989_make_birthdate_optional.py b/libweasyl/alembic/versions/57171ee9e989_make_birthdate_optional.py new file mode 100644 index 000000000..8721248f8 --- /dev/null +++ b/libweasyl/alembic/versions/57171ee9e989_make_birthdate_optional.py @@ -0,0 +1,34 @@ +"""Make birthdate optional + +Revision ID: 57171ee9e989 +Revises: 9a41d4fa38ba +Create Date: 2023-12-22 00:15:53.754117 + +""" + +# revision identifiers, used by Alembic. +revision = '57171ee9e989' +down_revision = '9a41d4fa38ba' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.alter_column('logincreate', 'birthday', + existing_type=sa.INTEGER(), + nullable=True) + op.add_column('userinfo', sa.Column('asserted_adult', sa.Boolean(), server_default='f', nullable=False)) + op.alter_column('userinfo', 'birthday', + existing_type=sa.INTEGER(), + nullable=True) + + +def downgrade(): + op.alter_column('userinfo', 'birthday', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('userinfo', 'asserted_adult') + op.alter_column('logincreate', 'birthday', + existing_type=sa.INTEGER(), + nullable=False) diff --git a/libweasyl/models/tables.py b/libweasyl/models/tables.py index c22169543..b151ef2b0 100644 --- a/libweasyl/models/tables.py +++ b/libweasyl/models/tables.py @@ -350,7 +350,7 @@ def default_fkey(*args, **kwargs): Column('login_name', String(length=40), nullable=False, unique=True), Column('hashpass', String(length=100), nullable=False), Column('email', String(length=100), nullable=False, unique=True), - Column('birthday', WeasylTimestampColumn(), nullable=False), + Column('birthday', WeasylTimestampColumn(), nullable=True), Column('created_at', TIMESTAMP(timezone=True), nullable=False, server_default=func.now()), # Used to determine if a record is invalid for purposes of plausible deniability of email addresses # AKA, create a logincreate entry if an in-use email address is provided, thus preserving the effect of @@ -884,7 +884,8 @@ def _tag_suggestion_feedback_table(content_table, id_column): userinfo = Table( 'userinfo', metadata, Column('userid', Integer(), primary_key=True, nullable=False), - Column('birthday', WeasylTimestampColumn(), nullable=False), + Column('birthday', WeasylTimestampColumn(), nullable=True), + Column('asserted_adult', Boolean(), nullable=False, server_default='f'), Column('gender', String(length=100), nullable=False, server_default=''), Column('country', String(length=50), nullable=False, server_default=''), default_fkey(['userid'], ['login.userid'], name='userinfo_userid_fkey'), diff --git a/libweasyl/ratings.py b/libweasyl/ratings.py index 7c67a99f3..bc00b5251 100644 --- a/libweasyl/ratings.py +++ b/libweasyl/ratings.py @@ -53,5 +53,8 @@ def __hash__(self): def get_ratings_for_age(age): + if age is None: + return ALL_RATINGS + age = max(0, age) return [rating for rating in ALL_RATINGS if rating.minimum_age <= age] diff --git a/weasyl/character.py b/weasyl/character.py index c72b8d2ec..6781e189a 100644 --- a/weasyl/character.py +++ b/weasyl/character.py @@ -43,6 +43,8 @@ def create(userid, character, friends, tags, thumbfile, submitfile): elif not character.rating: raise WeasylError("ratingInvalid") profile.check_user_rating_allowed(userid, character.rating) + if character.rating.minimum_age: + profile.assert_adult(userid) # Write temporary thumbnail file if thumbsize: @@ -372,6 +374,8 @@ def edit(userid, character, friends_only): if userid == query.userid: profile.check_user_rating_allowed(userid, character.rating) + if character.rating.minimum_age: + profile.assert_adult(userid) if friends_only: welcome.character_remove(character.charid) diff --git a/weasyl/errorcode.py b/weasyl/errorcode.py index a69cd9362..abb76a254 100644 --- a/weasyl/errorcode.py +++ b/weasyl/errorcode.py @@ -22,6 +22,10 @@ "fully processed.") error_messages = { + "birthdayInconsistentWithRating": ( + "You’ve already confirmed that you were 18 or older when setting rating preferences or a post’s rating; Weasyl won’t show an age inconsistent with that on your profile."), + "birthdayInconsistentWithTerms": ( + "You’ve already confirmed that you were 13 or older when signing up; Weasyl won’t show an age inconsistent with that on your profile."), "birthdayInsufficient": ( "Your date of birth indicates that you are not allowed to set your content rating settings to the " "level you entered. Please choose a lower rating level."), diff --git a/weasyl/journal.py b/weasyl/journal.py index cf03a272c..e9931589c 100644 --- a/weasyl/journal.py +++ b/weasyl/journal.py @@ -31,6 +31,8 @@ def create(userid, journal, friends_only=False, tags=None): elif not journal.rating: raise WeasylError("ratingInvalid") profile.check_user_rating_allowed(userid, journal.rating) + if journal.rating.minimum_age: + profile.assert_adult(userid) # Create journal jo = d.meta.tables["journal"] @@ -280,6 +282,8 @@ def edit(userid, journal, friends_only=False): if userid == query.userid: profile.check_user_rating_allowed(userid, journal.rating) + if journal.rating.minimum_age: + profile.assert_adult(userid) if friends_only: welcome.journal_remove(journal.journalid) diff --git a/weasyl/login.py b/weasyl/login.py index 0ba16d4aa..e46b46d69 100644 --- a/weasyl/login.py +++ b/weasyl/login.py @@ -207,17 +207,24 @@ def create(form): email = emailer.normalize_address(form.email) + # TODO: remove birth date check after checkbox-only form has been deployed for a while password = form.password if form.day and form.month and form.year: try: birthday = arrow.Arrow(int(form.year), int(form.month), int(form.day)) except ValueError: raise WeasylError("birthdayInvalid") + + if d.age_in_years(birthday) < 13: + raise WeasylError("birthdayInvalid") else: birthday = None + if "age" in form and form.age != "13+": + raise WeasylError("birthdayInvalid") + # Check invalid form data - if birthday is None or d.age_in_years(birthday) < 13: + if birthday is None and "age" not in form: raise WeasylError("birthdayInvalid") if not password_secure(password): raise WeasylError("passwordInsecure") @@ -261,7 +268,7 @@ def create(form): "login_name": sysname, "hashpass": passhash(password), "email": token, - "birthday": arrow.utcnow(), + "birthday": None, "invalid": True, # So we have a way for admins to determine which email address collided in the View Pending Accounts Page "invalid_email_addr": email, diff --git a/weasyl/profile.py b/weasyl/profile.py index 373259d2b..9564ccc70 100644 --- a/weasyl/profile.py +++ b/weasyl/profile.py @@ -1,6 +1,9 @@ +import datetime import re +import arrow import sqlalchemy as sa +from arrow import Arrow from pyramid.threadlocal import get_current_request from sqlalchemy import bindparam, func from sqlalchemy.dialects.postgresql import aggregate_order_by @@ -214,7 +217,8 @@ def select_myself(userid): def get_user_age(userid): assert userid - return d.convert_age(d.engine.scalar("SELECT birthday FROM userinfo WHERE userid = %(user)s", user=userid)) + birthday = d.engine.scalar("SELECT birthday FROM userinfo WHERE userid = %(user)s", user=userid) + return None if birthday is None else d.convert_age(birthday) def get_user_ratings(userid): @@ -224,7 +228,8 @@ def get_user_ratings(userid): def check_user_rating_allowed(userid, rating): # TODO(kailys): ensure usages always pass a Rating minimum_age = rating.minimum_age if isinstance(rating, ratings.Rating) else ratings.CODE_MAP[rating].minimum_age - if get_user_age(userid) < minimum_age: + user_age = get_user_age(userid) + if user_age is not None and user_age < minimum_age: raise WeasylError("ratingInvalid") @@ -245,7 +250,7 @@ def select_userinfo(userid, config): show_age = "b" in config or d.get_userid() in staff.MODS return { "birthday": query.birthday, - "age": d.convert_age(query.birthday) if show_age else None, + "age": d.convert_age(query.birthday) if show_age and query.birthday is not None else None, "show_age": "b" in config, "gender": query.gender, "country": query.country, @@ -519,6 +524,21 @@ def edit_streaming_settings(my_userid, userid, profile, set_stream=None, stream_ moderation.note_about(my_userid, userid, 'Streaming settings updated:', note_body) +_MOST_ADVANCED_TIME_ZONE = datetime.timezone(datetime.timedelta(hours=14)) + +_BIRTHDATE_UPDATE_BASE = ( + t.userinfo.update() + .where(t.userinfo.c.userid == bindparam("update_userid")) + .where(t.userinfo.c.birthday.is_(None)) + .values(birthday=bindparam("birthday")) +) + +_ASSERTED_ADULT = ( + sa.select(t.userinfo.c.asserted_adult) + .where(t.userinfo.c.userid == bindparam("userid")) +) + + # form # show_age # gender @@ -549,6 +569,48 @@ def edit_userinfo(userid, form): if social_rows: d.engine.execute(d.meta.tables['user_links'].insert().values(social_rows)) + if 'show_age' in form and form.get('birthdate-month') and form.get('birthdate-year'): + birthdate_month = int(form['birthdate-month']) + birthdate_year = int(form['birthdate-year']) + + if not (1 <= birthdate_month <= 12) or not (-100 <= birthdate_year - arrow.utcnow().year <= 0): + raise WeasylError("birthdayInvalid") + + birthdate_update = _BIRTHDATE_UPDATE_BASE + + # If it is impossible* for someone born in the specified month to be 18+ and the user has asserted that they're 18+, don't allow it. + # This is mainly to avoid inconsistency between the user's *displayed* age and their interactions. + # (* I haven't explicitly accounted for all the weird time things that have happened in the world, but we can at least do time zones.) + earliest_possible_utc_birthdate = ( + datetime.datetime( + year=birthdate_year, + month=birthdate_month, + day=1, + tzinfo=_MOST_ADVANCED_TIME_ZONE, + ) + .astimezone(datetime.timezone.utc) + .date() + ) + oldest_possible_age = d.age_in_years(earliest_possible_utc_birthdate) + if oldest_possible_age < 13: + raise WeasylError("birthdayInconsistentWithTerms") + + is_age_restricted = oldest_possible_age < 18 + if is_age_restricted: + birthdate_update = birthdate_update.where(~t.userinfo.c.asserted_adult) + + result = d.engine.execute(birthdate_update, { + "update_userid": userid, + "birthday": Arrow(year=birthdate_year, month=birthdate_month, day=1), + }) + + if result.rowcount != 1: + assert result.rowcount == 0 + if is_age_restricted and d.engine.scalar(_ASSERTED_ADULT, {"userid": userid}): + raise WeasylError("birthdayInconsistentWithRating") + + # otherwise, assume nothing was updated because a birthdate was already set + if form.show_age: d.engine.execute(""" UPDATE profile @@ -671,8 +733,9 @@ def edit_preferences(userid, :return: None """ config = d.get_config(userid) + user_age = get_user_age(userid) - if preferences is not None and get_user_age(userid) < preferences.rating.minimum_age: + if preferences is not None and user_age is not None and user_age < preferences.rating.minimum_age: preferences.rating = ratings.GENERAL updates = {} @@ -687,6 +750,9 @@ def edit_preferences(userid, # update jsonb preferences updates['jsonb_settings'] = jsonb_settings.get_raw() + if preferences is not None and preferences.rating.minimum_age: + assert_adult(userid) + d.engine.execute( t.profile.update().where(t.profile.c.userid == userid), updates @@ -694,6 +760,18 @@ def edit_preferences(userid, d._get_all_config.invalidate(userid) +def assert_adult(userid): + """ + Set a flag on a user indicating that they’ve asserted they’re 18 or older in performing some operation. + """ + d.engine.execute( + t.userinfo.update().where(t.userinfo.c.userid == userid), + { + 'asserted_adult': True, + }, + ) + + def select_manage(userid): """Selects a user's information for display in the admin user management page. @@ -804,16 +882,21 @@ def do_manage(my_userid, userid, username=None, full_name=None, catchphrase=None # Birthday if birthday is not None: - # HTML5 date format is yyyy-mm-dd - split = birthday.split("-") - if len(split) != 3 or d.convert_unixdate(day=split[2], month=split[1], year=split[0]) is None: - raise WeasylError("birthdayInvalid") - unixtime = d.convert_unixdate(day=split[2], month=split[1], year=split[0]) - age = d.convert_age(unixtime) + if birthday == "": + unixtime = None + age = None + else: + # HTML5 date format is yyyy-mm-dd + split = birthday.split("-") + if len(split) != 3 or d.convert_unixdate(day=split[2], month=split[1], year=split[0]) is None: + raise WeasylError("birthdayInvalid") + unixtime = d.convert_unixdate(day=split[2], month=split[1], year=split[0]) + age = d.convert_age(unixtime) - d.execute("UPDATE userinfo SET birthday = %i WHERE userid = %i", [unixtime, userid]) + result = d.engine.execute("UPDATE userinfo SET birthday = %(birthday)s WHERE userid = %(user)s", birthday=unixtime, user=userid) + assert result.rowcount == 1 - if age < ratings.EXPLICIT.minimum_age: + if age is not None and age < ratings.EXPLICIT.minimum_age: # reset rating preference and SFW mode rating preference to General d.engine.execute( """ @@ -825,7 +908,7 @@ def do_manage(my_userid, userid, username=None, full_name=None, catchphrase=None user=userid, ) d._get_all_config.invalidate(userid) - updates.append('- Birthday: %s' % (birthday,)) + updates.append('- Birthday: %s' % (birthday or 'removal',)) # Gender if gender is not None: diff --git a/weasyl/submission.py b/weasyl/submission.py index ecde8f46a..e69b815fa 100644 --- a/weasyl/submission.py +++ b/weasyl/submission.py @@ -102,6 +102,8 @@ def create_generic(userid, submission, **kwargs): raise WeasylError("Unexpected") profile.check_user_rating_allowed(userid, submission.rating) + if submission.rating.minimum_age: + profile.assert_adult(userid) newid = create_specific( userid=userid, @@ -977,6 +979,8 @@ def edit(userid, submission, embedlink=None, friends_only=False, critique=False) if userid == query.userid: profile.check_user_rating_allowed(userid, submission.rating) + if submission.rating.minimum_age: + profile.assert_adult(userid) if 'other' == query[3]: submission.content = "%s\n%s" % (embedlink, submission.content) diff --git a/weasyl/templates/admincontrol/manageuser.html b/weasyl/templates/admincontrol/manageuser.html index abeff3ce7..16aa69a64 100644 --- a/weasyl/templates/admincontrol/manageuser.html +++ b/weasyl/templates/admincontrol/manageuser.html @@ -69,7 +69,7 @@ Date of Birth
Update value
- + GenderUpdate value
diff --git a/weasyl/templates/control/edit_profile.html b/weasyl/templates/control/edit_profile.html index 4561ee7ae..f81e8353c 100644 --- a/weasyl/templates/control/edit_profile.html +++ b/weasyl/templates/control/edit_profile.html @@ -19,11 +19,30 @@Age
+ + diff --git a/weasyl/templates/edit/character.html b/weasyl/templates/edit/character.html index a23ed9d5a..6065de2d0 100644 --- a/weasyl/templates/edit/character.html +++ b/weasyl/templates/edit/character.html @@ -32,6 +32,8 @@Passwords must be a minimum of 10 characters.
- + - -