mirror of https://gitlab.com/tildes/tildes.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
261 lines
9.0 KiB
261 lines
9.0 KiB
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Views related to user settings."""
|
|
|
|
from datetime import timedelta
|
|
from io import BytesIO
|
|
import sys
|
|
|
|
import pyotp
|
|
import qrcode
|
|
from pyramid.httpexceptions import HTTPForbidden, HTTPUnprocessableEntity
|
|
from pyramid.request import Request
|
|
from pyramid.response import Response
|
|
from pyramid.view import view_config
|
|
from sqlalchemy import func
|
|
|
|
from tildes.enums import CommentLabelOption, CommentTreeSortOption
|
|
from tildes.lib.datetime import utc_now
|
|
from tildes.lib.string import separate_string
|
|
from tildes.models.comment import Comment, CommentLabel, CommentTree
|
|
from tildes.models.group import Group
|
|
from tildes.models.topic import Topic
|
|
from tildes.models.user import User
|
|
from tildes.schemas.user import (
|
|
BIO_MAX_LENGTH,
|
|
EMAIL_ADDRESS_NOTE_MAX_LENGTH,
|
|
UserSchema,
|
|
)
|
|
from tildes.views.decorators import use_kwargs
|
|
|
|
|
|
PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"]
|
|
|
|
THEME_OPTIONS = {
|
|
"white": "White",
|
|
"solarized-light": "Solarized Light",
|
|
"solarized-dark": "Solarized Dark",
|
|
"dracula": "Dracula",
|
|
"atom-one-dark": "Atom One Dark",
|
|
"black": "Black",
|
|
"zenburn": "Zenburn",
|
|
"gruvbox-light": "Gruvbox Light",
|
|
"gruvbox-dark": "Gruvbox Dark",
|
|
"love-dark": "Love Dark",
|
|
"love-light": "Love Light",
|
|
"nord-dark": "Nord Dark",
|
|
"nord-light": "Nord Light",
|
|
}
|
|
|
|
|
|
@view_config(route_name="settings", renderer="settings.jinja2")
|
|
def get_settings(request: Request) -> dict:
|
|
"""Generate the user settings page."""
|
|
site_default_theme = "white"
|
|
user_default_theme = request.user.theme_default or site_default_theme
|
|
|
|
# Make a copy of the theme options dict so we can add info to the names
|
|
theme_options = THEME_OPTIONS.copy()
|
|
|
|
if site_default_theme == user_default_theme:
|
|
theme_options[site_default_theme] += " (site and account default)"
|
|
else:
|
|
theme_options[user_default_theme] += " (account default)"
|
|
theme_options[site_default_theme] += " (site default)"
|
|
|
|
if request.user.comment_sort_order_default:
|
|
current_comment_sort_order = request.user.comment_sort_order_default
|
|
else:
|
|
current_comment_sort_order = CommentTreeSortOption.RELEVANCE
|
|
|
|
return {
|
|
"current_comment_sort_order": current_comment_sort_order,
|
|
"comment_sort_order_options": CommentTreeSortOption,
|
|
"theme_options": theme_options,
|
|
}
|
|
|
|
|
|
@view_config(
|
|
route_name="settings_account_recovery", renderer="settings_account_recovery.jinja2"
|
|
)
|
|
def get_settings_account_recovery(request: Request) -> dict:
|
|
"""Generate the account recovery page."""
|
|
# pylint: disable=unused-argument
|
|
return {"note_max_length": EMAIL_ADDRESS_NOTE_MAX_LENGTH}
|
|
|
|
|
|
@view_config(route_name="settings_two_factor", renderer="settings_two_factor.jinja2")
|
|
def get_settings_two_factor(request: Request) -> dict:
|
|
"""Generate the two-factor authentication page."""
|
|
# Generate a new secret key if the user doesn't have one.
|
|
if request.user.two_factor_secret is None:
|
|
request.user.two_factor_secret = pyotp.random_base32()
|
|
|
|
return {
|
|
"two_factor_secret": separate_string(request.user.two_factor_secret, " ", 4)
|
|
}
|
|
|
|
|
|
@view_config(route_name="settings_filters", renderer="settings_filters.jinja2")
|
|
def get_settings_filters(request: Request) -> dict:
|
|
"""Generate the filters settings page."""
|
|
# pylint: disable=unused-argument
|
|
return {}
|
|
|
|
|
|
@view_config(
|
|
route_name="settings_password_change", renderer="settings_password_change.jinja2"
|
|
)
|
|
def get_settings_password_change(request: Request) -> dict:
|
|
"""Generate the password change page."""
|
|
# pylint: disable=unused-argument
|
|
return {}
|
|
|
|
|
|
@view_config(route_name="settings_two_factor_qr_code")
|
|
def get_settings_two_factor_qr_code(request: Request) -> Response:
|
|
"""Generate the 2FA QR code."""
|
|
# If 2FA is already enabled, don't expose the secret.
|
|
if request.user.two_factor_enabled:
|
|
raise HTTPForbidden("Already enabled")
|
|
|
|
totp = pyotp.totp.TOTP(request.user.two_factor_secret)
|
|
otp_uri = totp.provisioning_uri(request.user.username, issuer_name="Tildes")
|
|
byte_io = BytesIO()
|
|
img = qrcode.make(otp_uri, border=2, box_size=4)
|
|
|
|
img.save(byte_io, "PNG")
|
|
|
|
return Response(byte_io.getvalue(), cache_control="private, no-cache")
|
|
|
|
|
|
@view_config(route_name="settings_bio", renderer="settings_bio.jinja2")
|
|
def get_settings_bio(request: Request) -> dict:
|
|
"""Generate the user bio settings page."""
|
|
# pylint: disable=unused-argument
|
|
return {"bio_max_length": BIO_MAX_LENGTH}
|
|
|
|
|
|
@view_config(route_name="settings_password_change", request_method="POST")
|
|
@use_kwargs(
|
|
{
|
|
"old_password": PASSWORD_FIELD,
|
|
"new_password": PASSWORD_FIELD,
|
|
"new_password_confirm": PASSWORD_FIELD,
|
|
},
|
|
location="form",
|
|
)
|
|
def post_settings_password_change(
|
|
request: Request, old_password: str, new_password: str, new_password_confirm: str
|
|
) -> Response:
|
|
"""Change the logged-in user's password."""
|
|
# enable checking the new password against the breached-passwords list
|
|
request.user.schema.context["check_breached_passwords"] = True
|
|
|
|
if new_password != new_password_confirm:
|
|
raise HTTPUnprocessableEntity("New password and confirmation do not match.")
|
|
|
|
request.user.change_password(old_password, new_password)
|
|
|
|
return Response("Your password has been updated")
|
|
|
|
|
|
@view_config(
|
|
route_name="settings_theme_previews", renderer="settings_theme_previews.jinja2"
|
|
)
|
|
def get_settings_theme_previews(request: Request) -> dict:
|
|
"""Generate the theme preview page."""
|
|
# get the generic/unknown user and a random group to display on the example posts
|
|
fake_user = request.query(User).filter(User.user_id == -1).one()
|
|
group = request.query(Group).order_by(func.random()).limit(1).one()
|
|
|
|
fake_link_topic = Topic.create_link_topic(
|
|
group, fake_user, "Example Link Topic", "https://tildes.net/"
|
|
)
|
|
|
|
fake_text_topic = Topic.create_text_topic(
|
|
group, fake_user, "Example Text Topic", "No real text"
|
|
)
|
|
fake_text_topic.content_metadata = {
|
|
"excerpt": "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
|
}
|
|
|
|
fake_topics = [fake_link_topic, fake_text_topic]
|
|
|
|
# manually add other necessary attributes to the fake topics
|
|
for fake_topic in fake_topics:
|
|
fake_topic.topic_id = sys.maxsize
|
|
fake_topic.tags = ["tag one", "tag two"]
|
|
fake_topic.num_comments = 123
|
|
fake_topic.num_votes = 12
|
|
fake_topic.created_time = utc_now() - timedelta(hours=12)
|
|
|
|
# create a fake top-level comment that appears to be written by the user
|
|
markdown = (
|
|
"This is what a regular comment written by yourself would look like.\n\n"
|
|
"It has **formatting** and a [link](https://tildes.net)."
|
|
)
|
|
fake_top_comment = Comment(fake_link_topic, request.user, markdown)
|
|
fake_top_comment.comment_id = sys.maxsize
|
|
fake_top_comment.created_time = utc_now() - timedelta(hours=12, minutes=30)
|
|
|
|
child_comments_markdown = [
|
|
(
|
|
"This reply has received an Exemplary label. It also has a blockquote:\n\n"
|
|
"> Hello World!"
|
|
),
|
|
(
|
|
"This is a reply written by the topic's OP with a code block in it:\n\n"
|
|
"```js\n"
|
|
"function foo() {\n"
|
|
" ['1', '2', '3'].map(parseInt);\n"
|
|
"}\n"
|
|
"```"
|
|
),
|
|
(
|
|
"This reply is new and has the *Mark New Comments* stripe on its left "
|
|
"(even if you don't have that feature enabled)."
|
|
),
|
|
]
|
|
|
|
fake_comments = [fake_top_comment]
|
|
|
|
# vary the ID and created_time on each fake comment so CommentTree works properly
|
|
current_comment_id = fake_top_comment.comment_id
|
|
current_created_time = fake_top_comment.created_time
|
|
for markdown in child_comments_markdown:
|
|
current_comment_id -= 1
|
|
current_created_time += timedelta(minutes=5)
|
|
|
|
fake_comment = Comment(
|
|
fake_link_topic, fake_user, markdown, parent_comment=fake_top_comment
|
|
)
|
|
fake_comment.comment_id = current_comment_id
|
|
fake_comment.created_time = current_created_time
|
|
fake_comment.parent_comment_id = fake_top_comment.comment_id
|
|
|
|
fake_comments.append(fake_comment)
|
|
|
|
# add other necessary attributes to all of the fake comments
|
|
for fake_comment in fake_comments:
|
|
fake_comment.num_votes = 0
|
|
|
|
fake_tree = CommentTree(
|
|
fake_comments, CommentTreeSortOption.RELEVANCE, request.user
|
|
)
|
|
|
|
# add a fake Exemplary label to the first child comment
|
|
fake_comments[1].labels = [
|
|
CommentLabel(fake_comments[1], fake_user, CommentLabelOption.EXEMPLARY, 1.0)
|
|
]
|
|
|
|
# the comment to mark as new is the last one, so set a visit time just before it
|
|
fake_last_visit_time = fake_comments[-1].created_time - timedelta(minutes=1)
|
|
|
|
return {
|
|
"theme_options": THEME_OPTIONS,
|
|
"fake_topics": fake_topics,
|
|
"fake_comment_tree": fake_tree,
|
|
"last_visit": fake_last_visit_time,
|
|
}
|