From be7648c9ed3d56a905790bbfa128c37e33e0e13f Mon Sep 17 00:00:00 2001
From: Thomas Sileo <t@a4.io>
Date: Sun, 14 Apr 2019 19:17:54 +0200
Subject: [PATCH] Question/poll support

---
 activitypub.py       |  73 +++++++++++++++++++-
 app.py               | 161 ++++++++++++++++++++++++++++++++++++++-----
 config.py            |  13 ++++
 sass/base_theme.scss |  22 ++++++
 templates/new.html   |  33 ++++++++-
 templates/utils.html |  87 +++++++++++++++++++----
 utils/opengraph.py   |  13 ++--
 7 files changed, 361 insertions(+), 41 deletions(-)

diff --git a/activitypub.py b/activitypub.py
index db5a977..288fa2a 100644
--- a/activitypub.py
+++ b/activitypub.py
@@ -1,3 +1,4 @@
+import hashlib
 import logging
 import os
 import json
@@ -73,6 +74,12 @@ def ensure_it_is_me(f):
     return wrapper
 
 
+def _answer_key(choice: str) -> str:
+    h = hashlib.new("sha1")
+    h.update(choice.encode())
+    return h.hexdigest()
+
+
 class Box(Enum):
     INBOX = "inbox"
     OUTBOX = "outbox"
@@ -106,13 +113,17 @@ class MicroblogPubBackend(Backend):
 
     def save(self, box: Box, activity: ap.BaseActivity) -> None:
         """Custom helper for saving an activity to the DB."""
+        is_public = True
+        if activity.has_type(ap.ActivityType.CREATE) and not activity.is_public():
+            is_public = False
+
         DB.activities.insert_one(
             {
                 "box": box.value,
                 "activity": activity.to_dict(),
                 "type": _to_list(activity.type),
                 "remote_id": activity.id,
-                "meta": {"undo": False, "deleted": False},
+                "meta": {"undo": False, "deleted": False, "public": is_public},
             }
         )
 
@@ -453,6 +464,46 @@ class MicroblogPubBackend(Backend):
             {"$inc": {"meta.count_reply": -1, "meta.count_direct_reply": -1}},
         )
 
+    def _process_question_reply(self, create: ap.Create, question: ap.Question) -> None:
+        choice = create.get_object().name
+
+        # Ensure it's a valid choice
+        if choice not in [
+            c["name"] for c in question._data.get("oneOf", question.anyOf)
+        ]:
+            logger.info("invalid choice")
+            return
+
+        # Check for duplicate votes
+        if DB.activities.find_one(
+            {
+                "activity.object.actor": create.get_actor().id,
+                "meta.answer_to": question.id,
+            }
+        ):
+            logger.info("duplicate response")
+            return
+
+        # Update the DB
+        answer_key = _answer_key(choice)
+
+        DB.activities.update_one(
+            {"activity.object.id": question.id},
+            {
+                "$inc": {
+                    "meta.question_replies": 1,
+                    f"meta.question_answers.{answer_key}": 1,
+                }
+            },
+        )
+
+        DB.activities.update_one(
+            {"remote_id": create.id},
+            {"$set": {"meta.answer_to": question.id, "meta.stream": False}},
+        )
+
+        return None
+
     @ensure_it_is_me
     def _handle_replies(self, as_actor: ap.Person, create: ap.Create) -> None:
         """Go up to the root reply, store unknown replies in the `threads` DB and set the "meta.thread_root_parent"
@@ -465,6 +516,26 @@ class MicroblogPubBackend(Backend):
         root_reply = in_reply_to
         reply = ap.fetch_remote_activity(root_reply)
 
+        # Ensure the this is a local reply, of a question, with a direct "to" addressing
+        if (
+            reply.id.startswith(BASE_URL)
+            and reply.has_type(ap.ActivityType.QUESTION.value)
+            and _to_list(create.get_object().to)[0].startswith(BASE_URL)
+            and not create.is_public()
+        ):
+            return self._process_question_reply(create, reply)
+        elif (
+            create.id.startswith(BASE_URL)
+            and reply.has_type(ap.ActivityType.QUESTION.value)
+            and not create.is_public()
+        ):
+            # Keep track of our own votes
+            DB.activities.update_one(
+                {"activity.object.id": reply.id, "box": "inbox"},
+                {"$set": {"meta.voted_for": create.get_object().name}},
+            )
+            return None
+
         creply = DB.activities.find_one_and_update(
             {"activity.object.id": in_reply_to},
             {"$inc": {"meta.count_reply": 1, "meta.count_direct_reply": 1}},
diff --git a/app.py b/app.py
index f732202..a3ec798 100644
--- a/app.py
+++ b/app.py
@@ -42,6 +42,7 @@ from little_boxes import activitypub as ap
 from little_boxes.activitypub import ActivityType
 from little_boxes.activitypub import _to_list
 from little_boxes.activitypub import clean_activity
+from little_boxes.activitypub import format_datetime
 from little_boxes.activitypub import get_backend
 from little_boxes.content_helper import parse_markdown
 from little_boxes.linked_data_sig import generate_signature
@@ -65,6 +66,7 @@ import config
 
 from activitypub import Box
 from activitypub import embed_collection
+from activitypub import _answer_key
 from config import USER_AGENT
 from config import ADMIN_API_KEY
 from config import BASE_URL
@@ -129,16 +131,21 @@ def verify_pass(pwd):
 def inject_config():
     q = {
         "type": "Create",
-        "activity.object.type": "Note",
         "activity.object.inReplyTo": None,
         "meta.deleted": False,
+        "meta.public": True,
     }
     notes_count = DB.activities.find(
         {"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
     ).count()
-    q = {"type": "Create", "activity.object.type": "Note", "meta.deleted": False}
     with_replies_count = DB.activities.find(
-        {"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
+        {
+            "box": Box.OUTBOX.value,
+            "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
+            "meta.undo": False,
+            "meta.deleted": False,
+            "meta.public": True,
+        }
     ).count()
     liked_count = DB.activities.count(
         {
@@ -169,6 +176,7 @@ def inject_config():
         liked_count=liked_count,
         with_replies_count=with_replies_count,
         me=ME,
+        base_url=config.BASE_URL,
     )
 
 
@@ -233,6 +241,16 @@ def _get_file_url(url, size, kind):
     return url
 
 
+@app.template_filter()
+def gtone(n):
+    return n > 1
+
+
+@app.template_filter()
+def gtnow(dtstr):
+    return format_datetime(datetime.now().astimezone()) > dtstr
+
+
 @app.template_filter()
 def remove_mongo_id(dat):
     if isinstance(dat, list):
@@ -805,6 +823,9 @@ def index():
         DB.activities, q, limit=25 - len(pinned)
     )
 
+    # FIXME(tsileo): add it on permakink too
+    [_add_answers_to_questions(item) for item in outbox_data]
+
     resp = render_template(
         "index.html",
         outbox_data=outbox_data,
@@ -823,6 +844,7 @@ def with_replies():
         "box": Box.OUTBOX.value,
         "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
         "meta.deleted": False,
+        "meta.public": True,
         "meta.undo": False,
     }
     outbox_data, older_than, newer_than = paginated_query(DB.activities, q)
@@ -914,6 +936,10 @@ def note_by_id(note_id):
         abort(404)
     if data["meta"].get("deleted", False):
         abort(410)
+
+    # If it's a Question, add the answers from meta
+    _add_answers_to_questions(data)
+
     thread = _build_thread(data)
     app.logger.info(f"thread={thread!r}")
 
@@ -1084,9 +1110,31 @@ def remove_context(activity: Dict[str, Any]) -> Dict[str, Any]:
     return activity
 
 
+def _add_answers_to_questions(raw_doc: Dict[str, Any]) -> None:
+    activity = raw_doc["activity"]
+    if (
+        "object" in activity
+        and _to_list(activity["object"]["type"])[0] == ActivityType.QUESTION.value
+    ):
+        for choice in activity["object"].get("oneOf", activity["object"].get("anyOf")):
+            choice["replies"] = {
+                "type": ActivityType.COLLECTION.value,
+                "totalItems": raw_doc["meta"]
+                .get("question_answers", {})
+                .get(_answer_key(choice["name"]), 0),
+            }
+        now = datetime.now().astimezone()
+        if format_datetime(now) > activity["object"]["endTime"]:
+            activity["object"]["closed"] = activity["object"]["endTime"]
+
+
 def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str, Any]:
     raw_doc = add_extra_collection(raw_doc)
     activity = clean_activity(raw_doc["activity"])
+
+    # Handle Questions
+    # TODO(tsileo): what about object embedded by ID/URL?
+    _add_answers_to_questions(raw_doc)
     if embed:
         return remove_context(activity)
     return activity
@@ -1101,6 +1149,8 @@ def outbox():
         q = {
             "box": Box.OUTBOX.value,
             "meta.deleted": False,
+            "meta.undo": False,
+            "meta.public": True,
             "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
         }
         return jsonify(
@@ -1150,6 +1200,7 @@ def outbox_activity(item_id):
     )
     if not data:
         abort(404)
+
     obj = activity_from_doc(data)
     if data["meta"].get("deleted", False):
         obj = ap.parse_activity(data["activity"])
@@ -1487,19 +1538,7 @@ def _user_api_arg(key: str, **kwargs):
 def _user_api_get_note(from_outbox: bool = False):
     oid = _user_api_arg("id")
     app.logger.info(f"fetching {oid}")
-    try:
-        note = ap.parse_activity(
-            get_backend().fetch_iri(oid), expected=ActivityType.NOTE
-        )
-    except Exception:
-        try:
-            note = ap.parse_activity(
-                get_backend().fetch_iri(oid), expected=ActivityType.VIDEO
-            )
-        except Exception:
-            raise ActivityNotFoundError(
-                "Expected Note or Video ActivityType, but got something else"
-            )
+    note = ap.parse_activity(get_backend().fetch_iri(oid))
     if from_outbox and not note.id.startswith(ID):
         raise NotFromOutboxError(
             f"cannot load {note.id}, id must be owned by the server"
@@ -1542,6 +1581,30 @@ def api_boost():
     return _user_api_response(activity=announce_id)
 
 
+@app.route("/api/vote", methods=["POST"])
+@api_required
+def api_vote():
+    oid = _user_api_arg("id")
+    app.logger.info(f"fetching {oid}")
+    note = ap.parse_activity(get_backend().fetch_iri(oid))
+    choice = _user_api_arg("choice")
+
+    raw_note = dict(
+        attributedTo=MY_PERSON.id,
+        cc=[],
+        to=note.get_actor().id,
+        name=choice,
+        tag=[],
+        inReplyTo=note.id,
+    )
+
+    note = ap.Note(**raw_note)
+    create = note.build_create()
+    create_id = post_to_outbox(create)
+
+    return _user_api_response(activity=create_id)
+
+
 @app.route("/api/like", methods=["POST"])
 @api_required
 def api_like():
@@ -1780,6 +1843,57 @@ def api_new_note():
     return _user_api_response(activity=create_id)
 
 
+@app.route("/api/new_question", methods=["POST"])
+@api_required
+def api_new_question():
+    source = _user_api_arg("content")
+    if not source:
+        raise ValueError("missing content")
+
+    content, tags = parse_markdown(source)
+    cc = [ID + "/followers"]
+
+    for tag in tags:
+        if tag["type"] == "Mention":
+            cc.append(tag["href"])
+
+    answers = []
+    for i in range(4):
+        a = _user_api_arg(f"answer{i}", default=None)
+        if not a:
+            break
+        answers.append({"type": ActivityType.NOTE.value, "name": a})
+
+    choices = {
+        "endTime": ap.format_datetime(
+            datetime.now().astimezone()
+            + timedelta(minutes=int(_user_api_arg("open_for")))
+        )
+    }
+    of = _user_api_arg("of")
+    if of == "anyOf":
+        choices["anyOf"] = answers
+    else:
+        choices["oneOf"] = answers
+
+    raw_question = dict(
+        attributedTo=MY_PERSON.id,
+        cc=list(set(cc)),
+        to=[ap.AS_PUBLIC],
+        content=content,
+        tag=tags,
+        source={"mediaType": "text/markdown", "content": source},
+        inReplyTo=None,
+        **choices,
+    )
+
+    question = ap.Question(**raw_question)
+    create = question.build_create()
+    create_id = post_to_outbox(create)
+
+    return _user_api_response(activity=create_id)
+
+
 @app.route("/api/stream")
 @api_required
 def api_stream():
@@ -2583,6 +2697,7 @@ def task_process_new_activity():
             if not note.inReplyTo or note.inReplyTo.startswith(ID):
                 tag_stream = True
 
+            # FIXME(tsileo): check for direct addressing in the to, cc, bcc... fields
             if (note.inReplyTo and note.inReplyTo.startswith(ID)) or note.has_mention(
                 ID
             ):
@@ -2734,8 +2849,6 @@ def task_cleanup():
     task = p.parse(request)
     app.logger.info(f"task={task!r}")
     p.push({}, "/task/cleanup_part_1")
-    p.push({}, "/task/cleanup_part_2")
-    p.push({}, "/task/cleanup_part_3")
     return ""
 
 
@@ -2847,6 +2960,17 @@ def task_cleanup_part_1():
         },
         {"$set": {"meta.keep": False}},
     )
+
+    DB.activities.update_many(
+        {
+            "box": Box.OUTBOX.value,
+            "type": ActivityType.CREATE.value,
+            "meta.public": {"$exists": False},
+        },
+        {"$set": {"meta.public": True}},
+    )
+
+    p.push({}, "/task/cleanup_part_2")
     return "OK"
 
 
@@ -2870,6 +2994,7 @@ def task_cleanup_part_2():
             MEDIA_CACHE.fs.delete(grid_item._id)
         DB.activities.delete_one({"_id": data["_id"]})
 
+    p.push({}, "/task/cleanup_part_3")
     return "OK"
 
 
diff --git a/config.py b/config.py
index 7b6d6ea..0e3e8fa 100644
--- a/config.py
+++ b/config.py
@@ -144,6 +144,19 @@ def create_indexes():
         ]
     )
 
+    DB.activities.create_index([("box", pymongo.ASCENDING)])
+
+    # Outbox query
+    DB.activities.create_index(
+        [
+            ("box", pymongo.ASCENDING),
+            ("type", pymongo.ASCENDING),
+            ("meta.undo", pymongo.ASCENDING),
+            ("meta.deleted", pymongo.ASCENDING),
+            ("meta.public", pymongo.ASCENDING),
+        ]
+    )
+
     DB.activities.create_index(
         [
             ("type", pymongo.ASCENDING),
diff --git a/sass/base_theme.scss b/sass/base_theme.scss
index b878a19..8628370 100644
--- a/sass/base_theme.scss
+++ b/sass/base_theme.scss
@@ -329,3 +329,25 @@ input[type=submit] {
 .note-video {
   margin: 30px 0 10px 0;
 }
+li.answer {
+  height:30px;
+  margin-bottom:10px;
+  position:relative;
+}
+.answer .answer-bar {
+  position:absolute;
+  height:30px;
+  border-radius:2px;
+}
+.answer .answer-text {
+  position:relative;
+  top:6px;
+  padding-left:10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.answer .answer-text > span {
+   width:70px;
+   display:inline-block;
+}
diff --git a/templates/new.html b/templates/new.html
index 490a6f5..ddd16c5 100644
--- a/templates/new.html
+++ b/templates/new.html
@@ -12,9 +12,13 @@
 <h3 style="padding-bottom: 30px">Replying to {{ content }}</h3>
 {{ utils.display_thread(thread) }}
 {% else %}
-<h3 style="padding-bottom:20px;">New note</h3>
+{% if request.args.get("question") == "1" %}
+<h3 style="padding-bottom:20px;">New question <small><a href="/admin/new">make it a note?</a></small></h3>
+{% else %}
+<h3 style="padding-bottom:20px;">New note <small><a href="/admin/new?question=1">make it a question?</a></small></h3>
 {% endif %}
-<form action="/api/new_note" method="POST" enctype="multipart/form-data">
+{% endif %}
+<form action="/api/new_{% if request.args.get("question") == "1" %}question{%else%}note{%endif%}" method="POST" enctype="multipart/form-data">
 <input type="hidden" name="redirect" value="/">
 <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 {% if reply %}<input type="hidden" name="reply" value="{{reply}}">{% endif %}
@@ -27,7 +31,32 @@
 
 <textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea>
 <input type="file" name="file">
+
+{% if request.args.get("question") == "1" %}
 <div style="margin-top:20px;">
+    <p>Open for: <select name="open_for">
+    <option value="30">30 minutes</option>
+    <option value="60">1 hour</option>
+    <option value="360">6 hour</option>
+    <option value="1440" selected>1 day</option>
+    <option value="4320">3 days</option>
+    <option value="10080">7 days</option>
+    </select></p>
+
+    <input type="hidden" name="of" value="oneOf" />
+    <!--
+    <p><select name="of">
+        <option value="oneOf">Single choice</option>
+        <option value="anyOf">Multiple choices</option>
+    </select></p>-->
+
+    {% for i in range(4) %}
+    <p><input type="text" name="answer{{i}}" placeholder="Answer #{{i+1}}"></p>
+    {% endfor %}
+
+</div>
+{% endif %}
+
 <input type="submit" value="post">
 </div>
 </form>
diff --git a/templates/utils.html b/templates/utils.html
index bd5ca6e..4aa2948 100644
--- a/templates/utils.html
+++ b/templates/utils.html
@@ -15,8 +15,6 @@
 {% endif %}
 {%- endmacro %}
 
-
-
 {% macro display_note(obj, perma=False, ui=False, likes=[], shares=[], meta={}, no_color=False) -%}
 {% if meta.actor %}
 {% set actor = meta.actor %}
@@ -24,6 +22,20 @@
 {% set actor = obj.attributedTo | get_actor  %}
 {% endif %}
 
+
+{% if session.logged_in %}
+{% set perma_id = obj.id | permalink_id %}
+
+{% if request.args.get('older_than') %}
+{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
+{% elif request.args.get('newer_than') %}
+{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
+{% else %}
+{% set redir = request.path + "#activity-" + perma_id %}
+{% endif %}
+
+
+
 <div class="note-box">
 <div class="note h-entry" id="activity-{{ obj.id | permalink_id }}">
 
@@ -58,11 +70,67 @@
     {{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
     {% elif obj | has_type('Question') %}
 	{{ obj.content | clean | safe }}
-    <ul>
+
+    {% if obj.id | is_from_outbox %}
+    <ul style="list-style:none;padding:0;">
+    {% set total_votes = [0] %}
     {% for oneOf in obj.oneOf %}
-        <li>{{ oneOf.name }} ({{ oneOf.replies.totalItems }})</li>
+    {% if oneOf.replies %}
+    {% if total_votes.append(total_votes.pop() + oneOf.replies.totalItems) %}{% endif %}
+    {% endif %}
+    {% endfor %}
+
+    {% for oneOf in obj.oneOf %}
+    {% set pct = 0 %}
+    {% if total_votes[0] > 0 and oneOf.replies %}
+    {% set pct = oneOf.replies.totalItems * 100.0 / total_votes[0] %}
+    {% endif %}
+    <li class="answer">
+        <span class="answer-bar color-menu-background" style="width:{{pct}}%;"></span>
+        <span class="answer-text">
+            <span>{{ '%0.0f'| format(pct) }}%</span>
+            {{ oneOf.name }} 
+        </span>
+    </li>
     {% endfor %}
     </ul>
+    <p><small>
+    {% if obj.closed %}
+    Ended {{ obj.endTime | format_timeago }} with <strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %}.
+    {% else %}
+    Ends {{ obj.endTime | format_timeago }} (<strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %} as of now).
+    {% endif %}
+    </small></p>
+    {% else %}
+
+    <ul style="list-style:none;padding:0;">
+    {% for oneOf in obj.oneOf %}
+    <li class="answer">
+<span class="answer-text">
+
+{% if not meta.voted_for and not obj.endTime | gtnow %}
+<span><form action="/api/vote" class="action-form"  method="POST">
+<input type="hidden" name="redirect" value="{{ redir }}">
+<input type="hidden" name="id" value="{{ obj.id }}">
+<input type="hidden" name="choice" value="{{ oneOf.name }}">
+<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+<button type="submit" class="bar-item">vote</button>
+</form></span>
+{% else %}
+    <span>???</span>
+{% endif %}
+{{ oneOf.name }} {% if oneOf.name == meta.voted_for %}(your vote){% endif %}
+</span>
+    </li>
+    {% endfor %}
+    <p><small>{% if obj.endTime | gtnow %}This question ended {{ obj.endTime | format_timeago }}.</small></p>
+    {% else %}This question ends {{ obj.endTime | format_timeago }}{% endif %}
+    </small></p>
+    </ul>
+    
+
+    {% endif %}
+
     {% else %}
 	{{ obj.content | clean | safe }}
     {% endif %}
@@ -120,17 +188,6 @@
 <a class ="bar-item" href="{{ obj | url_or_id | get_url }}">permalink</a> 
 {% endif %}
 
-{% if session.logged_in %}
-{% set perma_id = obj.id | permalink_id %}
-
-{% if request.args.get('older_than') %}
-{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
-{% elif request.args.get('newer_than') %}
-{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
-{% else %}
-{% set redir = request.path + "#activity-" + perma_id %}
-{% endif %}
-
 {% set aid = obj.id | quote_plus %}
 {% endif %}
 
diff --git a/utils/opengraph.py b/utils/opengraph.py
index 521b645..d6a9c51 100644
--- a/utils/opengraph.py
+++ b/utils/opengraph.py
@@ -20,11 +20,14 @@ def links_from_note(note):
             tags_href.add(h)
 
     links = set()
-    soup = BeautifulSoup(note["content"])
-    for link in soup.find_all("a"):
-        h = link.get("href")
-        if h.startswith("http") and h not in tags_href and is_url_valid(h):
-            links.add(h)
+    if "content" in note:
+        soup = BeautifulSoup(note["content"])
+        for link in soup.find_all("a"):
+            h = link.get("href")
+            if h.startswith("http") and h not in tags_href and is_url_valid(h):
+                links.add(h)
+
+    # FIXME(tsileo): support summary and name fields
 
     return links