Improved audience support and implement featured collection

This commit is contained in:
Thomas Sileo 2022-06-26 18:07:55 +02:00
parent ff8975acab
commit 4bf54c7040
16 changed files with 284 additions and 37 deletions

View file

@ -1,8 +1,8 @@
"""Initial migration
Revision ID: 714b4a5307c7
Revision ID: ba131b14c3a1
Revises:
Create Date: 2022-06-23 18:42:56.009810
Create Date: 2022-06-26 14:36:44.107422
"""
import sqlalchemy as sa
@ -10,7 +10,7 @@ import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '714b4a5307c7'
revision = 'ba131b14c3a1'
down_revision = None
branch_labels = None
depends_on = None
@ -81,10 +81,13 @@ def upgrade() -> None:
sa.Column('replies_count', sa.Integer(), nullable=False),
sa.Column('webmentions', sa.JSON(), nullable=True),
sa.Column('og_meta', sa.JSON(), nullable=True),
sa.Column('is_pinned', sa.Boolean(), nullable=False),
sa.Column('is_deleted', sa.Boolean(), nullable=False),
sa.Column('relates_to_inbox_object_id', sa.Integer(), nullable=True),
sa.Column('relates_to_outbox_object_id', sa.Integer(), nullable=True),
sa.Column('relates_to_actor_id', sa.Integer(), nullable=True),
sa.Column('undone_by_outbox_object_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['relates_to_actor_id'], ['actor.id'], ),
sa.ForeignKeyConstraint(['relates_to_inbox_object_id'], ['inbox.id'], ),
sa.ForeignKeyConstraint(['relates_to_outbox_object_id'], ['outbox.id'], ),
sa.ForeignKeyConstraint(['undone_by_outbox_object_id'], ['outbox.id'], ),

View file

@ -1,6 +1,7 @@
import enum
import json
import mimetypes
from typing import TYPE_CHECKING
from typing import Any
import httpx
@ -10,6 +11,9 @@ from app.config import AP_CONTENT_TYPE # noqa: F401
from app.httpsig import auth
from app.key import get_pubkey_as_pem
if TYPE_CHECKING:
from app.actor import Actor
RawObject = dict[str, Any]
AS_CTX = "https://www.w3.org/ns/activitystreams"
AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
@ -24,8 +28,18 @@ class ObjectIsGoneError(Exception):
class VisibilityEnum(str, enum.Enum):
PUBLIC = "public"
UNLISTED = "unlisted"
FOLLOWERS_ONLY = "followers-only"
DIRECT = "direct"
@staticmethod
def get_display_name(key: "VisibilityEnum") -> str:
return {
VisibilityEnum.PUBLIC: "Public - sent to followers and visible on the homepage", # noqa: E501
VisibilityEnum.UNLISTED: "Unlisted - like public, but hidden from the homepage", # noqa: E501,
VisibilityEnum.FOLLOWERS_ONLY: "Followers only",
VisibilityEnum.DIRECT: "Direct - only visible for mentioned actors",
}[key]
MICROBLOGPUB = {
"@context": [
@ -70,7 +84,7 @@ ME = {
"id": config.ID,
"following": config.BASE_URL + "/following",
"followers": config.BASE_URL + "/followers",
# "featured": ID + "/featured",
"featured": config.BASE_URL + "/featured",
"inbox": config.BASE_URL + "/inbox",
"outbox": config.BASE_URL + "/outbox",
"preferredUsername": config.USERNAME,
@ -198,13 +212,15 @@ def get_id(val: str | dict[str, Any]) -> str:
return val
def object_visibility(ap_activity: RawObject) -> VisibilityEnum:
def object_visibility(ap_activity: RawObject, actor: "Actor") -> VisibilityEnum:
to = as_list(ap_activity.get("to", []))
cc = as_list(ap_activity.get("cc", []))
if AS_PUBLIC in to:
return VisibilityEnum.PUBLIC
elif AS_PUBLIC in cc:
return VisibilityEnum.UNLISTED
elif actor.followers_collection_id in to + cc:
return VisibilityEnum.FOLLOWERS_ONLY
else:
return VisibilityEnum.DIRECT

View file

@ -97,6 +97,14 @@ class Actor:
else:
return "/static/nopic.png"
@property
def tags(self) -> list[ap.RawObject]:
return self.ap_actor.get("tag", [])
@property
def followers_collection_id(self) -> str:
return self.ap_actor["followers"]
class RemoteActor(Actor):
def __init__(self, ap_actor: ap.RawObject) -> None:

View file

@ -13,8 +13,10 @@ from app import activitypub as ap
from app import boxes
from app import models
from app import templates
from app.actor import LOCAL_ACTOR
from app.actor import get_actors_metadata
from app.boxes import get_inbox_object_by_ap_id
from app.boxes import get_outbox_object_by_ap_id
from app.boxes import send_follow
from app.config import generate_csrf_token
from app.config import session_serializer
@ -96,17 +98,32 @@ def admin_new(
in_reply_to: str | None = None,
db: Session = Depends(get_db),
) -> templates.TemplateResponse:
content = ""
in_reply_to_object = None
if in_reply_to:
in_reply_to_object = boxes.get_anybox_object_by_ap_id(db, in_reply_to)
# Add mentions to the initial note content
if not in_reply_to_object:
raise ValueError(f"Unknown object {in_reply_to=}")
if in_reply_to_object.actor.ap_id != LOCAL_ACTOR.ap_id:
content += f"{in_reply_to_object.actor.handle} "
for tag in in_reply_to_object.tags:
if tag.get("type") == "Mention" and tag["name"] != LOCAL_ACTOR.handle:
content += f'{tag["name"]} '
return templates.render_template(
db,
request,
"admin_new.html",
{"in_reply_to_object": in_reply_to_object},
{
"in_reply_to_object": in_reply_to_object,
"content": content,
"visibility_enum": [
(v.name, ap.VisibilityEnum.get_display_name(v))
for v in ap.VisibilityEnum
],
},
)
@ -194,24 +211,39 @@ def admin_inbox(
@router.get("/outbox")
def admin_outbox(
request: Request,
db: Session = Depends(get_db),
request: Request, db: Session = Depends(get_db), filter_by: str | None = None
) -> templates.TemplateResponse:
q = db.query(models.OutboxObject).filter(
models.OutboxObject.ap_type.not_in(["Accept"])
)
if filter_by:
q = q.filter(models.OutboxObject.ap_type == filter_by)
outbox = (
db.query(models.OutboxObject)
.options(
q.options(
joinedload(models.OutboxObject.relates_to_inbox_object),
joinedload(models.OutboxObject.relates_to_outbox_object),
joinedload(models.OutboxObject.relates_to_actor),
)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(20)
.all()
)
actors_metadata = get_actors_metadata(
db,
[
outbox_object.relates_to_actor
for outbox_object in outbox
if outbox_object.relates_to_actor
],
)
return templates.render_template(
db,
request,
"admin_outbox.html",
{
"actors_metadata": actors_metadata,
"outbox": outbox,
},
)
@ -288,6 +320,7 @@ def admin_profile(
models.InboxObject.actor_id == actor.id,
models.InboxObject.ap_type.in_(["Note", "Article", "Video"]),
)
.order_by(models.InboxObject.ap_published_at.desc())
.all()
)
@ -384,6 +417,38 @@ def admin_actions_unbookmark(
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/pin")
def admin_actions_pin(
request: Request,
ap_object_id: str = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db: Session = Depends(get_db),
) -> RedirectResponse:
outbox_object = get_outbox_object_by_ap_id(db, ap_object_id)
if not outbox_object:
raise ValueError("Should never happen")
outbox_object.is_pinned = True
db.commit()
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/unpin")
def admin_actions_unpin(
request: Request,
ap_object_id: str = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db: Session = Depends(get_db),
) -> RedirectResponse:
outbox_object = get_outbox_object_by_ap_id(db, ap_object_id)
if not outbox_object:
raise ValueError("Should never happen")
outbox_object.is_pinned = False
db.commit()
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/new")
def admin_actions_new(
request: Request,
@ -391,6 +456,7 @@ def admin_actions_new(
content: str = Form(),
redirect_url: str = Form(),
in_reply_to: str | None = Form(None),
visibility: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db: Session = Depends(get_db),
) -> RedirectResponse:
@ -405,6 +471,7 @@ def admin_actions_new(
source=content,
uploads=uploads,
in_reply_to=in_reply_to or None,
visibility=ap.VisibilityEnum[visibility],
)
return RedirectResponse(
request.url_for("outbox_by_public_id", public_id=public_id),

View file

@ -58,7 +58,7 @@ class Object:
@property
def visibility(self) -> ap.VisibilityEnum:
return ap.object_visibility(self.ap_object)
return ap.object_visibility(self.ap_object, self.actor)
@property
def ap_context(self) -> str | None:
@ -68,6 +68,10 @@ class Object:
def sensitive(self) -> bool:
return self.ap_object.get("sensitive", False)
@property
def tags(self) -> list[ap.RawObject]:
return self.ap_object.get("tag", [])
@property
def attachments(self) -> list["Attachment"]:
attachments = []

View file

@ -43,6 +43,7 @@ def save_outbox_object(
raw_object: ap.RawObject,
relates_to_inbox_object_id: int | None = None,
relates_to_outbox_object_id: int | None = None,
relates_to_actor_id: int | None = None,
source: str | None = None,
) -> models.OutboxObject:
ra = RemoteObject(raw_object)
@ -57,6 +58,7 @@ def save_outbox_object(
og_meta=ra.og_meta,
relates_to_inbox_object_id=relates_to_inbox_object_id,
relates_to_outbox_object_id=relates_to_outbox_object_id,
relates_to_actor_id=relates_to_actor_id,
activity_object_ap_id=ra.activity_object_ap_id,
is_hidden_from_homepage=True if ra.in_reply_to else False,
)
@ -136,7 +138,9 @@ def send_follow(db: Session, ap_actor_id: str) -> None:
"object": ap_actor_id,
}
outbox_object = save_outbox_object(db, follow_id, follow)
outbox_object = save_outbox_object(
db, follow_id, follow, relates_to_actor_id=actor.id
)
if not outbox_object.id:
raise ValueError("Should never happen")
@ -224,6 +228,7 @@ def send_create(
source: str,
uploads: list[tuple[models.Upload, str]],
in_reply_to: str | None,
visibility: ap.VisibilityEnum,
) -> str:
note_id = allocate_outbox_id()
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
@ -247,14 +252,33 @@ def send_create(
for (upload, filename) in uploads:
attachments.append(upload_to_attachment(upload, filename))
mentioned_actors = [
mention["href"] for mention in tags if mention["type"] == "Mention"
]
to = []
cc = []
if visibility == ap.VisibilityEnum.PUBLIC:
to = [ap.AS_PUBLIC]
cc = [f"{BASE_URL}/followers"] + mentioned_actors
elif visibility == ap.VisibilityEnum.UNLISTED:
to = [f"{BASE_URL}/followers"]
cc = [ap.AS_PUBLIC] + mentioned_actors
elif visibility == ap.VisibilityEnum.FOLLOWERS_ONLY:
to = [f"{BASE_URL}/followers"]
cc = mentioned_actors
elif visibility == ap.VisibilityEnum.DIRECT:
to = mentioned_actors
cc = []
note = {
"@context": ap.AS_CTX,
"type": "Note",
"id": outbox_object_id(note_id),
"attributedTo": ID,
"content": content,
"to": [ap.AS_PUBLIC],
"cc": [f"{BASE_URL}/followers"],
"to": to,
"cc": cc,
"published": published,
"context": context,
"conversation": context,

View file

@ -158,24 +158,30 @@ def index(
request: Request,
db: Session = Depends(get_db),
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
page: int | None = None,
) -> templates.TemplateResponse | ActivityPubResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
page = page or 1
q = db.query(models.OutboxObject).filter(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_hidden_from_homepage.is_(False),
)
total_count = q.count()
page_size = 2
page_offset = (page - 1) * page_size
outbox_objects = (
db.query(models.OutboxObject)
.options(
q.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.filter(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_hidden_from_homepage.is_(False),
)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(20)
.offset(page_offset)
.limit(page_size)
.all()
)
@ -183,7 +189,13 @@ def index(
db,
request,
"index.html",
{"request": request, "objects": outbox_objects},
{
"request": request,
"objects": outbox_objects,
"current_page": page,
"has_next_page": page_offset + len(outbox_objects) < total_count,
"has_previous_page": page > 1,
},
)
@ -369,6 +381,33 @@ def outbox(
)
@app.get("/featured")
def featured(
db: Session = Depends(get_db),
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse:
outbox_objects = (
db.query(models.OutboxObject)
.filter(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_pinned.is_(True),
)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(5)
.all()
)
return ActivityPubResponse(
{
"@context": DEFAULT_CTX,
"id": f"{ID}/featured",
"type": "OrderedCollection",
"totalItems": len(outbox_objects),
"orderedItems": [ap.remove_context(a.ap_object) for a in outbox_objects],
}
)
@app.get("/o/{public_id}")
def outbox_by_public_id(
public_id: str,
@ -499,7 +538,10 @@ def post_remote_follow(
@app.get("/.well-known/webfinger")
def wellknown_webfinger(resource: str) -> JSONResponse:
"""Exposes/servers WebFinger data."""
omg = f"acct:{USERNAME}@{DOMAIN}"
logger.info(f"{resource == omg}/{resource}/{omg}/{len(resource)}/{len(omg)}")
if resource not in [f"acct:{USERNAME}@{DOMAIN}", ID]:
logger.info(f"Got invalid req for {resource}")
raise HTTPException(status_code=404)
out = {
@ -651,6 +693,8 @@ def serve_proxy_media_resized(
try:
out = BytesIO(proxy_resp.content)
i = Image.open(out)
if i.is_animated:
raise ValueError
i.thumbnail((size, size))
resized_buf = BytesIO()
i.save(resized_buf, format=i.format)
@ -660,6 +704,11 @@ def serve_proxy_media_resized(
media_type=i.get_format_mimetype(), # type: ignore
headers=proxy_resp_headers,
)
except ValueError:
return PlainTextResponse(
proxy_resp.content,
headers=proxy_resp_headers,
)
except Exception:
logger.exception(f"Failed to resize {url} on the fly")
return PlainTextResponse(

View file

@ -156,6 +156,9 @@ class OutboxObject(Base, BaseObject):
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
# For the featured collection
is_pinned = Column(Boolean, nullable=False, default=False)
# Never actually delete from the outbox
is_deleted = Column(Boolean, nullable=False, default=False)
@ -181,6 +184,17 @@ class OutboxObject(Base, BaseObject):
remote_side=id,
uselist=False,
)
# For Follow activies
relates_to_actor_id = Column(
Integer,
ForeignKey("actor.id"),
nullable=True,
)
relates_to_actor: Mapped[Optional["Actor"]] = relationship(
"Actor",
foreign_keys=[relates_to_actor_id],
uselist=False,
)
undone_by_outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=True)

View file

@ -140,3 +140,6 @@ nav.flexbox {
float: right;
}
}
.custom-emoji {
max-width: 25px;
}

View file

@ -163,11 +163,14 @@ def _update_inline_imgs(content):
def _clean_html(html: str, note: Object) -> str:
try:
return bleach.clean(
_replace_custom_emojis(_update_inline_imgs(highlight(html)), note),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True,
return _replace_custom_emojis(
bleach.clean(
_update_inline_imgs(highlight(html)),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True,
),
note,
)
except Exception:
raise
@ -197,7 +200,7 @@ def _pluralize(count: int, singular: str = "", plural: str = "s") -> str:
def _replace_custom_emojis(content: str, note: Object) -> str:
idx = {}
for tag in note.ap_object.get("tag", []):
for tag in note.tags:
if tag.get("type") == "Emoji":
try:
idx[tag["name"]] = proxied_media_url(tag["icon"]["url"])

View file

@ -10,7 +10,14 @@
<form action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
{{ utils.embed_csrf_token() }}
{{ utils.embed_redirect_url() }}
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" style="font-size:1.2em;width:95%;"></textarea>
<p>
<select name="visibility">
{% for (k, v) in visibility_enum %}
<option value="{{ k }}">{{ v }}</option>
{% endfor %}
</select>
</p>
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
<p>
<input name="files" type="file" multiple>

View file

@ -2,15 +2,34 @@
{% extends "layout.html" %}
{% block content %}
<p>Filter by
{% for ap_type in ["Note", "Like", "Announce", "Follow"] %}
<a style="margin-right:12px;" href="{{ url_for("admin_outbox") }}?filter_by={{ ap_type }}">
{% if request.query_params.filter_by == ap_type %}
<strong>{{ ap_type }}</strong>
{% else %}
{{ ap_type }}
{% endif %}</a>
{% endfor %}.
{% if request.query_params.filter_by %}<a href="{{ url_for("admin_outbox") }}">Reset filter</a>{% endif %}</p>
</p>
{% for outbox_object in outbox %}
{% if outbox_object.ap_type == "Announce" %}
<div class="actor-action">You shared</div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% elif outbox_object.ap_type == "Like" %}
<div class="actor-action">You liked</div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% elif outbox_object.ap_type == "Follow" %}
<div class="actor-action">You followed</div>
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
{% elif outbox_object.ap_type in ["Article", "Note", "Video"] %}
{{ utils.display_object(outbox_object) }}
{% else %}
Implement {{ outbox_object.ap_type }}
{% endif %}
{% else %}
Implement {{ outbox_object.ap_type }}
{% endif %}
{% endfor %}

View file

@ -24,6 +24,7 @@
<li>{{ header_link("index", "Notes") }}</li>
<li>{{ header_link("followers", "Followers") }} <span>{{ followers_count }}</span></li>
<li>{{ header_link("following", "Following") }} <span>{{ following_count }}</span></li>
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
</ul>
</nav>

View file

@ -7,6 +7,12 @@
{{ utils.display_object(outbox_object) }}
{% endfor %}
{% if has_previous_page %}
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
{% endif %}
{% if has_next_page %}
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
{% endif %}
{% endblock %}

View file

@ -24,7 +24,6 @@
<li>Admin</li>
<li>{{ admin_link("index", "Public") }}</li>
<li>{{ admin_link("admin_new", "New") }}</li>
<li>{{ admin_link("stream", "Stream") }}</li>
<li>{{ admin_link("admin_inbox", "Inbox") }}/{{ admin_link("admin_outbox", "Outbox") }}</li>
<li>{{ admin_link("get_notifications", "Notifications") }} {% if notifications_count %}({{ notifications_count }}){% endif %}</li>
<li>{{ admin_link("get_lookup", "Lookup") }}</li>

View file

@ -42,6 +42,24 @@
</form>
{% endmacro %}
{% macro admin_pin_button(ap_object_id) %}
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="Pin">
</form>
{% endmacro %}
{% macro admin_unpin_button(ap_object_id) %}
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="Unpin">
</form>
{% endmacro %}
{% macro admin_announce_button(ap_object_id) %}
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
{{ embed_csrf_token() }}
@ -98,7 +116,7 @@
<img src="{{ actor.resized_icon_url }}" style="max-width:45px;">
</div>
<a href="{{ actor.url }}" style="">
<div><strong>{{ actor.name or actor.preferred_username }}</strong></div>
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
<div>{{ actor.handle }}</div>
</a>
</div>
@ -156,7 +174,7 @@
<div class="activity-content">
<img src="{{ object.actor.resized_icon_url }}" alt="" class="actor-icon">
<div class="activity-header">
<strong>{{ object.actor.name or object.actor.preferred_username }}</strong>
<strong>{{ object.actor.display_name }}</strong>
<span>{{ object.actor.handle }}</span>
<span class="activity-date" title="{{ object.ap_published_at.isoformat() }}">
{{ object.visibility.value }}
@ -206,8 +224,14 @@
<div class="bar-item">
{{ admin_reply_button(object.ap_id) }}
</div>
<div class="bar-item">
{% if object.is_pinned %}
{{ admin_unpin_button(object.ap_id) }}
{% else %}
{{ admin_pin_button(object.ap_id) }}
{% endif %}
</div>
{% endif %}
{% endif %}
{% if object.is_from_inbox %}