First shot at parsing replies tree

This commit is contained in:
Thomas Sileo 2022-06-24 22:41:43 +02:00
parent baceb6be6c
commit b3cbf1f6db
7 changed files with 99 additions and 11 deletions

View File

@ -17,6 +17,10 @@ AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
ACTOR_TYPES = ["Application", "Group", "Organization", "Person", "Service"] ACTOR_TYPES = ["Application", "Group", "Organization", "Person", "Service"]
class ObjectIsGoneError(Exception):
pass
class VisibilityEnum(str, enum.Enum): class VisibilityEnum(str, enum.Enum):
PUBLIC = "public" PUBLIC = "public"
UNLISTED = "unlisted" UNLISTED = "unlisted"
@ -108,6 +112,11 @@ def fetch(url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
params=params, params=params,
follow_redirects=True, follow_redirects=True,
) )
# Special handling for deleted object
if resp.status_code == 410:
raise ObjectIsGoneError(f"{url} is gone")
resp.raise_for_status() resp.raise_for_status()
try: try:
return resp.json() return resp.json()

View File

@ -244,7 +244,7 @@ def admin_actions_new(
files: list[UploadFile], files: list[UploadFile],
content: str = Form(), content: str = Form(),
redirect_url: str = Form(), redirect_url: str = Form(),
in_reply_to: str | None = Form(), in_reply_to: str | None = Form(None),
csrf_check: None = Depends(verify_csrf_token), csrf_check: None = Depends(verify_csrf_token),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:

View File

@ -53,7 +53,7 @@ class Object:
return ap.object_visibility(self.ap_object) return ap.object_visibility(self.ap_object)
@property @property
def context(self) -> str | None: def ap_context(self) -> str | None:
return self.ap_object.get("context") or self.ap_object.get("conversation") return self.ap_object.get("context") or self.ap_object.get("conversation")
@property @property

View File

@ -49,7 +49,7 @@ def save_outbox_object(
public_id=public_id, public_id=public_id,
ap_type=ra.ap_type, ap_type=ra.ap_type,
ap_id=ra.ap_id, ap_id=ra.ap_id,
ap_context=ra.context, ap_context=ra.ap_context,
ap_object=ra.ap_object, ap_object=ra.ap_object,
visibility=ra.visibility, visibility=ra.visibility,
og_meta=ra.og_meta, og_meta=ra.og_meta,
@ -233,9 +233,9 @@ def send_create(
in_reply_to_object = get_anybox_object_by_ap_id(db, in_reply_to) in_reply_to_object = get_anybox_object_by_ap_id(db, in_reply_to)
if not in_reply_to_object: if not in_reply_to_object:
raise ValueError(f"Invalid in reply to {in_reply_to=}") raise ValueError(f"Invalid in reply to {in_reply_to=}")
if not in_reply_to_object.context: if not in_reply_to_object.ap_context:
raise ValueError("Object has no context") raise ValueError("Object has no context")
context = in_reply_to_object.context context = in_reply_to_object.ap_context
for (upload, filename) in uploads: for (upload, filename) in uploads:
attachments.append(upload_to_attachment(upload, filename)) attachments.append(upload_to_attachment(upload, filename))
@ -544,7 +544,7 @@ def save_to_inbox(db: Session, raw_object: ap.RawObject) -> None:
ap_actor_id=actor.ap_id, ap_actor_id=actor.ap_id,
ap_type=ra.ap_type, ap_type=ra.ap_type,
ap_id=ra.ap_id, ap_id=ra.ap_id,
ap_context=ra.context, ap_context=ra.ap_context,
ap_published_at=ap_published_at, ap_published_at=ap_published_at,
ap_object=ra.ap_object, ap_object=ra.ap_object,
visibility=ra.visibility, visibility=ra.visibility,
@ -651,7 +651,7 @@ def save_to_inbox(db: Session, raw_object: ap.RawObject) -> None:
ap_actor_id=announced_actor.ap_id, ap_actor_id=announced_actor.ap_id,
ap_type=announced_object.ap_type, ap_type=announced_object.ap_type,
ap_id=announced_object.ap_id, ap_id=announced_object.ap_id,
ap_context=announced_object.context, ap_context=announced_object.ap_context,
ap_published_at=announced_object.ap_published_at, ap_published_at=announced_object.ap_published_at,
ap_object=announced_object.ap_object, ap_object=announced_object.ap_object,
visibility=announced_object.visibility, visibility=announced_object.visibility,

View File

@ -19,6 +19,7 @@ from Crypto.Hash import SHA256
from Crypto.Signature import PKCS1_v1_5 from Crypto.Signature import PKCS1_v1_5
from loguru import logger from loguru import logger
from app import activitypub as ap
from app import config from app import config
from app.key import Key from app.key import Key
from app.key import get_key from app.key import get_key
@ -63,6 +64,7 @@ def _body_digest(body: bytes) -> str:
@lru_cache(32) @lru_cache(32)
def _get_public_key(key_id: str) -> Key: def _get_public_key(key_id: str) -> Key:
# TODO: use DB to use cache actor
from app import activitypub as ap from app import activitypub as ap
actor = ap.fetch(key_id) actor = ap.fetch(key_id)
@ -110,6 +112,9 @@ async def httpsig_checker(
try: try:
k = _get_public_key(hsig["keyId"]) k = _get_public_key(hsig["keyId"])
except ap.ObjectIsGoneError:
logger.info("Actor is gone")
return HTTPSigInfo(has_valid_signature=False)
except Exception: except Exception:
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}') logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
return HTTPSigInfo(has_valid_signature=False) return HTTPSigInfo(has_valid_signature=False)

View File

@ -2,6 +2,8 @@ import base64
import os import os
import sys import sys
import time import time
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
from typing import Any from typing import Any
@ -27,6 +29,7 @@ from starlette.responses import JSONResponse
from app import activitypub as ap from app import activitypub as ap
from app import admin from app import admin
from app import boxes
from app import config from app import config
from app import httpsig from app import httpsig
from app import models from app import models
@ -368,6 +371,14 @@ def outbox(
) )
@dataclass
class ReplyTreeNode:
ap_object: boxes.AnyboxObject
children: list["ReplyTreeNode"]
is_requested: bool = False
is_root: bool = False
@app.get("/o/{public_id}") @app.get("/o/{public_id}")
def outbox_by_public_id( def outbox_by_public_id(
public_id: str, public_id: str,
@ -385,7 +396,7 @@ def outbox_by_public_id(
) )
.filter( .filter(
models.OutboxObject.public_id == public_id, models.OutboxObject.public_id == public_id,
# models.OutboxObject.is_deleted.is_(False), models.OutboxObject.is_deleted.is_(False),
) )
.one_or_none() .one_or_none()
) )
@ -395,6 +406,66 @@ def outbox_by_public_id(
if is_activitypub_requested(request): if is_activitypub_requested(request):
return ActivityPubResponse(maybe_object.ap_object) return ActivityPubResponse(maybe_object.ap_object)
# TODO: handle visibility
tree_nodes: list[boxes.AnyboxObject] = [maybe_object]
tree_nodes.extend(
db.query(models.InboxObject)
.filter(
models.InboxObject.ap_context == maybe_object.ap_context,
)
.all()
)
tree_nodes.extend(
db.query(models.OutboxObject)
.filter(
models.OutboxObject.ap_context == maybe_object.ap_context,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.id != maybe_object.id,
)
.all()
)
logger.info(f"root={maybe_object.ap_id}")
nodes_by_in_reply_to = defaultdict(list)
for node in tree_nodes:
nodes_by_in_reply_to[node.in_reply_to].append(node)
logger.info(f"in_reply_to={node.in_reply_to}")
logger.info(nodes_by_in_reply_to)
# TODO: get oldest if we cannot get to root?
if len(nodes_by_in_reply_to.get(None, [])) != 1:
raise ValueError("Failed to compute replies tree")
def _get_reply_node_children(
node: ReplyTreeNode,
index: defaultdict[str | None, list[boxes.AnyboxObject]],
) -> list[ReplyTreeNode]:
children = []
for child in index.get(node.ap_object.ap_id, []): # type: ignore
logger.info(f"{child=}")
child_node = ReplyTreeNode(
ap_object=child,
is_requested=child.ap_id == maybe_object.ap_id, # type: ignore
children=[],
)
child_node.children = _get_reply_node_children(child_node, index)
children.append(child_node)
return sorted(
children,
key=lambda node: node.ap_object.ap_published_at, # type: ignore
)
root_node = ReplyTreeNode(
ap_object=nodes_by_in_reply_to[None][0],
# ap_object=maybe_object,
is_root=True,
is_requested=nodes_by_in_reply_to[None][0].ap_id == maybe_object.ap_id,
children=[],
)
root_node.children = _get_reply_node_children(root_node, nodes_by_in_reply_to)
logger.info(root_node.ap_object.ap_id)
logger.info(root_node)
return templates.render_template( return templates.render_template(
db, db,
request, request,
@ -414,7 +485,10 @@ def outbox_activity_by_public_id(
# TODO: ACL? # TODO: ACL?
maybe_object = ( maybe_object = (
db.query(models.OutboxObject) db.query(models.OutboxObject)
.filter(models.OutboxObject.public_id == public_id) .filter(
models.OutboxObject.public_id == public_id,
models.OutboxObject.is_deleted.is_(False),
)
.one_or_none() .one_or_none()
) )
if not maybe_object: if not maybe_object:

View File

@ -156,7 +156,7 @@ class OutboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory):
public_id=public_id, public_id=public_id,
ap_type=ro.ap_type, ap_type=ro.ap_type,
ap_id=ro.ap_id, ap_id=ro.ap_id,
ap_context=ro.context, ap_context=ro.ap_context,
ap_object=ro.ap_object, ap_object=ro.ap_object,
visibility=ro.visibility, visibility=ro.visibility,
og_meta=ro.og_meta, og_meta=ro.og_meta,
@ -194,7 +194,7 @@ class InboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory):
ap_actor_id=actor.ap_id, ap_actor_id=actor.ap_id,
ap_type=ro.ap_type, ap_type=ro.ap_type,
ap_id=ro.ap_id, ap_id=ro.ap_id,
ap_context=ro.context, ap_context=ro.ap_context,
ap_published_at=ap_published_at, ap_published_at=ap_published_at,
ap_object=ro.ap_object, ap_object=ro.ap_object,
visibility=ro.visibility, visibility=ro.visibility,