1
0
mirror of https://git.sr.ht/~tsileo/microblog.pub synced 2025-06-05 21:59:23 +02:00

57 Commits

Author SHA1 Message Date
e30e0de10e No more HTTP sig check on the actor profile 2022-11-27 11:36:15 +01:00
e672d9b9f0 Update AUTHORS 2022-11-27 11:33:46 +01:00
Sam
dcd44ec3b6 fix unshare by getting recipients from Announce activity instead of Undo 2022-11-27 11:31:45 +01:00
Sam
71a4ea2425 fix typo on deleted object ap_type 2022-11-27 11:29:54 +01:00
441e3d90b1 Fix formatting 2022-11-23 21:58:59 +01:00
d9b9f596d3 Skip custom emojis which don't match emoji regexp
Otherwise, emojis containing forbidden symbols (for example, `-`)
appear in "emoji selector" on admin/new page, but are not replaced
with emoji image on submit.

Also add a note to documentation mentioning allowed characters.
2022-11-23 21:54:02 +01:00
2cc4eda143 Boostrap stream customization (API may change) 2022-11-22 20:30:35 +01:00
bd065446bf Hack in HTTP sig to drop Delete requests early on 2022-11-21 21:43:12 +01:00
8475f5bccd Fix admin session timeout 2022-11-21 20:43:51 +01:00
a435cd33c9 Allow to delete webmentions 2022-11-20 11:56:58 +01:00
d692ec060f Tweak webmention processing 2022-11-20 11:31:00 +01:00
4c6eb51ae2 Proper mf2 for replies 2022-11-20 11:12:34 +01:00
d36102255f Merge branch 'v2' into indieweb-merge-part2 2022-11-20 10:48:43 +01:00
cdbc545d5e Add a flag on new notifications 2022-11-20 10:13:17 +01:00
fbc46e0517 More logging for the admin session 2022-11-20 10:02:28 +01:00
ef4608f348 Switch back the proxy client to HTTP2 mode 2022-11-20 09:49:19 +01:00
4638b98fa8 Regenerate AUTHORS file 2022-11-20 09:49:00 +01:00
a9f41d6be7 Put 'with_icon' param in the correct macro call
Fix for https://todo.sr.ht/~tsileo/microblog.pub/66
2022-11-20 09:47:54 +01:00
59dfc3d128 Update the install guide 2022-11-19 08:38:51 +01:00
822280c280 Tweak proxy client (increased timeout, no more HTTP2) 2022-11-19 08:32:44 +01:00
c83dd30f41 Increase admin session validity to 3 days 2022-11-19 08:16:53 +01:00
9d312bc229 Fix typing 2022-11-19 08:15:36 +01:00
b37b77ad34 Make local actor icon optional
If a remote actor has no icon, we show our local default icon.

If we have no icon, we should allow remote instances to show their
default icon, instead of sending ours.
2022-11-19 08:12:49 +01:00
9ee3f3b971 More progess on webmention replies 2022-11-19 08:12:33 +01:00
066f5ec900 Merge branch 'v2' into indieweb-merge-part2 2022-11-18 20:36:58 +01:00
a2254f2674 Add return type to hmac_sha256 2022-11-18 20:30:29 +01:00
2151733e4f Add robots meta tags on pages in robots.txt
Useful when app is at a non-root path and we're not handling top-level
/robots.txt requests.
2022-11-18 20:30:29 +01:00
3cff4e4507 Use BASE_URL when generating {proxied,resized}_image_url
Necessary when running at a non-root path
2022-11-18 20:30:29 +01:00
120f92a9ed Display Webmention as replies when applicable 2022-11-18 20:20:58 +01:00
ae8029cd22 Fix template 2022-11-17 21:12:16 +01:00
434fd98cd9 Merge IndieWeb likes/reposts with their AP counterpart 2022-11-17 21:03:24 +01:00
89c90fba56 Start to merge IndieWeb and AP interactions 2022-11-17 09:18:06 +01:00
e29fe0a079 Fix DM admin page showing deleted objects 2022-11-15 23:07:10 +01:00
c5aee435f4 Tweak README 2022-11-15 22:22:56 +01:00
224f5d3f55 Add AUTHORS file 2022-11-15 22:20:28 +01:00
6583feb87d Tweak the documentation about contributions 2022-11-15 22:17:55 +01:00
04e75c78e0 Handle inbox delete handler for actors 2022-11-15 21:47:51 +01:00
68c27e083f Allow to click on picture to see the original one 2022-11-14 21:23:41 +01:00
d52528584a Tweak template for the local delete button 2022-11-13 18:32:38 +01:00
d352dc104a Add local delete option
Useful for removing replies showing up on the public website.
2022-11-13 18:19:52 +01:00
0c5ce67d4e Tweak remote instance redirection 2022-11-13 17:37:19 +01:00
9db7bdf0fb remote follow: use HTML redirect to work around CSP issue
In Chrome, I get the following when trying to use the remote follow form:

    Refused to send form data to 'https://example.com/remote_follow'
    because it violates the following Content Security Policy directive:
    "form-action 'self'".

It seems some browsers (but notably not Firefox) apply the form-action
policy to the redirect target in addition to the initial form
submission endpoint.  See:

    https://github.com/w3c/webappsec-csp/issues/8

In that thread, this workaround is suggested.
2022-11-13 17:11:02 +01:00
793a939046 Fix OG metadata scraping and improve workers 2022-11-13 13:00:22 +01:00
c3eb44add7 Improve mention parsing 2022-11-12 10:04:37 +01:00
9b75020c91 Fix for profile image URL support 2022-11-12 09:26:28 +01:00
36a1a6bd9c Fix for processing objects from Birdsite LIVE 2022-11-12 09:01:56 +01:00
164cd9bd00 Webfinger strips extra space 2022-11-11 15:25:55 +01:00
698a2bae11 Follow up fixes for the image URL support 2022-11-11 15:13:45 +01:00
4613997fe3 Add option to set image_url ("background image") for user
While this option is not used anywhere in microblog.pub itself, some
other servers do occasionally use it when showing remote profiles.

Also, this image _can_ be used in microblog.pub - just add this:

	<img src="{{ local_actor.image_url }}">

in the appropriate place of your template!
2022-11-11 15:08:17 +01:00
4c995957a6 Merge branch 'test-css-tweak' into v2 2022-11-11 15:07:40 +01:00
5c98b8dbfb Revert "Minor styling tweaks: piccalil.li's modern CSS Reset swyx.io's 100 Bytes of CSS to look great everywhere"
This reverts commit a339ff93b1.
2022-11-11 15:07:18 +01:00
48d5914851 Tweak orientation hint for attachments 2022-11-11 14:56:56 +01:00
8f00e522d7 pass through width and height of attachments to allow styling based on media orientation 2022-11-11 14:20:59 +01:00
62c9327500 Add support for setting a custom CSP 2022-11-09 21:26:43 +01:00
a339ff93b1 Minor styling tweaks: piccalil.li's modern CSS Reset swyx.io's 100 Bytes of CSS to look great everywhere 2022-11-09 20:39:27 +01:00
afd253a1b4 Fix OG image URL 2022-11-09 09:29:25 +01:00
509e10e79b Fix active URL in the navbar 2022-11-09 08:15:29 +01:00
42 changed files with 1112 additions and 157 deletions

9
AUTHORS Normal file
View File

@ -0,0 +1,9 @@
Thomas Sileo <t@a4.io>
Kevin Wallace <doof@doof.net>
Miguel Jacq <mig@mig5.net>
Alexey Shpakovsky <alexey@shpakovsky.ru>
Josh Washburne <josh@jodh.us>
Sam <samr1.dev@pm.me>
Ash McAllan <acegiak@gmail.com>
Cassio Zen <cassio@hey.com>
Cocoa <momijizukamori@gmail.com>

View File

@ -58,7 +58,7 @@ All the development takes place on [sourcehut](https://sr.ht/~tsileo/microblog.p
- [Issue tracker](https://todo.sr.ht/~tsileo/microblog.pub)
- [Mailing list](https://sr.ht/~tsileo/microblog.pub/lists)
Contributions are welcomed, check out the [documentation](https://docs.microblog.pub) for more details.
Contributions are welcomed, check out the [contributing section of the documentation](https://docs.microblog.pub/developer_guide.html#contributing) for more details.
## License

View File

@ -0,0 +1,32 @@
"""Add Webmention.webmention_type
Revision ID: fadfd359ce78
Revises: b28c0551c236
Create Date: 2022-11-16 19:42:56.925512+00:00
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'fadfd359ce78'
down_revision = 'b28c0551c236'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('webmention', schema=None) as batch_op:
batch_op.add_column(sa.Column('webmention_type', sa.Enum('UNKNOWN', 'LIKE', 'REPLY', 'REPOST', name='webmentiontype'), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('webmention', schema=None) as batch_op:
batch_op.drop_column('webmention_type')
# ### end Alembic commands ###

View File

@ -135,11 +135,6 @@ ME = {
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
"attachment": _LOCAL_ACTOR_METADATA,
"icon": {
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
"type": "Image",
"url": config.CONFIG.icon_url,
},
"publicKey": {
"id": f"{config.ID}#main-key",
"owner": config.ID,
@ -148,12 +143,26 @@ ME = {
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
}
if config.CONFIG.icon_url:
ME["icon"] = {
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
"type": "Image",
"url": config.CONFIG.icon_url,
}
if ALSO_KNOWN_AS:
ME["alsoKnownAs"] = [ALSO_KNOWN_AS]
if MOVED_TO:
ME["movedTo"] = MOVED_TO
if config.CONFIG.image_url:
ME["image"] = {
"mediaType": mimetypes.guess_type(config.CONFIG.image_url)[0],
"type": "Image",
"url": config.CONFIG.image_url,
}
class NotAnObjectError(Exception):
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:

View File

@ -12,6 +12,7 @@ from sqlalchemy.orm import joinedload
from app import activitypub as ap
from app import media
from app.config import BASE_URL
from app.database import AsyncSession
from app.utils.datetime import as_utc
from app.utils.datetime import now
@ -82,11 +83,21 @@ class Actor:
@property
def icon_url(self) -> str | None:
return self.ap_actor.get("icon", {}).get("url")
if icon := self.ap_actor.get("icon"):
return icon.get("url")
return None
@property
def icon_media_type(self) -> str | None:
return self.ap_actor.get("icon", {}).get("mediaType")
if icon := self.ap_actor.get("icon"):
return icon.get("mediaType")
return None
@property
def image_url(self) -> str | None:
if image := self.ap_actor.get("image"):
return image.get("url")
return None
@property
def public_key_as_pem(self) -> str:
@ -101,14 +112,14 @@ class Actor:
if self.icon_url:
return media.proxied_media_url(self.icon_url)
else:
return "/static/nopic.png"
return BASE_URL + "/static/nopic.png"
@property
def resized_icon_url(self) -> str:
if self.icon_url:
return media.resized_media_url(self.icon_url, 50)
else:
return "/static/nopic.png"
return BASE_URL + "/static/nopic.png"
@property
def tags(self) -> list[ap.RawObject]:
@ -214,24 +225,23 @@ async def fetch_actor(
if save_if_not_found:
ap_actor = await ap.fetch(actor_id)
# Some softwares uses URL when we expect ID
if actor_id == ap_actor.get("url"):
# Which mean we may already have it in DB
existing_actor_by_url = (
await db_session.scalars(
select(models.Actor).where(
models.Actor.ap_id == ap.get_id(ap_actor),
)
# Some softwares uses URL when we expect ID or uses a different casing
# (like Birdsite LIVE) , which mean we may already have it in DB
existing_actor_by_url = (
await db_session.scalars(
select(models.Actor).where(
models.Actor.ap_id == ap.get_id(ap_actor),
)
).one_or_none()
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
)
).one_or_none()
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 await save_actor(db_session, ap_actor)
else:
@ -381,6 +391,9 @@ def _actor_hash(actor: Actor) -> bytes:
if actor.icon_url:
h.update(actor.icon_url.encode())
if actor.image_url:
h.update(actor.image_url.encode())
if actor.attachments:
for a in actor.attachments:
if a.get("type") != "PropertyValue":

View File

@ -11,6 +11,7 @@ from fastapi.exceptions import HTTPException
from fastapi.responses import RedirectResponse
from loguru import logger
from sqlalchemy import and_
from sqlalchemy import delete
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy import select
@ -29,6 +30,7 @@ from app.boxes import send_block
from app.boxes import send_follow
from app.boxes import send_unblock
from app.config import EMOJIS
from app.config import SESSION_TIMEOUT
from app.config import generate_csrf_token
from app.config import session_serializer
from app.config import verify_csrf_token
@ -61,14 +63,17 @@ async def user_session_or_redirect(
)
if not session:
logger.info("No existing admin session")
raise _RedirectToLoginPage
try:
loaded_session = session_serializer.loads(session, max_age=3600 * 12)
loaded_session = session_serializer.loads(session, max_age=SESSION_TIMEOUT)
except Exception:
logger.exception("Failed to validate admin session")
raise _RedirectToLoginPage
if not loaded_session.get("is_logged_in"):
logger.info(f"Admin session invalidated: {loaded_session}")
raise _RedirectToLoginPage
return None
@ -439,6 +444,7 @@ async def admin_direct_messages(
models.InboxObject.ap_context.is_not(None),
# Skip transient object like poll relies
models.InboxObject.is_transient.is_(False),
models.InboxObject.is_deleted.is_(False),
)
.group_by(models.InboxObject.ap_context, models.InboxObject.actor_id)
)
@ -461,6 +467,7 @@ async def admin_direct_messages(
models.OutboxObject.ap_context.is_not(None),
# Skip transient object like poll relies
models.OutboxObject.is_transient.is_(False),
models.OutboxObject.is_deleted.is_(False),
)
.group_by(models.OutboxObject.ap_context)
)
@ -716,13 +723,9 @@ async def get_notifications(
actors_metadata = await get_actors_metadata(
db_session, [notif.actor for notif in notifications if notif.actor]
)
for notif in notifications:
notif.is_new = False
await db_session.commit()
more_unread_count = 0
next_cursor = None
if notifications and remaining_count > page_size:
decoded_next_cursor = notifications[-1].created_at
next_cursor = pagination.encode_cursor(decoded_next_cursor)
@ -736,7 +739,8 @@ async def get_notifications(
)
)
return await templates.render_template(
# Render the template before we change the new flag on notifications
tpl_resp = await templates.render_template(
db_session,
request,
"notifications.html",
@ -748,6 +752,13 @@ async def get_notifications(
},
)
if len({notif.id for notif in notifications if notif.is_new}):
for notif in notifications:
notif.is_new = False
await db_session.commit()
return tpl_resp
@router.get("/object")
async def admin_object(
@ -850,6 +861,66 @@ async def admin_profile(
)
@router.post("/actions/force_delete")
async def admin_actions_force_delete(
request: Request,
ap_object_id: str = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
ap_object_to_delete = await get_inbox_object_by_ap_id(db_session, ap_object_id)
if not ap_object_to_delete:
raise ValueError(f"Cannot find {ap_object_id}")
logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}")
await boxes._revert_side_effect_for_deleted_object(
db_session,
None,
ap_object_to_delete,
None,
)
ap_object_to_delete.is_deleted = True
await db_session.commit()
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/force_delete_webmention")
async def admin_actions_force_delete_webmention(
request: Request,
webmention_id: int = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
webmention = await boxes.get_webmention_by_id(db_session, webmention_id)
if not webmention:
raise ValueError(f"Cannot find {webmention_id}")
if not webmention.outbox_object:
raise ValueError(f"Missing related outbox object for {webmention_id}")
# TODO: move this
logger.info(f"Deleting {webmention_id}")
webmention.is_deleted = True
await db_session.flush()
from app.webmentions import _handle_webmention_side_effects
await _handle_webmention_side_effects(
db_session, webmention, webmention.outbox_object
)
# Delete related notifications
notif_deletion_result = await db_session.execute(
delete(models.Notification)
.where(models.Notification.webmention_id == webmention.id)
.execution_options(synchronize_session=False)
)
logger.info(
f"Deleted {notif_deletion_result.rowcount} notifications" # type: ignore
)
await db_session.commit()
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/follow")
async def admin_actions_follow(
request: Request,

View File

@ -280,6 +280,9 @@ class Attachment(BaseModel):
proxied_url: str | None = None
resized_url: str | None = None
width: int | None = None
height: int | None = None
@property
def mimetype(self) -> str:
mimetype = self.media_type

View File

@ -1,4 +1,5 @@
"""Actions related to the AP inbox/outbox."""
import datetime
import uuid
from collections import defaultdict
from dataclasses import dataclass
@ -31,6 +32,8 @@ from app.config import BLOCKED_SERVERS
from app.config import ID
from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.config import set_moved_to
from app.config import stream_visibility_callback
from app.customization import ObjectInfo
from app.database import AsyncSession
from app.outgoing_activities import new_outgoing_activity
from app.source import dedup_tags
@ -41,6 +44,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.facepile import WebmentionReply
from app.utils.text import slugify
AnyboxObject = models.InboxObject | models.OutboxObject
@ -201,7 +205,7 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
raise ValueError("Should never happen")
outbox_object_to_delete.is_deleted = True
await db_session.commit()
await db_session.flush()
# Compute the original recipients
recipients = await _compute_recipients(
@ -216,14 +220,17 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
db_session, outbox_object_to_delete.in_reply_to
)
if replied_object:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
if replied_object.is_from_outbox:
# Different helper here because we also count webmentions
new_replies_count = await _get_outbox_replies_count(
db_session, replied_object # type: ignore
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
replied_object.replies_count = new_replies_count
if replied_object.replies_count < 0:
logger.warning("negative replies count for {replied_object.ap_id}")
replied_object.replies_count = 0
else:
logger.info(f"{outbox_object_to_delete.in_reply_to} not found")
@ -420,7 +427,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
announced_object.announced_via_outbox_object_ap_id = None
# Send the Undo to the original recipients
recipients = await _compute_recipients(db_session, outbox_object.ap_object)
recipients = await _compute_recipients(db_session, announced_object.ap_object)
for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id)
elif outbox_object_to_undo.ap_type == "Block":
@ -1048,6 +1055,32 @@ async def get_outbox_object_by_ap_id(
) # type: ignore
async def get_outbox_object_by_slug_and_short_id(
db_session: AsyncSession,
slug: str,
short_id: str,
) -> models.OutboxObject | None:
return (
(
await db_session.execute(
select(models.OutboxObject)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.where(
models.OutboxObject.public_id.like(f"{short_id}%"),
models.OutboxObject.slug == slug,
models.OutboxObject.is_deleted.is_(False),
)
)
)
.unique()
.scalar_one_or_none()
)
async def get_anybox_object_by_ap_id(
db_session: AsyncSession, ap_id: str
) -> AnyboxObject | None:
@ -1057,6 +1090,20 @@ async def get_anybox_object_by_ap_id(
return await get_inbox_object_by_ap_id(db_session, ap_id)
async def get_webmention_by_id(
db_session: AsyncSession, webmention_id: int
) -> models.Webmention | None:
return (
await db_session.execute(
select(models.Webmention)
.where(models.Webmention.id == webmention_id)
.options(
joinedload(models.Webmention.outbox_object),
)
)
).scalar_one_or_none() # type: ignore
async def _handle_delete_activity(
db_session: AsyncSession,
from_actor: models.Actor,
@ -1124,6 +1171,23 @@ async def _handle_delete_activity(
logger.info("Removing actor from follower")
await db_session.delete(follower)
# Also mark Follow activities for this actor as deleted
follow_activities = (
await db_session.scalars(
select(models.OutboxObject).where(
models.OutboxObject.ap_type == "Follow",
models.OutboxObject.relates_to_actor_id
== ap_object_to_delete.id,
models.OutboxObject.is_deleted.is_(False),
)
)
).all()
for follow_activity in follow_activities:
logger.info(
f"Marking Follow activity {follow_activity.ap_id} as deleted"
)
follow_activity.is_deleted = True
following = (
await db_session.scalars(
select(models.Following).where(
@ -1184,9 +1248,70 @@ async def _get_replies_count(
)
async def _get_outbox_replies_count(
db_session: AsyncSession,
outbox_object: models.OutboxObject,
) -> int:
return (await _get_replies_count(db_session, outbox_object.ap_id)) + (
await db_session.scalar(
select(func.count(models.Webmention.id)).where(
models.Webmention.is_deleted.is_(False),
models.Webmention.outbox_object_id == outbox_object.id,
models.Webmention.webmention_type == models.WebmentionType.REPLY,
)
)
)
async def _get_outbox_likes_count(
db_session: AsyncSession,
outbox_object: models.OutboxObject,
) -> int:
return (
await db_session.scalar(
select(func.count(models.InboxObject.id)).where(
models.InboxObject.ap_type == "Like",
models.InboxObject.relates_to_outbox_object_id == outbox_object.id,
models.InboxObject.is_deleted.is_(False),
)
)
) + (
await db_session.scalar(
select(func.count(models.Webmention.id)).where(
models.Webmention.is_deleted.is_(False),
models.Webmention.outbox_object_id == outbox_object.id,
models.Webmention.webmention_type == models.WebmentionType.LIKE,
)
)
)
async def _get_outbox_announces_count(
db_session: AsyncSession,
outbox_object: models.OutboxObject,
) -> int:
return (
await db_session.scalar(
select(func.count(models.InboxObject.id)).where(
models.InboxObject.ap_type == "Announce",
models.InboxObject.relates_to_outbox_object_id == outbox_object.id,
models.InboxObject.is_deleted.is_(False),
)
)
) + (
await db_session.scalar(
select(func.count(models.Webmention.id)).where(
models.Webmention.is_deleted.is_(False),
models.Webmention.outbox_object_id == outbox_object.id,
models.Webmention.webmention_type == models.WebmentionType.REPOST,
)
)
)
async def _revert_side_effect_for_deleted_object(
db_session: AsyncSession,
delete_activity: models.InboxObject,
delete_activity: models.InboxObject | None,
deleted_ap_object: models.InboxObject,
forwarded_by_actor: models.Actor | None,
) -> None:
@ -1214,8 +1339,8 @@ async def _revert_side_effect_for_deleted_object(
# also needs to be forwarded
is_delete_needs_to_be_forwarded = True
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
new_replies_count = await _get_outbox_replies_count(
db_session, replied_object # type: ignore
)
await db_session.execute(
@ -1223,7 +1348,7 @@ async def _revert_side_effect_for_deleted_object(
.where(
models.OutboxObject.id == replied_object.id,
)
.values(replies_count=new_replies_count)
.values(replies_count=new_replies_count - 1)
)
else:
new_replies_count = await _get_replies_count(
@ -1235,7 +1360,7 @@ async def _revert_side_effect_for_deleted_object(
.where(
models.InboxObject.id == replied_object.id,
)
.values(replies_count=new_replies_count)
.values(replies_count=new_replies_count - 1)
)
if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id:
@ -1245,15 +1370,16 @@ async def _revert_side_effect_for_deleted_object(
)
if related_object:
if related_object.is_from_outbox:
likes_count = await _get_outbox_likes_count(db_session, related_object)
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.id == related_object.id,
)
.values(likes_count=models.OutboxObject.likes_count - 1)
.values(likes_count=likes_count - 1)
)
elif (
deleted_ap_object.ap_type == "Annouce"
deleted_ap_object.ap_type == "Announce"
and deleted_ap_object.activity_object_ap_id
):
related_object = await get_outbox_object_by_ap_id(
@ -1262,12 +1388,15 @@ async def _revert_side_effect_for_deleted_object(
)
if related_object:
if related_object.is_from_outbox:
announces_count = await _get_outbox_announces_count(
db_session, related_object
)
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.id == related_object.id,
)
.values(announces_count=models.OutboxObject.announces_count - 1)
.values(announces_count=announces_count - 1)
)
# Delete any Like/Announce
@ -1282,7 +1411,8 @@ async def _revert_side_effect_for_deleted_object(
# If it's a local replies, it was forwarded, so we also need to forward
# the Delete activity if possible
if (
delete_activity.activity_object_ap_id == deleted_ap_object.ap_id
delete_activity
and delete_activity.activity_object_ap_id == deleted_ap_object.ap_id
and delete_activity.has_ld_signature
and is_delete_needs_to_be_forwarded
):
@ -1753,16 +1883,30 @@ async def _process_note_object(
is_from_following = ro.actor.ap_id in {f.ap_actor_id for f in following}
is_reply = bool(ro.in_reply_to)
is_local_reply = (
is_local_reply = bool(
ro.in_reply_to
and ro.in_reply_to.startswith(BASE_URL)
and ro.content # Hide votes from Question
)
is_mention = False
hashtags = []
tags = ro.ap_object.get("tag", [])
for tag in ap.as_list(tags):
if tag.get("name") == LOCAL_ACTOR.handle or tag.get("href") == LOCAL_ACTOR.url:
is_mention = True
if tag.get("type") == "Hashtag":
if tag_name := tag.get("name"):
hashtags.append(tag_name)
object_info = ObjectInfo(
is_reply=is_reply,
is_local_reply=is_local_reply,
is_mention=is_mention,
is_from_following=is_from_following,
hashtags=hashtags,
actor_handle=ro.actor.handle,
remote_object=ro,
)
inbox_object = models.InboxObject(
server=urlparse(ro.ap_id).hostname,
@ -1780,9 +1924,7 @@ async def _process_note_object(
activity_object_ap_id=ro.activity_object_ap_id,
og_meta=await opengraph.og_meta_from_note(db_session, ro),
# Hide replies from the stream
is_hidden_from_stream=not (
(not is_reply and is_from_following) or is_mention or is_local_reply
),
is_hidden_from_stream=not stream_visibility_callback(object_info),
# We may already have some replies in DB
replies_count=await _get_replies_count(db_session, ro.ap_id),
)
@ -1808,8 +1950,8 @@ async def _process_note_object(
replied_object, # type: ignore # outbox check below
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
new_replies_count = await _get_outbox_replies_count(
db_session, replied_object # type: ignore
)
await db_session.execute(
@ -2055,7 +2197,10 @@ async def _handle_like_activity(
)
await db_session.delete(like_activity)
else:
relates_to_outbox_object.likes_count = models.OutboxObject.likes_count + 1
relates_to_outbox_object.likes_count = await _get_outbox_likes_count(
db_session,
relates_to_outbox_object,
)
notif = models.Notification(
notification_type=models.NotificationType.LIKE,
@ -2466,11 +2611,21 @@ async def fetch_actor_collection(db_session: AsyncSession, url: str) -> list[Act
@dataclass
class ReplyTreeNode:
ap_object: AnyboxObject
ap_object: AnyboxObject | None
wm_reply: WebmentionReply | None
children: list["ReplyTreeNode"]
is_requested: bool = False
is_root: bool = False
@property
def published_at(self) -> datetime.datetime:
if self.ap_object:
return self.ap_object.ap_published_at # type: ignore
elif self.wm_reply:
return self.wm_reply.published_at
else:
raise ValueError(f"Should never happen: {self}")
async def get_replies_tree(
db_session: AsyncSession,
@ -2544,6 +2699,7 @@ async def get_replies_tree(
for child in index.get(node.ap_object.ap_id, []): # type: ignore
child_node = ReplyTreeNode(
ap_object=child,
wm_reply=None,
is_requested=child.ap_id == requested_object.ap_id, # type: ignore
children=[],
)
@ -2552,7 +2708,7 @@ async def get_replies_tree(
return sorted(
children,
key=lambda node: node.ap_object.ap_published_at, # type: ignore
key=lambda node: node.published_at,
)
if None in nodes_by_in_reply_to:
@ -2565,6 +2721,7 @@ async def get_replies_tree(
root_node = ReplyTreeNode(
ap_object=root_ap_object,
wm_reply=None,
is_root=True,
is_requested=root_ap_object.ap_id == requested_object.ap_id,
children=[],

View File

@ -16,6 +16,8 @@ from loguru import logger
from mistletoe import markdown # type: ignore
from app.customization import _CUSTOM_ROUTES
from app.customization import _StreamVisibilityCallback
from app.customization import default_stream_visibility_callback
from app.utils.emoji import _load_emojis
from app.utils.version import get_version_commit
@ -91,7 +93,8 @@ class Config(pydantic.BaseModel):
name: str
summary: str
https: bool
icon_url: str
icon_url: str | None = None
image_url: str | None = None
secret: str
debug: bool = False
trusted_hosts: list[str] = ["127.0.0.1"]
@ -109,10 +112,14 @@ class Config(pydantic.BaseModel):
inbox_retention_days: int = 15
custom_content_security_policy: str | None = None
# Config items to make tests easier
sqlalchemy_database: str | None = None
key_path: str | None = None
session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default
# Only set when the app is served on a non-root path
id: str | None = None
@ -165,8 +172,10 @@ if CONFIG.privacy_replace:
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
ALSO_KNOWN_AS = CONFIG.also_known_as
CUSTOM_CONTENT_SECURITY_POLICY = CONFIG.custom_content_security_policy
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
SESSION_TIMEOUT = CONFIG.session_timeout
CUSTOM_FOOTER = (
markdown(CONFIG.custom_footer.replace("{version}", VERSION))
if CONFIG.custom_footer
@ -253,5 +262,16 @@ def verify_csrf_token(
return None
def hmac_sha256():
def hmac_sha256() -> hmac.HMAC:
return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)
stream_visibility_callback: _StreamVisibilityCallback
try:
from data.stream import ( # type: ignore # noqa: F401, E501
custom_stream_visibility_callback,
)
stream_visibility_callback = custom_stream_visibility_callback
except ImportError:
stream_visibility_callback = default_stream_visibility_callback

View File

@ -1,12 +1,19 @@
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Request
from loguru import logger
from starlette.responses import JSONResponse
if TYPE_CHECKING:
from app.ap_object import RemoteObject
_DATA_DIR = Path().parent.resolve() / "data"
_Handler = Callable[..., Any]
@ -110,3 +117,38 @@ def get_custom_router() -> APIRouter | None:
router.add_api_route(path, handler.handler)
return router
@dataclass
class ObjectInfo:
# Is it a reply?
is_reply: bool
# Is it a reply to an outbox object
is_local_reply: bool
# Is the object mentioning the local actor
is_mention: bool
# Is it from someone the local actor is following
is_from_following: bool
# List of hashtags, e.g. #microblogpub
hashtags: list[str]
# @dev@microblog.pub
actor_handle: str
remote_object: "RemoteObject"
_StreamVisibilityCallback = Callable[[ObjectInfo], bool]
def default_stream_visibility_callback(object_info: ObjectInfo) -> bool:
logger.info(f"{object_info=}")
return (
(not object_info.is_reply and object_info.is_from_following)
or object_info.is_mention
or object_info.is_local_reply
)

View File

@ -1,5 +1,6 @@
import base64
import hashlib
import json
import typing
from dataclasses import dataclass
from datetime import datetime
@ -198,6 +199,32 @@ async def httpsig_checker(
server=server,
)
# Try to drop Delete activity spams early on, this prevent making an extra
# HTTP requests trying to fetch an unavailable actor to verify the HTTP sig
try:
if request.method == "POST" and request.url.path.endswith("/inbox"):
from app import models # TODO: solve this circular import
activity = json.loads(body)
actor_id = ap.get_id(activity["actor"])
if (
ap.as_list(activity["type"])[0] == "Delete"
and actor_id == ap.get_id(activity["object"])
and not (
await db_session.scalars(
select(models.Actor).where(
models.Actor.ap_id == actor_id,
)
)
).one_or_none()
):
logger.info(f"Dropping Delete activity early for {body=}")
raise fastapi.HTTPException(status_code=202)
except fastapi.HTTPException as http_exc:
raise http_exc
except Exception:
logger.exception("Failed to check for Delete spam")
# logger.debug(f"hsig={hsig}")
signed_string, signature_date = _build_signed_string(
hsig["headers"],

View File

@ -3,7 +3,6 @@ import traceback
from datetime import datetime
from datetime import timedelta
import httpx
from loguru import logger
from sqlalchemy import func
from sqlalchemy import select
@ -108,6 +107,7 @@ async def process_next_incoming_activity(
next_activity.tries = next_activity.tries + 1
next_activity.last_try = now()
await db_session.commit()
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
try:
@ -120,13 +120,16 @@ async def process_next_incoming_activity(
),
timeout=60,
)
except httpx.TimeoutException as exc:
url = exc._request.url if exc._request else None
logger.error(f"Failed, HTTP timeout when fetching {url}")
except asyncio.exceptions.TimeoutError:
logger.error("Activity took too long to process")
await db_session.rollback()
await db_session.refresh(next_activity)
next_activity.error = traceback.format_exc()
_set_next_try(next_activity)
except Exception:
logger.exception("Failed")
await db_session.rollback()
await db_session.refresh(next_activity)
next_activity.error = traceback.format_exc()
_set_next_try(next_activity)
else:

View File

@ -73,6 +73,9 @@ from app.templates import is_current_user_admin
from app.uploads import UPLOAD_DIR
from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME
from app.utils.facepile import Face
from app.utils.facepile import WebmentionReply
from app.utils.facepile import merge_faces
from app.utils.highlight import HIGHLIGHT_CSS_HASH
from app.utils.url import check_url
from app.webfinger import get_remote_follow_template
@ -137,9 +140,15 @@ class CustomMiddleware:
headers["x-frame-options"] = "DENY"
headers["permissions-policy"] = "interest-cohort=()"
headers["content-security-policy"] = (
f"default-src 'self'; "
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
(
f"default-src 'self'; "
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
)
if not config.CUSTOM_CONTENT_SECURITY_POLICY
else config.CUSTOM_CONTENT_SECURITY_POLICY.format(
HIGHLIGHT_CSS_HASH=HIGHLIGHT_CSS_HASH
)
)
if not DEBUG:
headers["strict-transport-security"] = "max-age=63072000;"
@ -248,11 +257,34 @@ class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json"
async def redirect_to_remote_instance(
request: Request,
db_session: AsyncSession,
url: str,
) -> templates.TemplateResponse:
"""
Similar to RedirectResponse, but uses a 200 response with HTML.
Needed for remote redirects on form submission endpoints,
since our CSP policy disallows remote form submission.
https://github.com/w3c/webappsec-csp/issues/8#issuecomment-810108984
"""
return await templates.render_template(
db_session,
request,
"redirect_to_remote_instance.html",
{
"request": request,
"url": url,
},
headers={"Refresh": "0;url=" + url},
)
@app.get(config.NavBarItems.NOTES_PATH)
async def index(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
page: int | None = None,
) -> templates.TemplateResponse | ActivityPubResponse:
if is_activitypub_requested(request):
@ -694,7 +726,7 @@ async def _fetch_webmentions(
models.Webmention.outbox_object_id == outbox_object.id,
models.Webmention.is_deleted.is_(False),
)
.limit(10)
.limit(50)
)
).all()
@ -744,23 +776,90 @@ async def outbox_by_public_id(
is_current_user_admin=is_current_user_admin(request),
)
webmentions = await _fetch_webmentions(db_session, maybe_object)
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,
"replies_tree": _merge_replies(replies_tree, webmentions),
"outbox_object": maybe_object,
"likes": likes,
"shares": shares,
"webmentions": webmentions,
"likes": _merge_faces_from_inbox_object_and_webmentions(
likes,
webmentions,
models.WebmentionType.LIKE,
),
"shares": _merge_faces_from_inbox_object_and_webmentions(
shares,
webmentions,
models.WebmentionType.REPOST,
),
"webmentions": _filter_webmentions(webmentions),
},
)
def _filter_webmentions(
webmentions: list[models.Webmention],
) -> list[models.Webmention]:
return [
wm
for wm in webmentions
if wm.webmention_type
not in [
models.WebmentionType.LIKE,
models.WebmentionType.REPOST,
models.WebmentionType.REPLY,
]
]
def _merge_faces_from_inbox_object_and_webmentions(
inbox_objects: list[models.InboxObject],
webmentions: list[models.Webmention],
webmention_type: models.WebmentionType,
) -> list[Face]:
wm_faces = []
for wm in webmentions:
if wm.webmention_type != webmention_type:
continue
if face := Face.from_webmention(wm):
wm_faces.append(face)
return merge_faces(
[Face.from_inbox_object(obj) for obj in inbox_objects] + wm_faces
)
def _merge_replies(
reply_tree_node: boxes.ReplyTreeNode,
webmentions: list[models.Webmention],
) -> boxes.ReplyTreeNode:
# TODO: return None as we update the object in place
webmention_replies = []
for wm in [
wm for wm in webmentions if wm.webmention_type == models.WebmentionType.REPLY
]:
if rep := WebmentionReply.from_webmention(wm):
webmention_replies.append(
boxes.ReplyTreeNode(
ap_object=None,
wm_reply=rep,
is_requested=False,
children=[],
)
)
reply_tree_node.children = sorted(
reply_tree_node.children + webmention_replies,
key=lambda node: node.published_at,
reverse=True,
)
return reply_tree_node
@app.get("/articles/{short_id}/{slug}")
async def article_by_slug(
short_id: str,
@ -769,24 +868,8 @@ async def article_by_slug(
db_session: AsyncSession = Depends(get_db_session),
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
maybe_object = (
(
await db_session.execute(
select(models.OutboxObject)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.where(
models.OutboxObject.public_id.like(f"{short_id}%"),
models.OutboxObject.slug == slug,
models.OutboxObject.is_deleted.is_(False),
)
)
)
.unique()
.scalar_one_or_none()
maybe_object = await boxes.get_outbox_object_by_slug_and_short_id(
db_session, slug, short_id
)
if not maybe_object:
raise HTTPException(status_code=404)
@ -810,11 +893,19 @@ async def article_by_slug(
request,
"object.html",
{
"replies_tree": replies_tree,
"replies_tree": _merge_replies(replies_tree, webmentions),
"outbox_object": maybe_object,
"likes": likes,
"shares": shares,
"webmentions": webmentions,
"likes": _merge_faces_from_inbox_object_and_webmentions(
likes,
webmentions,
models.WebmentionType.LIKE,
),
"shares": _merge_faces_from_inbox_object_and_webmentions(
shares,
webmentions,
models.WebmentionType.REPOST,
),
"webmentions": _filter_webmentions(webmentions),
},
)
@ -953,9 +1044,10 @@ async def get_remote_follow(
@app.post("/remote_follow")
async def post_remote_follow(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(),
) -> RedirectResponse:
) -> templates.TemplateResponse:
if not profile.startswith("@"):
profile = f"@{profile}"
@ -964,9 +1056,10 @@ async def post_remote_follow(
# TODO(ts): error message to user
raise HTTPException(status_code=404)
return RedirectResponse(
return await redirect_to_remote_instance(
request,
db_session,
remote_follow_template.format(uri=ID),
status_code=302,
)
@ -994,10 +1087,11 @@ async def remote_interaction(
@app.post("/remote_interaction")
async def post_remote_interaction(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(),
ap_id: str = Form(),
) -> RedirectResponse:
) -> templates.TemplateResponse:
if not profile.startswith("@"):
profile = f"@{profile}"
@ -1006,9 +1100,10 @@ async def post_remote_interaction(
# TODO(ts): error message to user
raise HTTPException(status_code=404)
return RedirectResponse(
remote_follow_template.format(uri=ap_id),
status_code=302,
return await redirect_to_remote_instance(
request,
db_session,
remote_follow_template.format(uri=ID),
)
@ -1084,7 +1179,11 @@ async def nodeinfo(
)
proxy_client = httpx.AsyncClient(follow_redirects=True, http2=True)
proxy_client = httpx.AsyncClient(
http2=True,
follow_redirects=True,
timeout=httpx.Timeout(timeout=10.0),
)
async def _proxy_get(
@ -1364,7 +1463,7 @@ async def json_feed(
],
}
)
return {
result = {
"version": "https://jsonfeed.org/version/1",
"title": f"{LOCAL_ACTOR.display_name}'s microblog'",
"home_page_url": LOCAL_ACTOR.url,
@ -1372,10 +1471,12 @@ async def json_feed(
"author": {
"name": LOCAL_ACTOR.display_name,
"url": LOCAL_ACTOR.url,
"avatar": LOCAL_ACTOR.icon_url,
},
"items": data,
}
if LOCAL_ACTOR.icon_url:
result["author"]["avatar"] = LOCAL_ACTOR.icon_url # type: ignore
return result
async def _gen_rss_feed(
@ -1387,7 +1488,8 @@ async def _gen_rss_feed(
fg.description(f"{LOCAL_ACTOR.display_name}'s microblog")
fg.author({"name": LOCAL_ACTOR.display_name})
fg.link(href=LOCAL_ACTOR.url, rel="alternate")
fg.logo(LOCAL_ACTOR.icon_url)
if LOCAL_ACTOR.icon_url:
fg.logo(LOCAL_ACTOR.icon_url)
fg.language("en")
outbox_objects = await _get_outbox_for_feed(db_session)

View File

@ -251,6 +251,8 @@ class OutboxObject(Base, BaseObject):
"mediaType": attachment.upload.content_type,
"name": attachment.alt or attachment.filename,
"url": url,
"width": attachment.upload.width,
"height": attachment.upload.height,
"proxiedUrl": url,
"resizedUrl": BASE_URL
+ (
@ -466,6 +468,14 @@ class IndieAuthAccessToken(Base):
is_revoked = Column(Boolean, nullable=False, default=False)
@enum.unique
class WebmentionType(str, enum.Enum):
UNKNOWN = "unknown"
LIKE = "like"
REPLY = "reply"
REPOST = "repost"
class Webmention(Base):
__tablename__ = "webmention"
__table_args__ = (UniqueConstraint("source", "target", name="uix_source_target"),)
@ -482,6 +492,8 @@ class Webmention(Base):
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
outbox_object = relationship(OutboxObject, uselist=False)
webmention_type = Column(Enum(WebmentionType), nullable=True)
@property
def as_facepile_item(self) -> webmentions.Webmention | None:
if not self.source_microformats:
@ -491,6 +503,7 @@ class Webmention(Base):
self.source_microformats["items"], self.source
)
except Exception:
# TODO: return a facepile with the unknown image
logger.warning(
f"Failed to generate facefile item for Webmention id={self.id}"
)

View File

@ -467,6 +467,9 @@ a.label-btn {
span {
color: $muted-color;
}
span.new {
color: $secondary-color;
}
}
.actor-metadata {
color: $muted-color;
@ -541,3 +544,7 @@ a.label-btn {
content: ': ';
}
}
.margin-top-20 {
margin-top: 20px;
}

View File

@ -1,6 +1,7 @@
import re
import typing
from loguru import logger
from mistletoe import Document # type: ignore
from mistletoe.html_renderer import HTMLRenderer # type: ignore
from mistletoe.span_token import SpanToken # type: ignore
@ -78,13 +79,17 @@ class CustomRenderer(HTMLRenderer):
def render_mention(self, token: Mention) -> str:
mention = token.target
suffix = ""
if mention.endswith("."):
mention = mention[:-1]
suffix = "."
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
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>{suffix}' # noqa: E501
return link
def render_hashtag(self, token: Hashtag) -> str:
@ -118,23 +123,30 @@ async def _prefetch_mentioned_actors(
if mention in actors:
continue
_, username, domain = mention.split("@")
actor = (
await db_session.execute(
select(models.Actor).where(
models.Actor.handle == mention,
models.Actor.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if not actor:
actor_url = await webfinger.get_actor_url(mention)
if not actor_url:
# FIXME(ts): raise an error?
continue
actor = await fetch_actor(db_session, actor_url)
# XXX: the regex catches stuff like `@toto@example.com.`
if mention.endswith("."):
mention = mention[:-1]
actors[mention] = actor
try:
_, username, domain = mention.split("@")
actor = (
await db_session.execute(
select(models.Actor).where(
models.Actor.handle == mention,
models.Actor.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if not actor:
actor_url = await webfinger.get_actor_url(mention)
if not actor_url:
# FIXME(ts): raise an error?
continue
actor = await fetch_actor(db_session, actor_url)
actors[mention] = actor
except Exception:
logger.exception(f"Failed to prefetch {mention}")
return actors

View File

@ -27,6 +27,7 @@ from app.ap_object import Object
from app.config import BASE_URL
from app.config import CUSTOM_FOOTER
from app.config import DEBUG
from app.config import SESSION_TIMEOUT
from app.config import VERSION
from app.config import generate_csrf_token
from app.config import session_serializer
@ -69,10 +70,10 @@ def is_current_user_admin(request: Request) -> bool:
try:
loaded_session = session_serializer.loads(
session_cookie,
max_age=3600 * 12,
max_age=SESSION_TIMEOUT,
)
except Exception:
pass
logger.exception("Failed to validate session timeout")
else:
is_admin = loaded_session.get("is_logged_in")
@ -85,6 +86,7 @@ async def render_template(
template: str,
template_args: dict[str, Any] | None = None,
status_code: int = 200,
headers: dict[str, str] | None = None,
) -> TemplateResponse:
if template_args is None:
template_args = {}
@ -129,6 +131,7 @@ async def render_template(
**template_args,
},
status_code=status_code,
headers=headers,
)
@ -333,6 +336,14 @@ def _clean_html(html: str, note: Object) -> str:
raise
def _clean_html_wm(html: str) -> str:
return bleach.clean(
html,
attributes=ALLOWED_ATTRIBUTES,
strip=True,
)
def _timeago(original_dt: datetime) -> str:
dt = original_dt
if dt.tzinfo:
@ -409,6 +420,7 @@ def _poll_item_pct(item: ap.RawObject, voters_count: int) -> int:
_templates.env.filters["domain"] = _filter_domain
_templates.env.filters["media_proxy_url"] = _media_proxy_url
_templates.env.filters["clean_html"] = _clean_html
_templates.env.filters["clean_html_wm"] = _clean_html_wm
_templates.env.filters["timeago"] = _timeago
_templates.env.filters["format_date"] = _format_date
_templates.env.filters["has_media_type"] = _has_media_type
@ -424,3 +436,4 @@ _templates.env.globals["BASE_URL"] = config.BASE_URL
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING
_templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems
_templates.env.globals["ICON_URL"] = config.CONFIG.icon_url

View File

@ -14,7 +14,7 @@
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
<meta content="Homepage" property="og:title" />
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
<meta content="{{ local_actor.url }}" property="og:image" />
<meta content="{{ ICON_URL }}" property="og:image" />
<meta content="summary" property="twitter:card" />
<meta content="{{ local_actor.handle }}" property="profile:username" />
{% endif %}

View File

@ -3,6 +3,7 @@
{% block head %}
<title>{{ local_actor.display_name }}'s followers</title>
<meta name="robots" content="noindex, nofollow">
{% endblock %}
{% block content %}

View File

@ -3,6 +3,7 @@
{% block head %}
<title>{{ local_actor.display_name }}'s follows</title>
<meta name="robots" content="noindex, nofollow">
{% endblock %}
{% block content %}

View File

@ -26,11 +26,12 @@
{%- macro header_link(url, text) -%}
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %}
{%- macro navbar_item_link(navbar_item) -%}
<a href="{{ navbar_item[0] }}" {% if request.url.path == navbar_item[0] %}class="active"{% endif %}>{{ navbar_item[1] }}</a>
{% set url_for = BASE_URL + navbar_item[0] %}
<a href="{{ navbar_item[0] }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ navbar_item[1] }}</a>
{% endmacro %}
<div class="public-top-menu">

View File

@ -13,7 +13,7 @@
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
<meta content="Homepage" property="og:title" />
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
<meta content="{{ local_actor.url }}" property="og:image" />
<meta content="{{ ICON_URL }}" property="og:image" />
<meta content="summary" property="twitter:card" />
<meta content="{{ local_actor.handle }}" property="profile:username" />
{% endblock %}

View File

@ -19,7 +19,7 @@
<div id="admin">
{% macro admin_link(url, text) %}
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %}
<div class="admin-menu">
<nav class="flexbox">

View File

@ -1,5 +1,8 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<meta name="robots" content="noindex, nofollow">
{% endblock %}
{% block main_tag %} class="main-flex"{% endblock %}
{% block content %}
<div class="centered">

View File

@ -10,6 +10,9 @@
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">
{% if with_icon %}{{ utils.display_tiny_actor_icon(notif.actor) }}{% endif %} {{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
{% if notif.is_new %}
<span class="new">new</span>
{% endif %}
</div>
{% endmacro %}
@ -66,8 +69,8 @@
{{ notif_actor_action(notif, "shared a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "undo_announce" %}
{{ notif_actor_action(notif, "unshared a post") }}
{{ utils.display_object(notif.outbox_object, with_icon=True) }}
{{ notif_actor_action(notif, "unshared a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "mention" %}
{{ notif_actor_action(notif, "mentioned you") }}
{{ utils.display_object(notif.inbox_object) }}

View File

@ -31,9 +31,16 @@
{% macro display_replies_tree(replies_tree_node) %}
{% if replies_tree_node.is_requested %}
{{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=webmentions, expanded=not replies_tree_node.is_root, is_object_page=True) }}
{{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=webmentions, expanded=not replies_tree_node.is_root, is_object_page=True, is_h_entry=False) }}
{% else %}
{{ utils.display_object(replies_tree_node.ap_object) }}
{% if replies_tree_node.wm_reply %}
{# u-comment h-cite is displayed by default for webmention #}
{{ utils.display_webmention_reply(replies_tree_node.wm_reply) }}
{% else %}
<div class="u-comment h-cite">
{{ utils.display_object(replies_tree_node.ap_object, is_h_entry=False) }}
</div>
{% endif %}
{% endif %}
{% for child in replies_tree_node.children %}
@ -42,6 +49,8 @@
{% endmacro %}
<div class="h-entry">
{{ display_replies_tree(replies_tree) }}
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>{{ local_actor.display_name }}'s microblog - Redirect</title>
{% endblock %}
{% block content %}
{% include "header.html" %}
<div class="box">
<p>You are being redirected to your instance: <a href="{{ url }}">{{ url }}</a></p>
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@
{% block head %}
<title>Remote follow {{ local_actor.display_name }}</title>
<meta name="robots" content="noindex, nofollow">
{% endblock %}
{% block content %}

View File

@ -3,6 +3,7 @@
{% block head %}
<title>Interact from your instance</title>
<meta name="robots" content="noindex, nofollow">
{% endblock %}
{% block content %}

View File

@ -131,6 +131,28 @@
{% endblock %}
{% endmacro %}
{% macro admin_force_delete_button(ap_object_id, permalink_id=None) %}
{% block admin_force_delete_button scoped %}
<form action="{{ request.url_for("admin_actions_force_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="local delete">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_force_delete_webmention_button(webmention_id, permalink_id=None) %}
{% block admin_force_delete_webmention_button scoped %}
<form action="{{ request.url_for("admin_actions_force_delete_webmention") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="webmention_id" value="{{ webmention_id }}">
<input type="submit" value="local delete">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_announce_button(ap_object_id, permalink_id=None) %}
{% block admin_announce_button scoped %}
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
@ -384,21 +406,27 @@
{% for attachment in object.attachments %}
{% if attachment.type != "PropertyValue" %}
{% set orientation = "unknown" %}
{% if attachment.width %}
{% set orientation = "portrait" if attachment.width < attachment.height else "landscape" %}
{% endif %}
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
<div class="attachment-wrapper">
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
<div>
<div class="sensitive-attachment">
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
<div class="sensitive-attachment-box">
<div class="sensitive-attachment-box attachment-orientation-{{orientation}}">
<div></div>
{% else %}
<div class="attachment-item">
<div class="attachment-item attachment-orientation-{{orientation}}">
{% endif %}
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
{% if attachment.url not in object.inlined_images %}
<a class="media-link" href="{{ attachment.proxied_url }}" target="_blank">
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment">
</a>
{% endif %}
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
@ -424,11 +452,55 @@
{% endblock %}
{% endmacro %}
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
{% macro display_webmention_reply(wm_reply) %}
{% block display_webmention_reply scoped %}
<div class="ap-object u-comment h-cite">
<div class="actor-box h-card p-author">
<div class="icon-box">
<img src="{{ wm_reply.face.picture_url }}" alt="{{ wm_reply.face.name }}'s avatar" class="actor-icon u-photo">
</div>
<a href="{{ wm_reply.face.url }}" class="u-url">
<div><strong class="p-name">{{ wm_reply.face.name | clean_html_wm | safe }}</strong></div>
<div class="actor-handle">{{ wm_reply.face.url | truncate(64, True) }}</div>
</a>
</div>
<p class="in-reply-to">in reply to <a href="{{ wm_reply.in_reply_to }}" title="{{ wm_reply.in_reply_to }}" rel="nofollow">
this note
</a></p>
<div class="obj-content margin-top-20">
<div class="e-content">
{{ wm_reply.content | clean_html_wm | safe }}
</div>
</div>
<nav class="flexbox activity-bar margin-top-20">
<ul>
<li>
<div><a href="{{ wm_reply.url }}" rel="nofollow" class="object-permalink u-url u-uid">permalink</a></div>
</li>
<li>
<time class="dt-published" datetime="{{ wm_reply.published_at.replace(microsecond=0).isoformat() }}" title="{{ wm_reply.published_at.replace(microsecond=0).isoformat() }}">{{ wm_reply.published_at | timeago }}</time>
</li>
{% if is_admin %}
<li>
{{ admin_force_delete_webmention_button(wm_reply.webmention_id) }}
</li>
{% endif %}
</ul>
</nav>
</div>
{% endblock %}
{% endmacro %}
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False, is_h_entry=True) %}
{% block display_object scoped %}
{% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %}
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question", "Event"] %}
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}{% if is_h_entry %}h-entry{% endif %}" id="{{ object.permalink_id }}">
{% if is_article_mode %}
<data class="h-card">
@ -678,6 +750,11 @@
{{ admin_expand_button(object) }}
</li>
{% endif %}
{% if object.is_from_inbox and not object.announced_via_outbox_object_ap_id %}
<li>
{{ admin_force_delete_button(object.ap_id) }}
</li>
{% endif %}
</ul>
</nav>
{% endif %}
@ -689,8 +766,8 @@
<div class="interactions-block">Likes
<div class="facepile-wrapper">
{% for like in likes %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" rel="noreferrer">
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}">
<a href="{% if is_admin and like.ap_actor_id %}{{ url_for("admin_profile") }}?actor_id={{ like.ap_actor_id }}{% else %}{{ like.url }}{% endif %}" title="{{ like.name }}" rel="noreferrer">
<img src="{{ like.picture_url }}" alt="{{ like.name }}">
</a>
{% endfor %}
{% if object.likes_count > likes | length %}
@ -706,8 +783,8 @@
<div class="interactions-block">Shares
<div class="facepile-wrapper">
{% for share in shares %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" rel="noreferrer">
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}">
<a href="{% if is_admin and share.ap_actor_id %}{{ url_for("admin_profile") }}?actor_id={{ share.ap_actor_id }}{% else %}{{ share.url }}{% endif %}" title="{{ share.name }}" rel="noreferrer">
<img src="{{ share.picture_url }}" alt="{{ share.name }}">
</a>
{% endfor %}
{% if object.announces_count > shares | length %}

View File

@ -23,6 +23,8 @@ def _load_emojis(root_dir: Path, base_url: str) -> None:
mt = mimetypes.guess_type(emoji.name)[0]
if mt and mt.startswith("image/"):
name = emoji.name.split(".")[0]
if not re.match(EMOJI_REGEX, f":{name}:"):
continue
ap_emoji: "RawObject" = {
"type": "Emoji",
"name": f":{name}:",

159
app/utils/facepile.py Normal file
View File

@ -0,0 +1,159 @@
import datetime
from dataclasses import dataclass
from typing import Any
from typing import Optional
from loguru import logger
from app import media
from app.models import InboxObject
from app.models import Webmention
from app.utils.datetime import parse_isoformat
from app.utils.url import make_abs
@dataclass
class Face:
ap_actor_id: str | None
url: str
name: str
picture_url: str
created_at: datetime.datetime
@classmethod
def from_inbox_object(cls, like: InboxObject) -> "Face":
return cls(
ap_actor_id=like.actor.ap_id,
url=like.actor.url, # type: ignore
name=like.actor.handle, # type: ignore
picture_url=like.actor.resized_icon_url,
created_at=like.created_at, # type: ignore
)
@classmethod
def from_webmention(cls, webmention: Webmention) -> Optional["Face"]:
items = webmention.source_microformats.get("items", []) # type: ignore
for item in items:
if item["type"][0] == "h-card":
try:
return cls(
ap_actor_id=None,
url=(
item["properties"]["url"][0]
if item["properties"].get("url")
else webmention.source
),
name=item["properties"]["name"][0],
picture_url=media.resized_media_url(
make_abs(
item["properties"]["photo"][0], webmention.source
), # type: ignore
50,
),
created_at=webmention.created_at, # type: ignore
)
except Exception:
logger.exception(
f"Failed to build Face for webmention id={webmention.id}"
)
break
elif item["type"][0] == "h-entry":
author = item["properties"]["author"][0]
try:
return cls(
ap_actor_id=None,
url=webmention.source,
name=author["properties"]["name"][0],
picture_url=media.resized_media_url(
make_abs(
author["properties"]["photo"][0], webmention.source
), # type: ignore
50,
),
created_at=webmention.created_at, # type: ignore
)
except Exception:
logger.exception(
f"Failed to build Face for webmention id={webmention.id}"
)
break
return None
def merge_faces(faces: list[Face]) -> list[Face]:
return sorted(
faces,
key=lambda f: f.created_at,
reverse=True,
)[:10]
def _parse_face(webmention: Webmention, items: list[dict[str, Any]]) -> Face | None:
for item in items:
if item["type"][0] == "h-card":
try:
return Face(
ap_actor_id=None,
url=(
item["properties"]["url"][0]
if item["properties"].get("url")
else webmention.source
),
name=item["properties"]["name"][0],
picture_url=media.resized_media_url(
make_abs(
item["properties"]["photo"][0], webmention.source
), # type: ignore
50,
),
created_at=webmention.created_at, # type: ignore
)
except Exception:
logger.exception(
f"Failed to build Face for webmention id={webmention.id}"
)
break
return None
@dataclass
class WebmentionReply:
face: Face
content: str
url: str
published_at: datetime.datetime
in_reply_to: str
webmention_id: int
@classmethod
def from_webmention(cls, webmention: Webmention) -> Optional["WebmentionReply"]:
items = webmention.source_microformats.get("items", []) # type: ignore
for item in items:
if item["type"][0] == "h-entry":
try:
face = _parse_face(webmention, item["properties"].get("author", []))
if not face:
logger.info(
"Failed to build WebmentionReply/Face for "
f"webmention id={webmention.id}"
)
break
return cls(
face=face,
content=item["properties"]["content"][0]["html"],
url=item["properties"]["url"][0],
published_at=parse_isoformat(
item["properties"]["published"][0]
).replace(tzinfo=None),
in_reply_to=webmention.target, # type: ignore
webmention_id=webmention.id, # type: ignore
)
except Exception:
logger.exception(
f"Failed to build Face for webmention id={webmention.id}"
)
break
return None

View File

@ -1,12 +1,15 @@
import asyncio
import mimetypes
import re
import signal
from concurrent.futures import TimeoutError
from typing import Any
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup # type: ignore
from loguru import logger
from pebble import concurrent # type: ignore
from pydantic import BaseModel
from app import activitypub as ap
@ -29,7 +32,11 @@ class OpenGraphMeta(BaseModel):
site_name: str
@concurrent.process(timeout=5)
def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
# Prevent SIGTERM to bubble up to the worker
signal.signal(signal.SIGTERM, signal.SIG_IGN)
soup = BeautifulSoup(html, "html5lib")
ogs = {
og.attrs["property"]: og.attrs.get("content")
@ -58,6 +65,10 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
return OpenGraphMeta.parse_obj(raw)
def scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
return _scrap_og_meta(url, html).result()
async def external_urls(
db_session: AsyncSession,
ro: ap_object.RemoteObject | OutboxObject | InboxObject,
@ -126,7 +137,10 @@ async def _og_meta_from_url(url: str) -> OpenGraphMeta | None:
return None
try:
return _scrap_og_meta(url, resp.text)
return scrap_og_meta(url, resp.text)
except TimeoutError:
logger.info(f"Timed out when scraping OG meta for {url}")
return None
except Exception:
logger.info(f"Failed to scrap OG meta for {url}")
return None

View File

@ -69,5 +69,5 @@ class Worker(Generic[T]):
logger.info("stopping loop")
async def _shutdown(self, sig: signal.Signals) -> None:
logger.info(f"Caught {signal=}")
logger.info(f"Caught {sig=}")
self._stop_event.set()

View File

@ -12,6 +12,7 @@ async def webfinger(
resource: str,
) -> dict[str, Any] | None: # noqa: C901
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL."""
resource = resource.strip()
logger.info(f"performing webfinger resolution for {resource}")
protos = ["https", "http"]
if resource.startswith("http://"):

View File

@ -1,3 +1,5 @@
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup # type: ignore
from fastapi import APIRouter
@ -6,13 +8,20 @@ from fastapi import HTTPException
from fastapi import Request
from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy import func
from sqlalchemy import select
from app import models
from app.boxes import _get_outbox_announces_count
from app.boxes import _get_outbox_likes_count
from app.boxes import _get_outbox_replies_count
from app.boxes import get_outbox_object_by_ap_id
from app.boxes import get_outbox_object_by_slug_and_short_id
from app.database import AsyncSession
from app.database import get_db_session
from app.utils import microformats
from app.utils.facepile import Face
from app.utils.facepile import WebmentionReply
from app.utils.url import check_url
from app.utils.url import is_url_valid
@ -47,6 +56,7 @@ async def webmention_endpoint(
check_url(source)
check_url(target)
parsed_target_url = urlparse(target)
except Exception:
logger.exception("Invalid webmention request")
raise HTTPException(status_code=400, detail="Invalid payload")
@ -65,6 +75,16 @@ async def webmention_endpoint(
logger.info("Found existing Webmention, will try to update or delete")
mentioned_object = await get_outbox_object_by_ap_id(db_session, target)
if not mentioned_object and parsed_target_url.path.startswith("/articles/"):
try:
_, _, short_id, slug = parsed_target_url.path.split("/")
mentioned_object = await get_outbox_object_by_slug_and_short_id(
db_session, slug, short_id
)
except Exception:
logger.exception(f"Failed to match {target}")
if not mentioned_object:
logger.info(f"Invalid target {target=}")
@ -90,8 +110,13 @@ async def webmention_endpoint(
logger.warning(f"target {target=} not found in source")
if existing_webmention_in_db:
logger.info("Deleting existing Webmention")
mentioned_object.webmentions_count = mentioned_object.webmentions_count - 1
existing_webmention_in_db.is_deleted = True
await db_session.flush()
# Revert side effects
await _handle_webmention_side_effects(
db_session, existing_webmention_in_db, mentioned_object
)
notif = models.Notification(
notification_type=models.NotificationType.DELETED_WEBMENTION,
@ -110,10 +135,14 @@ async def webmention_endpoint(
else:
return JSONResponse(content={}, status_code=200)
webmention_type = models.WebmentionType.UNKNOWN
webmention: models.Webmention
if existing_webmention_in_db:
# Undelete if needed
existing_webmention_in_db.is_deleted = False
existing_webmention_in_db.source_microformats = data
await db_session.flush()
webmention = existing_webmention_in_db
notif = models.Notification(
notification_type=models.NotificationType.UPDATED_WEBMENTION,
@ -127,9 +156,11 @@ async def webmention_endpoint(
target=target,
source_microformats=data,
outbox_object_id=mentioned_object.id,
webmention_type=webmention_type,
)
db_session.add(new_webmention)
await db_session.flush()
webmention = new_webmention
notif = models.Notification(
notification_type=models.NotificationType.NEW_WEBMENTION,
@ -138,8 +169,60 @@ async def webmention_endpoint(
)
db_session.add(notif)
mentioned_object.webmentions_count = mentioned_object.webmentions_count + 1
# Determine the webmention type
for item in data.get("items", []):
if target in item.get("properties", {}).get(
"in-reply-to", []
) and WebmentionReply.from_webmention(webmention):
webmention_type = models.WebmentionType.REPLY
break
elif target in item.get("properties", {}).get(
"like-of", []
) and Face.from_webmention(webmention):
webmention_type = models.WebmentionType.LIKE
break
elif target in item.get("properties", {}).get(
"repost-of", []
) and Face.from_webmention(webmention):
webmention_type = models.WebmentionType.REPOST
break
if webmention_type != models.WebmentionType.UNKNOWN:
webmention.webmention_type = webmention_type
await db_session.flush()
# Handle side effect
await _handle_webmention_side_effects(db_session, webmention, mentioned_object)
await db_session.commit()
return JSONResponse(content={}, status_code=200)
async def _handle_webmention_side_effects(
db_session: AsyncSession,
webmention: models.Webmention,
mentioned_object: models.OutboxObject,
) -> None:
if webmention.webmention_type == models.WebmentionType.UNKNOWN:
# TODO: recount everything
mentioned_object.webmentions_count = await db_session.scalar(
select(func.count(models.Webmention.id)).where(
models.Webmention.is_deleted.is_(False),
models.Webmention.outbox_object_id == mentioned_object.id,
models.Webmention.webmention_type == models.WebmentionType.UNKNOWN,
)
)
elif webmention.webmention_type == models.WebmentionType.LIKE:
mentioned_object.likes_count = await _get_outbox_likes_count(
db_session, mentioned_object
)
elif webmention.webmention_type == models.WebmentionType.REPOST:
mentioned_object.announces_count = await _get_outbox_announces_count(
db_session, mentioned_object
)
elif webmention.webmention_type == models.WebmentionType.REPLY:
mentioned_object.replies_count = await _get_outbox_replies_count(
db_session, mentioned_object
)
else:
raise ValueError(f"Unhandled {webmention.webmention_type} webmention")

View File

@ -58,3 +58,24 @@ And check out the result by starting a static server using Python standard libra
cd docs/dist
python -m http.server 8001
```
## Contributing
Contributions/patches are welcome, but please start a discussion in a [ticket](https://todo.sr.ht/~tsileo/microblog.pub) or a [thread in the mailing list](https://lists.sr.ht/~tsileo/microblog.pub-devel) before working on anything consequent.
### Patches
Please ensure your code passes the code quality checks:
```bash
inv autoformat
inv lint
```
And that the tests suite is passing:
```bash
inv tests
```
Please also consider adding new test cases if needed.

View File

@ -193,4 +193,8 @@ http {
## YunoHost edition
[YunoHost](https://yunohost.org/) support is a work in progress.
[YunoHost](https://yunohost.org/) support is available (although it is not an official package for now): <https://git.sr.ht/~tsileo/microblog.pub_ynh>.
## Available tutorial/guides
- [Opalstack](https://community.opalstack.com/d/1055-howto-install-and-run-microblogpub-on-opalstack), thanks to [@defulmere@mastodon.social](https://mastodon.online/@defulmere).

View File

@ -113,6 +113,7 @@ You can copy/paste them from [getemoji.com](https://getemoji.com/).
#### Custom emoji
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
Do not use exotic characters in filename - only letters, numbers, and underscore symbol `_` are allowed.
#### Custom CSS
@ -131,9 +132,19 @@ See `app/scss/main.scss` to see what variables can be overridden.
If you'd like to customize your instance's theme beyond CSS, you can modify the app's HTML by placing templates in `data/templates` which overwrite the defaults in `app/templates`.
#### Custom Content Security Policy (CSP)
You can override the default Content Security Policy by adding a line in `data/profile.toml`:
```toml
custom_content_security_policy = "default-src 'self'; style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
```
This example will output the default CSP, note that `{HIGHLIGHT_CSS_HASH}` will be dynamically replaced by the correct value (the hash of the CSS needed for syntax highlighting).
#### Code highlighting theme
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `profile.toml`:
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `data/profile.toml`:
```toml
code_highlighting_theme = "solarized-dark"
@ -455,6 +466,7 @@ make self-destruct
If the server is not (re)starting, you can:
- [Ensure that the configuration is valid](/user_guide.html#configuration-checking)
- [Verify if you haven't any syntax error in the custom theme by recompiling the CSS](/user_guide.html#recompiling-css-files)
- Look at the log files
- [Ensure that the configuration is valid](/user_guide.html#configuration-checking).
- [Verify if you haven't any syntax error in the custom theme by recompiling the CSS](/user_guide.html#recompiling-css-files).
- Look at the log files (in `data/uvicorn.log`, `data/incoming.log` and `data/outgoing.log`).
- If the CSS is not working, ensure your reverse proxy is serving the static file correctly.

14
poetry.lock generated
View File

@ -689,6 +689,14 @@ category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "pebble"
version = "5.0.2"
description = "Threading and multiprocessing eye-candy."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pillow"
version = "9.3.0"
@ -1263,7 +1271,7 @@ dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "89df524a545a19a20440d1872c93151bbf3f68d3b3d20cc50bc9049dd0e6d25f"
content-hash = "13a1f5fc3f65c56e753062dca6ab74a50f7270d78a08ebf6297f7b4fa26b5eac"
[metadata.files]
aiosqlite = [
@ -1871,6 +1879,10 @@ pathspec = [
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
]
pebble = [
{file = "Pebble-5.0.2-py3-none-any.whl", hash = "sha256:61b2dfd52b1a8c083b4e6cf3e0f1ff2e8a430a6283c53969a7057a1c91bed3cd"},
{file = "Pebble-5.0.2.tar.gz", hash = "sha256:9c58c03eaf920c31287444c6fef39dc53baeac9de221ead104f5c9b48e8bd587"},
]
pillow = [
{file = "Pillow-9.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:0b7257127d646ff8676ec8a15520013a698d1fdc48bc2a79ba4e53df792526f2"},
{file = "Pillow-9.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b90f7616ea170e92820775ed47e136208e04c967271c9ef615b6fbd08d9af0e3"},

View File

@ -44,6 +44,7 @@ uvicorn = {extras = ["standard"], version = "^0.18.3"}
Brotli = "^1.0.9"
greenlet = "^1.1.3"
mistletoe = "^0.9.0"
Pebble = "^5.0.2"
[tool.poetry.dev-dependencies]
black = "^22.3.0"

View File

@ -75,9 +75,10 @@ def main() -> None:
proto = "http"
print("Note that you can put your icon/avatar in the static/ directory")
dat["icon_url"] = prompt(
if icon_url := prompt(
"icon URL: ", default=f'{proto}://{dat["domain"]}/static/nopic.png'
)
):
dat["icon_url"] = icon_url
dat["secret"] = os.urandom(16).hex()
with config_file.open("w") as f: