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

43 Commits

Author SHA1 Message Date
0dac5ec8fd Add missing template for search 2022-11-03 23:06:55 +01:00
ee37803987 Boostrap full-text search for the outbox 2022-11-03 23:03:52 +01:00
0badf0bc1f Fix permalink for Questions 2022-11-03 22:38:29 +01:00
32692a7dcd First shot at supporting custom handler 2022-11-02 08:51:21 +01:00
817dd98c5c Update deps 2022-11-01 19:11:47 +01:00
b6f0cd01d3 Less HTML restrictions for local content 2022-10-30 18:47:24 +01:00
c985dd84c3 Add slugify helper 2022-10-30 17:51:57 +01:00
3d049da2e5 Add slug support for Article 2022-10-30 17:50:59 +01:00
fd5293a05c Fix password reset task 2022-10-23 16:40:56 +02:00
3729500e3e Improve Block support 2022-10-23 16:37:32 +02:00
2853bf2a28 Fix tag dedup 2022-10-20 19:39:55 +02:00
0144a1c0d4 Tweak Mistletoe autolink 2022-10-19 21:09:30 +02:00
d93bcf6128 Complete the switch to mistletoe 2022-10-19 20:46:01 +02:00
647add2bab Added the ability to use a custom favicon. 2022-10-19 09:03:58 +02:00
f50a233ce9 Improved Block support 2022-10-18 21:39:09 +02:00
d909bf93a0 Tweak/fix install steps in the docs 2022-10-18 19:26:04 +02:00
8e7fbcc501 Tweak actor refresh 2022-10-11 20:49:06 +02:00
7a665df2b5 Tweak README 2022-10-10 11:05:36 +02:00
b5b56e9ed5 More actor refresh improvements 2022-10-09 11:36:00 +02:00
9a36b0edf5 Fix conversation processing 2022-10-07 19:50:14 +02:00
20f996d165 Tweak HTTP sig handling 2022-10-07 19:00:18 +02:00
602da69083 Support actor refresh while checking HTTP sig 2022-10-07 12:05:28 +02:00
f6cfe06f66 Force refresh actor once in a while 2022-10-07 08:55:05 +02:00
c8a9793638 Make hashtag case insensitive 2022-10-05 20:27:21 +02:00
5eaa0f291b More Markdown improvements 2022-10-05 20:05:16 +02:00
881d0ad899 Switch Markdown parser 2022-10-04 20:26:01 +02:00
5a20b9d23a More CSS tweaks for the in reply to section 2022-10-03 20:05:06 +02:00
919a61f75d Tweak in reply to link 2022-10-03 19:21:08 +02:00
7faa4655f8 Make 'in reply to' more user-friendly by hiding the URL behind object type 2022-10-03 19:12:28 +02:00
cf6a891349 Improve/fix non-media attachment display 2022-09-30 09:07:07 +02:00
58b383ba4e Don't try to contact onion services 2022-09-29 09:16:35 +02:00
57fc5ef913 Improve OG meta processing 2022-09-29 09:10:05 +02:00
5348398b23 Update deps 2022-09-29 08:42:53 +02:00
572a84b4bd Fix/imprive Undo support 2022-09-29 08:41:24 +02:00
992cd55d7b Tweak processing 2022-09-26 21:41:34 +02:00
6216b316e8 Add remote interaction button 2022-09-23 20:09:05 +02:00
96eae971b8 Prevent processing duplicate objects 2022-09-23 09:13:59 +02:00
928bdafeea Tweak Create processing for CacheFile 2022-09-23 09:01:50 +02:00
dc89aeb70b Fix permalink 2022-09-23 09:00:23 +02:00
25d3daa6d2 Improve inbox delete side effects 2022-09-22 19:56:36 +02:00
715df3c563 Update deps 2022-09-21 21:01:37 +02:00
cb5d21baeb More admin profile related tweaks 2022-09-21 21:00:17 +02:00
8d0b5d1114 Fix double profile button in the admin 2022-09-21 19:35:48 +02:00
40 changed files with 1899 additions and 694 deletions

View File

@ -12,32 +12,32 @@ config:
.PHONY: update
update:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
.PHONY: prune-old-data
prune-old-data:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
.PHONY: webfinger
webfinger:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
.PHONY: move-to
move-to:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
.PHONY: self-destruct
self-destruct:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
.PHONY: reset-password
reset-password:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv reset-password
-docker run --rm -it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv reset-password
.PHONY: check-config
check-config:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv check-config
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv check-config
.PHONY: compile-scss
compile-scss:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv compile-scss
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv compile-scss

View File

@ -22,7 +22,7 @@ There are still some rough edges, but the server is mostly functional.
- Author notes in Markdown, with code highlighting support
- Dedicated section for articles/blog posts (enabled when the first article is posted)
- Lightweight
- Uses SQLite, and no external dependencies except Python 3.10+
- Uses SQLite, and Python 3.10+
- Can be deployed on small VPS
- Privacy-aware
- EXIF metadata (like GPS location) are stripped before storage

View File

@ -1,34 +0,0 @@
"""Add support for quote URL
Revision ID: c3027d0e18dc
Revises: 604d125ea2fb
Create Date: 2022-09-21 07:08:24.568124+00:00
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'c3027d0e18dc'
down_revision = '604d125ea2fb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('inbox', schema=None) as batch_op:
batch_op.add_column(sa.Column('quoted_inbox_object_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_quoted_inbox_object_id', 'inbox', ['quoted_inbox_object_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('inbox', schema=None) as batch_op:
batch_op.drop_constraint('fk_quoted_inbox_object_id', type_='foreignkey')
batch_op.drop_column('quoted_inbox_object_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,48 @@
"""Add a slug field for outbox objects
Revision ID: b28c0551c236
Revises: 604d125ea2fb
Create Date: 2022-10-30 14:09:14.540461+00:00
"""
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.orm.session import Session
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b28c0551c236'
down_revision = '604d125ea2fb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('outbox', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sa.String(), nullable=True))
batch_op.create_index(batch_op.f('ix_outbox_slug'), ['slug'], unique=False)
# ### end Alembic commands ###
# Backfill the slug for existing articles
from app.models import OutboxObject
from app.utils.text import slugify
sess = Session(op.get_bind())
articles = sess.execute(select(OutboxObject).where(
OutboxObject.ap_type == "Article")
).scalars()
for article in articles:
title = article.ap_object["name"]
article.slug = slugify(title)
sess.commit()
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('outbox', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_outbox_slug'))
batch_op.drop_column('slug')
# ### end Alembic commands ###

View File

@ -0,0 +1,115 @@
"""Outbox FTS
Revision ID: 368f511ad954
Revises: b28c0551c236
Create Date: 2022-11-02 19:14:37.865923+00:00
"""
from sqlalchemy import insert
from sqlalchemy import select
from sqlalchemy.orm.session import Session
from alembic import op
# revision identifiers, used by Alembic.
revision = '368f511ad954'
down_revision = 'b28c0551c236'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# ### end Alembic commands ###
op.execute(
"CREATE VIRTUAL TABLE outbox_fts USING "
"fts5(summary, name, source, content='');"
)
op.execute(
"CREATE TRIGGER outbox_fts_ai AFTER "
"INSERT ON outbox WHEN new.ap_type in ('Article', 'Note', 'Question') BEGIN"
" INSERT INTO outbox_fts (rowid, source, name, summary)"
" VALUES ("
" new.id, "
" new.source, "
' json_extract(new.ap_object, "$.name"), '
' json_extract(new.ap_object, "$.summary")'
" ); "
"END;"
)
op.execute(
"CREATE TRIGGER outbox_fts_ad AFTER "
"DELETE ON outbox WHEN old.ap_type in ('Article', 'Note', 'Question') BEGIN"
" INSERT INTO outbox_fts (outbox_fts, rowid, source, name, summary)"
" VALUES ("
" 'delete', "
" old.id, "
" old.source, "
' json_extract(old.ap_object, "$.name"), '
' json_extract(old.ap_object, "$.summary")'
" ); "
"END;"
)
op.execute(
"CREATE TRIGGER outbox_fts_au_softdelete AFTER "
"UPDATE ON outbox WHEN new.is_deleted = 1 AND "
"new.ap_type in ('Article', 'Note', 'Question') BEGIN"
" INSERT INTO outbox_fts (outbox_fts, rowid, source, name, summary)"
" VALUES ("
" 'delete', "
" old.id, "
" old.source, "
' json_extract(old.ap_object, "$.name"), '
' json_extract(old.ap_object, "$.summary")'
" ); "
"END; "
)
op.execute(
"CREATE TRIGGER outbox_fts_au AFTER "
"UPDATE ON outbox "
"WHEN (new.source <> old.source OR new.ap_object <> old.ap_object) AND "
"new.ap_type in ('Note', 'Article', 'Quesion') BEGIN"
" INSERT INTO outbox_fts (outbox_fts, rowid, source, name, summary)"
" VALUES ("
" 'delete', "
" old.id, "
" old.source, "
' json_extract(old.ap_object, "$.name"), '
' json_extract(old.ap_object, "$.summary")'
" );"
" INSERT INTO outbox_fts (rowid, source, name, summary)"
" VALUES ("
" new.id, "
" new.source, "
' json_extract(new.ap_object, "$.name"), '
' json_extract(new.ap_object, "$.summary")'
" );"
"END;"
)
from app.models import OutboxObject
from app.models import outbox_fts
sess = Session(op.get_bind())
# Backfill the index
outbox_objects = sess.execute(select(OutboxObject).where(
OutboxObject.ap_type.in_(["Article", "Note", "Question"]))
).scalars()
for outbox_object in outbox_objects:
row = {"source": outbox_object.source, "rowid": outbox_object.id}
if name := outbox_object.ap_object.get("name"):
row["name"] = name
if summary := outbox_object.ap_object.get("summary"):
row["summary"] = summary
sess.execute(insert(outbox_fts).values(row))
sess.commit()
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# ### end Alembic commands ###
op.drop_table('outbox_fts')
op.execute("DROP TRIGGER outbox_fts_ai;")
op.execute("DROP TRIGGER outbox_fts_ad;")
op.execute("DROP TRIGGER outbox_fts_au_softdelete;")
op.execute("DROP TRIGGER outbox_fts_au;")

View File

@ -6,7 +6,6 @@ from typing import Any
import httpx
from loguru import logger
from markdown import markdown
from app import config
from app.config import ALSO_KNOWN_AS
@ -14,6 +13,7 @@ from app.config import AP_CONTENT_TYPE # noqa: F401
from app.config import MOVED_TO
from app.httpsig import auth
from app.key import get_pubkey_as_pem
from app.source import dedup_tags
from app.source import hashtagify
from app.utils.url import check_url
@ -53,15 +53,26 @@ AS_EXTENDED_CTX = [
]
class ObjectIsGoneError(Exception):
class FetchError(Exception):
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
resp_part = ""
if resp:
resp_part = f", got HTTP {resp.status_code}: {resp.text}"
message = f"Failed to fetch {url}{resp_part}"
super().__init__(message)
self.resp = resp
self.url = url
class ObjectIsGoneError(FetchError):
pass
class ObjectNotFoundError(Exception):
class ObjectNotFoundError(FetchError):
pass
class ObjectUnavailableError(Exception):
class ObjectUnavailableError(FetchError):
pass
@ -90,6 +101,19 @@ class VisibilityEnum(str, enum.Enum):
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
_LOCAL_ACTOR_METADATA = []
if config.CONFIG.metadata:
for kv in config.CONFIG.metadata:
kv_value, kv_tags = hashtagify(kv.value)
_LOCAL_ACTOR_METADATA.append(
{
"name": kv.key,
"type": "PropertyValue",
"value": kv_value,
}
)
_LOCAL_ACTOR_TAGS.extend(kv_tags)
ME = {
"@context": AS_EXTENDED_CTX,
@ -102,7 +126,7 @@ ME = {
"outbox": config.BASE_URL + "/outbox",
"preferredUsername": config.USERNAME,
"name": config.CONFIG.name,
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
"summary": _LOCAL_ACTOR_SUMMARY,
"endpoints": {
# For compat with servers expecting a sharedInbox...
"sharedInbox": config.BASE_URL
@ -110,16 +134,7 @@ ME = {
},
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
"attachment": [
{
"name": kv.key,
"type": "PropertyValue",
"value": markdown(kv.value, extensions=["mdx_linkify", "fenced_code"]),
}
for kv in config.CONFIG.metadata
]
if config.CONFIG.metadata
else [],
"attachment": _LOCAL_ACTOR_METADATA,
"icon": {
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
"type": "Image",
@ -130,7 +145,7 @@ ME = {
"owner": config.ID,
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
},
"tag": _LOCAL_ACTOR_TAGS,
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
}
if ALSO_KNOWN_AS:
@ -170,13 +185,17 @@ async def fetch(
# Special handling for deleted object
if resp.status_code == 410:
raise ObjectIsGoneError(f"{url} is gone")
raise ObjectIsGoneError(url, resp)
elif resp.status_code in [401, 403]:
raise ObjectUnavailableError(f"not allowed to fetch {url}")
raise ObjectUnavailableError(url, resp)
elif resp.status_code == 404:
raise ObjectNotFoundError(f"{url} not found")
raise ObjectNotFoundError(url, resp)
try:
resp.raise_for_status()
except httpx.HTTPError as http_error:
raise FetchError(url, resp) from http_error
try:
return resp.json()
except json.JSONDecodeError:

View File

@ -1,6 +1,7 @@
import hashlib
import typing
from dataclasses import dataclass
from datetime import timedelta
from functools import cached_property
from typing import Union
from urllib.parse import urlparse
@ -12,6 +13,8 @@ from sqlalchemy.orm import joinedload
from app import activitypub as ap
from app import media
from app.database import AsyncSession
from app.utils.datetime import as_utc
from app.utils.datetime import now
if typing.TYPE_CHECKING:
from app.models import Actor as ActorModel
@ -189,8 +192,26 @@ async def fetch_actor(
if existing_actor:
if existing_actor.is_deleted:
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
if now() - as_utc(existing_actor.updated_at) > timedelta(hours=24):
logger.info(
f"Refreshing {actor_id=} last updated {existing_actor.updated_at}"
)
try:
ap_actor = await ap.fetch(actor_id)
await update_actor_if_needed(
db_session,
existing_actor,
RemoteActor(ap_actor),
)
return existing_actor
except Exception:
logger.exception(f"Failed to refresh {actor_id}")
# If we fail to refresh the actor, return the cached one
return existing_actor
else:
return existing_actor
if save_if_not_found:
ap_actor = await ap.fetch(actor_id)
# Some softwares uses URL when we expect ID
@ -204,11 +225,32 @@ async def fetch_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
return await save_actor(db_session, ap_actor)
else:
raise ap.ObjectNotFoundError
raise ap.ObjectNotFoundError(actor_id)
async def update_actor_if_needed(
db_session: AsyncSession,
actor_in_db: "ActorModel",
ra: RemoteActor,
) -> None:
# Check if we actually need to udpte the actor in DB
if _actor_hash(ra) != _actor_hash(actor_in_db):
actor_in_db.ap_actor = ra.ap_actor
actor_in_db.handle = ra.handle
actor_in_db.ap_type = ra.ap_type
actor_in_db.updated_at = now()
await db_session.flush()
@dataclass
@ -217,9 +259,11 @@ class ActorMetadata:
is_following: bool
is_follower: bool
is_follow_request_sent: bool
is_follow_request_rejected: bool
outbox_follow_ap_id: str | None
inbox_follow_ap_id: str | None
moved_to: typing.Optional["ActorModel"]
has_blocked_local_actor: bool
ActorsMetadata = dict[str, ActorMetadata]
@ -262,6 +306,26 @@ async def get_actors_metadata(
)
)
}
rejected_follow_requests = {
reject.activity_object_ap_id
for reject in await db_session.execute(
select(models.InboxObject.activity_object_ap_id).where(
models.InboxObject.ap_type == "Reject",
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
)
)
}
blocks = {
block.ap_actor_id
for block in await db_session.execute(
select(models.InboxObject.ap_actor_id).where(
models.InboxObject.ap_type == "Block",
models.InboxObject.undone_by_inbox_object_id.is_(None),
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
)
)
}
idx: ActorsMetadata = {}
for actor in actors:
if not actor.ap_id:
@ -284,9 +348,15 @@ async def get_actors_metadata(
is_following=actor.ap_id in following,
is_follower=actor.ap_id in followers,
is_follow_request_sent=actor.ap_id in sent_follow_requests,
is_follow_request_rejected=bool(
sent_follow_requests[actor.ap_id] in rejected_follow_requests
)
if actor.ap_id in sent_follow_requests
else False,
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
inbox_follow_ap_id=followers.get(actor.ap_id),
moved_to=moved_to,
has_blocked_local_actor=actor.ap_id in blocks,
)
return idx

View File

@ -1,4 +1,5 @@
from datetime import datetime
from typing import Any
import httpx
from fastapi import APIRouter
@ -25,7 +26,9 @@ from app.actor import fetch_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_block
from app.boxes import send_follow
from app.boxes import send_unblock
from app.config import EMOJIS
from app.config import generate_csrf_token
from app.config import session_serializer
@ -147,6 +150,57 @@ async def get_lookup(
)
@router.get("/search")
async def admin_search(
request: Request,
query: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse | RedirectResponse:
results: list[Any] = []
if query:
results = (
(
await db_session.execute(
select(models.OutboxObject)
.join(
models.outbox_fts,
models.outbox_fts.c.rowid == models.OutboxObject.id,
)
.options(
joinedload(
models.OutboxObject.outbox_object_attachments
).options(joinedload(models.OutboxObjectAttachment.upload)),
joinedload(models.OutboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor),
),
joinedload(
models.OutboxObject.relates_to_outbox_object
).options(
joinedload(
models.OutboxObject.outbox_object_attachments
).options(joinedload(models.OutboxObjectAttachment.upload)),
),
)
.where(models.outbox_fts.c.outbox_fts.op("MATCH")(query))
.limit(20)
)
) # type: ignore
.unique()
.scalars()
)
return await templates.render_template(
db_session,
request,
"admin_search.html",
{
"query": query,
"results": results,
},
)
@router.get("/new")
async def admin_new(
request: Request,
@ -340,6 +394,7 @@ async def admin_inbox(
"Update",
"Undo",
"Read",
"Reject",
"Add",
"Remove",
"EmojiReact",
@ -868,10 +923,7 @@ async def admin_actions_block(
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
logger.info(f"Blocking {ap_actor_id}")
actor = await fetch_actor(db_session, ap_actor_id)
actor.is_blocked = True
await db_session.commit()
await send_block(db_session, ap_actor_id)
return RedirectResponse(redirect_url, status_code=302)
@ -884,9 +936,7 @@ async def admin_actions_unblock(
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
logger.info(f"Unblocking {ap_actor_id}")
actor = await fetch_actor(db_session, ap_actor_id)
actor.is_blocked = False
await db_session.commit()
await send_unblock(db_session, ap_actor_id)
return RedirectResponse(redirect_url, status_code=302)

View File

@ -1,13 +1,12 @@
import hashlib
import mimetypes
from datetime import datetime
from functools import cached_property
from typing import Any
from typing import Optional
import pydantic
from bs4 import BeautifulSoup # type: ignore
from loguru import logger
from markdown import markdown
from mistletoe import markdown # type: ignore
from app import activitypub as ap
from app.actor import LOCAL_ACTOR
@ -76,10 +75,6 @@ class Object:
def tags(self) -> list[ap.RawObject]:
return ap.as_list(self.ap_object.get("tag", []))
@property
def quote_url(self) -> str | None:
return self.ap_object.get("quoteUrl")
@cached_property
def inlined_images(self) -> set[str]:
image_urls: set[str] = set()
@ -161,7 +156,7 @@ class Object:
@cached_property
def url(self) -> str | None:
obj_url = self.ap_object.get("url")
if isinstance(obj_url, str):
if isinstance(obj_url, str) and obj_url:
return obj_url
elif obj_url:
for u in ap.as_list(obj_url):
@ -181,7 +176,7 @@ class Object:
# PeerTube returns the content as markdown
if self.ap_object.get("mediaType") == "text/markdown":
content = markdown(content, extensions=["mdx_linkify"])
content = markdown(content)
return content
@ -282,17 +277,22 @@ class Attachment(BaseModel):
proxied_url: str | None = None
resized_url: str | None = None
@property
def mimetype(self) -> str:
mimetype = self.media_type
if not mimetype:
mimetype, _ = mimetypes.guess_type(self.url)
if not mimetype:
return "unknown"
return mimetype.split("/")[-1]
class RemoteObject(Object):
def __init__(
self,
raw_object: ap.RawObject,
actor: Actor,
quoted_object: Object | None = None,
):
def __init__(self, raw_object: ap.RawObject, actor: Actor):
self._raw_object = raw_object
self._actor = actor
self._quoted_object = quoted_object
if self._actor.ap_id != ap.get_actor_id(self._raw_object):
raise ValueError(f"Invalid actor {self._actor.ap_id}")
@ -302,7 +302,6 @@ class RemoteObject(Object):
cls,
raw_object: ap.RawObject,
actor: Actor | None = None,
fetch_quoted_url: bool = True,
):
# Pre-fetch the actor
actor_id = ap.get_actor_id(raw_object)
@ -319,17 +318,7 @@ class RemoteObject(Object):
ap_actor=await ap.fetch(ap.get_actor_id(raw_object)),
)
quoted_object: Object | None = None
if quote_url := raw_object.get("quoteUrl"):
try:
quoted_object = await RemoteObject.from_raw_object(
await ap.fetch(quote_url),
fetch_quoted_url=fetch_quoted_url,
)
except Exception:
logger.exception(f"Failed to fetch {quote_url=}")
return cls(raw_object, _actor, quoted_object=quoted_object)
return cls(raw_object, _actor)
@property
def og_meta(self) -> list[dict[str, Any]] | None:
@ -342,9 +331,3 @@ class RemoteObject(Object):
@property
def actor(self) -> Actor:
return self._actor
@property
def quoted_object(self) -> Optional["RemoteObject"]:
if self._quoted_object:
return self._quoted_object
return None

View File

@ -24,6 +24,7 @@ from app.actor import Actor
from app.actor import RemoteActor
from app.actor import fetch_actor
from app.actor import save_actor
from app.actor import update_actor_if_needed
from app.ap_object import RemoteObject
from app.config import BASE_URL
from app.config import BLOCKED_SERVERS
@ -32,6 +33,7 @@ from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.config import set_moved_to
from app.database import AsyncSession
from app.outgoing_activities import new_outgoing_activity
from app.source import dedup_tags
from app.source import markdownify
from app.uploads import upload_to_attachment
from app.utils import opengraph
@ -39,6 +41,7 @@ from app.utils import webmentions
from app.utils.datetime import as_utc
from app.utils.datetime import now
from app.utils.datetime import parse_isoformat
from app.utils.text import slugify
AnyboxObject = models.InboxObject | models.OutboxObject
@ -61,6 +64,7 @@ async def save_outbox_object(
source: str | None = None,
is_transient: bool = False,
conversation: str | None = None,
slug: str | None = None,
) -> models.OutboxObject:
ro = await RemoteObject.from_raw_object(raw_object)
@ -80,6 +84,7 @@ async def save_outbox_object(
source=source,
is_transient=is_transient,
conversation=conversation,
slug=slug,
)
db_session.add(outbox_object)
await db_session.flush()
@ -88,6 +93,87 @@ async def save_outbox_object(
return outbox_object
async def send_unblock(db_session: AsyncSession, ap_actor_id: str) -> None:
actor = await fetch_actor(db_session, ap_actor_id)
block_activity = (
await db_session.scalars(
select(models.OutboxObject).where(
models.OutboxObject.activity_object_ap_id == actor.ap_id,
models.OutboxObject.is_deleted.is_(False),
)
)
).one_or_none()
if not block_activity:
raise ValueError(f"No Block activity for {ap_actor_id}")
await _send_undo(db_session, block_activity.ap_id)
await db_session.commit()
async def send_block(db_session: AsyncSession, ap_actor_id: str) -> None:
logger.info(f"Blocking {ap_actor_id}")
actor = await fetch_actor(db_session, ap_actor_id)
actor.is_blocked = True
# 1. Unfollow the actor
following = (
await db_session.scalars(
select(models.Following)
.options(joinedload(models.Following.outbox_object))
.where(
models.Following.ap_actor_id == actor.ap_id,
)
)
).one_or_none()
if following:
await _send_undo(db_session, following.outbox_object.ap_id)
# 2. If the blocked actor is a follower, reject the follow request
follower = (
await db_session.scalars(
select(models.Follower)
.options(joinedload(models.Follower.inbox_object))
.where(
models.Follower.ap_actor_id == actor.ap_id,
)
)
).one_or_none()
if follower:
await _send_reject(db_session, actor, follower.inbox_object)
await db_session.delete(follower)
# 3. Send a block
block_id = allocate_outbox_id()
block = {
"@context": ap.AS_EXTENDED_CTX,
"id": outbox_object_id(block_id),
"type": "Block",
"actor": LOCAL_ACTOR.ap_id,
"object": actor.ap_id,
}
outbox_object = await save_outbox_object(
db_session,
block_id,
block,
)
if not outbox_object.id:
raise ValueError("Should never happen")
await new_outgoing_activity(db_session, actor.inbox_url, outbox_object.id)
# 4. Create a notification
notif = models.Notification(
notification_type=models.NotificationType.BLOCK,
actor_id=actor.id,
outbox_object_id=outbox_object.id,
)
db_session.add(notif)
await db_session.commit()
async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
outbox_object_to_delete = await get_outbox_object_by_ap_id(db_session, ap_object_id)
if not outbox_object_to_delete:
@ -264,7 +350,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
if not outbox_object_to_undo:
raise ValueError(f"{ap_object_id} not found in the outbox")
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce"]:
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce", "Block"]:
raise ValueError(
f"Cannot build Undo for {outbox_object_to_undo.ap_type} activity"
)
@ -288,6 +374,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
raise ValueError("Should never happen")
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
outbox_object_to_undo.is_deleted = True
if outbox_object_to_undo.ap_type == "Follow":
if not outbox_object_to_undo.activity_object_ap_id:
@ -336,6 +423,30 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
recipients = await _compute_recipients(db_session, outbox_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":
if not outbox_object_to_undo.activity_object_ap_id:
raise ValueError(f"Invalid block activity {outbox_object_to_undo.ap_id}")
# Send the Undo to the blocked actor
blocked_actor = await fetch_actor(
db_session, outbox_object_to_undo.activity_object_ap_id
)
blocked_actor.is_blocked = False
await new_outgoing_activity(
db_session,
blocked_actor.inbox_url, # type: ignore
outbox_object.id,
)
notif = models.Notification(
notification_type=models.NotificationType.UNBLOCK,
actor_id=blocked_actor.id,
outbox_object_id=outbox_object.id,
)
db_session.add(notif)
else:
raise ValueError("Should never happen")
@ -346,6 +457,7 @@ async def fetch_conversation_root(
db_session: AsyncSession,
obj: AnyboxObject | RemoteObject,
is_root: bool = False,
depth: int = 0,
) -> str:
"""Some softwares do not set the context/conversation field (like Misskey).
This means we have to track conversation ourselves. To do so, we fetch
@ -353,10 +465,11 @@ async def fetch_conversation_root(
- use the context field if set
- or build a custom conversation ID
"""
if not obj.in_reply_to or is_root:
logger.info(f"Fetching convo root for ap_id={obj.ap_id}/{depth=}")
if obj.ap_context:
return obj.ap_context
else:
if not obj.in_reply_to or is_root or depth > 10:
# Use the root AP ID if there'no context
return f"microblogpub:root:{obj.ap_id}"
else:
@ -371,20 +484,24 @@ async def fetch_conversation_root(
)
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
except (
ap.ObjectNotFoundError,
ap.ObjectIsGoneError,
ap.FetchError,
ap.NotAnObjectError,
ap.ObjectUnavailableError,
):
return await fetch_conversation_root(db_session, obj, is_root=True)
return await fetch_conversation_root(
db_session, obj, is_root=True, depth=depth + 1
)
except httpx.HTTPStatusError as http_status_error:
if 400 <= http_status_error.response.status_code < 500:
# We may not have access, in this case consider if root
return await fetch_conversation_root(db_session, obj, is_root=True)
return await fetch_conversation_root(
db_session, obj, is_root=True, depth=depth + 1
)
else:
raise
return await fetch_conversation_root(db_session, in_reply_to_object)
return await fetch_conversation_root(
db_session, in_reply_to_object, depth=depth + 1
)
async def send_move(
@ -500,6 +617,9 @@ async def send_create(
else:
raise ValueError(f"Unhandled visibility {visibility}")
slug = None
url = outbox_object_id(note_id)
extra_obj_attrs = {}
if ap_type == "Question":
if not poll_answers or len(poll_answers) < 2:
@ -529,6 +649,8 @@ async def send_create(
if not name:
raise ValueError("Article must have a name")
slug = slugify(name)
url = f"{BASE_URL}/articles/{note_id[:7]}/{slug}"
extra_obj_attrs = {"name": name}
obj = {
@ -542,8 +664,8 @@ async def send_create(
"published": published,
"context": context,
"conversation": context,
"url": outbox_object_id(note_id),
"tag": tags,
"url": url,
"tag": dedup_tags(tags),
"summary": content_warning,
"inReplyTo": in_reply_to,
"sensitive": is_sensitive,
@ -556,6 +678,7 @@ async def send_create(
obj,
source=source,
conversation=conversation,
slug=slug,
)
if not outbox_object.id:
raise ValueError("Should never happen")
@ -563,7 +686,7 @@ async def send_create(
for tag in tags:
if tag["type"] == "Hashtag":
tagged_object = models.TaggedOutboxObject(
tag=tag["name"][1:],
tag=tag["name"][1:].lower(),
outbox_object_id=outbox_object.id,
)
db_session.add(tagged_object)
@ -1069,7 +1192,17 @@ async def _revert_side_effect_for_deleted_object(
) -> None:
is_delete_needs_to_be_forwarded = False
# Decrement the replies counter if needed
# Delete related notifications
notif_deletion_result = await db_session.execute(
delete(models.Notification)
.where(models.Notification.inbox_object_id == deleted_ap_object.id)
.execution_options(synchronize_session=False)
)
logger.info(
f"Deleted {notif_deletion_result.rowcount} notifications" # type: ignore
)
# Decrement/refresh the replies counter if needed
if deleted_ap_object.in_reply_to:
replied_object = await get_anybox_object_by_ap_id(
db_session,
@ -1387,6 +1520,13 @@ async def _handle_undo_activity(
inbox_object_id=ap_activity_to_undo.id,
)
db_session.add(notif)
elif ap_activity_to_undo.ap_type == "Block":
notif = models.Notification(
notification_type=models.NotificationType.UNBLOCKED,
actor_id=from_actor.id,
inbox_object_id=ap_activity_to_undo.id,
)
db_session.add(notif)
else:
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
@ -1481,7 +1621,7 @@ async def _handle_update_activity(
)
# Update the actor
from_actor.ap_actor = updated_actor.ap_actor
await update_actor_if_needed(db_session, from_actor, updated_actor)
elif (ap_type := wrapped_object["type"]) in [
"Question",
"Note",
@ -1504,6 +1644,7 @@ async def _handle_update_activity(
# Everything looks correct, update the object in the inbox
logger.info(f"Updating {existing_object.ap_id}")
existing_object.ap_object = wrapped_object
existing_object.updated_at = now()
else:
# TODO(ts): support updating objects
logger.info(f'Cannot update {wrapped_object["type"]}')
@ -1514,8 +1655,24 @@ async def _handle_create_activity(
from_actor: models.Actor,
create_activity: models.InboxObject,
forwarded_by_actor: models.Actor | None = None,
relates_to_inbox_object: models.InboxObject | None = None,
) -> None:
logger.info("Processing Create activity")
# Some PeerTube activities make no sense to process
if (
ap_object_type := ap.as_list(
(await ap.get_object(create_activity.ap_object))["type"]
)[0]
) in ["CacheFile"]:
logger.info(f"Dropping Create activity for {ap_object_type} object")
await db_session.delete(create_activity)
return None
if relates_to_inbox_object:
logger.warning(f"{relates_to_inbox_object.ap_id} is already in the inbox")
return None
wrapped_object = ap.unwrap_activity(create_activity.ap_object)
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
raise ValueError("Object actor does not match activity")
@ -1566,6 +1723,14 @@ async def _handle_read_activity(
if not wrapped_object_actor.is_blocked:
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor)
# Check if we already know about this object
if await get_inbox_object_by_ap_id(
db_session,
ro.ap_id,
):
logger.info(f"{ro.ap_id} is already in the inbox, skipping processing")
return None
# Then process it likes it's coming from a forwarded activity
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
@ -1576,11 +1741,8 @@ async def _process_note_object(
from_actor: models.Actor,
ro: RemoteObject,
forwarded_by_actor: models.Actor | None = None,
process_quoted_url: bool = True,
) -> models.InboxObject:
if process_quoted_url and parent_activity.quote_url == ro.ap_id:
logger.info(f"Processing quoted URL for {parent_activity.ap_id}")
elif parent_activity.ap_type not in ["Create", "Read"]:
) -> None:
if parent_activity.ap_type not in ["Create", "Read"]:
raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}")
ap_published_at = now()
@ -1623,7 +1785,6 @@ async def _process_note_object(
),
# We may already have some replies in DB
replies_count=await _get_replies_count(db_session, ro.ap_id),
quoted_inbox_object_id=None,
)
db_session.add(inbox_object)
@ -1704,28 +1865,6 @@ async def _process_note_object(
)
db_session.add(notif)
await db_session.flush()
if ro.quote_url and process_quoted_url:
try:
quoted_raw_object = await ap.fetch(ro.quote_url)
quoted_object_actor = await fetch_actor(
db_session, ap.get_actor_id(quoted_raw_object)
)
quoted_ro = RemoteObject(quoted_raw_object, quoted_object_actor)
quoted_inbox_object = await _process_note_object(
db_session,
inbox_object,
from_actor=quoted_object_actor,
ro=quoted_ro,
process_quoted_url=False,
)
inbox_object.quoted_inbox_object_id = quoted_inbox_object.id
except Exception:
logger.exception("Failed to process quoted object")
return inbox_object
async def _handle_vote_answer(
db_session: AsyncSession,
@ -1927,6 +2066,28 @@ async def _handle_like_activity(
db_session.add(notif)
async def _handle_block_activity(
db_session: AsyncSession,
actor: models.Actor,
block_activity: models.InboxObject,
):
if block_activity.activity_object_ap_id != LOCAL_ACTOR.ap_id:
logger.warning(
"Received invalid Block activity "
f"{block_activity.activity_object_ap_id=}"
)
await db_session.delete(block_activity)
return
# Create a notification
notif = models.Notification(
notification_type=models.NotificationType.BLOCKED,
actor_id=actor.id,
inbox_object_id=block_activity.id,
)
db_session.add(notif)
async def _process_transient_object(
db_session: AsyncSession,
raw_object: ap.RawObject,
@ -1975,7 +2136,7 @@ async def save_to_inbox(
except ap.ObjectNotFoundError:
logger.warning("Actor not found")
return
except httpx.HTTPStatusError:
except ap.FetchError:
logger.exception("Failed to fetch actor")
return
@ -1987,8 +2148,10 @@ async def save_to_inbox(
await _process_transient_object(db_session, raw_object, actor)
return None
if actor.is_blocked:
logger.warning("Actor {actor.ap_id} is blocked, ignoring object")
# If we just blocked an actor, we want to process any undo sent as side
# effects
if actor.is_blocked and ap.as_list(raw_object["type"])[0] != "Undo":
logger.warning(f"Actor {actor.ap_id} is blocked, ignoring object")
return None
raw_object_id = ap.get_id(raw_object)
@ -2083,7 +2246,11 @@ async def save_to_inbox(
if activity_ro.ap_type == "Create":
await _handle_create_activity(
db_session, actor, inbox_object, forwarded_by_actor=forwarded_by_actor
db_session,
actor,
inbox_object,
forwarded_by_actor=forwarded_by_actor,
relates_to_inbox_object=relates_to_inbox_object,
)
elif activity_ro.ap_type == "Read":
await _handle_read_activity(db_session, actor, inbox_object)
@ -2187,6 +2354,12 @@ async def save_to_inbox(
elif activity_ro.ap_type == "View":
# View is used by Peertube, there's nothing useful we can do with it
await db_session.delete(inbox_object)
elif activity_ro.ap_type == "Block":
await _handle_block_activity(
db_session,
actor,
inbox_object,
)
else:
logger.warning(f"Received an unknown {inbox_object.ap_type} object")
@ -2320,7 +2493,9 @@ async def get_replies_tree(
.where(
models.InboxObject.conversation
== requested_object.conversation,
models.InboxObject.ap_type.in_(["Note", "Page", "Article"]),
models.InboxObject.ap_type.in_(
["Note", "Page", "Article", "Question"]
),
models.InboxObject.is_deleted.is_(False),
models.InboxObject.visibility.in_(allowed_visibility),
)
@ -2338,7 +2513,9 @@ async def get_replies_tree(
models.OutboxObject.conversation
== requested_object.conversation,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.ap_type.in_(["Note", "Page", "Article"]),
models.OutboxObject.ap_type.in_(
["Note", "Page", "Article", "Question"]
),
models.OutboxObject.visibility.in_(allowed_visibility),
)
.options(

View File

@ -12,8 +12,9 @@ from fastapi import HTTPException
from fastapi import Request
from itsdangerous import URLSafeTimedSerializer
from loguru import logger
from markdown import markdown
from mistletoe import markdown # type: ignore
from app.customization import _CUSTOM_ROUTES
from app.utils.emoji import _load_emojis
from app.utils.version import get_version_commit
@ -158,9 +159,7 @@ ALSO_KNOWN_AS = CONFIG.also_known_as
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
CUSTOM_FOOTER = (
markdown(
CONFIG.custom_footer.replace("{version}", VERSION), extensions=["mdx_linkify"]
)
markdown(CONFIG.custom_footer.replace("{version}", VERSION))
if CONFIG.custom_footer
else None
)
@ -186,6 +185,31 @@ CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
MOVED_TO = _get_moved_to()
_NavBarItem = tuple[str, str]
class NavBarItems:
EXTRA_NAVBAR_ITEMS: list[_NavBarItem] = []
INDEX_NAVBAR_ITEM: _NavBarItem | None = None
NOTES_PATH = "/"
def load_custom_routes() -> None:
try:
from data import custom_routes # type: ignore # noqa: F401
except ImportError:
pass
for path, custom_handler in _CUSTOM_ROUTES.items():
# If a handler wants to replace the root, move the index to /notes
if path == "/":
NavBarItems.NOTES_PATH = "/notes"
NavBarItems.INDEX_NAVBAR_ITEM = (path, custom_handler.title)
else:
if custom_handler.show_in_navbar:
NavBarItems.EXTRA_NAVBAR_ITEMS.append((path, custom_handler.title))
session_serializer = URLSafeTimedSerializer(
CONFIG.secret,
salt=f"{ID}.session",

112
app/customization.py Normal file
View File

@ -0,0 +1,112 @@
from pathlib import Path
from typing import Any
from typing import Callable
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Request
from starlette.responses import JSONResponse
_DATA_DIR = Path().parent.resolve() / "data"
_Handler = Callable[..., Any]
class HTMLPage:
def __init__(
self,
title: str,
html_file: str,
show_in_navbar: bool,
) -> None:
self.title = title
self.html_file = _DATA_DIR / html_file
self.show_in_navbar = show_in_navbar
class RawHandler:
def __init__(
self,
title: str,
handler: Any,
show_in_navbar: bool,
) -> None:
self.title = title
self.handler = handler
self.show_in_navbar = show_in_navbar
_CUSTOM_ROUTES: dict[str, HTMLPage | RawHandler] = {}
def register_html_page(
path: str,
*,
title: str,
html_file: str,
show_in_navbar: bool = True,
) -> None:
if path in _CUSTOM_ROUTES:
raise ValueError(f"{path} is already registered")
_CUSTOM_ROUTES[path] = HTMLPage(title, html_file, show_in_navbar)
def register_raw_handler(
path: str,
*,
title: str,
handler: _Handler,
show_in_navbar: bool = True,
) -> None:
if path in _CUSTOM_ROUTES:
raise ValueError(f"{path} is already registered")
_CUSTOM_ROUTES[path] = RawHandler(title, handler, show_in_navbar)
class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json"
def _custom_page_handler(path: str, html_page: HTMLPage) -> Any:
from app import templates
from app.actor import LOCAL_ACTOR
from app.config import is_activitypub_requested
from app.database import AsyncSession
from app.database import get_db_session
async def _handler(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse | ActivityPubResponse:
if path == "/" and is_activitypub_requested(request):
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
return await templates.render_template(
db_session,
request,
"custom_page.html",
{
"page_content": html_page.html_file.read_text(),
"title": html_page.title,
},
)
return _handler
def get_custom_router() -> APIRouter | None:
if not _CUSTOM_ROUTES:
return None
router = APIRouter()
for path, handler in _CUSTOM_ROUTES.items():
if isinstance(handler, HTMLPage):
router.add_api_route(
path, _custom_page_handler(path, handler), methods=["GET"]
)
else:
router.add_api_route(path, handler.handler)
return router

View File

@ -88,8 +88,12 @@ def _body_digest(body: bytes) -> str:
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
if cached_key := _KEY_CACHE.get(key_id):
async def _get_public_key(
db_session: AsyncSession,
key_id: str,
should_skip_cache: bool = False,
) -> Key:
if not should_skip_cache and (cached_key := _KEY_CACHE.get(key_id)):
logger.info(f"Key {key_id} found in cache")
return cached_key
@ -101,6 +105,7 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
)
).one_or_none()
if not should_skip_cache:
if existing_actor and existing_actor.public_key_id == key_id:
k = Key(existing_actor.ap_id, key_id)
k.load_pub(existing_actor.public_key_as_pem)
@ -110,6 +115,8 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
# Fetch it
from app import activitypub as ap
from app.actor import RemoteActor
from app.actor import update_actor_if_needed
# Without signing the request as if it's the first contact, the 2 servers
# might race to fetch each other key
@ -133,6 +140,12 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
)
if should_skip_cache and actor["type"] != "Key" and existing_actor:
# We had to skip the cache, which means the actor key probably changed
# and we want to update our cached version
await update_actor_if_needed(db_session, existing_actor, RemoteActor(actor))
await db_session.commit()
_KEY_CACHE[key_id] = k
return k
@ -216,7 +229,17 @@ async def httpsig_checker(
has_valid_signature = _verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
)
# FIXME: fetch/update the user if the signature is wrong
# If the signature is not valid, we may have to update the cached actor
if not has_valid_signature:
logger.info("Invalid signature, trying to refresh actor")
try:
k = await _get_public_key(db_session, hsig["keyId"], should_skip_cache=True)
has_valid_signature = _verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
)
except Exception:
logger.exception("Failed to refresh actor")
httpsig_info = HTTPSigInfo(
has_valid_signature=has_valid_signature,

View File

@ -63,6 +63,7 @@ from app.config import USER_AGENT
from app.config import USERNAME
from app.config import is_activitypub_requested
from app.config import verify_csrf_token
from app.customization import get_custom_router
from app.database import AsyncSession
from app.database import async_session
from app.database import get_db_session
@ -192,6 +193,9 @@ app.include_router(admin.unauthenticated_router, prefix="/admin")
app.include_router(indieauth.router)
app.include_router(micropub.router)
app.include_router(webmentions.router)
config.load_custom_routes()
if custom_router := get_custom_router():
app.include_router(custom_router)
# XXX: order matters, the proxy middleware needs to be last
app.add_middleware(CustomMiddleware)
@ -243,7 +247,7 @@ class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json"
@app.get("/")
@app.get(config.NavBarItems.NOTES_PATH)
async def index(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
@ -632,13 +636,75 @@ async def _check_outbox_object_acl(
raise HTTPException(status_code=404)
async def _fetch_likes(
db_session: AsyncSession,
outbox_object: models.OutboxObject,
) -> list[models.InboxObject]:
return (
(
await db_session.scalars(
select(models.InboxObject)
.where(
models.InboxObject.ap_type == "Like",
models.InboxObject.activity_object_ap_id == outbox_object.ap_id,
models.InboxObject.is_deleted.is_(False),
)
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
)
)
.unique()
.all()
)
async def _fetch_shares(
db_session: AsyncSession,
outbox_object: models.OutboxObject,
) -> list[models.InboxObject]:
return (
(
await db_session.scalars(
select(models.InboxObject)
.filter(
models.InboxObject.ap_type == "Announce",
models.InboxObject.activity_object_ap_id == outbox_object.ap_id,
models.InboxObject.is_deleted.is_(False),
)
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
)
)
.unique()
.all()
)
async def _fetch_webmentions(
db_session: AsyncSession,
outbox_object: models.OutboxObject,
) -> list[models.Webmention]:
return (
await db_session.scalars(
select(models.Webmention)
.filter(
models.Webmention.outbox_object_id == outbox_object.id,
models.Webmention.is_deleted.is_(False),
)
.limit(10)
)
).all()
@app.get("/o/{public_id}")
async def outbox_by_public_id(
public_id: str,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
maybe_object = (
(
await db_session.execute(
@ -665,59 +731,79 @@ async def outbox_by_public_id(
if is_activitypub_requested(request):
return ActivityPubResponse(maybe_object.ap_object)
if maybe_object.ap_type == "Article":
return RedirectResponse(
f"/articles/{public_id[:7]}/{maybe_object.slug}",
status_code=301,
)
replies_tree = await boxes.get_replies_tree(
db_session,
maybe_object,
is_current_user_admin=is_current_user_admin(request),
)
likes = (
likes = await _fetch_likes(db_session, maybe_object)
shares = await _fetch_shares(db_session, maybe_object)
webmentions = await _fetch_webmentions(db_session, maybe_object)
return await templates.render_template(
db_session,
request,
"object.html",
{
"replies_tree": replies_tree,
"outbox_object": maybe_object,
"likes": likes,
"shares": shares,
"webmentions": webmentions,
},
)
@app.get("/articles/{short_id}/{slug}")
async def article_by_slug(
short_id: str,
slug: str,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
maybe_object = (
(
await db_session.scalars(
select(models.InboxObject)
await db_session.execute(
select(models.OutboxObject)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.where(
models.InboxObject.ap_type == "Like",
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
models.InboxObject.is_deleted.is_(False),
models.OutboxObject.public_id.like(f"{short_id}%"),
models.OutboxObject.slug == slug,
models.OutboxObject.is_deleted.is_(False),
)
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
)
)
.unique()
.all()
.scalar_one_or_none()
)
if not maybe_object:
raise HTTPException(status_code=404)
await _check_outbox_object_acl(request, db_session, maybe_object, httpsig_info)
if is_activitypub_requested(request):
return ActivityPubResponse(maybe_object.ap_object)
replies_tree = await boxes.get_replies_tree(
db_session,
maybe_object,
is_current_user_admin=is_current_user_admin(request),
)
shares = (
(
await db_session.scalars(
select(models.InboxObject)
.filter(
models.InboxObject.ap_type == "Announce",
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
models.InboxObject.is_deleted.is_(False),
)
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
)
)
.unique()
.all()
)
webmentions = (
await db_session.scalars(
select(models.Webmention)
.filter(
models.Webmention.outbox_object_id == maybe_object.id,
models.Webmention.is_deleted.is_(False),
)
.limit(10)
)
).all()
likes = await _fetch_likes(db_session, maybe_object)
shares = await _fetch_shares(db_session, maybe_object)
webmentions = await _fetch_webmentions(db_session, maybe_object)
return await templates.render_template(
db_session,
request,
@ -763,7 +849,7 @@ async def tag_by_name(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
where = [
models.TaggedOutboxObject.tag == tag,
models.TaggedOutboxObject.tag == tag.lower(),
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
]
@ -789,7 +875,7 @@ async def tag_by_name(
return ActivityPubResponse(
{
"@context": ap.AS_CTX,
"id": BASE_URL + f"/t/{tag}",
"id": BASE_URL + f"/t/{tag.lower()}",
"type": "OrderedCollection",
"totalItems": tagged_count,
"orderedItems": [
@ -883,6 +969,48 @@ async def post_remote_follow(
)
@app.get("/remote_interaction")
async def remote_interaction(
request: Request,
ap_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
outbox_object = await boxes.get_outbox_object_by_ap_id(
db_session,
ap_id,
)
if not outbox_object:
raise HTTPException(status_code=404)
return await templates.render_template(
db_session,
request,
"remote_interact.html",
{"outbox_object": outbox_object},
)
@app.post("/remote_interaction")
async def post_remote_interaction(
request: Request,
csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(),
ap_id: str = Form(),
) -> RedirectResponse:
if not profile.startswith("@"):
profile = f"@{profile}"
remote_follow_template = await get_remote_follow_template(profile)
if not remote_follow_template:
# TODO(ts): error message to user
raise HTTPException(status_code=404)
return RedirectResponse(
remote_follow_template.format(uri=ap_id),
status_code=302,
)
@app.get("/.well-known/webfinger")
async def wellknown_webfinger(resource: str) -> JSONResponse:
"""Exposes/servers WebFinger data."""
@ -1179,6 +1307,7 @@ async def robots_file():
Disallow: /followers
Disallow: /following
Disallow: /admin
Disallow: /remote_interaction
Disallow: /remote_follow"""

View File

@ -113,18 +113,6 @@ class InboxObject(Base, BaseObject):
uselist=False,
)
quoted_inbox_object_id = Column(
Integer,
ForeignKey("inbox.id", name="fk_quoted_inbox_object_id"),
nullable=True,
)
quoted_inbox_object: Mapped[Optional["InboxObject"]] = relationship(
"InboxObject",
foreign_keys=quoted_inbox_object_id,
remote_side=id,
uselist=False,
)
undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
# Link the oubox AP ID to allow undo without any extra query
@ -159,12 +147,6 @@ class InboxObject(Base, BaseObject):
def is_from_inbox(self) -> bool:
return True
@property
def quoted_object(self) -> Optional["InboxObject"]:
if self.quoted_inbox_object_id:
return self.quoted_inbox_object
return None
class OutboxObject(Base, BaseObject):
__tablename__ = "outbox"
@ -176,6 +158,7 @@ class OutboxObject(Base, BaseObject):
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
public_id = Column(String, nullable=False, index=True)
slug = Column(String, nullable=True, index=True)
ap_type = Column(String, nullable=False, index=True)
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
@ -300,8 +283,11 @@ class OutboxObject(Base, BaseObject):
return True
@property
def quoted_object(self) -> Optional["InboxObject"]:
return None
def url(self) -> str | None:
# XXX: rewrite old URL here for compat
if self.ap_type == "Article" and self.slug and self.public_id:
return f"{BASE_URL}/articles/{self.public_id[:7]}/{self.slug}"
return super().url
class Follower(Base):
@ -573,6 +559,14 @@ class NotificationType(str, enum.Enum):
UPDATED_WEBMENTION = "updated_webmention"
DELETED_WEBMENTION = "deleted_webmention"
# incoming
BLOCKED = "blocked"
UNBLOCKED = "unblocked"
# outgoing
BLOCK = "block"
UNBLOCK = "unblock"
class Notification(Base):
__tablename__ = "notifications"

View File

@ -388,7 +388,7 @@ nav.flexbox {
margin-right: 0px;
}
}
a {
a:not(.label-btn) {
color: $primary-color;
text-decoration: none;
&:hover, &:active {
@ -396,25 +396,31 @@ nav.flexbox {
text-decoration: underline;
}
}
a.active {
a.active:not(.label-btn) {
color: $secondary-color;
font-weight: bold;
}
}
// after nav.flexbox to override default behavior
a.label-btn {
color: $form-text-color;
&:hover {
text-decoration: none;
color: $form-text-color;
}
}
.ap-object {
margin: 15px 0;
padding: 20px;
.in-reply-to {
color: $muted-color;
&:hover {
color: $secondary-color;
text-decoration: underline;
}
}
nav {
color: $muted-color;
}
.in-reply-to {
display: inline;
color: $muted-color;
}
.e-content, .activity-og-meta {
a:hover {
text-decoration: underline;

View File

@ -1,52 +1,123 @@
import re
import typing
from markdown import markdown
from mistletoe import Document # type: ignore
from mistletoe.html_renderer import HTMLRenderer # type: ignore
from mistletoe.span_token import SpanToken # type: ignore
from pygments import highlight # type: ignore
from pygments.formatters import HtmlFormatter # type: ignore
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
from pygments.lexers import guess_lexer # type: ignore
from sqlalchemy import select
from app import webfinger
from app.config import BASE_URL
from app.config import CODE_HIGHLIGHTING_THEME
from app.database import AsyncSession
from app.utils import emoji
if typing.TYPE_CHECKING:
from app.actor import Actor
def _set_a_attrs(attrs, new=False):
attrs[(None, "target")] = "_blank"
attrs[(None, "class")] = "external"
attrs[(None, "rel")] = "noopener"
attrs[(None, "title")] = attrs[(None, "href")]
return attrs
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
_MENTION_REGEX = re.compile(r"(@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+)")
_URL_REGEX = re.compile(
"(https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*))" # noqa: E501
)
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
tags = []
hashtags = re.findall(_HASHTAG_REGEX, content)
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
for hashtag in hashtags:
tag = hashtag[1:]
link = f'<a href="{BASE_URL}/t/{tag}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
tags.append(dict(href=f"{BASE_URL}/t/{tag}", name=hashtag, type="Hashtag"))
content = content.replace(hashtag, link)
return content, tags
class AutoLink(SpanToken):
parse_inner = False
precedence = 1
pattern = _URL_REGEX
def __init__(self, match_obj: re.Match) -> None:
self.target = match_obj.group()
async def _mentionify(
class Mention(SpanToken):
parse_inner = False
precedence = 10
pattern = _MENTION_REGEX
def __init__(self, match_obj: re.Match) -> None:
self.target = match_obj.group()
class Hashtag(SpanToken):
parse_inner = False
precedence = 10
pattern = _HASHTAG_REGEX
def __init__(self, match_obj: re.Match) -> None:
self.target = match_obj.group()
class CustomRenderer(HTMLRenderer):
def __init__(
self,
mentioned_actors: dict[str, "Actor"] = {},
enable_mentionify: bool = True,
enable_hashtagify: bool = True,
) -> None:
extra_tokens = []
if enable_mentionify:
extra_tokens.append(Mention)
if enable_hashtagify:
extra_tokens.append(Hashtag)
super().__init__(AutoLink, *extra_tokens)
self.tags: list[dict[str, str]] = []
self.mentioned_actors = mentioned_actors
def render_auto_link(self, token: AutoLink) -> str:
template = '<a href="{target}" rel="noopener">{inner}</a>'
target = self.escape_url(token.target)
return template.format(target=target, inner=target)
def render_mention(self, token: Mention) -> str:
mention = token.target
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
return link
def render_hashtag(self, token: Hashtag) -> str:
tag = token.target[1:]
link = f'<a href="{BASE_URL}/t/{tag.lower()}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
self.tags.append(
dict(
href=f"{BASE_URL}/t/{tag.lower()}",
name=token.target.lower(),
type="Hashtag",
)
)
return link
def render_block_code(self, token: typing.Any) -> str:
code = token.children[0].content
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
return highlight(code, lexer, _FORMATTER)
async def _prefetch_mentioned_actors(
db_session: AsyncSession,
content: str,
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
) -> dict[str, "Actor"]:
from app import models
from app.actor import fetch_actor
tags = []
mentioned_actors = []
actors = {}
for mention in re.findall(_MENTION_REGEX, content):
if mention in actors:
continue
_, username, domain = mention.split("@")
actor = (
await db_session.execute(
@ -63,12 +134,27 @@ async def _mentionify(
continue
actor = await fetch_actor(db_session, actor_url)
mentioned_actors.append(actor)
tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
actors[mention] = actor
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
content = content.replace(mention, link)
return content, tags, mentioned_actors
return actors
def hashtagify(
content: str,
) -> tuple[str, list[dict[str, str]]]:
tags = []
with CustomRenderer(
mentioned_actors={},
enable_mentionify=False,
enable_hashtagify=True,
) as renderer:
rendered_content = renderer.render(Document(content))
tags.extend(renderer.tags)
# Handle custom emoji
tags.extend(emoji.tags(content))
return rendered_content, tags
async def markdownify(
@ -82,17 +168,33 @@ async def markdownify(
"""
tags = []
mentioned_actors: list["Actor"] = []
if enable_hashtagify:
content, hashtag_tags = hashtagify(content)
tags.extend(hashtag_tags)
mentioned_actors: dict[str, "Actor"] = {}
if enable_mentionify:
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
tags.extend(mention_tags)
mentioned_actors = await _prefetch_mentioned_actors(db_session, content)
with CustomRenderer(
mentioned_actors=mentioned_actors,
enable_mentionify=enable_mentionify,
enable_hashtagify=enable_hashtagify,
) as renderer:
rendered_content = renderer.render(Document(content))
tags.extend(renderer.tags)
# Handle custom emoji
tags.extend(emoji.tags(content))
content = markdown(content, extensions=["mdx_linkify", "fenced_code"])
return rendered_content, dedup_tags(tags), list(mentioned_actors.values())
return content, tags, mentioned_actors
def dedup_tags(tags: list[dict[str, str]]) -> list[dict[str, str]]:
idx = set()
deduped_tags = []
for tag in tags:
tag_idx = (tag["type"], tag["name"])
if tag_idx in idx:
continue
idx.add(tag_idx)
deduped_tags.append(tag)
return deduped_tags

View File

@ -291,6 +291,10 @@ ALLOWED_ATTRIBUTES: dict[str, list[str] | Callable[[str, str, str], bool]] = {
}
def _allow_all_attributes(tag: Any, name: Any, value: Any) -> bool:
return True
@lru_cache(maxsize=256)
def _update_inline_imgs(content):
soup = BeautifulSoup(content, "html5lib")
@ -320,7 +324,11 @@ def _clean_html(html: str, note: Object) -> str:
_update_inline_imgs(highlight(html))
),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
attributes=(
_allow_all_attributes
if note.ap_id.startswith(config.ID)
else ALLOWED_ATTRIBUTES
),
strip=True,
),
note,
@ -421,3 +429,4 @@ _templates.env.globals["CSS_HASH"] = config.CSS_HASH
_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

View File

@ -12,18 +12,16 @@
{% for outbox_object in outbox %}
{% if outbox_object.ap_type == "Announce" %}
<div class="actor-action">You shared</div>
<div class="actor-action">You shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% elif outbox_object.ap_type == "Like" %}
<div class="actor-action">You liked</div>
<div class="actor-action">You liked <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% elif outbox_object.ap_type == "Follow" %}
<div class="actor-action">You followed</div>
<div class="actor-action">You followed <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }}
{% else %}
Implement {{ outbox_object.ap_type }}
{% endif %}
{% endfor %}

View File

@ -0,0 +1,27 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>{{ local_actor.display_name }} - Search</title>
{% endblock %}
{% block content %}
<div class="box">
<form class="form" action="{{ url_for("admin_search") }}" method="GET">
<input type="text" name="query" value="{{ query if query else "" }}" autofocus>
<select name="what">
<option value="outbox">Outbox</option>
</select>
<input type="submit" value="Search">
</form>
</div>
{% for result in results %}
{{ utils.display_object(result) }}
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,30 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>{{ title }}</title>
{% if request.url.path == "/" %}
<link rel="indieauth-metadata" href="{{ url_for("well_known_authorization_server") }}">
<link rel="authorization_endpoint" href="{{ url_for("indieauth_authorization_endpoint") }}">
<link rel="token_endpoint" href="{{ url_for("indieauth_token_endpoint") }}">
<link rel="micropub" href="{{ url_for("micropub_endpoint") }}">
<link rel="alternate" href="{{ local_actor.url }}" title="ActivityPub profile" type="application/activity+json">
<meta content="profile" property="og:type" />
<meta content="{{ local_actor.url }}" property="og:url" />
<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="summary" property="twitter:card" />
<meta content="{{ local_actor.handle }}" property="profile:username" />
{% endif %}
{% endblock %}
{% block content %}
{% include "header.html" %}
<div class="box">
{{ page_content | safe }}
</div>
{% endblock %}

View File

@ -29,9 +29,16 @@
<a href="{{ url_for }}" {% if 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>
{% endmacro %}
<div class="public-top-menu">
<nav class="flexbox">
<ul>
{% if NAVBAR_ITEMS.INDEX_NAVBAR_ITEM %}
<li>{{ navbar_item_link(NAVBAR_ITEMS.INDEX_NAVBAR_ITEM) }}</li>
{% endif %}
<li>{{ header_link("index", "Notes") }}</li>
{% if articles_count %}
<li>{{ header_link("articles", "Articles") }}</li>
@ -43,6 +50,9 @@
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
{% endif %}
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
{% for navbar_item in NAVBAR_ITEMS.EXTRA_NAVBAR_ITEMS %}
{{ navbar_item_link(navbar_item) }}
{% endfor %}
</ul>
</nav>
</div>

View File

@ -30,6 +30,7 @@
<li>{{ admin_link("admin_inbox", "Inbox") }} / {{ admin_link("admin_outbox", "Outbox") }}</li>
<li>{{ admin_link("admin_direct_messages", "DMs") }}</li>
<li>{{ admin_link("get_notifications", "Notifications") }} {% if notifications_count %}({{ notifications_count }}){% endif %}</li>
<li>{{ admin_link("admin_search", "Search") }}</li>
<li>{{ admin_link("get_lookup", "Lookup") }}</li>
<li>{{ admin_link("admin_bookmarks", "Bookmarks") }}</li>
<li><a href="{{ url_for("logout")}}">Logout</a></li>

View File

@ -36,6 +36,18 @@
{%- elif notif.notification_type.value == "follow_request_rejected" %}
{{ notif_actor_action(notif, "rejected your follow request") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{% elif notif.notification_type.value == "blocked" %}
{{ notif_actor_action(notif, "blocked you") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{% elif notif.notification_type.value == "unblocked" %}
{{ notif_actor_action(notif, "unblocked you") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{% elif notif.notification_type.value == "block" %}
{{ notif_actor_action(notif, "was blocked") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{% elif notif.notification_type.value == "unblock" %}
{{ notif_actor_action(notif, "was unblocked") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{%- elif notif.notification_type.value == "move" %}
{# for move notif, the actor is the target and the inbox object the Move activity #}
<div class="actor-action">

View File

@ -0,0 +1,26 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>Interact from your instance</title>
{% endblock %}
{% block content %}
{% include "header.html" %}
<div class="box">
<h2>Interact with this object</h2>
</div>
{{ utils.display_object(outbox_object) }}
<div class="box">
<form class="form" action="{{ url_for("post_remote_interaction") }}" method="POST">
{{ utils.embed_csrf_token() }}
<input type="text" name="profile" placeholder="you@instance.tld" autofocus>
<input type="hidden" name="ap_id" value="{{ outbox_object.ap_id }}">
<input type="submit" value="interact from your instance">
</form>
</div>
{% endblock %}

View File

@ -216,13 +216,25 @@
<div>
<nav class="flexbox actor-metadata">
<ul>
{% if metadata.has_blocked_local_actor %}
<li>blocked you</li>
{% endif %}
{% if metadata.is_following %}
<li>already following</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
{% if not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
{% elif metadata.is_follow_request_sent %}
{% if metadata.is_follow_request_rejected %}
<li>follow request rejected</li>
{% if not metadata.has_blocked_local_actor %}
<li>{{ admin_follow_button(actor) }}</li>
{% endif %}
{% else %}
<li>follow request sent</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
{% endif %}
{% elif not actor.moved_to %}
<li>{{ admin_follow_button(actor) }}</li>
{% endif %}
@ -231,7 +243,7 @@
{% if not metadata.is_following and not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
{% elif actor.is_from_db and not with_details %}
{% elif actor.is_from_db and not with_details and not metadata.is_following %}
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
{% if actor.moved_to %}
@ -261,6 +273,9 @@
<li>rejected</li>
{% endif %}
{% endif %}
{% if with_details %}
<li><a href="{{ actor.url }}" class="label-btn">remote profile</a></li>
{% endif %}
</ul>
</nav>
</div>
@ -338,11 +353,13 @@
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
{% elif attachment.type == "Link" %}
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
{% else %}
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.url }}"{% endif %} class="attachment">
{% if attachment.name %}{{ attachment.name }}{% else %}{{ attachment.url | truncate(64, True) }}{% endif %}
</a> ({{ attachment.mimetype }})
{% endif %}
{% if object.sensitive %}
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
</div>
</div>
</div>
@ -369,9 +386,9 @@
{% endif %}
{% if object.in_reply_to %}
<a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
in reply to {{ object.in_reply_to|truncate(64, True) }}
</a>
<p class="in-reply-to">in reply to <a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" rel="nofollow">
this {{ object.ap_type|lower }}
</a></p>
{% endif %}
{% if object.ap_type == "Article" %}
@ -395,13 +412,6 @@
{{ object.content | clean_html(object) | safe }}
</div>
{% if object.quoted_object %}
<div class="ap-object-expanded ap-quoted-object">
{{ display_object(object.quoted_object) }}
</div>
{% endif %}
{% if object.ap_type == "Question" %}
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
{% if can_vote %}
@ -468,6 +478,16 @@
<li>
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
</li>
{% if object.is_from_outbox and is_object_page and not is_admin and not request.url.path.startswith("/remote_interaction") %}
<li>
<a class="label-btn" href="{{ request.url_for("remote_interaction") }}?ap_id={{ object.ap_id }}">
interact from your instance
</a>
</li>
{% endif %}
{% if not is_article_mode %}
<li>
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
@ -563,7 +583,7 @@
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
<li>
{% if object.announced_via_outbox_object_ap_id %}
{{ admin_undo_button(object.liked_via_outbox_object_ap_id, "unshare") }}
{{ admin_undo_button(object.announced_via_outbox_object_ap_id, "unshare") }}
{% else %}
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
{% endif %}

View File

@ -0,0 +1,32 @@
from typing import Any
from typing import Awaitable
from typing import Callable
from fastapi import Depends
from fastapi import Request
from fastapi.responses import JSONResponse
from app.actor import LOCAL_ACTOR
from app.config import is_activitypub_requested
from app.database import AsyncSession
from app.database import get_db_session
_Handler = Callable[[Request, AsyncSession], Awaitable[Any]]
def build_custom_index_handler(handler: _Handler) -> _Handler:
async def custom_index(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> Any:
# Serve the AP actor if requested
if is_activitypub_requested(request):
return JSONResponse(
LOCAL_ACTOR.ap_actor,
media_type="application/activity+json",
)
# Defer to the custom handler
return await handler(request, db_session)
return custom_index

View File

@ -9,6 +9,7 @@ from bs4 import BeautifulSoup # type: ignore
from loguru import logger
from pydantic import BaseModel
from app import activitypub as ap
from app import ap_object
from app import config
from app.actor import LOCAL_ACTOR
@ -66,11 +67,15 @@ async def external_urls(
tags_hrefs = set()
for tag in ro.tags:
if tag_href := tag.get("href"):
if tag_href and tag_href not in filter(None, [ro.quote_url]):
tags_hrefs.add(tag_href)
if tag.get("type") == "Mention":
if tag["href"] != LOCAL_ACTOR.ap_id:
try:
mentioned_actor = await fetch_actor(db_session, tag["href"])
except (ap.FetchError, ap.NotAnObjectError):
tags_hrefs.add(tag["href"])
continue
tags_hrefs.add(mentioned_actor.url)
tags_hrefs.add(mentioned_actor.ap_id)
else:
@ -85,6 +90,7 @@ async def external_urls(
if not h:
continue
try:
ph = urlparse(h)
mimetype, _ = mimetypes.guess_type(h)
if (
@ -97,6 +103,9 @@ async def external_urls(
)
):
urls.add(h)
except Exception:
logger.exception(f"Failed to check {h}")
continue
return urls - tags_hrefs

8
app/utils/text.py Normal file
View File

@ -0,0 +1,8 @@
import re
import unicodedata
def slugify(text: str) -> str:
value = unicodedata.normalize("NFKC", text)
value = re.sub(r"[^\w\s-]", "", value.lower())
return re.sub(r"[-\s]+", "-", value).strip("-_")

View File

@ -58,6 +58,10 @@ def is_url_valid(url: str) -> bool:
logger.warning(f"{parsed.hostname} is blocked")
return False
if parsed.hostname.endswith(".onion"):
logger.warning(f"{url} is an onion service")
return False
ip_address = _getaddrinfo(
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
)

View File

@ -89,6 +89,12 @@ Setup config.
poetry run inv configuration-wizard
```
Setup the database.
```bash
poetry run inv migrate-db
```
Grab your virtualenv path.
```bash

View File

@ -63,7 +63,7 @@ nav a:hover, main a:hover, header p a:hover {
max-width: 960px;
margin: 50px auto;
}
pre code {
pre {
padding: 10px;
overflow: auto;
display: block;

View File

@ -333,13 +333,13 @@ make compile-scss
### Password reset
If have lost your password, you can generate a new one using the `password-reset` task.
If have lost your password, you can generate a new one using the `reset-password` task.
#### Python edition
```bash
# shutdown supervisord
poetry run inv password-reset
poetry run inv reset-password
# edit data/profile.toml
# restart supervisord
```
@ -348,7 +348,7 @@ poetry run inv password-reset
```bash
docker compose stop
make password-reset
make reset-password
# edit data/profile.toml
docker compose up -d
```

706
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,6 @@ httpx = {extras = ["http2"], version = "^0.23.0"}
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
alembic = "^1.8.0"
bleach = "^5.0.0"
Markdown = "^3.3.7"
prompt-toolkit = "^3.0.29"
tomli-w = "^1.0.0"
python-dateutil = "^2.8.2"
@ -27,7 +26,6 @@ html5lib = "^1.1"
mf2py = "^1.1.2"
Pygments = "^2.12.0"
loguru = "^0.6.0"
mdx-linkify = "^2.1"
Pillow = "^9.1.1"
blurhash-python = "^1.1.3"
html2text = "^2020.1.16"
@ -45,6 +43,7 @@ boussole = "^2.0.0"
uvicorn = {extras = ["standard"], version = "^0.18.3"}
Brotli = "^1.0.9"
greenlet = "^1.1.3"
mistletoe = "^0.9.0"
[tool.poetry.dev-dependencies]
black = "^22.3.0"

View File

@ -1,19 +1,115 @@
import re
import shutil
import typing
from pathlib import Path
from typing import Any
from jinja2 import Environment
from jinja2 import FileSystemLoader
from jinja2 import select_autoescape
from markdown import markdown
from mistletoe import Document # type: ignore
from mistletoe import HTMLRenderer # type: ignore
from mistletoe import block_token # type: ignore
from pygments import highlight # type: ignore
from pygments.formatters import HtmlFormatter # type: ignore
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
from pygments.lexers import guess_lexer # type: ignore
from app.config import VERSION
from app.source import CustomRenderer
from app.utils.datetime import now
_FORMATTER = HtmlFormatter()
_FORMATTER.noclasses = True
def markdownify(content: str) -> str:
return markdown(
content, extensions=["mdx_linkify", "fenced_code", "codehilite", "toc"]
class DocRenderer(CustomRenderer):
def __init__(
self,
depth=5,
omit_title=True,
filter_conds=[],
) -> None:
super().__init__(
enable_mentionify=False,
enable_hashtagify=False,
)
self._headings: list[tuple[int, str, str]] = []
self._ids: set[str] = set()
self.depth = depth
self.omit_title = omit_title
self.filter_conds = filter_conds
@property
def toc(self):
"""
Returns table of contents as a block_token.List instance.
"""
def get_indent(level):
if self.omit_title:
level -= 1
return " " * 4 * (level - 1)
def build_list_item(heading):
level, content, title_id = heading
template = '{indent}- <a href="#{id}" rel="nofollow">{content}</a>\n'
return template.format(
indent=get_indent(level), content=content, id=title_id
)
lines = [build_list_item(heading) for heading in self._headings]
items = block_token.tokenize(lines)
return items[0]
def render_heading(self, token):
"""
Overrides super().render_heading; stores rendered heading first,
then returns it.
"""
template = '<h{level} id="{id}">{inner}</h{level}>'
inner = self.render_inner(token)
title_id = inner.lower().replace(" ", "-")
if title_id in self._ids:
i = 1
while 1:
title_id = f"{title_id}_{i}"
if title_id not in self._ids:
break
self._ids.add(title_id)
rendered = template.format(level=token.level, inner=inner, id=title_id)
content = self.parse_rendered_heading(rendered)
if not (
self.omit_title
and token.level == 1
or token.level > self.depth
or any(cond(content) for cond in self.filter_conds)
):
self._headings.append((token.level, content, title_id))
return rendered
@staticmethod
def parse_rendered_heading(rendered):
"""
Helper method; converts rendered heading to plain text.
"""
return re.sub(r"<.+?>", "", rendered)
def render_block_code(self, token: typing.Any) -> str:
code = token.children[0].content
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
return highlight(code, lexer, _FORMATTER)
def markdownify(content: str) -> tuple[str, Any]:
with DocRenderer() as renderer:
rendered_content = renderer.render(Document(content))
with HTMLRenderer() as html_renderer:
toc = html_renderer.render(renderer.toc)
return rendered_content, toc
def main() -> None:
@ -30,32 +126,36 @@ def main() -> None:
last_updated = now().replace(second=0, microsecond=0).isoformat()
readme = Path("README.md")
content, toc = markdownify(readme.read_text().removeprefix("# microblog.pub"))
template.stream(
content=markdownify(readme.read_text().removeprefix("# microblog.pub")),
content=content,
version=VERSION,
path="/",
last_updated=last_updated,
).dump("docs/dist/index.html")
install = Path("docs/install.md")
content, toc = markdownify(install.read_text())
template.stream(
content=markdownify(install.read_text()),
content=content.replace("[TOC]", toc),
version=VERSION,
path="/installing.html",
last_updated=last_updated,
).dump("docs/dist/installing.html")
user_guide = Path("docs/user_guide.md")
content, toc = markdownify(user_guide.read_text())
template.stream(
content=markdownify(user_guide.read_text()),
content=content.replace("[TOC]", toc),
version=VERSION,
path="/user_guide.html",
last_updated=last_updated,
).dump("docs/dist/user_guide.html")
developer_guide = Path("docs/developer_guide.md")
content, toc = markdownify(developer_guide.read_text())
template.stream(
content=markdownify(developer_guide.read_text()),
content=content.replace("[TOC]", toc),
version=VERSION,
path="/developer_guide.html",
last_updated=last_updated,

View File

@ -1,5 +1,6 @@
import asyncio
import io
import shutil
import tarfile
from contextlib import contextmanager
from pathlib import Path
@ -45,7 +46,12 @@ def compile_scss(ctx, watch=False):
# type: (Context, bool) -> None
from app.utils.favicon import build_favicon
favicon_file = Path("data/favicon.ico")
if not favicon_file.exists():
build_favicon()
else:
shutil.copy2(favicon_file, "app/static/favicon.ico")
theme_file = Path("data/_theme.scss")
if not theme_file.exists():
theme_file.write_text("// override vars for theming here")

View File

@ -68,6 +68,20 @@ def build_accept_activity(
}
def build_block_activity(
from_remote_actor: actor.RemoteActor,
for_remote_actor: actor.RemoteActor,
outbox_public_id: str | None = None,
) -> ap.RawObject:
return {
"@context": ap.AS_CTX,
"type": "Block",
"id": from_remote_actor.ap_id + "/block/" + (outbox_public_id or uuid4().hex),
"actor": from_remote_actor.ap_id,
"object": for_remote_actor.ap_id,
}
def build_move_activity(
from_remote_actor: actor.RemoteActor,
for_remote_object: actor.RemoteActor,

View File

@ -423,3 +423,53 @@ def test_inbox__move_activity(
).scalar_one()
assert notif.actor.ap_id == new_ra.ap_id
assert notif.inbox_object_id == inbox_activity.id
def test_inbox__block_activity(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# Given a remote actor
ra = setup_remote_actor(respx_mock)
# Which is followed by the local actor
setup_remote_actor_as_following(ra)
# When receiving a Block activity
follow_activity = RemoteObject(
factories.build_block_activity(
from_remote_actor=ra,
for_remote_actor=LOCAL_ACTOR,
),
ra,
)
with mock_httpsig_checker(ra):
response = client.post(
"/inbox",
headers={"Content-Type": ap.AS_CTX},
json=follow_activity.ap_object,
)
# Then the server returns a 202
assert response.status_code == 202
run_process_next_incoming_activity()
# And the actor was saved in DB
saved_actor = db.execute(select(models.Actor)).scalar_one()
assert saved_actor.ap_id == ra.ap_id
# And the Block activity was saved in the inbox
inbox_activity = db.execute(
select(models.InboxObject).where(models.InboxObject.ap_type == "Block")
).scalar_one()
# And a notification was created
notif = db.execute(
select(models.Notification).where(
models.Notification.notification_type == models.NotificationType.BLOCKED
)
).scalar_one()
assert notif.actor.ap_id == ra.ap_id
assert notif.inbox_object_id == inbox_activity.id

View File

@ -179,7 +179,7 @@ def test_send_create_activity__with_attachment(
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note"
assert outbox_object.summary is None
assert outbox_object.content == "<p>hello</p>"
assert outbox_object.content == "<p>hello</p>\n"
assert len(outbox_object.attachments) == 1
attachment = outbox_object.attachments[0]
assert attachment.type == "Document"
@ -227,7 +227,7 @@ def test_send_create_activity__no_content_with_cw_and_attachments(
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note"
assert outbox_object.summary is None
assert outbox_object.content == "<p>cw</p>"
assert outbox_object.content == "<p>cw</p>\n"
assert len(outbox_object.attachments) == 1