diff --git a/salt/salt/tildes-wiki.sls b/salt/salt/tildes-wiki.sls new file mode 100644 index 0000000..a6c2478 --- /dev/null +++ b/salt/salt/tildes-wiki.sls @@ -0,0 +1,23 @@ +{% from 'common.jinja2' import app_username %} + +# Create the base directory for the wiki files and initialize a git repo +/var/lib/tildes-wiki: + file.directory: + - user: {{ app_username }} + - group: {{ app_username }} + - mode: 775 + git.present: + - bare: False + - user: {{ app_username }} + +# Create the initial (empty) commit in the new git repo +wiki-initial-commit: + cmd.run: + - names: + - git config user.name "Tildes" + - git config user.email "Tildes" + - git commit --allow-empty -m "Initial commit" + - cwd: /var/lib/tildes-wiki + - runas: {{ app_username }} + - onchanges: + - file: /var/lib/tildes-wiki diff --git a/salt/salt/top.sls b/salt/salt/top.sls index ba77e1c..3b99b5b 100644 --- a/salt/salt/top.sls +++ b/salt/salt/top.sls @@ -19,6 +19,7 @@ base: - prometheus.exporters.rabbitmq_exporter - prometheus.exporters.redis_exporter - consumers + - tildes-wiki - boussole - webassets - cronjobs diff --git a/tildes/alembic/versions/9b88cb0a7b2c_add_groupwikipage.py b/tildes/alembic/versions/9b88cb0a7b2c_add_groupwikipage.py new file mode 100644 index 0000000..4c0493e --- /dev/null +++ b/tildes/alembic/versions/9b88cb0a7b2c_add_groupwikipage.py @@ -0,0 +1,57 @@ +"""Add GroupWikiPage + +Revision ID: 9b88cb0a7b2c +Revises: 53f81a72f076 +Create Date: 2019-05-24 18:47:29.828223 + +""" +from alembic import op +import sqlalchemy as sa + +from tildes.lib.database import CIText + +# revision identifiers, used by Alembic. +revision = "9b88cb0a7b2c" +down_revision = "53f81a72f076" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "group_wiki_pages", + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("slug", CIText(), nullable=False), + sa.Column("page_name", sa.Text(), nullable=False), + sa.Column( + "created_time", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.Column("last_edited_time", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("rendered_html", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["group_id"], + ["groups.group_id"], + name=op.f("fk_group_wiki_pages_group_id_groups"), + ), + sa.PrimaryKeyConstraint("group_id", "slug", name=op.f("pk_group_wiki_pages")), + ) + op.create_index( + op.f("ix_group_wiki_pages_last_edited_time"), + "group_wiki_pages", + ["last_edited_time"], + unique=False, + ) + op.create_check_constraint( + "page_name_length", "group_wiki_pages", "LENGTH(page_name) <= 40" + ) + + +def downgrade(): + op.drop_constraint("ck_group_wiki_pages_page_name_length", "group_wiki_pages") + op.drop_index( + op.f("ix_group_wiki_pages_last_edited_time"), table_name="group_wiki_pages" + ) + op.drop_table("group_wiki_pages") diff --git a/tildes/requirements-to-freeze.txt b/tildes/requirements-to-freeze.txt index 794306d..a0ed2e0 100644 --- a/tildes/requirements-to-freeze.txt +++ b/tildes/requirements-to-freeze.txt @@ -17,6 +17,7 @@ prometheus-client prospector psycopg2 publicsuffix2==2.20160818 +pygit2 Pygments pyotp pyramid diff --git a/tildes/requirements.txt b/tildes/requirements.txt index 3ea94f3..a43f9ad 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -55,6 +55,7 @@ pycodestyle==2.4.0 pycparser==2.19 pydocstyle==3.0.0 pyflakes==1.6.0 +pygit2==0.28.1 Pygments==2.4.0 pylint==2.1.1 pylint-celery==0.3 diff --git a/tildes/tildes/models/group/__init__.py b/tildes/tildes/models/group/__init__.py index b259bb7..4e34429 100644 --- a/tildes/tildes/models/group/__init__.py +++ b/tildes/tildes/models/group/__init__.py @@ -3,3 +3,4 @@ from .group import Group from .group_query import GroupQuery from .group_subscription import GroupSubscription +from .group_wiki_page import GroupWikiPage diff --git a/tildes/tildes/models/group/group.py b/tildes/tildes/models/group/group.py index 1190050..b915a08 100644 --- a/tildes/tildes/models/group/group.py +++ b/tildes/tildes/models/group/group.py @@ -108,6 +108,11 @@ class Group(DatabaseModel): acl.append((Allow, Authenticated, "post_topic")) + # wiki_page_create + # - permission must be granted specifically + acl.append((Allow, "admin", "wiki_page_create")) + acl.append((Allow, "wiki", "wiki_page_create")) + acl.append(DENY_ALL) return acl diff --git a/tildes/tildes/models/group/group_wiki_page.py b/tildes/tildes/models/group/group_wiki_page.py new file mode 100644 index 0000000..8296a3e --- /dev/null +++ b/tildes/tildes/models/group/group_wiki_page.py @@ -0,0 +1,134 @@ +# Copyright (c) 2019 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Contains the GroupWikiPage class.""" + +from datetime import datetime +from pathlib import Path +from typing import Any, Optional, Sequence, Tuple + +from pygit2 import Repository, Signature +from pyramid.security import Allow, DENY_ALL, Everyone +from sqlalchemy import CheckConstraint, Column, ForeignKey, Integer, Text, TIMESTAMP +from sqlalchemy.orm import relationship +from sqlalchemy.sql.expression import text + +from tildes.lib.database import CIText +from tildes.lib.datetime import utc_now +from tildes.lib.markdown import convert_markdown_to_safe_html +from tildes.lib.string import convert_to_url_slug +from tildes.models import DatabaseModel +from tildes.models.user import User +from tildes.schemas.group_wiki_page import GroupWikiPageSchema, PAGE_NAME_MAX_LENGTH +from .group import Group + + +class GroupWikiPage(DatabaseModel): + """Model for a wiki page in a group.""" + + schema_class = GroupWikiPageSchema + + __tablename__ = "group_wiki_pages" + + BASE_PATH = "/var/lib/tildes-wiki" + + group_id: int = Column( + Integer, ForeignKey("groups.group_id"), nullable=False, primary_key=True + ) + slug: str = Column(CIText, nullable=False, primary_key=True) + page_name: str = Column( + Text, + CheckConstraint( + f"LENGTH(page_name) <= {PAGE_NAME_MAX_LENGTH}", name="page_name_length" + ), + nullable=False, + ) + created_time: datetime = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=text("NOW()") + ) + last_edited_time: Optional[datetime] = Column(TIMESTAMP(timezone=True), index=True) + rendered_html: str = Column(Text, nullable=False) + + group: Group = relationship("Group", innerjoin=True, lazy=False) + + def __init__(self, group: Group, page_name: str, markdown: str, user: User): + """Create a new wiki page.""" + self.group = group + self.page_name = page_name + self.slug = convert_to_url_slug(page_name) + + # prevent possible conflict with url for creating a new page + if self.slug == "new_page": + raise ValueError("Invalid page name") + + if self.file_path.exists(): + raise ValueError("Wiki page already exists") + + # create the directory for the group if it doesn't already exist + self.file_path.parent.mkdir(mode=0o755, exist_ok=True) + + self.edit(markdown, user, f'~{group.path}: Create page "{page_name}"') + + def __acl__(self) -> Sequence[Tuple[str, Any, str]]: + """Pyramid security ACL.""" + acl = [] + + # view: + # - all wiki pages can be viewed by everyone + acl.append((Allow, Everyone, "view")) + + # edit: + # - permission must be granted specifically + acl.append((Allow, "admin", "edit")) + acl.append((Allow, "wiki", "edit")) + + acl.append(DENY_ALL) + + return acl + + @property + def file_path(self) -> Path: + """Return the full path to the page's file.""" + return Path(self.BASE_PATH, str(self.group.path), f"{self.slug}.md") + + @property + def markdown(self) -> Optional[str]: + """Return the wiki page's markdown.""" + try: + return self.file_path.read_text().rstrip("\r\n") + except FileNotFoundError: + return None + + @markdown.setter + def markdown(self, new_markdown: str) -> None: + """Write the wiki page's markdown to its file.""" + # write the markdown to the file, appending a newline if necessary + if not new_markdown.endswith("\n"): + new_markdown = new_markdown + "\n" + + self.file_path.write_text(new_markdown) + + def edit(self, new_markdown: str, user: User, edit_message: str) -> None: + """Set the page's markdown, render its HTML, and commit the repo.""" + if new_markdown == self.markdown: + return + + self.markdown = new_markdown + self.rendered_html = convert_markdown_to_safe_html(new_markdown) + self.last_edited_time = utc_now() + + repo = Repository(self.BASE_PATH) + author = Signature(user.username, user.username) + + repo.index.read() + repo.index.add(str(self.file_path.relative_to(self.BASE_PATH))) + repo.index.write() + + repo.create_commit( + repo.head.name, + author, + author, + edit_message, + repo.index.write_tree(), + [repo.head.target], + ) diff --git a/tildes/tildes/resources/group.py b/tildes/tildes/resources/group.py index f8cc84d..ef0172a 100644 --- a/tildes/tildes/resources/group.py +++ b/tildes/tildes/resources/group.py @@ -3,12 +3,13 @@ """Root factories for groups.""" +from marshmallow.fields import String from pyramid.httpexceptions import HTTPMovedPermanently from pyramid.request import Request from sqlalchemy_utils import Ltree from webargs.pyramidparser import use_kwargs -from tildes.models.group import Group +from tildes.models.group import Group, GroupWikiPage from tildes.resources import get_resource from tildes.schemas.group import GroupSchema @@ -32,3 +33,15 @@ def group_by_path(request: Request, path: str) -> Group: query = request.query(Group).filter(Group.path == Ltree(path)) return get_resource(request, query) + + +@use_kwargs({"wiki_page_slug": String()}, locations=("matchdict",)) +def group_wiki_page_by_slug(request: Request, wiki_page_slug: str) -> GroupWikiPage: + """Get a group's wiki page by its url slug (or 404).""" + group = group_by_path(request) # pylint: disable=no-value-for-parameter + + query = request.query(GroupWikiPage).filter( + GroupWikiPage.group == group, GroupWikiPage.slug == wiki_page_slug + ) + + return get_resource(request, query) diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index 45e5609..c4faec0 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -10,7 +10,7 @@ from pyramid.request import Request from pyramid.security import Allow, Authenticated from tildes.resources.comment import comment_by_id36, notification_by_comment_id36 -from tildes.resources.group import group_by_path +from tildes.resources.group import group_by_path, group_wiki_page_by_slug from tildes.resources.message import message_conversation_by_id36 from tildes.resources.topic import topic_by_id36 from tildes.resources.user import user_by_username @@ -36,6 +36,22 @@ def includeme(config: Configurator) -> None: config.add_route("group_topics", "/topics", factory=group_by_path) + config.add_route("group_wiki", "/wiki", factory=group_by_path) + + # if you change this from "new_page" make sure to also edit + # GroupWikiPage.__init__() to block the new slug to avoid url conflicts + config.add_route("group_wiki_new_page", "/wiki/new_page", factory=group_by_path) + + config.add_route( + "group_wiki_page", "/wiki/{wiki_page_slug}", factory=group_wiki_page_by_slug + ) + config.add_route( + "group_wiki_edit_page", + "/wiki/{wiki_page_slug}/edit", + factory=group_wiki_page_by_slug, + ) + + # these routes need to remain last inside this block config.add_route("topic", "/{topic_id36}/{title}", factory=topic_by_id36) config.add_route("topic_no_title", "/{topic_id36}", factory=topic_by_id36) diff --git a/tildes/tildes/schemas/group_wiki_page.py b/tildes/tildes/schemas/group_wiki_page.py new file mode 100644 index 0000000..f3d9688 --- /dev/null +++ b/tildes/tildes/schemas/group_wiki_page.py @@ -0,0 +1,23 @@ +# Copyright (c) 2019 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Validation/dumping schema for group wiki pages.""" + +from marshmallow import Schema + +from tildes.schemas.fields import Markdown, SimpleString + + +PAGE_NAME_MAX_LENGTH = 40 + + +class GroupWikiPageSchema(Schema): + """Marshmallow schema for group wiki pages.""" + + page_name = SimpleString(max_length=PAGE_NAME_MAX_LENGTH) + markdown = Markdown(max_length=100_000) + + class Meta: + """Always use strict checking so error handlers are invoked.""" + + strict = True diff --git a/tildes/tildes/templates/group_wiki.jinja2 b/tildes/tildes/templates/group_wiki.jinja2 new file mode 100644 index 0000000..0783abf --- /dev/null +++ b/tildes/tildes/templates/group_wiki.jinja2 @@ -0,0 +1,34 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/datetime.jinja2' import adaptive_date_responsive %} + +{% block title %}~{{ group.path }} wiki{% endblock %} + +{% block header_context_link %} +~{{ group.path }} +{% endblock %} + +{% block main_heading %}~{{ group.path }} wiki pages{% endblock %} + +{% block content %} +{% if page_list %} +
    + {% for page in page_list %} +
  • + {{ page.page_name }} +
    Last edited: {{ adaptive_date_responsive(page.last_edited_time) }}
    +
  • + {% endfor %} +
+{% else %} +

No pages yet.

+{% endif %} + +{% if request.has_permission("wiki_page_create", group) %} +
+Create new wiki page +{% endif %} +{% endblock %} diff --git a/tildes/tildes/templates/group_wiki_edit_page.jinja2 b/tildes/tildes/templates/group_wiki_edit_page.jinja2 new file mode 100644 index 0000000..d81e687 --- /dev/null +++ b/tildes/tildes/templates/group_wiki_edit_page.jinja2 @@ -0,0 +1,42 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/forms.jinja2' import markdown_textarea %} + +{% block title %}Edit wiki page{% endblock %} + +{% block header_context_link %} +~{{ page.group.path }} +{% endblock %} + +{% block main_heading %}Editing page "{{ page.page_name }}" in ~{{ page.group.path }}{% endblock %} + +{% block content %} +
+ + +
+ {{ markdown_textarea(text=page.markdown, auto_focus=True) }} +
+ +
+ + +
+ +
+ +
+ + {% include 'includes/wiki_editing_notes.jinja2' %} +
+{% endblock %} diff --git a/tildes/tildes/templates/group_wiki_new_page.jinja2 b/tildes/tildes/templates/group_wiki_new_page.jinja2 new file mode 100644 index 0000000..ee8d21b --- /dev/null +++ b/tildes/tildes/templates/group_wiki_new_page.jinja2 @@ -0,0 +1,43 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/forms.jinja2' import markdown_textarea %} + +{% block title %}New wiki page{% endblock %} + +{% block header_context_link %} +~{{ group.path }} +{% endblock %} + +{% block main_heading %}Create a new wiki page in ~{{ group.path }}{% endblock %} + +{% block content %} +
+ + +
+ + +
+ +
+ {{ markdown_textarea() }} +
+ +
+ +
+ + {% include 'includes/wiki_editing_notes.jinja2' %} +
+ +{% endblock %} diff --git a/tildes/tildes/templates/group_wiki_page.jinja2 b/tildes/tildes/templates/group_wiki_page.jinja2 new file mode 100644 index 0000000..d19dd12 --- /dev/null +++ b/tildes/tildes/templates/group_wiki_page.jinja2 @@ -0,0 +1,44 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base.jinja2' %} + +{% from 'macros/datetime.jinja2' import adaptive_date_responsive %} + +{% block title %}{{ page.page_name }} - ~{{ page.group.path }} wiki{% endblock %} + +{% block header_context_link %} +~{{ page.group.path }} +{% endblock %} + +{% block main_heading %}{{ page.page_name }}{% endblock %} + +{% block content %} +{{ page.rendered_html|safe }} + +
+

