def migrate(self) -> None: actor_cache: Dict[str, Dict[str, Any]] = {} for data in DB.activities.find({"type": ap.ActivityType.FOLLOW.value}): try: if data["meta"]["actor_id"] == ID: # It's a "following" actor = actor_cache.get(data["meta"]["object_id"]) if not actor: actor = ap.parse_activity(ap.get_backend().fetch_iri( data["meta"]["object_id"], no_cache=True)).to_dict(embed=True) if not actor: raise ValueError(f"missing actor {data!r}") actor_cache[actor["id"]] = actor DB.activities.update_one({"_id": data["_id"]}, {"$set": { "meta.object": actor }}) else: # It's a "followers" actor = actor_cache.get(data["meta"]["actor_id"]) if not actor: actor = ap.parse_activity(ap.get_backend().fetch_iri( data["meta"]["actor_id"], no_cache=True)).to_dict(embed=True) if not actor: raise ValueError(f"missing actor {data!r}") actor_cache[actor["id"]] = actor DB.activities.update_one({"_id": data["_id"]}, {"$set": { "meta.actor": actor }}) except Exception: logger.exception(f"failed to process actor {data!r}")
def outbox_item_activity(item_id): """ Outbox activity --- tags: - ActivityPub responses: 200: description: Returns something """ be = activitypub.get_backend() if not be: abort(500) item = Activity.query.filter( Activity.box == Box.OUTBOX.value, Activity.url == be.activity_url(item_id)).first() if not item: abort(404) obj = activity_from_doc(item.payload) if item.meta_deleted: obj = activitypub.parse_activity(item.payload) # FIXME not sure about that /activity tomb = obj.get_tombstone().to_dict() tomb["id"] = tomb["id"] + "/activity" resp = jsonify(tomb) resp.status_code = 410 return resp if obj["type"] != activitypub.ActivityType.CREATE.value: abort(404) return jsonify(**obj["object"])
def admin_new() -> _Response: reply_id = None content = "" thread: List[Any] = [] print(request.args) if request.args.get("reply"): data = DB.activities.find_one( {"activity.object.id": request.args.get("reply")}) if data: reply = ap.parse_activity(data["activity"]) else: data = dict( meta={}, activity=dict(object=ap.get_backend().fetch_iri( request.args.get("reply"))), ) reply = ap.parse_activity(data["activity"]["object"]) reply_id = reply.id if reply.ACTIVITY_TYPE == ap.ActivityType.CREATE: reply_id = reply.get_object().id actor = reply.get_actor() domain = urlparse(actor.id).netloc # FIXME(tsileo): if reply of reply, fetch all participants content = f"@{actor.preferredUsername}@{domain} " thread = _build_thread(data) return render_template( "new.html", reply=reply_id, content=content, thread=thread, visibility=ap.Visibility, emojis=config.EMOJIS.split(" "), )
def user_followers(name): """ User followers --- tags: - ActivityPub responses: 200: description: Returns a collection of Actors """ be = activitypub.get_backend() if not be: abort(500) # data = request.get_json(force=True) # if not data: # abort(500) current_app.logger.debug(f"req_headers={request.headers}") # current_app.logger.debug(f"raw_data={data}") user = User.query.filter(User.name == name).first() if not user: abort(404) actor = user.actor[0] followers_list = actor.followers return jsonify(**build_ordered_collection(followers_list, actor.url, request.args.get("page")))
def outbox_item(item_id): be = activitypub.get_backend() if not be: abort(500) # data = request.get_json() # if not data: # abort(500) current_app.logger.debug(f"req_headers={request.headers}") # current_app.logger.debug(f"raw_data={data}") current_app.logger.debug(f"activity url {be.activity_url(item_id)}") item = Activity.query.filter(Activity.box == Box.OUTBOX.value, Activity.url == be.activity_url(item_id)).first() if not item: abort(404) if item.meta_deleted: obj = activitypub.parse_activity(item.payload) resp = jsonify(**obj.get_tombstone().to_dict()) resp.status_code = 410 return resp current_app.logger.debug(f"item payload=={item.payload}") return jsonify(**activity_from_doc(item.payload))
def user_inbox(name): be = activitypub.get_backend() if not be: abort(500) data = request.get_json(force=True) if not data: abort(500) current_app.logger.debug(f"req_headers={request.headers}") current_app.logger.debug(f"raw_data={data}") try: if not verify_request(request.method, request.path, request.headers, request.data): raise Exception("failed to verify request") except Exception: current_app.logger.exception("failed to verify request") try: data = be.fetch_iri(data["id"]) except Exception: current_app.logger.exception(f"failed to fetch remote id " f"at {data['id']}") resp = {"error": "failed to verify request " "(using HTTP signatures or fetching the IRI)"} response = jsonify(resp) response.mimetype = "application/json; charset=utf-8" response.status = 422 return response activity = activitypub.parse_activity(data) current_app.logger.debug(f"inbox_activity={activity}/{data}") post_to_inbox(activity) return Response(status=201)
def admin_new() -> _Response: reply_id = None content = "" thread: List[Any] = [] print(request.args) default_visibility = None # ap.Visibility.PUBLIC if request.args.get("reply"): data = DB.activities.find_one( {"activity.object.id": request.args.get("reply")}) if data: reply = ap.parse_activity(data["activity"]) else: obj = ap.get_backend().fetch_iri(request.args.get("reply")) data = dict(meta=_meta(ap.parse_activity(obj)), activity=dict(object=obj)) data["_id"] = obj["id"] data["remote_id"] = obj["id"] reply = ap.parse_activity(data["activity"]["object"]) # Fetch the post visibility, in case it's follower only default_visibility = ap.get_visibility(reply) # If it's public, we default the reply to unlisted if default_visibility == ap.Visibility.PUBLIC: default_visibility = ap.Visibility.UNLISTED reply_id = reply.id if reply.ACTIVITY_TYPE == ap.ActivityType.CREATE: reply_id = reply.get_object().id actor = reply.get_actor() domain = urlparse(actor.id).netloc # FIXME(tsileo): if reply of reply, fetch all participants content = f"@{actor.preferredUsername}@{domain} " if reply.has_type(ap.ActivityType.CREATE): reply = reply.get_object() for mention in reply.get_mentions(): if mention.href in [actor.id, ID]: continue m = ap.fetch_remote_activity(mention.href) if m.has_type(ap.ACTOR_TYPES): d = urlparse(m.id).netloc content += f"@{m.preferredUsername}@{d} " thread = _build_thread(data) return htmlify( render_template( "new.html", reply=reply_id, content=content, thread=thread, default_visibility=default_visibility, visibility=ap.Visibility, emojis=config.EMOJIS.split(" "), custom_emojis=sorted( [ap.Emoji(**dat) for name, dat in EMOJIS_BY_NAME.items()], key=lambda e: e.name, ), ))
def task_fetch_remote_question() -> _Response: """Fetch a remote question for implementation that does not send Update.""" task = p.parse(flask.request) app.logger.info(f"task={task!r}") iri = task.payload try: app.logger.info(f"Fetching remote question {iri}") local_question = DB.activities.find_one( { "box": Box.INBOX.value, "type": ap.ActivityType.CREATE.value, "activity.object.id": iri, } ) remote_question = ap.get_backend().fetch_iri(iri, no_cache=True) # FIXME(tsileo): compute and set `meta.object_visiblity` (also update utils.py to do it) if ( local_question and ( local_question["meta"].get("voted_for") or local_question["meta"].get("subscribed") ) and not DB.notifications.find_one({"activity.id": remote_question["id"]}) ): DB.notifications.insert_one( { "type": "question_ended", "datetime": datetime.now(timezone.utc).isoformat(), "activity": remote_question, } ) # Update the Create if we received it in the inbox if local_question: DB.activities.update_one( {"remote_id": local_question["remote_id"], "box": Box.INBOX.value}, {"$set": {"activity.object": remote_question}}, ) # Also update all the cached copies (Like, Announce...) DB.activities.update_many( {"meta.object.id": remote_question["id"]}, {"$set": {"meta.object": remote_question}}, ) 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 _user_api_get_note(from_outbox: bool = False) -> ap.BaseActivity: oid = _user_api_arg("id") app.logger.info(f"fetching {oid}") note = ap.parse_activity(ap.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") return note
def user_outbox(name): be = activitypub.get_backend() if not be: abort(500) data = request.get_json(force=True) if not data: abort(500) current_app.logger.debug(f"req_headers={request.headers}") current_app.logger.debug(f"raw_data={data}")
def get_actor(url): if not url: return None if isinstance(url, list): url = url[0] if isinstance(url, dict): url = url.get("id") try: return ap.get_backend().fetch_iri(url) except (ActivityNotFoundError, ActivityGoneError): return f"Deleted<{url}>" except Exception as exc: return f"Error<{url}/{exc!r}>"
def post_to_remote_inbox(self, payload: str, to: str) -> None: if not current_app.config["AP_ENABLED"]: return # not federating if not enabled current_app.logger.debug(f"post_to_remote_inbox {payload}") ap_actor = json.loads(payload)["actor"] actor = Actor.query.filter(Actor.url == ap_actor).first() if not actor: current_app.logger.exception("no actor found") return key = Key(owner=actor.url) key.load(actor.private_key) signature_auth = HTTPSigAuth(key) # current_app.logger.debug(f"key=={key.__dict__}") try: current_app.logger.info("payload=%s", payload) current_app.logger.info("generating sig") signed_payload = json.loads(payload) backend = ap.get_backend() # Don't overwrite the signature if we're forwarding an activity if "signature" not in signed_payload: generate_signature(signed_payload, key) current_app.logger.info("to=%s", to) resp = requests.post( to, data=json.dumps(signed_payload), auth=signature_auth, headers={ "Content-Type": HEADERS[1], "Accept": HEADERS[1], "User-Agent": backend.user_agent() }, ) current_app.logger.info("resp=%s", resp) current_app.logger.info("resp_body=%s", resp.text) resp.raise_for_status() except HTTPError as err: current_app.logger.exception("request failed") if 400 >= err.response.status_code >= 499: current_app.logger.info("client error, no retry") return
def post_to_inbox(activity: ap.BaseActivity) -> None: # actor = activity.get_actor() backend = ap.get_backend() # TODO: drop if emitter is blocked # backend.outbox_is_blocked(target actor, actor.id) # TODO: drop if duplicate # backend.inbox_check_duplicate(actor, activity.id) backend.save(Box.INBOX, activity) process_new_activity.delay(activity.id) finish_inbox_processing.delay(activity.id)
def post_to_outbox(activity: ap.BaseActivity) -> str: current_app.logger.debug(f"post_to_outbox {activity}") if activity.has_type(ap.CREATE_TYPES): activity = activity.build_create() backend = ap.get_backend() # Assign a random ID obj_id = backend.random_object_id() activity.set_id(backend.activity_url(obj_id), obj_id) backend.save(Box.OUTBOX, activity) finish_post_to_outbox.delay(activity.id) return activity.id
def build_inbox_json_feed(path: str, request_cursor: Optional[str] = None ) -> Dict[str, Any]: """Build a JSON feed from the inbox activities.""" data = [] cursor = None q: Dict[str, Any] = { "type": "Create", "meta.deleted": False, "box": Box.INBOX.value, } if request_cursor: q["_id"] = {"$lt": request_cursor} for item in DB.activities.find(q, limit=50).sort("_id", -1): actor = ap.get_backend().fetch_iri(item["activity"]["actor"]) data.append({ "id": item["activity"]["id"], "url": item["activity"]["object"].get("url"), "content_html": item["activity"]["object"]["content"], "content_text": html2text(item["activity"]["object"]["content"]), "date_published": item["activity"]["object"].get("published"), "author": { "name": actor.get("name", actor.get("preferredUsername")), "url": actor.get("url"), "avatar": actor.get("icon", {}).get("url"), }, }) cursor = str(item["_id"]) resp = { "version": "https://jsonfeed.org/version/1", "title": f"{USERNAME}'s stream", "home_page_url": ID, "feed_url": ID + path, "items": data, } if cursor and len(data) == 50: resp["next_url"] = ID + path + "?cursor=" + cursor return resp
def lookup(url: str) -> ap.BaseActivity: """Try to find an AP object related to the given URL.""" try: if url.startswith("@"): actor_url = get_actor_url(url) if actor_url: return ap.fetch_remote_activity(actor_url) except NotAnActivityError: pass except requests.HTTPError: # Some websites may returns 404, 503 or others when they don't support webfinger, and we're just taking a guess # when performing the lookup. pass except requests.RequestException as err: raise RemoteServerUnavailableError(f"failed to fetch {url}: {err!r}") backend = ap.get_backend() try: resp = requests.head( url, timeout=10, allow_redirects=True, headers={"User-Agent": backend.user_agent()}, ) except requests.RequestException as err: raise RemoteServerUnavailableError(f"failed to GET {url}: {err!r}") try: resp.raise_for_status() except Exception: return ap.fetch_remote_activity(url) # If the page is HTML, maybe it contains an alternate link pointing to an AP object for alternate in mf2py.parse(resp.text).get("alternates", []): if alternate.get("type") == "application/activity+json": return ap.fetch_remote_activity(alternate["url"]) try: # Maybe the page was JSON-LD? data = resp.json() return ap.parse_activity(data) except Exception: pass # Try content negotiation (retry with the AP Accept header) return ap.fetch_remote_activity(url)
def forward_activity(self, iri: str) -> None: if not current_app.config["AP_ENABLED"]: return # not federating if not enabled try: activity = ap.fetch_remote_activity(iri) backend = ap.get_backend() recipients = backend.followers_as_recipients() current_app.logger.debug(f"Forwarding {activity!r} to {recipients}") activity = ap.clean_activity(activity.to_dict()) for recp in recipients: current_app.logger.debug(f"forwarding {activity!r} to {recp}") payload = json.dumps(activity) post_to_remote_inbox.delay(payload, recp) except Exception as err: # noqa: F841 current_app.logger.exception(f"failed to cache attachments for {iri}")
def finish_inbox_processing(self, iri: str) -> None: try: backend = ap.get_backend() activity = ap.fetch_remote_activity(iri) current_app.logger.info(f"activity={activity!r}") actor = activity.get_actor() id = activity.get_object_id() current_app.logger.debug(f"finish_inbox_processing actor {actor}") if activity.has_type(ap.ActivityType.DELETE): backend.inbox_delete(actor, activity) elif activity.has_type(ap.ActivityType.UPDATE): backend.inbox_update(actor, activity) elif activity.has_type(ap.ActivityType.CREATE): backend.inbox_create(actor, activity) elif activity.has_type(ap.ActivityType.ANNOUNCE): backend.inbox_announce(actor, activity) elif activity.has_type(ap.ActivityType.LIKE): backend.inbox_like(actor, activity) elif activity.has_type(ap.ActivityType.FOLLOW): # Reply to a Follow with an Accept accept = ap.Accept(actor=id, object=activity.to_dict(embed=True)) post_to_outbox(accept) backend.new_follower(activity, activity.get_actor(), activity.get_object()) elif activity.has_type(ap.ActivityType.ACCEPT): obj = activity.get_object() # FIXME: probably other types to ACCEPT the Activity if obj.has_type(ap.ActivityType.FOLLOW): # Accept new follower backend.new_following(activity, obj) elif activity.has_type(ap.ActivityType.UNDO): obj = activity.get_object() if obj.has_type(ap.ActivityType.LIKE): backend.inbox_undo_like(actor, obj) elif obj.has_type(ap.ActivityType.ANNOUNCE): backend.inbox_undo_announce(actor, obj) elif obj.has_type(ap.ActivityType.FOLLOW): backend.undo_new_follower(actor, obj) except (ActivityGoneError, ActivityNotFoundError, NotAnActivityError): current_app.logger.exception(f"no retry") except Exception as err: # noqa: F841 current_app.logger.exception(f"failed to cache attachments for" f" {iri}")
def user_followings(name): be = activitypub.get_backend() if not be: abort(500) # data = request.get_json(force=True) # if not data: # abort(500) current_app.logger.debug(f"req_headers={request.headers}") # current_app.logger.debug(f"raw_data={data}") user = User.query.filter(User.name == name).first() if not user: abort(404) actor = user.actor[0] followings_list = actor.followings return jsonify(**build_ordered_collection(followings_list, actor.url, request.args.get("page"), switch_side=True))
def outbox(): """ Global outbox --- tags: - ActivityPub responses: 200: description: Returns something """ be = activitypub.get_backend() if not be: abort(500) data = request.get_json(force=True) if not data: abort(500) current_app.logger.debug(f"req_headers={request.headers}") current_app.logger.debug(f"raw_data={data}")
def finish_post_to_outbox(self, iri: str) -> None: try: activity = ap.fetch_remote_activity(iri) backend = ap.get_backend() current_app.logger.info(f"finish_post_to_outbox {activity}") recipients = activity.recipients() actor = activity.get_actor() current_app.logger.debug(f"finish_post_to_outbox actor {actor!r}") if activity.has_type(ap.ActivityType.DELETE): backend.outbox_delete(actor, activity) elif activity.has_type(ap.ActivityType.UPDATE): backend.outbox_update(actor, activity) elif activity.has_type(ap.ActivityType.CREATE): backend.outbox_create(actor, activity) elif activity.has_type(ap.ActivityType.ANNOUNCE): backend.outbox_announce(actor, activity) elif activity.has_type(ap.ActivityType.LIKE): backend.outbox_like(actor, activity) elif activity.has_type(ap.ActivityType.UNDO): obj = activity.get_object() if obj.has_type(ap.ActivityType.LIKE): backend.outbox_undo_like(actor, obj) elif obj.has_type(ap.ActivityType.ANNOUNCE): backend.outbox_undo_announce(actor, obj) elif obj.has_type(ap.ActivityType.FOLLOW): backend.undo_new_following(actor, obj) current_app.logger.info(f"recipients={recipients}") activity = ap.clean_activity(activity.to_dict()) payload = json.dumps(activity) for recp in recipients: current_app.logger.debug(f"posting to {recp}") post_to_remote_inbox.delay(payload, recp) except (ActivityGoneError, ActivityNotFoundError): current_app.logger.exception(f"no retry") except Exception as err: # noqa: F841 current_app.logger.exception(f"failed to post " f"to remote inbox for {iri}")
def post_to_outbox(activity: ap.BaseActivity) -> str: current_app.logger.debug(f"post_to_outbox {activity!r}") if activity.has_type(ap.CREATE_TYPES): print("BUILD CREATE POST TO OUTBOX") activity = activity.build_create() backend = ap.get_backend() # Assign a random ID obj_id = backend.random_object_id() activity.set_id(backend.activity_url(obj_id), obj_id) backend.save(Box.OUTBOX, activity) # Broadcast only if AP is enabled if current_app.config["AP_ENABLED"]: finish_post_to_outbox.delay(activity.id) return activity.id
def _get_nodeinfo_url(server: str) -> Optional[str]: backend = ap.get_backend() for scheme in {"https", "http"}: try: resp = requests.get( f"{scheme}://{server}/.well-known/nodeinfo", timeout=10, allow_redirects=True, headers={"User-Agent": backend.user_agent()}, ) resp.raise_for_status() data = resp.json() for link in data.get("links", []): return link["href"] except requests.HTTPError: return None except requests.RequestException: continue return None
def _create_set_inbox_flags(activity: ap.Create, new_meta: _NewMeta) -> None: _logger.info(f"set_inbox_flags activity={activity!r}") obj = activity.get_object() _set_flag(new_meta, MetaKey.POLL_ANSWER, False) in_reply_to = obj.get_in_reply_to() # Check if it's a local reply if in_reply_to and is_local_url(in_reply_to): # TODO(tsileo): fetch the reply to check for poll answers more precisely # reply_of = ap.fetch_remote_activity(in_reply_to) # Ensure it's not a poll answer if obj.name and not obj.content: _set_flag(new_meta, MetaKey.POLL_ANSWER) return None # Flag it as a notification _flag_as_notification(activity, new_meta) # Also set the "keep mark" for the GC (as we want to keep it forever) _set_flag(new_meta, MetaKey.GC_KEEP) return None # Check for mention for mention in obj.get_mentions(): if mention.href and is_local_url(mention.href): # Flag it as a notification _flag_as_notification(activity, new_meta) # Also set the "keep mark" for the GC (as we want to keep it forever) _set_flag(new_meta, MetaKey.GC_KEEP) if not in_reply_to or (REPLIES_IN_STREAM and obj.get_actor().id in ap.get_backend().following()): # A good candidate for displaying in the stream _set_flag(new_meta, MetaKey.STREAM) return None
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 get_software_name(server: str) -> str: backend = ap.get_backend() nodeinfo_endpoint = _get_nodeinfo_url(server) if nodeinfo_endpoint: try: resp = requests.get( nodeinfo_endpoint, timeout=10, headers={"User-Agent": backend.user_agent()}, ) resp.raise_for_status() software_name = resp.json().get("software", {}).get("name") if software_name: return software_name return SoftwareName.UNKNOWN.value except requests.RequestException: return SoftwareName.UNKNOWN.value if _try_mastodon_api(server): return SoftwareName.MASTODON.value return SoftwareName.UNKNOWN.value
def search(): s = request.args.get("what") pcfg = {"title": gettext("Search user")} results = {"accounts": [], "sounds": [], "mode": None, "from": None} if current_user.is_authenticated: results["from"] = current_user.name # Search for sounds # TODO: Implement FTS to get sounds search # Search for accounts accounts = [] is_user_at_account = RE_ACCOUNT.match(s) if s.startswith("https://"): results["mode"] = "uri" if current_user.is_authenticated: users = ( db.session.query(Actor, Follower) .outerjoin( Follower, and_(Actor.id == Follower.target_id, Follower.actor_id == current_user.actor[0].id) ) .filter(Actor.url == s) .filter(not_(Actor.id == current_user.actor[0].id)) .all() ) else: users = db.session.query(Actor).filter(Actor.url == s).all() elif is_user_at_account: results["mode"] = "acct" user = is_user_at_account.group("user") instance = is_user_at_account.group("instance") if current_user.is_authenticated: users = ( db.session.query(Actor, Follower) .outerjoin( Follower, and_(Actor.id == Follower.target_id, Follower.actor_id == current_user.actor[0].id) ) .filter(Actor.preferred_username == user, Actor.domain == instance) .filter(not_(Actor.id == current_user.actor[0].id)) .all() ) else: users = db.session.query(Actor).filter(Actor.preferred_username == user, Actor.domain == instance).all() else: results["mode"] = "username" # Match actor username in database if current_user.is_authenticated: users = ( db.session.query(Actor, Follower) .outerjoin( Follower, and_(Actor.id == Follower.target_id, Follower.actor_id == current_user.actor[0].id) ) .filter(or_(Actor.preferred_username.contains(s), Actor.name.contains(s))) .filter(not_(Actor.id == current_user.actor[0].id)) .all() ) else: users = ( db.session.query(Actor).filter(or_(Actor.preferred_username.contains(s), Actor.name.contains(s))).all() ) # Handle the results if len(users) > 0: for user in users: if current_user.is_authenticated: if user[1]: follows = user[1].actor_id == current_user.actor[0].id else: follows = False else: follows = None if type(user) is Actor: # Unauthenticated results accounts.append( { "username": user.name, "name": user.preferred_username, "summary": user.summary, "instance": user.domain, "url": user.url, "remote": not user.is_local(), "follow": follows, } ) else: accounts.append( { "username": user[0].name, "name": user[0].preferred_username, "summary": user[0].summary, "instance": user[0].domain, "url": user[0].url, "remote": not user[0].is_local(), "follow": follows, } ) if len(accounts) <= 0: # Do a webfinger current_app.logger.debug(f"webfinger for {s}") try: remote_actor_url = get_actor_url(s, debug=current_app.debug) # We need to get the remote Actor backend = ap.get_backend() iri = backend.fetch_iri(remote_actor_url) if iri: current_app.logger.debug(f"got remote actor URL {remote_actor_url}") # Fixme handle unauthenticated users plus duplicates follows follow_rel = ( db.session.query(Actor.id, Follower.id) .outerjoin(Follower, Actor.id == Follower.target_id) .filter(Actor.url == remote_actor_url) .first() ) if follow_rel: follow_status = follow_rel[1] is not None else: follow_status = False domain = urlparse(iri["url"]) user = { "username": iri["name"], "name": iri["preferredUsername"], "instance": domain.netloc, "url": iri["url"], "remote": True, "summary": iri["summary"], "follow": follow_status, } accounts.append(user) results["mode"] = "webfinger" # Use iri to populate results["accounts"] except (InvalidURLError, ValueError): current_app.logger.exception(f"Invalid webfinger URL: {s}") # Finally fill the results dict results["accounts"] = accounts return render_template("search/results.jinja2", pcfg=pcfg, who=s, results=results)
def inbox(): # GET /inbox if request.method == "GET": if not is_api_request(): abort(404) try: _api_required() except BadSignature: abort(404) return activitypubify(**activitypub.build_ordered_collection( DB.activities, q={ "meta.deleted": False, "box": Box.INBOX.value }, cursor=request.args.get("cursor"), map_func=lambda doc: remove_context(doc["activity"]), col_name="inbox", )) # POST/ inbox try: data = request.get_json(force=True) if not isinstance(data, dict): raise ValueError("not a dict") except Exception: return Response( status=422, headers={"Content-Type": "application/json"}, response=json.dumps({ "error": "failed to decode request body as JSON", "request_id": g.request_id, }), ) # Check the blacklist now to see if we can return super early if is_blacklisted(data): logger.info(f"dropping activity from blacklisted host: {data['id']}") return Response(status=201) logger.info(f"request_id={g.request_id} req_headers={request.headers!r}") logger.info(f"request_id={g.request_id} raw_data={data}") try: req_verified, actor_id = verify_request(request.method, request.path, request.headers, request.data) if not req_verified: raise Exception("failed to verify request") logger.info(f"request_id={g.request_id} signed by {actor_id}") except Exception: logger.exception( f"failed to verify request {g.request_id}, trying to verify the payload by fetching the remote" ) try: remote_data = get_backend().fetch_iri(data["id"]) except ActivityGoneError: # XXX Mastodon sends Delete activities that are not dereferencable, it's the actor url with #delete # appended, so an `ActivityGoneError` kind of ensure it's "legit" if data["type"] == ActivityType.DELETE.value and data[ "id"].startswith(data["object"]): # If we're here, this means the key is not saved, so we cannot verify the object logger.info( f"received a Delete for an unknown actor {data!r}, drop it" ) return Response(status=201) except Exception: logger.exception(f"failed to fetch remote for payload {data!r}") if "type" in data: # Friendica does not returns a 410, but a 302 that redirect to an HTML page if ap._has_type(data["type"], ActivityType.DELETE): logger.info( f"received a Delete for an unknown actor {data!r}, drop it" ) return Response(status=201) if "id" in data: if DB.trash.find_one({"activity.id": data["id"]}): # It's already stored in trash, returns early return Response( status=422, headers={"Content-Type": "application/json"}, response=json.dumps({ "error": "failed to verify request (using HTTP signatures or fetching the IRI)", "request_id": g.request_id, }), ) # Now we can store this activity in the trash for later analysis # Track/store the payload for analysis ip, geoip = _get_ip() DB.trash.insert({ "activity": data, "meta": { "ts": datetime.now().timestamp(), "ip_address": ip, "geoip": geoip, "tb": traceback.format_exc(), "headers": dict(request.headers), "request_id": g.request_id, }, }) return Response( status=422, headers={"Content-Type": "application/json"}, response=json.dumps({ "error": "failed to verify request (using HTTP signatures or fetching the IRI)", "request_id": g.request_id, }), ) # We fetched the remote data successfully data = remote_data try: activity = ap.parse_activity(data) except ValueError: logger.exception( "failed to parse activity for req {g.request_id}: {data!r}") # Track/store the payload for analysis ip, geoip = _get_ip() DB.trash.insert({ "activity": data, "meta": { "ts": datetime.now().timestamp(), "ip_address": ip, "geoip": geoip, "tb": traceback.format_exc(), "headers": dict(request.headers), "request_id": g.request_id, }, }) return Response(status=201) logger.debug(f"inbox activity={g.request_id}/{activity}/{data}") post_to_inbox(activity) return Response(status=201)
def unfollow(): user = request.args.get("user") actor_me = current_user.actor[0] if user.startswith("https://"): actor = Actor.query.filter(Actor.url == user).first() local_user = actor.user else: local_user = User.query.filter(User.name == user).first() if local_user: # Process local unfollow actor_me.unfollow(local_user.actor[0]) flash(gettext("Unfollow successful"), "success") else: # Might be a remote unfollow # 1. Webfinger the user try: remote_actor_url = get_actor_url(user, debug=current_app.debug) except InvalidURLError: current_app.logger.exception(f"Invalid webfinger URL: {user}") remote_actor_url = None except requests.exceptions.HTTPError: current_app.logger.exception(f"Invali webfinger URL: {user}") remote_actor_url = None if not remote_actor_url: flash(gettext("User not found"), "error") return redirect(url_for("bp_users.profile", name=current_user.name)) # 2. Check if we have a local user actor_target = Actor.query.filter( Actor.url == remote_actor_url).first() if not actor_target: # 2.5 Fetch and save remote actor backend = ap.get_backend() iri = backend.fetch_iri(remote_actor_url) if not iri: flash(gettext("User not found"), "error") return redirect(url_for("bp_main.home")) act = ap.parse_activity(iri) actor_target, user_target = create_remote_actor(act) db.session.add(user_target) db.session.add(actor_target) # 2.5 Get the relation of the follow follow_relation = Follower.query.filter( Follower.actor_id == actor_me.id, Follower.target_id == actor_target.id).first() if not follow_relation: flash(gettext("You don't follow this user"), "error") return redirect(url_for("bp_users.profile", name=current_user.name)) # 3. Fetch the Activity of the Follow accept_activity = Activity.query.filter( Activity.url == follow_relation.activity_url).first() if not accept_activity: current_app.logger.error( f"cannot find accept activity {follow_relation.activity_url}") flash(gettext("Whoops, something went wrong")) return redirect(url_for("bp_users.profile", name=current_user.name)) # Then the Activity ID of the Accept will be the object id activity = ap.parse_activity(payload=accept_activity.payload) # Get the final activity (the Follow one) follow_activity = Activity.query.filter( Activity.url == activity.get_object_id()).first() if not follow_activity: current_app.logger.error( f"cannot find follow activity {activity.get_object_id()}") flash(gettext("Whoops, something went wrong")) return redirect(url_for("bp_users.profile", name=current_user.name)) ap_follow_activity = ap.parse_activity(payload=follow_activity.payload) # 4. Initiate a Follow request from actor_me to actor_target unfollow = ap_follow_activity.build_undo() post_to_outbox(unfollow) flash(gettext("Unfollow request have been transmitted"), "success") return redirect(url_for("bp_users.profile", name=current_user.name))
def search(): """ Search. --- tags: - Global parameters: - name: q in: query type: string required: true description: search string responses: 200: description: fixme. """ # Get logged in user from bearer token, or None if not logged in if current_token: current_user = current_token.user else: current_user = None s = request.args.get("q", None) if not s: return jsonify({"error": "No search string provided"}), 400 # This is the old search endpoint and needs to be improved # Especially tracks and accounts needs to be returned in the right format, with the data helpers # Users should be searched from known Actors or fetched # URI should be searched from known activities or fetched # FTS, well, FTS needs to be implemented results = {"accounts": [], "sounds": [], "mode": None, "from": None} if current_user: results["from"] = current_user.name # Search for sounds # TODO: Implement FTS to get sounds search sounds = [] # Search for accounts accounts = [] is_user_at_account = RE_ACCOUNT.match(s) if s.startswith("https://"): # Try to match the URI from Activities in database results["mode"] = "uri" users = Actor.query.filter(Actor.meta_deleted.is_(False), Actor.url == s).all() elif is_user_at_account: # It matches [email protected], try to match it locally results["mode"] = "acct" user = is_user_at_account.group("user") instance = is_user_at_account.group("instance") users = Actor.query.filter(Actor.meta_deleted.is_(False), Actor.preferred_username == user, Actor.domain == instance).all() else: # It's a FTS search results["mode"] = "username" # Match actor username in database if current_user: users = (db.session.query(Actor, Follower).outerjoin( Follower, and_(Actor.id == Follower.target_id, Follower.actor_id == current_user.actor[0].id)).filter( or_(Actor.preferred_username.contains(s), Actor.name.contains(s))).filter( not_(Actor.id == current_user.actor[0].id)).all()) else: users = (db.session.query(Actor).filter( or_(Actor.preferred_username.contains(s), Actor.name.contains(s))).all()) # Handle the found users if len(users) > 0: for actor in users: relationship = False if current_user: relationship = to_json_relationship(current_user, actor.user) accounts.append(to_json_account(actor.user, relationship)) if len(accounts) <= 0: # Do a webfinger # TODO FIXME: We should do this only if https:// or user@account submitted # And rework it slightly differently since we needs to backend.fetch_iri() for https:// who # can match a Sound and not only an Actor current_app.logger.debug(f"webfinger for {s}") try: remote_actor_url = get_actor_url(s, debug=current_app.debug) # We need to get the remote Actor backend = ap.get_backend() iri = backend.fetch_iri(remote_actor_url) if iri: # We have fetched an unknown Actor # Save it in database and return it properly current_app.logger.debug( f"got remote actor URL {remote_actor_url}") act = ap.parse_activity(iri) fetched_actor, fetched_user = create_remote_actor(act) db.session.add(fetched_user) db.session.add(fetched_actor) db.session.commit() relationship = False if current_user: relationship = to_json_relationship( current_user, fetched_user) accounts.append(to_json_account(fetched_user, relationship)) results["mode"] = "webfinger" except (InvalidURLError, ValueError): current_app.logger.exception(f"Invalid AP URL: {s}") # Then test fetching as a "normal" Activity ? # Finally fill the results dict results["accounts"] = accounts # FIXME: handle exceptions if results["mode"] == "uri" and len(sounds) <= 0: backend = ap.get_backend() iri = backend.fetch_iri(s) if iri: # FIXME: Is INBOX the right choice here ? backend.save(Box.INBOX, iri) # Fetch again, but get it from database activity = Activity.query.filter(Activity.url == iri).first() if not activity: current_app.logger.exception("WTF Activity is not saved") else: from tasks import create_sound_for_remote_track, upload_workflow sound_id = create_sound_for_remote_track(activity) sound = Sound.query.filter(Sound.id == sound_id).one() upload_workflow.delay(sound.id) relationship = False if current_user: relationship = to_json_relationship(current_user, sound.user) acct = to_json_account(sound.user, relationship) sounds.append(to_json_track(sound, acct)) return jsonify({"who": s, "results": results})