Compare commits
49 Commits
main
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd25d442f5 | ||
|
|
e9d4a34484 | ||
|
|
e6a0f4f11f | ||
|
|
9973683ee4 | ||
|
|
fd8505a6dd | ||
|
|
204c86f213 | ||
|
|
7744ec07e6 | ||
|
|
07b8bb0a14 | ||
|
|
52317518d8 | ||
|
|
b2b853c052 | ||
|
|
f17dd923a6 | ||
|
|
2016f0b727 | ||
|
|
6674eb827d | ||
|
|
8f4d3d76c1 | ||
|
|
cd3cc4def0 | ||
|
|
0c56d8dded | ||
|
|
f75cebcf85 | ||
|
|
96c2e44cf0 | ||
|
|
b649554642 | ||
|
|
caa1fd3115 | ||
|
|
7a68fc6ed3 | ||
|
|
46df2fdb87 | ||
|
|
0e9e6b9dc4 | ||
|
|
1cb9323167 | ||
|
|
bcfeec99e9 | ||
|
|
baf78f9805 | ||
|
|
6c42722dc6 | ||
|
|
1546c6d7c9 | ||
|
|
baf8f011e4 | ||
|
|
8b77ae61cd | ||
|
|
90c779aebb | ||
|
|
1af5b189d7 | ||
|
|
fc7b36688e | ||
|
|
2b76310dc1 | ||
|
|
15550dd687 | ||
|
|
ff4dfc7182 | ||
|
|
195df18e60 | ||
|
|
ecf183711d | ||
|
|
4e82193a5e | ||
|
|
ffad7ffea9 | ||
|
|
f197dcffcb | ||
|
|
5bf710a5ce | ||
|
|
b81d679af8 | ||
|
|
c7618b8a65 | ||
|
|
f3737b2471 | ||
|
|
084b1786f1 | ||
|
|
a4902b2ff4 | ||
|
|
09d36b89c1 | ||
|
|
54c73d1740 |
18
app.py
18
app.py
@@ -2,18 +2,16 @@ from __future__ import annotations
|
|||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g
|
from quart import g, request
|
||||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
|
|
||||||
from shared.infrastructure.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
|
||||||
from bp import (
|
from bp import (
|
||||||
register_wellknown_bp,
|
|
||||||
register_actors_bp,
|
|
||||||
register_identity_bp,
|
register_identity_bp,
|
||||||
register_auth_bp,
|
|
||||||
register_social_bp,
|
register_social_bp,
|
||||||
|
register_fragments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,9 +20,15 @@ async def federation_context() -> dict:
|
|||||||
from shared.infrastructure.context import base_context
|
from shared.infrastructure.context import base_context
|
||||||
from shared.services.navigation import get_navigation_tree
|
from shared.services.navigation import get_navigation_tree
|
||||||
from shared.infrastructure.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
|
ctx["nav_tree_html"] = await fetch_fragment(
|
||||||
|
"blog", "nav-tree",
|
||||||
|
params={"app_name": "federation", "path": request.path},
|
||||||
|
)
|
||||||
|
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||||
ctx["menu_items"] = await get_navigation_tree(g.s)
|
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||||
|
|
||||||
# Cart data (consistent with all other apps)
|
# Cart data (consistent with all other apps)
|
||||||
@@ -62,11 +66,11 @@ def create_app() -> "Quart":
|
|||||||
])
|
])
|
||||||
|
|
||||||
# --- blueprints ---
|
# --- blueprints ---
|
||||||
app.register_blueprint(register_wellknown_bp())
|
# Well-known + actors (webfinger, inbox, outbox, etc.) are now handled
|
||||||
app.register_blueprint(register_actors_bp())
|
# by the shared AP blueprint registered in create_base_app().
|
||||||
app.register_blueprint(register_identity_bp())
|
app.register_blueprint(register_identity_bp())
|
||||||
app.register_blueprint(register_auth_bp())
|
|
||||||
app.register_blueprint(register_social_bp())
|
app.register_blueprint(register_social_bp())
|
||||||
|
app.register_blueprint(register_fragments())
|
||||||
|
|
||||||
# --- home page ---
|
# --- home page ---
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
14
blog/models/__init__.py
Normal file
14
blog/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
|
||||||
|
from .snippet import Snippet
|
||||||
|
from .tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
# Shared models — canonical definitions live in shared/models/
|
||||||
|
from shared.models.ghost_membership_entities import (
|
||||||
|
GhostLabel, UserLabel,
|
||||||
|
GhostNewsletter, UserNewsletter,
|
||||||
|
GhostTier, GhostSubscription,
|
||||||
|
)
|
||||||
|
from shared.models.menu_item import MenuItem
|
||||||
|
from shared.models.kv import KV
|
||||||
|
from shared.models.magic_link import MagicLink
|
||||||
|
from shared.models.user import User
|
||||||
3
blog/models/ghost_content.py
Normal file
3
blog/models/ghost_content.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from shared.models.ghost_content import ( # noqa: F401
|
||||||
|
Tag, Post, Author, PostAuthor, PostTag, PostLike,
|
||||||
|
)
|
||||||
12
blog/models/ghost_membership_entities.py
Normal file
12
blog/models/ghost_membership_entities.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.ghost_membership_entities import (
|
||||||
|
GhostLabel, UserLabel,
|
||||||
|
GhostNewsletter, UserNewsletter,
|
||||||
|
GhostTier, GhostSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GhostLabel", "UserLabel",
|
||||||
|
"GhostNewsletter", "UserNewsletter",
|
||||||
|
"GhostTier", "GhostSubscription",
|
||||||
|
]
|
||||||
4
blog/models/kv.py
Normal file
4
blog/models/kv.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.kv import KV
|
||||||
|
|
||||||
|
__all__ = ["KV"]
|
||||||
4
blog/models/magic_link.py
Normal file
4
blog/models/magic_link.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.magic_link import MagicLink
|
||||||
|
|
||||||
|
__all__ = ["MagicLink"]
|
||||||
4
blog/models/menu_item.py
Normal file
4
blog/models/menu_item.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.menu_item import MenuItem
|
||||||
|
|
||||||
|
__all__ = ["MenuItem"]
|
||||||
32
blog/models/snippet.py
Normal file
32
blog/models/snippet.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Snippet(Base):
|
||||||
|
__tablename__ = "snippets"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
|
||||||
|
Index("ix_snippets_visibility", "visibility"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
visibility: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="private", server_default="private",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
|
||||||
|
)
|
||||||
52
blog/models/tag_group.py
Normal file
52
blog/models/tag_group.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy import (
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroup(Base):
|
||||||
|
__tablename__ = "tag_groups"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
|
||||||
|
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
|
colour: Mapped[Optional[str]] = mapped_column(String(32))
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
tag_links: Mapped[List["TagGroupTag"]] = relationship(
|
||||||
|
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroupTag(Base):
|
||||||
|
__tablename__ = "tag_group_tags"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tag_group_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")
|
||||||
4
blog/models/user.py
Normal file
4
blog/models/user.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["User"]
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
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 .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
|
from .social.routes import register as register_social_bp
|
||||||
|
from .fragments import register_fragments
|
||||||
|
|||||||
@@ -1,734 +0,0 @@
|
|||||||
"""ActivityPub actor endpoints: profiles, outbox, inbox.
|
|
||||||
|
|
||||||
Ported from ~/art-dag/activity-pub/app/routers/users.py.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from quart import Blueprint, request, abort, Response, g, render_template
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from shared.services.registry import services
|
|
||||||
from shared.models.federation import ActorProfile, APInboxItem
|
|
||||||
from shared.browser.app.csrf import csrf_exempt
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
AP_CONTENT_TYPE = "application/activity+json"
|
|
||||||
|
|
||||||
|
|
||||||
def _domain() -> str:
|
|
||||||
return os.getenv("AP_DOMAIN", "rose-ash.com")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_remote_actor(actor_url: str) -> dict | None:
|
|
||||||
"""Fetch a remote actor's JSON-LD profile."""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.get(
|
|
||||||
actor_url,
|
|
||||||
headers={"Accept": AP_CONTENT_TYPE},
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
return resp.json()
|
|
||||||
except Exception:
|
|
||||||
log.exception("Failed to fetch remote actor: %s", actor_url)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_accept(
|
|
||||||
actor: ActorProfile,
|
|
||||||
follow_activity: dict,
|
|
||||||
follower_inbox: str,
|
|
||||||
) -> None:
|
|
||||||
"""Send an Accept activity back to the follower."""
|
|
||||||
from shared.utils.http_signatures import sign_request
|
|
||||||
|
|
||||||
domain = _domain()
|
|
||||||
username = actor.preferred_username
|
|
||||||
actor_url = f"https://{domain}/users/{username}"
|
|
||||||
|
|
||||||
accept_id = f"{actor_url}/activities/{uuid.uuid4()}"
|
|
||||||
accept = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": accept_id,
|
|
||||||
"type": "Accept",
|
|
||||||
"actor": actor_url,
|
|
||||||
"object": follow_activity,
|
|
||||||
}
|
|
||||||
|
|
||||||
body_bytes = json.dumps(accept).encode()
|
|
||||||
key_id = f"{actor_url}#main-key"
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(follower_inbox)
|
|
||||||
headers = sign_request(
|
|
||||||
private_key_pem=actor.private_key_pem,
|
|
||||||
key_id=key_id,
|
|
||||||
method="POST",
|
|
||||||
path=parsed.path,
|
|
||||||
host=parsed.netloc,
|
|
||||||
body=body_bytes,
|
|
||||||
)
|
|
||||||
headers["Content-Type"] = AP_CONTENT_TYPE
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
follower_inbox,
|
|
||||||
content=body_bytes,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
log.info("Accept → %s: %d", follower_inbox, resp.status_code)
|
|
||||||
except Exception:
|
|
||||||
log.exception("Failed to send Accept to %s", follower_inbox)
|
|
||||||
|
|
||||||
|
|
||||||
async def _backfill_follower(
|
|
||||||
actor: ActorProfile,
|
|
||||||
follower_inbox: str,
|
|
||||||
) -> None:
|
|
||||||
"""Deliver recent Create activities to a new follower's inbox."""
|
|
||||||
from shared.events.handlers.ap_delivery_handler import (
|
|
||||||
_build_activity_json, _deliver_to_inbox,
|
|
||||||
)
|
|
||||||
from shared.models.federation import APActivity
|
|
||||||
|
|
||||||
domain = _domain()
|
|
||||||
|
|
||||||
# Fetch recent Create activities (most recent first, limit 20)
|
|
||||||
activities = (
|
|
||||||
await g.s.execute(
|
|
||||||
select(APActivity).where(
|
|
||||||
APActivity.actor_profile_id == actor.id,
|
|
||||||
APActivity.is_local == True, # noqa: E712
|
|
||||||
APActivity.activity_type == "Create",
|
|
||||||
)
|
|
||||||
.order_by(APActivity.published.desc())
|
|
||||||
.limit(20)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Backfilling %d posts to %s for @%s",
|
|
||||||
len(activities), follower_inbox, actor.preferred_username,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
for activity in reversed(activities): # oldest first
|
|
||||||
activity_json = _build_activity_json(activity, actor, domain)
|
|
||||||
await _deliver_to_inbox(client, follower_inbox, activity_json, actor, domain)
|
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix="/users"):
|
|
||||||
bp = Blueprint("actors", __name__, url_prefix=url_prefix)
|
|
||||||
|
|
||||||
@bp.get("/<username>")
|
|
||||||
async def profile(username: str):
|
|
||||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
|
||||||
if not actor:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
domain = _domain()
|
|
||||||
accept = request.headers.get("accept", "")
|
|
||||||
|
|
||||||
# AP JSON-LD response
|
|
||||||
if "application/activity+json" in accept or "application/ld+json" in accept:
|
|
||||||
actor_json = {
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
"https://w3id.org/security/v1",
|
|
||||||
],
|
|
||||||
"type": "Person",
|
|
||||||
"id": f"https://{domain}/users/{username}",
|
|
||||||
"name": actor.display_name or username,
|
|
||||||
"preferredUsername": username,
|
|
||||||
"summary": actor.summary or "",
|
|
||||||
"manuallyApprovesFollowers": False,
|
|
||||||
"inbox": f"https://{domain}/users/{username}/inbox",
|
|
||||||
"outbox": f"https://{domain}/users/{username}/outbox",
|
|
||||||
"followers": f"https://{domain}/users/{username}/followers",
|
|
||||||
"following": f"https://{domain}/users/{username}/following",
|
|
||||||
"publicKey": {
|
|
||||||
"id": f"https://{domain}/users/{username}#main-key",
|
|
||||||
"owner": f"https://{domain}/users/{username}",
|
|
||||||
"publicKeyPem": actor.public_key_pem,
|
|
||||||
},
|
|
||||||
"url": f"https://{domain}/users/{username}",
|
|
||||||
}
|
|
||||||
return Response(
|
|
||||||
response=json.dumps(actor_json),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# HTML profile page
|
|
||||||
activities, total = await services.federation.get_outbox(
|
|
||||||
g.s, username, page=1, per_page=20,
|
|
||||||
)
|
|
||||||
return await render_template(
|
|
||||||
"federation/profile.html",
|
|
||||||
actor=actor,
|
|
||||||
activities=activities,
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/<username>/outbox")
|
|
||||||
async def outbox(username: str):
|
|
||||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
|
||||||
if not actor:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
domain = _domain()
|
|
||||||
actor_id = f"https://{domain}/users/{username}"
|
|
||||||
page_param = request.args.get("page")
|
|
||||||
|
|
||||||
if not page_param:
|
|
||||||
_, total = await services.federation.get_outbox(g.s, username, page=1, per_page=1)
|
|
||||||
return Response(
|
|
||||||
response=json.dumps({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"id": f"{actor_id}/outbox",
|
|
||||||
"totalItems": total,
|
|
||||||
"first": f"{actor_id}/outbox?page=1",
|
|
||||||
}),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
page_num = int(page_param)
|
|
||||||
activities, total = await services.federation.get_outbox(
|
|
||||||
g.s, username, page=page_num, per_page=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for a in activities:
|
|
||||||
items.append({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": a.activity_type,
|
|
||||||
"id": a.activity_id,
|
|
||||||
"actor": actor_id,
|
|
||||||
"published": a.published.isoformat() if a.published else None,
|
|
||||||
"object": {
|
|
||||||
"type": a.object_type,
|
|
||||||
**(a.object_data or {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
response=json.dumps({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "OrderedCollectionPage",
|
|
||||||
"id": f"{actor_id}/outbox?page={page_num}",
|
|
||||||
"partOf": f"{actor_id}/outbox",
|
|
||||||
"totalItems": total,
|
|
||||||
"orderedItems": items,
|
|
||||||
}),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@bp.post("/<username>/inbox")
|
|
||||||
async def inbox(username: str):
|
|
||||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
|
||||||
if not actor:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
body = await request.get_json()
|
|
||||||
if not body:
|
|
||||||
abort(400, "Invalid JSON")
|
|
||||||
|
|
||||||
activity_type = body.get("type", "")
|
|
||||||
from_actor_url = body.get("actor", "")
|
|
||||||
|
|
||||||
# Verify HTTP signature (best-effort — log but don't block yet
|
|
||||||
# while we validate our implementation against real servers)
|
|
||||||
sig_valid = False
|
|
||||||
try:
|
|
||||||
from shared.utils.http_signatures import verify_request_signature
|
|
||||||
req_headers = dict(request.headers)
|
|
||||||
sig_header = req_headers.get("Signature", "")
|
|
||||||
|
|
||||||
# Fetch remote actor to get their public key
|
|
||||||
remote_actor = await _fetch_remote_actor(from_actor_url)
|
|
||||||
if remote_actor and sig_header:
|
|
||||||
pub_key_pem = (remote_actor.get("publicKey") or {}).get("publicKeyPem")
|
|
||||||
if pub_key_pem:
|
|
||||||
sig_valid = verify_request_signature(
|
|
||||||
public_key_pem=pub_key_pem,
|
|
||||||
signature_header=sig_header,
|
|
||||||
method="POST",
|
|
||||||
path=f"/users/{username}/inbox",
|
|
||||||
headers=req_headers,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.debug("Signature verification failed for %s", from_actor_url, exc_info=True)
|
|
||||||
|
|
||||||
if not sig_valid:
|
|
||||||
log.warning(
|
|
||||||
"Unverified inbox POST from %s (%s) — accepting anyway for now",
|
|
||||||
from_actor_url, activity_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load actor row for DB operations
|
|
||||||
actor_row = (
|
|
||||||
await g.s.execute(
|
|
||||||
select(ActorProfile).where(
|
|
||||||
ActorProfile.preferred_username == username
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
# Store raw inbox item
|
|
||||||
item = APInboxItem(
|
|
||||||
actor_profile_id=actor_row.id,
|
|
||||||
raw_json=body,
|
|
||||||
activity_type=activity_type,
|
|
||||||
from_actor=from_actor_url,
|
|
||||||
)
|
|
||||||
g.s.add(item)
|
|
||||||
await g.s.flush()
|
|
||||||
|
|
||||||
# Handle activity types inline
|
|
||||||
if activity_type == "Follow":
|
|
||||||
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 == "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"
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
item.processed_at = datetime.now(timezone.utc)
|
|
||||||
await g.s.flush()
|
|
||||||
|
|
||||||
return Response(status=202)
|
|
||||||
|
|
||||||
async def _handle_follow(
|
|
||||||
actor_row: ActorProfile,
|
|
||||||
body: dict,
|
|
||||||
from_actor_url: str,
|
|
||||||
) -> None:
|
|
||||||
"""Process a Follow activity: add follower, send Accept."""
|
|
||||||
remote_actor = await _fetch_remote_actor(from_actor_url)
|
|
||||||
if not remote_actor:
|
|
||||||
log.warning("Could not fetch remote actor for Follow: %s", from_actor_url)
|
|
||||||
return
|
|
||||||
|
|
||||||
follower_inbox = remote_actor.get("inbox")
|
|
||||||
if not follower_inbox:
|
|
||||||
log.warning("Remote actor has no inbox: %s", from_actor_url)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Derive acct from preferredUsername@domain
|
|
||||||
remote_username = remote_actor.get("preferredUsername", "")
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
remote_domain = urlparse(from_actor_url).netloc
|
|
||||||
follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url
|
|
||||||
|
|
||||||
pub_key = (remote_actor.get("publicKey") or {}).get("publicKeyPem")
|
|
||||||
|
|
||||||
# Add follower via service
|
|
||||||
await services.federation.add_follower(
|
|
||||||
g.s,
|
|
||||||
actor_row.preferred_username,
|
|
||||||
follower_acct=follower_acct,
|
|
||||||
follower_inbox=follower_inbox,
|
|
||||||
follower_actor_url=from_actor_url,
|
|
||||||
follower_public_key=pub_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"New follower: %s → @%s",
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Backfill: deliver recent posts to new follower's inbox
|
|
||||||
await _backfill_follower(actor_row, follower_inbox)
|
|
||||||
|
|
||||||
async def _handle_undo(
|
|
||||||
actor_row: ActorProfile,
|
|
||||||
body: dict,
|
|
||||||
from_actor_url: str,
|
|
||||||
) -> None:
|
|
||||||
"""Process an Undo activity (typically Undo Follow)."""
|
|
||||||
inner = body.get("object")
|
|
||||||
if not inner:
|
|
||||||
return
|
|
||||||
|
|
||||||
inner_type = inner.get("type") if isinstance(inner, dict) else None
|
|
||||||
if inner_type == "Follow":
|
|
||||||
# Derive acct
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
remote_domain = urlparse(from_actor_url).netloc
|
|
||||||
remote_actor = await _fetch_remote_actor(from_actor_url)
|
|
||||||
remote_username = ""
|
|
||||||
if remote_actor:
|
|
||||||
remote_username = remote_actor.get("preferredUsername", "")
|
|
||||||
follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url
|
|
||||||
|
|
||||||
removed = await services.federation.remove_follower(
|
|
||||||
g.s, actor_row.preferred_username, follower_acct,
|
|
||||||
)
|
|
||||||
if removed:
|
|
||||||
log.info("Unfollowed: %s → @%s", follower_acct, actor_row.preferred_username)
|
|
||||||
else:
|
|
||||||
log.debug("Undo Follow: follower not found: %s", follower_acct)
|
|
||||||
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("/<username>/followers")
|
|
||||||
async def followers(username: str):
|
|
||||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
|
||||||
if not actor:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
domain = _domain()
|
|
||||||
collection_id = f"https://{domain}/users/{username}/followers"
|
|
||||||
follower_list = await services.federation.get_followers(g.s, username)
|
|
||||||
page_param = request.args.get("page")
|
|
||||||
|
|
||||||
if not page_param:
|
|
||||||
return Response(
|
|
||||||
response=json.dumps({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"id": collection_id,
|
|
||||||
"totalItems": len(follower_list),
|
|
||||||
"first": f"{collection_id}?page=1",
|
|
||||||
}),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
response=json.dumps({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "OrderedCollectionPage",
|
|
||||||
"id": f"{collection_id}?page=1",
|
|
||||||
"partOf": collection_id,
|
|
||||||
"totalItems": len(follower_list),
|
|
||||||
"orderedItems": [f.follower_actor_url for f in follower_list],
|
|
||||||
}),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/<username>/following")
|
|
||||||
async def following(username: str):
|
|
||||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
|
||||||
if not actor:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
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:
|
|
||||||
return Response(
|
|
||||||
response=json.dumps({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"id": collection_id,
|
|
||||||
"totalItems": total,
|
|
||||||
"first": f"{collection_id}?page=1",
|
|
||||||
}),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
response=json.dumps({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "OrderedCollectionPage",
|
|
||||||
"id": f"{collection_id}?page=1",
|
|
||||||
"partOf": collection_id,
|
|
||||||
"totalItems": total,
|
|
||||||
"orderedItems": [f.actor_url for f in following_list],
|
|
||||||
}),
|
|
||||||
content_type=AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
return bp
|
|
||||||
@@ -196,11 +196,37 @@ def register(url_prefix="/auth"):
|
|||||||
qsession[SESSION_USER_KEY] = user_id
|
qsession[SESSION_USER_KEY] = user_id
|
||||||
|
|
||||||
redirect_url = pop_login_redirect_target()
|
redirect_url = pop_login_redirect_target()
|
||||||
return redirect(redirect_url, 303)
|
resp = redirect(redirect_url, 303)
|
||||||
|
resp.set_cookie(
|
||||||
|
"sso_hint", "1",
|
||||||
|
domain=".rose-ash.com", max_age=30 * 24 * 3600,
|
||||||
|
secure=True, samesite="Lax", httponly=True,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
@auth_bp.post("/logout/")
|
@auth_bp.post("/logout/")
|
||||||
async def logout():
|
async def logout():
|
||||||
qsession.pop(SESSION_USER_KEY, None)
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
return redirect(federation_url("/"))
|
resp = redirect(federation_url("/"))
|
||||||
|
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@auth_bp.get("/clear/")
|
||||||
|
async def clear():
|
||||||
|
"""One-time migration helper: clear all session cookies."""
|
||||||
|
qsession.clear()
|
||||||
|
resp = redirect(federation_url("/"))
|
||||||
|
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
||||||
|
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@auth_bp.get("/sso-logout/")
|
||||||
|
async def sso_logout():
|
||||||
|
"""SSO logout: clear federation session + sso_hint, redirect to blog."""
|
||||||
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
|
from shared.infrastructure.urls import blog_url
|
||||||
|
resp = redirect(blog_url("/"))
|
||||||
|
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
return auth_bp
|
return auth_bp
|
||||||
|
|||||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .routes import register as register_fragments
|
||||||
34
bp/fragments/routes.py
Normal file
34
bp/fragments/routes.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Federation app fragment endpoints.
|
||||||
|
|
||||||
|
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
|
by other coop apps via the fragment client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
async def _require_fragment_header():
|
||||||
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
|
return Response("", status=403)
|
||||||
|
|
||||||
|
@bp.get("/<fragment_type>")
|
||||||
|
async def get_fragment(fragment_type: str):
|
||||||
|
handler = _handlers.get(fragment_type)
|
||||||
|
if handler is None:
|
||||||
|
return Response("", status=200, content_type="text/html")
|
||||||
|
html = await handler()
|
||||||
|
return Response(html, status=200, content_type="text/html")
|
||||||
|
|
||||||
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -37,7 +37,7 @@ def register(url_prefix="/identity"):
|
|||||||
# Already has a username?
|
# Already has a username?
|
||||||
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
||||||
if actor:
|
if actor:
|
||||||
return redirect(url_for("actors.profile", username=actor.preferred_username))
|
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
||||||
|
|
||||||
return await render_template("federation/choose_username.html")
|
return await render_template("federation/choose_username.html")
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ def register(url_prefix="/identity"):
|
|||||||
# Already has a username?
|
# Already has a username?
|
||||||
existing = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
existing = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
||||||
if existing:
|
if existing:
|
||||||
return redirect(url_for("actors.profile", username=existing.preferred_username))
|
return redirect(url_for("activitypub.actor_profile", username=existing.preferred_username))
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
username = (form.get("username") or "").strip().lower()
|
username = (form.get("username") or "").strip().lower()
|
||||||
@@ -84,7 +84,7 @@ def register(url_prefix="/identity"):
|
|||||||
next_url = request.args.get("next")
|
next_url = request.args.get("next")
|
||||||
if next_url:
|
if next_url:
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
return redirect(url_for("actors.profile", username=actor.preferred_username))
|
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
||||||
|
|
||||||
@bp.get("/check-username")
|
@bp.get("/check-username")
|
||||||
async def check_username():
|
async def check_username():
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
"""Well-known federation endpoints: WebFinger, NodeInfo, host-meta.
|
|
||||||
|
|
||||||
Ported from ~/art-dag/activity-pub/app/routers/federation.py.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from quart import Blueprint, request, abort, Response, g
|
|
||||||
|
|
||||||
from shared.services.registry import services
|
|
||||||
|
|
||||||
|
|
||||||
def _domain() -> str:
|
|
||||||
return os.getenv("AP_DOMAIN", "rose-ash.com")
|
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix=""):
|
|
||||||
bp = Blueprint("wellknown", __name__, url_prefix=url_prefix)
|
|
||||||
|
|
||||||
@bp.get("/.well-known/webfinger")
|
|
||||||
async def webfinger():
|
|
||||||
resource = request.args.get("resource", "")
|
|
||||||
if not resource.startswith("acct:"):
|
|
||||||
abort(400, "Invalid resource format")
|
|
||||||
|
|
||||||
parts = resource[5:].split("@")
|
|
||||||
if len(parts) != 2:
|
|
||||||
abort(400, "Invalid resource format")
|
|
||||||
|
|
||||||
username, domain = parts
|
|
||||||
if domain != _domain():
|
|
||||||
abort(404, "User not on this server")
|
|
||||||
|
|
||||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
|
||||||
if not actor:
|
|
||||||
abort(404, "User not found")
|
|
||||||
|
|
||||||
domain = _domain()
|
|
||||||
return Response(
|
|
||||||
response=__import__("json").dumps({
|
|
||||||
"subject": resource,
|
|
||||||
"aliases": [f"https://{domain}/users/{username}"],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"rel": "self",
|
|
||||||
"type": "application/activity+json",
|
|
||||||
"href": f"https://{domain}/users/{username}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
|
||||||
"type": "text/html",
|
|
||||||
"href": f"https://{domain}/users/{username}",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
content_type="application/jrd+json",
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/.well-known/nodeinfo")
|
|
||||||
async def nodeinfo_index():
|
|
||||||
domain = _domain()
|
|
||||||
return Response(
|
|
||||||
response=__import__("json").dumps({
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
|
||||||
"href": f"https://{domain}/nodeinfo/2.0",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/nodeinfo/2.0")
|
|
||||||
async def nodeinfo():
|
|
||||||
stats = await services.federation.get_stats(g.s)
|
|
||||||
return Response(
|
|
||||||
response=__import__("json").dumps({
|
|
||||||
"version": "2.0",
|
|
||||||
"software": {
|
|
||||||
"name": "rose-ash",
|
|
||||||
"version": "1.0.0",
|
|
||||||
},
|
|
||||||
"protocols": ["activitypub"],
|
|
||||||
"usage": {
|
|
||||||
"users": {
|
|
||||||
"total": stats.get("actors", 0),
|
|
||||||
"activeMonth": stats.get("actors", 0),
|
|
||||||
},
|
|
||||||
"localPosts": stats.get("activities", 0),
|
|
||||||
},
|
|
||||||
"openRegistrations": False,
|
|
||||||
"metadata": {
|
|
||||||
"nodeName": "Rose Ash",
|
|
||||||
"nodeDescription": "Cooperative platform with ActivityPub federation",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/.well-known/host-meta")
|
|
||||||
async def host_meta():
|
|
||||||
domain = _domain()
|
|
||||||
xml = (
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
||||||
'<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n'
|
|
||||||
f' <Link rel="lrdd" type="application/xrd+xml" '
|
|
||||||
f'template="https://{domain}/.well-known/webfinger?resource={{uri}}"/>\n'
|
|
||||||
'</XRD>'
|
|
||||||
)
|
|
||||||
return Response(response=xml, content_type="application/xrd+xml")
|
|
||||||
|
|
||||||
return bp
|
|
||||||
2
cart/models/__init__.py
Normal file
2
cart/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .order import Order, OrderItem
|
||||||
|
from .page_config import PageConfig
|
||||||
1
cart/models/order.py
Normal file
1
cart/models/order.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.order import Order, OrderItem # noqa: F401
|
||||||
1
cart/models/page_config.py
Normal file
1
cart/models/page_config.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.page_config import PageConfig # noqa: F401
|
||||||
0
events/__init__.py
Normal file
0
events/__init__.py
Normal file
4
events/models/__init__.py
Normal file
4
events/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .calendars import (
|
||||||
|
Calendar, CalendarEntry, CalendarSlot,
|
||||||
|
TicketType, Ticket, CalendarEntryPost,
|
||||||
|
)
|
||||||
4
events/models/calendars.py
Normal file
4
events/models/calendars.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from shared.models.calendars import ( # noqa: F401
|
||||||
|
Calendar, CalendarEntry, CalendarSlot,
|
||||||
|
TicketType, Ticket, CalendarEntryPost,
|
||||||
|
)
|
||||||
0
market/__init__.py
Normal file
0
market/__init__.py
Normal file
8
market/models/__init__.py
Normal file
8
market/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .market import (
|
||||||
|
Product, ProductLike, ProductImage, ProductSection,
|
||||||
|
NavTop, NavSub, Listing, ListingItem,
|
||||||
|
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||||
|
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||||
|
CartItem,
|
||||||
|
)
|
||||||
|
from .market_place import MarketPlace
|
||||||
7
market/models/market.py
Normal file
7
market/models/market.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from shared.models.market import ( # noqa: F401
|
||||||
|
Product, ProductLike, ProductImage, ProductSection,
|
||||||
|
NavTop, NavSub, Listing, ListingItem,
|
||||||
|
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||||
|
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||||
|
CartItem,
|
||||||
|
)
|
||||||
1
market/models/market_place.py
Normal file
1
market/models/market_place.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.market_place import MarketPlace # noqa: F401
|
||||||
2
shared
2
shared
Submodule shared updated: 46f44f6171...9ab4b7b3fe
@@ -34,7 +34,7 @@
|
|||||||
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML"
|
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML"
|
||||||
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>
|
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('actors.profile', username=actor.preferred_username) }}"
|
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||||
class="px-2 py-1 rounded hover:bg-stone-200">
|
class="px-2 py-1 rounded hover:bg-stone-200">
|
||||||
@{{ actor.preferred_username }}
|
@{{ actor.preferred_username }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% if actor %}
|
{% if actor %}
|
||||||
<p class="mt-2"><strong>Username:</strong> @{{ actor.preferred_username }}</p>
|
<p class="mt-2"><strong>Username:</strong> @{{ actor.preferred_username }}</p>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
<a href="{{ url_for('actors.profile', username=actor.preferred_username) }}"
|
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||||
class="text-blue-600 hover:underline">
|
class="text-blue-600 hover:underline">
|
||||||
View profile
|
View profile
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user