Add slug support for Article

main
Thomas Sileo 2022-10-30 17:50:59 +01:00
parent fd5293a05c
commit 3d049da2e5
4 changed files with 188 additions and 41 deletions

View File

@ -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 ###

View File

@ -41,6 +41,7 @@ from app.utils import webmentions
from app.utils.datetime import as_utc from app.utils.datetime import as_utc
from app.utils.datetime import now from app.utils.datetime import now
from app.utils.datetime import parse_isoformat from app.utils.datetime import parse_isoformat
from app.utils.text import slugify
AnyboxObject = models.InboxObject | models.OutboxObject AnyboxObject = models.InboxObject | models.OutboxObject
@ -63,6 +64,7 @@ async def save_outbox_object(
source: str | None = None, source: str | None = None,
is_transient: bool = False, is_transient: bool = False,
conversation: str | None = None, conversation: str | None = None,
slug: str | None = None,
) -> models.OutboxObject: ) -> models.OutboxObject:
ro = await RemoteObject.from_raw_object(raw_object) ro = await RemoteObject.from_raw_object(raw_object)
@ -82,6 +84,7 @@ async def save_outbox_object(
source=source, source=source,
is_transient=is_transient, is_transient=is_transient,
conversation=conversation, conversation=conversation,
slug=slug,
) )
db_session.add(outbox_object) db_session.add(outbox_object)
await db_session.flush() await db_session.flush()
@ -614,6 +617,9 @@ async def send_create(
else: else:
raise ValueError(f"Unhandled visibility {visibility}") raise ValueError(f"Unhandled visibility {visibility}")
slug = None
url = outbox_object_id(note_id)
extra_obj_attrs = {} extra_obj_attrs = {}
if ap_type == "Question": if ap_type == "Question":
if not poll_answers or len(poll_answers) < 2: if not poll_answers or len(poll_answers) < 2:
@ -643,6 +649,8 @@ async def send_create(
if not name: if not name:
raise ValueError("Article must have a 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} extra_obj_attrs = {"name": name}
obj = { obj = {
@ -656,7 +664,7 @@ async def send_create(
"published": published, "published": published,
"context": context, "context": context,
"conversation": context, "conversation": context,
"url": outbox_object_id(note_id), "url": url,
"tag": dedup_tags(tags), "tag": dedup_tags(tags),
"summary": content_warning, "summary": content_warning,
"inReplyTo": in_reply_to, "inReplyTo": in_reply_to,
@ -670,6 +678,7 @@ async def send_create(
obj, obj,
source=source, source=source,
conversation=conversation, conversation=conversation,
slug=slug,
) )
if not outbox_object.id: if not outbox_object.id:
raise ValueError("Should never happen") raise ValueError("Should never happen")

View File

@ -632,13 +632,75 @@ async def _check_outbox_object_acl(
raise HTTPException(status_code=404) 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}") @app.get("/o/{public_id}")
async def outbox_by_public_id( async def outbox_by_public_id(
public_id: str, public_id: str,
request: Request, request: Request,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse: ) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
maybe_object = ( maybe_object = (
( (
await db_session.execute( await db_session.execute(
@ -665,59 +727,79 @@ async def outbox_by_public_id(
if is_activitypub_requested(request): if is_activitypub_requested(request):
return ActivityPubResponse(maybe_object.ap_object) return ActivityPubResponse(maybe_object.ap_object)
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( replies_tree = await boxes.get_replies_tree(
db_session, db_session,
maybe_object, maybe_object,
is_current_user_admin=is_current_user_admin(request), 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( await db_session.execute(
select(models.InboxObject) select(models.OutboxObject)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.where( .where(
models.InboxObject.ap_type == "Like", models.OutboxObject.public_id.like(f"{short_id}%"),
models.InboxObject.activity_object_ap_id == maybe_object.ap_id, models.OutboxObject.slug == slug,
models.InboxObject.is_deleted.is_(False), models.OutboxObject.is_deleted.is_(False),
) )
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
) )
) )
.unique() .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 = ( likes = await _fetch_likes(db_session, maybe_object)
( shares = await _fetch_shares(db_session, maybe_object)
await db_session.scalars( webmentions = await _fetch_webmentions(db_session, maybe_object)
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()
return await templates.render_template( return await templates.render_template(
db_session, db_session,
request, request,

View File

@ -158,6 +158,7 @@ class OutboxObject(Base, BaseObject):
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False) is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
public_id = Column(String, nullable=False, index=True) public_id = Column(String, nullable=False, index=True)
slug = Column(String, nullable=True, index=True)
ap_type = Column(String, nullable=False, index=True) ap_type = Column(String, nullable=False, index=True)
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, 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: def is_from_outbox(self) -> bool:
return True 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): class Follower(Base):
__tablename__ = "follower" __tablename__ = "follower"