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

144 Commits

Author SHA1 Message Date
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
a6321f52d8 Add task to reset password 2022-09-15 22:47:36 +02:00
4e1e4d0ea8 Tweak actor update 2022-09-15 22:19:01 +02:00
110f7df962 Fix GIF upload handling 2022-09-14 08:38:54 +02:00
4c86cd4be3 Always show followers/following page when admin 2022-09-13 22:33:20 +02:00
df06defbef Tweak docs 2022-09-13 21:23:32 +02:00
b2f268682c New config item to hide followers/following 2022-09-13 21:03:35 +02:00
567595bb4b Tweak inbox processing 2022-09-13 21:03:11 +02:00
91b8bb26b7 Bugfixes 2022-09-13 21:02:47 +02:00
bd4d5a004a Improve Announce handling 2022-09-13 07:59:35 +02:00
04da8725ed Improve fetch 2022-09-12 08:04:16 +02:00
0c7a19749d Tweak docs about moving 2022-09-11 19:37:35 +02:00
2a37034775 Fix move task 2022-09-11 19:26:41 +02:00
475e525468 Fix typos in the docs 2022-09-11 10:53:25 +02:00
c1231245a4 Complete self-destruct support 2022-09-11 10:51:08 +02:00
5eb6157c1b More tests for note creation 2022-09-09 22:14:09 +02:00
0f20a1d12f Allow to post note with attachments and a CW 2022-09-08 22:20:16 +02:00
356aace9bc Add move task 2022-09-08 20:57:52 +02:00
a701d3b06e Improve move support 2022-09-08 20:00:02 +02:00
333fa5dc40 Add new notification type for Move activities 2022-09-07 22:21:12 +02:00
032632c4dc Fix template 2022-09-07 21:54:56 +02:00
3641aa0adc Improve movedTo support 2022-09-07 21:29:09 +02:00
eba868e8e5 Fix admin delete in the UI 2022-09-07 19:45:34 +02:00
1bfea16eed Update deps 2022-09-06 21:00:39 +02:00
70120647c2 Tweak Move and outbox prefetch 2022-09-05 21:41:22 +02:00
e454e8fe84 Tweak admin login logic 2022-09-04 09:24:58 +02:00
f7671f0585 Process EXIF orientation for uploaded files 2022-09-03 10:15:37 +02:00
16da166ee1 Tweak queries in tests 2022-09-02 23:47:23 +02:00
d5c27287af Fix admin in reply to link 2022-09-01 21:00:14 +02:00
5f20eab3f1 More work towards support moving/deleting instance 2022-09-01 20:42:20 +02:00
b03daf1274 Fix in reply to link 2022-09-01 20:32:32 +02:00
191ce39d14 Add missing autorestart for supervisord config 2022-09-01 12:35:15 +02:00
6e3066bd9b Fix support for multi-codepoints emoji 2022-09-01 12:23:23 +02:00
0175f21273 Fix mentionify 2022-08-31 19:44:40 +02:00
36d356c97a Update user guide 2022-08-31 19:31:17 +02:00
6384dbcd93 Re-add support for custom emoji 2022-08-31 19:16:03 +02:00
c740813b57 Ensure pinned posts appear on front page before others 2022-08-31 08:19:47 +02:00
0ef2f1f89d Remove surrounding whitespace before processing query
Ran into this issue twice quite by accident with fat-fingering copy/paste on
my phone. If there is any whitespace in front of or trailing after the
lookup query, it returns an "Unexpected error". Stripping the string is the
quick and dirty way to clean it.

I hate modifying the same function argument name in place like that, but it
is valid Python. If you want me to assign it to a separate variable and
replace all the references of "query", let me know.

Thanks!
2022-08-31 08:16:32 +02:00
6d933863d2 Fix outbox delete side effects 2022-08-30 20:05:10 +02:00
8fe6cc9b9d Fix the delete button 2022-08-30 19:09:51 +02:00
4cb499e44d Fix form for new objects 2022-08-30 08:51:02 +02:00
95745374cd 'followers-only' posts are not necessarily deleted, but may not be viewable to the signed-in actor 2022-08-30 08:21:11 +02:00
db8f0cb141 Harden the CSP a bit for values that don't inherit default-src. Set Permissions-Policy. Remove TODO 2022-08-30 08:21:11 +02:00
05f840ecc8 Small typos in docs/install.md 2022-08-30 08:21:11 +02:00
ebdba62a06 No more inline CSS 2022-08-29 21:42:54 +02:00
2fb85e138e Remove inlined JS 2022-08-29 20:11:31 +02:00
b843b29975 Another Makefile fix 2022-08-29 19:44:02 +02:00
4f8bb00d86 Fix Makefile 2022-08-29 19:40:11 +02:00
a02c8cf0bb Fix NGINX setup instructions 2022-08-29 19:28:54 +02:00
ee5265f4dd Small tweaks/typos 2022-08-29 09:09:28 +02:00
727eaa9ee1 Tweak docs 2022-08-28 22:21:22 +02:00
39ca3ed7e2 Revert CSS changes 2022-08-28 19:53:11 +02:00
c67db749dc Tweak CSS 2022-08-28 19:35:51 +02:00
fc0445fcec Add missing template 2022-08-28 19:32:05 +02:00
c275d7064e Tweak supervisord config 2022-08-28 19:08:44 +02:00
1a7e9e4565 Fix OG metadata processing 2022-08-28 19:05:06 +02:00
87f035d298 HTML error page 2022-08-28 17:36:58 +02:00
651682829a Tweak worker shutdown 2022-08-28 12:05:44 +02:00
3f85c851be More share dedup tweak 2022-08-28 11:39:44 +02:00
333e367a5b Improve debug mode 2022-08-28 11:24:46 +02:00
09cdef118c Fix share dedup 2022-08-27 17:28:53 +02:00
00004a3239 Debug share dedup 2022-08-27 11:21:42 +02:00
7283ba134c Tweak templates 2022-08-27 09:45:14 +02:00
c8f3bed065 Tweak inbox display 2022-08-27 09:28:37 +02:00
93e0d073a0 Tweak lookup 2022-08-27 09:24:21 +02:00
e959085d38 Improve shares on homepage 2022-08-27 09:14:16 +02:00
aaf8b811dc Fix mention processing bug 2022-08-27 09:10:14 +02:00
4e445a7207 Prevent replay attacks with TLS1.3 0-RTT 2022-08-26 23:35:58 +02:00
40c4a4413d Tweak media proxy error 2022-08-26 22:04:38 +02:00
dd4773fc27 Fix share dedup 2022-08-26 21:23:16 +02:00
0db6b0e2ba Tweak deps 2022-08-26 21:07:46 +02:00
88cb82c9bb Improve static assets caching 2022-08-26 20:26:41 +02:00
372851caaf Tweak sample nginx conf 2022-08-26 20:25:55 +02:00
e16dbf03e7 Add NGINX tips in the doc 2022-08-26 20:18:59 +02:00
7d4b7f6756 Improve Announce dedup 2022-08-26 19:09:40 +02:00
edf9e28ed1 Tweak cache size 2022-08-26 18:58:21 +02:00
eb9a6024a8 Tweak supervisord config 2022-08-26 18:43:56 +02:00
84203fc66e More webp support 2022-08-26 09:28:00 +02:00
55d82c5843 Also save outbox attachment thumbnails as webp 2022-08-26 09:05:55 +02:00
53a31ae562 Webp support 2022-08-26 08:48:14 +02:00
d21ce3313d Fix notif page 2022-08-26 08:18:51 +02:00
93ee6c435d Tweak notifications 2022-08-26 08:15:49 +02:00
bec40cc050 Pagination for the admin profile page 2022-08-26 08:10:46 +02:00
505abd7da8 Only display tiny actor icon for shares 2022-08-26 07:57:10 +02:00
63073279e1 More actor icons 2022-08-26 07:43:39 +02:00
365e6cc534 Mention Docker disk usage in the install guide 2022-08-25 08:57:30 +02:00
e753fee632 Tweak read more link on notifications page 2022-08-25 08:51:46 +02:00
30cfd6260b Pagination for the notifications page 2022-08-25 08:45:07 +02:00
d43bf54609 Custom footer support 2022-08-24 21:18:30 +02:00
953a6c3b91 Fix empty tag page 2022-08-24 20:52:15 +02:00
ae28cf2294 Improve summary 2022-08-24 20:12:10 +02:00
3b767eae11 Improve version handling 2022-08-24 09:02:20 +02:00
6475714369 Update user guide 2022-08-24 07:52:46 +02:00
0811609e3e Tweak user guide 2022-08-23 19:40:45 +02:00
adcaf95ab2 Tweak supervisord conf for YNH 2022-08-22 19:32:39 +02:00
ce15d2b0c3 HTML error for failed admin login 2022-08-22 18:50:20 +02:00
e047a87620 Tweak supervisord conf for YNH 2022-08-22 18:49:19 +02:00
e55dc652ee Tweak inbox activity processing 2022-08-21 21:06:33 +02:00
65 changed files with 3334 additions and 811 deletions

View File

@ -10,13 +10,18 @@ ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
FROM python-base as builder-base FROM python-base as builder-base
RUN apt-get update 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 - RUN curl -sSL https://install.python-poetry.org | python3 -
WORKDIR $PYSETUP_PATH WORKDIR $PYSETUP_PATH
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml ./
RUN poetry install --no-dev RUN poetry install --only main
FROM python-base as production 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 \ RUN groupadd --gid 1000 microblogpub \
&& useradd --uid 1000 --gid microblogpub --shell /bin/bash microblogpub && useradd --uid 1000 --gid microblogpub --shell /bin/bash microblogpub
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH

View File

@ -9,12 +9,35 @@ build:
config: config:
# Run and remove instantly # Run and remove instantly
-docker run --rm -it --volume `pwd`/data:/app/data microblogpub/microblogpub inv configuration-wizard -docker run --rm -it --volume `pwd`/data:/app/data microblogpub/microblogpub inv configuration-wizard
-docker run --env MICROBLOGPUB_CONFIG_FILE=tests.toml --rm -it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv configuration-wizard
.PHONY: update .PHONY: update
update: update:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update -docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
.PHONY: prune-old-data .PHONY: prune-old-data
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 --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)
.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)
.PHONY: self-destruct
self-destruct:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
.PHONY: reset-password
reset-password:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv reset-password
.PHONY: check-config
check-config:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv check-config
.PHONY: compile-scss
compile-scss:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv compile-scss

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 - Author notes in Markdown, with code highlighting support
- Dedicated section for articles/blog posts (enabled when the first article is posted) - Dedicated section for articles/blog posts (enabled when the first article is posted)
- Lightweight - Lightweight
- Uses SQLite, and no external dependencies except Python 3.10+ - Uses SQLite, and Python 3.10+
- Can be deployed on small VPS - Can be deployed on small VPS
- Privacy-aware - Privacy-aware
- EXIF metadata (like GPS location) are stripped before storage - EXIF metadata (like GPS location) are stripped before storage

View File

@ -6,12 +6,15 @@ from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
from markdown import markdown
from app import config from app import config
from app.config import ALSO_KNOWN_AS
from app.config import AP_CONTENT_TYPE # noqa: F401 from app.config import AP_CONTENT_TYPE # noqa: F401
from app.config import MOVED_TO
from app.httpsig import auth from app.httpsig import auth
from app.key import get_pubkey_as_pem 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 from app.utils.url import check_url
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,6 +35,7 @@ AS_EXTENDED_CTX = [
"sensitive": "as:sensitive", "sensitive": "as:sensitive",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"}, "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
# toot # toot
"toot": "http://joinmastodon.org/ns#", "toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"}, "featured": {"@id": "toot:featured", "@type": "@id"},
@ -49,11 +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 pass
class ObjectNotFoundError(Exception): class ObjectNotFoundError(FetchError):
pass
class ObjectUnavailableError(FetchError):
pass pass
@ -81,6 +100,21 @@ class VisibilityEnum(str, enum.Enum):
}[key] }[key]
_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 = { ME = {
"@context": AS_EXTENDED_CTX, "@context": AS_EXTENDED_CTX,
"type": "Person", "type": "Person",
@ -92,7 +126,7 @@ ME = {
"outbox": config.BASE_URL + "/outbox", "outbox": config.BASE_URL + "/outbox",
"preferredUsername": config.USERNAME, "preferredUsername": config.USERNAME,
"name": config.CONFIG.name, "name": config.CONFIG.name,
"summary": config.CONFIG.summary, "summary": _LOCAL_ACTOR_SUMMARY,
"endpoints": { "endpoints": {
# For compat with servers expecting a sharedInbox... # For compat with servers expecting a sharedInbox...
"sharedInbox": config.BASE_URL "sharedInbox": config.BASE_URL
@ -100,16 +134,7 @@ ME = {
}, },
"url": config.ID + "/", # XXX: the path is important for Mastodon compat "url": config.ID + "/", # XXX: the path is important for Mastodon compat
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers, "manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
"attachment": [ "attachment": _LOCAL_ACTOR_METADATA,
{
"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 [],
"icon": { "icon": {
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0], "mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
"type": "Image", "type": "Image",
@ -120,8 +145,15 @@ ME = {
"owner": config.ID, "owner": config.ID,
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH), "publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
}, },
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
} }
if ALSO_KNOWN_AS:
ME["alsoKnownAs"] = [ALSO_KNOWN_AS]
if MOVED_TO:
ME["movedTo"] = MOVED_TO
class NotAnObjectError(Exception): class NotAnObjectError(Exception):
def __init__(self, url: str, resp: httpx.Response | None = None) -> None: def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
@ -153,11 +185,17 @@ async def fetch(
# Special handling for deleted object # Special handling for deleted object
if resp.status_code == 410: if resp.status_code == 410:
raise ObjectIsGoneError(f"{url} is gone") raise ObjectIsGoneError(url, resp)
elif resp.status_code in [401, 403]:
raise ObjectUnavailableError(url, resp)
elif resp.status_code == 404: elif resp.status_code == 404:
raise ObjectNotFoundError(f"{url} not found") raise ObjectNotFoundError(url, resp)
try:
resp.raise_for_status() resp.raise_for_status()
except httpx.HTTPError as http_error:
raise FetchError(url, resp) from http_error
try: try:
return resp.json() return resp.json()
except json.JSONDecodeError: except json.JSONDecodeError:

View File

@ -1,16 +1,20 @@
import hashlib import hashlib
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from functools import cached_property from functools import cached_property
from typing import Union from typing import Union
from urllib.parse import urlparse from urllib.parse import urlparse
from loguru import logger
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app import activitypub as ap from app import activitypub as ap
from app import media from app import media
from app.database import AsyncSession from app.database import AsyncSession
from app.utils.datetime import as_utc
from app.utils.datetime import now
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from app.models import Actor as ActorModel from app.models import Actor as ActorModel
@ -108,7 +112,7 @@ class Actor:
@property @property
def tags(self) -> list[ap.RawObject]: def tags(self) -> list[ap.RawObject]:
return self.ap_actor.get("tag", []) return ap.as_list(self.ap_actor.get("tag", []))
@property @property
def followers_collection_id(self) -> str | None: def followers_collection_id(self) -> str | None:
@ -118,6 +122,10 @@ class Actor:
def attachments(self) -> list[ap.RawObject]: def attachments(self) -> list[ap.RawObject]:
return ap.as_list(self.ap_actor.get("attachment", [])) return ap.as_list(self.ap_actor.get("attachment", []))
@cached_property
def moved_to(self) -> str | None:
return self.ap_actor.get("movedTo")
@cached_property @cached_property
def server(self) -> str: def server(self) -> str:
return urlparse(self.ap_id).hostname # type: ignore return urlparse(self.ap_id).hostname # type: ignore
@ -154,9 +162,9 @@ async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "Actor
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}") raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
actor = models.Actor( actor = models.Actor(
ap_id=ap_actor["id"], ap_id=ap.get_id(ap_actor["id"]),
ap_actor=ap_actor, ap_actor=ap_actor,
ap_type=ap_actor["type"], ap_type=ap.as_list(ap_actor["type"])[0],
handle=_handle(ap_actor), handle=_handle(ap_actor),
) )
db_session.add(actor) db_session.add(actor)
@ -184,13 +192,65 @@ async def fetch_actor(
if existing_actor: if existing_actor:
if existing_actor.is_deleted: if existing_actor.is_deleted:
raise ap.ObjectNotFoundError(f"{actor_id} was deleted") raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
if now() - as_utc(existing_actor.updated_at) > timedelta(hours=24):
logger.info(
f"Refreshing {actor_id=} last updated {existing_actor.updated_at}"
)
try:
ap_actor = await ap.fetch(actor_id)
await update_actor_if_needed(
db_session,
existing_actor,
RemoteActor(ap_actor),
)
return existing_actor
except Exception:
logger.exception(f"Failed to refresh {actor_id}")
# If we fail to refresh the actor, return the cached one
return existing_actor return existing_actor
else: else:
return existing_actor
if save_if_not_found: if save_if_not_found:
ap_actor = await ap.fetch(actor_id) 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:
# 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) return await save_actor(db_session, ap_actor)
else: else:
raise ap.ObjectNotFoundError raise ap.ObjectNotFoundError(actor_id)
async def update_actor_if_needed(
db_session: AsyncSession,
actor_in_db: "ActorModel",
ra: RemoteActor,
) -> None:
# Check if we actually need to udpte the actor in DB
if _actor_hash(ra) != _actor_hash(actor_in_db):
actor_in_db.ap_actor = ra.ap_actor
actor_in_db.handle = ra.handle
actor_in_db.ap_type = ra.ap_type
actor_in_db.updated_at = now()
await db_session.flush()
@dataclass @dataclass
@ -199,8 +259,11 @@ class ActorMetadata:
is_following: bool is_following: bool
is_follower: bool is_follower: bool
is_follow_request_sent: bool is_follow_request_sent: bool
is_follow_request_rejected: bool
outbox_follow_ap_id: str | None outbox_follow_ap_id: str | None
inbox_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] ActorsMetadata = dict[str, ActorMetadata]
@ -243,17 +306,57 @@ 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 = {} idx: ActorsMetadata = {}
for actor in actors: for actor in actors:
if not actor.ap_id: if not actor.ap_id:
raise ValueError("Should never happen") raise ValueError("Should never happen")
moved_to = None
if actor.moved_to:
try:
moved_to = await fetch_actor(
db_session,
actor.moved_to,
save_if_not_found=False,
)
except ap.ObjectNotFoundError:
pass
except Exception:
logger.exception(f"Failed to fetch {actor.moved_to=}")
idx[actor.ap_id] = ActorMetadata( idx[actor.ap_id] = ActorMetadata(
ap_actor_id=actor.ap_id, ap_actor_id=actor.ap_id,
is_following=actor.ap_id in following, is_following=actor.ap_id in following,
is_follower=actor.ap_id in followers, is_follower=actor.ap_id in followers,
is_follow_request_sent=actor.ap_id in sent_follow_requests, 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), outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
inbox_follow_ap_id=followers.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 return idx
@ -289,4 +392,7 @@ def _actor_hash(actor: Actor) -> bytes:
h.update(actor.public_key_id.encode()) h.update(actor.public_key_id.encode())
h.update(actor.public_key_as_pem.encode()) h.update(actor.public_key_as_pem.encode())
if actor.moved_to:
h.update(actor.moved_to.encode())
return h.digest() return h.digest()

View File

@ -34,18 +34,28 @@ from app.config import verify_password
from app.database import AsyncSession from app.database import AsyncSession
from app.database import get_db_session from app.database import get_db_session
from app.lookup import lookup from app.lookup import lookup
from app.templates import is_current_user_admin
from app.uploads import save_upload from app.uploads import save_upload
from app.utils import pagination from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME from app.utils.emoji import EMOJIS_BY_NAME
def user_session_or_redirect( async def user_session_or_redirect(
request: Request, request: Request,
session: str | None = Cookie(default=None), session: str | None = Cookie(default=None),
) -> 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( _RedirectToLoginPage = HTTPException(
status_code=302, 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: if not session:
@ -68,16 +78,6 @@ router = APIRouter(
unauthenticated_router = APIRouter() unauthenticated_router = APIRouter()
@router.get("/")
async def admin_index(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
return await templates.render_template(
db_session, request, "index.html", {"request": request}
)
@router.get("/lookup") @router.get("/lookup")
async def get_lookup( async def get_lookup(
request: Request, request: Request,
@ -94,6 +94,8 @@ async def get_lookup(
error = ap.FetchErrorTypeEnum.TIMEOUT error = ap.FetchErrorTypeEnum.TIMEOUT
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError): except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
error = ap.FetchErrorTypeEnum.NOT_FOUND error = ap.FetchErrorTypeEnum.NOT_FOUND
except (ap.ObjectUnavailableError):
error = ap.FetchErrorTypeEnum.UNAUHTORIZED
except Exception: except Exception:
logger.exception(f"Failed to lookup {query}") logger.exception(f"Failed to lookup {query}")
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR error = ap.FetchErrorTypeEnum.INTERNAL_ERROR
@ -122,7 +124,9 @@ async def get_lookup(
) )
if requested_object: if requested_object:
return RedirectResponse( return RedirectResponse(
request.url_for("admin_object") + f"?ap_id={ap_object.ap_id}", request.url_for("admin_object")
+ f"?ap_id={ap_object.ap_id}#"
+ requested_object.permalink_id,
status_code=302, status_code=302,
) )
@ -211,6 +215,7 @@ async def admin_bookmarks(
request: Request, request: Request,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse: ) -> templates.TemplateResponse:
# TODO: support pagination
stream = ( stream = (
( (
await db_session.scalars( await db_session.scalars(
@ -667,15 +672,30 @@ async def admin_outbox(
@router.get("/notifications") @router.get("/notifications")
async def get_notifications( async def get_notifications(
request: Request, db_session: AsyncSession = Depends(get_db_session) request: Request,
db_session: AsyncSession = Depends(get_db_session),
cursor: str | None = None,
) -> templates.TemplateResponse: ) -> templates.TemplateResponse:
where = []
if cursor:
decoded_cursor = pagination.decode_cursor(cursor)
where.append(models.Notification.created_at < decoded_cursor)
page_size = 20
remaining_count = await db_session.scalar(
select(func.count(models.Notification.id)).where(*where)
)
notifications = ( notifications = (
( (
await db_session.scalars( await db_session.scalars(
select(models.Notification) select(models.Notification)
.where(*where)
.options( .options(
joinedload(models.Notification.actor), joinedload(models.Notification.actor),
joinedload(models.Notification.inbox_object), joinedload(models.Notification.inbox_object).options(
joinedload(models.InboxObject.actor)
),
joinedload(models.Notification.outbox_object).options( joinedload(models.Notification.outbox_object).options(
joinedload( joinedload(
models.OutboxObject.outbox_object_attachments models.OutboxObject.outbox_object_attachments
@ -684,6 +704,7 @@ async def get_notifications(
joinedload(models.Notification.webmention), joinedload(models.Notification.webmention),
) )
.order_by(models.Notification.created_at.desc()) .order_by(models.Notification.created_at.desc())
.limit(page_size)
) )
) )
.unique() .unique()
@ -697,6 +718,21 @@ async def get_notifications(
notif.is_new = False notif.is_new = False
await db_session.commit() await db_session.commit()
more_unread_count = 0
next_cursor = None
if notifications and remaining_count > page_size:
decoded_next_cursor = notifications[-1].created_at
next_cursor = pagination.encode_cursor(decoded_next_cursor)
# If on the "see more" page there's more unread notification, we want
# to display it next to the link
more_unread_count = await db_session.scalar(
select(func.count(models.Notification.id)).where(
models.Notification.is_new.is_(True),
models.Notification.created_at < decoded_next_cursor,
)
)
return await templates.render_template( return await templates.render_template(
db_session, db_session,
request, request,
@ -704,6 +740,8 @@ async def get_notifications(
{ {
"notifications": notifications, "notifications": notifications,
"actors_metadata": actors_metadata, "actors_metadata": actors_metadata,
"next_cursor": next_cursor,
"more_unread_count": more_unread_count,
}, },
) )
@ -715,7 +753,7 @@ async def admin_object(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse: ) -> templates.TemplateResponse:
requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id) requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id)
if not requested_object: if not requested_object or requested_object.is_deleted:
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
replies_tree = await boxes.get_replies_tree( replies_tree = await boxes.get_replies_tree(
@ -736,8 +774,10 @@ async def admin_object(
async def admin_profile( async def admin_profile(
request: Request, request: Request,
actor_id: str, actor_id: str,
cursor: str | None = None,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse: ) -> templates.TemplateResponse:
# TODO: show featured/pinned
actor = ( actor = (
await db_session.execute( await db_session.execute(
select(models.Actor).where(models.Actor.ap_id == actor_id) select(models.Actor).where(models.Actor.ap_id == actor_id)
@ -748,17 +788,27 @@ async def admin_profile(
actors_metadata = await get_actors_metadata(db_session, [actor]) actors_metadata = await get_actors_metadata(db_session, [actor])
inbox_objects = ( where = [
(
await db_session.scalars(
select(models.InboxObject)
.where(
models.InboxObject.is_deleted.is_(False), models.InboxObject.is_deleted.is_(False),
models.InboxObject.actor_id == actor.id, models.InboxObject.actor_id == actor.id,
models.InboxObject.ap_type.in_( models.InboxObject.ap_type.in_(
["Note", "Article", "Video", "Page", "Announce"] ["Note", "Article", "Video", "Page", "Announce"]
), ),
]
if cursor:
decoded_cursor = pagination.decode_cursor(cursor)
where.append(models.InboxObject.ap_published_at < decoded_cursor)
page_size = 20
remaining_count = await db_session.scalar(
select(func.count(models.InboxObject.id)).where(*where)
) )
inbox_objects = (
(
await db_session.scalars(
select(models.InboxObject)
.where(*where)
.options( .options(
joinedload(models.InboxObject.relates_to_inbox_object).options( joinedload(models.InboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor) joinedload(models.InboxObject.actor)
@ -771,12 +821,19 @@ async def admin_profile(
joinedload(models.InboxObject.actor), joinedload(models.InboxObject.actor),
) )
.order_by(models.InboxObject.ap_published_at.desc()) .order_by(models.InboxObject.ap_published_at.desc())
.limit(page_size)
) )
) )
.unique() .unique()
.all() .all()
) )
next_cursor = (
pagination.encode_cursor(inbox_objects[-1].created_at)
if inbox_objects and remaining_count > page_size
else None
)
return await templates.render_template( return await templates.render_template(
db_session, db_session,
request, request,
@ -785,6 +842,7 @@ async def admin_profile(
"actors_metadata": actors_metadata, "actors_metadata": actors_metadata,
"actor": actor, "actor": actor,
"inbox_objects": inbox_objects, "inbox_objects": inbox_objects,
"next_cursor": next_cursor,
}, },
) )
@ -974,7 +1032,7 @@ async def admin_actions_unpin(
async def admin_actions_new( async def admin_actions_new(
request: Request, request: Request,
files: list[UploadFile] = [], files: list[UploadFile] = [],
content: str = Form(), content: str | None = Form(None),
redirect_url: str = Form(), redirect_url: str = Form(),
in_reply_to: str | None = Form(None), in_reply_to: str | None = Form(None),
content_warning: str | None = Form(None), content_warning: str | None = Form(None),
@ -985,6 +1043,19 @@ async def admin_actions_new(
csrf_check: None = Depends(verify_csrf_token), csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse: ) -> RedirectResponse:
if not content and not content_warning:
raise HTTPException(status_code=422, detail="Error: object must have a content")
# Do like Mastodon, if there's only a CW with no content and some attachments,
# swap the CW and the content
if not content and content_warning and len(files) >= 1:
content = content_warning
is_sensitive = True
content_warning = None
if not content:
raise HTTPException(status_code=422, detail="Error: objec must have a content")
# XXX: for some reason, no files restuls in an empty single file # XXX: for some reason, no files restuls in an empty single file
uploads = [] uploads = []
raw_form_data = await request.form() raw_form_data = await request.form()
@ -1054,7 +1125,10 @@ async def admin_actions_vote(
async def login( async def login(
request: Request, request: Request,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse: ) -> templates.TemplateResponse | RedirectResponse:
if is_current_user_admin(request):
return RedirectResponse(request.url_for("admin_stream"), status_code=302)
return await templates.render_template( return await templates.render_template(
db_session, db_session,
request, request,
@ -1072,11 +1146,25 @@ async def login_validation(
password: str = Form(), password: str = Form(),
redirect: str | None = Form(None), redirect: str | None = Form(None),
csrf_check: None = Depends(verify_csrf_token), csrf_check: None = Depends(verify_csrf_token),
) -> RedirectResponse: db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse | templates.TemplateResponse:
if not verify_password(password): if not verify_password(password):
raise HTTPException(status_code=401) logger.warning("Invalid password")
return await templates.render_template(
db_session,
request,
"login.html",
{
"error": "Invalid password",
"csrf_token": generate_csrf_token(),
"redirect": request.query_params.get("redirect", ""),
},
status_code=403,
)
resp = RedirectResponse(redirect or "/admin/stream", status_code=302) resp = RedirectResponse(
redirect or request.url_for("admin_stream"), status_code=302
)
resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501 resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501
return resp return resp

View File

@ -1,11 +1,12 @@
import hashlib import hashlib
import mimetypes
from datetime import datetime from datetime import datetime
from functools import cached_property from functools import cached_property
from typing import Any from typing import Any
import pydantic import pydantic
from bs4 import BeautifulSoup # type: ignore from bs4 import BeautifulSoup # type: ignore
from markdown import markdown from mistletoe import markdown # type: ignore
from app import activitypub as ap from app import activitypub as ap
from app.actor import LOCAL_ACTOR from app.actor import LOCAL_ACTOR
@ -155,7 +156,7 @@ class Object:
@cached_property @cached_property
def url(self) -> str | None: def url(self) -> str | None:
obj_url = self.ap_object.get("url") obj_url = self.ap_object.get("url")
if isinstance(obj_url, str): if isinstance(obj_url, str) and obj_url:
return obj_url return obj_url
elif obj_url: elif obj_url:
for u in ap.as_list(obj_url): for u in ap.as_list(obj_url):
@ -175,7 +176,7 @@ class Object:
# PeerTube returns the content as markdown # PeerTube returns the content as markdown
if self.ap_object.get("mediaType") == "text/markdown": if self.ap_object.get("mediaType") == "text/markdown":
content = markdown(content, extensions=["mdx_linkify"]) content = markdown(content)
return content return content
@ -208,6 +209,13 @@ class Object:
def in_reply_to(self) -> str | None: def in_reply_to(self) -> str | None:
return self.ap_object.get("inReplyTo") return self.ap_object.get("inReplyTo")
@property
def is_in_reply_to_from_inbox(self) -> bool | None:
if not self.in_reply_to:
return None
return not self.in_reply_to.startswith(LOCAL_ACTOR.ap_id)
@property @property
def has_ld_signature(self) -> bool: def has_ld_signature(self) -> bool:
return bool(self.ap_object.get("signature")) return bool(self.ap_object.get("signature"))
@ -269,6 +277,17 @@ class Attachment(BaseModel):
proxied_url: str | None = None proxied_url: str | None = None
resized_url: str | None = None resized_url: str | None = None
@property
def mimetype(self) -> str:
mimetype = self.media_type
if not mimetype:
mimetype, _ = mimetypes.guess_type(self.url)
if not mimetype:
return "unknown"
return mimetype.split("/")[-1]
class RemoteObject(Object): class RemoteObject(Object):
def __init__(self, raw_object: ap.RawObject, actor: Actor): def __init__(self, raw_object: ap.RawObject, actor: Actor):

View File

@ -24,13 +24,16 @@ from app.actor import Actor
from app.actor import RemoteActor from app.actor import RemoteActor
from app.actor import fetch_actor from app.actor import fetch_actor
from app.actor import save_actor from app.actor import save_actor
from app.actor import update_actor_if_needed
from app.ap_object import RemoteObject from app.ap_object import RemoteObject
from app.config import BASE_URL from app.config import BASE_URL
from app.config import BLOCKED_SERVERS from app.config import BLOCKED_SERVERS
from app.config import ID from app.config import ID
from app.config import MANUALLY_APPROVES_FOLLOWERS from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.config import set_moved_to
from app.database import AsyncSession from app.database import AsyncSession
from app.outgoing_activities import new_outgoing_activity from app.outgoing_activities import new_outgoing_activity
from app.source import dedup_tags
from app.source import markdownify from app.source import markdownify
from app.uploads import upload_to_attachment from app.uploads import upload_to_attachment
from app.utils import opengraph from app.utils import opengraph
@ -93,6 +96,7 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
raise ValueError(f"{ap_object_id} not found in the outbox") raise ValueError(f"{ap_object_id} not found in the outbox")
delete_id = allocate_outbox_id() delete_id = allocate_outbox_id()
# FIXME addressing
delete = { delete = {
"@context": ap.AS_EXTENDED_CTX, "@context": ap.AS_EXTENDED_CTX,
"id": outbox_object_id(delete_id), "id": outbox_object_id(delete_id),
@ -122,6 +126,23 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
for rcp in recipients: for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id) await new_outgoing_activity(db_session, rcp, outbox_object.id)
# Revert side effects
if outbox_object_to_delete.in_reply_to:
replied_object = await get_anybox_object_by_ap_id(
db_session, outbox_object_to_delete.in_reply_to
)
if replied_object:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
replied_object.replies_count = new_replies_count
if replied_object.replies_count < 0:
logger.warning("negative replies count for {replied_object.ap_id}")
replied_object.replies_count = 0
else:
logger.info(f"{outbox_object_to_delete.in_reply_to} not found")
await db_session.commit() await db_session.commit()
@ -269,6 +290,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
raise ValueError("Should never happen") raise ValueError("Should never happen")
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id 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 outbox_object_to_undo.ap_type == "Follow":
if not outbox_object_to_undo.activity_object_ap_id: if not outbox_object_to_undo.activity_object_ap_id:
@ -327,17 +349,19 @@ async def fetch_conversation_root(
db_session: AsyncSession, db_session: AsyncSession,
obj: AnyboxObject | RemoteObject, obj: AnyboxObject | RemoteObject,
is_root: bool = False, is_root: bool = False,
depth: int = 0,
) -> str: ) -> str:
"""Some softwares do not set the context/conversation field (like Misskey). """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: the root of the conversation and either:
- use the context field if set - use the context field if set
- or build a custom conversation ID - or build a custom conversation ID
""" """
if not obj.in_reply_to or is_root: logger.info(f"Fetching convo root for ap_id={obj.ap_id}/{depth=}")
if obj.ap_context: if obj.ap_context:
return obj.ap_context return obj.ap_context
else:
if not obj.in_reply_to or is_root or depth > 10:
# Use the root AP ID if there'no context # Use the root AP ID if there'no context
return f"microblogpub:root:{obj.ap_id}" return f"microblogpub:root:{obj.ap_id}"
else: else:
@ -351,16 +375,78 @@ async def fetch_conversation_root(
db_session, ap.get_actor_id(raw_reply) db_session, ap.get_actor_id(raw_reply)
) )
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor) in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError): except (
return await fetch_conversation_root(db_session, obj, is_root=True) 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: except httpx.HTTPStatusError as http_status_error:
if 400 <= http_status_error.response.status_code < 500: if 400 <= http_status_error.response.status_code < 500:
# We may not have access, in this case consider if root # 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: else:
raise 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(
db_session: AsyncSession,
target: str,
) -> None:
move_id = allocate_outbox_id()
obj = {
"@context": ap.AS_CTX,
"type": "Move",
"id": outbox_object_id(move_id),
"actor": LOCAL_ACTOR.ap_id,
"object": LOCAL_ACTOR.ap_id,
"target": target,
}
outbox_object = await save_outbox_object(db_session, move_id, obj)
if not outbox_object.id:
raise ValueError("Should never happen")
recipients = await _get_followers_recipients(db_session)
for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id)
# Store the moved to in order to update the profile
set_moved_to(target)
await db_session.commit()
async def send_self_destruct(db_session: AsyncSession) -> None:
delete_id = allocate_outbox_id()
delete = {
"@context": ap.AS_EXTENDED_CTX,
"id": outbox_object_id(delete_id),
"type": "Delete",
"actor": ID,
"object": ID,
"to": [ap.AS_PUBLIC],
}
outbox_object = await save_outbox_object(
db_session,
delete_id,
delete,
)
if not outbox_object.id:
raise ValueError("Should never happen")
recipients = await compute_all_known_recipients(db_session)
for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id)
await db_session.commit()
async def send_create( async def send_create(
@ -384,6 +470,7 @@ async def send_create(
content, tags, mentioned_actors = await markdownify(db_session, source) content, tags, mentioned_actors = await markdownify(db_session, source)
attachments = [] attachments = []
in_reply_to_object: AnyboxObject | None = None
if in_reply_to: if in_reply_to:
in_reply_to_object = await get_anybox_object_by_ap_id(db_session, 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: if not in_reply_to_object:
@ -401,23 +488,6 @@ async def send_create(
context = in_reply_to_object.ap_context context = in_reply_to_object.ap_context
conversation = 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: for (upload, filename, alt_text) in uploads:
attachments.append(upload_to_attachment(upload, filename, alt_text)) attachments.append(upload_to_attachment(upload, filename, alt_text))
@ -482,7 +552,7 @@ async def send_create(
"context": context, "context": context,
"conversation": context, "conversation": context,
"url": outbox_object_id(note_id), "url": outbox_object_id(note_id),
"tag": tags, "tag": dedup_tags(tags),
"summary": content_warning, "summary": content_warning,
"inReplyTo": in_reply_to, "inReplyTo": in_reply_to,
"sensitive": is_sensitive, "sensitive": is_sensitive,
@ -502,7 +572,7 @@ async def send_create(
for tag in tags: for tag in tags:
if tag["type"] == "Hashtag": if tag["type"] == "Hashtag":
tagged_object = models.TaggedOutboxObject( tagged_object = models.TaggedOutboxObject(
tag=tag["name"][1:], tag=tag["name"][1:].lower(),
outbox_object_id=outbox_object.id, outbox_object_id=outbox_object.id,
) )
db_session.add(tagged_object) db_session.add(tagged_object)
@ -536,6 +606,31 @@ async def send_create(
) )
await db_session.commit() 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 return note_id
@ -708,6 +803,17 @@ async def _compute_recipients(
return recipients return recipients
async def compute_all_known_recipients(db_session: AsyncSession) -> set[str]:
return {
actor.shared_inbox_url or actor.inbox_url
for actor in (
await db_session.scalars(
select(models.Actor).where(models.Actor.is_deleted.is_(False))
)
).all()
}
async def _get_following(db_session: AsyncSession) -> list[models.Follower]: async def _get_following(db_session: AsyncSession) -> list[models.Follower]:
return ( return (
( (
@ -859,7 +965,7 @@ async def _handle_delete_activity(
except ap.ObjectNotFoundError: except ap.ObjectNotFoundError:
pass pass
if ap_object_to_delete is None: if ap_object_to_delete is None or not ap_object_to_delete.is_from_db:
logger.info( logger.info(
"Received Delete for an unknown object " "Received Delete for an unknown object "
f"{delete_activity.activity_object_ap_id}" f"{delete_activity.activity_object_ap_id}"
@ -941,6 +1047,29 @@ async def _handle_delete_activity(
await db_session.flush() 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( async def _revert_side_effect_for_deleted_object(
db_session: AsyncSession, db_session: AsyncSession,
delete_activity: models.InboxObject, delete_activity: models.InboxObject,
@ -949,7 +1078,17 @@ async def _revert_side_effect_for_deleted_object(
) -> None: ) -> None:
is_delete_needs_to_be_forwarded = False 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: if deleted_ap_object.in_reply_to:
replied_object = await get_anybox_object_by_ap_id( replied_object = await get_anybox_object_by_ap_id(
db_session, db_session,
@ -961,20 +1100,28 @@ async def _revert_side_effect_for_deleted_object(
# also needs to be forwarded # also needs to be forwarded
is_delete_needs_to_be_forwarded = True is_delete_needs_to_be_forwarded = True
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute( await db_session.execute(
update(models.OutboxObject) update(models.OutboxObject)
.where( .where(
models.OutboxObject.id == replied_object.id, models.OutboxObject.id == replied_object.id,
) )
.values(replies_count=models.OutboxObject.replies_count - 1) .values(replies_count=new_replies_count)
) )
else: else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute( await db_session.execute(
update(models.InboxObject) update(models.InboxObject)
.where( .where(
models.InboxObject.id == replied_object.id, models.InboxObject.id == replied_object.id,
) )
.values(replies_count=models.InboxObject.replies_count - 1) .values(replies_count=new_replies_count)
) )
if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id: if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id:
@ -1259,6 +1406,13 @@ async def _handle_undo_activity(
inbox_object_id=ap_activity_to_undo.id, inbox_object_id=ap_activity_to_undo.id,
) )
db_session.add(notif) 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: else:
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity") logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
@ -1281,13 +1435,16 @@ async def _handle_move_activity(
return None return None
# Fetch the target account # Fetch the target account
new_actor_id = move_activity.ap_object.get("target") target = move_activity.ap_object.get("target")
if not new_actor_id: if not target:
logger.warning("Missing target") logger.warning("Missing target")
return None return None
new_actor_id = ap.get_id(target)
new_actor = await fetch_actor(db_session, new_actor_id) new_actor = await fetch_actor(db_session, new_actor_id)
logger.info(f"Moving {old_actor_id} to {new_actor_id}")
# Ensure the target account references the old account # Ensure the target account references the old account
if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])): if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])):
logger.warning( logger.warning(
@ -1310,7 +1467,21 @@ async def _handle_move_activity(
await _send_undo(db_session, following.outbox_object.ap_id) await _send_undo(db_session, following.outbox_object.ap_id)
# Follow the new one # Follow the new one
if not (
await db_session.execute(
select(models.Following).where(models.Following.ap_actor_id == new_actor_id)
)
).scalar():
await _send_follow(db_session, new_actor_id) await _send_follow(db_session, new_actor_id)
else:
logger.info(f"Already following target {new_actor_id}")
notif = models.Notification(
notification_type=models.NotificationType.MOVE,
actor_id=new_actor.id,
inbox_object_id=move_activity.id,
)
db_session.add(notif)
async def _handle_update_activity( async def _handle_update_activity(
@ -1326,7 +1497,8 @@ async def _handle_update_activity(
updated_actor = RemoteActor(wrapped_object) updated_actor = RemoteActor(wrapped_object)
if ( if (
from_actor.ap_id != updated_actor.ap_id from_actor.ap_id != updated_actor.ap_id
or from_actor.ap_type != updated_actor.ap_type or ap.as_list(from_actor.ap_type)[0] not in ap.ACTOR_TYPES
or ap.as_list(updated_actor.ap_type)[0] not in ap.ACTOR_TYPES
or from_actor.handle != updated_actor.handle or from_actor.handle != updated_actor.handle
): ):
raise ValueError( raise ValueError(
@ -1335,7 +1507,7 @@ async def _handle_update_activity(
) )
# Update the actor # 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 [ elif (ap_type := wrapped_object["type"]) in [
"Question", "Question",
"Note", "Note",
@ -1358,6 +1530,7 @@ async def _handle_update_activity(
# Everything looks correct, update the object in the inbox # Everything looks correct, update the object in the inbox
logger.info(f"Updating {existing_object.ap_id}") logger.info(f"Updating {existing_object.ap_id}")
existing_object.ap_object = wrapped_object existing_object.ap_object = wrapped_object
existing_object.updated_at = now()
else: else:
# TODO(ts): support updating objects # TODO(ts): support updating objects
logger.info(f'Cannot update {wrapped_object["type"]}') logger.info(f'Cannot update {wrapped_object["type"]}')
@ -1368,8 +1541,24 @@ async def _handle_create_activity(
from_actor: models.Actor, from_actor: models.Actor,
create_activity: models.InboxObject, create_activity: models.InboxObject,
forwarded_by_actor: models.Actor | None = None, forwarded_by_actor: models.Actor | None = None,
relates_to_inbox_object: models.InboxObject | None = None,
) -> None: ) -> None:
logger.info("Processing Create activity") 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) wrapped_object = ap.unwrap_activity(create_activity.ap_object)
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object): if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
raise ValueError("Object actor does not match activity") raise ValueError("Object actor does not match activity")
@ -1387,8 +1576,9 @@ async def _handle_create_activity(
logger.warning( logger.warning(
f"Got a Delete for {ro.ap_id} from {delete_object.actor.ap_id}??" f"Got a Delete for {ro.ap_id} from {delete_object.actor.ap_id}??"
) )
return None
else: 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 create_activity.is_deleted = True
await db_session.flush() await db_session.flush()
return None return None
@ -1419,6 +1609,14 @@ async def _handle_read_activity(
if not wrapped_object_actor.is_blocked: if not wrapped_object_actor.is_blocked:
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor) 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 # Then process it likes it's coming from a forwarded activity
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro) await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
@ -1471,6 +1669,8 @@ async def _process_note_object(
is_hidden_from_stream=not ( is_hidden_from_stream=not (
(not is_reply and is_from_following) or is_mention or is_local_reply (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) db_session.add(inbox_object)
@ -1494,20 +1694,28 @@ async def _process_note_object(
replied_object, # type: ignore # outbox check below replied_object, # type: ignore # outbox check below
) )
else: else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute( await db_session.execute(
update(models.OutboxObject) update(models.OutboxObject)
.where( .where(
models.OutboxObject.id == replied_object.id, models.OutboxObject.id == replied_object.id,
) )
.values(replies_count=models.OutboxObject.replies_count + 1) .values(replies_count=new_replies_count)
) )
else: else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute( await db_session.execute(
update(models.InboxObject) update(models.InboxObject)
.where( .where(
models.InboxObject.id == replied_object.id, 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 # This object is a reply of a local object, we may need to forward it
@ -1654,10 +1862,32 @@ async def _handle_announce_activity(
# We already know about this object, show the announce in the # We already know about this object, show the announce in the
# stream if it's not already there, from an followed actor # stream if it's not already there, from an followed actor
# and if we haven't seen it recently # and if we haven't seen it recently
skip_delta = timedelta(hours=1)
delta_from_original = now() - as_utc(
relates_to_inbox_object.ap_published_at # type: ignore
)
dup_count = 0
if ( if (
now() - as_utc(relates_to_inbox_object.ap_published_at) # type: ignore not relates_to_inbox_object.is_hidden_from_stream
) > timedelta(hours=1): and delta_from_original < skip_delta
) or (
dup_count := (
await db_session.scalar(
select(func.count(models.InboxObject.id)).where(
models.InboxObject.ap_type == "Announce",
models.InboxObject.ap_published_at > now() - skip_delta,
models.InboxObject.relates_to_inbox_object_id
== relates_to_inbox_object.id,
models.InboxObject.is_hidden_from_stream.is_(False),
)
)
)
) > 0:
logger.info(f"Deduping Announce {delta_from_original=}/{dup_count=}")
announce_activity.is_hidden_from_stream = True
else:
announce_activity.is_hidden_from_stream = not is_from_following announce_activity.is_hidden_from_stream = not is_from_following
else: else:
# Save it as an inbox object # Save it as an inbox object
if not announce_activity.activity_object_ap_id: if not announce_activity.activity_object_ap_id:
@ -1665,6 +1895,12 @@ async def _handle_announce_activity(
announced_raw_object = await ap.fetch( announced_raw_object = await ap.fetch(
announce_activity.activity_object_ap_id announce_activity.activity_object_ap_id
) )
# Some software return objects wrapped in a Create activity (like
# python-federation)
if ap.as_list(announced_raw_object["type"])[0] == "Create":
announced_raw_object = await ap.get_object(announced_raw_object)
announced_actor = await fetch_actor( announced_actor = await fetch_actor(
db_session, ap.get_actor_id(announced_raw_object) db_session, ap.get_actor_id(announced_raw_object)
) )
@ -1716,15 +1952,39 @@ async def _handle_like_activity(
db_session.add(notif) 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( async def _process_transient_object(
db_session: AsyncSession, db_session: AsyncSession,
raw_object: ap.RawObject, raw_object: ap.RawObject,
from_actor: models.Actor, from_actor: models.Actor,
) -> None: ) -> None:
# TODO: track featured/pinned objects for actors
ap_type = raw_object["type"] ap_type = raw_object["type"]
if ap_type in ["Add", "Remove"]: if ap_type in ["Add", "Remove"]:
logger.info(f"Dropping unsupported {ap_type} object") logger.info(f"Dropping unsupported {ap_type} object")
else: else:
# FIXME(ts): handle transient create
logger.warning(f"Received unknown {ap_type} object") logger.warning(f"Received unknown {ap_type} object")
return None return None
@ -1735,12 +1995,34 @@ async def save_to_inbox(
raw_object: ap.RawObject, raw_object: ap.RawObject,
sent_by_ap_actor_id: str, sent_by_ap_actor_id: str,
) -> None: ) -> None:
# Special case for server sending the actor as a payload (like python-federation)
if ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
if ap.get_id(raw_object) == sent_by_ap_actor_id:
updated_actor = RemoteActor(raw_object)
try:
actor = await fetch_actor(db_session, sent_by_ap_actor_id)
except ap.ObjectNotFoundError:
logger.warning("Actor not found")
return
# Update the actor
actor.ap_actor = updated_actor.ap_actor
await db_session.commit()
return
else:
logger.warning(
f"Reveived an actor payload {raw_object} from " f"{sent_by_ap_actor_id}"
)
return
try: try:
actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"])) actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"]))
except ap.ObjectNotFoundError: except ap.ObjectNotFoundError:
logger.warning("Actor not found") logger.warning("Actor not found")
return return
except httpx.HTTPStatusError: except ap.FetchError:
logger.exception("Failed to fetch actor") logger.exception("Failed to fetch actor")
return return
@ -1748,7 +2030,7 @@ async def save_to_inbox(
logger.warning(f"Server {actor.server} is blocked") logger.warning(f"Server {actor.server} is blocked")
return return
if "id" not in raw_object: if "id" not in raw_object or not raw_object["id"]:
await _process_transient_object(db_session, raw_object, actor) await _process_transient_object(db_session, raw_object, actor)
return None return None
@ -1766,7 +2048,13 @@ async def save_to_inbox(
) )
forwarded_by_actor = await fetch_actor(db_session, sent_by_ap_actor_id) forwarded_by_actor = await fetch_actor(db_session, sent_by_ap_actor_id)
if not (await ldsig.verify_signature(db_session, raw_object)): is_sig_verified = False
try:
is_sig_verified = await ldsig.verify_signature(db_session, raw_object)
except Exception:
logger.exception("Failed to verify LD sig")
if not is_sig_verified:
logger.warning( logger.warning(
f"Failed to verify LD sig, fetching remote object {raw_object_id}" f"Failed to verify LD sig, fetching remote object {raw_object_id}"
) )
@ -1842,7 +2130,11 @@ async def save_to_inbox(
if activity_ro.ap_type == "Create": if activity_ro.ap_type == "Create":
await _handle_create_activity( 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": elif activity_ro.ap_type == "Read":
await _handle_read_activity(db_session, actor, inbox_object) await _handle_read_activity(db_session, actor, inbox_object)
@ -1943,6 +2235,15 @@ async def save_to_inbox(
relates_to_outbox_object, relates_to_outbox_object,
relates_to_inbox_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: else:
logger.warning(f"Received an unknown {inbox_object.ap_type} object") logger.warning(f"Received an unknown {inbox_object.ap_type} object")
@ -1956,7 +2257,7 @@ async def _prefetch_actor_outbox(
"""Try to fetch some notes to fill the stream""" """Try to fetch some notes to fill the stream"""
saved = 0 saved = 0
outbox = await ap.parse_collection(actor.outbox_url, limit=20) outbox = await ap.parse_collection(actor.outbox_url, limit=20)
for activity in outbox: for activity in outbox[:20]:
activity_id = ap.get_id(activity) activity_id = ap.get_id(activity)
raw_activity = await ap.fetch(activity_id) raw_activity = await ap.fetch(activity_id)
if ap.as_list(raw_activity["type"])[0] == "Create": if ap.as_list(raw_activity["type"])[0] == "Create":
@ -1969,6 +2270,7 @@ async def _prefetch_actor_outbox(
if not saved_inbox_object.in_reply_to: if not saved_inbox_object.in_reply_to:
saved_inbox_object.is_hidden_from_stream = False saved_inbox_object.is_hidden_from_stream = False
saved += 1 saved += 1
if saved >= 5: if saved >= 5:

View File

@ -12,8 +12,10 @@ from fastapi import HTTPException
from fastapi import Request from fastapi import Request
from itsdangerous import URLSafeTimedSerializer from itsdangerous import URLSafeTimedSerializer
from loguru import logger from loguru import logger
from mistletoe import markdown # type: ignore
from app.utils.emoji import _load_emojis from app.utils.emoji import _load_emojis
from app.utils.version import get_version_commit
ROOT_DIR = Path().parent.resolve() ROOT_DIR = Path().parent.resolve()
@ -24,7 +26,7 @@ VERSION_COMMIT = "dev"
try: try:
from app._version import VERSION_COMMIT # type: ignore from app._version import VERSION_COMMIT # type: ignore
except ImportError: except ImportError:
pass VERSION_COMMIT = get_version_commit()
# Force reloading cache when the CSS is updated # Force reloading cache when the CSS is updated
CSS_HASH = "none" CSS_HASH = "none"
@ -34,6 +36,31 @@ try:
except FileNotFoundError: except FileNotFoundError:
pass pass
# Force reloading cache when the JS is changed
JS_HASH = "none"
try:
# To keep things simple, we keep a single hash for the 2 files
js_data_common = (ROOT_DIR / "app" / "static" / "common-admin.js").read_bytes()
js_data_new = (ROOT_DIR / "app" / "static" / "new.js").read_bytes()
JS_HASH = hashlib.md5(
js_data_common + js_data_new, usedforsecurity=False
).hexdigest()
except FileNotFoundError:
pass
MOVED_TO_FILE = ROOT_DIR / "data" / "moved_to.dat"
def _get_moved_to() -> str | None:
if not MOVED_TO_FILE.exists():
return None
return MOVED_TO_FILE.read_text()
def set_moved_to(moved_to: str) -> None:
MOVED_TO_FILE.write_text(moved_to)
VERSION = f"2.0.0+{VERSION_COMMIT}" VERSION = f"2.0.0+{VERSION_COMMIT}"
USER_AGENT = f"microblogpub/{VERSION}" USER_AGENT = f"microblogpub/{VERSION}"
@ -71,6 +98,12 @@ class Config(pydantic.BaseModel):
metadata: list[_ProfileMetadata] | None = None metadata: list[_ProfileMetadata] | None = None
code_highlighting_theme = "friendly_grayscale" code_highlighting_theme = "friendly_grayscale"
blocked_servers: list[_BlockedServer] = [] blocked_servers: list[_BlockedServer] = []
custom_footer: str | None = None
emoji: str | None = None
also_known_as: str | None = None
hides_followers: bool = False
hides_following: bool = False
inbox_retention_days: int = 15 inbox_retention_days: int = 15
@ -114,13 +147,21 @@ _SCHEME = "https" if CONFIG.https else "http"
ID = f"{_SCHEME}://{DOMAIN}" ID = f"{_SCHEME}://{DOMAIN}"
USERNAME = CONFIG.username USERNAME = CONFIG.username
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
HIDES_FOLLOWERS = CONFIG.hides_followers
HIDES_FOLLOWING = CONFIG.hides_following
PRIVACY_REPLACE = None PRIVACY_REPLACE = None
if CONFIG.privacy_replace: if CONFIG.privacy_replace:
PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace} PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace}
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers} BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
ALSO_KNOWN_AS = CONFIG.also_known_as
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
CUSTOM_FOOTER = (
markdown(CONFIG.custom_footer.replace("{version}", VERSION))
if CONFIG.custom_footer
else None
)
BASE_URL = ID BASE_URL = ID
DEBUG = CONFIG.debug DEBUG = CONFIG.debug
@ -130,6 +171,9 @@ KEY_PATH = (
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem" (ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
) )
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾" EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
if CONFIG.emoji:
EMOJIS = CONFIG.emoji
# Emoji template for the FE # Emoji template for the FE
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">' EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
@ -137,6 +181,8 @@ _load_emojis(ROOT_DIR, BASE_URL)
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
MOVED_TO = _get_moved_to()
session_serializer = URLSafeTimedSerializer( session_serializer = URLSafeTimedSerializer(
CONFIG.secret, CONFIG.secret,
@ -152,10 +198,19 @@ def generate_csrf_token() -> str:
return csrf_serializer.dumps(secrets.token_hex(16)) # type: ignore 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: try:
csrf_serializer.loads(csrf_token, max_age=1800) csrf_serializer.loads(csrf_token, max_age=1800)
except (itsdangerous.BadData, itsdangerous.SignatureExpired): except (itsdangerous.BadData, itsdangerous.SignatureExpired):
logger.exception("Failed to verify CSRF token") 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 return None

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.config import DB_PATH from app.config import DB_PATH
from app.config import DEBUG
from app.config import SQLALCHEMY_DATABASE_URL from app.config import SQLALCHEMY_DATABASE_URL
engine = create_engine( engine = create_engine(
@ -18,7 +19,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}" DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
async_engine = create_async_engine( async_engine = create_async_engine(
DATABASE_URL, future=True, echo=False, connect_args={"timeout": 15} DATABASE_URL, future=True, echo=DEBUG, connect_args={"timeout": 15}
) )
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)

View File

@ -88,8 +88,12 @@ def _body_digest(body: bytes) -> str:
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8") return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key: async def _get_public_key(
if cached_key := _KEY_CACHE.get(key_id): 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") logger.info(f"Key {key_id} found in cache")
return cached_key return cached_key
@ -101,6 +105,7 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0]) select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
) )
).one_or_none() ).one_or_none()
if not should_skip_cache:
if existing_actor and existing_actor.public_key_id == key_id: if existing_actor and existing_actor.public_key_id == key_id:
k = Key(existing_actor.ap_id, key_id) k = Key(existing_actor.ap_id, key_id)
k.load_pub(existing_actor.public_key_as_pem) k.load_pub(existing_actor.public_key_as_pem)
@ -110,16 +115,15 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
# Fetch it # Fetch it
from app import activitypub as ap 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 # Without signing the request as if it's the first contact, the 2 servers
# might race to fetch each other key # might race to fetch each other key
try: try:
actor = await ap.fetch(key_id, disable_httpsig=True) actor = await ap.fetch(key_id, disable_httpsig=True)
except httpx.HTTPStatusError as http_err: except ap.ObjectUnavailableError:
if http_err.response.status_code in [401, 403]:
actor = await ap.fetch(key_id, disable_httpsig=False) actor = await ap.fetch(key_id, disable_httpsig=False)
else:
raise
if actor["type"] == "Key": if actor["type"] == "Key":
# The Key is not embedded in the Person # The Key is not embedded in the Person
@ -130,11 +134,18 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
k.load_pub(actor["publicKey"]["publicKeyPem"]) k.load_pub(actor["publicKey"]["publicKeyPem"])
# Ensure the right key was fetch # Ensure the right key was fetch
# TODO: some server have the key ID `http://` but fetching it return `https`
if key_id not in [k.key_id(), k.owner]: if key_id not in [k.key_id(), k.owner]:
raise ValueError( raise ValueError(
f"failed to fetch requested key {key_id}: got {actor['publicKey']}" 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 _KEY_CACHE[key_id] = k
return k return k
@ -215,10 +226,23 @@ async def httpsig_checker(
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}') logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
return HTTPSigInfo(has_valid_signature=False) return HTTPSigInfo(has_valid_signature=False)
httpsig_info = HTTPSigInfo( has_valid_signature = _verify_h(
has_valid_signature=_verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey signed_string, base64.b64decode(hsig["signature"]), k.pubkey
), )
# 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,
signed_by_ap_actor_id=k.owner, signed_by_ap_actor_id=k.owner,
server=server, server=server,
) )

View File

@ -26,7 +26,7 @@ async def new_ap_incoming_activity(
raw_object: ap.RawObject, raw_object: ap.RawObject,
) -> models.IncomingActivity | None: ) -> models.IncomingActivity | None:
ap_id: str ap_id: str
if "id" not in raw_object: if "id" not in raw_object or ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
if "@context" not in raw_object: if "@context" not in raw_object:
logger.warning(f"Dropping invalid object: {raw_object}") logger.warning(f"Dropping invalid object: {raw_object}")
return None return None
@ -112,10 +112,13 @@ async def process_next_incoming_activity(
if next_activity.ap_object and next_activity.sent_by_ap_actor_id: if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
try: try:
async with db_session.begin_nested(): async with db_session.begin_nested():
await save_to_inbox( await asyncio.wait_for(
save_to_inbox(
db_session, db_session,
next_activity.ap_object, next_activity.ap_object,
next_activity.sent_by_ap_actor_id, next_activity.sent_by_ap_actor_id,
),
timeout=60,
) )
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
url = exc._request.url if exc._request else None url = exc._request.url if exc._request else None

View File

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

View File

@ -10,6 +10,7 @@ from app.source import _MENTION_REGEX
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject: async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
query = query.strip()
if query.startswith("@") or _MENTION_REGEX.match("@" + query): if query.startswith("@") or _MENTION_REGEX.match("@" + query):
query = await webfinger.get_actor_url(query) # type: ignore # None check below query = await webfinger.get_actor_url(query) # type: ignore # None check below
@ -37,4 +38,9 @@ async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES: if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES:
return RemoteActor(ap_obj) return RemoteActor(ap_obj)
else: else:
# Some software return objects wrapped in a Create activity (like
# python-federation)
if ap.as_list(ap_obj["type"])[0] == "Create":
ap_obj = await ap.get_object(ap_obj)
return await RemoteObject.from_raw_object(ap_obj) return await RemoteObject.from_raw_object(ap_obj)

View File

@ -8,6 +8,7 @@ from typing import Any
from typing import MutableMapping from typing import MutableMapping
from typing import Type from typing import Type
import fastapi
import httpx import httpx
import starlette import starlette
from asgiref.typing import ASGI3Application from asgiref.typing import ASGI3Application
@ -20,6 +21,7 @@ from fastapi import FastAPI
from fastapi import Form from fastapi import Form
from fastapi import Request from fastapi import Request
from fastapi import Response from fastapi import Response
from fastapi.exception_handlers import http_exception_handler
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
@ -35,6 +37,7 @@ from sqlalchemy.orm import joinedload
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from starlette.datastructures import Headers from starlette.datastructures import Headers
from starlette.datastructures import MutableHeaders from starlette.datastructures import MutableHeaders
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import Message from starlette.types import Message
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # type: ignore from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # type: ignore
@ -61,20 +64,25 @@ from app.config import USERNAME
from app.config import is_activitypub_requested from app.config import is_activitypub_requested
from app.config import verify_csrf_token from app.config import verify_csrf_token
from app.database import AsyncSession from app.database import AsyncSession
from app.database import async_session
from app.database import get_db_session from app.database import get_db_session
from app.incoming_activities import new_ap_incoming_activity from app.incoming_activities import new_ap_incoming_activity
from app.templates import is_current_user_admin from app.templates import is_current_user_admin
from app.uploads import UPLOAD_DIR from app.uploads import UPLOAD_DIR
from app.utils import pagination from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME from app.utils.emoji import EMOJIS_BY_NAME
from app.utils.highlight import HIGHLIGHT_CSS_HASH
from app.utils.url import check_url from app.utils.url import check_url
from app.webfinger import get_remote_follow_template from app.webfinger import get_remote_follow_template
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(32) # Only images <1MB will be cached, so 64MB of data will be cached
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(64)
# TODO(ts): # TODO(ts):
# Next: # Next:
# - self-destruct + move support and actions/tasks for
# - doc for prune/move/delete
# - fix issue with followers from a blocked server (skip it?) # - fix issue with followers from a blocked server (skip it?)
# - allow to share old notes # - allow to share old notes
# - only show 10 most recent threads in DMs # - only show 10 most recent threads in DMs
@ -118,21 +126,21 @@ class CustomMiddleware:
# And add the security headers # And add the security headers
headers = MutableHeaders(scope=message) headers = MutableHeaders(scope=message)
headers["X-Request-ID"] = request_id headers["X-Request-ID"] = request_id
headers["Server"] = "microblogpub" headers["x-powered-by"] = "microblogpub"
headers[ headers[
"referrer-policy" "referrer-policy"
] = "no-referrer, strict-origin-when-cross-origin" ] = "no-referrer, strict-origin-when-cross-origin"
headers["x-content-type-options"] = "nosniff" headers["x-content-type-options"] = "nosniff"
headers["x-xss-protection"] = "1; mode=block" headers["x-xss-protection"] = "1; mode=block"
headers["x-frame-options"] = "SAMEORIGIN" headers["x-frame-options"] = "DENY"
# TODO(ts): disallow inline CSS? headers["permissions-policy"] = "interest-cohort=()"
headers[ headers["content-security-policy"] = (
"content-security-policy" f"default-src 'self'; "
] = "default-src 'self'; style-src 'self' 'unsafe-inline';" f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
)
if not DEBUG: if not DEBUG:
headers[ headers["strict-transport-security"] = "max-age=63072000;"
"strict-transport-security"
] = "max-age=63072000; includeSubdomains"
await send(message) # type: ignore await send(message) # type: ignore
@ -164,7 +172,15 @@ class CustomMiddleware:
return None return None
app = FastAPI(docs_url=None, redoc_url=None) def _check_0rtt_early_data(request: Request) -> None:
"""Disable TLS1.3 0-RTT requests for non-GET."""
if request.headers.get("Early-Data", None) == "1" and request.method != "GET":
raise fastapi.HTTPException(status_code=425, detail="Too early")
app = FastAPI(
docs_url=None, redoc_url=None, dependencies=[Depends(_check_0rtt_early_data)]
)
app.mount( app.mount(
"/custom_emoji", "/custom_emoji",
StaticFiles(directory="data/custom_emoji"), StaticFiles(directory="data/custom_emoji"),
@ -192,6 +208,37 @@ logger_format = (
logger.add(sys.stdout, format=logger_format, level="DEBUG" if DEBUG else "INFO") logger.add(sys.stdout, format=logger_format, level="DEBUG" if DEBUG else "INFO")
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(
request: Request,
exc: StarletteHTTPException,
) -> templates.TemplateResponse | JSONResponse:
accept_value = request.headers.get("accept")
if (
accept_value
and accept_value.startswith("text/html")
and 400 <= exc.status_code < 600
):
async with async_session() as db_session:
title = (
{
404: "Oops, nothing to see here",
500: "Oops, something went wrong",
}
).get(exc.status_code, exc.detail)
try:
return await templates.render_template(
db_session,
request,
"error.html",
{"title": title},
status_code=exc.status_code,
)
finally:
await db_session.close()
return await http_exception_handler(request, exc)
class ActivityPubResponse(JSONResponse): class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json" media_type = "application/activity+json"
@ -204,6 +251,7 @@ async def index(
page: int | None = None, page: int | None = None,
) -> templates.TemplateResponse | ActivityPubResponse: ) -> templates.TemplateResponse | ActivityPubResponse:
if is_activitypub_requested(request): if is_activitypub_requested(request):
return ActivityPubResponse(LOCAL_ACTOR.ap_actor) return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
page = page or 1 page = page or 1
@ -234,6 +282,7 @@ async def index(
), ),
), ),
) )
.order_by(models.OutboxObject.is_pinned.desc())
.order_by(models.OutboxObject.ap_published_at.desc()) .order_by(models.OutboxObject.ap_published_at.desc())
.offset(page_offset) .offset(page_offset)
.limit(page_size) .limit(page_size)
@ -354,6 +403,20 @@ async def _build_followx_collection(
return collection_page return collection_page
async def _empty_followx_collection(
db_session: AsyncSession,
model_cls: Type[models.Following | models.Follower],
path: str,
) -> ap.RawObject:
total_items = await db_session.scalar(select(func.count(model_cls.id)))
return {
"@context": ap.AS_CTX,
"id": ID + path,
"type": "OrderedCollection",
"totalItems": total_items,
}
@app.get("/followers") @app.get("/followers")
async def followers( async def followers(
request: Request, request: Request,
@ -364,6 +427,15 @@ async def followers(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse: ) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request): if is_activitypub_requested(request):
if config.HIDES_FOLLOWERS:
return ActivityPubResponse(
await _empty_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
)
)
else:
return ActivityPubResponse( return ActivityPubResponse(
await _build_followx_collection( await _build_followx_collection(
db_session=db_session, db_session=db_session,
@ -374,6 +446,9 @@ async def followers(
) )
) )
if config.HIDES_FOLLOWERS and not is_current_user_admin(request):
raise HTTPException(status_code=404)
# We only show the most recent 20 followers on the public website # We only show the most recent 20 followers on the public website
followers_result = await db_session.scalars( followers_result = await db_session.scalars(
select(models.Follower) select(models.Follower)
@ -411,6 +486,15 @@ async def following(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse: ) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request): if is_activitypub_requested(request):
if config.HIDES_FOLLOWING:
return ActivityPubResponse(
await _empty_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
)
)
else:
return ActivityPubResponse( return ActivityPubResponse(
await _build_followx_collection( await _build_followx_collection(
db_session=db_session, db_session=db_session,
@ -421,6 +505,9 @@ async def following(
) )
) )
if config.HIDES_FOLLOWING and not is_current_user_admin(request):
raise HTTPException(status_code=404)
# We only show the most recent 20 follows on the public website # We only show the most recent 20 follows on the public website
following = ( following = (
( (
@ -676,7 +763,7 @@ async def tag_by_name(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse: ) -> ActivityPubResponse | templates.TemplateResponse:
where = [ where = [
models.TaggedOutboxObject.tag == tag, models.TaggedOutboxObject.tag == tag.lower(),
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC, models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False), models.OutboxObject.is_deleted.is_(False),
] ]
@ -685,10 +772,10 @@ async def tag_by_name(
.join(models.TaggedOutboxObject) .join(models.TaggedOutboxObject)
.where(*where) .where(*where)
) )
if is_activitypub_requested(request):
if not tagged_count: if not tagged_count:
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
if is_activitypub_requested(request):
outbox_object_ids = await db_session.execute( outbox_object_ids = await db_session.execute(
select(models.OutboxObject.ap_id) select(models.OutboxObject.ap_id)
.join( .join(
@ -702,7 +789,7 @@ async def tag_by_name(
return ActivityPubResponse( return ActivityPubResponse(
{ {
"@context": ap.AS_CTX, "@context": ap.AS_CTX,
"id": BASE_URL + f"/t/{tag}", "id": BASE_URL + f"/t/{tag.lower()}",
"type": "OrderedCollection", "type": "OrderedCollection",
"totalItems": tagged_count, "totalItems": tagged_count,
"orderedItems": [ "orderedItems": [
@ -736,6 +823,7 @@ async def tag_by_name(
"request": request, "request": request,
"objects": outbox_objects, "objects": outbox_objects,
}, },
status_code=200 if len(outbox_objects) else 404,
) )
@ -795,6 +883,48 @@ async def post_remote_follow(
) )
@app.get("/remote_interaction")
async def remote_interaction(
request: Request,
ap_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
outbox_object = await boxes.get_outbox_object_by_ap_id(
db_session,
ap_id,
)
if not outbox_object:
raise HTTPException(status_code=404)
return await templates.render_template(
db_session,
request,
"remote_interact.html",
{"outbox_object": outbox_object},
)
@app.post("/remote_interaction")
async def post_remote_interaction(
request: Request,
csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(),
ap_id: str = Form(),
) -> RedirectResponse:
if not profile.startswith("@"):
profile = f"@{profile}"
remote_follow_template = await get_remote_follow_template(profile)
if not remote_follow_template:
# TODO(ts): error message to user
raise HTTPException(status_code=404)
return RedirectResponse(
remote_follow_template.format(uri=ap_id),
status_code=302,
)
@app.get("/.well-known/webfinger") @app.get("/.well-known/webfinger")
async def wellknown_webfinger(resource: str) -> JSONResponse: async def wellknown_webfinger(resource: str) -> JSONResponse:
"""Exposes/servers WebFinger data.""" """Exposes/servers WebFinger data."""
@ -904,29 +1034,49 @@ def _filter_proxy_resp_headers(
} }
def _strip_content_type(headers: dict[str, str]) -> dict[str, str]:
return {k: v for k, v in headers.items() if k.lower() != "content-type"}
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/{encoded_url}")
async def serve_proxy_media(request: Request, encoded_url: str) -> StreamingResponse: async def serve_proxy_media(
request: Request,
encoded_url: str,
) -> StreamingResponse | PlainTextResponse:
# Decode the base64-encoded URL # Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode() url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url) check_url(url)
proxy_resp = await _proxy_get(request, url, stream=True) proxy_resp = await _proxy_get(request, url, stream=True)
if proxy_resp.status_code >= 300:
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
return PlainTextResponse(
"proxy error",
status_code=proxy_resp.status_code,
)
return StreamingResponse( return StreamingResponse(
proxy_resp.aiter_raw(), proxy_resp.aiter_raw(),
status_code=proxy_resp.status_code, status_code=proxy_resp.status_code,
headers=_filter_proxy_resp_headers( headers=_add_cache_control(
_filter_proxy_resp_headers(
proxy_resp, proxy_resp,
[ [
"content-length", "content-length",
"content-type", "content-type",
"content-range", "content-range",
"accept-ranges" "etag", "accept-ranges",
"cache-control", "etag",
"expires", "expires",
"date", "date",
"last-modified", "last-modified",
], ],
)
), ),
background=BackgroundTask(proxy_resp.aclose), background=BackgroundTask(proxy_resp.aclose),
) )
@ -954,23 +1104,25 @@ async def serve_proxy_media_resized(
) )
proxy_resp = await _proxy_get(request, url, stream=False) proxy_resp = await _proxy_get(request, url, stream=False)
if proxy_resp.status_code != 200: if proxy_resp.status_code >= 300:
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
return PlainTextResponse( return PlainTextResponse(
proxy_resp.content, "proxy error",
status_code=proxy_resp.status_code, status_code=proxy_resp.status_code,
) )
# Filter the headers # Filter the headers
proxy_resp_headers = _filter_proxy_resp_headers( proxy_resp_headers = _add_cache_control(
_filter_proxy_resp_headers(
proxy_resp, proxy_resp,
[ [
"content-type", "content-type",
"etag", "etag",
"cache-control",
"expires", "expires",
"last-modified", "last-modified",
], ],
) )
)
try: try:
out = BytesIO(proxy_resp.content) out = BytesIO(proxy_resp.content)
@ -978,23 +1130,31 @@ async def serve_proxy_media_resized(
if getattr(i, "is_animated", False): if getattr(i, "is_animated", False):
raise ValueError raise ValueError
i.thumbnail((size, size)) i.thumbnail((size, size))
is_webp = False
try:
resized_buf = BytesIO()
i.save(resized_buf, format="webp")
is_webp = True
except Exception:
logger.exception("Failed to convert to webp")
resized_buf = BytesIO() resized_buf = BytesIO()
i.save(resized_buf, format=i.format) i.save(resized_buf, format=i.format)
resized_buf.seek(0) resized_buf.seek(0)
resized_content = resized_buf.read() resized_content = resized_buf.read()
resized_mimetype = i.get_format_mimetype() # type: ignore resized_mimetype = (
"image/webp" if is_webp else i.get_format_mimetype() # type: ignore
)
# Only cache images < 1MB # Only cache images < 1MB
if len(resized_content) < 2**20: if len(resized_content) < 2**20:
_RESIZED_CACHE[(url, size)] = ( _RESIZED_CACHE[(url, size)] = (
resized_content, resized_content,
resized_mimetype, resized_mimetype,
proxy_resp_headers, _strip_content_type(proxy_resp_headers),
) )
return PlainTextResponse( return PlainTextResponse(
resized_content, resized_content,
media_type=resized_mimetype, media_type=resized_mimetype,
headers=proxy_resp_headers, headers=_strip_content_type(proxy_resp_headers),
) )
except ValueError: except ValueError:
return PlainTextResponse( return PlainTextResponse(
@ -1028,6 +1188,7 @@ async def serve_attachment(
return FileResponse( return FileResponse(
UPLOAD_DIR / content_hash, UPLOAD_DIR / content_hash,
media_type=upload.content_type, media_type=upload.content_type,
headers={"Cache-Control": "max-age=31536000"},
) )
@ -1049,7 +1210,8 @@ async def serve_attachment_thumbnail(
return FileResponse( return FileResponse(
UPLOAD_DIR / (content_hash + "_resized"), UPLOAD_DIR / (content_hash + "_resized"),
media_type=upload.content_type, media_type="image/webp",
headers={"Cache-Control": "max-age=31536000"},
) )
@ -1059,6 +1221,7 @@ async def robots_file():
Disallow: /followers Disallow: /followers
Disallow: /following Disallow: /following
Disallow: /admin Disallow: /admin
Disallow: /remote_interaction
Disallow: /remote_follow""" Disallow: /remote_follow"""

View File

@ -45,7 +45,7 @@ class Actor(Base, BaseActor):
created_at = Column(DateTime(timezone=True), nullable=False, default=now) created_at = Column(DateTime(timezone=True), nullable=False, default=now)
updated_at = Column(DateTime(timezone=True), nullable=False, default=now) updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
ap_id = Column(String, unique=True, nullable=False, index=True) ap_id: Mapped[str] = Column(String, unique=True, nullable=False, index=True)
ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False) ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False)
ap_type = Column(String, nullable=False) ap_type = Column(String, nullable=False)
@ -75,7 +75,7 @@ class InboxObject(Base, BaseObject):
ap_actor_id = Column(String, nullable=False) ap_actor_id = Column(String, nullable=False)
ap_type = Column(String, nullable=False, 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_context = Column(String, nullable=True)
ap_published_at = Column(DateTime(timezone=True), nullable=False) ap_published_at = Column(DateTime(timezone=True), nullable=False)
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False) ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
@ -126,7 +126,7 @@ class InboxObject(Base, BaseObject):
is_deleted = Column(Boolean, nullable=False, default=False) is_deleted = Column(Boolean, nullable=False, default=False)
is_transient = Column(Boolean, nullable=False, default=False, server_default="0") is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
replies_count = Column(Integer, nullable=False, default=0) replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True) og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
@ -160,7 +160,7 @@ class OutboxObject(Base, BaseObject):
public_id = Column(String, nullable=False, index=True) public_id = Column(String, nullable=False, index=True)
ap_type = Column(String, nullable=False, 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_context = Column(String, nullable=True)
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False) ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
@ -176,7 +176,7 @@ class OutboxObject(Base, BaseObject):
likes_count = Column(Integer, nullable=False, default=0) likes_count = Column(Integer, nullable=False, default=0)
announces_count = Column(Integer, nullable=False, default=0) announces_count = Column(Integer, nullable=False, default=0)
replies_count = Column(Integer, nullable=False, default=0) replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
webmentions_count: Mapped[int] = Column( webmentions_count: Mapped[int] = Column(
Integer, nullable=False, default=0, server_default="0" Integer, nullable=False, default=0, server_default="0"
) )
@ -537,6 +537,8 @@ class NotificationType(str, enum.Enum):
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted" FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
FOLLOW_REQUEST_REJECTED = "follow_request_rejected" FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
MOVE = "move"
LIKE = "like" LIKE = "like"
UNDO_LIKE = "undo_like" UNDO_LIKE = "undo_like"
@ -549,6 +551,9 @@ class NotificationType(str, enum.Enum):
UPDATED_WEBMENTION = "updated_webmention" UPDATED_WEBMENTION = "updated_webmention"
DELETED_WEBMENTION = "deleted_webmention" DELETED_WEBMENTION = "deleted_webmention"
BLOCKED = "blocked"
UNBLOCKED = "unblocked"
class Notification(Base): class Notification(Base):
__tablename__ = "notifications" __tablename__ = "notifications"

View File

@ -67,6 +67,7 @@ async def _send_actor_update_if_needed(
logger.info("Will send an Update for the local actor") logger.info("Will send an Update for the local actor")
from app.boxes import allocate_outbox_id from app.boxes import allocate_outbox_id
from app.boxes import compute_all_known_recipients
from app.boxes import outbox_object_id from app.boxes import outbox_object_id
from app.boxes import save_outbox_object from app.boxes import save_outbox_object
@ -85,24 +86,8 @@ async def _send_actor_update_if_needed(
# Send the update to the followers collection and all the actor we have ever # Send the update to the followers collection and all the actor we have ever
# contacted # contacted
followers = ( recipients = await compute_all_known_recipients(db_session)
( for rcp in recipients:
await db_session.scalars(
select(models.Follower).options(joinedload(models.Follower.actor))
)
)
.unique()
.all()
)
for rcp in {
follower.actor.shared_inbox_url or follower.actor.inbox_url
for follower in followers
} | {
row.recipient
for row in await db_session.execute(
select(func.distinct(models.OutgoingActivity.recipient).label("recipient"))
)
}: # type: ignore
await new_outgoing_activity( await new_outgoing_activity(
db_session, db_session,
recipient=rcp, recipient=rcp,

View File

@ -13,6 +13,40 @@ $code-highlight-background: #f0f0f0;
// Load custom theme // Load custom theme
@import "theme.scss"; @import "theme.scss";
.primary-color {
color: $primary-color;
}
#admin {
.admin-menu {
margin-bottom: 30px;
padding: 0 20px;
}
}
.empty-state {
padding: 20px;
}
.public-top-menu {
margin: 30px 0 0 0;
}
.width-95 {
width: 95%;
}
.bold {
font-weight: bold;
}
.admin-new {
textarea {
font-size: 1.2em;
width: 95%;
}
}
.show-more-wrapper { .show-more-wrapper {
.p-summary { .p-summary {
display: inline-block; display: inline-block;
@ -61,13 +95,6 @@ blockquote {
color: $muted-color; color: $muted-color;
} }
.poll-bar {
width:100%;height:20px;
line {
stroke: $secondary-color;
}
}
.light-background { .light-background {
background: $light-background; background: $light-background;
} }
@ -116,6 +143,9 @@ dl {
strong { strong {
color: $primary-color; color: $primary-color;
} }
span {
color: $muted-color;
}
} }
div.highlight { div.highlight {
@ -182,6 +212,7 @@ a {
} }
} }
#main { #main {
display: flex;
flex: 1; flex: 1;
} }
main { main {
@ -189,11 +220,36 @@ main {
max-width: 1000px; max-width: 1000px;
margin: 30px auto; margin: 30px auto;
} }
.main-flex {
display: flex;
flex: 1;
}
.centered {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
div {
display: block;
}
}
footer { footer {
width: 100%; width: 100%;
max-width: 1000px; max-width: 1000px;
margin: 20px auto; margin: 20px auto;
color: $muted-color; color: $muted-color;
p {
margin: 0;
}
}
.tiny-actor-icon {
max-width: 24px;
max-height: 24px;
position: relative;
top: 5px;
} }
.actor-box { .actor-box {
display: flex; display: flex;
@ -217,6 +273,9 @@ footer {
padding: 0 20px; padding: 0 20px;
li { li {
display: block; display: block;
span {
padding-right:10px;
}
} }
} }
@ -251,6 +310,57 @@ footer {
margin: 20px 0; margin: 20px 0;
} }
.show-hide-sensitive-btn {
display:inline-block;
}
.no-margin-top {
margin-top: 0;
}
.float-right {
float: right;
}
ul.poll-items {
list-style-type: none;
padding: 0;
li {
display: block;
p {
margin: 20px 0 10px 0;
.poll-vote {
padding-left: 20px;
}
}
.poll-bar {
width:100%;height:20px;
line {
stroke: $secondary-color;
stroke-width: 20px;
}
}
}
}
.attachment-wrapper {
.attachment-item {
margin-top: 20px;
}
img.attachment {
margin: 0;
}
a.attachment {
display: inline-block;
margin-bottom: 15px;
}
audio.attachment {
width: 480px;
}
}
nav { nav {
form { form {
margin: 15px 0; margin: 15px 0;
@ -278,7 +388,7 @@ nav.flexbox {
margin-right: 0px; margin-right: 0px;
} }
} }
a { a:not(.label-btn) {
color: $primary-color; color: $primary-color;
text-decoration: none; text-decoration: none;
&:hover, &:active { &:hover, &:active {
@ -286,32 +396,38 @@ nav.flexbox {
text-decoration: underline; text-decoration: underline;
} }
} }
a.active { a.active:not(.label-btn) {
color: $secondary-color; color: $secondary-color;
font-weight: bold; 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 { .ap-object {
margin: 15px 0; margin: 15px 0;
padding: 20px; padding: 20px;
.in-reply-to {
color: $muted-color;
&:hover {
color: $secondary-color;
text-decoration: underline;
}
}
nav { nav {
color: $muted-color; color: $muted-color;
} }
.in-reply-to {
display: inline;
color: $muted-color;
}
.e-content, .activity-og-meta { .e-content, .activity-og-meta {
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
.activity-attachment { .activity-attachment {
margin: 30px 0; margin: 30px 0 20px 0;
img, audio, video { img, audio, video {
width: 100%; width: 100%;
max-width: 740px; max-width: 740px;
@ -322,6 +438,20 @@ nav.flexbox {
max-width: 740px; max-width: 740px;
} }
} }
.activity-og-meta {
display: flex;
column-gap: 20px;
margin: 20px 0;
img {
max-width: 200px;
max-height: 100px;
}
small {
display: block;
}
}
.ap-object-expanded { .ap-object-expanded {
border: 2px dashed $secondary-color; border: 2px dashed $secondary-color;
} }
@ -344,3 +474,60 @@ nav.flexbox {
.emoji, .custom-emoji { .emoji, .custom-emoji {
max-width: 25px; max-width: 25px;
} }
.indieauth-box {
display: flex;
column-gap: 20px;
.indieauth-logo {
flex: initial;
width: 100px;
img {
max-width: 100px;
}
}
.indieauth-details {
flex: 1;
div {
padding-left: 20px;
a {
font-size: 1.2em;
font-weight: 600;
}
}
}
}
.public-interactions {
display: flex;
column-gap: 20px;
flex-wrap: wrap;
margin-top: 20px;
.interactions-block {
flex: 0 1 30%;
max-width: 50%;
.facepile-wrapper {
display: flex;
column-gap: 20px;
row-gap: 20px;
flex-wrap: wrap;
margin-top: 20px;
a {
height: 50px;
img {
max-width: 50px;
}
}
.and-x-more {
display: inline-block;
align-self: center;
}
}
}
}
.error-title {
a {
text-decoration: underline;
}
}

View File

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

@ -0,0 +1,11 @@
document.addEventListener('DOMContentLoaded', (ev) => {
// Add confirm to "delete" button next to outbox objects
var forms = document.getElementsByClassName("object-delete-form")
for (var i = 0; i < forms.length; i++) {
forms[i].addEventListener('submit', (ev) => {
if (!confirm('Do you really want to delete this object?')) {
ev.preventDefault();
};
});
}
});

View File

@ -26,7 +26,7 @@ from app.actor import LOCAL_ACTOR
from app.ap_object import Attachment from app.ap_object import Attachment
from app.ap_object import Object from app.ap_object import Object
from app.config import BASE_URL from app.config import BASE_URL
from app.config import CSS_HASH from app.config import CUSTOM_FOOTER
from app.config import DEBUG from app.config import DEBUG
from app.config import VERSION from app.config import VERSION
from app.config import generate_csrf_token from app.config import generate_csrf_token
@ -90,6 +90,7 @@ async def render_template(
request: Request, request: Request,
template: str, template: str,
template_args: dict[str, Any] | None = None, template_args: dict[str, Any] | None = None,
status_code: int = 200,
) -> TemplateResponse: ) -> TemplateResponse:
if template_args is None: if template_args is None:
template_args = {} template_args = {}
@ -103,7 +104,6 @@ async def render_template(
"request": request, "request": request,
"debug": DEBUG, "debug": DEBUG,
"microblogpub_version": VERSION, "microblogpub_version": VERSION,
"css_hash": CSS_HASH,
"is_admin": is_admin, "is_admin": is_admin,
"csrf_token": generate_csrf_token(), "csrf_token": generate_csrf_token(),
"highlight_css": HIGHLIGHT_CSS, "highlight_css": HIGHLIGHT_CSS,
@ -131,8 +131,10 @@ async def render_template(
select(func.count(models.Following.id)) select(func.count(models.Following.id))
), ),
"actor_types": ap.ACTOR_TYPES, "actor_types": ap.ACTOR_TYPES,
"custom_footer": CUSTOM_FOOTER,
**template_args, **template_args,
}, },
status_code=status_code,
) )
@ -377,7 +379,7 @@ def _html2text(content: str) -> str:
def _replace_emoji(u: str, _) -> str: def _replace_emoji(u: str, _) -> str:
filename = hex(ord(u))[2:] filename = "-".join(hex(ord(c))[2:] for c in u)
return config.EMOJI_TPL.format(filename=filename, raw=u) return config.EMOJI_TPL.format(filename=filename, raw=u)
@ -414,3 +416,8 @@ _templates.env.filters["pluralize"] = _pluralize
_templates.env.filters["parse_datetime"] = _parse_datetime _templates.env.filters["parse_datetime"] = _parse_datetime
_templates.env.filters["poll_item_pct"] = _poll_item_pct _templates.env.filters["poll_item_pct"] = _poll_item_pct
_templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url _templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url
_templates.env.globals["JS_HASH"] = config.JS_HASH
_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

View File

@ -10,7 +10,9 @@
{% for anybox_object, convo, actors in threads %} {% for anybox_object, convo, actors in threads %}
<div class="actor-action"> <div class="actor-action">
With {% for actor in actors %} With {% for actor in actors %}
<a href="">{{ actor.handle }}</a> <a href="{{ url_for("admin_profile") }}?actor_id={{ actor.ap_id }}">
{{ actor.handle }}
</a>
{% endfor %} {% endfor %}
</div> </div>
{{ utils.display_object(anybox_object) }} {{ utils.display_object(anybox_object) }}

View File

@ -19,7 +19,7 @@
{% for inbox_object in inbox %} {% for inbox_object in inbox %}
{% if inbox_object.ap_type == "Announce" %} {% if inbox_object.ap_type == "Announce" %}
{{ utils.actor_action(inbox_object, "shared") }} {{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }} {{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %} {% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
{{ utils.display_object(inbox_object) }} {{ utils.display_object(inbox_object) }}
@ -27,7 +27,7 @@
{{ utils.actor_action(inbox_object, "followed you") }} {{ utils.actor_action(inbox_object, "followed you") }}
{{ utils.display_actor(inbox_object.actor, actors_metadata) }} {{ utils.display_actor(inbox_object.actor, actors_metadata) }}
{% elif inbox_object.ap_type == "Like" %} {% elif inbox_object.ap_type == "Like" %}
{{ utils.actor_action(inbox_object, "liked one of your post") }} {{ utils.actor_action(inbox_object, "liked one of your posts", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }} {{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% else %} {% else %}
<p> <p>

View File

@ -25,7 +25,7 @@
</nav> </nav>
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST"> <form class="form admin-new" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
{{ utils.embed_csrf_token() }} {{ utils.embed_csrf_token() }}
{{ utils.embed_redirect_url() }} {{ utils.embed_redirect_url() }}
<p> <p>
@ -38,7 +38,7 @@
{% if request.query_params.type == "Article" %} {% if request.query_params.type == "Article" %}
<p> <p>
<input type="text" style="width:95%" name="name" placeholder="Title"> <input type="text" class="width-95" name="name" placeholder="Title">
</p> </p>
{% endif %} {% endif %}
@ -49,7 +49,7 @@
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span> <span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
{% endfor %} {% endfor %}
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea> <textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!">{{ content }}</textarea>
{% if request.query_params.type == "Question" %} {% if request.query_params.type == "Question" %}
<p> <p>
@ -69,20 +69,20 @@
</p> </p>
{% for i in ["1", "2", "3", "4"] %} {% for i in ["1", "2", "3", "4"] %}
<p> <p>
<input type="text" name="poll_answer_{{ i }}" style="width:95%;" placeholder="Option {{ i }}, leave empty to disable"> <input type="text" name="poll_answer_{{ i }}" class="width-95" placeholder="Option {{ i }}, leave empty to disable">
</p> </p>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<p> <p>
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)"{% if content_warning %} value="{{ content_warning }}"{% endif %} style="width:95%;"> <input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)"{% if content_warning %} value="{{ content_warning }}"{% endif %} class="width-95">
</p> </p>
<p> <p>
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label> <input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
</p> </p>
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}"> <input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
<p> <p>
<input id="files" name="files" type="file" multiple style="width:95%;"> <input id="files" name="files" type="file" class="width-95" multiple>
</p> </p>
<div id="alts"></div> <div id="alts"></div>
<p> <p>
@ -90,5 +90,5 @@
</p> </p>
</form> </form>
</div> </div>
<script src="/static/new.js"></script> <script src="/static/new.js?v={{ JS_HASH }}"></script>
{% endblock %} {% endblock %}

View File

@ -12,18 +12,16 @@
{% for outbox_object in outbox %} {% for outbox_object in outbox %}
{% if outbox_object.ap_type == "Announce" %} {% 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) }} {{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% elif outbox_object.ap_type == "Like" %} {% 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) }} {{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% elif outbox_object.ap_type == "Follow" %} {% 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) }} {{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %} {% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }} {{ utils.display_object(outbox_object) }}
{% else %}
Implement {{ outbox_object.ap_type }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -9,10 +9,21 @@
{{ utils.display_actor(actor, actors_metadata, with_details=True) }} {{ utils.display_actor(actor, actors_metadata, with_details=True) }}
{% for inbox_object in inbox_objects %} {% for inbox_object in inbox_objects %}
{% if inbox_object.ap_type == "Announce" %} {% if inbox_object.ap_type == "Announce" %}
{{ utils.actor_action(inbox_object, "shared") }} {{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }} {{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% else %} {% else %}
{{ utils.display_object(inbox_object) }} {{ utils.display_object(inbox_object) }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if next_cursor %}
<div class="box">
<p>
<a href="{{ request.url._path }}?actor_id={{ request.query_params.actor_id }}&cursor={{ next_cursor }}">
See more
</a>
</p>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -12,7 +12,7 @@
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data> <data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
{% for outbox_object in objects %} {% for outbox_object in objects %}
<li> <li>
<span class="muted" style="padding-right:10px;">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</span> <a href="{{ outbox_object.url }}">{{ outbox_object.name }}</a> <span class="muted">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</span> <a href="{{ outbox_object.url }}">{{ outbox_object.name }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

12
app/templates/error.html Normal file
View File

@ -0,0 +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 box">
<h1 class="error-title">{{ title | safe }}</h1>
</div>
{% endblock %}

View File

@ -29,15 +29,19 @@
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a> <a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %} {% endmacro %}
<div style="margin:30px 0 0 0;"> <div class="public-top-menu">
<nav class="flexbox"> <nav class="flexbox">
<ul> <ul>
<li>{{ header_link("index", "Notes") }}</li> <li>{{ header_link("index", "Notes") }}</li>
{% if articles_count %} {% if articles_count %}
<li>{{ header_link("articles", "Articles") }}</li> <li>{{ header_link("articles", "Articles") }}</li>
{% endif %} {% endif %}
{% if not HIDES_FOLLOWERS or is_admin %}
<li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li> <li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li>
{% endif %}
{% if not HIDES_FOLLOWING or is_admin %}
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li> <li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
{% endif %}
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li> <li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
</ul> </ul>
</nav> </nav>

View File

@ -21,19 +21,21 @@
{% block content %} {% block content %}
{% include "header.html" %} {% include "header.html" %}
<div class="h-feed"> {% if objects %}
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
{% for outbox_object in objects %}
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }}
{% elif outbox_object.ap_type == "Announce" %}
<div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% endif %}
{% endfor %}
</div>
<div class="box"> <div class="h-feed">
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
{% for outbox_object in objects %}
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }}
{% elif outbox_object.ap_type == "Announce" %}
<div class="shared-header"><strong>{{ utils.display_tiny_actor_icon(local_actor) }} {{ local_actor.display_name | clean_html(local_actor) | safe }}</strong> 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) }}
{% endif %}
{% endfor %}
</div>
<div class="box">
{% if has_previous_page %} {% if has_previous_page %}
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a> <a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
{% endif %} {% endif %}
@ -41,6 +43,12 @@
{% if has_next_page %} {% if has_next_page %}
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a> <a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
{% endif %} {% endif %}
</div> </div>
{% else %}
<div class="empty-state">
<p>Nothing to see here yet!</p>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -2,15 +2,15 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="box"> <div class="box">
<div style="display:flex;column-gap: 20px;"> <div class"indieauth-box">
{% if client.logo %} {% if client.logo %}
<div style="flex:initial;width:100px;"> <div class="indieauth-logo">
<img src="{{client.logo | media_proxy_url }}" style="max-width:100px;" alt="{{ client.name }} logo"> <img src="{{client.logo | media_proxy_url }}" alt="{{ client.name }} logo">
</div> </div>
{% endif %} {% endif %}
<div style="flex:1;"> <div class="indieauth-details">
<div style="padding-left: 20px;"> <div>
<a class="lcolor" style="font-size:1.2em;font-weight:600;" href="{{ client.url }}">{{ client.name }}</a> <a class="lcolor" href="{{ client.url }}">{{ client.name }}</a>
<p>wants you to login as <strong class="lcolor">{{ me }}</strong> with the following redirect URI: <code>{{ redirect_uri }}</code>.</p> <p>wants you to login as <strong class="lcolor">{{ me }}</strong> with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>

View File

@ -4,26 +4,24 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <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="/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" 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("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="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="/static/favicon.ico">
<style> <style>{{ highlight_css }}</style>
{{ highlight_css }}
</style>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<div id="main"> <div id="main">
<main> <main{%- block main_tag %}{%- endblock %}>
{% if is_admin %} {% if is_admin %}
<div id="admin"> <div id="admin">
{% macro admin_link(url, text) %} {% macro admin_link(url, text) %}
{% set url_for = request.app.router.url_path_for(url) %} {% 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> <a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %} {% endmacro %}
<div style="margin-bottom:30px;padding: 0 20px;"> <div class="admin-menu">
<nav class="flexbox"> <nav class="flexbox">
<ul> <ul>
<li>{{ admin_link("index", "Public") }}</li> <li>{{ admin_link("index", "Public") }}</li>
@ -47,8 +45,15 @@
<footer class="footer"> <footer class="footer">
<div class="box"> <div class="box">
{% if custom_footer %}
{{ custom_footer | safe }}
{% else %}
Powered by <a href="https://docs.microblog.pub">microblog.pub</a> <small class="microblogpub-version"><code>{{ microblogpub_version }}</code></small> and the <a href="https://activitypub.rocks/">ActivityPub</a> protocol. <a href="{{ url_for("login") }}">Admin</a>. Powered by <a href="https://docs.microblog.pub">microblog.pub</a> <small class="microblogpub-version"><code>{{ microblogpub_version }}</code></small> and the <a href="https://activitypub.rocks/">ActivityPub</a> protocol. <a href="{{ url_for("login") }}">Admin</a>.
{% endif %}
</div> </div>
</footer> </footer>
{% if is_admin %}
<script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
{% endif %}
</body> </body>
</html> </html>

View File

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

View File

@ -19,7 +19,9 @@
{% if error %} {% if error %}
<div class="box error-box"> <div class="box error-box">
{% if error.value == "NOT_FOUND" %} {% if error.value == "NOT_FOUND" %}
<p>The remote object was deleted.</p> <p>The remote object is unavailable.</p>
{% elif error.value == "UNAUTHORIZED" %}
<p>Missing permissions to fetch the remote object.</p>
{% elif error.value == "TIMEOUT" %} {% elif error.value == "TIMEOUT" %}
<p>Lookup timed out, please try refreshing the page.</p> <p>Lookup timed out, please try refreshing the page.</p>
{% else %} {% else %}

View File

@ -5,9 +5,10 @@
<title>{{ local_actor.display_name }} - Notifications</title> <title>{{ local_actor.display_name }} - Notifications</title>
{% endblock %} {% endblock %}
{% macro notif_actor_action(notif, text) %} {% macro notif_actor_action(notif, text, with_icon=False) %}
<div class="actor-action"> <div class="actor-action">
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">{{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }} <a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">
{% if with_icon %}{{ utils.display_tiny_actor_icon(notif.actor) }}{% endif %} {{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span> <span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
</div> </div>
{% endmacro %} {% endmacro %}
@ -35,18 +36,32 @@
{%- elif notif.notification_type.value == "follow_request_rejected" %} {%- elif notif.notification_type.value == "follow_request_rejected" %}
{{ notif_actor_action(notif, "rejected your follow request") }} {{ notif_actor_action(notif, "rejected your follow request") }}
{{ utils.display_actor(notif.actor, actors_metadata) }} {{ 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 == "move" %}
{# for move notif, the actor is the target and the inbox object the Move activity #}
<div class="actor-action">
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.inbox_object.actor.ap_id }}">
{{ utils.display_tiny_actor_icon(notif.inbox_object.actor) }} {{ notif.inbox_object.actor.display_name | clean_html(notif.inbox_object.actor) | safe }}</a> has moved to
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
</div>
{{ utils.display_actor(notif.actor) }}
{% elif notif.notification_type.value == "like" %} {% elif notif.notification_type.value == "like" %}
{{ notif_actor_action(notif, "liked a post") }} {{ notif_actor_action(notif, "liked a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "undo_like" %} {% elif notif.notification_type.value == "undo_like" %}
{{ notif_actor_action(notif, "unliked a post") }} {{ notif_actor_action(notif, "unliked a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "announce" %} {% elif notif.notification_type.value == "announce" %}
{{ notif_actor_action(notif, "shared a post") }} {{ notif_actor_action(notif, "shared a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "undo_announce" %} {% elif notif.notification_type.value == "undo_announce" %}
{{ notif_actor_action(notif, "unshared a post") }} {{ notif_actor_action(notif, "unshared a post") }}
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object, with_icon=True) }}
{% elif notif.notification_type.value == "mention" %} {% elif notif.notification_type.value == "mention" %}
{{ notif_actor_action(notif, "mentioned you") }} {{ notif_actor_action(notif, "mentioned you") }}
{{ utils.display_object(notif.inbox_object) }} {{ utils.display_object(notif.inbox_object) }}
@ -57,7 +72,7 @@
{% if facepile_item %} {% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a> <a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% endif %} {% endif %}
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a> <a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
</div> </div>
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "updated_webmention" %} {% elif notif.notification_type.value == "updated_webmention" %}
@ -67,7 +82,7 @@
{% if facepile_item %} {% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a> <a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% endif %} {% endif %}
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a> <a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
</div> </div>
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "deleted_webmention" %} {% elif notif.notification_type.value == "deleted_webmention" %}
@ -77,7 +92,7 @@
{% if facepile_item %} {% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a> <a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% endif %} {% endif %}
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a> <a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
</div> </div>
{{ utils.display_object(notif.outbox_object) }} {{ utils.display_object(notif.outbox_object) }}
{% else %} {% else %}
@ -88,4 +103,15 @@
</div> </div>
{%- endfor %} {%- endfor %}
</div> </div>
{% if next_cursor %}
<div class="box">
<p>
<a href="{{ request.url._path }}?cursor={{ next_cursor }}">
See more{% if more_unread_count %} ({{ more_unread_count }} unread left){% endif %}
</a>
</p>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -3,7 +3,11 @@
{% block head %} {% block head %}
{% if outbox_object %} {% if outbox_object %}
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %} {% if outbox_object.content %}
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %}
{% else %}
{% set excerpt = outbox_object.summary | html2text | trim | truncate(50) %}
{% endif %}
<title>{% if outbox_object.name %}{{ outbox_object.name }}{% else %}{{ local_actor.display_name }}: "{{ excerpt }}"{% endif %}</title> <title>{% if outbox_object.name %}{{ outbox_object.name }}{% else %}{{ local_actor.display_name }}: "{{ excerpt }}"{% endif %}</title>
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}"> <link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
<link rel="alternate" href="{{ request.url }}" type="application/activity+json"> <link rel="alternate" href="{{ request.url }}" type="application/activity+json">

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

@ -96,11 +96,11 @@
</form> </form>
{% endmacro %} {% endmacro %}
{% macro admin_delete_button(ap_object_id) %} {% macro admin_delete_button(ap_object) %}
<form action="{{ request.url_for("admin_actions_delete") }}" method="POST" onsubmit="return confirm('Do you really want to delete this object?');"> <form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url() }} <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_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
<input type="submit" value="delete"> <input type="submit" value="delete">
</form> </form>
{% endmacro %} {% endmacro %}
@ -154,9 +154,10 @@
</form> </form>
{% endmacro %} {% endmacro %}
{% macro admin_expand_button(ap_object_id) %} {% macro admin_expand_button(ap_object) %}
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
<form action="{{ url_for("admin_object") }}" method="GET"> <form action="{{ url_for("admin_object") }}" method="GET">
<input type="hidden" name="ap_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
<button type="submit">expand</button> <button type="submit">expand</button>
</form> </form>
{% endmacro %} {% endmacro %}
@ -180,9 +181,15 @@
</nav> </nav>
{% endmacro %} {% endmacro %}
{% macro actor_action(inbox_object, text) %} {% macro display_tiny_actor_icon(actor) %}
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
{% endmacro %}
{% macro actor_action(inbox_object, text, with_icon=False) %}
<div class="actor-action"> <div class="actor-action">
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">{{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}</a> {{ text }} <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 }}
</a> {{ text }}
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span> <span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
</div> </div>
@ -199,7 +206,7 @@
<div class="icon-box"> <div class="icon-box">
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo"> <img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
</div> </div>
<a href="{{ actor.url }}" class="u-url" style=""> <a href="{{ actor.url }}" class="u-url">
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div> <div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
<div class="actor-handle p-name">{{ actor.handle }}</div> <div class="actor-handle p-name">{{ actor.handle }}</div>
</a> </a>
@ -209,14 +216,26 @@
<div> <div>
<nav class="flexbox actor-metadata"> <nav class="flexbox actor-metadata">
<ul> <ul>
{% if metadata.has_blocked_local_actor %}
<li>blocked you</li>
{% endif %}
{% if metadata.is_following %} {% if metadata.is_following %}
<li>already following</li> <li>already following</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li> <li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
{% if not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li> <li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
{% elif metadata.is_follow_request_sent %} {% elif metadata.is_follow_request_sent %}
{% if metadata.is_follow_request_rejected %}
<li>follow request rejected</li>
{% if not metadata.has_blocked_local_actor %}
<li>{{ admin_follow_button(actor) }}</li>
{% endif %}
{% else %}
<li>follow request sent</li> <li>follow request sent</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li> <li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
{% else %} {% endif %}
{% elif not actor.moved_to %}
<li>{{ admin_follow_button(actor) }}</li> <li>{{ admin_follow_button(actor) }}</li>
{% endif %} {% endif %}
{% if metadata.is_follower %} {% if metadata.is_follower %}
@ -224,7 +243,11 @@
{% if not metadata.is_following and not with_details %} {% if not metadata.is_following and not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li> <li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %} {% endif %}
</li> {% 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 %}
<li>has moved to {% if metadata.moved_to %}<a href="{{ url_for("admin_profile") }}?actor_id={{ actor.moved_to }}">{{ metadata.moved_to.handle }}</a>{% else %}<a href="{{ url_for("get_lookup") }}?query={{ actor.moved_to }}">{{ actor.moved_to }}</a>{% endif %}</li>
{% endif %} {% endif %}
{% if actor.is_from_db %} {% if actor.is_from_db %}
{% if actor.is_blocked %} {% if actor.is_blocked %}
@ -250,6 +273,9 @@
<li>rejected</li> <li>rejected</li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if with_details %}
<li><a href="{{ actor.url }}" class="label-btn">remote profile</a></li>
{% endif %}
</ul> </ul>
</nav> </nav>
</div> </div>
@ -284,17 +310,17 @@
{% macro display_og_meta(object) %} {% macro display_og_meta(object) %}
{% if object.og_meta %} {% if object.og_meta %}
{% for og_meta in object.og_meta %} {% for og_meta in object.og_meta[:1] %}
<div class="activity-og-meta" style="display:flex;column-gap: 20px;margin:20px 0;"> <div class="activity-og-meta">
{% if og_meta.image %} {% if og_meta.image %}
<div> <div>
<img src="{{ og_meta.image | media_proxy_url }}" style="max-width:200px;max-height:100px;"> <img src="{{ og_meta.image | media_proxy_url }}">
</div> </div>
{% endif %} {% endif %}
<div> <div>
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a> <a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
{% if og_meta.site_name %} {% if og_meta.site_name %}
<small style="display:block;">{{ og_meta.site_name }}</small> <small>{{ og_meta.site_name }}</small>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -307,31 +333,33 @@
{% for attachment in object.attachments %} {% for attachment in object.attachments %}
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %} {% 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 class="attachment-wrapper">
<label for="{{attachment.proxied_url}}" class="label-btn" style="display:inline-block;">show/hide sensitive content</label> <label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
<div> <div>
<div class="sensitive-attachment"> <div class="sensitive-attachment">
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true"> <input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
<div class="sensitive-attachment-box"> <div class="sensitive-attachment-box">
<div></div> <div></div>
{% else %} {% else %}
<div style="margin-top:20px;"> <div class="attachment-item">
{% endif %} {% endif %}
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %} {% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
{% if attachment.url not in object.inlined_images %} {% if attachment.url not in object.inlined_images %}
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment" style="margin:0;"> <img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment">
{% endif %} {% endif %}
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %} {% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video> <video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %} {% 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 %} style="width:480px;" class="attachment"></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" %} {% elif attachment.type == "Link" %}
<a href="{{ attachment.url }}" class="attachment" style="display:inline-block;margin-bottom: 15px;">{{ attachment.url }}</a> <a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
{% else %} {% 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 %} {% 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> </div>
</div> </div>
@ -358,13 +386,13 @@
{% endif %} {% endif %}
{% if object.in_reply_to %} {% if object.in_reply_to %}
<a href="{% if is_admin %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow"> <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">
in reply to {{ object.in_reply_to|truncate(64, True) }} this {{ object.ap_type|lower }}
</a> </a></p>
{% endif %} {% endif %}
{% if object.ap_type == "Article" %} {% if object.ap_type == "Article" %}
<h2 class="p-name" style="margin-top:0;">{{ object.name }}</h2> <h2 class="p-name no-margin-top">{{ object.name }}</h2>
{% endif %} {% endif %}
{% if is_article_mode %} {% if is_article_mode %}
@ -394,11 +422,11 @@
{% endif %} {% endif %}
{% if object.poll_items %} {% if object.poll_items %}
<ul style="list-style-type: none;padding:0;"> <ul class="poll-items">
{% for item in object.poll_items %} {% for item in object.poll_items %}
<li style="display:block;"> <li>
{% set pct = item | poll_item_pct(object.poll_voters_count) %} {% set pct = item | poll_item_pct(object.poll_voters_count) %}
<p style="margin:20px 0 10px 0;"> <p>
{% if can_vote %} {% if can_vote %}
<input type="{% if object.is_one_of_poll %}radio{% else %}checkbox{% endif %}" name="name" value="{{ item.name }}" id="{{object.permalink_id}}-{{item.name}}"> <input type="{% if object.is_one_of_poll %}radio{% else %}checkbox{% endif %}" name="name" value="{{ item.name }}" id="{{object.permalink_id}}-{{item.name}}">
<label for="{{object.permalink_id}}-{{item.name}}"> <label for="{{object.permalink_id}}-{{item.name}}">
@ -407,17 +435,17 @@
{{ item.name | clean_html(object) | safe }} {{ item.name | clean_html(object) | safe }}
{% if object.voted_for_answers and item.name in object.voted_for_answers %} {% if object.voted_for_answers and item.name in object.voted_for_answers %}
<span class="muted" style="padding-left:20px;">you voted for this answer</span> <span class="muted poll-vote">you voted for this answer</span>
{% endif %} {% endif %}
{% if can_vote %} {% if can_vote %}
</label> </label>
{% endif %} {% endif %}
<span style="float:right;">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span> <span class="float-right">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
</p> </p>
<svg class="poll-bar"> <svg class="poll-bar">
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px" style="stroke-width: 20px;"></line> <line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px"></line>
</svg> </svg>
</li> </li>
{% endfor %} {% endfor %}
@ -431,16 +459,17 @@
</form> </form>
{% endif %} {% endif %}
{{ display_og_meta(object) }}
{% endif %} {% endif %}
{{ display_og_meta(object) }}
</div> </div>
{% if object.summary %} {% if object.summary %}
</div> </div>
{% endif %} {% endif %}
<div class="activity-attachment" style="margin-bottom:20px;"> <div class="activity-attachment">
{{ display_attachments(object) }} {{ display_attachments(object) }}
</div> </div>
@ -449,6 +478,16 @@
<li> <li>
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div> <div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
</li> </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 %} {% if not is_article_mode %}
<li> <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> <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>
@ -496,7 +535,7 @@
{% if (object.is_from_outbox or is_admin) and object.replies_count %} {% if (object.is_from_outbox or is_admin) and object.replies_count %}
<li> <li>
<a href="{% if is_admin and not object.is_from_outbox %}{{ url_for("admin_object") }}?ap_id={{ object.ap_id }}{% else %}{{ object.url }}{% endif %}"><strong>{{ object.replies_count }}</strong> repl{{ object.replies_count | pluralize("y", "ies") }}</a> <a href="{% if is_admin and not object.is_from_outbox %}{{ url_for("admin_object") }}?ap_id={{ object.ap_id }}{% if object.in_reply_to %}#{{ object.permalink_id }}{% endif %}{% else %}{{ object.url }}{% endif %}"><strong>{{ object.replies_count }}</strong> repl{{ object.replies_count | pluralize("y", "ies") }}</a>
</li> </li>
{% endif %} {% endif %}
@ -508,7 +547,7 @@
<ul> <ul>
{% if object.is_from_outbox %} {% if object.is_from_outbox %}
<li> <li>
{{ admin_delete_button(object.ap_id) }} {{ admin_delete_button(object) }}
</li> </li>
<li> <li>
@ -544,7 +583,7 @@
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %} {% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
<li> <li>
{% if object.announced_via_outbox_object_ap_id %} {% 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 %} {% else %}
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }} {{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
{% endif %} {% endif %}
@ -559,7 +598,7 @@
{% endif %} {% endif %}
{% if object.is_from_inbox or object.is_from_outbox %} {% if object.is_from_inbox or object.is_from_outbox %}
<li> <li>
{{ admin_expand_button(object.ap_id) }} {{ admin_expand_button(object) }}
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -568,17 +607,17 @@
{% if likes or shares or webmentions %} {% if likes or shares or webmentions %}
<div style="display: flex;column-gap: 20px;flex-wrap: wrap;margin-top:20px;"> <div class="public-interactions">
{% if likes %} {% if likes %}
<div style="flex: 0 1 30%;max-width: 50%;">Likes <div class="interactions-block">Likes
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;"> <div class="facepile-wrapper">
{% for like in likes %} {% for like in likes %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" style="height:50px;" rel="noreferrer"> <a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" rel="noreferrer">
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}" style="max-width:50px;"> <img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}">
</a> </a>
{% endfor %} {% endfor %}
{% if object.likes_count > likes | length %} {% if object.likes_count > likes | length %}
<div style="display:inline-block;align-self:center;"> <div class="and-x-more">
and {{ object.likes_count - likes | length }} more. and {{ object.likes_count - likes | length }} more.
</div> </div>
{% endif %} {% endif %}
@ -587,15 +626,15 @@
{% endif %} {% endif %}
{% if shares %} {% if shares %}
<div style="flex: 0 1 30%;max-width: 50%;">Shares <div class="interactions-block">Shares
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;"> <div class="facepile-wrapper">
{% for share in shares %} {% for share in shares %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" style="height:50px;" rel="noreferrer"> <a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" rel="noreferrer">
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}" style="max-width:50px;"> <img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}">
</a> </a>
{% endfor %} {% endfor %}
{% if object.announces_count > shares | length %} {% if object.announces_count > shares | length %}
<div style="display:inline-block;align-self:center;"> <div class="and-x-more">
and {{ object.announces_count - shares | length }} more. and {{ object.announces_count - shares | length }} more.
</div> </div>
{% endif %} {% endif %}
@ -604,13 +643,13 @@
{% endif %} {% endif %}
{% if webmentions %} {% if webmentions %}
<div style="flex: 0 1 30%;max-width: 50%;">Webmentions <div class="interactions-block">Webmentions
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;"> <div class="facepile-wrapper">
{% for webmention in webmentions %} {% for webmention in webmentions %}
{% set wm = webmention.as_facepile_item %} {% set wm = webmention.as_facepile_item %}
{% if wm %} {% if wm %}
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" style="height:50px;" rel="noreferrer"> <a href="{{ wm.url }}" title="{{ wm.actor_name }}" rel="noreferrer">
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}" style="max-width:50px;"> <img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}">
</a> </a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -5,6 +5,7 @@ import blurhash # type: ignore
from fastapi import UploadFile from fastapi import UploadFile
from loguru import logger from loguru import logger
from PIL import Image from PIL import Image
from PIL import ImageOps
from sqlalchemy import select from sqlalchemy import select
from app import activitypub as ap from app import activitypub as ap
@ -45,11 +46,13 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
width = None width = None
height = None height = None
if f.content_type.startswith("image"): if f.content_type.startswith("image") and not f.content_type == "image/gif":
image_blurhash = blurhash.encode(f.file, x_components=4, y_components=3) with Image.open(f.file) as _original_image:
f.file.seek(0) # Fix image orientation (as we will remove the info from the EXIF
# metadata)
original_image = ImageOps.exif_transpose(_original_image)
with Image.open(f.file) as original_image: # Re-creating the image drop the EXIF metadata
destination_image = Image.new( destination_image = Image.new(
original_image.mode, original_image.mode,
original_image.size, original_image.size,
@ -57,15 +60,18 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
destination_image.putdata(original_image.getdata()) destination_image.putdata(original_image.getdata())
destination_image.save( destination_image.save(
dest_filename, dest_filename,
format=original_image.format, format=_original_image.format,
) )
with open(dest_filename, "rb") as dest_f:
image_blurhash = blurhash.encode(dest_f, x_components=4, y_components=3)
try: try:
width, height = original_image.size width, height = destination_image.size
original_image.thumbnail((740, 740)) destination_image.thumbnail((740, 740))
original_image.save( destination_image.save(
UPLOAD_DIR / f"{content_hash}_resized", UPLOAD_DIR / f"{content_hash}_resized",
format=original_image.format, format="webp",
) )
except Exception: except Exception:
logger.exception( logger.exception(

View File

@ -1,3 +1,5 @@
import base64
import hashlib
from functools import lru_cache from functools import lru_cache
from bs4 import BeautifulSoup # type: ignore from bs4 import BeautifulSoup # type: ignore
@ -11,6 +13,9 @@ from app.config import CODE_HIGHLIGHTING_THEME
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME) _FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
HIGHLIGHT_CSS = _FORMATTER.get_style_defs() HIGHLIGHT_CSS = _FORMATTER.get_style_defs()
HIGHLIGHT_CSS_HASH = base64.b64encode(
hashlib.sha256(HIGHLIGHT_CSS.encode()).digest()
).decode()
@lru_cache(256) @lru_cache(256)

View File

@ -1,3 +1,4 @@
import asyncio
import mimetypes import mimetypes
import re import re
from typing import Any from typing import Any
@ -8,6 +9,7 @@ from bs4 import BeautifulSoup # type: ignore
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from app import activitypub as ap
from app import ap_object from app import ap_object
from app import config from app import config
from app.actor import LOCAL_ACTOR from app.actor import LOCAL_ACTOR
@ -36,7 +38,7 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
# FIXME some page have no <title> # FIXME some page have no <title>
raw = { raw = {
"url": url, "url": url,
"title": soup.find("title").text, "title": soup.find("title").text.strip(),
"image": None, "image": None,
"description": None, "description": None,
"site_name": urlparse(url).hostname, "site_name": urlparse(url).hostname,
@ -68,7 +70,12 @@ async def external_urls(
tags_hrefs.add(tag_href) tags_hrefs.add(tag_href)
if tag.get("type") == "Mention": if tag.get("type") == "Mention":
if tag["href"] != LOCAL_ACTOR.ap_id: if tag["href"] != LOCAL_ACTOR.ap_id:
try:
mentioned_actor = await fetch_actor(db_session, tag["href"]) 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.url)
tags_hrefs.add(mentioned_actor.ap_id) tags_hrefs.add(mentioned_actor.ap_id)
else: else:
@ -80,6 +87,10 @@ async def external_urls(
soup = BeautifulSoup(ro.content, "html5lib") soup = BeautifulSoup(ro.content, "html5lib")
for link in soup.find_all("a"): for link in soup.find_all("a"):
h = link.get("href") h = link.get("href")
if not h:
continue
try:
ph = urlparse(h) ph = urlparse(h)
mimetype, _ = mimetypes.guess_type(h) mimetype, _ = mimetypes.guess_type(h)
if ( if (
@ -92,6 +103,9 @@ async def external_urls(
) )
): ):
urls.add(h) urls.add(h)
except Exception:
logger.exception(f"Failed to check {h}")
continue
return urls - tags_hrefs return urls - tags_hrefs
@ -124,9 +138,21 @@ async def og_meta_from_note(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
og_meta = [] og_meta = []
urls = await external_urls(db_session, ro) urls = await external_urls(db_session, ro)
logger.debug(f"Lookig OG metadata in {urls=}")
for url in urls: for url in urls:
logger.debug(f"Processing {url}")
try: try:
maybe_og_meta = await _og_meta_from_url(url) maybe_og_meta = None
try:
maybe_og_meta = await asyncio.wait_for(
_og_meta_from_url(url),
timeout=5,
)
except asyncio.TimeoutError:
logger.info(f"Timing out fetching {url}")
except Exception:
logger.exception(f"Failed scrap OG meta for {url}")
if maybe_og_meta: if maybe_og_meta:
og_meta.append(maybe_og_meta.dict()) og_meta.append(maybe_og_meta.dict())
except httpx.HTTPError: except httpx.HTTPError:

View File

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

12
app/utils/version.py Normal file
View File

@ -0,0 +1,12 @@
import subprocess
def get_version_commit() -> str:
try:
return (
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
.split()[0]
.decode()
)
except Exception:
return "dev"

View File

@ -54,12 +54,17 @@ class Worker(Generic[T]):
{task, stop_task}, return_when=asyncio.FIRST_COMPLETED {task, stop_task}, return_when=asyncio.FIRST_COMPLETED
) )
logger.info(f"Waiting for tasks to finish {done=}/{pending=}") logger.info(f"Waiting for tasks to finish {done=}/{pending=}")
await asyncio.sleep(5)
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
logger.info(f"Cancelling {len(tasks)} tasks") logger.info(f"Cancelling {len(tasks)} tasks")
[task.cancel() for task in tasks] [task.cancel() for task in tasks]
await asyncio.gather(*tasks, return_exceptions=True) try:
await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=15,
)
except asyncio.TimeoutError:
logger.info("Tasks failed to cancel")
logger.info("stopping loop") logger.info("stopping loop")

View File

@ -6,7 +6,6 @@ from typing import Any
import bcrypt import bcrypt
import tomli_w import tomli_w
from markdown import markdown # type: ignore
from app.key import generate_key from app.key import generate_key
@ -44,7 +43,7 @@ def setup_config_file(
dat["username"] = username dat["username"] = username
dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
dat["name"] = name dat["name"] = name
dat["summary"] = markdown(summary) dat["summary"] = summary
dat["https"] = True dat["https"] = True
proto = "https" proto = "https"
dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png' dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png'

View File

@ -29,6 +29,7 @@ async def webfinger(
is_404 = False is_404 = False
resp: httpx.Response | None = None
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
for i, proto in enumerate(protos): for i, proto in enumerate(protos):
try: try:
@ -59,7 +60,10 @@ async def webfinger(
if is_404: if is_404:
return None return None
if resp:
return resp.json() return resp.json()
else:
return None
async def get_remote_follow_template(resource: str) -> str | None: async def get_remote_follow_template(resource: str) -> str | None:

View File

@ -1,6 +1,6 @@
# Developer's guide # 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] [TOC]
@ -10,12 +10,13 @@ Microblog.pub is a "modern" Python application with "old-school" server-rendered
- [Poetry](https://python-poetry.org/) is used for dependency management. - [Poetry](https://python-poetry.org/) is used for dependency management.
- Most of the code is asynchronous, using [asyncio](https://docs.python.org/3/library/asyncio.html). - Most of the code is asynchronous, using [asyncio](https://docs.python.org/3/library/asyncio.html).
- SQLite3 is the default database. - SQLite3 for data storage
The server has 2 components: The server has 3 components:
- The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates) - The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates)
- An additional process that takes care of sending "outgoing actities" - One process that takes care of sending "outgoing activities"
- One process that takes care of processing "incoming activities"
### Tasks ### Tasks
@ -29,7 +30,7 @@ inv -l
### Media storage ### Media storage
The uploads are stored in the `data/` directory, using a simple content-addressed storage (file contents hash is the name of the store BLOB). The uploads are stored in the `data/` directory, using a simple content-addressed storage system (file contents hash is BLOB filename).
Files metadata are stored in the database. Files metadata are stored in the database.
## Installation ## Installation

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. 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 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 ```bash
git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld
@ -55,6 +55,12 @@ docker compose stop
docker compose up -d docker compose up -d
``` ```
As you probably already know, Docker can (and will) eat a lot of disk space, when updating you should [prune old images](https://docs.docker.com/config/pruning/#prune-images) from time to time:
```bash
docker image prune -a --filter "until=24h"
```
## Python developer edition ## Python developer edition
Assuming you have a working **Python 3.10+** environment. Assuming you have a working **Python 3.10+** environment.
@ -83,6 +89,12 @@ Setup config.
poetry run inv configuration-wizard poetry run inv configuration-wizard
``` ```
Setup the database.
```bash
poetry run inv migrate-db
```
Grab your virtualenv path. Grab your virtualenv path.
```bash ```bash
@ -99,7 +111,7 @@ Setup a reverse proxy (see the next section).
### Updating ### Updating
To update microblogpub locally, pull the remote changes and run the `update` task to regeneratee the CSS and run any DB migrations. To update microblogpub locally, pull the remote changes and run the `update` task to regenerate the CSS and run any DB migrations.
```bash ```bash
git pull git pull
@ -130,6 +142,11 @@ server {
# [...] # [...]
} }
# This should be outside the `server` block
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
``` ```
Optionally, you can serve static files using NGINX directly, with an additional `location` block. Optionally, you can serve static files using NGINX directly, with an additional `location` block.
@ -147,8 +164,33 @@ server {
# path for static files # path for static files
rewrite ^/static/(.*) /$1 break; rewrite ^/static/(.*) /$1 break;
root /path/to/your-domain.tld/app/static/; root /path/to/your-domain.tld/app/static/;
expires 1y;
} }
# [...] # [...]
} }
``` ```
### NGINX config tips
Enable HTTP2 (which is disabled by default):
```nginx
server {
# [...]
listen [::]:443 ssl http2;
}
```
Tweak `/etc/nginx/nginx.conf` and add gzip compression for ActivityPub responses:
```nginx
http {
# [...]
gzip_types text/plain text/css application/json application/javascript application/activity+json application/octet-stream;
}
```
## YunoHost edition
[YunoHost](https://yunohost.org/) support is a work in progress.

View File

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

View File

@ -29,18 +29,69 @@ You can tweak your profile by tweaking these items:
- `summary` (using Markdown) - `summary` (using Markdown)
- `icon_url` - `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 software like Mastodon will limit the number of key/value to 4.
```toml
metadata = [
{key = "Documentation", value = "[https://docs.microblog.pub](https://docs.microblog.pub)"},
{key = "Source code", value = "[https://sr.ht/~tsileo/microblog.pub/](https://sr.ht/~tsileo/microblog.pub/)"},
]
```
### Manually approving followers
If you wish to manually approve followers, add this config item to `profile.toml`:
```toml
manually_approves_followers = true
```
The default value is `false`.
### Hiding followers
If you wish to hide your followers, add this config item to `profile.toml`:
```toml
hides_followers = true
```
The default value is `false`.
### Hiding who you are following
If you wish to hide who you are following, add this config item to `profile.toml`:
```toml
hides_following = true
```
The default value is `false`.
### Privacy replace ### 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). 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 ```toml
privacy_replace = [ privacy_replace = [
{domain = "youtube.com", replace_by = "yewtu.be"}, {domain = "youtube.com", replace_by = "yewtu.be"},
{domain = "youtu.be", replace_by = "yewtu.be"},
{domain = "twitter.com", replace_by = "nitter.fdn.fr"}, {domain = "twitter.com", replace_by = "nitter.fdn.fr"},
{domain = "medium.com", replace_by = "scribe.rip"}, {domain = "medium.com", replace_by = "scribe.rip"},
{domain = "reddit.com", replace_by = "teddit.net"}, {domain = "reddit.com", replace_by = "teddit.net"},
@ -49,6 +100,16 @@ privacy_replace = [
### Customization ### Customization
#### Default emoji
If you don't like cats, or need more emoji, you can add your favorite emoji in `profile.toml` and it will replace the default ones:
```
emoji = "🙂🐹📌"
```
You can copy/paste them from [getemoji.com](https://getemoji.com/).
#### Custom emoji #### Custom emoji
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically. You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
@ -64,19 +125,43 @@ $primary-color: #e14eea;
$secondary-color: #32cd32; $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.
#### 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`:
```toml
code_highlighting_theme = "solarized-dark"
```
### Blocking 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`.
The `reason` field is just there to help you document/remember why a server was blocked.
You should unfollow any account from a server before blocking it.
```toml
blocked_servers = [
{hostname = "bad.tld", reason = "Bot spam"},
]
```
## Public website ## Public website
Public notes will be visible on the homepage. 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. And only the last 20 interactions (likes/shares/webmentions) will be displayed, to keep things simple/clean.
## Admin section ## 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. The password is the one set during the initial configuration.
### Lookup ### Lookup
@ -129,35 +214,36 @@ microblog.pub supports the most common interactions supported by the Fediverse.
### Shares ### Shares
Sharing an object will relay it to your followers and notify the author. Sharing (or announcing) an object will relay it to your followers and notify the author.
It will also be displayed on the homepage. It will also be displayed on the homepage.
Most receiving servers will increment the number of shares. Most receiving servers will increment the number of shares.
TODO receiving Receiving a share will trigger a notification, increment the shares counter on the object and the actor avatar will be displayed on the object permalink.
### Likes ### Likes
Liking an object will notify the author. Liking an object will notify the author.
Unkike 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. Most receiving servers will increment the number of likes.
TODO receiving Receiving a like will trigger a notification, increment the likes counter on the object and the actor avatar will be displayed on the object permalink.
### Bookmarks ### Bookmarks
Bookmarks allow you to like objects without notifying the author. 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.
TODO receiving It will also prevent objects to be pruned.
### Webmentions ### Webmentions
Sending webmention to ping mentioned website is done automatically once a note is authored, see TODO. Sending webmentions to ping mentioned websites is done automatically once a public note is authored.
TODO side-effect of receiving a webmention. 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.
## Backup and restore ## Backup and restore
@ -171,3 +257,200 @@ All the data generated by the server is located in the `data/` directory:
- Uploaded media - Uploaded media
Restoring is as easy as adding your backed up `data/` directory into a fresh deployment. Restoring is as easy as adding your backed up `data/` directory into a fresh deployment.
## Moving from another instance
If you want to move followers from your existing account, ensure it is supported in your software documentation.
For [Mastodon you can look at Moving or leaving accounts](https://docs.joinmastodon.org/user/moving/).
If you wish to move **to** another instance, see [Moving to another instance](/user_guide.html#moving-to-another-instance).
First you need to grab the "ActivityPub actor URL" for your existing account:
### Python edition
```bash
# For a Python install
poetry run inv webfinger username@domain.tld
```
Edit the config.
### Docker edition
```bash
# For a Docker install
make account=username@domain.tld webfinger
```
Edit the config.
### Edit the config
And add a reference to your old/existing account in `profile.toml`:
```toml
also_known_as = "my@old-account.com"
```
Restart the server, and you should be able to complete the move from your existing account.
## 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 `password-reset` task.
#### Python edition
```bash
# shutdown supervisord
poetry run inv password-reset
# edit data/profile.toml
# restart supervisord
```
#### Docker edition
```bash
docker compose stop
make password-reset
# edit data/profile.toml
docker compose up -d
```
### Pruning old data
You should prune old data from time to time to free disk space.
The default retention for the inbox data is 15 days.
It's configurable via the `inbox_retention_days` config item in `profile.toml`:
```toml
inbox_retention_days = 30
```
Data owned by the server will never be deleted (at least for now), along with:
- bookmarked objects
- liked objects
- shared objects
- inbox objects mentioning the local actor
- objects related to local conversations (i.e. direct messages, replies)
For now, it's recommended to make a backup before running the task in case it deletes unwanted data.
You should shutdown the server before running the task.
#### Python edition
```bash
# shutdown supervisord
cp -r data/microblogpub.db data/microblogpub.db.bak
poetry run inv prune-old-data
# relaunch supervisord and ensure it works as expected
rm data/microblogpub.db.bak
```
#### Docker edition
```bash
docker compose stop
cp -r data/microblogpub.db data/microblogpub.db.bak
make prune-old-data
docker compose up -d
rm data/microblogpub.db.bak
```
### Moving to another instance
If you want to migrate to another instance, you have the ability to move your existing followers to your new account.
Your new account should reference the existing one, refer to your software configuration (for example [Moving or leaving accounts from the Mastodon doc](https://docs.joinmastodon.org/user/moving/)).
If you wish to move **from** another instance, see [Moving from another instance](/user_guide.html#moving-from-another-instance).
Execute the Move task:
#### Python edition
```bash
# For a Python install
poetry run inv move-to username@domain.tld
```
#### Docker edition
```bash
# For a Docker install
make account=username@domain.tld move-to
```
### Deleting the instance
If you want to delete your instance, you can request other instances to delete your remote profile.
Note that this is a best-effort delete as some instances may not delete your data.
The command won't remove any local data, it just broadcasts account deletion messages to all known servers.
After executing the command, you should let the server run until all the outgoing delete tasks are sent.
Once deleted, you won't be able to use your instance anymore, but you will be able to perform a fresh re-install of any ActivityPub software.
#### Python edition
```bash
# For a Python install
poetry run inv self-destruct
```
#### Docker edition
```bash
# 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

View File

@ -4,11 +4,10 @@ logfile=/dev/null
logfile_maxbytes=0 logfile_maxbytes=0
pidfile=data/supervisord.pid pidfile=data/supervisord.pid
[fcgi-program:uvicorn] [program:uvicorn]
socket=tcp://0.0.0.0:8000 command=uvicorn app.main:app --no-server-header --host 0.0.0.0
command=uvicorn app.main:app --no-server-header --fd 0 numprocs=1
numprocs=2 autorestart=true
process_name=uvicorn-%(process_num)d
redirect_stderr=true redirect_stderr=true
stdout_logfile=data/uvicorn.log stdout_logfile=data/uvicorn.log
stdout_logfile_maxbytes=50MB stdout_logfile_maxbytes=50MB
@ -16,6 +15,7 @@ stdout_logfile_maxbytes=50MB
[program:incoming_worker] [program:incoming_worker]
command=inv process-incoming-activities command=inv process-incoming-activities
numproc=1 numproc=1
autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile=data/incoming.log stdout_logfile=data/incoming.log
stdout_logfile_maxbytes=50MB stdout_logfile_maxbytes=50MB
@ -23,6 +23,7 @@ stdout_logfile_maxbytes=50MB
[program:outgoing_worker] [program:outgoing_worker]
command=inv process-outgoing-activities command=inv process-outgoing-activities
numproc=1 numproc=1
autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile=data/outgoing.log stdout_logfile=data/outgoing.log
stdout_logfile_maxbytes=50MB stdout_logfile_maxbytes=50MB

View File

@ -1,24 +1,25 @@
[supervisord] [supervisord]
[fcgi-program:uvicorn] [program:uvicorn]
socket=tcp://localhost:8000 command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0 numprocs=1
numprocs=2 autorestart=true
process_name=uvicorn-%(process_num)d
redirect_stderr=true redirect_stderr=true
stdout_logfile=uvicorn.log stdout_logfile=uvicorn.log
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=50MB
[program:incoming_worker] [program:incoming_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
numproc=1 numproc=1
autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile=incoming_worker.log stdout_logfile=incoming_worker.log
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=50MB
[program:outgoing_worker] [program:outgoing_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
numproc=1 numproc=1
autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile=outgoing_worker.log stdout_logfile=outgoing_worker.log
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=50MB

View File

@ -1,24 +1,26 @@
[supervisord] [supervisord]
[fcgi-program:uvicorn] [program:uvicorn]
socket=tcp://localhost:%(ENV_UVICORN_PORT)s command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0 numprocs=1
numprocs=2 autorestart=true
process_name=uvicorn-%(process_num)d process_name=uvicorn-%(process_num)d
redirect_stderr=true redirect_stderr=true
stdout_logfile=uvicorn.log stdout_logfile=%(ENV_LOG_PATH)s/uvicorn.log
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
[program:incoming_worker] [program:incoming_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
numproc=1 numproc=1
autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile=incoming_worker.log stdout_logfile=%(ENV_LOG_PATH)s/incoming.log
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
[program:outgoing_worker] [program:outgoing_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
numproc=1 numproc=1
autorestart=true
redirect_stderr=true redirect_stderr=true
stdout_logfile=outgoing_worker.log stdout_logfile=%(ENV_LOG_PATH)s/outgoing.log
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0

819
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,6 @@ license = "AGPL-3.0"
python = "^3.10" python = "^3.10"
Jinja2 = "^3.1.2" Jinja2 = "^3.1.2"
fastapi = "^0.78.0" fastapi = "^0.78.0"
uvicorn = "^0.17.6"
pycryptodome = "^3.14.1" pycryptodome = "^3.14.1"
bcrypt = "^3.2.2" bcrypt = "^3.2.2"
itsdangerous = "^2.1.2" itsdangerous = "^2.1.2"
@ -19,7 +18,6 @@ httpx = {extras = ["http2"], version = "^0.23.0"}
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"} SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
alembic = "^1.8.0" alembic = "^1.8.0"
bleach = "^5.0.0" bleach = "^5.0.0"
Markdown = "^3.3.7"
prompt-toolkit = "^3.0.29" prompt-toolkit = "^3.0.29"
tomli-w = "^1.0.0" tomli-w = "^1.0.0"
python-dateutil = "^2.8.2" python-dateutil = "^2.8.2"
@ -28,7 +26,6 @@ html5lib = "^1.1"
mf2py = "^1.1.2" mf2py = "^1.1.2"
Pygments = "^2.12.0" Pygments = "^2.12.0"
loguru = "^0.6.0" loguru = "^0.6.0"
mdx-linkify = "^2.1"
Pillow = "^9.1.1" Pillow = "^9.1.1"
blurhash-python = "^1.1.3" blurhash-python = "^1.1.3"
html2text = "^2020.1.16" html2text = "^2020.1.16"
@ -43,6 +40,10 @@ asgiref = "^3.5.2"
supervisor = "^4.2.4" supervisor = "^4.2.4"
invoke = "^1.7.1" invoke = "^1.7.1"
boussole = "^2.0.0" boussole = "^2.0.0"
uvicorn = {extras = ["standard"], version = "^0.18.3"}
Brotli = "^1.0.9"
greenlet = "^1.1.3"
mistletoe = "^0.9.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
black = "^22.3.0" black = "^22.3.0"

View File

@ -1,19 +1,115 @@
import re
import shutil import shutil
import typing
from pathlib import Path from pathlib import Path
from typing import Any
from jinja2 import Environment from jinja2 import Environment
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
from jinja2 import select_autoescape 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.config import VERSION
from app.source import CustomRenderer
from app.utils.datetime import now from app.utils.datetime import now
_FORMATTER = HtmlFormatter()
_FORMATTER.noclasses = True
def markdownify(content: str) -> str:
return markdown( class DocRenderer(CustomRenderer):
content, extensions=["mdx_linkify", "fenced_code", "codehilite", "toc"] 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: def main() -> None:
@ -30,32 +126,36 @@ def main() -> None:
last_updated = now().replace(second=0, microsecond=0).isoformat() last_updated = now().replace(second=0, microsecond=0).isoformat()
readme = Path("README.md") readme = Path("README.md")
content, toc = markdownify(readme.read_text().removeprefix("# microblog.pub"))
template.stream( template.stream(
content=markdownify(readme.read_text().removeprefix("# microblog.pub")), content=content,
version=VERSION, version=VERSION,
path="/", path="/",
last_updated=last_updated, last_updated=last_updated,
).dump("docs/dist/index.html") ).dump("docs/dist/index.html")
install = Path("docs/install.md") install = Path("docs/install.md")
content, toc = markdownify(install.read_text())
template.stream( template.stream(
content=markdownify(install.read_text()), content=content.replace("[TOC]", toc),
version=VERSION, version=VERSION,
path="/installing.html", path="/installing.html",
last_updated=last_updated, last_updated=last_updated,
).dump("docs/dist/installing.html") ).dump("docs/dist/installing.html")
user_guide = Path("docs/user_guide.md") user_guide = Path("docs/user_guide.md")
content, toc = markdownify(user_guide.read_text())
template.stream( template.stream(
content=markdownify(user_guide.read_text()), content=content.replace("[TOC]", toc),
version=VERSION, version=VERSION,
path="/user_guide.html", path="/user_guide.html",
last_updated=last_updated, last_updated=last_updated,
).dump("docs/dist/user_guide.html") ).dump("docs/dist/user_guide.html")
developer_guide = Path("docs/developer_guide.md") developer_guide = Path("docs/developer_guide.md")
content, toc = markdownify(developer_guide.read_text())
template.stream( template.stream(
content=markdownify(developer_guide.read_text()), content=content.replace("[TOC]", toc),
version=VERSION, version=VERSION,
path="/developer_guide.html", path="/developer_guide.html",
last_updated=last_updated, last_updated=last_updated,

View File

@ -6,7 +6,6 @@ from typing import Any
import bcrypt import bcrypt
import tomli_w import tomli_w
from markdown import markdown # type: ignore
from prompt_toolkit import prompt from prompt_toolkit import prompt
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
@ -58,8 +57,7 @@ def main() -> None:
prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt() prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt()
).decode() ).decode()
dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"]) dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"])
dat["summary"] = markdown( dat["summary"] = prompt(
prompt(
( (
"summary (short description, in markdown, " "summary (short description, in markdown, "
"press [CTRL] + [SPACE] to submit):\n" "press [CTRL] + [SPACE] to submit):\n"
@ -67,7 +65,6 @@ def main() -> None:
key_bindings=_kb, key_bindings=_kb,
multiline=True, multiline=True,
) )
)
dat["https"] = True dat["https"] = True
proto = "https" proto = "https"
yn = "" yn = ""

155
tasks.py
View File

@ -1,6 +1,6 @@
import asyncio import asyncio
import io import io
import subprocess import shutil
import tarfile import tarfile
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
@ -46,7 +46,12 @@ def compile_scss(ctx, watch=False):
# type: (Context, bool) -> None # type: (Context, bool) -> None
from app.utils.favicon import build_favicon from app.utils.favicon import build_favicon
favicon_file = Path("data/favicon.ico")
if not favicon_file.exists():
build_favicon() build_favicon()
else:
shutil.copy2(favicon_file, "app/static/favicon.ico")
theme_file = Path("data/_theme.scss") theme_file = Path("data/_theme.scss")
if not theme_file.exists(): if not theme_file.exists():
theme_file.write_text("// override vars for theming here") theme_file.write_text("// override vars for theming here")
@ -164,14 +169,12 @@ def stats(ctx):
@contextmanager @contextmanager
def embed_version() -> Generator[None, None, None]: def embed_version() -> Generator[None, None, None]:
from app.utils.version import get_version_commit
version_file = Path("app/_version.py") version_file = Path("app/_version.py")
version_file.unlink(missing_ok=True) version_file.unlink(missing_ok=True)
version = ( version_commit = get_version_commit()
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"]) version_file.write_text(f'VERSION_COMMIT = "{version_commit}"')
.split()[0]
.decode()
)
version_file.write_text(f'VERSION_COMMIT = "{version}"')
try: try:
yield yield
finally: finally:
@ -193,6 +196,109 @@ def prune_old_data(ctx):
asyncio.run(run_prune_old_data()) asyncio.run(run_prune_old_data())
@task
def webfinger(ctx, account):
# type: (Context, str) -> None
import traceback
from loguru import logger
from app.source import _MENTION_REGEX
from app.webfinger import get_actor_url
logger.disable("app")
if not account.startswith("@"):
account = f"@{account}"
if not _MENTION_REGEX.match(account):
print(f"Invalid acccount {account}")
return
print(f"Resolving {account}")
try:
maybe_actor_url = asyncio.run(get_actor_url(account))
if maybe_actor_url:
print(f"SUCCESS: {maybe_actor_url}")
else:
print(f"ERROR: Failed to resolve {account}")
except Exception as exc:
print(f"ERROR: Failed to resolve {account}")
print("".join(traceback.format_exception(exc)))
@task
def move_to(ctx, moved_to):
# type: (Context, str) -> None
import traceback
from loguru import logger
from app.actor import LOCAL_ACTOR
from app.actor import fetch_actor
from app.boxes import send_move
from app.database import async_session
from app.source import _MENTION_REGEX
from app.webfinger import get_actor_url
logger.disable("app")
if not moved_to.startswith("@"):
moved_to = f"@{moved_to}"
if not _MENTION_REGEX.match(moved_to):
print(f"Invalid acccount {moved_to}")
return
async def _send_move():
print(f"Initiating move to {moved_to}")
async with async_session() as db_session:
try:
moved_to_actor_id = await get_actor_url(moved_to)
except Exception as exc:
print(f"ERROR: Failed to resolve {moved_to}")
print("".join(traceback.format_exception(exc)))
return
if not moved_to_actor_id:
print("ERROR: Failed to resolve {moved_to}")
return
new_actor = await fetch_actor(db_session, moved_to_actor_id)
if LOCAL_ACTOR.ap_id not in new_actor.ap_actor.get("alsoKnownAs", []):
print(
f"{new_actor.handle}/{moved_to_actor_id} is missing "
f"{LOCAL_ACTOR.ap_id} in alsoKnownAs"
)
return
await send_move(db_session, new_actor.ap_id)
print("Done")
asyncio.run(_send_move())
@task
def self_destruct(ctx):
# type: (Context) -> None
from loguru import logger
from app.boxes import send_self_destruct
from app.database import async_session
logger.disable("app")
async def _send_self_destruct():
if input("Initiating self destruct, type yes to confirm: ") != "yes":
print("Aborting")
async with async_session() as db_session:
await send_self_destruct(db_session)
print("Done")
asyncio.run(_send_self_destruct())
@task @task
def yunohost_config( def yunohost_config(
ctx, ctx,
@ -212,3 +318,38 @@ def yunohost_config(
summary=summary, summary=summary,
password=password, password=password,
) )
@task
def reset_password(ctx):
# type: (Context) -> None
import bcrypt
from prompt_toolkit import prompt
new_password = bcrypt.hashpw(
prompt("New admin password: ", is_password=True).encode(), bcrypt.gensalt()
).decode()
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( def build_move_activity(
from_remote_actor: actor.RemoteActor, from_remote_actor: actor.RemoteActor,
for_remote_object: actor.RemoteActor, for_remote_object: actor.RemoteActor,
@ -84,12 +98,13 @@ def build_move_activity(
def build_note_object( def build_note_object(
from_remote_actor: actor.RemoteActor, from_remote_actor: actor.RemoteActor | models.Actor,
outbox_public_id: str | None = None, outbox_public_id: str | None = None,
content: str = "Hello", content: str = "Hello",
to: list[str] = None, to: list[str] = None,
cc: list[str] = None, cc: list[str] = None,
tags: list[ap.RawObject] = None, tags: list[ap.RawObject] = None,
in_reply_to: str | None = None,
) -> ap.RawObject: ) -> ap.RawObject:
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z") published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
@ -108,8 +123,8 @@ def build_note_object(
"url": from_remote_actor.ap_id + "/note/" + note_id, "url": from_remote_actor.ap_id + "/note/" + note_id,
"tag": tags or [], "tag": tags or [],
"summary": None, "summary": None,
"inReplyTo": None,
"sensitive": False, "sensitive": False,
"inReplyTo": in_reply_to,
} }

View File

@ -52,4 +52,4 @@ def test_sqlalchemy_factory(db: Session) -> None:
ap_actor=ra.ap_actor, ap_actor=ra.ap_actor,
ap_id=ra.ap_id, ap_id=ra.ap_id,
) )
assert actor_in_db.id == db.query(models.Actor).one().id assert actor_in_db.id == db.execute(select(models.Actor)).scalar_one().id

View File

@ -75,7 +75,7 @@ def test_inbox_incoming_follow_request(
assert inbox_object.ap_object == follow_activity.ap_object assert inbox_object.ap_object == follow_activity.ap_object
# And a follower was internally created # And a follower was internally created
follower = db.query(models.Follower).one() follower = db.execute(select(models.Follower)).scalar_one()
assert follower.ap_actor_id == ra.ap_id assert follower.ap_actor_id == ra.ap_id
assert follower.actor_id == saved_actor.id assert follower.actor_id == saved_actor.id
assert follower.inbox_object_id == inbox_object.id assert follower.inbox_object_id == inbox_object.id
@ -414,3 +414,62 @@ def test_inbox__move_activity(
) )
== 1 == 1
) )
# And a notification was created
notif = db.execute(
select(models.Notification).where(
models.Notification.notification_type == models.NotificationType.MOVE
)
).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

@ -2,13 +2,17 @@ from unittest import mock
import respx import respx
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import activitypub as ap from app import activitypub as ap
from app import models from app import models
from app import webfinger from app import webfinger
from app.actor import LOCAL_ACTOR
from app.config import generate_csrf_token from app.config import generate_csrf_token
from tests.utils import generate_admin_session_cookies from tests.utils import generate_admin_session_cookies
from tests.utils import setup_inbox_note
from tests.utils import setup_outbox_note
from tests.utils import setup_remote_actor from tests.utils import setup_remote_actor
from tests.utils import setup_remote_actor_as_follower from tests.utils import setup_remote_actor_as_follower
@ -49,16 +53,184 @@ def test_send_follow_request(
assert response.headers.get("Location") == "http://testserver/" assert response.headers.get("Location") == "http://testserver/"
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Follow" assert outbox_object.ap_type == "Follow"
assert outbox_object.activity_object_ap_id == ra.ap_id assert outbox_object.activity_object_ap_id == ra.ap_id
# And an outgoing activity was queued # And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == ra.inbox_url assert outgoing_activity.recipient == ra.inbox_url
def test_send_delete__reverts_side_effects(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# given a remote actor
ra = setup_remote_actor(respx_mock)
# who is a follower
follower = setup_remote_actor_as_follower(ra)
actor = follower.actor
# with a note that has existing replies
inbox_note = setup_inbox_note(actor)
# with a bogus counter
inbox_note.replies_count = 5
db.commit()
# 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,
)
db.commit()
# When deleting one of the replies
response = client.post(
"/admin/actions/delete",
data={
"redirect_url": "http://testserver/",
"ap_object_id": outbox_note2.ap_id,
"csrf_token": generate_csrf_token(),
},
cookies=generate_admin_session_cookies(),
)
# Then the server returns a 302
assert response.status_code == 302
assert response.headers.get("Location") == "http://testserver/"
# And the Delete activity was created in the outbox
outbox_object = db.execute(
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_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 refreshed correctly
db.refresh(inbox_note)
assert inbox_note.replies_count == 1
def test_send_create_activity__no_content(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# given a remote actor
ra = setup_remote_actor(respx_mock)
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
response = client.post(
"/admin/actions/new",
data={
"redirect_url": "http://testserver/",
"visibility": ap.VisibilityEnum.PUBLIC.name,
"csrf_token": generate_csrf_token(),
},
cookies=generate_admin_session_cookies(),
)
# Then the server returns a 422
assert response.status_code == 422
def test_send_create_activity__with_attachment(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# given a remote actor
ra = setup_remote_actor(respx_mock)
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
response = client.post(
"/admin/actions/new",
data={
"content": "hello",
"redirect_url": "http://testserver/",
"visibility": ap.VisibilityEnum.PUBLIC.name,
"csrf_token": generate_csrf_token(),
},
files=[
("files", ("attachment.txt", "hello")),
],
cookies=generate_admin_session_cookies(),
)
# Then the server returns a 302
assert response.status_code == 302
# And the Follow activity was created in the outbox
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>\n"
assert len(outbox_object.attachments) == 1
attachment = outbox_object.attachments[0]
assert attachment.type == "Document"
attachment_response = client.get(attachment.url)
assert attachment_response.status_code == 200
assert attachment_response.content == b"hello"
upload = db.execute(select(models.Upload)).scalar_one()
assert upload.content_hash == (
"324dcf027dd4a30a932c441f365a25e86b173defa4b8e58948253471b81b72cf"
)
outbox_attachment = db.execute(select(models.OutboxObjectAttachment)).scalar_one()
assert outbox_attachment.upload_id == upload.id
assert outbox_attachment.outbox_object_id == outbox_object.id
assert outbox_attachment.filename == "attachment.txt"
def test_send_create_activity__no_content_with_cw_and_attachments(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# given a remote actor
ra = setup_remote_actor(respx_mock)
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
response = client.post(
"/admin/actions/new",
data={
"content_warning": "cw",
"redirect_url": "http://testserver/",
"visibility": ap.VisibilityEnum.PUBLIC.name,
"csrf_token": generate_csrf_token(),
},
files={"files": ("attachment.txt", "hello")},
cookies=generate_admin_session_cookies(),
)
# Then the server returns a 302
assert response.status_code == 302
# And the Follow activity was created in the outbox
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>\n"
assert len(outbox_object.attachments) == 1
def test_send_create_activity__no_followers_and_with_mention( def test_send_create_activity__no_followers_and_with_mention(
db: Session, db: Session,
client: TestClient, client: TestClient,
@ -83,11 +255,11 @@ def test_send_create_activity__no_followers_and_with_mention(
assert response.status_code == 302 assert response.status_code == 302
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note" assert outbox_object.ap_type == "Note"
# And an outgoing activity was queued # And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == ra.inbox_url assert outgoing_activity.recipient == ra.inbox_url
@ -119,11 +291,11 @@ def test_send_create_activity__with_followers(
assert response.status_code == 302 assert response.status_code == 302
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note" assert outbox_object.ap_type == "Note"
# And an outgoing activity was queued # And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url assert outgoing_activity.recipient == follower.actor.inbox_url
@ -159,7 +331,7 @@ def test_send_create_activity__question__one_of(
assert response.status_code == 302 assert response.status_code == 302
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Question" assert outbox_object.ap_type == "Question"
assert outbox_object.is_one_of_poll is True assert outbox_object.is_one_of_poll is True
assert len(outbox_object.poll_items) == 2 assert len(outbox_object.poll_items) == 2
@ -167,7 +339,7 @@ def test_send_create_activity__question__one_of(
assert outbox_object.is_poll_ended is False assert outbox_object.is_poll_ended is False
# And an outgoing activity was queued # And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url assert outgoing_activity.recipient == follower.actor.inbox_url
@ -205,7 +377,7 @@ def test_send_create_activity__question__any_of(
assert response.status_code == 302 assert response.status_code == 302
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Question" assert outbox_object.ap_type == "Question"
assert outbox_object.is_one_of_poll is False assert outbox_object.is_one_of_poll is False
assert len(outbox_object.poll_items) == 4 assert len(outbox_object.poll_items) == 4
@ -213,7 +385,7 @@ def test_send_create_activity__question__any_of(
assert outbox_object.is_poll_ended is False assert outbox_object.is_poll_ended is False
# And an outgoing activity was queued # And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url assert outgoing_activity.recipient == follower.actor.inbox_url
@ -246,11 +418,11 @@ def test_send_create_activity__article(
assert response.status_code == 302 assert response.status_code == 302
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Article" assert outbox_object.ap_type == "Article"
assert outbox_object.ap_object["name"] == "Article" assert outbox_object.ap_object["name"] == "Article"
# And an outgoing activity was queued # And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url assert outgoing_activity.recipient == follower.actor.inbox_url

View File

@ -1,3 +1,5 @@
from unittest import mock
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -31,7 +33,19 @@ def test_followers__ap(client, db) -> None:
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE}) response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
assert response.json()["id"].endswith("/followers") json_resp = response.json()
assert json_resp["id"].endswith("/followers")
assert "first" in json_resp
def test_followers__ap_hides_followers(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
json_resp = response.json()
assert json_resp["id"].endswith("/followers")
assert "first" not in json_resp
def test_followers__html(client, db) -> None: def test_followers__html(client, db) -> None:
@ -40,14 +54,40 @@ def test_followers__html(client, db) -> None:
assert response.headers["content-type"].startswith("text/html") assert response.headers["content-type"].startswith("text/html")
def test_followers__html_hides_followers(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
response = client.get("/followers", headers={"Accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("text/html")
def test_following__ap(client, db) -> None: def test_following__ap(client, db) -> None:
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE}) response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
assert response.json()["id"].endswith("/following") json_resp = response.json()
assert json_resp["id"].endswith("/following")
assert "first" in json_resp
def test_following__ap_hides_following(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
json_resp = response.json()
assert json_resp["id"].endswith("/following")
assert "first" not in json_resp
def test_following__html(client, db) -> None: def test_following__html(client, db) -> None:
response = client.get("/following") response = client.get("/following")
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html") assert response.headers["content-type"].startswith("text/html")
def test_following__html_hides_following(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
response = client.get("/following", headers={"Accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("text/html")

View File

@ -1,4 +1,5 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import activitypub as ap from app import activitypub as ap
@ -35,7 +36,7 @@ def test_tags__note_with_tag(db: Session, client: TestClient) -> None:
assert response.status_code == 302 assert response.status_code == 302
# And the Follow activity was created in the outbox # And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note" assert outbox_object.ap_type == "Note"
assert len(outbox_object.tags) == 1 assert len(outbox_object.tags) == 1
emoji_tag = outbox_object.tags[0] emoji_tag = outbox_object.tags[0]

View File

@ -169,6 +169,53 @@ def setup_remote_actor_as_following_and_follower(
return following, follower return following, follower
def setup_outbox_note(
content: str = "Hello",
to: list[str] = None,
cc: list[str] = None,
tags: list[ap.RawObject] = None,
in_reply_to: str | None = None,
) -> models.OutboxObject:
note_id = uuid4().hex
note_from_outbox = RemoteObject(
factories.build_note_object(
from_remote_actor=LOCAL_ACTOR,
outbox_public_id=note_id,
content=content,
to=to,
cc=cc,
tags=tags,
in_reply_to=in_reply_to,
),
LOCAL_ACTOR,
)
return factories.OutboxObjectFactory.from_remote_object(note_id, note_from_outbox)
def setup_inbox_note(
actor: models.Actor,
content: str = "Hello",
to: list[str] = None,
cc: list[str] = None,
tags: list[ap.RawObject] = None,
in_reply_to: str | None = None,
) -> models.OutboxObject:
note_id = uuid4().hex
note_from_outbox = RemoteObject(
factories.build_note_object(
from_remote_actor=actor,
outbox_public_id=note_id,
content=content,
to=to,
cc=cc,
tags=tags,
in_reply_to=in_reply_to,
),
actor,
)
return factories.InboxObjectFactory.from_remote_object(note_from_outbox, actor)
def setup_inbox_delete( def setup_inbox_delete(
actor: models.Actor, deleted_object_ap_id: str actor: models.Actor, deleted_object_ap_id: str
) -> models.InboxObject: ) -> models.InboxObject: