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

83 Commits

Author SHA1 Message Date
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
d96ec913d4 Add support for displaying events from Mobilizon 2022-11-07 20:35:23 +01:00
5b505b0e37 Update deps 2022-11-07 18:53:52 +01:00
530491ff10 Fix typing 2022-11-07 18:53:45 +01:00
48740ea8cb Allow templates to be overridden in data/templates/
I'd like to customize my instance's theme beyond what's possible with
_theme.scss.  This patch would allow me to do that, and keep my changes
self-contained in data/ without maintaining a local patchset over
app/templates/.

For utils.html, I've also added scoped blocks around the body of every
macro.  This allows the macros to be overridden individually in
data/templates/utils.html, without copying the whole file.  For example,
to only override the display of a specific actor's name/icon:

    {% extends "app/utils.html" %}
    {% block display_actor %}
    {% if actor.ap_id == "https://me.example.com" %}
    <!-- custom actor display -->
    {% else %}
    {{ super() }}
    {% endif %}
    {% endblock %}
2022-11-07 18:46:21 +01:00
0d7c121781 Fix formatting 2022-11-06 16:57:04 +01:00
a4cfd65009 Sign media URLs to avoid becoming an open proxy
Signatures are valid for ~1 week.
2022-11-04 19:36:26 +01:00
540b9d1470 Minor tweaks about non-root handling 2022-11-04 19:28:21 +01:00
1c076049cf Fix URL generation when not at domain root 2022-11-04 19:22:30 +01:00
242bf7b515 fixup! Fix URL generation when not at domain root
Oops -- missed these two!  Sorry for the noise; let me know if you'd
like me to squash and resubmit.
2022-11-04 19:22:30 +01:00
2843155501 Allow actor id to be specified in config
This is useful if the actor won't be at the root of the domain.
2022-11-04 19:22:30 +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
4fcf585c23 Fix OG meta display 2022-09-20 20:15:59 +02:00
6873ede288 Tweak CSS 2022-09-20 20:00:35 +02:00
e0ad21f335 Drop View activities 2022-09-20 12:22:00 +02:00
b3f25e7da1 Improve replies counter for out-of-order replies 2022-09-19 21:16:09 +02:00
d44c8a58aa More improvements for the replies counter 2022-09-19 20:46:05 +02:00
54aa2f51f4 Improve replies counter handling 2022-09-19 20:31:54 +02:00
3305d489ec Fix tag parsing for actors 2022-09-19 19:33:44 +02:00
e19c623c71 Tweak Dockerfile 2022-09-18 21:33:50 +02:00
5905ad96b4 Tweak Dockerfile 2022-09-18 20:54:25 +02:00
9093659b0a Tweak error wording 2022-09-16 18:37:09 +02:00
b99552384c Improve expired session and CSRF error handling 2022-09-16 18:14:50 +02:00
949365d8ba Add more tasks and tweak docs 2022-09-16 17:38:19 +02:00
a55b06b252 knoweldge -> knowledge 2022-09-16 08:58:22 +02:00
c30033c19e Fix minor grammatical issues, mostly in docs 2022-09-16 08:52:43 +02:00
52 changed files with 2451 additions and 764 deletions

View File

@ -10,13 +10,18 @@ ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
FROM python-base as builder-base
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl build-essential gcc
RUN apt-get install -y --no-install-recommends curl build-essential gcc libffi-dev libssl-dev libxml2-dev libxslt1-dev zlib1g-dev libxslt-dev gcc libjpeg-dev zlib1g-dev libwebp-dev
# rustc is needed to compile Python packages
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl -sSL https://install.python-poetry.org | python3 -
WORKDIR $PYSETUP_PATH
COPY poetry.lock pyproject.toml ./
RUN poetry install --no-dev
RUN poetry install --only main
FROM python-base as production
RUN apt-get update
RUN apt-get install -y --no-install-recommends libjpeg-dev libxslt1-dev libxml2-dev libxslt-dev
RUN groupadd --gid 1000 microblogpub \
&& useradd --uid 1000 --gid microblogpub --shell /bin/bash microblogpub
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH

View File

@ -12,20 +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
move-to:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
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 --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 --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 --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

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

@ -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:
@ -139,6 +154,13 @@ if 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:
@ -170,13 +192,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
resp.raise_for_status()
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
@ -79,11 +82,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:
@ -109,7 +122,7 @@ class Actor:
@property
def tags(self) -> list[ap.RawObject]:
return self.ap_actor.get("tag", [])
return ap.as_list(self.ap_actor.get("tag", []))
@property
def followers_collection_id(self) -> str | None:
@ -189,26 +202,64 @@ async def fetch_actor(
if existing_actor:
if existing_actor.is_deleted:
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
return existing_actor
else:
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),
)
)
).one_or_none()
if existing_actor_by_url:
return existing_actor_by_url
return await save_actor(db_session, ap_actor)
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:
raise ap.ObjectNotFoundError
return existing_actor
if save_if_not_found:
ap_actor = await ap.fetch(actor_id)
# 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
return await save_actor(db_session, ap_actor)
else:
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 +268,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 +315,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 +357,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
@ -311,6 +390,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

@ -25,7 +25,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
@ -40,13 +42,22 @@ from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME
def user_session_or_redirect(
async def user_session_or_redirect(
request: Request,
session: str | None = Cookie(default=None),
) -> None:
if request.method == "POST":
form_data = await request.form()
if "redirect_url" in form_data:
redirect_url = form_data["redirect_url"]
else:
redirect_url = request.url_for("admin_stream")
else:
redirect_url = str(request.url)
_RedirectToLoginPage = HTTPException(
status_code=302,
headers={"Location": request.url_for("login") + f"?redirect={request.url}"},
headers={"Location": request.url_for("login") + f"?redirect={redirect_url}"},
)
if not session:
@ -331,6 +342,7 @@ async def admin_inbox(
"Update",
"Undo",
"Read",
"Reject",
"Add",
"Remove",
"EmojiReact",
@ -838,6 +850,30 @@ 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/follow")
async def admin_actions_follow(
request: Request,
@ -859,10 +895,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)
@ -875,9 +908,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,11 +1,12 @@
import hashlib
import mimetypes
from datetime import datetime
from functools import cached_property
from typing import Any
import pydantic
from bs4 import BeautifulSoup # type: ignore
from markdown import markdown
from mistletoe import markdown # type: ignore
from app import activitypub as ap
from app.actor import LOCAL_ACTOR
@ -95,6 +96,9 @@ class Object:
def attachments(self) -> list["Attachment"]:
attachments = []
for obj in ap.as_list(self.ap_object.get("attachment", [])):
if obj.get("type") == "PropertyValue":
continue
if obj.get("type") == "Link":
attachments.append(
Attachment.parse_obj(
@ -155,7 +159,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):
@ -175,7 +179,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
@ -276,6 +280,20 @@ 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
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):

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:
@ -130,7 +216,11 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
db_session, outbox_object_to_delete.in_reply_to
)
if replied_object:
replied_object.replies_count = replied_object.replies_count - 1
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
@ -260,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"
)
@ -284,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:
@ -332,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")
@ -342,19 +457,21 @@ 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 set, we fetch
This means we have to track conversation ourselves. To do so, we fetch
the root of the conversation and either:
- use the context field if set
- or build a custom conversation ID
"""
if not obj.in_reply_to or is_root:
if obj.ap_context:
return obj.ap_context
else:
# Use the root AP ID if there'no context
return f"microblogpub:root:{obj.ap_id}"
logger.info(f"Fetching convo root for ap_id={obj.ap_id}/{depth=}")
if obj.ap_context:
return obj.ap_context
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:
in_reply_to_object: AnyboxObject | RemoteObject | None = (
await get_anybox_object_by_ap_id(db_session, obj.in_reply_to)
@ -366,16 +483,25 @@ async def fetch_conversation_root(
db_session, ap.get_actor_id(raw_reply)
)
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError, ap.NotAnObjectError):
return await fetch_conversation_root(db_session, obj, is_root=True)
except (
ap.FetchError,
ap.NotAnObjectError,
):
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(
@ -452,6 +578,7 @@ async def send_create(
content, tags, mentioned_actors = await markdownify(db_session, source)
attachments = []
in_reply_to_object: AnyboxObject | None = None
if in_reply_to:
in_reply_to_object = await get_anybox_object_by_ap_id(db_session, in_reply_to)
if not in_reply_to_object:
@ -469,23 +596,6 @@ async def send_create(
context = in_reply_to_object.ap_context
conversation = in_reply_to_object.ap_context
if in_reply_to_object.is_from_outbox:
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.ap_id == in_reply_to,
)
.values(replies_count=models.OutboxObject.replies_count + 1)
)
elif in_reply_to_object.is_from_inbox:
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.ap_id == in_reply_to,
)
.values(replies_count=models.InboxObject.replies_count + 1)
)
for (upload, filename, alt_text) in uploads:
attachments.append(upload_to_attachment(upload, filename, alt_text))
@ -507,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:
@ -536,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 = {
@ -549,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,
@ -563,6 +678,7 @@ async def send_create(
obj,
source=source,
conversation=conversation,
slug=slug,
)
if not outbox_object.id:
raise ValueError("Should never happen")
@ -570,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)
@ -604,6 +720,31 @@ async def send_create(
)
await db_session.commit()
# Refresh the replies counter if needed
if in_reply_to_object:
new_replies_count = await _get_replies_count(
db_session, in_reply_to_object.ap_id
)
if in_reply_to_object.is_from_outbox:
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.ap_id == in_reply_to_object.ap_id,
)
.values(replies_count=new_replies_count)
)
elif in_reply_to_object.is_from_inbox:
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.ap_id == in_reply_to_object.ap_id,
)
.values(replies_count=new_replies_count)
)
await db_session.commit()
return note_id
@ -1020,15 +1161,48 @@ async def _handle_delete_activity(
await db_session.flush()
async def _get_replies_count(
db_session: AsyncSession,
replied_object_ap_id: str,
) -> int:
return (
await db_session.scalar(
select(func.count(models.InboxObject.id)).where(
func.json_extract(models.InboxObject.ap_object, "$.inReplyTo")
== replied_object_ap_id,
models.InboxObject.is_deleted.is_(False),
)
)
) + (
await db_session.scalar(
select(func.count(models.OutboxObject.id)).where(
func.json_extract(models.OutboxObject.ap_object, "$.inReplyTo")
== replied_object_ap_id,
models.OutboxObject.is_deleted.is_(False),
)
)
)
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:
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,
@ -1040,20 +1214,28 @@ 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
)
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.id == replied_object.id,
)
.values(replies_count=models.OutboxObject.replies_count - 1)
.values(replies_count=new_replies_count - 1)
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.id == replied_object.id,
)
.values(replies_count=models.InboxObject.replies_count - 1)
.values(replies_count=new_replies_count - 1)
)
if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id:
@ -1100,7 +1282,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
):
@ -1338,6 +1521,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")
@ -1432,7 +1622,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",
@ -1455,6 +1645,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"]}')
@ -1465,8 +1656,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")
@ -1484,8 +1691,9 @@ async def _handle_create_activity(
logger.warning(
f"Got a Delete for {ro.ap_id} from {delete_object.actor.ap_id}??"
)
return None
else:
logger.info("Got a Delete for this object, deleting activity")
logger.info("Already received a Delete for this object, deleting activity")
create_activity.is_deleted = True
await db_session.flush()
return None
@ -1516,6 +1724,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)
@ -1568,6 +1784,8 @@ async def _process_note_object(
is_hidden_from_stream=not (
(not is_reply and is_from_following) or is_mention or is_local_reply
),
# We may already have some replies in DB
replies_count=await _get_replies_count(db_session, ro.ap_id),
)
db_session.add(inbox_object)
@ -1591,20 +1809,28 @@ 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
)
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.id == replied_object.id,
)
.values(replies_count=models.OutboxObject.replies_count + 1)
.values(replies_count=new_replies_count)
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.id == replied_object.id,
)
.values(replies_count=models.InboxObject.replies_count + 1)
.values(replies_count=new_replies_count)
)
# This object is a reply of a local object, we may need to forward it
@ -1841,6 +2067,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,
@ -1889,7 +2137,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
@ -1901,8 +2149,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)
@ -1997,7 +2247,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)
@ -2098,6 +2352,15 @@ async def save_to_inbox(
relates_to_outbox_object,
relates_to_inbox_object,
)
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")
@ -2231,7 +2494,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),
)
@ -2249,7 +2514,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

@ -1,4 +1,5 @@
import hashlib
import hmac
import os
import secrets
from pathlib import Path
@ -12,8 +13,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
@ -90,6 +92,7 @@ class Config(pydantic.BaseModel):
summary: str
https: bool
icon_url: str
image_url: str | None = None
secret: str
debug: bool = False
trusted_hosts: list[str] = ["127.0.0.1"]
@ -107,10 +110,15 @@ 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
# Only set when the app is served on a non-root path
id: str | None = None
def load_config() -> Config:
try:
@ -145,6 +153,11 @@ CONFIG = load_config()
DOMAIN = CONFIG.domain
_SCHEME = "https" if CONFIG.https else "http"
ID = f"{_SCHEME}://{DOMAIN}"
# When running the app on a path, the ID maybe set by the config, but in this
# case, a valid webfinger must be served on the root domain
if CONFIG.id:
ID = CONFIG.id
USERNAME = CONFIG.username
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
HIDES_FOLLOWERS = CONFIG.hides_followers
@ -155,12 +168,11 @@ 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
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
)
@ -177,7 +189,9 @@ if CONFIG.emoji:
EMOJIS = CONFIG.emoji
# Emoji template for the FE
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
EMOJI_TPL = (
'<img src="{base_url}/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
)
_load_emojis(ROOT_DIR, BASE_URL)
@ -186,6 +200,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",
@ -200,10 +239,23 @@ def generate_csrf_token() -> str:
return csrf_serializer.dumps(secrets.token_hex(16)) # type: ignore
def verify_csrf_token(csrf_token: str = Form()) -> None:
def verify_csrf_token(
csrf_token: str = Form(),
redirect_url: str | None = Form(None),
) -> None:
please_try_again = "please try again"
if redirect_url:
please_try_again = f'<a href="{redirect_url}">please try again</a>'
try:
csrf_serializer.loads(csrf_token, max_age=1800)
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
logger.exception("Failed to verify CSRF token")
raise HTTPException(status_code=403, detail="CSRF error")
raise HTTPException(
status_code=403,
detail=f"The security token has expired, {please_try_again}",
)
return None
def hmac_sha256():
return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)

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,15 +105,18 @@ 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 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)
logger.info(f"Found {key_id} on an existing actor")
_KEY_CACHE[key_id] = k
return k
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)
logger.info(f"Found {key_id} on an existing actor")
_KEY_CACHE[key_id] = k
return k
# 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

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

@ -276,7 +276,7 @@ async def _check_access_token(
if now() > access_token_info.created_at.replace(tzinfo=timezone.utc) + timedelta(
seconds=access_token_info.expires_in
):
logger.info("Access token is expired")
logger.info("Access token has expired")
return False, None
return True, access_token_info

View File

@ -48,6 +48,7 @@ from app import boxes
from app import config
from app import httpsig
from app import indieauth
from app import media
from app import micropub
from app import models
from app import templates
@ -63,6 +64,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
@ -135,9 +137,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;"
@ -192,6 +200,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 +254,31 @@ class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json"
@app.get("/")
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),
@ -632,13 +667,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 +762,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"{BASE_URL}/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 +880,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 +906,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": [
@ -866,9 +983,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}"
@ -877,9 +995,54 @@ 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),
)
@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,
db_session: AsyncSession = Depends(get_db_session),
csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(),
ap_id: str = Form(),
) -> templates.TemplateResponse:
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 await redirect_to_remote_instance(
request,
db_session,
remote_follow_template.format(uri=ID),
status_code=302,
)
@ -1000,14 +1163,17 @@ def _add_cache_control(headers: dict[str, str]) -> dict[str, str]:
return {**headers, "Cache-Control": "max-age=31536000"}
@app.get("/proxy/media/{encoded_url}")
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}")
async def serve_proxy_media(
request: Request,
exp: int,
sig: str,
encoded_url: str,
) -> StreamingResponse | PlainTextResponse:
# Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url)
media.verify_proxied_media_sig(exp, url, sig)
proxy_resp = await _proxy_get(request, url, stream=True)
@ -1040,9 +1206,11 @@ async def serve_proxy_media(
)
@app.get("/proxy/media/{encoded_url}/{size}")
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}/{size}")
async def serve_proxy_media_resized(
request: Request,
exp: int,
sig: str,
encoded_url: str,
size: int,
) -> PlainTextResponse:
@ -1052,6 +1220,7 @@ async def serve_proxy_media_resized(
# Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url)
media.verify_proxied_media_sig(exp, url, sig)
if cached_resp := _RESIZED_CACHE.get((url, size)):
resized_content, resized_mimetype, resp_headers = cached_resp
@ -1179,6 +1348,7 @@ async def robots_file():
Disallow: /followers
Disallow: /following
Disallow: /admin
Disallow: /remote_interaction
Disallow: /remote_follow"""

