mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2025-06-05 21:59:23 +02:00
Compare commits
2 Commits
2.0.0-rc.5
...
quote-url-
Author | SHA1 | Date | |
---|---|---|---|
0f1fdd3944 | |||
254588f7c0 |
@ -0,0 +1,34 @@
|
|||||||
|
"""Add support for quote URL
|
||||||
|
|
||||||
|
Revision ID: c3027d0e18dc
|
||||||
|
Revises: 604d125ea2fb
|
||||||
|
Create Date: 2022-09-21 07:08:24.568124+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c3027d0e18dc'
|
||||||
|
down_revision = '604d125ea2fb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('quoted_inbox_object_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_quoted_inbox_object_id', 'inbox', ['quoted_inbox_object_id'], ['id'])
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_quoted_inbox_object_id', type_='foreignkey')
|
||||||
|
batch_op.drop_column('quoted_inbox_object_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.config import ALSO_KNOWN_AS
|
from app.config import ALSO_KNOWN_AS
|
||||||
@ -13,7 +14,6 @@ from app.config import AP_CONTENT_TYPE # noqa: F401
|
|||||||
from app.config import MOVED_TO
|
from app.config import MOVED_TO
|
||||||
from app.httpsig import auth
|
from app.httpsig import auth
|
||||||
from app.key import get_pubkey_as_pem
|
from app.key import get_pubkey_as_pem
|
||||||
from app.source import dedup_tags
|
|
||||||
from app.source import hashtagify
|
from app.source import hashtagify
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
|
|
||||||
@ -53,26 +53,15 @@ AS_EXTENDED_CTX = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class FetchError(Exception):
|
class ObjectIsGoneError(Exception):
|
||||||
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
|
||||||
resp_part = ""
|
|
||||||
if resp:
|
|
||||||
resp_part = f", got HTTP {resp.status_code}: {resp.text}"
|
|
||||||
message = f"Failed to fetch {url}{resp_part}"
|
|
||||||
super().__init__(message)
|
|
||||||
self.resp = resp
|
|
||||||
self.url = url
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectIsGoneError(FetchError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ObjectNotFoundError(FetchError):
|
class ObjectNotFoundError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ObjectUnavailableError(FetchError):
|
class ObjectUnavailableError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -101,19 +90,6 @@ class VisibilityEnum(str, enum.Enum):
|
|||||||
|
|
||||||
|
|
||||||
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
||||||
_LOCAL_ACTOR_METADATA = []
|
|
||||||
if config.CONFIG.metadata:
|
|
||||||
for kv in config.CONFIG.metadata:
|
|
||||||
kv_value, kv_tags = hashtagify(kv.value)
|
|
||||||
_LOCAL_ACTOR_METADATA.append(
|
|
||||||
{
|
|
||||||
"name": kv.key,
|
|
||||||
"type": "PropertyValue",
|
|
||||||
"value": kv_value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_LOCAL_ACTOR_TAGS.extend(kv_tags)
|
|
||||||
|
|
||||||
|
|
||||||
ME = {
|
ME = {
|
||||||
"@context": AS_EXTENDED_CTX,
|
"@context": AS_EXTENDED_CTX,
|
||||||
@ -126,7 +102,7 @@ ME = {
|
|||||||
"outbox": config.BASE_URL + "/outbox",
|
"outbox": config.BASE_URL + "/outbox",
|
||||||
"preferredUsername": config.USERNAME,
|
"preferredUsername": config.USERNAME,
|
||||||
"name": config.CONFIG.name,
|
"name": config.CONFIG.name,
|
||||||
"summary": _LOCAL_ACTOR_SUMMARY,
|
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
# For compat with servers expecting a sharedInbox...
|
# For compat with servers expecting a sharedInbox...
|
||||||
"sharedInbox": config.BASE_URL
|
"sharedInbox": config.BASE_URL
|
||||||
@ -134,7 +110,16 @@ ME = {
|
|||||||
},
|
},
|
||||||
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
||||||
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
||||||
"attachment": _LOCAL_ACTOR_METADATA,
|
"attachment": [
|
||||||
|
{
|
||||||
|
"name": kv.key,
|
||||||
|
"type": "PropertyValue",
|
||||||
|
"value": markdown(kv.value, extensions=["mdx_linkify", "fenced_code"]),
|
||||||
|
}
|
||||||
|
for kv in config.CONFIG.metadata
|
||||||
|
]
|
||||||
|
if config.CONFIG.metadata
|
||||||
|
else [],
|
||||||
"icon": {
|
"icon": {
|
||||||
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
||||||
"type": "Image",
|
"type": "Image",
|
||||||
@ -145,7 +130,7 @@ ME = {
|
|||||||
"owner": config.ID,
|
"owner": config.ID,
|
||||||
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
||||||
},
|
},
|
||||||
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
|
"tag": _LOCAL_ACTOR_TAGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ALSO_KNOWN_AS:
|
if ALSO_KNOWN_AS:
|
||||||
@ -185,17 +170,13 @@ async def fetch(
|
|||||||
|
|
||||||
# Special handling for deleted object
|
# Special handling for deleted object
|
||||||
if resp.status_code == 410:
|
if resp.status_code == 410:
|
||||||
raise ObjectIsGoneError(url, resp)
|
raise ObjectIsGoneError(f"{url} is gone")
|
||||||
elif resp.status_code in [401, 403]:
|
elif resp.status_code in [401, 403]:
|
||||||
raise ObjectUnavailableError(url, resp)
|
raise ObjectUnavailableError(f"not allowed to fetch {url}")
|
||||||
elif resp.status_code == 404:
|
elif resp.status_code == 404:
|
||||||
raise ObjectNotFoundError(url, resp)
|
raise ObjectNotFoundError(f"{url} not found")
|
||||||
|
|
||||||
try:
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except httpx.HTTPError as http_error:
|
|
||||||
raise FetchError(url, resp) from http_error
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
43
app/actor.py
43
app/actor.py
@ -1,7 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@ -13,8 +12,6 @@ from sqlalchemy.orm import joinedload
|
|||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app import media
|
from app import media
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.utils.datetime import as_utc
|
|
||||||
from app.utils.datetime import now
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from app.models import Actor as ActorModel
|
from app.models import Actor as ActorModel
|
||||||
@ -192,26 +189,8 @@ async def fetch_actor(
|
|||||||
if existing_actor:
|
if existing_actor:
|
||||||
if existing_actor.is_deleted:
|
if existing_actor.is_deleted:
|
||||||
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
||||||
|
|
||||||
if now() - as_utc(existing_actor.updated_at) > timedelta(hours=24):
|
|
||||||
logger.info(
|
|
||||||
f"Refreshing {actor_id=} last updated {existing_actor.updated_at}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ap_actor = await ap.fetch(actor_id)
|
|
||||||
await update_actor_if_needed(
|
|
||||||
db_session,
|
|
||||||
existing_actor,
|
|
||||||
RemoteActor(ap_actor),
|
|
||||||
)
|
|
||||||
return existing_actor
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to refresh {actor_id}")
|
|
||||||
# If we fail to refresh the actor, return the cached one
|
|
||||||
return existing_actor
|
return existing_actor
|
||||||
else:
|
else:
|
||||||
return existing_actor
|
|
||||||
|
|
||||||
if save_if_not_found:
|
if save_if_not_found:
|
||||||
ap_actor = await ap.fetch(actor_id)
|
ap_actor = await ap.fetch(actor_id)
|
||||||
# Some softwares uses URL when we expect ID
|
# Some softwares uses URL when we expect ID
|
||||||
@ -225,31 +204,11 @@ async def fetch_actor(
|
|||||||
)
|
)
|
||||||
).one_or_none()
|
).one_or_none()
|
||||||
if existing_actor_by_url:
|
if existing_actor_by_url:
|
||||||
# Update the actor as we had to fetch it anyway
|
|
||||||
await update_actor_if_needed(
|
|
||||||
db_session,
|
|
||||||
existing_actor_by_url,
|
|
||||||
RemoteActor(ap_actor),
|
|
||||||
)
|
|
||||||
return existing_actor_by_url
|
return existing_actor_by_url
|
||||||
|
|
||||||
return await save_actor(db_session, ap_actor)
|
return await save_actor(db_session, ap_actor)
|
||||||
else:
|
else:
|
||||||
raise ap.ObjectNotFoundError(actor_id)
|
raise ap.ObjectNotFoundError
|
||||||
|
|
||||||
|
|
||||||
async def update_actor_if_needed(
|
|
||||||
db_session: AsyncSession,
|
|
||||||
actor_in_db: "ActorModel",
|
|
||||||
ra: RemoteActor,
|
|
||||||
) -> None:
|
|
||||||
# Check if we actually need to udpte the actor in DB
|
|
||||||
if _actor_hash(ra) != _actor_hash(actor_in_db):
|
|
||||||
actor_in_db.ap_actor = ra.ap_actor
|
|
||||||
actor_in_db.handle = ra.handle
|
|
||||||
actor_in_db.ap_type = ra.ap_type
|
|
||||||
actor_in_db.updated_at = now()
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
|
from loguru import logger
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
@ -75,6 +76,10 @@ class Object:
|
|||||||
def tags(self) -> list[ap.RawObject]:
|
def tags(self) -> list[ap.RawObject]:
|
||||||
return ap.as_list(self.ap_object.get("tag", []))
|
return ap.as_list(self.ap_object.get("tag", []))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote_url(self) -> str | None:
|
||||||
|
return self.ap_object.get("quoteUrl")
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def inlined_images(self) -> set[str]:
|
def inlined_images(self) -> set[str]:
|
||||||
image_urls: set[str] = set()
|
image_urls: set[str] = set()
|
||||||
@ -156,7 +161,7 @@ class Object:
|
|||||||
@cached_property
|
@cached_property
|
||||||
def url(self) -> str | None:
|
def url(self) -> str | None:
|
||||||
obj_url = self.ap_object.get("url")
|
obj_url = self.ap_object.get("url")
|
||||||
if isinstance(obj_url, str) and obj_url:
|
if isinstance(obj_url, str):
|
||||||
return obj_url
|
return obj_url
|
||||||
elif obj_url:
|
elif obj_url:
|
||||||
for u in ap.as_list(obj_url):
|
for u in ap.as_list(obj_url):
|
||||||
@ -277,22 +282,17 @@ class Attachment(BaseModel):
|
|||||||
proxied_url: str | None = None
|
proxied_url: str | None = None
|
||||||
resized_url: str | None = None
|
resized_url: str | None = None
|
||||||
|
|
||||||
@property
|
|
||||||
def mimetype(self) -> str:
|
|
||||||
mimetype = self.media_type
|
|
||||||
if not mimetype:
|
|
||||||
mimetype, _ = mimetypes.guess_type(self.url)
|
|
||||||
|
|
||||||
if not mimetype:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
return mimetype.split("/")[-1]
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteObject(Object):
|
class RemoteObject(Object):
|
||||||
def __init__(self, raw_object: ap.RawObject, actor: Actor):
|
def __init__(
|
||||||
|
self,
|
||||||
|
raw_object: ap.RawObject,
|
||||||
|
actor: Actor,
|
||||||
|
quoted_object: Object | None = None,
|
||||||
|
):
|
||||||
self._raw_object = raw_object
|
self._raw_object = raw_object
|
||||||
self._actor = actor
|
self._actor = actor
|
||||||
|
self._quoted_object = quoted_object
|
||||||
|
|
||||||
if self._actor.ap_id != ap.get_actor_id(self._raw_object):
|
if self._actor.ap_id != ap.get_actor_id(self._raw_object):
|
||||||
raise ValueError(f"Invalid actor {self._actor.ap_id}")
|
raise ValueError(f"Invalid actor {self._actor.ap_id}")
|
||||||
@ -302,6 +302,7 @@ class RemoteObject(Object):
|
|||||||
cls,
|
cls,
|
||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
actor: Actor | None = None,
|
actor: Actor | None = None,
|
||||||
|
fetch_quoted_url: bool = True,
|
||||||
):
|
):
|
||||||
# Pre-fetch the actor
|
# Pre-fetch the actor
|
||||||
actor_id = ap.get_actor_id(raw_object)
|
actor_id = ap.get_actor_id(raw_object)
|
||||||
@ -318,7 +319,17 @@ class RemoteObject(Object):
|
|||||||
ap_actor=await ap.fetch(ap.get_actor_id(raw_object)),
|
ap_actor=await ap.fetch(ap.get_actor_id(raw_object)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(raw_object, _actor)
|
quoted_object: Object | None = None
|
||||||
|
if quote_url := raw_object.get("quoteUrl"):
|
||||||
|
try:
|
||||||
|
quoted_object = await RemoteObject.from_raw_object(
|
||||||
|
await ap.fetch(quote_url),
|
||||||
|
fetch_quoted_url=fetch_quoted_url,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to fetch {quote_url=}")
|
||||||
|
|
||||||
|
return cls(raw_object, _actor, quoted_object=quoted_object)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def og_meta(self) -> list[dict[str, Any]] | None:
|
def og_meta(self) -> list[dict[str, Any]] | None:
|
||||||
@ -331,3 +342,9 @@ class RemoteObject(Object):
|
|||||||
@property
|
@property
|
||||||
def actor(self) -> Actor:
|
def actor(self) -> Actor:
|
||||||
return self._actor
|
return self._actor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_object(self) -> Optional["RemoteObject"]:
|
||||||
|
if self._quoted_object:
|
||||||
|
return self._quoted_object
|
||||||
|
return None
|
||||||
|
106
app/boxes.py
106
app/boxes.py
@ -24,7 +24,6 @@ from app.actor import Actor
|
|||||||
from app.actor import RemoteActor
|
from app.actor import RemoteActor
|
||||||
from app.actor import fetch_actor
|
from app.actor import fetch_actor
|
||||||
from app.actor import save_actor
|
from app.actor import save_actor
|
||||||
from app.actor import update_actor_if_needed
|
|
||||||
from app.ap_object import RemoteObject
|
from app.ap_object import RemoteObject
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import BLOCKED_SERVERS
|
from app.config import BLOCKED_SERVERS
|
||||||
@ -33,7 +32,6 @@ from app.config import MANUALLY_APPROVES_FOLLOWERS
|
|||||||
from app.config import set_moved_to
|
from app.config import set_moved_to
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.outgoing_activities import new_outgoing_activity
|
from app.outgoing_activities import new_outgoing_activity
|
||||||
from app.source import dedup_tags
|
|
||||||
from app.source import markdownify
|
from app.source import markdownify
|
||||||
from app.uploads import upload_to_attachment
|
from app.uploads import upload_to_attachment
|
||||||
from app.utils import opengraph
|
from app.utils import opengraph
|
||||||
@ -290,7 +288,6 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
|
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
|
||||||
outbox_object_to_undo.is_deleted = True
|
|
||||||
|
|
||||||
if outbox_object_to_undo.ap_type == "Follow":
|
if outbox_object_to_undo.ap_type == "Follow":
|
||||||
if not outbox_object_to_undo.activity_object_ap_id:
|
if not outbox_object_to_undo.activity_object_ap_id:
|
||||||
@ -349,7 +346,6 @@ async def fetch_conversation_root(
|
|||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
obj: AnyboxObject | RemoteObject,
|
obj: AnyboxObject | RemoteObject,
|
||||||
is_root: bool = False,
|
is_root: bool = False,
|
||||||
depth: int = 0,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Some softwares do not set the context/conversation field (like Misskey).
|
"""Some softwares do not set the context/conversation field (like Misskey).
|
||||||
This means we have to track conversation ourselves. To do so, we fetch
|
This means we have to track conversation ourselves. To do so, we fetch
|
||||||
@ -357,11 +353,10 @@ async def fetch_conversation_root(
|
|||||||
- use the context field if set
|
- use the context field if set
|
||||||
- or build a custom conversation ID
|
- or build a custom conversation ID
|
||||||
"""
|
"""
|
||||||
logger.info(f"Fetching convo root for ap_id={obj.ap_id}/{depth=}")
|
if not obj.in_reply_to or is_root:
|
||||||
if obj.ap_context:
|
if obj.ap_context:
|
||||||
return obj.ap_context
|
return obj.ap_context
|
||||||
|
else:
|
||||||
if not obj.in_reply_to or is_root or depth > 10:
|
|
||||||
# Use the root AP ID if there'no context
|
# Use the root AP ID if there'no context
|
||||||
return f"microblogpub:root:{obj.ap_id}"
|
return f"microblogpub:root:{obj.ap_id}"
|
||||||
else:
|
else:
|
||||||
@ -376,24 +371,20 @@ async def fetch_conversation_root(
|
|||||||
)
|
)
|
||||||
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
||||||
except (
|
except (
|
||||||
ap.FetchError,
|
ap.ObjectNotFoundError,
|
||||||
|
ap.ObjectIsGoneError,
|
||||||
ap.NotAnObjectError,
|
ap.NotAnObjectError,
|
||||||
|
ap.ObjectUnavailableError,
|
||||||
):
|
):
|
||||||
return await fetch_conversation_root(
|
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||||
db_session, obj, is_root=True, depth=depth + 1
|
|
||||||
)
|
|
||||||
except httpx.HTTPStatusError as http_status_error:
|
except httpx.HTTPStatusError as http_status_error:
|
||||||
if 400 <= http_status_error.response.status_code < 500:
|
if 400 <= http_status_error.response.status_code < 500:
|
||||||
# We may not have access, in this case consider if root
|
# We may not have access, in this case consider if root
|
||||||
return await fetch_conversation_root(
|
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||||
db_session, obj, is_root=True, depth=depth + 1
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return await fetch_conversation_root(
|
return await fetch_conversation_root(db_session, in_reply_to_object)
|
||||||
db_session, in_reply_to_object, depth=depth + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_move(
|
async def send_move(
|
||||||
@ -552,7 +543,7 @@ async def send_create(
|
|||||||
"context": context,
|
"context": context,
|
||||||
"conversation": context,
|
"conversation": context,
|
||||||
"url": outbox_object_id(note_id),
|
"url": outbox_object_id(note_id),
|
||||||
"tag": dedup_tags(tags),
|
"tag": tags,
|
||||||
"summary": content_warning,
|
"summary": content_warning,
|
||||||
"inReplyTo": in_reply_to,
|
"inReplyTo": in_reply_to,
|
||||||
"sensitive": is_sensitive,
|
"sensitive": is_sensitive,
|
||||||
@ -572,7 +563,7 @@ async def send_create(
|
|||||||
for tag in tags:
|
for tag in tags:
|
||||||
if tag["type"] == "Hashtag":
|
if tag["type"] == "Hashtag":
|
||||||
tagged_object = models.TaggedOutboxObject(
|
tagged_object = models.TaggedOutboxObject(
|
||||||
tag=tag["name"][1:].lower(),
|
tag=tag["name"][1:],
|
||||||
outbox_object_id=outbox_object.id,
|
outbox_object_id=outbox_object.id,
|
||||||
)
|
)
|
||||||
db_session.add(tagged_object)
|
db_session.add(tagged_object)
|
||||||
@ -1078,17 +1069,7 @@ async def _revert_side_effect_for_deleted_object(
|
|||||||
) -> None:
|
) -> None:
|
||||||
is_delete_needs_to_be_forwarded = False
|
is_delete_needs_to_be_forwarded = False
|
||||||
|
|
||||||
# Delete related notifications
|
# Decrement the replies counter if needed
|
||||||
notif_deletion_result = await db_session.execute(
|
|
||||||
delete(models.Notification)
|
|
||||||
.where(models.Notification.inbox_object_id == deleted_ap_object.id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Deleted {notif_deletion_result.rowcount} notifications" # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decrement/refresh the replies counter if needed
|
|
||||||
if deleted_ap_object.in_reply_to:
|
if deleted_ap_object.in_reply_to:
|
||||||
replied_object = await get_anybox_object_by_ap_id(
|
replied_object = await get_anybox_object_by_ap_id(
|
||||||
db_session,
|
db_session,
|
||||||
@ -1500,7 +1481,7 @@ async def _handle_update_activity(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update the actor
|
# Update the actor
|
||||||
await update_actor_if_needed(db_session, from_actor, updated_actor)
|
from_actor.ap_actor = updated_actor.ap_actor
|
||||||
elif (ap_type := wrapped_object["type"]) in [
|
elif (ap_type := wrapped_object["type"]) in [
|
||||||
"Question",
|
"Question",
|
||||||
"Note",
|
"Note",
|
||||||
@ -1523,7 +1504,6 @@ async def _handle_update_activity(
|
|||||||
# Everything looks correct, update the object in the inbox
|
# Everything looks correct, update the object in the inbox
|
||||||
logger.info(f"Updating {existing_object.ap_id}")
|
logger.info(f"Updating {existing_object.ap_id}")
|
||||||
existing_object.ap_object = wrapped_object
|
existing_object.ap_object = wrapped_object
|
||||||
existing_object.updated_at = now()
|
|
||||||
else:
|
else:
|
||||||
# TODO(ts): support updating objects
|
# TODO(ts): support updating objects
|
||||||
logger.info(f'Cannot update {wrapped_object["type"]}')
|
logger.info(f'Cannot update {wrapped_object["type"]}')
|
||||||
@ -1534,24 +1514,8 @@ async def _handle_create_activity(
|
|||||||
from_actor: models.Actor,
|
from_actor: models.Actor,
|
||||||
create_activity: models.InboxObject,
|
create_activity: models.InboxObject,
|
||||||
forwarded_by_actor: models.Actor | None = None,
|
forwarded_by_actor: models.Actor | None = None,
|
||||||
relates_to_inbox_object: models.InboxObject | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info("Processing Create activity")
|
logger.info("Processing Create activity")
|
||||||
|
|
||||||
# Some PeerTube activities make no sense to process
|
|
||||||
if (
|
|
||||||
ap_object_type := ap.as_list(
|
|
||||||
(await ap.get_object(create_activity.ap_object))["type"]
|
|
||||||
)[0]
|
|
||||||
) in ["CacheFile"]:
|
|
||||||
logger.info(f"Dropping Create activity for {ap_object_type} object")
|
|
||||||
await db_session.delete(create_activity)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if relates_to_inbox_object:
|
|
||||||
logger.warning(f"{relates_to_inbox_object.ap_id} is already in the inbox")
|
|
||||||
return None
|
|
||||||
|
|
||||||
wrapped_object = ap.unwrap_activity(create_activity.ap_object)
|
wrapped_object = ap.unwrap_activity(create_activity.ap_object)
|
||||||
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
|
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
|
||||||
raise ValueError("Object actor does not match activity")
|
raise ValueError("Object actor does not match activity")
|
||||||
@ -1602,14 +1566,6 @@ async def _handle_read_activity(
|
|||||||
if not wrapped_object_actor.is_blocked:
|
if not wrapped_object_actor.is_blocked:
|
||||||
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor)
|
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor)
|
||||||
|
|
||||||
# Check if we already know about this object
|
|
||||||
if await get_inbox_object_by_ap_id(
|
|
||||||
db_session,
|
|
||||||
ro.ap_id,
|
|
||||||
):
|
|
||||||
logger.info(f"{ro.ap_id} is already in the inbox, skipping processing")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Then process it likes it's coming from a forwarded activity
|
# Then process it likes it's coming from a forwarded activity
|
||||||
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
|
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
|
||||||
|
|
||||||
@ -1620,8 +1576,11 @@ async def _process_note_object(
|
|||||||
from_actor: models.Actor,
|
from_actor: models.Actor,
|
||||||
ro: RemoteObject,
|
ro: RemoteObject,
|
||||||
forwarded_by_actor: models.Actor | None = None,
|
forwarded_by_actor: models.Actor | None = None,
|
||||||
) -> None:
|
process_quoted_url: bool = True,
|
||||||
if parent_activity.ap_type not in ["Create", "Read"]:
|
) -> models.InboxObject:
|
||||||
|
if process_quoted_url and parent_activity.quote_url == ro.ap_id:
|
||||||
|
logger.info(f"Processing quoted URL for {parent_activity.ap_id}")
|
||||||
|
elif parent_activity.ap_type not in ["Create", "Read"]:
|
||||||
raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}")
|
raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}")
|
||||||
|
|
||||||
ap_published_at = now()
|
ap_published_at = now()
|
||||||
@ -1664,6 +1623,7 @@ async def _process_note_object(
|
|||||||
),
|
),
|
||||||
# We may already have some replies in DB
|
# We may already have some replies in DB
|
||||||
replies_count=await _get_replies_count(db_session, ro.ap_id),
|
replies_count=await _get_replies_count(db_session, ro.ap_id),
|
||||||
|
quoted_inbox_object_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
db_session.add(inbox_object)
|
db_session.add(inbox_object)
|
||||||
@ -1744,6 +1704,28 @@ async def _process_note_object(
|
|||||||
)
|
)
|
||||||
db_session.add(notif)
|
db_session.add(notif)
|
||||||
|
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
if ro.quote_url and process_quoted_url:
|
||||||
|
try:
|
||||||
|
quoted_raw_object = await ap.fetch(ro.quote_url)
|
||||||
|
quoted_object_actor = await fetch_actor(
|
||||||
|
db_session, ap.get_actor_id(quoted_raw_object)
|
||||||
|
)
|
||||||
|
quoted_ro = RemoteObject(quoted_raw_object, quoted_object_actor)
|
||||||
|
quoted_inbox_object = await _process_note_object(
|
||||||
|
db_session,
|
||||||
|
inbox_object,
|
||||||
|
from_actor=quoted_object_actor,
|
||||||
|
ro=quoted_ro,
|
||||||
|
process_quoted_url=False,
|
||||||
|
)
|
||||||
|
inbox_object.quoted_inbox_object_id = quoted_inbox_object.id
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process quoted object")
|
||||||
|
|
||||||
|
return inbox_object
|
||||||
|
|
||||||
|
|
||||||
async def _handle_vote_answer(
|
async def _handle_vote_answer(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
@ -1993,7 +1975,7 @@ async def save_to_inbox(
|
|||||||
except ap.ObjectNotFoundError:
|
except ap.ObjectNotFoundError:
|
||||||
logger.warning("Actor not found")
|
logger.warning("Actor not found")
|
||||||
return
|
return
|
||||||
except ap.FetchError:
|
except httpx.HTTPStatusError:
|
||||||
logger.exception("Failed to fetch actor")
|
logger.exception("Failed to fetch actor")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -2101,11 +2083,7 @@ async def save_to_inbox(
|
|||||||
|
|
||||||
if activity_ro.ap_type == "Create":
|
if activity_ro.ap_type == "Create":
|
||||||
await _handle_create_activity(
|
await _handle_create_activity(
|
||||||
db_session,
|
db_session, actor, inbox_object, forwarded_by_actor=forwarded_by_actor
|
||||||
actor,
|
|
||||||
inbox_object,
|
|
||||||
forwarded_by_actor=forwarded_by_actor,
|
|
||||||
relates_to_inbox_object=relates_to_inbox_object,
|
|
||||||
)
|
)
|
||||||
elif activity_ro.ap_type == "Read":
|
elif activity_ro.ap_type == "Read":
|
||||||
await _handle_read_activity(db_session, actor, inbox_object)
|
await _handle_read_activity(db_session, actor, inbox_object)
|
||||||
|
@ -88,12 +88,8 @@ def _body_digest(body: bytes) -> str:
|
|||||||
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
async def _get_public_key(
|
async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
||||||
db_session: AsyncSession,
|
if cached_key := _KEY_CACHE.get(key_id):
|
||||||
key_id: str,
|
|
||||||
should_skip_cache: bool = False,
|
|
||||||
) -> Key:
|
|
||||||
if not should_skip_cache and (cached_key := _KEY_CACHE.get(key_id)):
|
|
||||||
logger.info(f"Key {key_id} found in cache")
|
logger.info(f"Key {key_id} found in cache")
|
||||||
return cached_key
|
return cached_key
|
||||||
|
|
||||||
@ -105,7 +101,6 @@ async def _get_public_key(
|
|||||||
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
||||||
)
|
)
|
||||||
).one_or_none()
|
).one_or_none()
|
||||||
if not should_skip_cache:
|
|
||||||
if existing_actor and existing_actor.public_key_id == key_id:
|
if existing_actor and existing_actor.public_key_id == key_id:
|
||||||
k = Key(existing_actor.ap_id, key_id)
|
k = Key(existing_actor.ap_id, key_id)
|
||||||
k.load_pub(existing_actor.public_key_as_pem)
|
k.load_pub(existing_actor.public_key_as_pem)
|
||||||
@ -115,8 +110,6 @@ async def _get_public_key(
|
|||||||
|
|
||||||
# Fetch it
|
# Fetch it
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app.actor import RemoteActor
|
|
||||||
from app.actor import update_actor_if_needed
|
|
||||||
|
|
||||||
# Without signing the request as if it's the first contact, the 2 servers
|
# Without signing the request as if it's the first contact, the 2 servers
|
||||||
# might race to fetch each other key
|
# might race to fetch each other key
|
||||||
@ -140,12 +133,6 @@ async def _get_public_key(
|
|||||||
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_skip_cache and actor["type"] != "Key" and existing_actor:
|
|
||||||
# We had to skip the cache, which means the actor key probably changed
|
|
||||||
# and we want to update our cached version
|
|
||||||
await update_actor_if_needed(db_session, existing_actor, RemoteActor(actor))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
_KEY_CACHE[key_id] = k
|
_KEY_CACHE[key_id] = k
|
||||||
return k
|
return k
|
||||||
|
|
||||||
@ -229,17 +216,7 @@ async def httpsig_checker(
|
|||||||
has_valid_signature = _verify_h(
|
has_valid_signature = _verify_h(
|
||||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
||||||
)
|
)
|
||||||
|
# FIXME: fetch/update the user if the signature is wrong
|
||||||
# If the signature is not valid, we may have to update the cached actor
|
|
||||||
if not has_valid_signature:
|
|
||||||
logger.info("Invalid signature, trying to refresh actor")
|
|
||||||
try:
|
|
||||||
k = await _get_public_key(db_session, hsig["keyId"], should_skip_cache=True)
|
|
||||||
has_valid_signature = _verify_h(
|
|
||||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to refresh actor")
|
|
||||||
|
|
||||||
httpsig_info = HTTPSigInfo(
|
httpsig_info = HTTPSigInfo(
|
||||||
has_valid_signature=has_valid_signature,
|
has_valid_signature=has_valid_signature,
|
||||||
|
47
app/main.py
47
app/main.py
@ -763,7 +763,7 @@ async def tag_by_name(
|
|||||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||||
where = [
|
where = [
|
||||||
models.TaggedOutboxObject.tag == tag.lower(),
|
models.TaggedOutboxObject.tag == tag,
|
||||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||||
models.OutboxObject.is_deleted.is_(False),
|
models.OutboxObject.is_deleted.is_(False),
|
||||||
]
|
]
|
||||||
@ -789,7 +789,7 @@ async def tag_by_name(
|
|||||||
return ActivityPubResponse(
|
return ActivityPubResponse(
|
||||||
{
|
{
|
||||||
"@context": ap.AS_CTX,
|
"@context": ap.AS_CTX,
|
||||||
"id": BASE_URL + f"/t/{tag.lower()}",
|
"id": BASE_URL + f"/t/{tag}",
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"totalItems": tagged_count,
|
"totalItems": tagged_count,
|
||||||
"orderedItems": [
|
"orderedItems": [
|
||||||
@ -883,48 +883,6 @@ async def post_remote_follow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/remote_interaction")
|
|
||||||
async def remote_interaction(
|
|
||||||
request: Request,
|
|
||||||
ap_id: str,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> templates.TemplateResponse:
|
|
||||||
outbox_object = await boxes.get_outbox_object_by_ap_id(
|
|
||||||
db_session,
|
|
||||||
ap_id,
|
|
||||||
)
|
|
||||||
if not outbox_object:
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
|
|
||||||
return await templates.render_template(
|
|
||||||
db_session,
|
|
||||||
request,
|
|
||||||
"remote_interact.html",
|
|
||||||
{"outbox_object": outbox_object},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/remote_interaction")
|
|
||||||
async def post_remote_interaction(
|
|
||||||
request: Request,
|
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
|
||||||
profile: str = Form(),
|
|
||||||
ap_id: str = Form(),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
if not profile.startswith("@"):
|
|
||||||
profile = f"@{profile}"
|
|
||||||
|
|
||||||
remote_follow_template = await get_remote_follow_template(profile)
|
|
||||||
if not remote_follow_template:
|
|
||||||
# TODO(ts): error message to user
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
|
|
||||||
return RedirectResponse(
|
|
||||||
remote_follow_template.format(uri=ap_id),
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/.well-known/webfinger")
|
@app.get("/.well-known/webfinger")
|
||||||
async def wellknown_webfinger(resource: str) -> JSONResponse:
|
async def wellknown_webfinger(resource: str) -> JSONResponse:
|
||||||
"""Exposes/servers WebFinger data."""
|
"""Exposes/servers WebFinger data."""
|
||||||
@ -1221,7 +1179,6 @@ async def robots_file():
|
|||||||
Disallow: /followers
|
Disallow: /followers
|
||||||
Disallow: /following
|
Disallow: /following
|
||||||
Disallow: /admin
|
Disallow: /admin
|
||||||
Disallow: /remote_interaction
|
|
||||||
Disallow: /remote_follow"""
|
Disallow: /remote_follow"""
|
||||||
|
|
||||||
|
|
||||||
|
@ -113,6 +113,18 @@ class InboxObject(Base, BaseObject):
|
|||||||
uselist=False,
|
uselist=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
quoted_inbox_object_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("inbox.id", name="fk_quoted_inbox_object_id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
quoted_inbox_object: Mapped[Optional["InboxObject"]] = relationship(
|
||||||
|
"InboxObject",
|
||||||
|
foreign_keys=quoted_inbox_object_id,
|
||||||
|
remote_side=id,
|
||||||
|
uselist=False,
|
||||||
|
)
|
||||||
|
|
||||||
undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
|
undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
|
||||||
|
|
||||||
# Link the oubox AP ID to allow undo without any extra query
|
# Link the oubox AP ID to allow undo without any extra query
|
||||||
@ -147,6 +159,12 @@ class InboxObject(Base, BaseObject):
|
|||||||
def is_from_inbox(self) -> bool:
|
def is_from_inbox(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_object(self) -> Optional["InboxObject"]:
|
||||||
|
if self.quoted_inbox_object_id:
|
||||||
|
return self.quoted_inbox_object
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class OutboxObject(Base, BaseObject):
|
class OutboxObject(Base, BaseObject):
|
||||||
__tablename__ = "outbox"
|
__tablename__ = "outbox"
|
||||||
@ -281,6 +299,10 @@ class OutboxObject(Base, BaseObject):
|
|||||||
def is_from_outbox(self) -> bool:
|
def is_from_outbox(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_object(self) -> Optional["InboxObject"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Follower(Base):
|
class Follower(Base):
|
||||||
__tablename__ = "follower"
|
__tablename__ = "follower"
|
||||||
|
@ -388,7 +388,7 @@ nav.flexbox {
|
|||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a:not(.label-btn) {
|
a {
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
&:hover, &:active {
|
&:hover, &:active {
|
||||||
@ -396,29 +396,23 @@ nav.flexbox {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.active:not(.label-btn) {
|
a.active {
|
||||||
color: $secondary-color;
|
color: $secondary-color;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// after nav.flexbox to override default behavior
|
|
||||||
a.label-btn {
|
|
||||||
color: $form-text-color;
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
color: $form-text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-object {
|
.ap-object {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
nav {
|
|
||||||
color: $muted-color;
|
|
||||||
}
|
|
||||||
.in-reply-to {
|
.in-reply-to {
|
||||||
display: inline;
|
color: $muted-color;
|
||||||
|
&:hover {
|
||||||
|
color: $secondary-color;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nav {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
.e-content, .activity-og-meta {
|
.e-content, .activity-og-meta {
|
||||||
|
178
app/source.py
178
app/source.py
@ -1,123 +1,52 @@
|
|||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from mistletoe import Document # type: ignore
|
from markdown import markdown
|
||||||
from mistletoe.html_renderer import HTMLRenderer # type: ignore
|
|
||||||
from mistletoe.span_token import SpanToken # type: ignore
|
|
||||||
from pygments import highlight # type: ignore
|
|
||||||
from pygments.formatters import HtmlFormatter # type: ignore
|
|
||||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
|
||||||
from pygments.lexers import guess_lexer # type: ignore
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import webfinger
|
from app import webfinger
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import CODE_HIGHLIGHTING_THEME
|
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.utils import emoji
|
from app.utils import emoji
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from app.actor import Actor
|
from app.actor import Actor
|
||||||
|
|
||||||
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
|
||||||
|
def _set_a_attrs(attrs, new=False):
|
||||||
|
attrs[(None, "target")] = "_blank"
|
||||||
|
attrs[(None, "class")] = "external"
|
||||||
|
attrs[(None, "rel")] = "noopener"
|
||||||
|
attrs[(None, "title")] = attrs[(None, "href")]
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
||||||
_MENTION_REGEX = re.compile(r"(@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+)")
|
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
||||||
_URL_REGEX = re.compile(
|
|
||||||
"(https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*))" # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AutoLink(SpanToken):
|
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
|
||||||
parse_inner = False
|
tags = []
|
||||||
precedence = 10
|
hashtags = re.findall(_HASHTAG_REGEX, content)
|
||||||
pattern = _URL_REGEX
|
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
|
||||||
|
for hashtag in hashtags:
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
tag = hashtag[1:]
|
||||||
self.target = match_obj.group()
|
link = f'<a href="{BASE_URL}/t/{tag}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
||||||
|
tags.append(dict(href=f"{BASE_URL}/t/{tag}", name=hashtag, type="Hashtag"))
|
||||||
|
content = content.replace(hashtag, link)
|
||||||
|
return content, tags
|
||||||
|
|
||||||
|
|
||||||
class Mention(SpanToken):
|
async def _mentionify(
|
||||||
parse_inner = False
|
|
||||||
precedence = 10
|
|
||||||
pattern = _MENTION_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class Hashtag(SpanToken):
|
|
||||||
parse_inner = False
|
|
||||||
precedence = 10
|
|
||||||
pattern = _HASHTAG_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class CustomRenderer(HTMLRenderer):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
mentioned_actors: dict[str, "Actor"] = {},
|
|
||||||
enable_mentionify: bool = True,
|
|
||||||
enable_hashtagify: bool = True,
|
|
||||||
) -> None:
|
|
||||||
extra_tokens = []
|
|
||||||
if enable_mentionify:
|
|
||||||
extra_tokens.append(Mention)
|
|
||||||
if enable_hashtagify:
|
|
||||||
extra_tokens.append(Hashtag)
|
|
||||||
super().__init__(AutoLink, *extra_tokens)
|
|
||||||
|
|
||||||
self.tags: list[dict[str, str]] = []
|
|
||||||
self.mentioned_actors = mentioned_actors
|
|
||||||
|
|
||||||
def render_auto_link(self, token: AutoLink) -> str:
|
|
||||||
template = '<a href="{target}" rel="noopener">{inner}</a>'
|
|
||||||
target = self.escape_url(token.target)
|
|
||||||
return template.format(target=target, inner=target)
|
|
||||||
|
|
||||||
def render_mention(self, token: Mention) -> str:
|
|
||||||
mention = token.target
|
|
||||||
actor = self.mentioned_actors.get(mention)
|
|
||||||
if not actor:
|
|
||||||
return mention
|
|
||||||
|
|
||||||
self.tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
|
||||||
|
|
||||||
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
|
|
||||||
return link
|
|
||||||
|
|
||||||
def render_hashtag(self, token: Hashtag) -> str:
|
|
||||||
tag = token.target[1:]
|
|
||||||
link = f'<a href="{BASE_URL}/t/{tag.lower()}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
|
||||||
self.tags.append(
|
|
||||||
dict(
|
|
||||||
href=f"{BASE_URL}/t/{tag.lower()}",
|
|
||||||
name=token.target.lower(),
|
|
||||||
type="Hashtag",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return link
|
|
||||||
|
|
||||||
def render_block_code(self, token: typing.Any) -> str:
|
|
||||||
code = token.children[0].content
|
|
||||||
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
|
|
||||||
return highlight(code, lexer, _FORMATTER)
|
|
||||||
|
|
||||||
|
|
||||||
async def _prefetch_mentioned_actors(
|
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
content: str,
|
content: str,
|
||||||
) -> dict[str, "Actor"]:
|
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
|
||||||
from app import models
|
from app import models
|
||||||
from app.actor import fetch_actor
|
from app.actor import fetch_actor
|
||||||
|
|
||||||
actors = {}
|
tags = []
|
||||||
|
mentioned_actors = []
|
||||||
for mention in re.findall(_MENTION_REGEX, content):
|
for mention in re.findall(_MENTION_REGEX, content):
|
||||||
if mention in actors:
|
|
||||||
continue
|
|
||||||
|
|
||||||
_, username, domain = mention.split("@")
|
_, username, domain = mention.split("@")
|
||||||
actor = (
|
actor = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
@ -134,27 +63,12 @@ async def _prefetch_mentioned_actors(
|
|||||||
continue
|
continue
|
||||||
actor = await fetch_actor(db_session, actor_url)
|
actor = await fetch_actor(db_session, actor_url)
|
||||||
|
|
||||||
actors[mention] = actor
|
mentioned_actors.append(actor)
|
||||||
|
tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
||||||
|
|
||||||
return actors
|
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
|
||||||
|
content = content.replace(mention, link)
|
||||||
|
return content, tags, mentioned_actors
|
||||||
def hashtagify(
|
|
||||||
content: str,
|
|
||||||
) -> tuple[str, list[dict[str, str]]]:
|
|
||||||
tags = []
|
|
||||||
with CustomRenderer(
|
|
||||||
mentioned_actors={},
|
|
||||||
enable_mentionify=False,
|
|
||||||
enable_hashtagify=True,
|
|
||||||
) as renderer:
|
|
||||||
rendered_content = renderer.render(Document(content))
|
|
||||||
tags.extend(renderer.tags)
|
|
||||||
|
|
||||||
# Handle custom emoji
|
|
||||||
tags.extend(emoji.tags(content))
|
|
||||||
|
|
||||||
return rendered_content, tags
|
|
||||||
|
|
||||||
|
|
||||||
async def markdownify(
|
async def markdownify(
|
||||||
@ -168,33 +82,17 @@ async def markdownify(
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
tags = []
|
tags = []
|
||||||
mentioned_actors: dict[str, "Actor"] = {}
|
mentioned_actors: list["Actor"] = []
|
||||||
|
if enable_hashtagify:
|
||||||
|
content, hashtag_tags = hashtagify(content)
|
||||||
|
tags.extend(hashtag_tags)
|
||||||
if enable_mentionify:
|
if enable_mentionify:
|
||||||
mentioned_actors = await _prefetch_mentioned_actors(db_session, content)
|
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
|
||||||
|
tags.extend(mention_tags)
|
||||||
with CustomRenderer(
|
|
||||||
mentioned_actors=mentioned_actors,
|
|
||||||
enable_mentionify=enable_mentionify,
|
|
||||||
enable_hashtagify=enable_hashtagify,
|
|
||||||
) as renderer:
|
|
||||||
rendered_content = renderer.render(Document(content))
|
|
||||||
tags.extend(renderer.tags)
|
|
||||||
|
|
||||||
# Handle custom emoji
|
# Handle custom emoji
|
||||||
tags.extend(emoji.tags(content))
|
tags.extend(emoji.tags(content))
|
||||||
|
|
||||||
return rendered_content, tags, list(mentioned_actors.values())
|
content = markdown(content, extensions=["mdx_linkify", "fenced_code"])
|
||||||
|
|
||||||
|
return content, tags, mentioned_actors
|
||||||
def dedup_tags(tags: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
||||||
idx = set()
|
|
||||||
deduped_tags = []
|
|
||||||
for tag in tags:
|
|
||||||
tag_idx = (tag["type"], tag["name"])
|
|
||||||
if tag_idx in idx:
|
|
||||||
continue
|
|
||||||
|
|
||||||
idx.add(tag_idx)
|
|
||||||
deduped_tags.append(tag)
|
|
||||||
|
|
||||||
return deduped_tags
|
|
||||||
|
@ -12,16 +12,18 @@
|
|||||||
{% for outbox_object in outbox %}
|
{% for outbox_object in outbox %}
|
||||||
|
|
||||||
{% if outbox_object.ap_type == "Announce" %}
|
{% if outbox_object.ap_type == "Announce" %}
|
||||||
<div class="actor-action">You shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You shared</div>
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
{% elif outbox_object.ap_type == "Like" %}
|
{% elif outbox_object.ap_type == "Like" %}
|
||||||
<div class="actor-action">You liked <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You liked</div>
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
{% elif outbox_object.ap_type == "Follow" %}
|
{% elif outbox_object.ap_type == "Follow" %}
|
||||||
<div class="actor-action">You followed <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You followed</div>
|
||||||
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
||||||
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
||||||
{{ utils.display_object(outbox_object) }}
|
{{ utils.display_object(outbox_object) }}
|
||||||
|
{% else %}
|
||||||
|
Implement {{ outbox_object.ap_type }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
{%- import "utils.html" as utils with context -%}
|
|
||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>Interact from your instance</title>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% include "header.html" %}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<h2>Interact with this object</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ utils.display_object(outbox_object) }}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<form class="form" action="{{ url_for("post_remote_interaction") }}" method="POST">
|
|
||||||
{{ utils.embed_csrf_token() }}
|
|
||||||
<input type="text" name="profile" placeholder="you@instance.tld" autofocus>
|
|
||||||
<input type="hidden" name="ap_id" value="{{ outbox_object.ap_id }}">
|
|
||||||
<input type="submit" value="interact from your instance">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -219,9 +219,7 @@
|
|||||||
{% if metadata.is_following %}
|
{% if metadata.is_following %}
|
||||||
<li>already following</li>
|
<li>already following</li>
|
||||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
||||||
{% if not with_details %}
|
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
|
||||||
{% elif metadata.is_follow_request_sent %}
|
{% elif metadata.is_follow_request_sent %}
|
||||||
<li>follow request sent</li>
|
<li>follow request sent</li>
|
||||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
||||||
@ -233,7 +231,7 @@
|
|||||||
{% if not metadata.is_following and not with_details %}
|
{% if not metadata.is_following and not with_details %}
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif actor.is_from_db and not with_details and not metadata.is_following %}
|
{% elif actor.is_from_db and not with_details %}
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if actor.moved_to %}
|
{% if actor.moved_to %}
|
||||||
@ -263,9 +261,6 @@
|
|||||||
<li>rejected</li>
|
<li>rejected</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if with_details %}
|
|
||||||
<li><a href="{{ actor.url }}" class="label-btn">remote profile</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -343,13 +338,11 @@
|
|||||||
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
||||||
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
|
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
|
||||||
{% elif attachment.type == "Link" %}
|
{% elif attachment.type == "Link" %}
|
||||||
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
|
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.url }}"{% endif %} class="attachment">
|
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
|
||||||
{% if attachment.name %}{{ attachment.name }}{% else %}{{ attachment.url | truncate(64, True) }}{% endif %}
|
|
||||||
</a> ({{ attachment.mimetype }})
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
{% if object.sensitive %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -376,9 +369,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.in_reply_to %}
|
{% if object.in_reply_to %}
|
||||||
<p class="in-reply-to">in reply to <a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" rel="nofollow">
|
<a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
|
||||||
this {{ object.ap_type|lower }}
|
in reply to {{ object.in_reply_to|truncate(64, True) }}
|
||||||
</a></p>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_type == "Article" %}
|
{% if object.ap_type == "Article" %}
|
||||||
@ -402,6 +395,13 @@
|
|||||||
{{ object.content | clean_html(object) | safe }}
|
{{ object.content | clean_html(object) | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if object.quoted_object %}
|
||||||
|
<div class="ap-object-expanded ap-quoted-object">
|
||||||
|
{{ display_object(object.quoted_object) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if object.ap_type == "Question" %}
|
{% if object.ap_type == "Question" %}
|
||||||
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
@ -468,16 +468,6 @@
|
|||||||
<li>
|
<li>
|
||||||
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if object.is_from_outbox and is_object_page and not is_admin and not request.url.path.startswith("/remote_interaction") %}
|
|
||||||
<li>
|
|
||||||
<a class="label-btn" href="{{ request.url_for("remote_interaction") }}?ap_id={{ object.ap_id }}">
|
|
||||||
interact from your instance
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{% if not is_article_mode %}
|
{% if not is_article_mode %}
|
||||||
<li>
|
<li>
|
||||||
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
||||||
@ -573,7 +563,7 @@
|
|||||||
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
||||||
<li>
|
<li>
|
||||||
{% if object.announced_via_outbox_object_ap_id %}
|
{% if object.announced_via_outbox_object_ap_id %}
|
||||||
{{ admin_undo_button(object.announced_via_outbox_object_ap_id, "unshare") }}
|
{{ admin_undo_button(object.liked_via_outbox_object_ap_id, "unshare") }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -9,7 +9,6 @@ from bs4 import BeautifulSoup # type: ignore
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import activitypub as ap
|
|
||||||
from app import ap_object
|
from app import ap_object
|
||||||
from app import config
|
from app import config
|
||||||
from app.actor import LOCAL_ACTOR
|
from app.actor import LOCAL_ACTOR
|
||||||
@ -67,15 +66,11 @@ async def external_urls(
|
|||||||
tags_hrefs = set()
|
tags_hrefs = set()
|
||||||
for tag in ro.tags:
|
for tag in ro.tags:
|
||||||
if tag_href := tag.get("href"):
|
if tag_href := tag.get("href"):
|
||||||
|
if tag_href and tag_href not in filter(None, [ro.quote_url]):
|
||||||
tags_hrefs.add(tag_href)
|
tags_hrefs.add(tag_href)
|
||||||
if tag.get("type") == "Mention":
|
if tag.get("type") == "Mention":
|
||||||
if tag["href"] != LOCAL_ACTOR.ap_id:
|
if tag["href"] != LOCAL_ACTOR.ap_id:
|
||||||
try:
|
|
||||||
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
||||||
except (ap.FetchError, ap.NotAnObjectError):
|
|
||||||
tags_hrefs.add(tag["href"])
|
|
||||||
continue
|
|
||||||
|
|
||||||
tags_hrefs.add(mentioned_actor.url)
|
tags_hrefs.add(mentioned_actor.url)
|
||||||
tags_hrefs.add(mentioned_actor.ap_id)
|
tags_hrefs.add(mentioned_actor.ap_id)
|
||||||
else:
|
else:
|
||||||
@ -90,7 +85,6 @@ async def external_urls(
|
|||||||
if not h:
|
if not h:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
|
||||||
ph = urlparse(h)
|
ph = urlparse(h)
|
||||||
mimetype, _ = mimetypes.guess_type(h)
|
mimetype, _ = mimetypes.guess_type(h)
|
||||||
if (
|
if (
|
||||||
@ -103,9 +97,6 @@ async def external_urls(
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
urls.add(h)
|
urls.add(h)
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to check {h}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return urls - tags_hrefs
|
return urls - tags_hrefs
|
||||||
|
|
||||||
|
@ -58,10 +58,6 @@ def is_url_valid(url: str) -> bool:
|
|||||||
logger.warning(f"{parsed.hostname} is blocked")
|
logger.warning(f"{parsed.hostname} is blocked")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if parsed.hostname.endswith(".onion"):
|
|
||||||
logger.warning(f"{url} is an onion service")
|
|
||||||
return False
|
|
||||||
|
|
||||||
ip_address = _getaddrinfo(
|
ip_address = _getaddrinfo(
|
||||||
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
||||||
)
|
)
|
||||||
|
58
poetry.lock
generated
58
poetry.lock
generated
@ -197,7 +197,7 @@ python-versions = "~=3.7"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2022.9.24"
|
version = "2022.9.14"
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -286,11 +286,11 @@ doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faker"
|
name = "faker"
|
||||||
version = "15.0.0"
|
version = "14.2.0"
|
||||||
description = "Faker is a Python package that generates fake data for you."
|
description = "Faker is a Python package that generates fake data for you."
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
python-dateutil = ">=2.4"
|
python-dateutil = ">=2.4"
|
||||||
@ -463,7 +463,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "humanize"
|
name = "humanize"
|
||||||
version = "4.4.0"
|
version = "4.3.0"
|
||||||
description = "Python humanize utilities"
|
description = "Python humanize utilities"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -582,7 +582,7 @@ source = ["Cython (>=0.29.7)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.2.3"
|
version = "1.2.2"
|
||||||
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
|
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -648,14 +648,6 @@ BeautifulSoup4 = ">=4.6.0"
|
|||||||
html5lib = ">=1.0.1"
|
html5lib = ">=1.0.1"
|
||||||
requests = ">=2.18.4"
|
requests = ">=2.18.4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mistletoe"
|
|
||||||
version = "0.9.0"
|
|
||||||
description = "A fast, extensible Markdown parser in pure Python."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "~=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "0.960"
|
version = "0.960"
|
||||||
@ -1126,7 +1118,7 @@ python-versions = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-pillow"
|
name = "types-pillow"
|
||||||
version = "9.2.2"
|
version = "9.2.1"
|
||||||
description = "Typing stubs for Pillow"
|
description = "Typing stubs for Pillow"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1142,7 +1134,7 @@ python-versions = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-requests"
|
name = "types-requests"
|
||||||
version = "2.28.11"
|
version = "2.28.10"
|
||||||
description = "Typing stubs for requests"
|
description = "Typing stubs for requests"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1161,7 +1153,7 @@ python-versions = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-urllib3"
|
name = "types-urllib3"
|
||||||
version = "1.26.25"
|
version = "1.26.24"
|
||||||
description = "Typing stubs for urllib3"
|
description = "Typing stubs for urllib3"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1283,7 +1275,7 @@ dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "bc8585a0da6f4d4e54afafde1da287ed75ed6544981d11bba561a7678bc31b8f"
|
content-hash = "84b3a6dcfc055fb0712c6abbf1bf94d9526eda940c4ddb0bd275664e68a4c3e3"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiosqlite = [
|
aiosqlite = [
|
||||||
@ -1437,8 +1429,8 @@ cachetools = [
|
|||||||
{file = "cachetools-5.2.0.tar.gz", hash = "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757"},
|
{file = "cachetools-5.2.0.tar.gz", hash = "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757"},
|
||||||
]
|
]
|
||||||
certifi = [
|
certifi = [
|
||||||
{file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"},
|
{file = "certifi-2022.9.14-py3-none-any.whl", hash = "sha256:e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516"},
|
||||||
{file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"},
|
{file = "certifi-2022.9.14.tar.gz", hash = "sha256:36973885b9542e6bd01dea287b2b4b3b21236307c56324fcc3f1160f2d655ed5"},
|
||||||
]
|
]
|
||||||
cffi = [
|
cffi = [
|
||||||
{file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
|
{file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
|
||||||
@ -1530,8 +1522,8 @@ factory-boy = [
|
|||||||
{file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
|
{file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
|
||||||
]
|
]
|
||||||
faker = [
|
faker = [
|
||||||
{file = "Faker-15.0.0-py3-none-any.whl", hash = "sha256:84c83f0ac1a2c8ecabd784c501aa0ef1d082d4aee52c3d797d586081c166434c"},
|
{file = "Faker-14.2.0-py3-none-any.whl", hash = "sha256:e02c55a5b0586caaf913cc6c254b3de178e08b031c5922e590fd033ebbdbfd02"},
|
||||||
{file = "Faker-15.0.0.tar.gz", hash = "sha256:245fc7d23470dc57164bd9a59b7b1126e16289ffcf813d88a6c8e9b8a37ea3fb"},
|
{file = "Faker-14.2.0.tar.gz", hash = "sha256:6db56e2c43a2b74250d1c332ef25fef7dc07dcb6c5fab5329dd7b4467b8ed7b9"},
|
||||||
]
|
]
|
||||||
fastapi = [
|
fastapi = [
|
||||||
{file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"},
|
{file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"},
|
||||||
@ -1667,8 +1659,8 @@ httpx = [
|
|||||||
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
|
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
|
||||||
]
|
]
|
||||||
humanize = [
|
humanize = [
|
||||||
{file = "humanize-4.4.0-py3-none-any.whl", hash = "sha256:8830ebf2d65d0395c1bd4c79189ad71e023f277c2c7ae00f263124432e6f2ffa"},
|
{file = "humanize-4.3.0-py3-none-any.whl", hash = "sha256:5dd159c9910cd57b94072e4d7decae097f0eb84c4645153706929a7f127cb2ef"},
|
||||||
{file = "humanize-4.4.0.tar.gz", hash = "sha256:efb2584565cc86b7ea87a977a15066de34cdedaf341b11c851cfcfd2b964779c"},
|
{file = "humanize-4.3.0.tar.gz", hash = "sha256:0dfac79fe8c1c0c734c14177b07b857bad9ae30dd50daa0a14e2c3d8054ee0c4"},
|
||||||
]
|
]
|
||||||
hyperframe = []
|
hyperframe = []
|
||||||
idna = [
|
idna = [
|
||||||
@ -1784,8 +1776,8 @@ lxml = [
|
|||||||
{file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"},
|
{file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"},
|
||||||
]
|
]
|
||||||
mako = [
|
mako = [
|
||||||
{file = "Mako-1.2.3-py3-none-any.whl", hash = "sha256:c413a086e38cd885088d5e165305ee8eed04e8b3f8f62df343480da0a385735f"},
|
{file = "Mako-1.2.2-py3-none-any.whl", hash = "sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534"},
|
||||||
{file = "Mako-1.2.3.tar.gz", hash = "sha256:7fde96466fcfeedb0eed94f187f20b23d85e4cb41444be0e542e2c8c65c396cd"},
|
{file = "Mako-1.2.2.tar.gz", hash = "sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f"},
|
||||||
]
|
]
|
||||||
markdown = []
|
markdown = []
|
||||||
markupsafe = [
|
markupsafe = [
|
||||||
@ -1840,10 +1832,6 @@ mdx-linkify = [
|
|||||||
mf2py = [
|
mf2py = [
|
||||||
{file = "mf2py-1.1.2.tar.gz", hash = "sha256:84f1f8f2ff3f1deb1c30be497e7ccd805452996a662fd4a77f09e0105bede2c9"},
|
{file = "mf2py-1.1.2.tar.gz", hash = "sha256:84f1f8f2ff3f1deb1c30be497e7ccd805452996a662fd4a77f09e0105bede2c9"},
|
||||||
]
|
]
|
||||||
mistletoe = [
|
|
||||||
{file = "mistletoe-0.9.0-py3-none-any.whl", hash = "sha256:11316e2fe0be422a8248293ad0efbee9ad0c6f3683b2f45bc6b989ea17a68c74"},
|
|
||||||
{file = "mistletoe-0.9.0.tar.gz", hash = "sha256:3cb96d78226d08f0d3bf09efcaf330d23902492006e18b2c06558e8b86bf7faf"},
|
|
||||||
]
|
|
||||||
mypy = [
|
mypy = [
|
||||||
{file = "mypy-0.960-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3a3e525cd76c2c4f90f1449fd034ba21fcca68050ff7c8397bb7dd25dd8b8248"},
|
{file = "mypy-0.960-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3a3e525cd76c2c4f90f1449fd034ba21fcca68050ff7c8397bb7dd25dd8b8248"},
|
||||||
{file = "mypy-0.960-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7a76dc4f91e92db119b1be293892df8379b08fd31795bb44e0ff84256d34c251"},
|
{file = "mypy-0.960-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7a76dc4f91e92db119b1be293892df8379b08fd31795bb44e0ff84256d34c251"},
|
||||||
@ -2205,18 +2193,18 @@ types-markdown = [
|
|||||||
{file = "types_Markdown-3.4.2-py3-none-any.whl", hash = "sha256:7dd9fe8bd2645ec984f438b255f42b795507b5cd2a998cc1970ec13f443dc832"},
|
{file = "types_Markdown-3.4.2-py3-none-any.whl", hash = "sha256:7dd9fe8bd2645ec984f438b255f42b795507b5cd2a998cc1970ec13f443dc832"},
|
||||||
]
|
]
|
||||||
types-pillow = [
|
types-pillow = [
|
||||||
{file = "types-Pillow-9.2.2.tar.gz", hash = "sha256:6b525b0951ada076f3aefe2347e0bff6231283ce959ad8577a4416604acc673c"},
|
{file = "types-Pillow-9.2.1.tar.gz", hash = "sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4"},
|
||||||
{file = "types_Pillow-9.2.2-py3-none-any.whl", hash = "sha256:bfe14afa1e9047a52b3dc326c93b4f57db72641794e194f11b511f68b0061814"},
|
{file = "types_Pillow-9.2.1-py3-none-any.whl", hash = "sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"},
|
||||||
]
|
]
|
||||||
types-python-dateutil = []
|
types-python-dateutil = []
|
||||||
types-requests = [
|
types-requests = [
|
||||||
{file = "types-requests-2.28.11.tar.gz", hash = "sha256:7ee827eb8ce611b02b5117cfec5da6455365b6a575f5e3ff19f655ba603e6b4e"},
|
{file = "types-requests-2.28.10.tar.gz", hash = "sha256:97d8f40aa1ffe1e58c3726c77d63c182daea9a72d9f1fa2cafdea756b2a19f2c"},
|
||||||
{file = "types_requests-2.28.11-py3-none-any.whl", hash = "sha256:af5f55e803cabcfb836dad752bd6d8a0fc8ef1cd84243061c0e27dee04ccf4fd"},
|
{file = "types_requests-2.28.10-py3-none-any.whl", hash = "sha256:45b485725ed58752f2b23461252f1c1ad9205b884a1e35f786bb295525a3e16a"},
|
||||||
]
|
]
|
||||||
types-tabulate = []
|
types-tabulate = []
|
||||||
types-urllib3 = [
|
types-urllib3 = [
|
||||||
{file = "types-urllib3-1.26.25.tar.gz", hash = "sha256:5aef0e663724eef924afa8b320b62ffef2c1736c1fa6caecfc9bc6c8ae2c3def"},
|
{file = "types-urllib3-1.26.24.tar.gz", hash = "sha256:a1b3aaea7dda3eb1b51699ee723aadd235488e4dc4648e030f09bc429ecff42f"},
|
||||||
{file = "types_urllib3-1.26.25-py3-none-any.whl", hash = "sha256:c1d78cef7bd581e162e46c20a57b2e1aa6ebecdcf01fd0713bb90978ff3e3427"},
|
{file = "types_urllib3-1.26.24-py3-none-any.whl", hash = "sha256:cf7918503d02d3576e503bbfb419b0e047c4617653bba09624756ab7175e15c9"},
|
||||||
]
|
]
|
||||||
typing-extensions = [
|
typing-extensions = [
|
||||||
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
|
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
|
||||||
|
@ -45,7 +45,6 @@ boussole = "^2.0.0"
|
|||||||
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
||||||
Brotli = "^1.0.9"
|
Brotli = "^1.0.9"
|
||||||
greenlet = "^1.1.3"
|
greenlet = "^1.1.3"
|
||||||
mistletoe = "^0.9.0"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^22.3.0"
|
black = "^22.3.0"
|
||||||
|
@ -179,7 +179,7 @@ def test_send_create_activity__with_attachment(
|
|||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
assert outbox_object.summary is None
|
assert outbox_object.summary is None
|
||||||
assert outbox_object.content == "<p>hello</p>\n"
|
assert outbox_object.content == "<p>hello</p>"
|
||||||
assert len(outbox_object.attachments) == 1
|
assert len(outbox_object.attachments) == 1
|
||||||
attachment = outbox_object.attachments[0]
|
attachment = outbox_object.attachments[0]
|
||||||
assert attachment.type == "Document"
|
assert attachment.type == "Document"
|
||||||
@ -227,7 +227,7 @@ def test_send_create_activity__no_content_with_cw_and_attachments(
|
|||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
assert outbox_object.summary is None
|
assert outbox_object.summary is None
|
||||||
assert outbox_object.content == "<p>cw</p>\n"
|
assert outbox_object.content == "<p>cw</p>"
|
||||||
assert len(outbox_object.attachments) == 1
|
assert len(outbox_object.attachments) == 1
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user