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 @@
+ {% if obj.closed %} + Ended {{ obj.endTime | format_timeago }} with {{ total_votes[0] }} vote{% if total_votes[0] | gtone %}s{% endif %}. + {% else %} + Ends {{ obj.endTime | format_timeago }} ({{ total_votes[0] }} vote{% if total_votes[0] | gtone %}s{% endif %} as of now). + {% endif %} +
+ {% else %} + +{% if obj.endTime | gtnow %}This question ended {{ obj.endTime | format_timeago }}.
+ {% else %}This question ends {{ obj.endTime | format_timeago }}{% endif %} + +