Browse Source

Merge branch 'master' into feature-saved-themes

merge-requests/25/head
Celeo 7 years ago
parent
commit
2cfe939755
  1. 63
      tildes/alembic/versions/50c251c4a19c_add_search_column_index_for_topics.py
  2. 38
      tildes/alembic/versions/67e332481a6e_add_two_factor_authentication.py
  3. 40
      tildes/alembic/versions/d33fb803a153_switch_to_general_permissions_column.py
  4. 3
      tildes/requirements-to-freeze.txt
  5. 3
      tildes/requirements.txt
  6. 1
      tildes/scss/_base.scss
  7. 2
      tildes/scss/modules/_btn.scss
  8. 8
      tildes/scss/modules/_sidebar.scss
  9. 1
      tildes/scss/modules/_site-header.scss
  10. 22
      tildes/scss/modules/_topic.scss
  11. 15
      tildes/sql/init/triggers/topics/topics.sql
  12. 11
      tildes/static/js/behaviors/comment-collapse-all-button.js
  13. 11
      tildes/static/js/behaviors/comment-expand-all-button.js
  14. 14
      tildes/tests/test_markdown.py
  15. 35
      tildes/tildes/__init__.py
  16. 7
      tildes/tildes/auth.py
  17. 15
      tildes/tildes/lib/markdown.py
  18. 3
      tildes/tildes/lib/ratelimit.py
  19. 17
      tildes/tildes/lib/string.py
  20. 5
      tildes/tildes/models/database_model.py
  21. 3
      tildes/tildes/models/log/log.py
  22. 22
      tildes/tildes/models/topic/topic.py
  23. 5
      tildes/tildes/models/topic/topic_query.py
  24. 41
      tildes/tildes/models/user/user.py
  25. 12
      tildes/tildes/models/user/user_invite_code.py
  26. 11
      tildes/tildes/routes.py
  27. 7
      tildes/tildes/templates/home.jinja2
  28. 17
      tildes/tildes/templates/intercooler/login_two_factor.jinja2
  29. 3
      tildes/tildes/templates/intercooler/two_factor_disabled.jinja2
  30. 11
      tildes/tildes/templates/intercooler/two_factor_enabled.jinja2
  31. 2
      tildes/tildes/templates/login.jinja2
  32. 31
      tildes/tildes/templates/search.jinja2
  33. 9
      tildes/tildes/templates/settings.jinja2
  34. 53
      tildes/tildes/templates/settings_two_factor.jinja2
  35. 14
      tildes/tildes/templates/topic.jinja2
  36. 31
      tildes/tildes/templates/topic_listing.jinja2
  37. 4
      tildes/tildes/views/api/web/comment.py
  38. 59
      tildes/tildes/views/api/web/user.py
  39. 6
      tildes/tildes/views/decorators.py
  40. 68
      tildes/tildes/views/login.py
  41. 34
      tildes/tildes/views/settings.py
  42. 61
      tildes/tildes/views/topic.py

63
tildes/alembic/versions/50c251c4a19c_add_search_column_index_for_topics.py

@ -0,0 +1,63 @@
"""Add search column/index for topics
Revision ID: 50c251c4a19c
Revises: d33fb803a153
Create Date: 2018-08-20 19:18:04.129255
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "50c251c4a19c"
down_revision = "d33fb803a153"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"topics", sa.Column("search_tsv", postgresql.TSVECTOR(), nullable=True)
)
op.create_index(
"ix_topics_search_tsv_gin",
"topics",
["search_tsv"],
unique=False,
postgresql_using="gin",
)
op.execute(
"""
UPDATE topics
SET search_tsv = to_tsvector('pg_catalog.english', title)
|| to_tsvector('pg_catalog.english', COALESCE(markdown, ''));
"""
)
op.execute(
"""
CREATE TRIGGER topic_update_search_tsv_insert
BEFORE INSERT ON topics
FOR EACH ROW
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);
CREATE TRIGGER topic_update_search_tsv_update
BEFORE UPDATE ON topics
FOR EACH ROW
WHEN (
(OLD.title IS DISTINCT FROM NEW.title)
OR (OLD.markdown IS DISTINCT FROM NEW.markdown)
)
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);
"""
)
def downgrade():
op.drop_index("ix_topics_search_tsv_gin", table_name="topics")
op.drop_column("topics", "search_tsv")
op.execute("DROP TRIGGER topic_update_search_tsv_insert ON topics")
op.execute("DROP TRIGGER topic_update_search_tsv_update ON topics")

38
tildes/alembic/versions/67e332481a6e_add_two_factor_authentication.py

@ -0,0 +1,38 @@
"""Add two-factor authentication
Revision ID: 67e332481a6e
Revises: fab922a8bb04
Create Date: 2018-07-31 02:53:50.182862
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "67e332481a6e"
down_revision = "fab922a8bb04"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column(
"two_factor_backup_codes", postgresql.ARRAY(sa.Text()), nullable=True
),
)
op.add_column(
"users",
sa.Column(
"two_factor_enabled", sa.Boolean(), server_default="false", nullable=False
),
)
op.add_column("users", sa.Column("two_factor_secret", sa.Text(), nullable=True))
def downgrade():
op.drop_column("users", "two_factor_secret")
op.drop_column("users", "two_factor_enabled")
op.drop_column("users", "two_factor_backup_codes")

40
tildes/alembic/versions/d33fb803a153_switch_to_general_permissions_column.py

