diff --git a/app.py b/app.py index 7fbc64f..da11194 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ from bp import ( register_actors_bp, register_identity_bp, register_auth_bp, + register_social_bp, ) @@ -53,6 +54,7 @@ def create_app() -> "Quart": app.register_blueprint(register_actors_bp()) app.register_blueprint(register_identity_bp()) app.register_blueprint(register_auth_bp()) + app.register_blueprint(register_social_bp()) # --- home page --- @app.get("/") diff --git a/bp/__init__.py b/bp/__init__.py index 9500840..d25f3b4 100644 --- a/bp/__init__.py +++ b/bp/__init__.py @@ -2,3 +2,4 @@ from .wellknown.routes import register as register_wellknown_bp from .actors.routes import register as register_actors_bp from .identity.routes import register as register_identity_bp from .auth.routes import register as register_auth_bp +from .social.routes import register as register_social_bp diff --git a/bp/actors/routes.py b/bp/actors/routes.py index 0d2f692..403c134 100644 --- a/bp/actors/routes.py +++ b/bp/actors/routes.py @@ -301,8 +301,18 @@ def register(url_prefix="/users"): await _handle_follow(actor_row, body, from_actor_url) elif activity_type == "Undo": await _handle_undo(actor_row, body, from_actor_url) - elif activity_type in ("Like", "Announce"): - log.info("Received %s from %s (noted)", activity_type, from_actor_url) + elif activity_type == "Accept": + await _handle_accept(actor_row, body, from_actor_url) + elif activity_type == "Create": + await _handle_create(actor_row, body, from_actor_url) + elif activity_type == "Update": + await _handle_update(actor_row, body, from_actor_url) + elif activity_type == "Delete": + await _handle_delete(actor_row, body, from_actor_url) + elif activity_type == "Like": + await _handle_like(actor_row, body, from_actor_url) + elif activity_type == "Announce": + await _handle_announce(actor_row, body, from_actor_url) # Mark as processed item.state = "processed" @@ -351,6 +361,29 @@ def register(url_prefix="/users"): follower_acct, actor_row.preferred_username, ) + # Notification + from shared.models.federation import APNotification, RemoteActor + ra = ( + await g.s.execute( + select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) + ) + ).scalar_one_or_none() + if not ra: + # Store this remote actor + ra_dto = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url) + if ra_dto: + ra = (await g.s.execute( + select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) + )).scalar_one_or_none() + + if ra: + notif = APNotification( + actor_profile_id=actor_row.id, + notification_type="follow", + from_remote_actor_id=ra.id, + ) + g.s.add(notif) + # Send Accept await _send_accept(actor_row, body, follower_inbox) @@ -388,6 +421,246 @@ def register(url_prefix="/users"): else: log.debug("Undo for %s — not handled", inner_type) + async def _handle_accept( + actor_row: ActorProfile, + body: dict, + from_actor_url: str, + ) -> None: + """Process Accept activity — update outbound follow state.""" + inner = body.get("object") + if not inner: + return + + inner_type = inner.get("type") if isinstance(inner, dict) else None + if inner_type == "Follow": + await services.federation.accept_follow_response( + g.s, actor_row.preferred_username, from_actor_url, + ) + log.info("Follow accepted by %s for @%s", from_actor_url, actor_row.preferred_username) + + async def _handle_create( + actor_row: ActorProfile, + body: dict, + from_actor_url: str, + ) -> None: + """Process Create(Note/Article) — ingest remote post.""" + obj = body.get("object") + if not obj or not isinstance(obj, dict): + return + + obj_type = obj.get("type", "") + if obj_type not in ("Note", "Article"): + log.debug("Create with type %s — skipping", obj_type) + return + + # Get or create remote actor + remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url) + if not remote: + log.warning("Could not resolve remote actor for Create: %s", from_actor_url) + return + + await services.federation.ingest_remote_post(g.s, remote.id, body, obj) + log.info("Ingested %s from %s", obj_type, from_actor_url) + + # Create notification if mentions a local actor + from shared.models.federation import APNotification, APRemotePost, RemoteActor + tags = obj.get("tag", []) + if isinstance(tags, list): + domain = _domain() + for tag in tags: + if not isinstance(tag, dict): + continue + if tag.get("type") != "Mention": + continue + href = tag.get("href", "") + if f"https://{domain}/users/" in href: + mentioned_username = href.rsplit("/", 1)[-1] + mentioned = await services.federation.get_actor_by_username( + g.s, mentioned_username, + ) + if mentioned: + # Find the remote post we just created + rp = (await g.s.execute( + select(APRemotePost).where( + APRemotePost.object_id == obj.get("id") + ) + )).scalar_one_or_none() + + ra = (await g.s.execute( + select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) + )).scalar_one_or_none() + + notif = APNotification( + actor_profile_id=mentioned.id, + notification_type="mention", + from_remote_actor_id=ra.id if ra else None, + target_remote_post_id=rp.id if rp else None, + ) + g.s.add(notif) + + # Also check if it's a reply to one of our posts + in_reply_to = obj.get("inReplyTo") + if in_reply_to and f"https://{domain}/users/" in str(in_reply_to): + # It's a reply to one of our local posts + from shared.models.federation import APActivity + local_activity = (await g.s.execute( + select(APActivity).where( + APActivity.activity_id == in_reply_to, + ) + )).scalar_one_or_none() + if local_activity: + ra = (await g.s.execute( + select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) + )).scalar_one_or_none() + rp = (await g.s.execute( + select(APRemotePost).where( + APRemotePost.object_id == obj.get("id") + ) + )).scalar_one_or_none() + + notif = APNotification( + actor_profile_id=local_activity.actor_profile_id, + notification_type="reply", + from_remote_actor_id=ra.id if ra else None, + target_remote_post_id=rp.id if rp else None, + ) + g.s.add(notif) + + async def _handle_update( + actor_row: ActorProfile, + body: dict, + from_actor_url: str, + ) -> None: + """Process Update — re-ingest remote post.""" + obj = body.get("object") + if not obj or not isinstance(obj, dict): + return + obj_type = obj.get("type", "") + if obj_type in ("Note", "Article"): + remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url) + if remote: + await services.federation.ingest_remote_post(g.s, remote.id, body, obj) + log.info("Updated %s from %s", obj_type, from_actor_url) + + async def _handle_delete( + actor_row: ActorProfile, + body: dict, + from_actor_url: str, + ) -> None: + """Process Delete — remove remote post.""" + obj = body.get("object") + if isinstance(obj, str): + object_id = obj + elif isinstance(obj, dict): + object_id = obj.get("id", "") + else: + return + if object_id: + await services.federation.delete_remote_post(g.s, object_id) + log.info("Deleted remote post %s from %s", object_id, from_actor_url) + + async def _handle_like( + actor_row: ActorProfile, + body: dict, + from_actor_url: str, + ) -> None: + """Process incoming Like — record interaction + notify.""" + from shared.models.federation import APInteraction, APNotification, RemoteActor + + object_id = body.get("object", "") + if isinstance(object_id, dict): + object_id = object_id.get("id", "") + if not object_id: + return + + remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url) + if not remote: + return + + ra = (await g.s.execute( + select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) + )).scalar_one_or_none() + + # Find the local activity this Like targets + from shared.models.federation import APActivity + target = (await g.s.execute( + select(APActivity).where(APActivity.activity_id == object_id) + )).scalar_one_or_none() + + if not target: + # Try matching object_data.id + log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id) + return + + # Record interaction + interaction = APInteraction( + remote_actor_id=ra.id if ra else None, + post_type="local", + post_id=target.id, + interaction_type="like", + activity_id=body.get("id"), + ) + g.s.add(interaction) + + # Notification + notif = APNotification( + actor_profile_id=target.actor_profile_id, + notification_type="like", + from_remote_actor_id=ra.id if ra else None, + target_activity_id=target.id, + ) + g.s.add(notif) + log.info("Like from %s on activity %s", from_actor_url, object_id) + + async def _handle_announce( + actor_row: ActorProfile, + body: dict, + from_actor_url: str, + ) -> None: + """Process incoming Announce (boost) — record interaction + notify.""" + from shared.models.federation import APInteraction, APNotification, RemoteActor + + object_id = body.get("object", "") + if isinstance(object_id, dict): + object_id = object_id.get("id", "") + if not object_id: + return + + remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url) + if not remote: + return + + ra = (await g.s.execute( + select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) + )).scalar_one_or_none() + + from shared.models.federation import APActivity + target = (await g.s.execute( + select(APActivity).where(APActivity.activity_id == object_id) + )).scalar_one_or_none() + + if not target: + log.info("Announce from %s for %s (target not found locally)", from_actor_url, object_id) + return + + interaction = APInteraction( + remote_actor_id=ra.id if ra else None, + post_type="local", + post_id=target.id, + interaction_type="boost", + activity_id=body.get("id"), + ) + g.s.add(interaction) + + notif = APNotification( + actor_profile_id=target.actor_profile_id, + notification_type="boost", + from_remote_actor_id=ra.id if ra else None, + target_activity_id=target.id, + ) + g.s.add(notif) + log.info("Announce from %s on activity %s", from_actor_url, object_id) + @bp.get("//followers") async def followers(username: str): actor = await services.federation.get_actor_by_username(g.s, username) @@ -431,6 +704,7 @@ def register(url_prefix="/users"): domain = _domain() collection_id = f"https://{domain}/users/{username}/following" + following_list, total = await services.federation.get_following(g.s, username) page_param = request.args.get("page") if not page_param: @@ -439,7 +713,7 @@ def register(url_prefix="/users"): "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": collection_id, - "totalItems": 0, + "totalItems": total, "first": f"{collection_id}?page=1", }), content_type=AP_CONTENT_TYPE, @@ -451,8 +725,8 @@ def register(url_prefix="/users"): "type": "OrderedCollectionPage", "id": f"{collection_id}?page=1", "partOf": collection_id, - "totalItems": 0, - "orderedItems": [], + "totalItems": total, + "orderedItems": [f.actor_url for f in following_list], }), content_type=AP_CONTENT_TYPE, ) diff --git a/bp/social/__init__.py b/bp/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/social/routes.py b/bp/social/routes.py new file mode 100644 index 0000000..0c3b75c --- /dev/null +++ b/bp/social/routes.py @@ -0,0 +1,302 @@ +"""Social fediverse routes: timeline, compose, search, follow, interactions, notifications.""" +from __future__ import annotations + +import logging +from datetime import datetime + +from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response + +from shared.services.registry import services + +log = logging.getLogger(__name__) + + +def _require_actor(): + """Return actor context or abort 403.""" + actor = g.get("ctx", {}).get("actor") if hasattr(g, "ctx") else None + if not actor: + actor = getattr(g, "_social_actor", None) + if not actor: + abort(403, "You need to choose a federation username first") + return actor + + +def register(url_prefix="/social"): + bp = Blueprint("social", __name__, url_prefix=url_prefix) + + @bp.before_request + async def load_actor(): + """Load actor profile for authenticated users.""" + if g.get("user"): + actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) + g._social_actor = actor + + # -- Timeline ------------------------------------------------------------- + + @bp.get("/") + async def home_timeline(): + if not g.get("user"): + return redirect(url_for("auth.login_form")) + actor = _require_actor() + items = await services.federation.get_home_timeline(g.s, actor.id) + return await render_template( + "federation/timeline.html", + items=items, + timeline_type="home", + actor=actor, + ) + + @bp.get("/timeline") + async def home_timeline_page(): + actor = _require_actor() + before_str = request.args.get("before") + before = None + if before_str: + try: + before = datetime.fromisoformat(before_str) + except ValueError: + pass + items = await services.federation.get_home_timeline( + g.s, actor.id, before=before, + ) + return await render_template( + "federation/_timeline_items.html", + items=items, + timeline_type="home", + actor=actor, + ) + + @bp.get("/public") + async def public_timeline(): + items = await services.federation.get_public_timeline(g.s) + actor = getattr(g, "_social_actor", None) + return await render_template( + "federation/timeline.html", + items=items, + timeline_type="public", + actor=actor, + ) + + @bp.get("/public/timeline") + async def public_timeline_page(): + before_str = request.args.get("before") + before = None + if before_str: + try: + before = datetime.fromisoformat(before_str) + except ValueError: + pass + items = await services.federation.get_public_timeline(g.s, before=before) + actor = getattr(g, "_social_actor", None) + return await render_template( + "federation/_timeline_items.html", + items=items, + timeline_type="public", + actor=actor, + ) + + # -- Compose -------------------------------------------------------------- + + @bp.get("/compose") + async def compose_form(): + actor = _require_actor() + reply_to = request.args.get("reply_to") + return await render_template( + "federation/compose.html", + actor=actor, + reply_to=reply_to, + ) + + @bp.post("/compose") + async def compose_submit(): + actor = _require_actor() + form = await request.form + content = form.get("content", "").strip() + if not content: + return redirect(url_for("social.compose_form")) + + visibility = form.get("visibility", "public") + in_reply_to = form.get("in_reply_to") or None + + await services.federation.create_local_post( + g.s, actor.id, + content=content, + visibility=visibility, + in_reply_to=in_reply_to, + ) + return redirect(url_for("social.home_timeline")) + + @bp.post("/delete/") + async def delete_post(post_id: int): + actor = _require_actor() + await services.federation.delete_local_post(g.s, actor.id, post_id) + return redirect(url_for("social.home_timeline")) + + # -- Search + Follow ------------------------------------------------------ + + @bp.get("/search") + async def search(): + actor = getattr(g, "_social_actor", None) + query = request.args.get("q", "").strip() + result = None + if query: + result = await services.federation.search_remote_actor(g.s, query) + return await render_template( + "federation/search.html", + query=query, + result=result, + actor=actor, + ) + + @bp.post("/follow") + async def follow(): + actor = _require_actor() + form = await request.form + remote_actor_url = form.get("actor_url", "") + if remote_actor_url: + await services.federation.send_follow( + g.s, actor.preferred_username, remote_actor_url, + ) + return redirect(url_for("social.search")) + + @bp.post("/unfollow") + async def unfollow(): + actor = _require_actor() + form = await request.form + remote_actor_url = form.get("actor_url", "") + if remote_actor_url: + await services.federation.unfollow( + g.s, actor.preferred_username, remote_actor_url, + ) + return redirect(url_for("social.search")) + + # -- Interactions --------------------------------------------------------- + + @bp.post("/like") + async def like(): + actor = _require_actor() + form = await request.form + object_id = form.get("object_id", "") + author_inbox = form.get("author_inbox", "") + await services.federation.like_post(g.s, actor.id, object_id, author_inbox) + # Return updated buttons for HTMX + return await _interaction_buttons_response(actor, object_id, author_inbox) + + @bp.post("/unlike") + async def unlike(): + actor = _require_actor() + form = await request.form + object_id = form.get("object_id", "") + author_inbox = form.get("author_inbox", "") + await services.federation.unlike_post(g.s, actor.id, object_id, author_inbox) + return await _interaction_buttons_response(actor, object_id, author_inbox) + + @bp.post("/boost") + async def boost(): + actor = _require_actor() + form = await request.form + object_id = form.get("object_id", "") + author_inbox = form.get("author_inbox", "") + await services.federation.boost_post(g.s, actor.id, object_id, author_inbox) + return await _interaction_buttons_response(actor, object_id, author_inbox) + + @bp.post("/unboost") + async def unboost(): + actor = _require_actor() + form = await request.form + object_id = form.get("object_id", "") + author_inbox = form.get("author_inbox", "") + await services.federation.unboost_post(g.s, actor.id, object_id, author_inbox) + return await _interaction_buttons_response(actor, object_id, author_inbox) + + async def _interaction_buttons_response(actor, object_id, author_inbox): + """Re-render interaction buttons after a like/boost action.""" + from shared.models.federation import APInteraction, APRemotePost, APActivity + from sqlalchemy import select + from shared.services.federation_impl import SqlFederationService + + svc = services.federation + post_type, post_id = await svc._resolve_post(g.s, object_id) + + like_count = 0 + boost_count = 0 + liked_by_me = False + boosted_by_me = False + + if post_type: + from sqlalchemy import func as sa_func + like_count = (await g.s.execute( + select(sa_func.count(APInteraction.id)).where( + APInteraction.post_type == post_type, + APInteraction.post_id == post_id, + APInteraction.interaction_type == "like", + ) + )).scalar() or 0 + boost_count = (await g.s.execute( + select(sa_func.count(APInteraction.id)).where( + APInteraction.post_type == post_type, + APInteraction.post_id == post_id, + APInteraction.interaction_type == "boost", + ) + )).scalar() or 0 + liked_by_me = bool((await g.s.execute( + select(APInteraction.id).where( + APInteraction.actor_profile_id == actor.id, + APInteraction.post_type == post_type, + APInteraction.post_id == post_id, + APInteraction.interaction_type == "like", + ).limit(1) + )).scalar()) + boosted_by_me = bool((await g.s.execute( + select(APInteraction.id).where( + APInteraction.actor_profile_id == actor.id, + APInteraction.post_type == post_type, + APInteraction.post_id == post_id, + APInteraction.interaction_type == "boost", + ).limit(1) + )).scalar()) + + return await render_template( + "federation/_interaction_buttons.html", + item_object_id=object_id, + item_author_inbox=author_inbox, + like_count=like_count, + boost_count=boost_count, + liked_by_me=liked_by_me, + boosted_by_me=boosted_by_me, + actor=actor, + ) + + # -- Notifications -------------------------------------------------------- + + @bp.get("/notifications") + async def notifications(): + actor = _require_actor() + items = await services.federation.get_notifications(g.s, actor.id) + await services.federation.mark_notifications_read(g.s, actor.id) + return await render_template( + "federation/notifications.html", + notifications=items, + actor=actor, + ) + + @bp.get("/notifications/count") + async def notification_count(): + actor = getattr(g, "_social_actor", None) + if not actor: + return Response("0", content_type="text/plain") + count = await services.federation.unread_notification_count(g.s, actor.id) + if count > 0: + return Response( + f'{count}', + content_type="text/html", + ) + return Response("", content_type="text/html") + + @bp.post("/notifications/read") + async def mark_read(): + actor = _require_actor() + await services.federation.mark_notifications_read(g.s, actor.id) + return redirect(url_for("social.notifications")) + + return bp diff --git a/shared b/shared index 9a8b556..bccfff0 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit 9a8b556c13512d84091c0e01fd746bbfcaf68c71 +Subproject commit bccfff0c699fdf9e3ee7c911af1f17a5294943f6 diff --git a/templates/federation/_interaction_buttons.html b/templates/federation/_interaction_buttons.html new file mode 100644 index 0000000..5551732 --- /dev/null +++ b/templates/federation/_interaction_buttons.html @@ -0,0 +1,61 @@ +{% set oid = item.object_id if item is defined and item.object_id is defined else item_object_id | default('') %} +{% set ainbox = item.author_inbox if item is defined and item.author_inbox is defined else item_author_inbox | default('') %} +{% set lcount = item.like_count if item is defined and item.like_count is defined else like_count | default(0) %} +{% set bcount = item.boost_count if item is defined and item.boost_count is defined else boost_count | default(0) %} +{% set liked = item.liked_by_me if item is defined and item.liked_by_me is defined else liked_by_me | default(false) %} +{% set boosted = item.boosted_by_me if item is defined and item.boosted_by_me is defined else boosted_by_me | default(false) %} + +
+ {% if liked %} +
+ + + + +
+ {% else %} +
+ + + + +
+ {% endif %} + + {% if boosted %} +
+ + + + +
+ {% else %} +
+ + + + +
+ {% endif %} + + {% if oid %} + Reply + {% endif %} +
diff --git a/templates/federation/_notification.html b/templates/federation/_notification.html new file mode 100644 index 0000000..d18ef4d --- /dev/null +++ b/templates/federation/_notification.html @@ -0,0 +1,42 @@ +
+
+ {% if notif.from_actor_icon %} + + {% else %} +
+ {{ notif.from_actor_name[0] | upper if notif.from_actor_name else '?' }} +
+ {% endif %} + +
+
+ {{ notif.from_actor_name }} + + @{{ notif.from_actor_username }}{% if notif.from_actor_domain %}@{{ notif.from_actor_domain }}{% endif %} + + + {% if notif.notification_type == "follow" %} + followed you + {% elif notif.notification_type == "like" %} + liked your post + {% elif notif.notification_type == "boost" %} + boosted your post + {% elif notif.notification_type == "mention" %} + mentioned you + {% elif notif.notification_type == "reply" %} + replied to your post + {% endif %} +
+ + {% if notif.target_content_preview %} +
+ {{ notif.target_content_preview }} +
+ {% endif %} + +
+ {{ notif.created_at.strftime('%b %d, %H:%M') }} +
+
+
+
diff --git a/templates/federation/_post_card.html b/templates/federation/_post_card.html new file mode 100644 index 0000000..33102ca --- /dev/null +++ b/templates/federation/_post_card.html @@ -0,0 +1,52 @@ +
+ {% if item.boosted_by %} +
+ Boosted by {{ item.boosted_by }} +
+ {% endif %} + +
+ {% if item.actor_icon %} + + {% else %} +
+ {{ item.actor_name[0] | upper if item.actor_name else '?' }} +
+ {% endif %} + +
+
+ {{ item.actor_name }} + + @{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %} + + + {% if item.published %} + {{ item.published.strftime('%b %d, %H:%M') }} + {% endif %} + +
+ + {% if item.summary %} +
+ CW: {{ item.summary }} +
{{ item.content | safe }}
+
+ {% else %} +
{{ item.content | safe }}
+ {% endif %} + + {% if item.url and item.post_type == "remote" %} + + original + + {% endif %} + + {% if actor %} +
+ {% include "federation/_interaction_buttons.html" with context %} +
+ {% endif %} +
+
+
diff --git a/templates/federation/_timeline_items.html b/templates/federation/_timeline_items.html new file mode 100644 index 0000000..60fe317 --- /dev/null +++ b/templates/federation/_timeline_items.html @@ -0,0 +1,11 @@ +{% for item in items %} + {% include "federation/_post_card.html" %} +{% endfor %} + +{% if items %} + {% set last = items[-1] %} +
+
+{% endif %} diff --git a/templates/federation/actor_card.html b/templates/federation/actor_card.html new file mode 100644 index 0000000..cd97c70 --- /dev/null +++ b/templates/federation/actor_card.html @@ -0,0 +1,45 @@ +
+
+ {% if result.icon_url %} + + {% else %} +
+ {{ result.preferred_username[0] | upper }} +
+ {% endif %} + +
+
+ {{ result.display_name or result.preferred_username }} + @{{ result.preferred_username }}@{{ result.domain }} +
+ + {% if result.summary %} +
+ {{ result.summary | safe }} +
+ {% endif %} + + {% if actor %} +
+
+ + + +
+
+ + + +
+
+ {% endif %} +
+
+
diff --git a/templates/federation/base.html b/templates/federation/base.html index 06a0c52..b8d67b9 100644 --- a/templates/federation/base.html +++ b/templates/federation/base.html @@ -5,6 +5,7 @@ {% block title %}Rose Ash{% endblock %} +