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

113 Commits

Author SHA1 Message Date
0f1fdd3944 Add missing migration 2022-09-21 19:19:12 +02:00
254588f7c0 Boostrap support for quote URL 2022-09-21 19:18:44 +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
60 changed files with 2671 additions and 635 deletions

View File

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

View File

@ -9,12 +9,35 @@ build:
config:
# Run and remove instantly
-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
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
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

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

View File

@ -9,9 +9,12 @@ from loguru import logger
from markdown import markdown
from app import config
from app.config import ALSO_KNOWN_AS
from app.config import AP_CONTENT_TYPE # noqa: F401
from app.config import MOVED_TO
from app.httpsig import auth
from app.key import get_pubkey_as_pem
from app.source import hashtagify
from app.utils.url import check_url
if TYPE_CHECKING:
@ -32,6 +35,7 @@ AS_EXTENDED_CTX = [
"sensitive": "as:sensitive",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
# toot
"toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"},
@ -57,6 +61,10 @@ class ObjectNotFoundError(Exception):
pass
class ObjectUnavailableError(Exception):
pass
class FetchErrorTypeEnum(str, enum.Enum):
TIMEOUT = "TIMEOUT"
NOT_FOUND = "NOT_FOUND"
@ -81,6 +89,8 @@ class VisibilityEnum(str, enum.Enum):
}[key]
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
ME = {
"@context": AS_EXTENDED_CTX,
"type": "Person",
@ -92,7 +102,7 @@ ME = {
"outbox": config.BASE_URL + "/outbox",
"preferredUsername": config.USERNAME,
"name": config.CONFIG.name,
"summary": config.CONFIG.summary,
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
"endpoints": {
# For compat with servers expecting a sharedInbox...
"sharedInbox": config.BASE_URL
@ -120,8 +130,15 @@ ME = {
"owner": config.ID,
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
},
"tag": _LOCAL_ACTOR_TAGS,
}
if ALSO_KNOWN_AS:
ME["alsoKnownAs"] = [ALSO_KNOWN_AS]
if MOVED_TO:
ME["movedTo"] = MOVED_TO
class NotAnObjectError(Exception):
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
@ -154,6 +171,8 @@ async def fetch(
# Special handling for deleted object
if resp.status_code == 410:
raise ObjectIsGoneError(f"{url} is gone")
elif resp.status_code in [401, 403]:
raise ObjectUnavailableError(f"not allowed to fetch {url}")
elif resp.status_code == 404:
raise ObjectNotFoundError(f"{url} not found")

View File

@ -5,6 +5,7 @@ from functools import cached_property
from typing import Union
from urllib.parse import urlparse
from loguru import logger
from sqlalchemy import select
from sqlalchemy.orm import joinedload
@ -108,7 +109,7 @@ class Actor:
@property
def tags(self) -> list[ap.RawObject]:
return self.ap_actor.get("tag", [])
return ap.as_list(self.ap_actor.get("tag", []))
@property
def followers_collection_id(self) -> str | None:
@ -118,6 +119,10 @@ class Actor:
def attachments(self) -> list[ap.RawObject]:
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
def server(self) -> str:
return urlparse(self.ap_id).hostname # type: ignore
@ -154,9 +159,9 @@ async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "Actor
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
actor = models.Actor(
ap_id=ap_actor["id"],
ap_id=ap.get_id(ap_actor["id"]),
ap_actor=ap_actor,
ap_type=ap_actor["type"],
ap_type=ap.as_list(ap_actor["type"])[0],
handle=_handle(ap_actor),
)
db_session.add(actor)
@ -188,6 +193,19 @@ async def fetch_actor(
else:
if save_if_not_found:
ap_actor = await ap.fetch(actor_id)
# Some softwares uses URL when we expect ID
if actor_id == ap_actor.get("url"):
# Which mean we may already have it in DB
existing_actor_by_url = (
await db_session.scalars(
select(models.Actor).where(
models.Actor.ap_id == ap.get_id(ap_actor),
)
)
).one_or_none()
if existing_actor_by_url:
return existing_actor_by_url
return await save_actor(db_session, ap_actor)
else:
raise ap.ObjectNotFoundError
@ -201,6 +219,7 @@ class ActorMetadata:
is_follow_request_sent: bool
outbox_follow_ap_id: str | None
inbox_follow_ap_id: str | None
moved_to: typing.Optional["ActorModel"]
ActorsMetadata = dict[str, ActorMetadata]
@ -247,6 +266,19 @@ async def get_actors_metadata(
for actor in actors:
if not actor.ap_id:
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(
ap_actor_id=actor.ap_id,
is_following=actor.ap_id in following,
@ -254,6 +286,7 @@ async def get_actors_metadata(
is_follow_request_sent=actor.ap_id in sent_follow_requests,
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
inbox_follow_ap_id=followers.get(actor.ap_id),
moved_to=moved_to,
)
return idx
@ -289,4 +322,7 @@ def _actor_hash(actor: Actor) -> bytes:
h.update(actor.public_key_id.encode())
h.update(actor.public_key_as_pem.encode())
if actor.moved_to:
h.update(actor.moved_to.encode())
return h.digest()

View File

@ -34,18 +34,28 @@ from app.config import verify_password
from app.database import AsyncSession
from app.database import get_db_session
from app.lookup import lookup
from app.templates import is_current_user_admin
from app.uploads import save_upload
from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME
def user_session_or_redirect(
async def user_session_or_redirect(
request: Request,
session: str | None = Cookie(default=None),
) -> None:
if request.method == "POST":
form_data = await request.form()
if "redirect_url" in form_data:
redirect_url = form_data["redirect_url"]
else:
redirect_url = request.url_for("admin_stream")
else:
redirect_url = str(request.url)
_RedirectToLoginPage = HTTPException(
status_code=302,
headers={"Location": request.url_for("login") + f"?redirect={request.url}"},
headers={"Location": request.url_for("login") + f"?redirect={redirect_url}"},
)
if not session:
@ -68,16 +78,6 @@ 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")
async def get_lookup(
request: Request,
@ -94,6 +94,8 @@ async def get_lookup(
error = ap.FetchErrorTypeEnum.TIMEOUT
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
error = ap.FetchErrorTypeEnum.NOT_FOUND
except (ap.ObjectUnavailableError):
error = ap.FetchErrorTypeEnum.UNAUHTORIZED
except Exception:
logger.exception(f"Failed to lookup {query}")
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR
@ -122,7 +124,9 @@ async def get_lookup(
)
if requested_object:
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,
)
@ -211,6 +215,7 @@ async def admin_bookmarks(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
# TODO: support pagination
stream = (
(
await db_session.scalars(
@ -667,15 +672,30 @@ async def admin_outbox(
@router.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:
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 = (
(
await db_session.scalars(
select(models.Notification)
.where(*where)
.options(
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.OutboxObject.outbox_object_attachments
@ -684,6 +704,7 @@ async def get_notifications(
joinedload(models.Notification.webmention),
)
.order_by(models.Notification.created_at.desc())
.limit(page_size)
)
)
.unique()
@ -697,6 +718,21 @@ async def get_notifications(
notif.is_new = False
await db_session.commit()
more_unread_count = 0
next_cursor = None
if notifications and remaining_count > page_size:
decoded_next_cursor = notifications[-1].created_at
next_cursor = pagination.encode_cursor(decoded_next_cursor)
# 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(
db_session,
request,
@ -704,6 +740,8 @@ async def get_notifications(
{
"notifications": notifications,
"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),
) -> templates.TemplateResponse:
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)
replies_tree = await boxes.get_replies_tree(
@ -736,8 +774,10 @@ async def admin_object(
async def admin_profile(
request: Request,
actor_id: str,
cursor: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
# TODO: show featured/pinned
actor = (
await db_session.execute(
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])
where = [
models.InboxObject.is_deleted.is_(False),
models.InboxObject.actor_id == actor.id,
models.InboxObject.ap_type.in_(
["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(
models.InboxObject.is_deleted.is_(False),
models.InboxObject.actor_id == actor.id,
models.InboxObject.ap_type.in_(
["Note", "Article", "Video", "Page", "Announce"]
),
)
.where(*where)
.options(
joinedload(models.InboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor)
@ -771,12 +821,19 @@ async def admin_profile(
joinedload(models.InboxObject.actor),
)
.order_by(models.InboxObject.ap_published_at.desc())
.limit(page_size)
)
)
.unique()
.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(
db_session,
request,
@ -785,6 +842,7 @@ async def admin_profile(
"actors_metadata": actors_metadata,
"actor": actor,
"inbox_objects": inbox_objects,
"next_cursor": next_cursor,
},
)
@ -974,7 +1032,7 @@ async def admin_actions_unpin(
async def admin_actions_new(
request: Request,
files: list[UploadFile] = [],
content: str = Form(),
content: str | None = Form(None),
redirect_url: str = Form(),
in_reply_to: 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),
db_session: AsyncSession = Depends(get_db_session),
) -> 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
uploads = []
raw_form_data = await request.form()
@ -1054,7 +1125,10 @@ async def admin_actions_vote(
async def login(
request: Request,
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(
db_session,
request,
@ -1072,11 +1146,25 @@ async def login_validation(
password: str = Form(),
redirect: str | None = Form(None),
csrf_check: None = Depends(verify_csrf_token),
) -> RedirectResponse:
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse | templates.TemplateResponse:
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
return resp

View File

@ -2,9 +2,11 @@ import hashlib
from datetime import datetime
from functools import cached_property
from typing import Any
from typing import Optional
import pydantic
from bs4 import BeautifulSoup # type: ignore
from loguru import logger
from markdown import markdown
from app import activitypub as ap
@ -74,6 +76,10 @@ class Object:
def tags(self) -> list[ap.RawObject]:
return ap.as_list(self.ap_object.get("tag", []))
@property
def quote_url(self) -> str | None:
return self.ap_object.get("quoteUrl")
@cached_property
def inlined_images(self) -> set[str]:
image_urls: set[str] = set()
@ -208,6 +214,13 @@ class Object:
def in_reply_to(self) -> str | None:
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
def has_ld_signature(self) -> bool:
return bool(self.ap_object.get("signature"))
@ -271,9 +284,15 @@ class Attachment(BaseModel):
class RemoteObject(Object):
def __init__(self, raw_object: ap.RawObject, actor: Actor):
def __init__(
self,
raw_object: ap.RawObject,
actor: Actor,
quoted_object: Object | None = None,
):
self._raw_object = raw_object
self._actor = actor
self._quoted_object = quoted_object
if self._actor.ap_id != ap.get_actor_id(self._raw_object):
raise ValueError(f"Invalid actor {self._actor.ap_id}")
@ -283,6 +302,7 @@ class RemoteObject(Object):
cls,
raw_object: ap.RawObject,
actor: Actor | None = None,
fetch_quoted_url: bool = True,
):
# Pre-fetch the actor
actor_id = ap.get_actor_id(raw_object)
@ -299,7 +319,17 @@ class RemoteObject(Object):
ap_actor=await ap.fetch(ap.get_actor_id(raw_object)),
)
return cls(raw_object, _actor)
quoted_object: Object | None = None
if quote_url := raw_object.get("quoteUrl"):
try:
quoted_object = await RemoteObject.from_raw_object(
await ap.fetch(quote_url),
fetch_quoted_url=fetch_quoted_url,
)
except Exception:
logger.exception(f"Failed to fetch {quote_url=}")
return cls(raw_object, _actor, quoted_object=quoted_object)
@property
def og_meta(self) -> list[dict[str, Any]] | None:
@ -312,3 +342,9 @@ class RemoteObject(Object):
@property
def actor(self) -> Actor:
return self._actor
@property
def quoted_object(self) -> Optional["RemoteObject"]:
if self._quoted_object:
return self._quoted_object
return None

View File

@ -29,6 +29,7 @@ from app.config import BASE_URL
from app.config import BLOCKED_SERVERS
from app.config import ID
from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.config import set_moved_to
from app.database import AsyncSession
from app.outgoing_activities import new_outgoing_activity
from app.source import markdownify
@ -93,6 +94,7 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
raise ValueError(f"{ap_object_id} not found in the outbox")
delete_id = allocate_outbox_id()
# FIXME addressing
delete = {
"@context": ap.AS_EXTENDED_CTX,
"id": outbox_object_id(delete_id),
@ -122,6 +124,23 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
for rcp in recipients:
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()
@ -329,7 +348,7 @@ async def fetch_conversation_root(
is_root: bool = False,
) -> str:
"""Some softwares do not set the context/conversation field (like Misskey).
This means we have to track conversation ourselves. To do set, we fetch
This means we have to track conversation ourselves. To do so, we fetch
the root of the conversation and either:
- use the context field if set
- or build a custom conversation ID
@ -351,7 +370,12 @@ async def fetch_conversation_root(
db_session, ap.get_actor_id(raw_reply)
)
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
except (
ap.ObjectNotFoundError,
ap.ObjectIsGoneError,
ap.NotAnObjectError,
ap.ObjectUnavailableError,
):
return await fetch_conversation_root(db_session, obj, is_root=True)
except httpx.HTTPStatusError as http_status_error:
if 400 <= http_status_error.response.status_code < 500:
@ -363,6 +387,59 @@ async def fetch_conversation_root(
return await fetch_conversation_root(db_session, in_reply_to_object)
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(
db_session: AsyncSession,
ap_type: str,
@ -384,6 +461,7 @@ async def send_create(
content, tags, mentioned_actors = await markdownify(db_session, source)
attachments = []
in_reply_to_object: AnyboxObject | None = None
if in_reply_to:
in_reply_to_object = await get_anybox_object_by_ap_id(db_session, in_reply_to)
if not in_reply_to_object:
@ -401,23 +479,6 @@ async def send_create(
context = in_reply_to_object.ap_context
conversation = in_reply_to_object.ap_context
if in_reply_to_object.is_from_outbox:
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.ap_id == in_reply_to,
)
.values(replies_count=models.OutboxObject.replies_count + 1)
)
elif in_reply_to_object.is_from_inbox:
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.ap_id == in_reply_to,
)
.values(replies_count=models.InboxObject.replies_count + 1)
)
for (upload, filename, alt_text) in uploads:
attachments.append(upload_to_attachment(upload, filename, alt_text))
@ -536,6 +597,31 @@ async def send_create(
)
await db_session.commit()
# Refresh the replies counter if needed
if in_reply_to_object:
new_replies_count = await _get_replies_count(
db_session, in_reply_to_object.ap_id
)
if in_reply_to_object.is_from_outbox:
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.ap_id == in_reply_to_object.ap_id,
)
.values(replies_count=new_replies_count)
)
elif in_reply_to_object.is_from_inbox:
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.ap_id == in_reply_to_object.ap_id,
)
.values(replies_count=new_replies_count)
)
await db_session.commit()
return note_id
@ -708,6 +794,17 @@ async def _compute_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]:
return (
(
@ -859,7 +956,7 @@ async def _handle_delete_activity(
except ap.ObjectNotFoundError:
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(
"Received Delete for an unknown object "
f"{delete_activity.activity_object_ap_id}"
@ -941,6 +1038,29 @@ async def _handle_delete_activity(
await db_session.flush()
async def _get_replies_count(
db_session: AsyncSession,
replied_object_ap_id: str,
) -> int:
return (
await db_session.scalar(
select(func.count(models.InboxObject.id)).where(
func.json_extract(models.InboxObject.ap_object, "$.inReplyTo")
== replied_object_ap_id,
models.InboxObject.is_deleted.is_(False),
)
)
) + (
await db_session.scalar(
select(func.count(models.OutboxObject.id)).where(
func.json_extract(models.OutboxObject.ap_object, "$.inReplyTo")
== replied_object_ap_id,
models.OutboxObject.is_deleted.is_(False),
)
)
)
async def _revert_side_effect_for_deleted_object(
db_session: AsyncSession,
delete_activity: models.InboxObject,
@ -961,20 +1081,28 @@ async def _revert_side_effect_for_deleted_object(
# also needs to be forwarded
is_delete_needs_to_be_forwarded = True
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.id == replied_object.id,
)
.values(replies_count=models.OutboxObject.replies_count - 1)
.values(replies_count=new_replies_count)
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.id == replied_object.id,
)
.values(replies_count=models.InboxObject.replies_count - 1)
.values(replies_count=new_replies_count)
)
if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id:
@ -1281,13 +1409,16 @@ async def _handle_move_activity(
return None
# Fetch the target account
new_actor_id = move_activity.ap_object.get("target")
if not new_actor_id:
target = move_activity.ap_object.get("target")
if not target:
logger.warning("Missing target")
return None
new_actor_id = ap.get_id(target)
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
if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])):
logger.warning(
@ -1310,7 +1441,21 @@ async def _handle_move_activity(
await _send_undo(db_session, following.outbox_object.ap_id)
# Follow the new one
await _send_follow(db_session, new_actor_id)
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)
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(
@ -1326,7 +1471,8 @@ async def _handle_update_activity(
updated_actor = RemoteActor(wrapped_object)
if (
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
):
raise ValueError(
@ -1387,8 +1533,9 @@ async def _handle_create_activity(
logger.warning(
f"Got a Delete for {ro.ap_id} from {delete_object.actor.ap_id}??"
)
return None
else:
logger.info("Got a Delete for this object, deleting activity")
logger.info("Already received a Delete for this object, deleting activity")
create_activity.is_deleted = True
await db_session.flush()
return None
@ -1429,8 +1576,11 @@ async def _process_note_object(
from_actor: models.Actor,
ro: RemoteObject,
forwarded_by_actor: models.Actor | None = None,
) -> None:
if parent_activity.ap_type not in ["Create", "Read"]:
process_quoted_url: bool = True,
) -> models.InboxObject:
if process_quoted_url and parent_activity.quote_url == ro.ap_id:
logger.info(f"Processing quoted URL for {parent_activity.ap_id}")
elif parent_activity.ap_type not in ["Create", "Read"]:
raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}")
ap_published_at = now()
@ -1471,6 +1621,9 @@ async def _process_note_object(
is_hidden_from_stream=not (
(not is_reply and is_from_following) or is_mention or is_local_reply
),
# We may already have some replies in DB
replies_count=await _get_replies_count(db_session, ro.ap_id),
quoted_inbox_object_id=None,
)
db_session.add(inbox_object)
@ -1494,20 +1647,28 @@ async def _process_note_object(
replied_object, # type: ignore # outbox check below
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute(
update(models.OutboxObject)
.where(
models.OutboxObject.id == replied_object.id,
)
.values(replies_count=models.OutboxObject.replies_count + 1)
.values(replies_count=new_replies_count)
)
else:
new_replies_count = await _get_replies_count(
db_session, replied_object.ap_id
)
await db_session.execute(
update(models.InboxObject)
.where(
models.InboxObject.id == replied_object.id,
)
.values(replies_count=models.InboxObject.replies_count + 1)
.values(replies_count=new_replies_count)
)
# This object is a reply of a local object, we may need to forward it
@ -1543,6 +1704,28 @@ async def _process_note_object(
)
db_session.add(notif)
await db_session.flush()
if ro.quote_url and process_quoted_url:
try:
quoted_raw_object = await ap.fetch(ro.quote_url)
quoted_object_actor = await fetch_actor(
db_session, ap.get_actor_id(quoted_raw_object)
)
quoted_ro = RemoteObject(quoted_raw_object, quoted_object_actor)
quoted_inbox_object = await _process_note_object(
db_session,
inbox_object,
from_actor=quoted_object_actor,
ro=quoted_ro,
process_quoted_url=False,
)
inbox_object.quoted_inbox_object_id = quoted_inbox_object.id
except Exception:
logger.exception("Failed to process quoted object")
return inbox_object
async def _handle_vote_answer(
db_session: AsyncSession,
@ -1654,10 +1837,32 @@ async def _handle_announce_activity(
# We already know about this object, show the announce in the
# stream if it's not already there, from an followed actor
# 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 (
now() - as_utc(relates_to_inbox_object.ap_published_at) # type: ignore
) > timedelta(hours=1):
not relates_to_inbox_object.is_hidden_from_stream
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
else:
# Save it as an inbox object
if not announce_activity.activity_object_ap_id:
@ -1665,6 +1870,12 @@ async def _handle_announce_activity(
announced_raw_object = await ap.fetch(
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(
db_session, ap.get_actor_id(announced_raw_object)
)
@ -1721,10 +1932,12 @@ async def _process_transient_object(
raw_object: ap.RawObject,
from_actor: models.Actor,
) -> None:
# TODO: track featured/pinned objects for actors
ap_type = raw_object["type"]
if ap_type in ["Add", "Remove"]:
logger.info(f"Dropping unsupported {ap_type} object")
else:
# FIXME(ts): handle transient create
logger.warning(f"Received unknown {ap_type} object")
return None
@ -1735,6 +1948,28 @@ async def save_to_inbox(
raw_object: ap.RawObject,
sent_by_ap_actor_id: str,
) -> 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:
actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"]))
except ap.ObjectNotFoundError:
@ -1748,7 +1983,7 @@ async def save_to_inbox(
logger.warning(f"Server {actor.server} is blocked")
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)
return None
@ -1766,7 +2001,13 @@ async def save_to_inbox(
)
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(
f"Failed to verify LD sig, fetching remote object {raw_object_id}"
)
@ -1943,6 +2184,9 @@ async def save_to_inbox(
relates_to_outbox_object,
relates_to_inbox_object,
)
elif activity_ro.ap_type == "View":
# View is used by Peertube, there's nothing useful we can do with it
await db_session.delete(inbox_object)
else:
logger.warning(f"Received an unknown {inbox_object.ap_type} object")
@ -1956,7 +2200,7 @@ async def _prefetch_actor_outbox(
"""Try to fetch some notes to fill the stream"""
saved = 0
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)
raw_activity = await ap.fetch(activity_id)
if ap.as_list(raw_activity["type"])[0] == "Create":
@ -1969,7 +2213,8 @@ async def _prefetch_actor_outbox(
if not saved_inbox_object.in_reply_to:
saved_inbox_object.is_hidden_from_stream = False
saved += 1
saved += 1
if saved >= 5:
break

View File

@ -12,8 +12,10 @@ from fastapi import HTTPException
from fastapi import Request
from itsdangerous import URLSafeTimedSerializer
from loguru import logger
from markdown import markdown
from app.utils.emoji import _load_emojis
from app.utils.version import get_version_commit
ROOT_DIR = Path().parent.resolve()
@ -24,7 +26,7 @@ VERSION_COMMIT = "dev"
try:
from app._version import VERSION_COMMIT # type: ignore
except ImportError:
pass
VERSION_COMMIT = get_version_commit()
# Force reloading cache when the CSS is updated
CSS_HASH = "none"
@ -34,6 +36,31 @@ try:
except FileNotFoundError:
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}"
USER_AGENT = f"microblogpub/{VERSION}"
@ -71,6 +98,12 @@ class Config(pydantic.BaseModel):
metadata: list[_ProfileMetadata] | None = None
code_highlighting_theme = "friendly_grayscale"
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
@ -114,13 +147,23 @@ _SCHEME = "https" if CONFIG.https else "http"
ID = f"{_SCHEME}://{DOMAIN}"
USERNAME = CONFIG.username
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
HIDES_FOLLOWERS = CONFIG.hides_followers
HIDES_FOLLOWING = CONFIG.hides_following
PRIVACY_REPLACE = None
if 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}
ALSO_KNOWN_AS = CONFIG.also_known_as
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
CUSTOM_FOOTER = (
markdown(
CONFIG.custom_footer.replace("{version}", VERSION), extensions=["mdx_linkify"]
)
if CONFIG.custom_footer
else None
)
BASE_URL = ID
DEBUG = CONFIG.debug
@ -130,6 +173,9 @@ KEY_PATH = (
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
)
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
if CONFIG.emoji:
EMOJIS = CONFIG.emoji
# Emoji template for the FE
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
@ -137,6 +183,8 @@ _load_emojis(ROOT_DIR, BASE_URL)
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
MOVED_TO = _get_moved_to()
session_serializer = URLSafeTimedSerializer(
CONFIG.secret,
@ -152,10 +200,19 @@ def generate_csrf_token() -> str:
return csrf_serializer.dumps(secrets.token_hex(16)) # type: ignore
def verify_csrf_token(csrf_token: str = Form()) -> None:
def verify_csrf_token(
csrf_token: str = Form(),
redirect_url: str | None = Form(None),
) -> None:
please_try_again = "please try again"
if redirect_url:
please_try_again = f'<a href="{redirect_url}">please try again</a>'
try:
csrf_serializer.loads(csrf_token, max_age=1800)
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
logger.exception("Failed to verify CSRF token")
raise HTTPException(status_code=403, detail="CSRF error")
raise HTTPException(
status_code=403,
detail=f"The security token has expired, {please_try_again}",
)
return None

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import DB_PATH
from app.config import DEBUG
from app.config import SQLALCHEMY_DATABASE_URL
engine = create_engine(
@ -18,7 +19,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
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)

View File

@ -115,11 +115,8 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
# might race to fetch each other key
try:
actor = await ap.fetch(key_id, disable_httpsig=True)
except httpx.HTTPStatusError as http_err:
if http_err.response.status_code in [401, 403]:
actor = await ap.fetch(key_id, disable_httpsig=False)
else:
raise
except ap.ObjectUnavailableError:
actor = await ap.fetch(key_id, disable_httpsig=False)
if actor["type"] == "Key":
# The Key is not embedded in the Person
@ -130,6 +127,7 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
k.load_pub(actor["publicKey"]["publicKeyPem"])
# 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]:
raise ValueError(
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
@ -215,10 +213,13 @@ async def httpsig_checker(
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
return HTTPSigInfo(has_valid_signature=False)
has_valid_signature = _verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
)
# FIXME: fetch/update the user if the signature is wrong
httpsig_info = HTTPSigInfo(
has_valid_signature=_verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
),
has_valid_signature=has_valid_signature,
signed_by_ap_actor_id=k.owner,
server=server,
)

View File

@ -26,7 +26,7 @@ async def new_ap_incoming_activity(
raw_object: ap.RawObject,
) -> models.IncomingActivity | None:
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:
logger.warning(f"Dropping invalid object: {raw_object}")
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:
try:
async with db_session.begin_nested():
await save_to_inbox(
db_session,
next_activity.ap_object,
next_activity.sent_by_ap_actor_id,
await asyncio.wait_for(
save_to_inbox(
db_session,
next_activity.ap_object,
next_activity.sent_by_ap_actor_id,
),
timeout=60,
)
except httpx.TimeoutException as exc:
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(
seconds=access_token_info.expires_in
):
logger.info("Access token is expired")
logger.info("Access token has expired")
return False, None
return True, access_token_info

View File

@ -10,6 +10,7 @@ from app.source import _MENTION_REGEX
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
query = query.strip()
if query.startswith("@") or _MENTION_REGEX.match("@" + query):
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:
return RemoteActor(ap_obj)
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)

View File

@ -8,6 +8,7 @@ from typing import Any
from typing import MutableMapping
from typing import Type
import fastapi
import httpx
import starlette
from asgiref.typing import ASGI3Application
@ -20,6 +21,7 @@ from fastapi import FastAPI
from fastapi import Form
from fastapi import Request
from fastapi import Response
from fastapi.exception_handlers import http_exception_handler
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse
from fastapi.responses import PlainTextResponse
@ -35,6 +37,7 @@ from sqlalchemy.orm import joinedload
from starlette.background import BackgroundTask
from starlette.datastructures import Headers
from starlette.datastructures import MutableHeaders
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse
from starlette.types import Message
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 verify_csrf_token
from app.database import AsyncSession
from app.database import async_session
from app.database import get_db_session
from app.incoming_activities import new_ap_incoming_activity
from app.templates import is_current_user_admin
from app.uploads import UPLOAD_DIR
from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME
from app.utils.highlight import HIGHLIGHT_CSS_HASH
from app.utils.url import check_url
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):
# Next:
# - self-destruct + move support and actions/tasks for
# - doc for prune/move/delete
# - fix issue with followers from a blocked server (skip it?)
# - allow to share old notes
# - only show 10 most recent threads in DMs
@ -118,21 +126,21 @@ class CustomMiddleware:
# And add the security headers
headers = MutableHeaders(scope=message)
headers["X-Request-ID"] = request_id
headers["Server"] = "microblogpub"
headers["x-powered-by"] = "microblogpub"
headers[
"referrer-policy"
] = "no-referrer, strict-origin-when-cross-origin"
headers["x-content-type-options"] = "nosniff"
headers["x-xss-protection"] = "1; mode=block"
headers["x-frame-options"] = "SAMEORIGIN"
# TODO(ts): disallow inline CSS?
headers[
"content-security-policy"
] = "default-src 'self'; style-src 'self' 'unsafe-inline';"
headers["x-frame-options"] = "DENY"
headers["permissions-policy"] = "interest-cohort=()"
headers["content-security-policy"] = (
f"default-src 'self'; "
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
)
if not DEBUG:
headers[
"strict-transport-security"
] = "max-age=63072000; includeSubdomains"
headers["strict-transport-security"] = "max-age=63072000;"
await send(message) # type: ignore
@ -164,7 +172,15 @@ class CustomMiddleware:
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(
"/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")
@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):
media_type = "application/activity+json"
@ -204,6 +251,7 @@ async def index(
page: int | None = None,
) -> templates.TemplateResponse | ActivityPubResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
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())
.offset(page_offset)
.limit(page_size)
@ -354,6 +403,20 @@ async def _build_followx_collection(
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")
async def followers(
request: Request,
@ -364,15 +427,27 @@ async def followers(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
page=page,
next_cursor=next_cursor,
if config.HIDES_FOLLOWERS:
return ActivityPubResponse(
await _empty_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
)
)
)
else:
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
page=page,
next_cursor=next_cursor,
)
)
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
followers_result = await db_session.scalars(
@ -411,15 +486,27 @@ async def following(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
page=page,
next_cursor=next_cursor,
if config.HIDES_FOLLOWING:
return ActivityPubResponse(
await _empty_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
)
)
)
else:
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
page=page,
next_cursor=next_cursor,
)
)
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
following = (
@ -685,10 +772,10 @@ async def tag_by_name(
.join(models.TaggedOutboxObject)
.where(*where)
)
if not tagged_count:
raise HTTPException(status_code=404)
if is_activitypub_requested(request):
if not tagged_count:
raise HTTPException(status_code=404)
outbox_object_ids = await db_session.execute(
select(models.OutboxObject.ap_id)
.join(
@ -736,6 +823,7 @@ async def tag_by_name(
"request": request,
"objects": outbox_objects,
},
status_code=200 if len(outbox_objects) else 404,
)
@ -904,29 +992,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}")
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
url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url)
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(
proxy_resp.aiter_raw(),
status_code=proxy_resp.status_code,
headers=_filter_proxy_resp_headers(
proxy_resp,
[
"content-length",
"content-type",
"content-range",
"accept-ranges" "etag",
"cache-control",
"expires",
"date",
"last-modified",
],
headers=_add_cache_control(
_filter_proxy_resp_headers(
proxy_resp,
[
"content-length",
"content-type",
"content-range",
"accept-ranges",
"etag",
"expires",
"date",
"last-modified",
],
)
),
background=BackgroundTask(proxy_resp.aclose),
)
@ -954,22 +1062,24 @@ async def serve_proxy_media_resized(
)
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(
proxy_resp.content,
"proxy error",
status_code=proxy_resp.status_code,
)
# Filter the headers
proxy_resp_headers = _filter_proxy_resp_headers(
proxy_resp,
[
"content-type",
"etag",
"cache-control",
"expires",
"last-modified",
],
proxy_resp_headers = _add_cache_control(
_filter_proxy_resp_headers(
proxy_resp,
[
"content-type",
"etag",
"expires",
"last-modified",
],
)
)
try:
@ -978,23 +1088,31 @@ async def serve_proxy_media_resized(
if getattr(i, "is_animated", False):
raise ValueError
i.thumbnail((size, size))
resized_buf = BytesIO()
i.save(resized_buf, format=i.format)
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()
i.save(resized_buf, format=i.format)
resized_buf.seek(0)
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
if len(resized_content) < 2**20:
_RESIZED_CACHE[(url, size)] = (
resized_content,
resized_mimetype,
proxy_resp_headers,
_strip_content_type(proxy_resp_headers),
)
return PlainTextResponse(
resized_content,
media_type=resized_mimetype,
headers=proxy_resp_headers,
headers=_strip_content_type(proxy_resp_headers),
)
except ValueError:
return PlainTextResponse(
@ -1028,6 +1146,7 @@ async def serve_attachment(
return FileResponse(
UPLOAD_DIR / content_hash,
media_type=upload.content_type,
headers={"Cache-Control": "max-age=31536000"},
)
@ -1049,7 +1168,8 @@ async def serve_attachment_thumbnail(
return FileResponse(
UPLOAD_DIR / (content_hash + "_resized"),
media_type=upload.content_type,
media_type="image/webp",
headers={"Cache-Control": "max-age=31536000"},
)

View File

@ -45,7 +45,7 @@ class Actor(Base, BaseActor):
created_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_type = Column(String, nullable=False)
@ -75,7 +75,7 @@ class InboxObject(Base, BaseObject):
ap_actor_id = Column(String, nullable=False)
ap_type = Column(String, nullable=False, index=True)
ap_id = Column(String, nullable=False, unique=True, index=True)
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
ap_context = Column(String, nullable=True)
ap_published_at = Column(DateTime(timezone=True), nullable=False)
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
@ -113,6 +113,18 @@ class InboxObject(Base, BaseObject):
uselist=False,
)
quoted_inbox_object_id = Column(
Integer,
ForeignKey("inbox.id", name="fk_quoted_inbox_object_id"),
nullable=True,
)
quoted_inbox_object: Mapped[Optional["InboxObject"]] = relationship(
"InboxObject",
foreign_keys=quoted_inbox_object_id,
remote_side=id,
uselist=False,
)
undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
# Link the oubox AP ID to allow undo without any extra query
@ -126,7 +138,7 @@ class InboxObject(Base, BaseObject):
is_deleted = Column(Boolean, nullable=False, default=False)
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)
@ -147,6 +159,12 @@ class InboxObject(Base, BaseObject):
def is_from_inbox(self) -> bool:
return True
@property
def quoted_object(self) -> Optional["InboxObject"]:
if self.quoted_inbox_object_id:
return self.quoted_inbox_object
return None
class OutboxObject(Base, BaseObject):
__tablename__ = "outbox"
@ -160,7 +178,7 @@ class OutboxObject(Base, BaseObject):
public_id = 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_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
@ -176,7 +194,7 @@ class OutboxObject(Base, BaseObject):
likes_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(
Integer, nullable=False, default=0, server_default="0"
)
@ -281,6 +299,10 @@ class OutboxObject(Base, BaseObject):
def is_from_outbox(self) -> bool:
return True
@property
def quoted_object(self) -> Optional["InboxObject"]:
return None
class Follower(Base):
__tablename__ = "follower"
@ -537,6 +559,8 @@ class NotificationType(str, enum.Enum):
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
MOVE = "move"
LIKE = "like"
UNDO_LIKE = "undo_like"

View File

@ -67,6 +67,7 @@ async def _send_actor_update_if_needed(
logger.info("Will send an Update for the local actor")
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 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
# contacted
followers = (
(
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
recipients = await compute_all_known_recipients(db_session)
for rcp in recipients:
await new_outgoing_activity(
db_session,
recipient=rcp,

View File

@ -13,6 +13,40 @@ $code-highlight-background: #f0f0f0;
// Load custom theme
@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 {
.p-summary {
display: inline-block;
@ -61,13 +95,6 @@ blockquote {
color: $muted-color;
}
.poll-bar {
width:100%;height:20px;
line {
stroke: $secondary-color;
}
}
.light-background {
background: $light-background;
}
@ -116,6 +143,9 @@ dl {
strong {
color: $primary-color;
}
span {
color: $muted-color;
}
}
div.highlight {
@ -182,6 +212,7 @@ a {
}
}
#main {
display: flex;
flex: 1;
}
main {
@ -189,11 +220,36 @@ main {
max-width: 1000px;
margin: 30px auto;
}
.main-flex {
display: flex;
flex: 1;
}
.centered {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
div {
display: block;
}
}
footer {
width: 100%;
max-width: 1000px;
margin: 20px auto;
color: $muted-color;
width: 100%;
max-width: 1000px;
margin: 20px auto;
color: $muted-color;
p {
margin: 0;
}
}
.tiny-actor-icon {
max-width: 24px;
max-height: 24px;
position: relative;
top: 5px;
}
.actor-box {
display: flex;
@ -217,6 +273,9 @@ footer {
padding: 0 20px;
li {
display: block;
span {
padding-right:10px;
}
}
}
@ -251,6 +310,57 @@ footer {
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 {
form {
margin: 15px 0;
@ -311,7 +421,7 @@ nav.flexbox {
}
}
.activity-attachment {
margin: 30px 0;
margin: 30px 0 20px 0;
img, audio, video {
width: 100%;
max-width: 740px;
@ -322,6 +432,20 @@ nav.flexbox {
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 {
border: 2px dashed $secondary-color;
}
@ -344,3 +468,60 @@ nav.flexbox {
.emoji, .custom-emoji {
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,16 +1,17 @@
import re
import typing
from markdown import markdown
from sqlalchemy import select
from app import models
from app import webfinger
from app.actor import Actor
from app.actor import fetch_actor
from app.config import BASE_URL
from app.database import AsyncSession
from app.utils import emoji
if typing.TYPE_CHECKING:
from app.actor import Actor
def _set_a_attrs(attrs, new=False):
attrs[(None, "target")] = "_blank"
@ -24,9 +25,7 @@ _HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
async def _hashtagify(
db_session: AsyncSession, content: str
) -> tuple[str, list[dict[str, str]]]:
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
tags = []
hashtags = re.findall(_HASHTAG_REGEX, content)
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
@ -41,14 +40,20 @@ async def _hashtagify(
async def _mentionify(
db_session: AsyncSession,
content: str,
) -> tuple[str, list[dict[str, str]], list[Actor]]:
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
from app import models
from app.actor import fetch_actor
tags = []
mentioned_actors = []
for mention in re.findall(_MENTION_REGEX, content):
_, username, domain = mention.split("@")
actor = (
await db_session.execute(
select(models.Actor).where(models.Actor.handle == mention)
select(models.Actor).where(
models.Actor.handle == mention,
models.Actor.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if not actor:
@ -69,19 +74,19 @@ async def _mentionify(
async def markdownify(
db_session: AsyncSession,
content: str,
mentionify: bool = True,
hashtagify: bool = True,
) -> tuple[str, list[dict[str, str]], list[Actor]]:
enable_mentionify: bool = True,
enable_hashtagify: bool = True,
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
"""
>>> content, tags = markdownify("Hello")
"""
tags = []
mentioned_actors: list[Actor] = []
if hashtagify:
content, hashtag_tags = await _hashtagify(db_session, content)
mentioned_actors: list["Actor"] = []
if enable_hashtagify:
content, hashtag_tags = hashtagify(content)
tags.extend(hashtag_tags)
if mentionify:
if enable_mentionify:
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
tags.extend(mention_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 Object
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 VERSION
from app.config import generate_csrf_token
@ -90,6 +90,7 @@ async def render_template(
request: Request,
template: str,
template_args: dict[str, Any] | None = None,
status_code: int = 200,
) -> TemplateResponse:
if template_args is None:
template_args = {}
@ -103,7 +104,6 @@ async def render_template(
"request": request,
"debug": DEBUG,
"microblogpub_version": VERSION,
"css_hash": CSS_HASH,
"is_admin": is_admin,
"csrf_token": generate_csrf_token(),
"highlight_css": HIGHLIGHT_CSS,
@ -131,8 +131,10 @@ async def render_template(
select(func.count(models.Following.id))
),
"actor_types": ap.ACTOR_TYPES,
"custom_footer": CUSTOM_FOOTER,
**template_args,
},
status_code=status_code,
)
@ -377,7 +379,7 @@ def _html2text(content: 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)
@ -414,3 +416,8 @@ _templates.env.filters["pluralize"] = _pluralize
_templates.env.filters["parse_datetime"] = _parse_datetime
_templates.env.filters["poll_item_pct"] = _poll_item_pct
_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 %}
<div class="actor-action">
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 %}
</div>
{{ utils.display_object(anybox_object) }}

View File

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

View File

@ -25,7 +25,7 @@
</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_redirect_url() }}
<p>
@ -38,7 +38,7 @@
{% if request.query_params.type == "Article" %}
<p>
<input type="text" style="width:95%" name="name" placeholder="Title">
<input type="text" class="width-95" name="name" placeholder="Title">
</p>
{% endif %}
@ -49,7 +49,7 @@
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
{% 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" %}
<p>
@ -69,20 +69,20 @@
</p>
{% for i in ["1", "2", "3", "4"] %}
<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>
{% endfor %}
{% endif %}
<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>
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
</p>
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
<p>
<input id="files" name="files" type="file" multiple style="width:95%;">
<input id="files" name="files" type="file" class="width-95" multiple>
</p>
<div id="alts"></div>
<p>
@ -90,5 +90,5 @@
</p>
</form>
</div>
<script src="/static/new.js"></script>
<script src="/static/new.js?v={{ JS_HASH }}"></script>
{% endblock %}

View File

@ -9,10 +9,21 @@
{{ utils.display_actor(actor, actors_metadata, with_details=True) }}
{% for inbox_object in inbox_objects %}
{% 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) }}
{% else %}
{{ utils.display_object(inbox_object) }}
{% endif %}
{% 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 %}

View File

@ -12,7 +12,7 @@
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
{% for outbox_object in objects %}
<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>
{% endfor %}
</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>
{% endmacro %}
<div style="margin:30px 0 0 0;">
<div class="public-top-menu">
<nav class="flexbox">
<ul>
<li>{{ header_link("index", "Notes") }}</li>
{% if articles_count %}
<li>{{ header_link("articles", "Articles") }}</li>
{% endif %}
{% if not HIDES_FOLLOWERS or is_admin %}
<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>
{% endif %}
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
</ul>
</nav>

View File

@ -21,26 +21,34 @@
{% block content %}
{% include "header.html" %}
<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>{{ local_actor.display_name }}</strong> shared</div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% if objects %}
<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 %}
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
{% endif %}
{% if has_next_page %}
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
{% endif %}
</div>
{% else %}
<div class="empty-state">
<p>Nothing to see here yet!</p>
</div>
{% endif %}
{% endfor %}
</div>
<div class="box">
{% if has_previous_page %}
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
{% endif %}
{% if has_next_page %}
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
{% endif %}
</div>
{% endblock %}

View File

@ -2,15 +2,15 @@
{% extends "layout.html" %}
{% block content %}
<div class="box">
<div style="display:flex;column-gap: 20px;">
<div class"indieauth-box">
{% if client.logo %}
<div style="flex:initial;width:100px;">
<img src="{{client.logo | media_proxy_url }}" style="max-width:100px;" alt="{{ client.name }} logo">
<div class="indieauth-logo">
<img src="{{client.logo | media_proxy_url }}" alt="{{ client.name }} logo">
</div>
{% endif %}
<div style="flex:1;">
<div style="padding-left: 20px;">
<a class="lcolor" style="font-size:1.2em;font-weight:600;" href="{{ client.url }}">{{ client.name }}</a>
<div class="indieauth-details">
<div>
<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>

View File

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

View File

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

View File

@ -19,7 +19,9 @@
{% if error %}
<div class="box error-box">
{% 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" %}
<p>Lookup timed out, please try refreshing the page.</p>
{% else %}

View File

@ -5,9 +5,10 @@
<title>{{ local_actor.display_name }} - Notifications</title>
{% endblock %}
{% macro notif_actor_action(notif, text) %}
{% macro notif_actor_action(notif, text, with_icon=False) %}
<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>
</div>
{% endmacro %}
@ -35,18 +36,26 @@
{%- elif notif.notification_type.value == "follow_request_rejected" %}
{{ notif_actor_action(notif, "rejected your follow request") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{%- elif notif.notification_type.value == "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" %}
{{ notif_actor_action(notif, "liked a post") }}
{{ notif_actor_action(notif, "liked a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }}
{% 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) }}
{% 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) }}
{% elif notif.notification_type.value == "undo_announce" %}
{{ 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" %}
{{ notif_actor_action(notif, "mentioned you") }}
{{ utils.display_object(notif.inbox_object) }}
@ -57,7 +66,7 @@
{% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% 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>
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "updated_webmention" %}
@ -67,7 +76,7 @@
{% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% 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>
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "deleted_webmention" %}
@ -77,7 +86,7 @@
{% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% 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>
{{ utils.display_object(notif.outbox_object) }}
{% else %}
@ -88,4 +97,15 @@
</div>
{%- endfor %}
</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 %}

View File

@ -3,7 +3,11 @@
{% block head %}
{% 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>
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
<link rel="alternate" href="{{ request.url }}" type="application/activity+json">

View File

@ -96,11 +96,11 @@
</form>
{% endmacro %}
{% macro admin_delete_button(ap_object_id) %}
<form action="{{ request.url_for("admin_actions_delete") }}" method="POST" onsubmit="return confirm('Do you really want to delete this object?');">
{% macro admin_delete_button(ap_object) %}
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="hidden" name="redirect_url" value="{% if request.url.path.endswith("/" + ap_object.public_id) or (request.url.path == "/admin/object" and request.query_params.ap_id.endswith("/" + ap_object.public_id)) %}{{ request.base_url}}{% else %}{{ request.url }}{% endif %}">
<input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
<input type="submit" value="delete">
</form>
{% endmacro %}
@ -154,9 +154,10 @@
</form>
{% 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">
<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>
</form>
{% endmacro %}
@ -180,9 +181,15 @@
</nav>
{% 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">
<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>
</div>
@ -199,7 +206,7 @@
<div class="icon-box">
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
</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 class="actor-handle p-name">{{ actor.handle }}</div>
</a>
@ -216,7 +223,7 @@
{% elif metadata.is_follow_request_sent %}
<li>follow request sent</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
{% else %}
{% elif not actor.moved_to %}
<li>{{ admin_follow_button(actor) }}</li>
{% endif %}
{% if metadata.is_follower %}
@ -224,7 +231,11 @@
{% if not metadata.is_following and not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
</li>
{% elif actor.is_from_db and not with_details %}
<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 %}
{% if actor.is_from_db %}
{% if actor.is_blocked %}
@ -284,17 +295,17 @@
{% macro display_og_meta(object) %}
{% if object.og_meta %}
{% for og_meta in object.og_meta %}
<div class="activity-og-meta" style="display:flex;column-gap: 20px;margin:20px 0;">
{% for og_meta in object.og_meta[:1] %}
<div class="activity-og-meta">
{% if og_meta.image %}
<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>
{% endif %}
<div>
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
{% if og_meta.site_name %}
<small style="display:block;">{{ og_meta.site_name }}</small>
<small>{{ og_meta.site_name }}</small>
{% endif %}
</div>
</div>
@ -307,27 +318,27 @@
{% 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"))) %}
<div>
<label for="{{attachment.proxied_url}}" class="label-btn" style="display:inline-block;">show/hide sensitive content</label>
<div class="attachment-wrapper">
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
<div>
<div class="sensitive-attachment">
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
<div class="sensitive-attachment-box">
<div></div>
{% else %}
<div style="margin-top:20px;">
<div class="attachment-item">
{% endif %}
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
{% 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 %}
{% 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>
{% 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" %}
<a href="{{ attachment.url }}" class="attachment" style="display:inline-block;margin-bottom: 15px;">{{ attachment.url }}</a>
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
{% else %}
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
{% endif %}
@ -358,13 +369,13 @@
{% endif %}
{% 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">
<a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
in reply to {{ object.in_reply_to|truncate(64, True) }}
</a>
{% endif %}
{% 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 %}
{% if is_article_mode %}
@ -384,6 +395,13 @@
{{ object.content | clean_html(object) | safe }}
</div>
{% if object.quoted_object %}
<div class="ap-object-expanded ap-quoted-object">
{{ display_object(object.quoted_object) }}
</div>
{% endif %}
{% if object.ap_type == "Question" %}
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
{% if can_vote %}
@ -394,11 +412,11 @@
{% endif %}
{% if object.poll_items %}
<ul style="list-style-type: none;padding:0;">
<ul class="poll-items">
{% for item in object.poll_items %}
<li style="display:block;">
<li>
{% set pct = item | poll_item_pct(object.poll_voters_count) %}
<p style="margin:20px 0 10px 0;">
<p>
{% 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}}">
<label for="{{object.permalink_id}}-{{item.name}}">
@ -407,17 +425,17 @@
{{ item.name | clean_html(object) | safe }}
{% 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 %}
{% if can_vote %}
</label>
{% 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>
<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>
</li>
{% endfor %}
@ -431,16 +449,17 @@
</form>
{% endif %}
{{ display_og_meta(object) }}
{% endif %}
{{ display_og_meta(object) }}
</div>
{% if object.summary %}
</div>
{% endif %}
<div class="activity-attachment" style="margin-bottom:20px;">
<div class="activity-attachment">
{{ display_attachments(object) }}
</div>
@ -496,7 +515,7 @@
{% if (object.is_from_outbox or is_admin) and object.replies_count %}
<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>
{% endif %}
@ -508,7 +527,7 @@
<ul>
{% if object.is_from_outbox %}
<li>
{{ admin_delete_button(object.ap_id) }}
{{ admin_delete_button(object) }}
</li>
<li>
@ -559,7 +578,7 @@
{% endif %}
{% if object.is_from_inbox or object.is_from_outbox %}
<li>
{{ admin_expand_button(object.ap_id) }}
{{ admin_expand_button(object) }}
</li>
{% endif %}
</ul>
@ -568,17 +587,17 @@
{% 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 %}
<div style="flex: 0 1 30%;max-width: 50%;">Likes
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
<div class="interactions-block">Likes
<div class="facepile-wrapper">
{% for like in likes %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" style="height:50px;" rel="noreferrer">
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}" style="max-width:50px;">
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" rel="noreferrer">
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}">
</a>
{% endfor %}
{% 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.
</div>
{% endif %}
@ -587,15 +606,15 @@
{% endif %}
{% if shares %}
<div style="flex: 0 1 30%;max-width: 50%;">Shares
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
<div class="interactions-block">Shares
<div class="facepile-wrapper">
{% for share in shares %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" style="height:50px;" rel="noreferrer">
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}" style="max-width:50px;">
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" rel="noreferrer">
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}">
</a>
{% endfor %}
{% 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.
</div>
{% endif %}
@ -604,13 +623,13 @@
{% endif %}
{% if webmentions %}
<div style="flex: 0 1 30%;max-width: 50%;">Webmentions
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
<div class="interactions-block">Webmentions
<div class="facepile-wrapper">
{% for webmention in webmentions %}
{% set wm = webmention.as_facepile_item %}
{% if wm %}
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" style="height:50px;" rel="noreferrer">
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}" style="max-width:50px;">
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" rel="noreferrer">
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}">
</a>
{% endif %}
{% endfor %}

View File

@ -5,6 +5,7 @@ import blurhash # type: ignore
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from PIL import ImageOps
from sqlalchemy import select
from app import activitypub as ap
@ -45,11 +46,13 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
width = None
height = None
if f.content_type.startswith("image"):
image_blurhash = blurhash.encode(f.file, x_components=4, y_components=3)
f.file.seek(0)
if f.content_type.startswith("image") and not f.content_type == "image/gif":
with Image.open(f.file) as _original_image:
# 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(
original_image.mode,
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.save(
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:
width, height = original_image.size
original_image.thumbnail((740, 740))
original_image.save(
width, height = destination_image.size
destination_image.thumbnail((740, 740))
destination_image.save(
UPLOAD_DIR / f"{content_hash}_resized",
format=original_image.format,
format="webp",
)
except Exception:
logger.exception(

View File

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

View File

@ -1,3 +1,4 @@
import asyncio
import mimetypes
import re
from typing import Any
@ -36,7 +37,7 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
# FIXME some page have no <title>
raw = {
"url": url,
"title": soup.find("title").text,
"title": soup.find("title").text.strip(),
"image": None,
"description": None,
"site_name": urlparse(url).hostname,
@ -65,7 +66,8 @@ async def external_urls(
tags_hrefs = set()
for tag in ro.tags:
if tag_href := tag.get("href"):
tags_hrefs.add(tag_href)
if tag_href and tag_href not in filter(None, [ro.quote_url]):
tags_hrefs.add(tag_href)
if tag.get("type") == "Mention":
if tag["href"] != LOCAL_ACTOR.ap_id:
mentioned_actor = await fetch_actor(db_session, tag["href"])
@ -80,6 +82,9 @@ async def external_urls(
soup = BeautifulSoup(ro.content, "html5lib")
for link in soup.find_all("a"):
h = link.get("href")
if not h:
continue
ph = urlparse(h)
mimetype, _ = mimetypes.guess_type(h)
if (
@ -124,9 +129,21 @@ async def og_meta_from_note(
) -> list[dict[str, Any]]:
og_meta = []
urls = await external_urls(db_session, ro)
logger.debug(f"Lookig OG metadata in {urls=}")
for url in urls:
logger.debug(f"Processing {url}")
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:
og_meta.append(maybe_og_meta.dict())
except httpx.HTTPError:

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
)
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()]
logger.info(f"Cancelling {len(tasks)} 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")

View File

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

View File

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

View File

@ -1,6 +1,6 @@
# Developer's guide
This guide assume you have some knoweldge of [ActivityPub](https://activitypub.rocks/).
This guide assumes you have some knowledge of [ActivityPub](https://activitypub.rocks/).
[TOC]
@ -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.
- 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)
- 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
@ -29,7 +30,7 @@ inv -l
### 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.
## 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.
Note that if you want to serve static assets via your reverse proxy (like nginx), clone it in a place
where accessible by your reverse proxy user.
where it is accessible by your reverse proxy user.
```bash
git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld
@ -55,6 +55,12 @@ docker compose stop
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
Assuming you have a working **Python 3.10+** environment.
@ -99,7 +105,7 @@ Setup a reverse proxy (see the next section).
### 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
git pull
@ -130,6 +136,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.
@ -147,8 +158,33 @@ server {
# path for static files
rewrite ^/static/(.*) /$1 break;
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

@ -29,18 +29,69 @@ You can tweak your profile by tweaking these items:
- `summary` (using Markdown)
- `icon_url`
Whenever one of these config items is updated, an `Update` activity will be sent to all know server to update your remote profile.
Whenever one of these config items is updated, an `Update` activity will be sent to all known servers to update your remote profile.
The server will need to be restarted for taking changes into account.
Before restarting the server, you can ensure you haven't made any mistakes by running the [configuration checking task](/user_guide.html#configuration-checking).
### Profile metadata
You can add metadata to your profile with the `metadata` config item.
Markdown is supported in the `value` field.
Be aware that most other 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
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
You can define domains to be rewritten to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
or [Nitter](https://nitter.net/about).
To do so, just add as these extra config items, this is a sample config that rewrite URLs for Twitter, Youtube, Reddit and Medium:
To do so, add these extra config items. This is a sample config that rewrite URLs for Twitter, Youtube, Reddit and Medium:
```toml
privacy_replace = [
{domain = "youtube.com", replace_by = "yewtu.be"},
{domain = "youtu.be", replace_by = "yewtu.be"},
{domain = "twitter.com", replace_by = "nitter.fdn.fr"},
{domain = "medium.com", replace_by = "scribe.rip"},
{domain = "reddit.com", replace_by = "teddit.net"},
@ -49,6 +100,16 @@ privacy_replace = [
### 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
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;
```
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 notes will be visible on the homepage.
Only the last 20 followers/follows you be showing on the public website.
Only the last 20 followers/follows you have will be shown on the public website.
And only the last 20 interactions (likes/shares/webmentions) will be displayed, to keep things simple/clean.
## Admin section
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin`.
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin/login`.
The password is the one set during the initial configuration.
### Lookup
@ -129,35 +214,36 @@ microblog.pub supports the most common interactions supported by the Fediverse.
### 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.
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
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.
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 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
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
@ -171,3 +257,200 @@ All the data generated by the server is located in the `data/` directory:
- Uploaded media
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
pidfile=data/supervisord.pid
[fcgi-program:uvicorn]
socket=tcp://0.0.0.0:8000
command=uvicorn app.main:app --no-server-header --fd 0
numprocs=2
process_name=uvicorn-%(process_num)d
[program:uvicorn]
command=uvicorn app.main:app --no-server-header --host 0.0.0.0
numprocs=1
autorestart=true
redirect_stderr=true
stdout_logfile=data/uvicorn.log
stdout_logfile_maxbytes=50MB
@ -16,6 +15,7 @@ stdout_logfile_maxbytes=50MB
[program:incoming_worker]
command=inv process-incoming-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=data/incoming.log
stdout_logfile_maxbytes=50MB
@ -23,6 +23,7 @@ stdout_logfile_maxbytes=50MB
[program:outgoing_worker]
command=inv process-outgoing-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=data/outgoing.log
stdout_logfile_maxbytes=50MB

View File

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

View File

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

718
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"
Jinja2 = "^3.1.2"
fastapi = "^0.78.0"
uvicorn = "^0.17.6"
pycryptodome = "^3.14.1"
bcrypt = "^3.2.2"
itsdangerous = "^2.1.2"
@ -43,6 +42,9 @@ asgiref = "^3.5.2"
supervisor = "^4.2.4"
invoke = "^1.7.1"
boussole = "^2.0.0"
uvicorn = {extras = ["standard"], version = "^0.18.3"}
Brotli = "^1.0.9"
greenlet = "^1.1.3"
[tool.poetry.dev-dependencies]
black = "^22.3.0"

View File

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

149
tasks.py
View File

@ -1,6 +1,5 @@
import asyncio
import io
import subprocess
import tarfile
from contextlib import contextmanager
from pathlib import Path
@ -164,14 +163,12 @@ def stats(ctx):
@contextmanager
def embed_version() -> Generator[None, None, None]:
from app.utils.version import get_version_commit
version_file = Path("app/_version.py")
version_file.unlink(missing_ok=True)
version = (
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
.split()[0]
.decode()
)
version_file.write_text(f'VERSION_COMMIT = "{version}"')
version_commit = get_version_commit()
version_file.write_text(f'VERSION_COMMIT = "{version_commit}"')
try:
yield
finally:
@ -193,6 +190,109 @@ def prune_old_data(ctx):
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
def yunohost_config(
ctx,
@ -212,3 +312,38 @@ def yunohost_config(
summary=summary,
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

@ -84,12 +84,13 @@ def build_move_activity(
def build_note_object(
from_remote_actor: actor.RemoteActor,
from_remote_actor: actor.RemoteActor | models.Actor,
outbox_public_id: str | None = None,
content: str = "Hello",
to: list[str] = None,
cc: list[str] = None,
tags: list[ap.RawObject] = None,
in_reply_to: str | None = None,
) -> ap.RawObject:
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
@ -108,8 +109,8 @@ def build_note_object(
"url": from_remote_actor.ap_id + "/note/" + note_id,
"tag": tags or [],
"summary": None,
"inReplyTo": None,
"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_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
# 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.actor_id == saved_actor.id
assert follower.inbox_object_id == inbox_object.id
@ -414,3 +414,12 @@ def test_inbox__move_activity(
)
== 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

View File

@ -2,13 +2,17 @@ from unittest import mock
import respx
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import activitypub as ap
from app import models
from app import webfinger
from app.actor import LOCAL_ACTOR
from app.config import generate_csrf_token
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_as_follower
@ -49,16 +53,184 @@ def test_send_follow_request(
assert response.headers.get("Location") == "http://testserver/"
# 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.activity_object_ap_id == ra.ap_id
# 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.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>"
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>"
assert len(outbox_object.attachments) == 1
def test_send_create_activity__no_followers_and_with_mention(
db: Session,
client: TestClient,
@ -83,11 +255,11 @@ def test_send_create_activity__no_followers_and_with_mention(
assert response.status_code == 302
# 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"
# 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.recipient == ra.inbox_url
@ -119,11 +291,11 @@ def test_send_create_activity__with_followers(
assert response.status_code == 302
# 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"
# 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.recipient == follower.actor.inbox_url
@ -159,7 +331,7 @@ def test_send_create_activity__question__one_of(
assert response.status_code == 302
# 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.is_one_of_poll is True
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
# 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.recipient == follower.actor.inbox_url
@ -205,7 +377,7 @@ def test_send_create_activity__question__any_of(
assert response.status_code == 302
# 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.is_one_of_poll is False
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
# 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.recipient == follower.actor.inbox_url
@ -246,11 +418,11 @@ def test_send_create_activity__article(
assert response.status_code == 302
# 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_object["name"] == "Article"
# 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.recipient == follower.actor.inbox_url

View File

@ -1,3 +1,5 @@
from unittest import mock
import pytest
from fastapi.testclient import TestClient
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})
assert response.status_code == 200
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:
@ -40,14 +54,40 @@ def test_followers__html(client, db) -> None:
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:
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
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:
response = client.get("/following")
assert response.status_code == 200
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 sqlalchemy import select
from sqlalchemy.orm import Session
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
# 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 len(outbox_object.tags) == 1
emoji_tag = outbox_object.tags[0]

View File

@ -169,6 +169,53 @@ def setup_remote_actor_as_following_and_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(
actor: models.Actor, deleted_object_ap_id: str
) -> models.InboxObject: