diff --git a/alembic/versions/2022_10_30_1409-b28c0551c236_add_a_slug_field_for_outbox_objects.py b/alembic/versions/2022_10_30_1409-b28c0551c236_add_a_slug_field_for_outbox_objects.py new file mode 100644 index 0000000..d48f18c --- /dev/null +++ b/alembic/versions/2022_10_30_1409-b28c0551c236_add_a_slug_field_for_outbox_objects.py @@ -0,0 +1,48 @@ +"""Add a slug field for outbox objects + +Revision ID: b28c0551c236 +Revises: 604d125ea2fb +Create Date: 2022-10-30 14:09:14.540461+00:00 + +""" +import sqlalchemy as sa +from sqlalchemy import select +from sqlalchemy.orm.session import Session + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'b28c0551c236' +down_revision = '604d125ea2fb' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('outbox', schema=None) as batch_op: + batch_op.add_column(sa.Column('slug', sa.String(), nullable=True)) + batch_op.create_index(batch_op.f('ix_outbox_slug'), ['slug'], unique=False) + + # ### end Alembic commands ### + + # Backfill the slug for existing articles + from app.models import OutboxObject + from app.utils.text import slugify + sess = Session(op.get_bind()) + articles = sess.execute(select(OutboxObject).where( + OutboxObject.ap_type == "Article") + ).scalars() + for article in articles: + title = article.ap_object["name"] + article.slug = slugify(title) + sess.commit() + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('outbox', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_outbox_slug')) + batch_op.drop_column('slug') + + # ### end Alembic commands ### diff --git a/app/boxes.py b/app/boxes.py index c9a986c..f885be9 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -41,6 +41,7 @@ from app.utils import webmentions from app.utils.datetime import as_utc from app.utils.datetime import now from app.utils.datetime import parse_isoformat +from app.utils.text import slugify AnyboxObject = models.InboxObject | models.OutboxObject @@ -63,6 +64,7 @@ async def save_outbox_object( source: str | None = None, is_transient: bool = False, conversation: str | None = None, + slug: str | None = None, ) -> models.OutboxObject: ro = await RemoteObject.from_raw_object(raw_object) @@ -82,6 +84,7 @@ async def save_outbox_object( source=source, is_transient=is_transient, conversation=conversation, + slug=slug, ) db_session.add(outbox_object) await db_session.flush() @@ -614,6 +617,9 @@ async def send_create( else: raise ValueError(f"Unhandled visibility {visibility}") + slug = None + url = outbox_object_id(note_id) + extra_obj_attrs = {} if ap_type == "Question": if not poll_answers or len(poll_answers) < 2: @@ -643,6 +649,8 @@ async def send_create( if not name: raise ValueError("Article must have a name") + slug = slugify(name) + url = f"{BASE_URL}/articles/{note_id[:7]}/{slug}" extra_obj_attrs = {"name": name} obj = { @@ -656,7 +664,7 @@ async def send_create( "published": published, "context": context, "conversation": context, - "url": outbox_object_id(note_id), + "url": url, "tag": dedup_tags(tags), "summary": content_warning, "inReplyTo": in_reply_to, @@ -670,6 +678,7 @@ async def send_create( obj, source=source, conversation=conversation, + slug=slug, ) if not outbox_object.id: raise ValueError("Should never happen") diff --git a/app/main.py b/app/main.py index 9a1c7f2..8cce9b3 100644 --- a/app/main.py +++ b/app/main.py @@ -632,13 +632,75 @@ async def _check_outbox_object_acl( raise HTTPException(status_code=404) +async def _fetch_likes( + db_session: AsyncSession, + outbox_object: models.OutboxObject, +) -> list[models.InboxObject]: + return ( + ( + await db_session.scalars( + select(models.InboxObject) + .where( + models.InboxObject.ap_type == "Like", + models.InboxObject.activity_object_ap_id == outbox_object.ap_id, + models.InboxObject.is_deleted.is_(False), + ) + .options(joinedload(models.InboxObject.actor)) + .order_by(models.InboxObject.ap_published_at.desc()) + .limit(10) + ) + ) + .unique() + .all() + ) + + +async def _fetch_shares( + db_session: AsyncSession, + outbox_object: models.OutboxObject, +) -> list[models.InboxObject]: + return ( + ( + await db_session.scalars( + select(models.InboxObject) + .filter( + models.InboxObject.ap_type == "Announce", + models.InboxObject.activity_object_ap_id == outbox_object.ap_id, + models.InboxObject.is_deleted.is_(False), + ) + .options(joinedload(models.InboxObject.actor)) + .order_by(models.InboxObject.ap_published_at.desc()) + .limit(10) + ) + ) + .unique() + .all() + ) + + +async def _fetch_webmentions( + db_session: AsyncSession, + outbox_object: models.OutboxObject, +) -> list[models.Webmention]: + return ( + await db_session.scalars( + select(models.Webmention) + .filter( + models.Webmention.outbox_object_id == outbox_object.id, + models.Webmention.is_deleted.is_(False), + ) + .limit(10) + ) + ).all() + + @app.get("/o/{public_id}") async def outbox_by_public_id( public_id: str, request: Request, db_session: AsyncSession = Depends(get_db_session), httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), -) -> ActivityPubResponse | templates.TemplateResponse: +) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse: maybe_object = ( ( await db_session.execute( @@ -665,59 +727,79 @@ async def outbox_by_public_id( if is_activitypub_requested(request): return ActivityPubResponse(maybe_object.ap_object) + if maybe_object.ap_type == "Article": + return RedirectResponse( + f"/articles/{public_id[:7]}/{maybe_object.slug}", + status_code=301, + ) + replies_tree = await boxes.get_replies_tree( db_session, maybe_object, is_current_user_admin=is_current_user_admin(request), ) - likes = ( + likes = await _fetch_likes(db_session, maybe_object) + shares = await _fetch_shares(db_session, maybe_object) + webmentions = await _fetch_webmentions(db_session, maybe_object) + return await templates.render_template( + db_session, + request, + "object.html", + { + "replies_tree": replies_tree, + "outbox_object": maybe_object, + "likes": likes, + "shares": shares, + "webmentions": webmentions, + }, + ) + + +@app.get("/articles/{short_id}/{slug}") +async def article_by_slug( + short_id: str, + slug: str, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), +) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse: + maybe_object = ( ( - await db_session.scalars( - select(models.InboxObject) + await db_session.execute( + select(models.OutboxObject) + .options( + joinedload(models.OutboxObject.outbox_object_attachments).options( + joinedload(models.OutboxObjectAttachment.upload) + ) + ) .where( - models.InboxObject.ap_type == "Like", - models.InboxObject.activity_object_ap_id == maybe_object.ap_id, - models.InboxObject.is_deleted.is_(False), + models.OutboxObject.public_id.like(f"{short_id}%"), + models.OutboxObject.slug == slug, + models.OutboxObject.is_deleted.is_(False), ) - .options(joinedload(models.InboxObject.actor)) - .order_by(models.InboxObject.ap_published_at.desc()) - .limit(10) ) ) .unique() - .all() + .scalar_one_or_none() + ) + if not maybe_object: + raise HTTPException(status_code=404) + + await _check_outbox_object_acl(request, db_session, maybe_object, httpsig_info) + + if is_activitypub_requested(request): + return ActivityPubResponse(maybe_object.ap_object) + + replies_tree = await boxes.get_replies_tree( + db_session, + maybe_object, + is_current_user_admin=is_current_user_admin(request), ) - shares = ( - ( - await db_session.scalars( - select(models.InboxObject) - .filter( - models.InboxObject.ap_type == "Announce", - models.InboxObject.activity_object_ap_id == maybe_object.ap_id, - models.InboxObject.is_deleted.is_(False), - ) - .options(joinedload(models.InboxObject.actor)) - .order_by(models.InboxObject.ap_published_at.desc()) - .limit(10) - ) - ) - .unique() - .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() - + likes = await _fetch_likes(db_session, maybe_object) + shares = await _fetch_shares(db_session, maybe_object) + webmentions = await _fetch_webmentions(db_session, maybe_object) return await templates.render_template( db_session, request, diff --git a/app/models.py b/app/models.py index c96eb41..30fcedb 100644 --- a/app/models.py +++ b/app/models.py @@ -158,6 +158,7 @@ class OutboxObject(Base, BaseObject): is_hidden_from_homepage = Column(Boolean, nullable=False, default=False) public_id = Column(String, nullable=False, index=True) + slug = Column(String, nullable=True, index=True) ap_type = Column(String, nullable=False, index=True) ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True) @@ -281,6 +282,13 @@ class OutboxObject(Base, BaseObject): def is_from_outbox(self) -> bool: return True + @property + def url(self) -> str | None: + # XXX: rewrite old URL here for compat + if self.ap_type == "Article" and self.slug and self.public_id: + return f"{BASE_URL}/articles/{self.public_id[:7]}/{self.slug}" + return super().url + class Follower(Base): __tablename__ = "follower"