diff --git a/alembic/versions/69ce9fbdc483_add_webmentions_count.py b/alembic/versions/69ce9fbdc483_add_webmentions_count.py new file mode 100644 index 0000000..4a15626 --- /dev/null +++ b/alembic/versions/69ce9fbdc483_add_webmentions_count.py @@ -0,0 +1,28 @@ +"""Add webmentions count + +Revision ID: 69ce9fbdc483 +Revises: 1647cef23e9b +Create Date: 2022-07-14 15:35:01.716133 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = '69ce9fbdc483' +down_revision = '1647cef23e9b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('outbox', sa.Column('webmentions_count', sa.Integer(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('outbox', 'webmentions_count') + # ### end Alembic commands ### diff --git a/alembic/versions/fd23d95e5c16_improved_webmentions.py b/alembic/versions/fd23d95e5c16_improved_webmentions.py new file mode 100644 index 0000000..b6a6cc4 --- /dev/null +++ b/alembic/versions/fd23d95e5c16_improved_webmentions.py @@ -0,0 +1,48 @@ +"""Improved Webmentions + +Revision ID: fd23d95e5c16 +Revises: 69ce9fbdc483 +Create Date: 2022-07-14 16:10:54.202455 + +""" +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'fd23d95e5c16' +down_revision = '69ce9fbdc483' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('webmention', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('source', sa.String(), nullable=False), + sa.Column('source_microformats', sa.JSON(), nullable=True), + sa.Column('target', sa.String(), nullable=False), + sa.Column('outbox_object_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['outbox_object_id'], ['outbox.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('source', 'target', name='uix_source_target') + ) + op.create_index(op.f('ix_webmention_id'), 'webmention', ['id'], unique=False) + op.create_index(op.f('ix_webmention_source'), 'webmention', ['source'], unique=True) + op.create_index(op.f('ix_webmention_target'), 'webmention', ['target'], unique=False) + op.drop_column('outbox', 'webmentions') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('outbox', sa.Column('webmentions', sqlite.JSON(), nullable=True)) + op.drop_index(op.f('ix_webmention_target'), table_name='webmention') + op.drop_index(op.f('ix_webmention_source'), table_name='webmention') + op.drop_index(op.f('ix_webmention_id'), table_name='webmention') + op.drop_table('webmention') + # ### end Alembic commands ### diff --git a/app/boxes.py b/app/boxes.py index c75c4e9..bf42a5e 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -27,12 +27,12 @@ from app.ap_object import RemoteObject from app.config import BASE_URL from app.config import ID from app.database import AsyncSession -from app.database import now from app.outgoing_activities import new_outgoing_activity from app.source import markdownify from app.uploads import upload_to_attachment from app.utils import opengraph from app.utils import webmentions +from app.utils.datetime import now from app.utils.datetime import parse_isoformat AnyboxObject = models.InboxObject | models.OutboxObject diff --git a/app/database.py b/app/database.py index beba7f0..ebfdd6f 100644 --- a/app/database.py +++ b/app/database.py @@ -1,4 +1,3 @@ -import datetime from typing import Any from typing import AsyncGenerator @@ -23,10 +22,6 @@ async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit Base: Any = declarative_base() -def now() -> datetime.datetime: - return datetime.datetime.now(datetime.timezone.utc) - - async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async with async_session() as session: try: diff --git a/app/incoming_activities.py b/app/incoming_activities.py index f29e348..1f880da 100644 --- a/app/incoming_activities.py +++ b/app/incoming_activities.py @@ -13,7 +13,7 @@ from app import models from app.boxes import save_to_inbox from app.database import AsyncSession from app.database import async_session -from app.database import now +from app.utils.datetime import now _MAX_RETRIES = 5 @@ -63,7 +63,7 @@ async def process_next_incoming_activity(db_session: AsyncSession) -> bool: select(func.count(models.IncomingActivity.id)).where(*where) ) if q_count > 0: - logger.info(f"{q_count} outgoing activities ready to process") + logger.info(f"{q_count} incoming activities ready to process") if not q_count: # logger.debug("No activities to process") return False diff --git a/app/indieauth.py b/app/indieauth.py index e75531e..a0cf6d3 100644 --- a/app/indieauth.py +++ b/app/indieauth.py @@ -21,8 +21,8 @@ from app.admin import user_session_or_redirect from app.config import verify_csrf_token from app.database import AsyncSession from app.database import get_db_session -from app.database import now from app.utils import indieauth +from app.utils.datetime import now router = APIRouter() diff --git a/app/main.py b/app/main.py index ed684d8..a0ceac1 100644 --- a/app/main.py +++ b/app/main.py @@ -551,6 +551,17 @@ async def outbox_by_public_id( .all() ) + webmentions = ( + await db_session.scalars( + select(models.Webmention) + .filter( + models.Webmention.outbox_object_id == maybe_object.id, + models.Webmention.is_deleted.is_(False), + ) + .limit(10) + ) + ).all() + return await templates.render_template( db_session, request, @@ -560,6 +571,7 @@ async def outbox_by_public_id( "outbox_object": maybe_object, "likes": likes, "shares": shares, + "webmentions": webmentions, }, ) diff --git a/app/models.py b/app/models.py index 757383e..2ff1e14 100644 --- a/app/models.py +++ b/app/models.py @@ -3,6 +3,7 @@ from typing import Any from typing import Optional from typing import Union +from loguru import logger from sqlalchemy import JSON from sqlalchemy import Boolean from sqlalchemy import Column @@ -22,7 +23,8 @@ from app.ap_object import Attachment from app.ap_object import Object as BaseObject from app.config import BASE_URL from app.database import Base -from app.database import now +from app.utils import webmentions +from app.utils.datetime import now class Actor(Base, BaseActor): @@ -152,10 +154,11 @@ class OutboxObject(Base, BaseObject): likes_count = Column(Integer, nullable=False, default=0) announces_count = Column(Integer, nullable=False, default=0) replies_count = Column(Integer, nullable=False, default=0) + webmentions_count: Mapped[int] = Column( + Integer, nullable=False, default=0, server_default="0" + ) # reactions: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True) - webmentions = Column(JSON, nullable=True) - og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True) # For the featured collection @@ -457,3 +460,34 @@ class IndieAuthAccessToken(Base): expires_in = Column(Integer, nullable=False) scope = Column(String, nullable=False) is_revoked = Column(Boolean, nullable=False, default=False) + + +class Webmention(Base): + __tablename__ = "webmention" + __table_args__ = (UniqueConstraint("source", "target", name="uix_source_target"),) + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=now) + + is_deleted = Column(Boolean, nullable=False, default=False) + + source: Mapped[str] = Column(String, nullable=False, index=True, unique=True) + source_microformats: Mapped[dict[str, Any] | None] = Column(JSON, nullable=True) + + target = Column(String, nullable=False, index=True) + outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False) + outbox_object = relationship(OutboxObject, uselist=False) + + @property + def as_facepile_item(self) -> webmentions.Webmention | None: + if not self.source_microformats: + return None + try: + return webmentions.Webmention.from_microformats( + self.source_microformats["items"], self.source + ) + except Exception: + logger.warning( + f"Failed to generate facefile item for Webmention id={self.id}" + ) + return None diff --git a/app/outgoing_activities.py b/app/outgoing_activities.py index 27bd29d..e1e1d6e 100644 --- a/app/outgoing_activities.py +++ b/app/outgoing_activities.py @@ -20,8 +20,8 @@ from app.actor import _actor_hash from app.config import KEY_PATH from app.database import AsyncSession from app.database import SessionLocal -from app.database import now from app.key import Key +from app.utils.datetime import now _MAX_RETRIES = 16 diff --git a/app/templates.py b/app/templates.py index 1a98730..8ef484a 100644 --- a/app/templates.py +++ b/app/templates.py @@ -30,8 +30,8 @@ from app.config import VERSION from app.config import generate_csrf_token from app.config import session_serializer from app.database import AsyncSession -from app.database import now from app.media import proxied_media_url +from app.utils.datetime import now from app.utils.highlight import HIGHLIGHT_CSS from app.utils.highlight import highlight diff --git a/app/templates/indieauth_flow.html b/app/templates/indieauth_flow.html index 3a7f641..a1ebe9b 100644 --- a/app/templates/indieauth_flow.html +++ b/app/templates/indieauth_flow.html @@ -5,7 +5,7 @@