View File

@ -1,15 +1,44 @@
import base64
import time
from app.config import BASE_URL
from app.config import hmac_sha256
SUPPORTED_RESIZE = [50, 740]
EXPIRY_PERIOD = 86400
EXPIRY_LENGTH = 7
class InvalidProxySignatureError(Exception):
pass
def proxied_media_sig(expires: int, url: str) -> str:
hm = hmac_sha256()
hm.update(f"{expires}".encode())
hm.update(b"|")
hm.update(url.encode())
return base64.urlsafe_b64encode(hm.digest()).decode()
def verify_proxied_media_sig(expires: int, url: str, sig: str) -> None:
now = int(time.time() / EXPIRY_PERIOD)
expected = proxied_media_sig(expires, url)
if now > expires or sig != expected:
raise InvalidProxySignatureError("invalid or expired media")
def proxied_media_url(url: str) -> str:
if url.startswith(BASE_URL):
return url
expires = int(time.time() / EXPIRY_PERIOD) + EXPIRY_LENGTH
sig = proxied_media_sig(expires, url)
return "/proxy/media/" + base64.urlsafe_b64encode(url.encode()).decode()
return (
BASE_URL
+ f"/proxy/media/{expires}/{sig}/"
+ base64.urlsafe_b64encode(url.encode()).decode()
)
def resized_media_url(url: str, size: int) -> str:

View File

@ -75,7 +75,7 @@ class InboxObject(Base, BaseObject):
ap_actor_id = Column(String, nullable=False)
ap_type = Column(String, nullable=False, index=True)
ap_id = Column(String, nullable=False, unique=True, index=True)
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
ap_context = Column(String, nullable=True)
ap_published_at = Column(DateTime(timezone=True), nullable=False)
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
@ -158,9 +158,10 @@ 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 = Column(String, nullable=False, unique=True, index=True)
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
ap_context = Column(String, nullable=True)
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
@ -250,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
+ (
@ -281,6 +284,13 @@ class OutboxObject(Base, BaseObject):
def is_from_outbox(self) -> bool:
return True
@property
def url(self) -> str | None:
# XXX: rewrite old URL here for compat
if self.ap_type == "Article" and self.slug and self.public_id:
return f"{BASE_URL}/articles/{self.public_id[:7]}/{self.slug}"
return super().url
class Follower(Base):
__tablename__ = "follower"
@ -551,6 +561,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

@ -212,6 +212,7 @@ a {
}
}
#main {
display: flex;
flex: 1;
}
main {
@ -220,10 +221,19 @@ main {
margin: 30px auto;
}
.main-flex {
display: flex;
flex: 1;
}
.centered {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
div {
display: block;
}
}
footer {
@ -378,7 +388,7 @@ nav.flexbox {
margin-right: 0px;
}
}
a {
a:not(.label-btn) {
color: $primary-color;
text-decoration: none;
&:hover, &:active {
@ -386,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;
@ -509,3 +525,19 @@ nav.flexbox {
}
}
}
.error-title {
a {
text-decoration: underline;
}
}
.ap-place {
h3 {
display: inline;
font-weight: normal;
}
h3::after {
content: ': ';
}
}

View File

@ -1,74 +1,172 @@
import re
import typing
from markdown import markdown
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
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
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>{suffix}' # 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):
_, username, domain = mention.split("@")
actor = (
await db_session.execute(
select(models.Actor).where(
models.Actor.handle == mention,
models.Actor.is_deleted.is_(False),
if mention in actors:
continue
# XXX: the regex catches stuff like `@toto@example.com.`
if mention.endswith("."):
mention = mention[:-1]
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)
).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)
mentioned_actors.append(actor)
tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
actors[mention] = actor
except Exception:
logger.exception(f"Failed to prefetch {mention}")
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 +180,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

@ -1,4 +1,3 @@
import base64
from datetime import datetime
from datetime import timezone
from functools import lru_cache
@ -39,7 +38,7 @@ from app.utils.highlight import HIGHLIGHT_CSS
from app.utils.highlight import highlight
_templates = Jinja2Templates(
directory="app/templates",
directory=["data/templates", "app/templates"], # type: ignore # bad typing
trim_blocks=True,
lstrip_blocks=True,
)
@ -59,13 +58,8 @@ def _filter_domain(text: str) -> str:
def _media_proxy_url(url: str | None) -> str:
if not url:
return "/static/nopic.png"
if url.startswith(BASE_URL):
return url
encoded_url = base64.urlsafe_b64encode(url.encode()).decode()
return f"/proxy/media/{encoded_url}"
return BASE_URL + "/static/nopic.png"
return proxied_media_url(url)
def is_current_user_admin(request: Request) -> bool:
@ -91,6 +85,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 = {}
@ -135,6 +130,7 @@ async def render_template(
**template_args,
},
status_code=status_code,
headers=headers,
)
@ -291,6 +287,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 +320,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,
@ -380,7 +384,7 @@ def _html2text(content: str) -> str:
def _replace_emoji(u: str, _) -> str:
filename = "-".join(hex(ord(c))[2:] for c in u)
return config.EMOJI_TPL.format(filename=filename, raw=u)
return config.EMOJI_TPL.format(base_url=BASE_URL, filename=filename, raw=u)
def _emojify(text: str, is_local: bool) -> str:
@ -421,3 +425,5 @@ _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
_templates.env.globals["ICON_URL"] = config.CONFIG.icon_url

View File

@ -27,7 +27,7 @@
{{ utils.actor_action(inbox_object, "followed you") }}
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
{% elif inbox_object.ap_type == "Like" %}
{{ utils.actor_action(inbox_object, "liked one of your post", with_icon=True) }}
{{ utils.actor_action(inbox_object, "liked one of your posts", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% else %}
<p>

View File

@ -90,5 +90,5 @@
</p>
</form>
</div>
<script src="/static/new.js?v={{ JS_HASH }}"></script>
<script src="{{ BASE_URL }}/static/new.js?v={{ JS_HASH }}"></script>
{% endblock %}

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,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="{{ ICON_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

@ -1,12 +1,12 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block main_tag %} class="main-flex"{% endblock %}
{% block head %}
<title>{{ title }}</title>
{% endblock %}
{% block content %}
<div class="centered primary-color">
<h1>{{ title }}</h1>
<div class="centered primary-color box">
<h1 class="error-title">{{ title | safe }}</h1>
</div>
{% endblock %}

View File

@ -25,13 +25,21 @@
</div>
{%- macro header_link(url, text) -%}
{% set url_for = request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %}
{%- macro navbar_item_link(navbar_item) -%}
{% 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">
<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 +51,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

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

@ -4,22 +4,22 @@
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="/static/css/main.css?v={{ CSS_HASH }}">
<link rel="stylesheet" href="{{ BASE_URL }}/static/css/main.css?v={{ CSS_HASH }}">
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="icon" type="image/x-icon" href="{{ BASE_URL }}/static/favicon.ico">
<style>{{ highlight_css }}</style>
{% block head %}{% endblock %}
</head>
<body>
<div id="main">
<main>
<main{%- block main_tag %}{%- endblock %}>
{% if is_admin %}
<div id="admin">
{% macro admin_link(url, text) %}
{% set url_for = request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
<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">
@ -53,7 +53,7 @@
</div>
</footer>
{% if is_admin %}
<script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script>
{% endif %}
</body>
</html>

View File

@ -1,15 +1,18 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block main_tag %} class="main-flex"{% endblock %}
{% block content %}
<div class="centered">
{% if error %}
<p class="primary-color">Invalid password.</p>
{% endif %}
<form class="form" action="/admin/login" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect" value="{{ redirect }}">
<input type="password" placeholder="password" name="password" autofocus>
<input type="submit" value="login">
</form>
<div>
{% if error %}
<p class="primary-color">Invalid password.</p>
{% endif %}
<form class="form" action="{{ BASE_URL }}/admin/login" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect" value="{{ redirect }}">
<input type="password" placeholder="password" name="password" autofocus>
<input type="submit" value="login">
</form>
</div>
</div>
{% endblock %}

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,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

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

@ -1,168 +1,220 @@
{% macro embed_csrf_token() %}
{% block embed_csrf_token scoped %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endblock %}
{% endmacro %}
{% macro embed_redirect_url(permalink_id=None) %}
{% block embed_redirect_url scoped %}
<input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}">
{% endblock %}
{% endmacro %}
{% macro admin_block_button(actor) %}
{% block admin_block_button scoped %}
<form action="{{ request.url_for("admin_actions_block") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
<input type="submit" value="block">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_unblock_button(actor) %}
{% block admin_unblock_button scoped %}
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
<input type="submit" value="unblock">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_follow_button(actor) %}
{% block admin_follow_button scoped %}
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
<input type="submit" value="follow">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_accept_incoming_follow_button(notif) %}
{% block admin_accept_incoming_follow_button scoped %}
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="notification_id" value="{{ notif.id }}">
<input type="submit" value="accept follow">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_reject_incoming_follow_button(notif) %}
{% block admin_reject_incoming_follow_button scoped %}
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="notification_id" value="{{ notif.id }}">
<input type="submit" value="reject follow">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_like_button(ap_object_id, permalink_id) %}
{% block admin_like_button scoped %}
<form action="{{ request.url_for("admin_actions_like") }}" 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="like">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_bookmark_button(ap_object_id, permalink_id) %}
{% block admin_bookmark_button scoped %}
<form action="{{ request.url_for("admin_actions_bookmark") }}" 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="bookmark">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
{% block admin_unbookmark_button scoped %}
<form action="{{ request.url_for("admin_actions_unbookmark") }}" 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="unbookmark">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_pin_button(ap_object_id, permalink_id) %}
{% block admin_pin_button scoped %}
<form action="{{ request.url_for("admin_actions_pin") }}" 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="pin">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_unpin_button(ap_object_id, permalink_id) %}
{% block admin_unpin_button scoped %}
<form action="{{ request.url_for("admin_actions_unpin") }}" 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="unpin">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_delete_button(ap_object) %}
{% block admin_delete_button scoped %}
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }}
<input type="hidden" name="redirect_url" value="{% if request.url.path.endswith("/" + ap_object.public_id) or (request.url.path == "/admin/object" and request.query_params.ap_id.endswith("/" + ap_object.public_id)) %}{{ request.base_url}}{% else %}{{ request.url }}{% endif %}">
<input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
<input type="submit" value="delete">
</form>
{% 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_announce_button(ap_object_id, permalink_id=None) %}
{% block admin_announce_button scoped %}
<form action="{{ request.url_for("admin_actions_announce") }}" 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="share">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %}
{% block admin_undo_button scoped %}
<form action="{{ request.url_for("admin_actions_undo") }}" 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="{{ action }}">
</form>
{% endblock %}
{% endmacro %}
{% macro admin_reply_button(ap_object_id) %}
<form action="/admin/new" method="GET">
{% block admin_reply_button scoped %}
<form action="{{ BASE_URL }}/admin/new" method="GET">
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
<button type="submit">reply</button>
</form>
{% endblock %}
{% endmacro %}
{% macro admin_dm_button(actor_handle) %}
<form action="/admin/new" method="GET">
{% block admin_dm_button scoped %}
<form action="{{ BASE_URL }}/admin/new" method="GET">
<input type="hidden" name="with_content" value="{{ actor_handle }}">
<input type="hidden" name="with_visibility" value="DIRECT">
<button type="submit">direct message</button>
</form>
{% endblock %}
{% endmacro %}
{% macro admin_mention_button(actor_handle) %}
<form action="/admin/new" method="GET">
{% block admin_mention_button scoped %}
<form action="{{ BASE_URL }}/admin/new" method="GET">
<input type="hidden" name="with_content" value="{{ actor_handle }}">
<button type="submit">mention</button>
</form>
{% endblock %}
{% endmacro %}
{% macro admin_profile_button(ap_actor_id) %}
{% block admin_profile_button scoped %}
<form action="{{ url_for("admin_profile") }}" method="GET">
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
<button type="submit">profile</button>
</form>
{% endblock %}
{% endmacro %}
{% macro admin_expand_button(ap_object) %}
{% block admin_expand_button scoped %}
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
<form action="{{ url_for("admin_object") }}" method="GET">
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
<button type="submit">expand</button>
</form>
{% endblock %}
{% endmacro %}
{% macro display_box_filters(route) %}
{% block display_box_filters scoped %}
<nav class="flexbox box">
<ul>
<li>Filter by</li>
@ -179,13 +231,17 @@
{% endif %}
</ul>
</nav>
{% endblock %}
{% endmacro %}
{% macro display_tiny_actor_icon(actor) %}
{% block display_tiny_actor_icon scoped %}
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
{% endblock %}
{% endmacro %}
{% macro actor_action(inbox_object, text, with_icon=False) %}
{% block actor_action scoped %}
<div class="actor-action">
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
{% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}
@ -193,9 +249,11 @@
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
</div>
{% endblock %}
{% endmacro %}
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
{% block display_actor scoped %}
{% set metadata = actors_metadata.get(actor.ap_id) %}
{% if not embedded %}
@ -216,13 +274,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>
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% if not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
{% elif metadata.is_follow_request_sent %}
<li>follow request sent</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
{% 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 +301,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 +331,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>
@ -291,11 +364,13 @@
</div>
{% endif %}
{% endblock %}
{% endmacro %}
{% macro display_og_meta(object) %}
{% block display_og_meta scoped %}
{% if object.og_meta %}
{% for og_meta in object.og_meta %}
{% for og_meta in object.og_meta[:1] %}
<div class="activity-og-meta">
{% if og_meta.image %}
<div>
@ -311,22 +386,29 @@
</div>
{% endfor %}
{% endif %}
{% endblock %}
{% endmacro %}
{% macro display_attachments(object) %}
{% block display_attachments scoped %}
{% 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")) %}
@ -338,11 +420,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>
@ -350,12 +434,15 @@
{% else %}
</div>
{% endif %}
{% endif %}
{% endfor %}
{% endblock %}
{% endmacro %}
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
{% 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"] %}
{% 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 }}">
{% if is_article_mode %}
@ -369,15 +456,37 @@
{% 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" %}
{% if object.ap_type in ["Article", "Event"] %}
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
{% endif %}
{% if object.ap_type == "Event" %}
{% if object.ap_object.get("endTime") and object.ap_object.get("startTime") %}
<p>On {{ object.ap_object.startTime | parse_datetime | format_date }}
(ends {{ object.ap_object.endTime | parse_datetime | format_date }})</p>
{% endif %}
{% endif %}
{% if object.ap_object.get("location") %}
{% set loc = object.ap_object.get("location") %}
{% if loc.type == "Place" and loc.latitude and loc.longitude %}
<div class="ap-place">
<h3>Location</h3>
{% if loc.name %}{{ loc.name }}{% endif %}
<span class="h-geo">
<data class="p-latitude" value="{{ loc.latitude}}"></data>
<data class="p-longitude" value="{{ loc.longitude }}"></data>
<a href="https://www.openstreetmap.org/?mlat={{ loc.latitude }}&mlon={{ loc.longitude }}#map=16/{{loc.latitude}}/{{loc.longitude}}">{{loc.latitude}},{{loc.longitude}}</a>
</span>
</div>
{% endif %}
{% endif %}
{% if is_article_mode %}
<time class="dt-published muted" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at.strftime("%b %d, %Y") }}</time>
{% endif %}
@ -442,10 +551,11 @@
</form>
{% endif %}
{{ display_og_meta(object) }}
{% endif %}
{{ display_og_meta(object) }}
</div>
{% if object.summary %}
</div>
@ -460,6 +570,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>
@ -555,7 +675,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 %}
@ -573,6 +693,11 @@
{{ admin_expand_button(object) }}
</li>
{% endif %}
{% if object.is_from_inbox %}
<li>
{{ admin_force_delete_button(object.ap_id) }}
</li>
{% endif %}
</ul>
</nav>
{% endif %}
@ -635,4 +760,5 @@
</div>
{% endif %}
{% endblock %}
{% endmacro %}

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

