First shot at parsing replies tree
parent
baceb6be6c
commit
b3cbf1f6db
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
10
app/boxes.py
10
app/boxes.py
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
78
app/main.py
78
app/main.py
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue