def authorize_follow(): if request.method == "GET": return htmlify( render_template("authorize_remote_follow.html", profile=request.args.get("profile"))) csrf.protect() actor = get_actor_url(request.form.get("profile")) if not actor: abort(500) q = { "box": Box.OUTBOX.value, "type": ap.ActivityType.FOLLOW.value, "meta.undo": False, "activity.object": actor, } if DB.activities.count(q) > 0: return redirect("/following") follow = ap.Follow(actor=MY_PERSON.id, object=actor, to=[actor], cc=[ap.AS_PUBLIC], published=now()) post_to_outbox(follow) return redirect("/following")
def admin_update_actor() -> _Response: update = ap.Update( actor=MY_PERSON.id, object=MY_PERSON.to_dict(), to=[MY_PERSON.followers], cc=[ap.AS_PUBLIC], published=now(), ) post_to_outbox(update) return "OK"
def admin_update_actor() -> _Response: # FIXME(tsileo): make this a task, and keep track of our own actor_hash at startup update = ap.Update( actor=MY_PERSON.id, object=MY_PERSON.to_dict(), to=[MY_PERSON.followers], cc=[ap.AS_PUBLIC], published=now(), ) post_to_outbox(update) return "OK"
def api_follow() -> _Response: actor = _user_api_arg("actor") q = { "box": Box.OUTBOX.value, "type": ap.ActivityType.FOLLOW.value, "meta.undo": False, "activity.object": actor, } existing = DB.activities.find_one(q) if existing: return _user_api_response(activity=existing["activity"]["id"]) follow = ap.Follow( actor=MY_PERSON.id, object=actor, to=[actor], cc=[ap.AS_PUBLIC], published=now(), context=new_context(), ) follow_id = post_to_outbox(follow) return _user_api_response(activity=follow_id)
def outbox(): if request.method == "GET": if not is_api_request(): abort(404) _log_sig() # TODO(tsileo): returns the whole outbox if authenticated and look at OCAP support q = { "box": Box.OUTBOX.value, "meta.deleted": False, "meta.undo": False, "meta.public": True, "type": { "$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value] }, } return jsonify(**activitypub.build_ordered_collection( DB.activities, q=q, cursor=request.args.get("cursor"), map_func=lambda doc: activity_from_doc(doc, embed=True), col_name="outbox", )) # Handle POST request try: _api_required() except BadSignature: abort(401) data = request.get_json(force=True) activity = ap.parse_activity(data) activity_id = post_to_outbox(activity) return Response(status=201, headers={"Location": activity_id})
def api_undo() -> _Response: oid = _user_api_arg("id") doc = DB.activities.find_one( { "box": Box.OUTBOX.value, "$or": [{"remote_id": activity_url(oid)}, {"remote_id": oid}], } ) if not doc: raise ActivityNotFoundError(f"cannot found {oid}") obj = ap.parse_activity(doc.get("activity")) undo = ap.Undo( actor=MY_PERSON.id, context=new_context(obj), object=obj.to_dict(embed=True, embed_object_id_only=True), published=now(), to=obj.to, cc=obj.cc, ) # FIXME(tsileo): detect already undo-ed and make this API call idempotent undo_id = post_to_outbox(undo) return _user_api_response(activity=undo_id)
def _follow_process_inbox(activity: ap.Follow, new_meta: _NewMeta) -> None: _logger.info(f"process_inbox activity={activity!r}") # Reply to a Follow with an Accept actor_id = activity.get_actor().id accept = ap.Accept( actor=config.ID, object={ "type": "Follow", "id": activity.id, "object": activity.get_object_id(), "actor": actor_id, }, to=[actor_id], published=now(), ) post_to_outbox(accept)
def api_like() -> _Response: note = _user_api_get_note() to: List[str] = [] cc: List[str] = [] note_visibility = ap.get_visibility(note) if note_visibility == ap.Visibility.PUBLIC: to = [ap.AS_PUBLIC] cc = [ID + "/followers", note.get_actor().id] elif note_visibility == ap.Visibility.UNLISTED: to = [ID + "/followers", note.get_actor().id] cc = [ap.AS_PUBLIC] else: to = [note.get_actor().id] like = ap.Like( object=note.id, actor=MY_PERSON.id, to=to, cc=cc, published=now(), context=new_context(note), ) like_id = post_to_outbox(like) return _user_api_response(activity=like_id)
def api_ack_reply() -> _Response: reply_iri = _user_api_arg("reply_iri") obj = ap.fetch_remote_activity(reply_iri) if obj.has_type(ap.ActivityType.CREATE): obj = obj.get_object() # TODO(tsileo): tweak the adressing? update_one_activity( { **by_type(ap.ActivityType.CREATE), **by_object_id(obj.id) }, {"$set": { "meta.reply_acked": True }}, ) read = ap.Read( actor=MY_PERSON.id, object=obj.id, to=[MY_PERSON.followers], cc=[ap.AS_PUBLIC, obj.get_actor().id], published=now(), context=new_context(obj), ) read_id = post_to_outbox(read) return _user_api_response(activity=read_id)
def api_new_question() -> _Response: 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": ap.ActivityType.NOTE.value, "name": a, "replies": { "type": ap.ActivityType.COLLECTION.value, "totalItems": 0 }, }) open_for = int(_user_api_arg("open_for")) choices = { "endTime": ap.format_datetime( datetime.now(timezone.utc) + timedelta(minutes=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) Tasks.update_question_outbox(create_id, open_for) return _user_api_response(activity=create_id)
def task_send_actor_update() -> _Response: task = p.parse(flask.request) app.logger.info(f"task={task!r}") try: update = ap.Update( actor=MY_PERSON.id, object=MY_PERSON.to_dict(), to=[MY_PERSON.followers], cc=[ap.AS_PUBLIC], published=now(), context=new_context(), ) post_to_outbox(update) except Exception as err: app.logger.exception(f"failed to send actor update") raise TaskError() from err return ""
def task_update_question() -> _Response: """Sends an Update.""" task = p.parse(flask.request) app.logger.info(f"task={task!r}") iri = task.payload try: app.logger.info(f"Updating question {iri}") cc = [config.ID + "/followers"] doc = DB.activities.find_one({ "box": Box.OUTBOX.value, "remote_id": iri }) _add_answers_to_question(doc) question = ap.Question(**doc["activity"]["object"]) raw_update = dict( actor=question.id, object=question.to_dict(embed=True), attributedTo=MY_PERSON.id, cc=list(set(cc)), to=[ap.AS_PUBLIC], ) raw_update["@context"] = config.DEFAULT_CTX update = ap.Update(**raw_update) print(update) print(update.to_dict()) post_to_outbox(update) except HTTPError as err: app.logger.exception("request failed") if 400 >= err.response.status_code >= 499: app.logger.info("client error, no retry") return "" raise TaskError() from err except Exception as err: app.logger.exception("task failed") raise TaskError() from err return ""
def api_delete() -> _Response: """API endpoint to delete a Note activity.""" note = _user_api_get_note(from_outbox=True) # Create the delete, same audience as the Create object delete = ap.Delete( actor=ID, object=ap.Tombstone(id=note.id).to_dict(embed=True), to=note.to, cc=note.cc, published=now(), ) delete_id = post_to_outbox(delete) return _user_api_response(activity=delete_id)
def api_block() -> _Response: actor = _user_api_arg("actor") existing = DB.activities.find_one({ "box": Box.OUTBOX.value, "type": ap.ActivityType.BLOCK.value, "activity.object": actor, "meta.undo": False, }) if existing: return _user_api_response(activity=existing["activity"]["id"]) block = ap.Block(actor=MY_PERSON.id, object=actor) block_id = post_to_outbox(block) return _user_api_response(activity=block_id)
def api_boost() -> _Response: note = _user_api_get_note() # Ensures the note visibility allow us to build an Announce (in respect to the post visibility) if ap.get_visibility(note) not in [ap.Visibility.PUBLIC, ap.Visibility.UNLISTED]: abort(400) announce = ap.Announce( actor=MY_PERSON.id, object=note.id, to=[MY_PERSON.followers, note.attributedTo], cc=[ap.AS_PUBLIC], published=now(), context=new_context(note), ) announce_id = post_to_outbox(announce) return _user_api_response(activity=announce_id)
def api_vote() -> _Response: oid = _user_api_arg("id") app.logger.info(f"fetching {oid}") note = ap.parse_activity(ap.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, ) raw_note["@context"] = config.DEFAULT_CTX note = ap.Note(**raw_note) create = note.build_create() create_id = post_to_outbox(create) return _user_api_response(activity=create_id)
def outbox(): if request.method == "GET": if not is_api_request(): abort(404) _log_sig() # TODO(tsileo): returns the whole outbox if authenticated and look at OCAP support q = { **in_outbox(), "$or": [ { **by_type(ActivityType.CREATE), **not_deleted(), **by_visibility(ap.Visibility.PUBLIC), }, { **by_type(ActivityType.ANNOUNCE), **not_undo() }, ], } return activitypubify(**activitypub.build_ordered_collection( DB.activities, q=q, cursor=request.args.get("cursor"), map_func=lambda doc: activity_from_doc(doc, embed=True), col_name="outbox", )) # Handle POST request aka C2S API try: _api_required() except BadSignature: abort(401) data = request.get_json(force=True) activity = ap.parse_activity(data) activity_id = post_to_outbox(activity) return Response(status=201, headers={"Location": activity_id})
def api_new_note() -> _Response: # Basic Micropub (https://www.w3.org/TR/micropub/) query configuration support if request.method == "GET" and request.args.get("q") == "config": return jsonify({}) elif request.method == "GET": abort(405) source = None summary = None place_tags = [] # Basic Micropub (https://www.w3.org/TR/micropub/) "create" support is_micropub = False # First, check if the Micropub specific fields are present if ( _user_api_arg("h", default=None) == "entry" or _user_api_arg("type", default=[None])[0] == "h-entry" ): is_micropub = True # Ensure the "create" scope is set if "jwt_payload" not in flask.g or "create" not in flask.g.jwt_payload["scope"]: abort(403) # Handle location sent via form-data # `geo:28.5,9.0,0.0` location = _user_api_arg("location", default="") if location.startswith("geo:"): slat, slng, *_ = location[4:].split(",") place_tags.append( { "type": ap.ActivityType.PLACE.value, "url": "", "name": "", "latitude": float(slat), "longitude": float(slng), } ) # Handle JSON microformats2 data if _user_api_arg("type", default=None): _logger.info(f"Micropub request: {request.json}") try: source = request.json["properties"]["content"][0] except (ValueError, KeyError): pass # Handle HTML if isinstance(source, dict): source = source.get("html") try: summary = request.json["properties"]["name"][0] except (ValueError, KeyError): pass # Try to parse the name as summary if the payload is POSTed using form-data if summary is None: summary = _user_api_arg("name", default=None) # This step will also parse content from Micropub request if source is None: source = _user_api_arg("content", default=None) if not source: raise ValueError("missing content") if summary is None: summary = _user_api_arg("summary", default="") if not place_tags: if _user_api_arg("location_lat", default=None): lat = float(_user_api_arg("location_lat")) lng = float(_user_api_arg("location_lng")) loc_name = _user_api_arg("location_name", default="") place_tags.append( { "type": ap.ActivityType.PLACE.value, "url": "", "name": loc_name, "latitude": lat, "longitude": lng, } ) # All the following fields are specific to the API (i.e. not Micropub related) _reply, reply = None, None try: _reply = _user_api_arg("reply") except ValueError: pass visibility = ap.Visibility[ _user_api_arg("visibility", default=ap.Visibility.PUBLIC.name) ] content, tags = parse_markdown(source) # Check for custom emojis tags = tags + emojis.tags(content) + place_tags to: List[str] = [] cc: List[str] = [] if visibility == ap.Visibility.PUBLIC: to = [ap.AS_PUBLIC] cc = [ID + "/followers"] elif visibility == ap.Visibility.UNLISTED: to = [ID + "/followers"] cc = [ap.AS_PUBLIC] elif visibility == ap.Visibility.FOLLOWERS_ONLY: to = [ID + "/followers"] cc = [] if _reply: reply = ap.fetch_remote_activity(_reply) if visibility == ap.Visibility.DIRECT: to.append(reply.attributedTo) else: cc.append(reply.attributedTo) context = new_context(reply) for tag in tags: if tag["type"] == "Mention": to.append(tag["href"]) raw_note = dict( attributedTo=MY_PERSON.id, cc=list(set(cc) - set([MY_PERSON.id])), to=list(set(to) - set([MY_PERSON.id])), summary=summary, content=content, tag=tags, source={"mediaType": "text/markdown", "content": source}, inReplyTo=reply.id if reply else None, context=context, ) if request.files: for f in request.files.keys(): if not request.files[f].filename: continue file = request.files[f] rfilename = secure_filename(file.filename) with BytesIO() as buf: file.save(buf) oid = MEDIA_CACHE.save_upload(buf, rfilename) mtype = mimetypes.guess_type(rfilename)[0] raw_note["attachment"] = [ { "mediaType": mtype, "name": _user_api_arg("file_description", default=rfilename), "type": "Document", "url": f"{BASE_URL}/uploads/{oid}/{rfilename}", } ] note = ap.Note(**raw_note) create = note.build_create() create_id = post_to_outbox(create) # Return a 201 with the note URL in the Location header if this was a Micropub request if is_micropub: resp = flask.Response("", headers={"Location": create_id}) resp.status_code = 201 return resp return _user_api_response(activity=create_id)
def api_new_note() -> _Response: source = _user_api_arg("content") if not source: raise ValueError("missing content") _reply, reply = None, None try: _reply = _user_api_arg("reply") except ValueError: pass visibility = ap.Visibility[_user_api_arg( "visibility", default=ap.Visibility.PUBLIC.name)] content, tags = parse_markdown(source) to: List[str] = [] cc: List[str] = [] if visibility == ap.Visibility.PUBLIC: to = [ap.AS_PUBLIC] cc = [ID + "/followers"] elif visibility == ap.Visibility.UNLISTED: to = [ID + "/followers"] cc = [ap.AS_PUBLIC] elif visibility == ap.Visibility.FOLLOWERS_ONLY: to = [ID + "/followers"] cc = [] if _reply: reply = ap.fetch_remote_activity(_reply) if visibility == ap.Visibility.DIRECT: to.append(reply.attributedTo) else: cc.append(reply.attributedTo) for tag in tags: if tag["type"] == "Mention": if visibility == ap.Visibility.DIRECT: to.append(tag["href"]) else: cc.append(tag["href"]) raw_note = dict( attributedTo=MY_PERSON.id, cc=list(set(cc)), to=list(set(to)), content=content, tag=tags, source={ "mediaType": "text/markdown", "content": source }, inReplyTo=reply.id if reply else None, ) if "file" in request.files and request.files["file"].filename: file = request.files["file"] rfilename = secure_filename(file.filename) with BytesIO() as buf: file.save(buf) oid = MEDIA_CACHE.save_upload(buf, rfilename) mtype = mimetypes.guess_type(rfilename)[0] raw_note["attachment"] = [{ "mediaType": mtype, "name": rfilename, "type": "Document", "url": f"{BASE_URL}/uploads/{oid}/{rfilename}", }] note = ap.Note(**raw_note) create = note.build_create() create_id = post_to_outbox(create) return _user_api_response(activity=create_id)