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. 10
      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. 8
      tildes/tildes/templates/topic.jinja2
  36. 25
      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. 66
      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
mypy
mypy-extensions
Pillow
prometheus-client
psycopg2
publicsuffix2
@ -20,6 +21,7 @@ pydocstyle
pylama
pylama-pylint
pylint==1.7.5 # pylama has issues with 1.8.1
pyotp
pyramid
pyramid-debugtoolbar
pyramid-ipython
@ -30,6 +32,7 @@ pyramid-webassets
pytest
pytest-mock
PyYAML # needs to be installed separately for webassets
qrcode
SQLAlchemy
SQLAlchemy-Utils
stripe

3
tildes/requirements.txt

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

1
tildes/scss/_base.scss

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

2
tildes/scss/modules/_btn.scss

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

8
tildes/scss/modules/_sidebar.scss

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

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

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

10
tildes/scss/modules/_topic.scss

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

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

@ -12,3 +12,18 @@ CREATE TRIGGER delete_topic_set_deleted_time_update
FOR EACH ROW
WHEN (OLD.is_deleted = false AND NEW.is_deleted = true)
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():
"""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)
assert "<ol" not in html
@ -338,3 +334,11 @@ def test_username_ref_inside_pre_ignored():
processed = convert_markdown_to_safe_html(markdown)
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."""
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, Optional, Tuple
from paste.deploy.config import PrefixMiddleware
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPTooManyRequests
from pyramid.registry import Registry
from pyramid.request import Request
from redis import StrictRedis
@ -50,6 +51,7 @@ def main(global_config: Dict[str, str], **settings: str) -> PrefixMiddleware:
# pylint: enable=unnecessary-lambda
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_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)
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(
request: Request, query: Optional[Dict[str, Any]] = None
) -> str:
@ -130,10 +139,18 @@ def current_listing_base_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.")
base_view_vars = ("order", "period", "per_page", "tag", "type", "unfiltered")
query_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.
"""
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.")
normal_view_vars = ("order", "period", "per_page")
query_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:
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:

15
tildes/tildes/lib/markdown.py

@ -80,9 +80,9 @@ HTML_ATTRIBUTE_WHITELIST = {
PROTOCOL_WHITELIST = ("http", "https")
# 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(
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"\.\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.
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
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)
@ -170,7 +169,7 @@ def escape_accidental_ordered_lists(markdown: str) -> str:
def postprocess_markdown_html(html: str) -> str:
"""Apply post-processing to HTML generated by markdown parser."""
# 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
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
_RATE_LIMITED_ACTIONS = (
RateLimitedAction("login", timedelta(hours=1), 20),
RateLimitedAction("login_two_factor", timedelta(hours=1), 20),
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

17
tildes/tildes/lib/string.py

@ -188,3 +188,20 @@ def _sanitize_characters(original: str) -> str:
final_characters.append(char)
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 sqlalchemy import event
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.schema import MetaData
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
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.engine import Connection
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import relationship
from sqlalchemy.sql.expression import text
@ -51,7 +52,7 @@ class BaseLog:
@declared_attr
def info(self) -> Column:
"""Return the info column."""
return Column(JSONB)
return Column(MutableDict.as_mutable(JSONB))
@declared_attr
def user(self) -> Any:

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

@ -14,8 +14,9 @@ from sqlalchemy import (
Text,
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.mutable import MutableDict
from sqlalchemy.orm import deferred, relationship
from sqlalchemy.sql.expression import text
from sqlalchemy_utils import Ltree
@ -102,7 +103,7 @@ class Topic(DatabaseModel):
_markdown: Optional[str] = deferred(Column("markdown", Text))
rendered_html: 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_votes: int = Column(Integer, nullable=False, server_default="0", index=True)
_tags: List[Ltree] = Column(
@ -110,12 +111,16 @@ class Topic(DatabaseModel):
)
is_official: 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)
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
def markdown(self) -> Optional[str]:
@ -263,14 +268,19 @@ class Topic(DatabaseModel):
acl.append((Allow, self.user_id, "delete"))
# 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, "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", "move"))
acl.append((Allow, "topic.move", "move"))
acl.append((Allow, "admin", "edit_title"))
acl.append((Allow, "topic.edit_title", "edit_title"))
acl.append(DENY_ALL)

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

@ -3,6 +3,7 @@
from typing import Any, Sequence
from pyramid.request import Request
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, null
from sqlalchemy_utils import Ltree
@ -137,3 +138,7 @@ class TopicQuery(PaginatedQuery):
# pylint: disable=protected-access
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 mypy_extensions import NoReturn
from pyotp import TOTP
from pyramid.security import (
ALL_PERMISSIONS,
Allow,
@ -21,7 +22,7 @@ from sqlalchemy import (
Text,
TIMESTAMP,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.dialects.postgresql import ARRAY, ENUM, JSONB
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import deferred
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(
TIMESTAMP(timezone=True),
nullable=False,
@ -85,7 +89,7 @@ class User(DatabaseModel):
open_new_tab_text: bool = Column(Boolean, nullable=False, server_default="false")
theme_account_default: str = Column(Text, nullable=False, server_default="")
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_period: Optional[str] = Column(Text)
_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
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
def email_address(self) -> NoReturn:
"""Return an error since reading the email address isn't possible."""
@ -182,3 +205,17 @@ class User(DatabaseModel):
def num_unread_total(self) -> int:
"""Return total number of unread items (notifications + messages)."""
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.sql.expression import text
from tildes.lib.string import separate_string
from tildes.models import DatabaseModel
from .user import User
@ -36,16 +37,7 @@ class UserInviteCode(DatabaseModel):
def __str__(self) -> str:
"""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:
"""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."""
config.add_route("home", "/")
config.add_route("search", "/search")
config.add_route("groups", "/groups")
config.add_route("login", "/login")
config.add_route("login_two_factor", "/login_two_factor")
config.add_route("logout", "/logout", factory=LoggedInFactory)
config.add_route("register", "/register")
@ -61,6 +64,14 @@ def includeme(config: Configurator) -> None:
"/settings/account_recovery",
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(
"settings_comment_visits", "/settings/comment_visits", factory=LoggedInFactory
)

7
tildes/tildes/templates/home.jinja2

@ -17,6 +17,13 @@
{% 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">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h2>Home</h2>
<p>The home page shows topics from groups that you're subscribed to.</p>
{% 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 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() }}">
<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>
<div class="text-small text-secondary">To be able to regain access in case of lost password, compromise, etc.</div>
</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>
<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>

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 %}

8
tildes/tildes/templates/topic.jinja2

@ -144,7 +144,7 @@
{% if topic.num_comments > 0 %}
<section class="topic-comments">
<header>
<header class="topic-comments-header">
<h2>
{% trans num_comments=topic.num_comments %}
{{ num_comments }} comment
@ -153,6 +153,9 @@
{% endtrans %}
</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">
<div class="form-group">
<label for="comment_order">Comments sorted by</label>
@ -166,12 +169,11 @@
>{{ option.description }}</option>
{% endfor %}
</select>
</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>
</div>
</form>
</header>

25
tildes/tildes/templates/topic_listing.jinja2

@ -46,9 +46,14 @@
<form class="form-listing-options" method="get">
<input type="hidden" name="order" value="{{ order.name.lower() }}">
{% if tag %}
{% if tag is defined and tag %}
<input type="hidden" name="tag" value="{{ tag }}">
{% endif %}
{% if search is defined %}
<input type="hidden" name="q" value="{{ search }}">
{% endif %}
<div class="form-group">
<label for="period">from</label>
<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="other">other period</option>
</select>
</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>
</div>
</form>
{% if not is_default_view %}
{% if is_default_view is defined and not is_default_view %}
<form
{% if is_single_group %}
data-ic-patch-to="{{ request.route_url(
@ -96,6 +100,7 @@
{% endif %}
</div>
{% if search is not defined %}
<div class="topic-listing-filter">
{% if tag %}
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>
{% endif %}
</div>
{% endif %}
{% if not topics %}
<div class="empty">
@ -125,7 +131,7 @@
{% if topics %}
<ol class="topic-listing"
{% if rank_start is not none %}
{% if rank_start is defined and rank_start is not none %}
start="{{ rank_start }}"
{% endif %}
>
@ -134,7 +140,7 @@
<li>
{# 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(
topic,
show_group=topic.group != request.context,
@ -169,6 +175,13 @@
{% 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">
<button class="btn btn-sm input-group-btn">Search</button>
</div>
</form>
<h3>~{{ group.path }}</h3>
{% 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.schemas.comment import CommentSchema, CommentTagSchema
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:
@ -57,6 +57,7 @@ def _increment_topic_comments_seen(request: Request, comment: Comment) -> None:
permission="comment",
)
@use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_toplevel_comment(request: Request, markdown: str) -> dict:
"""Post a new top-level comment on a topic with Intercooler."""
topic = request.context
@ -90,6 +91,7 @@ def post_toplevel_comment(request: Request, markdown: str) -> dict:
permission="reply",
)
@use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_comment_reply(request: Request, markdown: str) -> dict:
"""Post a reply to a comment with Intercooler."""
parent_comment = request.context

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

@ -1,16 +1,23 @@
"""Web API endpoints related to users."""
import random
import string
from typing import Optional
from marshmallow import ValidationError
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.response import Response
from sqlalchemy.exc import IntegrityError
from webargs.pyramidparser import use_kwargs
from tildes.enums import LogEventType, TopicSortOption
from tildes.lib.string import separate_string
from tildes.models.log import Log
from tildes.models.user import User, UserInviteCode
from tildes.schemas.fields import Enum, ShortTimePeriod
@ -84,6 +91,56 @@ def patch_change_email_address(
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(
route_name="user",
request_method="PATCH",

6
tildes/tildes/views/decorators.py

@ -2,7 +2,7 @@
from typing import Any, Callable
from pyramid.httpexceptions import HTTPFound, HTTPTooManyRequests
from pyramid.httpexceptions import HTTPFound
from pyramid.request import Request
from pyramid.view import view_config
@ -35,10 +35,8 @@ def rate_limit_view(action_name: str) -> Callable:
def decorator(func: Callable) -> Callable:
def wrapper(*args: Any, **kwargs: Any) -> Any:
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)

66
tildes/tildes/views/login.py

@ -1,7 +1,10 @@
"""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.response import Response
from pyramid.security import NO_PERMISSION_REQUIRED, remember
from pyramid.view import view_config
from webargs.pyramidparser import use_kwargs
@ -24,6 +27,22 @@ def get_login(request: Request) -> dict:
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(
route_name="login", request_method="POST", permission=NO_PERMISSION_REQUIRED
)
@ -57,20 +76,45 @@ def post_login(request: Request, username: str, password: str) -> HTTPFound:
if user.is_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)
finish_login(request, user)
raise HTTPFound(location="/")
# 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(
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")

34
tildes/tildes/views/settings.py

@ -1,9 +1,13 @@
"""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.response import Response
from pyramid.view import view_config
import qrcode
from webargs.pyramidparser import use_kwargs
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}
@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(
route_name="settings_comment_visits", renderer="settings_comment_visits.jinja2"
)
@ -69,6 +80,27 @@ def get_settings_password_change(request: Request) -> dict:
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")
@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.topic import TopicSchema
from tildes.schemas.topic_listing import TopicListingSchema
from tildes.views.decorators import rate_limit_view
DefaultSettings = namedtuple("DefaultSettings", ["order", "period"])
@ -64,6 +65,8 @@ def post_group_topics(
except ValidationError:
raise ValidationError({"tags": ["Invalid tags"]})
request.apply_rate_limit("topic_post")
request.db_session.add(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(
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")
@use_kwargs(CommentSchema(only=("markdown",)))
@rate_limit_view("comment_post")
def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound:
"""Post a new top-level comment on a topic."""
topic = request.context

Loading…
Cancel
Save