@ -0,0 +1,40 @@
"""Switch to general permissions column
Revision ID: d33fb803a153
Revises: 67e332481a6e
Create Date: 2018-08-16 23:07:07.643208
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "d33fb803a153"
down_revision = "67e332481a6e"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column(
"permissions", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
)
op.drop_column("users", "is_admin")
def downgrade():
op.add_column(
"users",
sa.Column(
"is_admin",
sa.BOOLEAN(),
server_default=sa.text("false"),
autoincrement=False,
nullable=False,
),
)
op.drop_column("users", "permissions")

3
tildes/requirements-to-freeze.txt

@ -13,6 +13,7 @@ html5lib
ipython ipython
mypy mypy
mypy-extensions mypy-extensions
Pillow
prometheus-client prometheus-client
psycopg2 psycopg2
publicsuffix2 publicsuffix2
@ -20,6 +21,7 @@ pydocstyle
pylama pylama
pylama-pylint pylama-pylint
pylint==1.7.5 # pylama has issues with 1.8.1 pylint==1.7.5 # pylama has issues with 1.8.1
pyotp
pyramid pyramid
pyramid-debugtoolbar pyramid-debugtoolbar
pyramid-ipython pyramid-ipython
@ -30,6 +32,7 @@ pyramid-webassets
pytest pytest
pytest-mock pytest-mock
PyYAML # needs to be installed separately for webassets PyYAML # needs to be installed separately for webassets
qrcode
SQLAlchemy SQLAlchemy
SQLAlchemy-Utils SQLAlchemy-Utils
stripe stripe

3
tildes/requirements.txt

@ -38,6 +38,7 @@ parso==0.3.1
PasteDeploy==1.5.2 PasteDeploy==1.5.2
pexpect==4.6.0 pexpect==4.6.0
pickleshare==0.7.4 pickleshare==0.7.4
Pillow==5.2.0
plaster==1.0 plaster==1.0
plaster-pastedeploy==0.6 plaster-pastedeploy==0.6
pluggy==0.7.1 pluggy==0.7.1
@ -55,6 +56,7 @@ Pygments==2.2.0
pylama==7.4.3 pylama==7.4.3
pylama-pylint==3.0.1 pylama-pylint==3.0.1
pylint==1.7.5 pylint==1.7.5
pyotp==2.2.6
pyramid==1.9.2 pyramid==1.9.2
pyramid-debugtoolbar==4.4 pyramid-debugtoolbar==4.4
pyramid-ipython==0.2 pyramid-ipython==0.2
@ -68,6 +70,7 @@ pytest-mock==1.10.0
python-dateutil==2.7.3 python-dateutil==2.7.3
python-editor==1.0.3 python-editor==1.0.3
PyYAML==3.13 PyYAML==3.13
qrcode==6.0
redis==2.10.6 redis==2.10.6
repoze.lru==0.7 repoze.lru==0.7
requests==2.19.1 requests==2.19.1

1
tildes/scss/_base.scss

@ -196,6 +196,7 @@ table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
width: auto; width: auto;
margin-bottom: 1rem;
} }
td, th { td, th {

2
tildes/scss/modules/_btn.scss

@ -52,10 +52,8 @@
font-weight: normal; font-weight: normal;
border-left-width: 0;
margin-right: 0.4rem; margin-right: 0.4rem;
@media (min-width: $size-md) { @media (min-width: $size-md) {
border-left-width: 1px;
margin-right: 0.2rem; margin-right: 0.2rem;
min-width: 0.8rem; min-width: 0.8rem;
} }

8
tildes/scss/modules/_sidebar.scss

@ -11,6 +11,14 @@
.sidebar-controls .btn { .sidebar-controls .btn {
width: auto; width: auto;
} }
.form-search {
margin-bottom: 1rem;
.btn {
font-weight: normal;
}
}
} }
.sidebar-controls { .sidebar-controls {

1
tildes/scss/modules/_site-header.scss

@ -12,6 +12,7 @@
} }
.site-header-context { .site-header-context {
white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }

22
tildes/scss/modules/_topic.scss

@ -245,17 +245,21 @@
overflow: auto; overflow: auto;
} }
.topic-comments {
header {
display: flex;
.topic-comments-header {
display: flex;
flex-wrap: wrap;
align-items: center;
h2 {
white-space: nowrap;
}
margin-bottom: 0.4rem;
.form-listing-options {
margin-left: auto;
}
h2 {
margin-bottom: 0;
margin-right: 0.4rem;
white-space: nowrap;
}
.form-listing-options {
margin-left: auto;
} }
} }

15
tildes/sql/init/triggers/topics/topics.sql

@ -12,3 +12,18 @@ CREATE TRIGGER delete_topic_set_deleted_time_update
FOR EACH ROW FOR EACH ROW
WHEN (OLD.is_deleted = false AND NEW.is_deleted = true) WHEN (OLD.is_deleted = false AND NEW.is_deleted = true)
EXECUTE PROCEDURE set_topic_deleted_time(); EXECUTE PROCEDURE set_topic_deleted_time();
CREATE TRIGGER topic_update_search_tsv_insert
BEFORE INSERT ON topics
FOR EACH ROW
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);
CREATE TRIGGER topic_update_search_tsv_update
BEFORE UPDATE ON topics
FOR EACH ROW
WHEN (
(OLD.title IS DISTINCT FROM NEW.title)
OR (OLD.markdown IS DISTINCT FROM NEW.markdown)
)
EXECUTE PROCEDURE tsvector_update_trigger(search_tsv, 'pg_catalog.english', title, markdown);

11
tildes/static/js/behaviors/comment-collapse-all-button.js

@ -0,0 +1,11 @@
$.onmount('[data-js-comment-collapse-all-button]', function() {
$(this).click(function(event) {
$('.comment[data-comment-depth="1"]:not(.is-comment-collapsed)').each(
function(idx, child) {
$(child).find(
'[data-js-comment-collapse-button]:first').trigger('click');
});
$(this).blur();
});
});

11
tildes/static/js/behaviors/comment-expand-all-button.js

@ -0,0 +1,11 @@
$.onmount('[data-js-comment-expand-all-button]', function() {
$(this).click(function(event) {
$('.comment.is-comment-collapsed').each(
function(idx, child) {
$(child).find(
'[data-js-comment-collapse-button]:first').trigger('click');
});
$(this).blur();
});
});

14
tildes/tests/test_markdown.py

@ -70,11 +70,7 @@ def test_deliberate_ordered_list():
def test_accidental_ordered_list(): def test_accidental_ordered_list():
"""Ensure a common "accidental" ordered list gets escaped.""" """Ensure a common "accidental" ordered list gets escaped."""
markdown = (
"What year did this happen?\n\n"
"1975. It was a long time ago.\n\n"
"But I remember it like it was yesterday."
)
markdown = "1975. It was a long time ago."
html = convert_markdown_to_safe_html(markdown) html = convert_markdown_to_safe_html(markdown)
assert "<ol" not in html assert "<ol" not in html
@ -338,3 +334,11 @@ def test_username_ref_inside_pre_ignored():
processed = convert_markdown_to_safe_html(markdown) processed = convert_markdown_to_safe_html(markdown)
assert "<a" not in processed assert "<a" not in processed
def test_group_ref_inside_code_ignored():
"""Ensure a group reference inside a <code> tag doesn't get linked."""
markdown = "Strikethrough works like: `this ~should not~ work`."
processed = convert_markdown_to_safe_html(markdown)
assert "<a" not in processed

35
tildes/tildes/__init__.py

@ -1,9 +1,10 @@
"""Configure and initialize the Pyramid app.""" """Configure and initialize the Pyramid app."""
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, Optional, Tuple
from paste.deploy.config import PrefixMiddleware from paste.deploy.config import PrefixMiddleware
from pyramid.config import Configurator from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPTooManyRequests
from pyramid.registry import Registry from pyramid.registry import Registry
from pyramid.request import Request from pyramid.request import Request
from redis import StrictRedis from redis import StrictRedis
@ -50,6 +51,7 @@ def main(global_config: Dict[str, str], **settings: str) -> PrefixMiddleware:
# pylint: enable=unnecessary-lambda # pylint: enable=unnecessary-lambda
config.add_request_method(check_rate_limit, "check_rate_limit") config.add_request_method(check_rate_limit, "check_rate_limit")
config.add_request_method(apply_rate_limit, "apply_rate_limit")
config.add_request_method(current_listing_base_url, "current_listing_base_url") config.add_request_method(current_listing_base_url, "current_listing_base_url")
config.add_request_method(current_listing_normal_url, "current_listing_normal_url") config.add_request_method(current_listing_normal_url, "current_listing_normal_url")
@ -120,6 +122,13 @@ def check_rate_limit(request: Request, action_name: str) -> RateLimitResult:
return RateLimitResult.merged_result(results) return RateLimitResult.merged_result(results)
def apply_rate_limit(request: Request, action_name: str) -> None:
"""Check the rate limit for an action, and raise HTTP 429 if it's exceeded."""
result = request.check_rate_limit(action_name)
if not result.is_allowed:
raise result.add_headers_to_response(HTTPTooManyRequests())
def current_listing_base_url( def current_listing_base_url(
request: Request, query: Optional[Dict[str, Any]] = None request: Request, query: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
@ -130,10 +139,18 @@ def current_listing_base_url(
The `query` argument allows adding query variables to the generated url. The `query` argument allows adding query variables to the generated url.
""" """
if request.matched_route.name not in ("home", "group", "user"):
base_vars_by_route: Dict[str, Tuple[str, ...]] = {
"group": ("order", "period", "per_page", "tag", "unfiltered"),
"home": ("order", "period", "per_page", "tag", "unfiltered"),
"search": ("order", "period", "per_page", "q"),
"user": ("per_page", "type"),
}
try:
base_view_vars = base_vars_by_route[request.matched_route.name]
except KeyError:
raise AttributeError("Current route is not supported.") raise AttributeError("Current route is not supported.")
base_view_vars = ("order", "period", "per_page", "tag", "type", "unfiltered")
query_vars = { query_vars = {
key: val for key, val in request.GET.copy().items() if key in base_view_vars key: val for key, val in request.GET.copy().items() if key in base_view_vars
} }
@ -156,10 +173,18 @@ def current_listing_normal_url(
The `query` argument allows adding query variables to the generated url. The `query` argument allows adding query variables to the generated url.
""" """
if request.matched_route.name not in ("home", "group", "user"):
normal_vars_by_route: Dict[str, Tuple[str, ...]] = {
"group": ("order", "period", "per_page"),
"home": ("order", "period", "per_page"),
"search": ("order", "period", "per_page", "q"),
"user": ("per_page",),
}
try:
normal_view_vars = normal_vars_by_route[request.matched_route.name]
except KeyError:
raise AttributeError("Current route is not supported.") raise AttributeError("Current route is not supported.")
normal_view_vars = ("order", "period", "per_page")
query_vars = { query_vars = {
key: val for key, val in request.GET.copy().items() if key in normal_view_vars key: val for key, val in request.GET.copy().items() if key in normal_view_vars
} }

7
tildes/tildes/auth.py

@ -56,12 +56,7 @@ def auth_callback(user_id: int, request: Request) -> Optional[Sequence[str]]:
if user_id != request.user.user_id: if user_id != request.user.user_id:
raise AssertionError("auth_callback called with different user_id") raise AssertionError("auth_callback called with different user_id")
principals = []
if request.user.is_admin:
principals.append("admin")
return principals
return request.user.auth_principals
def includeme(config: Configurator) -> None: def includeme(config: Configurator) -> None:

15
tildes/tildes/lib/markdown.py

@ -80,9 +80,9 @@ HTML_ATTRIBUTE_WHITELIST = {
PROTOCOL_WHITELIST = ("http", "https") PROTOCOL_WHITELIST = ("http", "https")
# Regex that finds ordered list markdown that was probably accidental - ones being # Regex that finds ordered list markdown that was probably accidental - ones being
# initiated by anything except "1."
# initiated by anything except "1." at the start of a post
BAD_ORDERED_LIST_REGEX = re.compile( BAD_ORDERED_LIST_REGEX = re.compile(
r"((?:\A|\n\n)" # Either the start of the entire text, or a new paragraph
r"((?:\A)" # The start of the entire text
r"(?!1\.)\d+)" # A number that isn't "1" r"(?!1\.)\d+)" # A number that isn't "1"
r"\.\s" # Followed by a period and a space r"\.\s" # Followed by a period and a space
) )
@ -156,13 +156,12 @@ def escape_accidental_ordered_lists(markdown: str) -> str:
"""Escape markdown that's probably an accidental ordered list. """Escape markdown that's probably an accidental ordered list.
It's a common markdown mistake to accidentally start a numbered list, by beginning a It's a common markdown mistake to accidentally start a numbered list, by beginning a
post or paragraph with a number followed by a period. For example, someone might try
to write "1975. It was a long time ago.", and the result will be a comment that says
"1. It was a long time ago." since that gets parsed into a numbered list.
post with a number followed by a period. For example, someone might try to write
"1975. It was a long time ago.", and the result will be a comment that says "1. It
was a long time ago." since that gets parsed into a numbered list.
This fixes that quirk of markdown by escaping anything that would start a numbered This fixes that quirk of markdown by escaping anything that would start a numbered
list except for "1. ". This will cause a few other edge cases, but I believe they're
less common/important than fixing this common error.
list at the beginning of a post, except for "1. ".
""" """
return BAD_ORDERED_LIST_REGEX.sub(r"\1\\. ", markdown) return BAD_ORDERED_LIST_REGEX.sub(r"\1\\. ", markdown)
@ -170,7 +169,7 @@ def escape_accidental_ordered_lists(markdown: str) -> str:
def postprocess_markdown_html(html: str) -> str: def postprocess_markdown_html(html: str) -> str:
"""Apply post-processing to HTML generated by markdown parser.""" """Apply post-processing to HTML generated by markdown parser."""
# list of tag names to exclude from linkification # list of tag names to exclude from linkification
linkify_skipped_tags = ["pre"]
linkify_skipped_tags = ["code", "pre"]
# search for text that looks like urls and convert to actual links # search for text that looks like urls and convert to actual links
html = bleach.linkify( html = bleach.linkify(

3
tildes/tildes/lib/ratelimit.py

@ -279,7 +279,10 @@ class RateLimitedAction:
# each action must have a unique name to prevent key collisions # each action must have a unique name to prevent key collisions
_RATE_LIMITED_ACTIONS = ( _RATE_LIMITED_ACTIONS = (
RateLimitedAction("login", timedelta(hours=1), 20), RateLimitedAction("login", timedelta(hours=1), 20),
RateLimitedAction("login_two_factor", timedelta(hours=1), 20),
RateLimitedAction("register", timedelta(hours=1), 50), RateLimitedAction("register", timedelta(hours=1), 50),
RateLimitedAction("topic_post", timedelta(hours=1), 6, max_burst=4),
RateLimitedAction("comment_post", timedelta(hours=1), 30, max_burst=20),
) )
# (public) dict to be able to look up the actions by name # (public) dict to be able to look up the actions by name

17
tildes/tildes/lib/string.py

@ -188,3 +188,20 @@ def _sanitize_characters(original: str) -> str:
final_characters.append(char) final_characters.append(char)
return "".join(final_characters) return "".join(final_characters)
def separate_string(original: str, separator: str, segment_size: int) -> str:
"""Separate a string into "segments", inserting a separator every X chars.
This is useful for strings being used as "codes" such as invite codes and 2FA backup
codes, so that they can be displayed in a more easily-readable format.
"""
separated = ""
for count, char in enumerate(original):
if count > 0 and count % segment_size == 0:
separated += separator
separated += char
return separated

5
tildes/tildes/models/database_model.py

@ -4,9 +4,7 @@ from typing import Any, Optional, Type, TypeVar
from marshmallow import Schema from marshmallow import Schema
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.schema import MetaData from sqlalchemy.schema import MetaData
from sqlalchemy.sql.schema import Table from sqlalchemy.sql.schema import Table
@ -124,6 +122,3 @@ DatabaseModel = declarative_base( # pylint: disable=invalid-name
# attach the listener for SQLAlchemy ORM attribute "set" events to all models # attach the listener for SQLAlchemy ORM attribute "set" events to all models
event.listen(DatabaseModel, "attribute_instrument", attach_set_listener) event.listen(DatabaseModel, "attribute_instrument", attach_set_listener)
# associate JSONB columns with MutableDict so value changes are detected
MutableDict.associate_with(JSONB)

3
tildes/tildes/models/log/log.py

@ -7,6 +7,7 @@ from sqlalchemy import BigInteger, Column, event, ForeignKey, Integer, Table, TI
from sqlalchemy.dialects.postgresql import ENUM, INET, JSONB from sqlalchemy.dialects.postgresql import ENUM, INET, JSONB
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
@ -51,7 +52,7 @@ class BaseLog:
@declared_attr @declared_attr
def info(self) -> Column: def info(self) -> Column:
"""Return the info column.""" """Return the info column."""
return Column(JSONB)
return Column(MutableDict.as_mutable(JSONB))
@declared_attr @declared_attr
def user(self) -> Any: def user(self) -> Any:

22
tildes/tildes/models/topic/topic.py

@ -14,8 +14,9 @@ from sqlalchemy import (
Text, Text,
TIMESTAMP, TIMESTAMP,
) )
from sqlalchemy.dialects.postgresql import ENUM, JSONB
from sqlalchemy.dialects.postgresql import ENUM, JSONB, TSVECTOR
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import deferred, relationship from sqlalchemy.orm import deferred, relationship
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
@ -102,7 +103,7 @@ class Topic(DatabaseModel):
_markdown: Optional[str] = deferred(Column("markdown", Text)) _markdown: Optional[str] = deferred(Column("markdown", Text))
rendered_html: Optional[str] = Column(Text) rendered_html: Optional[str] = Column(Text)
link: Optional[str] = Column(Text) link: Optional[str] = Column(Text)
content_metadata: Dict[str, Any] = Column(JSONB)
content_metadata: Dict[str, Any] = Column(MutableDict.as_mutable(JSONB))
num_comments: int = Column(Integer, nullable=False, server_default="0", index=True) num_comments: int = Column(Integer, nullable=False, server_default="0", index=True)
num_votes: int = Column(Integer, nullable=False, server_default="0", index=True) num_votes: int = Column(Integer, nullable=False, server_default="0", index=True)
_tags: List[Ltree] = Column( _tags: List[Ltree] = Column(
@ -110,12 +111,16 @@ class Topic(DatabaseModel):
) )
is_official: bool = Column(Boolean, nullable=False, server_default="false") is_official: bool = Column(Boolean, nullable=False, server_default="false")
is_locked: bool = Column(Boolean, nullable=False, server_default="false") is_locked: bool = Column(Boolean, nullable=False, server_default="false")
search_tsv: Any = deferred(Column(TSVECTOR))
user: User = relationship("User", lazy=False, innerjoin=True) user: User = relationship("User", lazy=False, innerjoin=True)
group: Group = relationship("Group", innerjoin=True) group: Group = relationship("Group", innerjoin=True)
# Create a GiST index on the tags column
__table_args__ = (Index("ix_topics_tags_gist", _tags, postgresql_using="gist"),)
# Create specialized indexes
__table_args__ = (
Index("ix_topics_tags_gist", _tags, postgresql_using="gist"),
Index("ix_topics_search_tsv_gin", "search_tsv", postgresql_using="gin"),
)
@hybrid_property @hybrid_property
def markdown(self) -> Optional[str]: def markdown(self) -> Optional[str]:
@ -263,14 +268,19 @@ class Topic(DatabaseModel):
acl.append((Allow, self.user_id, "delete")) acl.append((Allow, self.user_id, "delete"))
# tag: # tag:
# - only the author and admins can tag topics
# - allow tagging by the author, admins, and people with "topic.tag" principal
acl.append((Allow, self.user_id, "tag")) acl.append((Allow, self.user_id, "tag"))
acl.append((Allow, "admin", "tag")) acl.append((Allow, "admin", "tag"))
acl.append((Allow, "topic.tag", "tag"))
# admin tools
# tools that require specifically granted permissions
acl.append((Allow, "admin", "lock")) acl.append((Allow, "admin", "lock"))
acl.append((Allow, "admin", "move")) acl.append((Allow, "admin", "move"))
acl.append((Allow, "topic.move", "move"))
acl.append((Allow, "admin", "edit_title")) acl.append((Allow, "admin", "edit_title"))
acl.append((Allow, "topic.edit_title", "edit_title"))
acl.append(DENY_ALL) acl.append(DENY_ALL)

5
tildes/tildes/models/topic/topic_query.py

@ -3,6 +3,7 @@
from typing import Any, Sequence from typing import Any, Sequence
from pyramid.request import Request from pyramid.request import Request
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, null from sqlalchemy.sql.expression import and_, null
from sqlalchemy_utils import Ltree from sqlalchemy_utils import Ltree
@ -137,3 +138,7 @@ class TopicQuery(PaginatedQuery):
# pylint: disable=protected-access # pylint: disable=protected-access
return self.filter(Topic._tags.descendant_of(tag)) # type: ignore return self.filter(Topic._tags.descendant_of(tag)) # type: ignore
def search(self, query: str) -> "TopicQuery":
"""Restrict the topics to ones that match a search query (generative)."""
return self.filter(Topic.search_tsv.op("@@")(func.plainto_tsquery(query)))

41
tildes/tildes/models/user/user.py

@ -4,6 +4,7 @@ from datetime import datetime
from typing import Any, List, Optional, Sequence, Tuple from typing import Any, List, Optional, Sequence, Tuple
from mypy_extensions import NoReturn from mypy_extensions import NoReturn
from pyotp import TOTP
from pyramid.security import ( from pyramid.security import (
ALL_PERMISSIONS, ALL_PERMISSIONS,
Allow, Allow,
@ -21,7 +22,7 @@ from sqlalchemy import (
Text, Text,
TIMESTAMP, TIMESTAMP,
) )
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.dialects.postgresql import ARRAY, ENUM, JSONB
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import deferred from sqlalchemy.orm import deferred
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
@ -62,6 +63,9 @@ class User(DatabaseModel):
), ),
) )
) )
two_factor_enabled: bool = Column(Boolean, nullable=False, server_default="false")
two_factor_secret: Optional[str] = deferred(Column(Text))
two_factor_backup_codes: List[str] = deferred(Column(ARRAY(Text)))
created_time: datetime = Column( created_time: datetime = Column(
TIMESTAMP(timezone=True), TIMESTAMP(timezone=True),
nullable=False, nullable=False,
@ -85,7 +89,7 @@ class User(DatabaseModel):
open_new_tab_text: bool = Column(Boolean, nullable=False, server_default="false") open_new_tab_text: bool = Column(Boolean, nullable=False, server_default="false")
theme_account_default: str = Column(Text, nullable=False, server_default="") theme_account_default: str = Column(Text, nullable=False, server_default="")
is_banned: bool = Column(Boolean, nullable=False, server_default="false") is_banned: bool = Column(Boolean, nullable=False, server_default="false")
is_admin: bool = Column(Boolean, nullable=False, server_default="false")
permissions: Any = Column(JSONB)
home_default_order: Optional[TopicSortOption] = Column(ENUM(TopicSortOption)) home_default_order: Optional[TopicSortOption] = Column(ENUM(TopicSortOption))
home_default_period: Optional[str] = Column(Text) home_default_period: Optional[str] = Column(Text)
_filtered_topic_tags: List[Ltree] = Column( _filtered_topic_tags: List[Ltree] = Column(
@ -162,6 +166,25 @@ class User(DatabaseModel):
# disable mypy on this line because it doesn't handle setters correctly # disable mypy on this line because it doesn't handle setters correctly
self.password = new_password # type: ignore self.password = new_password # type: ignore
def is_correct_two_factor_code(self, code: str) -> bool:
"""Verify that a TOTP/backup code is correct."""
totp = TOTP(self.two_factor_secret)
code = code.strip().replace(" ", "").lower()
if totp.verify(code):
return True
elif self.two_factor_backup_codes and code in self.two_factor_backup_codes:
# Need to set the attribute so SQLAlchemy knows it changed
self.two_factor_backup_codes = [
backup_code
for backup_code in self.two_factor_backup_codes
if backup_code != code
]
return True
return False
@property @property
def email_address(self) -> NoReturn: def email_address(self) -> NoReturn:
"""Return an error since reading the email address isn't possible.""" """Return an error since reading the email address isn't possible."""
@ -182,3 +205,17 @@ class User(DatabaseModel):
def num_unread_total(self) -> int: def num_unread_total(self) -> int:
"""Return total number of unread items (notifications + messages).""" """Return total number of unread items (notifications + messages)."""
return self.num_unread_messages + self.num_unread_notifications return self.num_unread_messages + self.num_unread_notifications
@property
def auth_principals(self) -> List[str]:
"""Return the user's authorization principals (used for permissions)."""
if not self.permissions:
return []
if isinstance(self.permissions, str):
return [self.permissions]
if isinstance(self.permissions, list):
return self.permissions
raise ValueError("Unknown permissions format")

12
tildes/tildes/models/user/user_invite_code.py

@ -7,6 +7,7 @@ import string
from sqlalchemy import CheckConstraint, Column, ForeignKey, Integer, Text, TIMESTAMP from sqlalchemy import CheckConstraint, Column, ForeignKey, Integer, Text, TIMESTAMP
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
from tildes.lib.string import separate_string
from tildes.models import DatabaseModel from tildes.models import DatabaseModel
from .user import User from .user import User
@ -36,16 +37,7 @@ class UserInviteCode(DatabaseModel):
def __str__(self) -> str: def __str__(self) -> str:
"""Format the code into a more easily readable version.""" """Format the code into a more easily readable version."""
formatted = ""
for count, char in enumerate(self.code):
# add a dash every 5 chars
if count > 0 and count % 5 == 0:
formatted += "-"
formatted += char.upper()
return formatted
return separate_string(self.code, "-", 5)
def __init__(self, user: User) -> None: def __init__(self, user: User) -> None:
"""Create a new (random) invite code owned by the user. """Create a new (random) invite code owned by the user.

11
tildes/tildes/routes.py

@ -17,9 +17,12 @@ def includeme(config: Configurator) -> None:
"""Set up application routes.""" """Set up application routes."""
config.add_route("home", "/") config.add_route("home", "/")
config.add_route("search", "/search")
config.add_route("groups", "/groups") config.add_route("groups", "/groups")
config.add_route("login", "/login") config.add_route("login", "/login")
config.add_route("login_two_factor", "/login_two_factor")
config.add_route("logout", "/logout", factory=LoggedInFactory) config.add_route("logout", "/logout", factory=LoggedInFactory)
config.add_route("register", "/register") config.add_route("register", "/register")
@ -61,6 +64,14 @@ def includeme(config: Configurator) -> None:
"/settings/account_recovery", "/settings/account_recovery",
factory=LoggedInFactory, factory=LoggedInFactory,
) )
config.add_route(
"settings_two_factor", "/settings/two_factor", factory=LoggedInFactory
)
config.add_route(
"settings_two_factor_qr_code",
"/settings/two_factor/qr_code",
factory=LoggedInFactory,
)
config.add_route( config.add_route(
"settings_comment_visits", "/settings/comment_visits", factory=LoggedInFactory "settings_comment_visits", "/settings/comment_visits", factory=LoggedInFactory
) )

7
tildes/tildes/templates/home.jinja2

@ -17,6 +17,13 @@
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}
<form class="form-search" method="GET" action="/search">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h2>Home</h2> <h2>Home</h2>
<p>The home page shows topics from groups that you're subscribed to.</p> <p>The home page shows topics from groups that you're subscribed to.</p>
{% if request.user %} {% if request.user %}

17
tildes/tildes/templates/intercooler/login_two_factor.jinja2

@ -0,0 +1,17 @@
<p>Two-factor authentication is enabled on this account. Please enter the code from your authenticator app below. If you do not have access to your authenticator device, enter a backup code.</p>
<form class="form-narrow" method="post" action="/login_two_factor" data-ic-post-to="/login_two_factor">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
{% if keep %}
<input type="hidden" name="keep" value="on">
{% endif %}
<div class="form-group">
<label class="form-label col-4" for="code">Code</label>
<input class="form-input" id="code" name="code" type="text" placeholder="Code" data-js-auto-focus>
</div>
<div class="form-buttons">
<button class="btn btn-primary" type="submit">Continue</button>
</div>
</form>

3
tildes/tildes/templates/intercooler/two_factor_disabled.jinja2

@ -0,0 +1,3 @@
<p>Two-factor authentication has been disabled. You will no longer need a code when logging in.</p>
<p>Keep in mind: if you ever reenable two-factor authentication, your previous backup codes will not be valid.</p>

11
tildes/tildes/templates/intercooler/two_factor_enabled.jinja2

@ -0,0 +1,11 @@
<p>Congratulations! Two-factor authentication has been enabled.</p>
<p>These are your backup codes. In the event that you lose access to your authenticator device, you will need one of these codes to regain access to your account (or disable two-factor authentication). Each code can only be used once.</p>
<p><strong class="text-warning">Make sure to write them down and store them in a safe place.</strong></p>
<ol>
{% for code in backup_codes %}
<li><code>{{ code }}</code></li>
{% endfor %}
</ol>

2
tildes/tildes/templates/login.jinja2

@ -5,7 +5,7 @@
{% block main_heading %}Log in{% endblock %} {% block main_heading %}Log in{% endblock %}
{% block content %} {% block content %}
<form class="form-narrow" method="post" data-ic-post-to="/login">
<form class="form-narrow" method="post" data-ic-post-to="/login" data-ic-target="closest main">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<div class="form-group"> <div class="form-group">

31
tildes/tildes/templates/search.jinja2

@ -0,0 +1,31 @@
{% extends 'topic_listing.jinja2' %}
{% block title %}Search results for "{{ search }}"{% endblock %}
{% block header_context_link %}<span class="site-header-context">Search: "{{ search }}"</span>{% endblock %}
{% block sidebar %}
<form class="form-search" method="GET" action="/search">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q" value="{{ search }}">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h2>Search results</h2>
<p><a href="/">Back to home page</a></p>
{% if request.user and request.user.subscriptions %}
<div class="divider"></div>
<ul class="nav">
<li>Subscriptions</li>
<ul class="nav">
{% for subscription in request.user.subscriptions|sort(attribute='group') %}
<li class="nav-item"><a href="/~{{ subscription.group.path }}">~{{ subscription.group.path }}</a></li>
{% endfor %}
</ul>
</ul>
{% endif %}
{% endblock %}

9
tildes/tildes/templates/settings.jinja2

@ -117,6 +117,15 @@
<a href="/settings/account_recovery">Set up account recovery</a> <a href="/settings/account_recovery">Set up account recovery</a>
<div class="text-small text-secondary">To be able to regain access in case of lost password, compromise, etc.</div> <div class="text-small text-secondary">To be able to regain access in case of lost password, compromise, etc.</div>
</li> </li>
<li>
{% if not request.user.two_factor_enabled %}
<a href="/settings/two_factor">Enable two-factor authentication</a>
<div class="text-small text-secondary">For extra security, you can enable two-factor authentication.</div>
{% else %}
<a href="/settings/two_factor">Disable two-factor authentication</a>
<div class="text-small text-secondary">Disabling two-factor authentication requires a code from your device or a backup code.</div>
{% endif %}
</li>
<li> <li>
<a href="/settings/comment_visits">Toggle marking new comments (currently {{ 'enabled' if request.user.track_comment_visits else 'disabled' }})</a> <a href="/settings/comment_visits">Toggle marking new comments (currently {{ 'enabled' if request.user.track_comment_visits else 'disabled' }})</a>
<div class="text-small text-secondary">Marks new comments in topics since your last visit, and which topics have any</div> <div class="text-small text-secondary">Marks new comments in topics since your last visit, and which topics have any</div>

53
tildes/tildes/templates/settings_two_factor.jinja2

@ -0,0 +1,53 @@
{% extends 'base_no_sidebar.jinja2' %}
{% block title %}Set up two-factor authentication{% endblock %}
{% block main_heading %}Set up two-factor authentication{% endblock %}
{% block content %}
{% if request.user.two_factor_enabled %}
<p>You already have two-factor authentication enabled. To disable it, enter a code from your authenticator device below and click the button. If you do not have access to your authenticator device, enter a backup code.</p>
<form
name="disable-two-factor"
autocomplete="off"
data-ic-post-to="{{ request.route_url('ic_user', username=request.user.username) }}"
data-ic-target="closest main"
>
<div class="form-group">
<label class="form-label" for="code">TOTP or backup code</label>
<input class="form-input" id="code" name="code" type="text" placeholder="Code" required>
</div>
<div class="form-buttons">
<button class="btn btn-error" type="submit">Disable two-factor authentication</button>
</div>
</form>
{% else %}
<p>To get started, you'll need to install an app such as <a href="https://support.google.com/accounts/answer/1066447">Google Authenticator</a>, <a href="https://authy.com/download">Authy</a>, <a href="https://freeotp.github.io">FreeOTP</a>, or any app that supports TOTP.</p>
<p>Next, scan the below QR code with the app of your choice.</p>
<img src="/settings/two_factor/qr_code" alt="" />
<p>Lastly, enter the 6-digit code displayed in the app.</p>
<div class="divider"></div>
<form
name="enable-two-factor"
autocomplete="off"
data-ic-post-to="{{ request.route_url('ic_user', username=request.user.username) }}"
data-ic-target="closest main"
>
<div class="form-group">
<label class="form-label" for="code">Code</label>
<input class="form-input" id="code" name="code" type="text" placeholder="Code">
</div>
<div class="form-buttons">
<button class="btn btn-primary" type="submit">Enable two-factor authentication</button>
</div>
</form>
{% endif %}
{% endblock %}

14
tildes/tildes/templates/topic.jinja2

@ -144,7 +144,7 @@
{% if topic.num_comments > 0 %} {% if topic.num_comments > 0 %}
<section class="topic-comments"> <section class="topic-comments">
<header>
<header class="topic-comments-header">
<h2> <h2>
{% trans num_comments=topic.num_comments %} {% trans num_comments=topic.num_comments %}
{{ num_comments }} comment {{ num_comments }} comment
@ -153,6 +153,9 @@
{% endtrans %} {% endtrans %}
</h2> </h2>
<button class="btn btn-comment-collapse" title="Collapse reply comments" data-js-comment-collapse-all-button>&minus;</button>
<button class="btn btn-comment-collapse" title="Expand all comments" data-js-comment-expand-all-button>+</button>
<form class="form-listing-options" method="get"> <form class="form-listing-options" method="get">
<div class="form-group"> <div class="form-group">
<label for="comment_order">Comments sorted by</label> <label for="comment_order">Comments sorted by</label>
@ -166,12 +169,11 @@
>{{ option.description }}</option> >{{ option.description }}</option>
{% endfor %} {% endfor %}
</select> </select>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</div> </div>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</form> </form>
</header> </header>

31
tildes/tildes/templates/topic_listing.jinja2

@ -46,9 +46,14 @@
<form class="form-listing-options" method="get"> <form class="form-listing-options" method="get">
<input type="hidden" name="order" value="{{ order.name.lower() }}"> <input type="hidden" name="order" value="{{ order.name.lower() }}">
{% if tag %}
{% if tag is defined and tag %}
<input type="hidden" name="tag" value="{{ tag }}"> <input type="hidden" name="tag" value="{{ tag }}">
{% endif %} {% endif %}
{% if search is defined %}
<input type="hidden" name="q" value="{{ search }}">
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="period">from</label> <label for="period">from</label>
<select id="period" name="period" class="form-select" data-js-time-period-select> <select id="period" name="period" class="form-select" data-js-time-period-select>
@ -64,15 +69,14 @@
<option value="all"{{ ' selected' if not period else '' }}>all time</option> <option value="all"{{ ' selected' if not period else '' }}>all time</option>
<option value="other">other period</option> <option value="other">other period</option>
</select> </select>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</div> </div>
{# add a submit button for people with js disabled so this is still usable #}
<noscript>
<button type="submit" class="btn btn-primary btn-sm">OK</button>
</noscript>
</form> </form>
{% if not is_default_view %}
{% if is_default_view is defined and not is_default_view %}
<form <form
{% if is_single_group %} {% if is_single_group %}
data-ic-patch-to="{{ request.route_url( data-ic-patch-to="{{ request.route_url(
@ -96,6 +100,7 @@
{% endif %} {% endif %}
</div> </div>
{% if search is not defined %}
<div class="topic-listing-filter"> <div class="topic-listing-filter">
{% if tag %} {% if tag %}
Showing only topics with the tag "{{ tag|replace('_', ' ') }}". Showing only topics with the tag "{{ tag|replace('_', ' ') }}".
@ -108,6 +113,7 @@
<a href="{{ request.current_listing_normal_url({'unfiltered': 'true'}) }}">View unfiltered list</a> <a href="{{ request.current_listing_normal_url({'unfiltered': 'true'}) }}">View unfiltered list</a>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% if not topics %} {% if not topics %}
<div class="empty"> <div class="empty">
@ -125,7 +131,7 @@
{% if topics %} {% if topics %}
<ol class="topic-listing" <ol class="topic-listing"
{% if rank_start is not none %}
{% if rank_start is defined and rank_start is not none %}
start="{{ rank_start }}" start="{{ rank_start }}"
{% endif %} {% endif %}
> >
@ -134,7 +140,7 @@
<li> <li>
{# only display the rank on topics if the rank_start variable is set #} {# only display the rank on topics if the rank_start variable is set #}
{% if rank_start is not none %}
{% if rank_start is defined and rank_start is not none %}
{{ render_topic_for_listing( {{ render_topic_for_listing(
topic, topic,
show_group=topic.group != request.context, show_group=topic.group != request.context,
@ -169,6 +175,13 @@
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}
<form class="form-search" method="GET" action="/search">
<div class="input-group">
<input type="text" class="form-input input-sm" name="q" id="q">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h3>~{{ group.path }}</h3> <h3>~{{ group.path }}</h3>
{% if group.short_description %} {% if group.short_description %}

4
tildes/tildes/views/api/web/comment.py

@ -16,7 +16,7 @@ from tildes.models.comment import Comment, CommentNotification, CommentTag, Comm
from tildes.models.topic import TopicVisit from tildes.models.topic import TopicVisit
from tildes.schemas.comment import CommentSchema, CommentTagSchema from tildes.schemas.comment import CommentSchema, CommentTagSchema
from tildes.views import IC_NOOP from tildes.views import IC_NOOP
from tildes.views.decorators import ic_view_config
from tildes.views.decorators import ic_view_config, rate_limit_view
def _increment_topic_comments_seen(request: Request, comment: Comment) -> None: def _increment_topic_comments_seen(request: Request, comment: Comment) -> None:
@ -57,6 +57,7 @@ def _increment_topic_comments_seen(request: Request, comment: Comment) -> None:
permission="comment", permission="comment",
) )
@use_kwargs(CommentSchema(only=("markdown",))) @use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_toplevel_comment(request: Request, markdown: str) -> dict: def post_toplevel_comment(request: Request, markdown: str) -> dict:
"""Post a new top-level comment on a topic with Intercooler.""" """Post a new top-level comment on a topic with Intercooler."""
topic = request.context topic = request.context
@ -90,6 +91,7 @@ def post_toplevel_comment(request: Request, markdown: str) -> dict:
permission="reply", permission="reply",
) )
@use_kwargs(CommentSchema(only=("markdown",))) @use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_comment_reply(request: Request, markdown: str) -> dict: def post_comment_reply(request: Request, markdown: str) -> dict:
"""Post a reply to a comment with Intercooler.""" """Post a reply to a comment with Intercooler."""
parent_comment = request.context parent_comment = request.context

59
tildes/tildes/views/api/web/user.py

@ -1,16 +1,23 @@
"""Web API endpoints related to users.""" """Web API endpoints related to users."""
import random
import string
from typing import Optional from typing import Optional
from marshmallow import ValidationError from marshmallow import ValidationError
from marshmallow.fields import String from marshmallow.fields import String
from pyramid.httpexceptions import HTTPForbidden, HTTPUnprocessableEntity
from pyramid.httpexceptions import (
HTTPForbidden,
HTTPUnauthorized,
HTTPUnprocessableEntity,
)
from pyramid.request import Request from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from webargs.pyramidparser import use_kwargs from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType, TopicSortOption from tildes.enums import LogEventType, TopicSortOption
from tildes.lib.string import separate_string
from tildes.models.log import Log from tildes.models.log import Log
from tildes.models.user import User, UserInviteCode from tildes.models.user import User, UserInviteCode
from tildes.schemas.fields import Enum, ShortTimePeriod from tildes.schemas.fields import Enum, ShortTimePeriod
@ -84,6 +91,56 @@ def patch_change_email_address(
return Response("Your email address has been updated") return Response("Your email address has been updated")
@ic_view_config(
route_name="user",
request_method="POST",
request_param="ic-trigger-name=enable-two-factor",
renderer="two_factor_enabled.jinja2",
permission="change_two_factor",
)
@use_kwargs({"code": String()})
def post_enable_two_factor(request: Request, code: str) -> dict:
"""Enable two-factor authentication for the user."""
user = request.context
if not user.is_correct_two_factor_code(code):
raise HTTPUnprocessableEntity("Invalid code, please try again.")
request.user.two_factor_enabled = True
# Generate 10 backup codes (16 lowercase letters each)
request.user.two_factor_backup_codes = [
"".join(random.choices(string.ascii_lowercase, k=16)) for _ in range(10)
]
# format the backup codes to be easier to read for output
backup_codes = [
separate_string(code, " ", 4) for code in request.user.two_factor_backup_codes
]
return {"backup_codes": backup_codes}
@ic_view_config(
route_name="user",
request_method="POST",
request_param="ic-trigger-name=disable-two-factor",
renderer="two_factor_disabled.jinja2",
permission="change_two_factor",
)
@use_kwargs({"code": String()})
def post_disable_two_factor(request: Request, code: str) -> Response:
"""Disable two-factor authentication for the user."""
if not request.user.is_correct_two_factor_code(code):
raise HTTPUnauthorized(body="Invalid code")
request.user.two_factor_enabled = False
request.user.two_factor_secret = None
request.user.two_factor_backup_codes = None
return {}
@ic_view_config( @ic_view_config(
route_name="user", route_name="user",
request_method="PATCH", request_method="PATCH",

6
tildes/tildes/views/decorators.py

@ -2,7 +2,7 @@
from typing import Any, Callable from typing import Any, Callable
from pyramid.httpexceptions import HTTPFound, HTTPTooManyRequests
from pyramid.httpexceptions import HTTPFound
from pyramid.request import Request from pyramid.request import Request
from pyramid.view import view_config from pyramid.view import view_config
@ -35,10 +35,8 @@ def rate_limit_view(action_name: str) -> Callable:
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
def wrapper(*args: Any, **kwargs: Any) -> Any: def wrapper(*args: Any, **kwargs: Any) -> Any:
request = args[0] request = args[0]
result = request.check_rate_limit(action_name)
if not result.is_allowed:
raise result.add_headers_to_response(HTTPTooManyRequests())
request.apply_rate_limit(action_name)
return func(*args, **kwargs) return func(*args, **kwargs)

68
tildes/tildes/views/login.py

@ -1,7 +1,10 @@
"""Views related to logging in/out.""" """Views related to logging in/out."""
from pyramid.httpexceptions import HTTPFound, HTTPUnprocessableEntity
from marshmallow.fields import String
from pyramid.httpexceptions import HTTPFound, HTTPUnauthorized, HTTPUnprocessableEntity
from pyramid.renderers import render_to_response
from pyramid.request import Request from pyramid.request import Request
from pyramid.response import Response
from pyramid.security import NO_PERMISSION_REQUIRED, remember from pyramid.security import NO_PERMISSION_REQUIRED, remember
from pyramid.view import view_config from pyramid.view import view_config
from webargs.pyramidparser import use_kwargs from webargs.pyramidparser import use_kwargs
@ -24,6 +27,22 @@ def get_login(request: Request) -> dict:
return {} return {}
def finish_login(request: Request, user: User) -> None:
"""Save the user ID into session."""
# Username/password were correct - attach the user_id to the session
remember(request, user.user_id)
# Depending on "keep me logged in", set session timeout to 1 year or 1 day
if request.params.get("keep"):
request.session.adjust_timeout_for_session(31_536_000)
else:
request.session.adjust_timeout_for_session(86_400)
# set request.user before logging so the user is associated with the event
request.user = user
request.db_session.add(Log(LogEventType.USER_LOG_IN, request))
@view_config( @view_config(
route_name="login", request_method="POST", permission=NO_PERMISSION_REQUIRED route_name="login", request_method="POST", permission=NO_PERMISSION_REQUIRED
) )
@ -57,22 +76,47 @@ def post_login(request: Request, username: str, password: str) -> HTTPFound:
if user.is_banned: if user.is_banned:
raise HTTPUnprocessableEntity("This account has been banned") raise HTTPUnprocessableEntity("This account has been banned")
# Username/password were correct - attach the user_id to the session
remember(request, user.user_id)
# If 2FA is enabled, save username to session and make user enter code
if user.two_factor_enabled:
request.session["two_factor_username"] = username
return render_to_response(
"tildes:templates/intercooler/login_two_factor.jinja2",
{"keep": request.params.get("keep")},
request=request,
)
# Depending on "keep me logged in", set session timeout to 1 year or 1 day
if request.params.get("keep"):
request.session.adjust_timeout_for_session(31_536_000)
else:
request.session.adjust_timeout_for_session(86_400)
# set request.user before logging so the user is associated with the event
request.user = user
request.db_session.add(Log(LogEventType.USER_LOG_IN, request))
finish_login(request, user)
raise HTTPFound(location="/") raise HTTPFound(location="/")
@view_config(
route_name="login_two_factor",
request_method="POST",
permission=NO_PERMISSION_REQUIRED,
)
@not_logged_in
@rate_limit_view("login_two_factor")
@use_kwargs({"code": String()})
def post_login_two_factor(request: Request, code: str) -> Response:
"""Process a log in request with 2FA."""
# Look up the user for the supplied username
user = (
request.query(User)
.undefer_all_columns()
.filter(User.username == request.session["two_factor_username"])
.one_or_none()
)
if user.is_correct_two_factor_code(code):
del request.session["two_factor_username"]
finish_login(request, user)
raise HTTPFound(location="/")
else:
raise HTTPUnauthorized(body="Invalid code, please try again.")
@view_config(route_name="logout") @view_config(route_name="logout")
def get_logout(request: Request) -> HTTPFound: def get_logout(request: Request) -> HTTPFound:
"""Process a log out request.""" """Process a log out request."""

34
tildes/tildes/views/settings.py

@ -1,9 +1,13 @@
"""Views related to user settings.""" """Views related to user settings."""
from pyramid.httpexceptions import HTTPUnprocessableEntity
from io import BytesIO
import pyotp
from pyramid.httpexceptions import HTTPForbidden, HTTPUnprocessableEntity
from pyramid.request import Request from pyramid.request import Request
from pyramid.response import Response from pyramid.response import Response
from pyramid.view import view_config from pyramid.view import view_config
import qrcode
from webargs.pyramidparser import use_kwargs from webargs.pyramidparser import use_kwargs
from tildes.schemas.user import EMAIL_ADDRESS_NOTE_MAX_LENGTH, UserSchema from tildes.schemas.user import EMAIL_ADDRESS_NOTE_MAX_LENGTH, UserSchema
@ -44,6 +48,13 @@ def get_settings_account_recovery(request: Request) -> dict:
return {"note_max_length": EMAIL_ADDRESS_NOTE_MAX_LENGTH} 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."""
# pylint: disable=unused-argument
return {}
@view_config( @view_config(
route_name="settings_comment_visits", renderer="settings_comment_visits.jinja2" route_name="settings_comment_visits", renderer="settings_comment_visits.jinja2"
) )
@ -69,6 +80,27 @@ def get_settings_password_change(request: Request) -> dict:
return {} 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")
# 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()
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_password_change", request_method="POST") @view_config(route_name="settings_password_change", request_method="POST")
@use_kwargs( @use_kwargs(
{ {

61
tildes/tildes/views/topic.py

@ -30,6 +30,7 @@ from tildes.schemas.comment import CommentSchema
from tildes.schemas.fields import Enum, ShortTimePeriod from tildes.schemas.fields import Enum, ShortTimePeriod
from tildes.schemas.topic import TopicSchema from tildes.schemas.topic import TopicSchema
from tildes.schemas.topic_listing import TopicListingSchema from tildes.schemas.topic_listing import TopicListingSchema
from tildes.views.decorators import rate_limit_view
DefaultSettings = namedtuple("DefaultSettings", ["order", "period"]) DefaultSettings = namedtuple("DefaultSettings", ["order", "period"])
@ -64,6 +65,8 @@ def post_group_topics(
except ValidationError: except ValidationError:
raise ValidationError({"tags": ["Invalid tags"]}) raise ValidationError({"tags": ["Invalid tags"]})
request.apply_rate_limit("topic_post")
request.db_session.add(new_topic) request.db_session.add(new_topic)
request.db_session.add(LogTopic(LogEventType.TOPIC_POST, request, new_topic)) request.db_session.add(LogTopic(LogEventType.TOPIC_POST, request, new_topic))
@ -161,6 +164,63 @@ def get_group_topics(
} }
@view_config(route_name="search", renderer="search.jinja2")
@use_kwargs(TopicListingSchema(only=("after", "before", "order", "per_page", "period")))
@use_kwargs({"search": String(load_from="q")})
def get_search(
request: Request,
order: Any,
period: Any,
after: str,
before: str,
per_page: int,
search: str,
) -> dict:
"""Get a list of search results."""
# pylint: disable=too-many-arguments
if order is missing:
order = TopicSortOption.NEW
if period is missing:
period = None
query = (
request.query(Topic)
.join_all_relationships()
.search(search)
.apply_sort_option(order)
)
# restrict the time period, if not set to "all time"
if period:
query = query.inside_time_period(period)
# apply before/after pagination restrictions if relevant
if before:
query = query.before_id36(before)
if after:
query = query.after_id36(after)
topics = query.get_page(per_page)
period_options = [SimpleHoursPeriod(hours) for hours in (1, 12, 24, 72)]
# add the current period to the bottom of the dropdown if it's not one of the
# "standard" ones
if period and period not in period_options:
period_options.append(period)
return {
"search": search,
"topics": topics,
"order": order,
"order_options": TopicSortOption,
"period": period,
"period_options": period_options,
}
@view_config( @view_config(
route_name="new_topic", renderer="new_topic.jinja2", permission="post_topic" route_name="new_topic", renderer="new_topic.jinja2", permission="post_topic"
) )
@ -225,6 +285,7 @@ def get_topic(request: Request, comment_order: CommentSortOption) -> dict:
@view_config(route_name="topic", request_method="POST", permission="comment") @view_config(route_name="topic", request_method="POST", permission="comment")
@use_kwargs(CommentSchema(only=("markdown",))) @use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound: def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound:
"""Post a new top-level comment on a topic.""" """Post a new top-level comment on a topic."""
topic = request.context topic = request.context

Loading…
Cancel
Save