The text of this wiki page is licensed under Creative Commons Attribution-ShareAlike 4.0.

+ +Back to wiki page list +{% endblock %} + +{% block sidebar %} +

Wiki page info

+
+
Last edited
+
{{ adaptive_date_responsive(page.last_edited_time) }}
+
+ +{% if request.has_permission("edit", page) %} + Edit this page +{% endif %} + + +{% endblock %} diff --git a/tildes/tildes/templates/includes/wiki_editing_notes.jinja2 b/tildes/tildes/templates/includes/wiki_editing_notes.jinja2 new file mode 100644 index 0000000..5539155 --- /dev/null +++ b/tildes/tildes/templates/includes/wiki_editing_notes.jinja2 @@ -0,0 +1,8 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +
+

Important notes about editing wiki pages

+

By submitting content to the Tildes wiki, you are agreeing to license it under Creative Commons Attribution-ShareAlike 4.0 and understand that doing so means that the content can be copied, modified, and redistributed by others (as long as they follow the terms of that license).

+

The full history of Tildes wiki pages is retained and publicly available. Please be very careful not to include private information or other sensitive data, since it may be impossible to remove from the history.

+
diff --git a/tildes/tildes/templates/topic_listing.jinja2 b/tildes/tildes/templates/topic_listing.jinja2 index ff6eb62..1fdac49 100644 --- a/tildes/tildes/templates/topic_listing.jinja2 +++ b/tildes/tildes/templates/topic_listing.jinja2 @@ -220,6 +220,17 @@ Post a new topic {% endif %} + {% if wiki_pages %} + + {% endif %} + {% if request.user and not (tag or unfiltered) %}
diff --git a/tildes/tildes/views/group_wiki_page.py b/tildes/tildes/views/group_wiki_page.py new file mode 100644 index 0000000..f0ae625 --- /dev/null +++ b/tildes/tildes/views/group_wiki_page.py @@ -0,0 +1,102 @@ +# Copyright (c) 2019 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Views related to group wiki pages.""" + +from pyramid.httpexceptions import HTTPFound +from pyramid.request import Request +from pyramid.view import view_config +from webargs.pyramidparser import use_kwargs + +from tildes.models.group import GroupWikiPage +from tildes.schemas.fields import SimpleString +from tildes.schemas.group_wiki_page import GroupWikiPageSchema + + +@view_config(route_name="group_wiki", renderer="group_wiki.jinja2") +def get_group_wiki(request: Request) -> dict: + """Get the group wiki page list.""" + group = request.context + + page_list = ( + request.query(GroupWikiPage) + .filter(GroupWikiPage.group == group) + .order_by(GroupWikiPage.slug) + .all() + ) + + return {"group": group, "page_list": page_list} + + +@view_config(route_name="group_wiki_page", renderer="group_wiki_page.jinja2") +def get_group_wiki_page(request: Request) -> dict: + """Display a single group wiki page.""" + page = request.context + + page_list = ( + request.query(GroupWikiPage) + .filter(GroupWikiPage.group == page.group) + .order_by(GroupWikiPage.slug) + .all() + ) + + return {"page": page, "page_list": page_list} + + +@view_config( + route_name="group_wiki_new_page", + renderer="group_wiki_new_page.jinja2", + permission="wiki_page_create", +) +def get_wiki_new_page_form(request: Request) -> dict: + """Form for entering a new wiki page to create.""" + group = request.context + + return {"group": group} + + +@view_config( + route_name="group_wiki", request_method="POST", permission="wiki_page_create" +) +@use_kwargs(GroupWikiPageSchema()) +def post_group_wiki(request: Request, page_name: str, markdown: str) -> HTTPFound: + """Create a new wiki page in a group.""" + group = request.context + + new_page = GroupWikiPage(group, page_name, markdown, request.user) + + request.db_session.add(new_page) + + raise HTTPFound( + location=request.route_url( + "group_wiki_page", group_path=group.path, wiki_page_slug=new_page.slug + ) + ) + + +@view_config( + route_name="group_wiki_edit_page", + renderer="group_wiki_edit_page.jinja2", + permission="edit", +) +def get_wiki_edit_page_form(request: Request) -> dict: + """Form for editing an existing wiki page.""" + page = request.context + + return {"page": page} + + +@view_config(route_name="group_wiki_page", request_method="POST", permission="edit") +@use_kwargs(GroupWikiPageSchema(only=("markdown",))) +@use_kwargs({"edit_message": SimpleString(max_length=80)}) +def post_group_wiki_page(request: Request, markdown: str, edit_message: str) -> dict: + """Apply an edit to a single group wiki page.""" + page = request.context + + page.edit(markdown, request.user, edit_message) + + raise HTTPFound( + location=request.route_url( + "group_wiki_page", group_path=page.group.path, wiki_page_slug=page.slug + ) + ) diff --git a/tildes/tildes/views/topic.py b/tildes/tildes/views/topic.py index 856b2b3..9187425 100644 --- a/tildes/tildes/views/topic.py +++ b/tildes/tildes/views/topic.py @@ -27,7 +27,7 @@ from tildes.enums import ( from tildes.lib.database import ArrayOfLtree from tildes.lib.datetime import SimpleHoursPeriod from tildes.models.comment import Comment, CommentNotification, CommentTree -from tildes.models.group import Group +from tildes.models.group import Group, GroupWikiPage from tildes.models.log import LogComment, LogTopic from tildes.models.topic import Topic, TopicVisit from tildes.models.user import UserGroupSettings @@ -106,7 +106,7 @@ def get_group_topics( unfiltered: bool, ) -> dict: """Get a listing of topics in the group.""" - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments, too-many-branches if request.matched_route.name == "home": # on the home page, include topics from the user's subscribed groups # (or all groups, if logged-out) @@ -169,6 +169,16 @@ def get_group_topics( if period and period not in period_options: period_options.append(period) + if isinstance(request.context, Group): + wiki_pages = ( + request.query(GroupWikiPage) + .filter(GroupWikiPage.group == request.context) + .order_by(GroupWikiPage.slug) + .all() + ) + else: + wiki_pages = None + return { "group": request.context, "groups": groups, @@ -184,6 +194,7 @@ def get_group_topics( "rank_start": rank_start, "tag": tag, "unfiltered": unfiltered, + "wiki_pages": wiki_pages, }