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"]
class ObjectIsGoneError(Exception):
pass
class VisibilityEnum(str, enum.Enum):
PUBLIC = "public"
UNLISTED = "unlisted"
@ -108,6 +112,11 @@ def fetch(url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
params=params,
follow_redirects=True,
)
# Special handling for deleted object
if resp.status_code == 410:
raise ObjectIsGoneError(f"{url} is gone")
resp.raise_for_status()
try:
return resp.json()

View file

@ -244,7 +244,7 @@ def admin_actions_new(
files: list[UploadFile],
content: 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),
db: Session = Depends(get_db),
) -> RedirectResponse:

View file

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

View file

@ -49,7 +49,7 @@ def save_outbox_object(
public_id=public_id,
ap_type=ra.ap_type,
ap_id=ra.ap_id,
ap_context=ra.context,
ap_context=ra.ap_context,
ap_object=ra.ap_object,
visibility=ra.visibility,
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)
if not in_reply_to_object:
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")
context = in_reply_to_object.context
context = in_reply_to_object.ap_context
for (upload, filename) in uploads:
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_type=ra.ap_type,
ap_id=ra.ap_id,
ap_context=ra.context,
ap_context=ra.ap_context,
ap_published_at=ap_published_at,
ap_object=ra.ap_object,
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_type=announced_object.ap_type,
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_object=announced_object.ap_object,
visibility=announced_object.visibility,

View file

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

View file

@ -2,6 +2,8 @@ import base64
import os
import sys
import time
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from io import BytesIO
from typing import Any
@ -27,6 +29,7 @@ from starlette.responses import JSONResponse
from app import activitypub as ap
from app import admin
from app import boxes
from app import config
from app import httpsig
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}")
def outbox_by_public_id(
public_id: str,
@ -385,7 +396,7 @@ def outbox_by_public_id(
)
.filter(
models.OutboxObject.public_id == public_id,
# models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_deleted.is_(False),
)
.one_or_none()
)
@ -395,6 +406,66 @@ def outbox_by_public_id(
if is_activitypub_requested(request):
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(
db,
request,
@ -414,7 +485,10 @@ def outbox_activity_by_public_id(
# TODO: ACL?
maybe_object = (
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()
)
if not maybe_object:

View file

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