@ -1,14 +1,18 @@
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
from app import ap_object
from app import config
from app.actor import LOCAL_ACTOR
@ -28,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")
@ -57,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,
@ -69,7 +81,12 @@ async def external_urls(
tags_hrefs.add(tag_href)
if tag.get("type") == "Mention":
if tag["href"] != LOCAL_ACTOR.ap_id:
mentioned_actor = await fetch_actor(db_session, tag["href"])
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:
@ -84,18 +101,22 @@ async def external_urls(
if not h:
continue
ph = urlparse(h)
mimetype, _ = mimetypes.guess_type(h)
if (
ph.scheme in {"http", "https"}
and ph.hostname != note_host
and is_url_valid(h)
and (
not mimetype
or mimetype.split("/")[0] not in ["image", "video", "audio"]
)
):
urls.add(h)
try:
ph = urlparse(h)
mimetype, _ = mimetypes.guess_type(h)
if (
ph.scheme in {"http", "https"}
and ph.hostname != note_host
and is_url_valid(h)
and (
not mimetype
or mimetype.split("/")[0] not in ["image", "video", "audio"]
)
):
urls.add(h)
except Exception:
logger.exception(f"Failed to check {h}")
continue
return urls - tags_hrefs
@ -116,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

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

@ -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://"):

1
data/templates/app Symbolic link
View File

@ -0,0 +1 @@
../../app/templates/

View File

@ -5,6 +5,7 @@ admin_password = "$2b$12$OwCyZM33uXQUVrChgER.h.qgFJ4fBp6tdFwArR3Lm1LV8NgMvIxVa"
name = "test"
summary = "<p>Hello</p>"
https = false
id = "http://localhost:8000"
icon_url = "https://localhost:8000/static/nopic.png"
secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
debug = true

View File

@ -1,6 +1,6 @@
# Developer's guide
This guide assume you have some knoweldge of [ActivityPub](https://activitypub.rocks/).
This guide assumes you have some knowledge of [ActivityPub](https://activitypub.rocks/).
[TOC]

View File

@ -11,7 +11,7 @@ For now, there's no image published on Docker Hub, this means you will have to b
Clone the repository, replace `you-domain.tld` by your own domain.
Note that if you want to serve static assets via your reverse proxy (like nginx), clone it in a place
where accessible by your reverse proxy user.
where it is accessible by your reverse proxy user.
```bash
git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld
@ -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

@ -29,17 +29,20 @@ You can tweak your profile by tweaking these items:
- `summary` (using Markdown)
- `icon_url`
Whenever one of these config items is updated, an `Update` activity will be sent to all know server to update your remote profile.
Whenever one of these config items is updated, an `Update` activity will be sent to all known servers to update your remote profile.
The server will need to be restarted for taking changes into account.
Before restarting the server, you can ensure you haven't made any mistakes by running the [configuration checking task](/user_guide.html#configuration-checking).
### Profile metadata
You can add metadata to your profile with the `metadata` config item.
Markdown is supported in the `value` field.
Be aware that most other softwares like Mastodon will limit the number of key/value to 4.
Be aware that most other software like Mastodon will limit the number of key/value to 4.
```toml
metadata = [
@ -68,9 +71,9 @@ hides_followers = true
The default value is `false`.
### Hiding following
### Hiding who you are following
If you wish to hide your following, add this config item to `profile.toml`:
If you wish to hide who you are following, add this config item to `profile.toml`:
```toml
hides_following = true
@ -80,10 +83,10 @@ The default value is `false`.
### Privacy replace
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
You can define domains to be rewritten to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
or [Nitter](https://nitter.net/about).
To do so, just add as these extra config items, this is a sample config that rewrite URLs for Twitter, Youtube, Reddit and Medium:
To do so, add these extra config items. This is a sample config that rewrite URLs for Twitter, Youtube, Reddit and Medium:
```toml
privacy_replace = [
@ -122,11 +125,25 @@ $primary-color: #e14eea;
$secondary-color: #32cd32;
```
See `app/scss/main.scss` to see what variables can be overidden.
See `app/scss/main.scss` to see what variables can be overridden.
#### Custom templates
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"
@ -134,7 +151,7 @@ code_highlighting_theme = "solarized-dark"
### Blocking servers
In addition to blocking "single actors" via the admin interface, you can also prevent any communications with whole servers.
In addition to blocking "single actors" via the admin interface, you can also prevent any communication with entire servers.
Add a `blocked_servers` config item into `profile.toml`.
@ -152,13 +169,13 @@ blocked_servers = [
Public notes will be visible on the homepage.
Only the last 20 followers/follows you be showing on the public website.
Only the last 20 followers/follows you have will be shown on the public website.
And only the last 20 interactions (likes/shares/webmentions) will be displayed, to keep things simple/clean.
## Admin section
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin`.
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin/login`.
The password is the one set during the initial configuration.
### Lookup
@ -222,7 +239,7 @@ Receiving a share will trigger a notification, increment the shares counter on t
Liking an object will notify the author.
Unlike sharing, liked object are not displayed on the homepage.
Unlike sharing, liked objects are not displayed on the homepage.
Most receiving servers will increment the number of likes.
@ -232,13 +249,13 @@ Receiving a like will trigger a notification, increment the likes counter on the
Bookmarks allow you to like objects without notifying the author.
It is basically a "private like", and allow you to easily access them later.
It is basically a "private like", and allows you to easily access them later.
It will also prevent objects to be pruned.
### Webmentions
Sending webmention to ping mentioned websites is done automatically once a public note is authored.
Sending webmentions to ping mentioned websites is done automatically once a public note is authored.
Receiving a webmention will trigger a notification, increment the webmentions counter on the object and the source page will be displayed on the object permalink.
@ -283,7 +300,7 @@ make account=username@domain.tld webfinger
Edit the config.
#### Edit the config
### Edit the config
And add a reference to your old/existing account in `profile.toml`:
@ -295,6 +312,61 @@ Restart the server, and you should be able to complete the move from your existi
## Tasks
### Configuration checking
You can confirm that your configuration file (`data/profile.toml`) is valid using the `check-config`
#### Python edition
```bash
poetry run inv check-config
```
#### Docker edition
```bash
make check-config
```
### Recompiling CSS files
You can ensure your custom theme is valid by recompiling the CSS manually using the `compile-scss` task.
#### Python edition
```bash
poetry run inv compile-scss
```
#### Docker edition
```bash
make compile-scss
```
### Password reset
If have lost your password, you can generate a new one using the `reset-password` task.
#### Python edition
```bash
# shutdown supervisord
poetry run inv reset-password
# edit data/profile.toml
# restart supervisord
```
#### Docker edition
```bash
docker compose stop
make reset-password
# edit data/profile.toml
docker compose up -d
```
### Pruning old data
You should prune old data from time to time to free disk space.
@ -388,3 +460,11 @@ poetry run inv self-destruct
# For a Docker install
make self-destruct
```
## Troubleshooting
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

874
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"
@ -44,6 +42,9 @@ invoke = "^1.7.1"
boussole = "^2.0.0"
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

@ -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
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")
@ -327,3 +333,23 @@ def reset_password(ctx):
print()
print("Update data/profile.toml with:")
print(f'admin_password = "{new_password}"')
@task
def check_config(ctx):
# type: (Context) -> None
import sys
import traceback
from loguru import logger
logger.disable("app")
try:
from app import config # noqa: F401
except Exception as exc:
print("Config error, please fix data/profile.toml:\n")
print("".join(traceback.format_exception(exc)))
sys.exit(1)
else:
print("Config is OK")

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

@ -77,23 +77,29 @@ def test_send_delete__reverts_side_effects(
# with a note that has existing replies
inbox_note = setup_inbox_note(actor)
inbox_note.replies_count = 1
# with a bogus counter
inbox_note.replies_count = 5
db.commit()
# and a local reply
outbox_note = setup_outbox_note(
# and 2 local replies
setup_outbox_note(
to=[ap.AS_PUBLIC],
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
in_reply_to=inbox_note.ap_id,
)
outbox_note2 = setup_outbox_note(
to=[ap.AS_PUBLIC],
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
in_reply_to=inbox_note.ap_id,
)
inbox_note.replies_count = inbox_note.replies_count + 1
db.commit()
# When deleting one of the replies
response = client.post(
"/admin/actions/delete",
data={
"redirect_url": "http://testserver/",
"ap_object_id": outbox_note.ap_id,
"ap_object_id": outbox_note2.ap_id,
"csrf_token": generate_csrf_token(),
},
cookies=generate_admin_session_cookies(),
@ -108,14 +114,14 @@ def test_send_delete__reverts_side_effects(
select(models.OutboxObject).where(models.OutboxObject.ap_type == "Delete")
).scalar_one()
assert outbox_object.ap_type == "Delete"
assert outbox_object.activity_object_ap_id == outbox_note.ap_id
assert outbox_object.activity_object_ap_id == outbox_note2.ap_id
# And an outgoing activity was queued
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == ra.inbox_url
# And the replies count of the replied object was decremented
# And the replies count of the replied object was refreshed correctly
db.refresh(inbox_note)
assert inbox_note.replies_count == 1
@ -173,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"
@ -221,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