mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2025-06-05 21:59:23 +02:00
Compare commits
95 Commits
2.0.0-rc.1
...
2.0.0-rc.8
Author | SHA1 | Date | |
---|---|---|---|
d352dc104a | |||
0c5ce67d4e | |||
9db7bdf0fb | |||
793a939046 | |||
c3eb44add7 | |||
9b75020c91 | |||
36a1a6bd9c | |||
164cd9bd00 | |||
698a2bae11 | |||
4613997fe3 | |||
4c995957a6 | |||
5c98b8dbfb | |||
48d5914851 | |||
8f00e522d7 | |||
62c9327500 | |||
a339ff93b1 | |||
afd253a1b4 | |||
509e10e79b | |||
d96ec913d4 | |||
5b505b0e37 | |||
530491ff10 | |||
48740ea8cb | |||
0d7c121781 | |||
a4cfd65009 | |||
540b9d1470 | |||
1c076049cf | |||
242bf7b515 | |||
2843155501 | |||
0badf0bc1f | |||
32692a7dcd | |||
817dd98c5c | |||
b6f0cd01d3 | |||
c985dd84c3 | |||
3d049da2e5 | |||
fd5293a05c | |||
3729500e3e | |||
2853bf2a28 | |||
0144a1c0d4 | |||
d93bcf6128 | |||
647add2bab | |||
f50a233ce9 | |||
d909bf93a0 | |||
8e7fbcc501 | |||
7a665df2b5 | |||
b5b56e9ed5 | |||
9a36b0edf5 | |||
20f996d165 | |||
602da69083 | |||
f6cfe06f66 | |||
c8a9793638 | |||
5eaa0f291b | |||
881d0ad899 | |||
5a20b9d23a | |||
919a61f75d | |||
7faa4655f8 | |||
cf6a891349 | |||
58b383ba4e | |||
57fc5ef913 | |||
5348398b23 | |||
572a84b4bd | |||
992cd55d7b | |||
6216b316e8 | |||
96eae971b8 | |||
928bdafeea | |||
dc89aeb70b | |||
25d3daa6d2 | |||
715df3c563 | |||
cb5d21baeb | |||
8d0b5d1114 | |||
4fcf585c23 | |||
6873ede288 | |||
e0ad21f335 | |||
b3f25e7da1 | |||
d44c8a58aa | |||
54aa2f51f4 | |||
3305d489ec | |||
e19c623c71 | |||
5905ad96b4 | |||
9093659b0a | |||
b99552384c | |||
949365d8ba | |||
a55b06b252 | |||
c30033c19e | |||
a6321f52d8 | |||
4e1e4d0ea8 | |||
110f7df962 | |||
4c86cd4be3 | |||
df06defbef | |||
b2f268682c | |||
567595bb4b | |||
91b8bb26b7 | |||
bd4d5a004a | |||
04da8725ed | |||
0c7a19749d | |||
2a37034775 |
@ -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
|
||||
|
24
Makefile
24
Makefile
@ -12,20 +12,32 @@ config:
|
||||
|
||||
.PHONY: update
|
||||
update:
|
||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
|
||||
|
||||
.PHONY: prune-old-data
|
||||
prune-old-data:
|
||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
||||
|
||||
.PHONY: webfinger
|
||||
webfinger:
|
||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
|
||||
|
||||
.PHONY: move-to
|
||||
move-to:
|
||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
|
||||
|
||||
.PHONY: self-destruct
|
||||
move-to:
|
||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
|
||||
self-destruct:
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
|
||||
|
||||
.PHONY: reset-password
|
||||
reset-password:
|
||||
-docker run --rm -it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv reset-password
|
||||
|
||||
.PHONY: check-config
|
||||
check-config:
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv check-config
|
||||
|
||||
.PHONY: compile-scss
|
||||
compile-scss:
|
||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv compile-scss
|
||||
|
@ -22,7 +22,7 @@ There are still some rough edges, but the server is mostly functional.
|
||||
- Author notes in Markdown, with code highlighting support
|
||||
- Dedicated section for articles/blog posts (enabled when the first article is posted)
|
||||
- Lightweight
|
||||
- Uses SQLite, and no external dependencies except Python 3.10+
|
||||
- Uses SQLite, and Python 3.10+
|
||||
- Can be deployed on small VPS
|
||||
- Privacy-aware
|
||||
- EXIF metadata (like GPS location) are stripped before storage
|
||||
|
@ -0,0 +1,48 @@
|
||||
"""Add a slug field for outbox objects
|
||||
|
||||
Revision ID: b28c0551c236
|
||||
Revises: 604d125ea2fb
|
||||
Create Date: 2022-10-30 14:09:14.540461+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b28c0551c236'
|
||||
down_revision = '604d125ea2fb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('slug', sa.String(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_outbox_slug'), ['slug'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
# Backfill the slug for existing articles
|
||||
from app.models import OutboxObject
|
||||
from app.utils.text import slugify
|
||||
sess = Session(op.get_bind())
|
||||
articles = sess.execute(select(OutboxObject).where(
|
||||
OutboxObject.ap_type == "Article")
|
||||
).scalars()
|
||||
for article in articles:
|
||||
title = article.ap_object["name"]
|
||||
article.slug = slugify(title)
|
||||
sess.commit()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_outbox_slug'))
|
||||
batch_op.drop_column('slug')
|
||||
|
||||
# ### end Alembic commands ###
|
@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from markdown import markdown
|
||||
|
||||
from app import config
|
||||
from app.config import ALSO_KNOWN_AS
|
||||
@ -14,6 +13,7 @@ from app.config import AP_CONTENT_TYPE # noqa: F401
|
||||
from app.config import MOVED_TO
|
||||
from app.httpsig import auth
|
||||
from app.key import get_pubkey_as_pem
|
||||
from app.source import dedup_tags
|
||||
from app.source import hashtagify
|
||||
from app.utils.url import check_url
|
||||
|
||||
@ -53,11 +53,26 @@ AS_EXTENDED_CTX = [
|
||||
]
|
||||
|
||||
|
||||
class ObjectIsGoneError(Exception):
|
||||
class FetchError(Exception):
|
||||
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
||||
resp_part = ""
|
||||
if resp:
|
||||
resp_part = f", got HTTP {resp.status_code}: {resp.text}"
|
||||
message = f"Failed to fetch {url}{resp_part}"
|
||||
super().__init__(message)
|
||||
self.resp = resp
|
||||
self.url = url
|
||||
|
||||
|
||||
class ObjectIsGoneError(FetchError):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectNotFoundError(Exception):
|
||||
class ObjectNotFoundError(FetchError):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectUnavailableError(FetchError):
|
||||
pass
|
||||
|
||||
|
||||
@ -86,6 +101,19 @@ class VisibilityEnum(str, enum.Enum):
|
||||
|
||||
|
||||
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
||||
_LOCAL_ACTOR_METADATA = []
|
||||
if config.CONFIG.metadata:
|
||||
for kv in config.CONFIG.metadata:
|
||||
kv_value, kv_tags = hashtagify(kv.value)
|
||||
_LOCAL_ACTOR_METADATA.append(
|
||||
{
|
||||
"name": kv.key,
|
||||
"type": "PropertyValue",
|
||||
"value": kv_value,
|
||||
}
|
||||
)
|
||||
_LOCAL_ACTOR_TAGS.extend(kv_tags)
|
||||
|
||||
|
||||
ME = {
|
||||
"@context": AS_EXTENDED_CTX,
|
||||
@ -98,7 +126,7 @@ ME = {
|
||||
"outbox": config.BASE_URL + "/outbox",
|
||||
"preferredUsername": config.USERNAME,
|
||||
"name": config.CONFIG.name,
|
||||
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
|
||||
"summary": _LOCAL_ACTOR_SUMMARY,
|
||||
"endpoints": {
|
||||
# For compat with servers expecting a sharedInbox...
|
||||
"sharedInbox": config.BASE_URL
|
||||
@ -106,16 +134,7 @@ ME = {
|
||||
},
|
||||
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
||||
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
||||
"attachment": [
|
||||
{
|
||||
"name": kv.key,
|
||||
"type": "PropertyValue",
|
||||
"value": markdown(kv.value, extensions=["mdx_linkify", "fenced_code"]),
|
||||
}
|
||||
for kv in config.CONFIG.metadata
|
||||
]
|
||||
if config.CONFIG.metadata
|
||||
else [],
|
||||
"attachment": _LOCAL_ACTOR_METADATA,
|
||||
"icon": {
|
||||
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
||||
"type": "Image",
|
||||
@ -126,7 +145,7 @@ ME = {
|
||||
"owner": config.ID,
|
||||
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
||||
},
|
||||
"tag": _LOCAL_ACTOR_TAGS,
|
||||
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
|
||||
}
|
||||
|
||||
if ALSO_KNOWN_AS:
|
||||
@ -135,6 +154,13 @@ if ALSO_KNOWN_AS:
|
||||
if MOVED_TO:
|
||||
ME["movedTo"] = MOVED_TO
|
||||
|
||||
if config.CONFIG.image_url:
|
||||
ME["image"] = {
|
||||
"mediaType": mimetypes.guess_type(config.CONFIG.image_url)[0],
|
||||
"type": "Image",
|
||||
"url": config.CONFIG.image_url,
|
||||
}
|
||||
|
||||
|
||||
class NotAnObjectError(Exception):
|
||||
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
||||
@ -166,11 +192,17 @@ async def fetch(
|
||||
|
||||
# Special handling for deleted object
|
||||
if resp.status_code == 410:
|
||||
raise ObjectIsGoneError(f"{url} is gone")
|
||||
raise ObjectIsGoneError(url, resp)
|
||||
elif resp.status_code in [401, 403]:
|
||||
raise ObjectUnavailableError(url, resp)
|
||||
elif resp.status_code == 404:
|
||||
raise ObjectNotFoundError(f"{url} not found")
|
||||
raise ObjectNotFoundError(url, resp)
|
||||
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as http_error:
|
||||
raise FetchError(url, resp) from http_error
|
||||
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
return resp.json()
|
||||
except json.JSONDecodeError:
|
||||
|
124
app/actor.py
124
app/actor.py
@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from typing import Union
|
||||
from urllib.parse import urlparse
|
||||
@ -12,6 +13,8 @@ from sqlalchemy.orm import joinedload
|
||||
from app import activitypub as ap
|
||||
from app import media
|
||||
from app.database import AsyncSession
|
||||
from app.utils.datetime import as_utc
|
||||
from app.utils.datetime import now
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from app.models import Actor as ActorModel
|
||||
@ -79,11 +82,21 @@ class Actor:
|
||||
|
||||
@property
|
||||
def icon_url(self) -> str | None:
|
||||
return self.ap_actor.get("icon", {}).get("url")
|
||||
if icon := self.ap_actor.get("icon"):
|
||||
return icon.get("url")
|
||||
return None
|
||||
|
||||
@property
|
||||
def icon_media_type(self) -> str | None:
|
||||
return self.ap_actor.get("icon", {}).get("mediaType")
|
||||
if icon := self.ap_actor.get("icon"):
|
||||
return icon.get("mediaType")
|
||||
return None
|
||||
|
||||
@property
|
||||
def image_url(self) -> str | None:
|
||||
if image := self.ap_actor.get("image"):
|
||||
return image.get("url")
|
||||
return None
|
||||
|
||||
@property
|
||||
def public_key_as_pem(self) -> str:
|
||||
@ -109,7 +122,7 @@ class Actor:
|
||||
|
||||
@property
|
||||
def tags(self) -> list[ap.RawObject]:
|
||||
return self.ap_actor.get("tag", [])
|
||||
return ap.as_list(self.ap_actor.get("tag", []))
|
||||
|
||||
@property
|
||||
def followers_collection_id(self) -> str | None:
|
||||
@ -189,26 +202,64 @@ async def fetch_actor(
|
||||
if existing_actor:
|
||||
if existing_actor.is_deleted:
|
||||
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
||||
return existing_actor
|
||||
else:
|
||||
if save_if_not_found:
|
||||
ap_actor = await ap.fetch(actor_id)
|
||||
# Some softwares uses URL when we expect ID
|
||||
if actor_id == ap_actor.get("url"):
|
||||
# Which mean we may already have it in DB
|
||||
existing_actor_by_url = (
|
||||
await db_session.scalars(
|
||||
select(models.Actor).where(
|
||||
models.Actor.ap_id == ap.get_id(ap_actor),
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if existing_actor_by_url:
|
||||
return existing_actor_by_url
|
||||
|
||||
return await save_actor(db_session, ap_actor)
|
||||
if now() - as_utc(existing_actor.updated_at) > timedelta(hours=24):
|
||||
logger.info(
|
||||
f"Refreshing {actor_id=} last updated {existing_actor.updated_at}"
|
||||
)
|
||||
try:
|
||||
ap_actor = await ap.fetch(actor_id)
|
||||
await update_actor_if_needed(
|
||||
db_session,
|
||||
existing_actor,
|
||||
RemoteActor(ap_actor),
|
||||
)
|
||||
return existing_actor
|
||||
except Exception:
|
||||
logger.exception(f"Failed to refresh {actor_id}")
|
||||
# If we fail to refresh the actor, return the cached one
|
||||
return existing_actor
|
||||
else:
|
||||
raise ap.ObjectNotFoundError
|
||||
return existing_actor
|
||||
|
||||
if save_if_not_found:
|
||||
ap_actor = await ap.fetch(actor_id)
|
||||
# Some softwares uses URL when we expect ID or uses a different casing
|
||||
# (like Birdsite LIVE) , which mean we may already have it in DB
|
||||
existing_actor_by_url = (
|
||||
await db_session.scalars(
|
||||
select(models.Actor).where(
|
||||
models.Actor.ap_id == ap.get_id(ap_actor),
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if existing_actor_by_url:
|
||||
# Update the actor as we had to fetch it anyway
|
||||
await update_actor_if_needed(
|
||||
db_session,
|
||||
existing_actor_by_url,
|
||||
RemoteActor(ap_actor),
|
||||
)
|
||||
return existing_actor_by_url
|
||||
|
||||
return await save_actor(db_session, ap_actor)
|
||||
else:
|
||||
raise ap.ObjectNotFoundError(actor_id)
|
||||
|
||||
|
||||
async def update_actor_if_needed(
|
||||
db_session: AsyncSession,
|
||||
actor_in_db: "ActorModel",
|
||||
ra: RemoteActor,
|
||||
) -> None:
|
||||
# Check if we actually need to udpte the actor in DB
|
||||
if _actor_hash(ra) != _actor_hash(actor_in_db):
|
||||
actor_in_db.ap_actor = ra.ap_actor
|
||||
actor_in_db.handle = ra.handle
|
||||
actor_in_db.ap_type = ra.ap_type
|
||||
|
||||
actor_in_db.updated_at = now()
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -217,9 +268,11 @@ class ActorMetadata:
|
||||
is_following: bool
|
||||
is_follower: bool
|
||||
is_follow_request_sent: bool
|
||||
is_follow_request_rejected: bool
|
||||
outbox_follow_ap_id: str | None
|
||||
inbox_follow_ap_id: str | None
|
||||
moved_to: typing.Optional["ActorModel"]
|
||||
has_blocked_local_actor: bool
|
||||
|
||||
|
||||
ActorsMetadata = dict[str, ActorMetadata]
|
||||
@ -262,6 +315,26 @@ async def get_actors_metadata(
|
||||
)
|
||||
)
|
||||
}
|
||||
rejected_follow_requests = {
|
||||
reject.activity_object_ap_id
|
||||
for reject in await db_session.execute(
|
||||
select(models.InboxObject.activity_object_ap_id).where(
|
||||
models.InboxObject.ap_type == "Reject",
|
||||
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
|
||||
)
|
||||
)
|
||||
}
|
||||
blocks = {
|
||||
block.ap_actor_id
|
||||
for block in await db_session.execute(
|
||||
select(models.InboxObject.ap_actor_id).where(
|
||||
models.InboxObject.ap_type == "Block",
|
||||
models.InboxObject.undone_by_inbox_object_id.is_(None),
|
||||
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
idx: ActorsMetadata = {}
|
||||
for actor in actors:
|
||||
if not actor.ap_id:
|
||||
@ -284,9 +357,15 @@ async def get_actors_metadata(
|
||||
is_following=actor.ap_id in following,
|
||||
is_follower=actor.ap_id in followers,
|
||||
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
||||
is_follow_request_rejected=bool(
|
||||
sent_follow_requests[actor.ap_id] in rejected_follow_requests
|
||||
)
|
||||
if actor.ap_id in sent_follow_requests
|
||||
else False,
|
||||
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
||||
inbox_follow_ap_id=followers.get(actor.ap_id),
|
||||
moved_to=moved_to,
|
||||
has_blocked_local_actor=actor.ap_id in blocks,
|
||||
)
|
||||
return idx
|
||||
|
||||
@ -311,6 +390,9 @@ def _actor_hash(actor: Actor) -> bytes:
|
||||
if actor.icon_url:
|
||||
h.update(actor.icon_url.encode())
|
||||
|
||||
if actor.image_url:
|
||||
h.update(actor.image_url.encode())
|
||||
|
||||
if actor.attachments:
|
||||
for a in actor.attachments:
|
||||
if a.get("type") != "PropertyValue":
|
||||
|
51
app/admin.py
51
app/admin.py
@ -25,7 +25,9 @@ from app.actor import fetch_actor
|
||||
from app.actor import get_actors_metadata
|
||||
from app.boxes import get_inbox_object_by_ap_id
|
||||
from app.boxes import get_outbox_object_by_ap_id
|
||||
from app.boxes import send_block
|
||||
from app.boxes import send_follow
|
||||
from app.boxes import send_unblock
|
||||
from app.config import EMOJIS
|
||||
from app.config import generate_csrf_token
|
||||
from app.config import session_serializer
|
||||
@ -40,13 +42,22 @@ from app.utils import pagination
|
||||
from app.utils.emoji import EMOJIS_BY_NAME
|
||||
|
||||
|
||||
def user_session_or_redirect(
|
||||
async def user_session_or_redirect(
|
||||
request: Request,
|
||||
session: str | None = Cookie(default=None),
|
||||
) -> None:
|
||||
if request.method == "POST":
|
||||
form_data = await request.form()
|
||||
if "redirect_url" in form_data:
|
||||
redirect_url = form_data["redirect_url"]
|
||||
else:
|
||||
redirect_url = request.url_for("admin_stream")
|
||||
else:
|
||||
redirect_url = str(request.url)
|
||||
|
||||
_RedirectToLoginPage = HTTPException(
|
||||
status_code=302,
|
||||
headers={"Location": request.url_for("login") + f"?redirect={request.url}"},
|
||||
headers={"Location": request.url_for("login") + f"?redirect={redirect_url}"},
|
||||
)
|
||||
|
||||
if not session:
|
||||
@ -85,6 +96,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
|
||||
@ -329,6 +342,7 @@ async def admin_inbox(
|
||||
"Update",
|
||||
"Undo",
|
||||
"Read",
|
||||
"Reject",
|
||||
"Add",
|
||||
"Remove",
|
||||
"EmojiReact",
|
||||
@ -836,6 +850,30 @@ async def admin_profile(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/actions/force_delete")
|
||||
async def admin_actions_force_delete(
|
||||
request: Request,
|
||||
ap_object_id: str = Form(),
|
||||
redirect_url: str = Form(),
|
||||
csrf_check: None = Depends(verify_csrf_token),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> RedirectResponse:
|
||||
ap_object_to_delete = await get_inbox_object_by_ap_id(db_session, ap_object_id)
|
||||
if not ap_object_to_delete:
|
||||
raise ValueError(f"Cannot find {ap_object_id}")
|
||||
|
||||
logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}")
|
||||
await boxes._revert_side_effect_for_deleted_object(
|
||||
db_session,
|
||||
None,
|
||||
ap_object_to_delete,
|
||||
None,
|
||||
)
|
||||
ap_object_to_delete.is_deleted = True
|
||||
await db_session.commit()
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/actions/follow")
|
||||
async def admin_actions_follow(
|
||||
request: Request,
|
||||
@ -857,10 +895,7 @@ async def admin_actions_block(
|
||||
csrf_check: None = Depends(verify_csrf_token),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> RedirectResponse:
|
||||
logger.info(f"Blocking {ap_actor_id}")
|
||||
actor = await fetch_actor(db_session, ap_actor_id)
|
||||
actor.is_blocked = True
|
||||
await db_session.commit()
|
||||
await send_block(db_session, ap_actor_id)
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
|
||||
@ -873,9 +908,7 @@ async def admin_actions_unblock(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> RedirectResponse:
|
||||
logger.info(f"Unblocking {ap_actor_id}")
|
||||
actor = await fetch_actor(db_session, ap_actor_id)
|
||||
actor.is_blocked = False
|
||||
await db_session.commit()
|
||||
await send_unblock(db_session, ap_actor_id)
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
from bs4 import BeautifulSoup # type: ignore
|
||||
from markdown import markdown
|
||||
from mistletoe import markdown # type: ignore
|
||||
|
||||
from app import activitypub as ap
|
||||
from app.actor import LOCAL_ACTOR
|
||||
@ -95,6 +96,9 @@ class Object:
|
||||
def attachments(self) -> list["Attachment"]:
|
||||
attachments = []
|
||||
for obj in ap.as_list(self.ap_object.get("attachment", [])):
|
||||
if obj.get("type") == "PropertyValue":
|
||||
continue
|
||||
|
||||
if obj.get("type") == "Link":
|
||||
attachments.append(
|
||||
Attachment.parse_obj(
|
||||
@ -155,7 +159,7 @@ class Object:
|
||||
@cached_property
|
||||
def url(self) -> str | None:
|
||||
obj_url = self.ap_object.get("url")
|
||||
if isinstance(obj_url, str):
|
||||
if isinstance(obj_url, str) and obj_url:
|
||||
return obj_url
|
||||
elif obj_url:
|
||||
for u in ap.as_list(obj_url):
|
||||
@ -175,7 +179,7 @@ class Object:
|
||||
|
||||
# PeerTube returns the content as markdown
|
||||
if self.ap_object.get("mediaType") == "text/markdown":
|
||||
content = markdown(content, extensions=["mdx_linkify"])
|
||||
content = markdown(content)
|
||||
|
||||
return content
|
||||
|
||||
@ -276,6 +280,20 @@ class Attachment(BaseModel):
|
||||
proxied_url: str | None = None
|
||||
resized_url: str | None = None
|
||||
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
|
||||
@property
|
||||
def mimetype(self) -> str:
|
||||
mimetype = self.media_type
|
||||
if not mimetype:
|
||||
mimetype, _ = mimetypes.guess_type(self.url)
|
||||
|
||||
if not mimetype:
|
||||
return "unknown"
|
||||
|
||||
return mimetype.split("/")[-1]
|
||||
|
||||
|
||||
class RemoteObject(Object):
|
||||
def __init__(self, raw_object: ap.RawObject, actor: Actor):
|
||||
|
397
app/boxes.py
397
app/boxes.py
@ -24,6 +24,7 @@ from app.actor import Actor
|
||||
from app.actor import RemoteActor
|
||||
from app.actor import fetch_actor
|
||||
from app.actor import save_actor
|
||||
from app.actor import update_actor_if_needed
|
||||
from app.ap_object import RemoteObject
|
||||
from app.config import BASE_URL
|
||||
from app.config import BLOCKED_SERVERS
|
||||
@ -32,6 +33,7 @@ from app.config import MANUALLY_APPROVES_FOLLOWERS
|
||||
from app.config import set_moved_to
|
||||
from app.database import AsyncSession
|
||||
from app.outgoing_activities import new_outgoing_activity
|
||||
from app.source import dedup_tags
|
||||
from app.source import markdownify
|
||||
from app.uploads import upload_to_attachment
|
||||
from app.utils import opengraph
|
||||
@ -39,6 +41,7 @@ from app.utils import webmentions
|
||||
from app.utils.datetime import as_utc
|
||||
from app.utils.datetime import now
|
||||
from app.utils.datetime import parse_isoformat
|
||||
from app.utils.text import slugify
|
||||
|
||||
AnyboxObject = models.InboxObject | models.OutboxObject
|
||||
|
||||
@ -61,6 +64,7 @@ async def save_outbox_object(
|
||||
source: str | None = None,
|
||||
is_transient: bool = False,
|
||||
conversation: str | None = None,
|
||||
slug: str | None = None,
|
||||
) -> models.OutboxObject:
|
||||
ro = await RemoteObject.from_raw_object(raw_object)
|
||||
|
||||
@ -80,6 +84,7 @@ async def save_outbox_object(
|
||||
source=source,
|
||||
is_transient=is_transient,
|
||||
conversation=conversation,
|
||||
slug=slug,
|
||||
)
|
||||
db_session.add(outbox_object)
|
||||
await db_session.flush()
|
||||
@ -88,6 +93,87 @@ async def save_outbox_object(
|
||||
return outbox_object
|
||||
|
||||
|
||||
async def send_unblock(db_session: AsyncSession, ap_actor_id: str) -> None:
|
||||
actor = await fetch_actor(db_session, ap_actor_id)
|
||||
|
||||
block_activity = (
|
||||
await db_session.scalars(
|
||||
select(models.OutboxObject).where(
|
||||
models.OutboxObject.activity_object_ap_id == actor.ap_id,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if not block_activity:
|
||||
raise ValueError(f"No Block activity for {ap_actor_id}")
|
||||
|
||||
await _send_undo(db_session, block_activity.ap_id)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def send_block(db_session: AsyncSession, ap_actor_id: str) -> None:
|
||||
logger.info(f"Blocking {ap_actor_id}")
|
||||
actor = await fetch_actor(db_session, ap_actor_id)
|
||||
actor.is_blocked = True
|
||||
|
||||
# 1. Unfollow the actor
|
||||
following = (
|
||||
await db_session.scalars(
|
||||
select(models.Following)
|
||||
.options(joinedload(models.Following.outbox_object))
|
||||
.where(
|
||||
models.Following.ap_actor_id == actor.ap_id,
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if following:
|
||||
await _send_undo(db_session, following.outbox_object.ap_id)
|
||||
|
||||
# 2. If the blocked actor is a follower, reject the follow request
|
||||
follower = (
|
||||
await db_session.scalars(
|
||||
select(models.Follower)
|
||||
.options(joinedload(models.Follower.inbox_object))
|
||||
.where(
|
||||
models.Follower.ap_actor_id == actor.ap_id,
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if follower:
|
||||
await _send_reject(db_session, actor, follower.inbox_object)
|
||||
await db_session.delete(follower)
|
||||
|
||||
# 3. Send a block
|
||||
block_id = allocate_outbox_id()
|
||||
block = {
|
||||
"@context": ap.AS_EXTENDED_CTX,
|
||||
"id": outbox_object_id(block_id),
|
||||
"type": "Block",
|
||||
"actor": LOCAL_ACTOR.ap_id,
|
||||
"object": actor.ap_id,
|
||||
}
|
||||
outbox_object = await save_outbox_object(
|
||||
db_session,
|
||||
block_id,
|
||||
block,
|
||||
)
|
||||
if not outbox_object.id:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
await new_outgoing_activity(db_session, actor.inbox_url, outbox_object.id)
|
||||
|
||||
# 4. Create a notification
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.BLOCK,
|
||||
actor_id=actor.id,
|
||||
outbox_object_id=outbox_object.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
outbox_object_to_delete = await get_outbox_object_by_ap_id(db_session, ap_object_id)
|
||||
if not outbox_object_to_delete:
|
||||
@ -130,7 +216,11 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
db_session, outbox_object_to_delete.in_reply_to
|
||||
)
|
||||
if replied_object:
|
||||
replied_object.replies_count = replied_object.replies_count - 1
|
||||
new_replies_count = await _get_replies_count(
|
||||
db_session, replied_object.ap_id
|
||||
)
|
||||
|
||||
replied_object.replies_count = new_replies_count
|
||||
if replied_object.replies_count < 0:
|
||||
logger.warning("negative replies count for {replied_object.ap_id}")
|
||||
replied_object.replies_count = 0
|
||||
@ -260,7 +350,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
if not outbox_object_to_undo:
|
||||
raise ValueError(f"{ap_object_id} not found in the outbox")
|
||||
|
||||
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce"]:
|
||||
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce", "Block"]:
|
||||
raise ValueError(
|
||||
f"Cannot build Undo for {outbox_object_to_undo.ap_type} activity"
|
||||
)
|
||||
@ -284,6 +374,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
|
||||
outbox_object_to_undo.is_deleted = True
|
||||
|
||||
if outbox_object_to_undo.ap_type == "Follow":
|
||||
if not outbox_object_to_undo.activity_object_ap_id:
|
||||
@ -332,6 +423,30 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
recipients = await _compute_recipients(db_session, outbox_object.ap_object)
|
||||
for rcp in recipients:
|
||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||
elif outbox_object_to_undo.ap_type == "Block":
|
||||
if not outbox_object_to_undo.activity_object_ap_id:
|
||||
raise ValueError(f"Invalid block activity {outbox_object_to_undo.ap_id}")
|
||||
|
||||
# Send the Undo to the blocked actor
|
||||
blocked_actor = await fetch_actor(
|
||||
db_session, outbox_object_to_undo.activity_object_ap_id
|
||||
)
|
||||
|
||||
blocked_actor.is_blocked = False
|
||||
|
||||
await new_outgoing_activity(
|
||||
db_session,
|
||||
blocked_actor.inbox_url, # type: ignore
|
||||
outbox_object.id,
|
||||
)
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNBLOCK,
|
||||
actor_id=blocked_actor.id,
|
||||
outbox_object_id=outbox_object.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
else:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
@ -342,19 +457,21 @@ async def fetch_conversation_root(
|
||||
db_session: AsyncSession,
|
||||
obj: AnyboxObject | RemoteObject,
|
||||
is_root: bool = False,
|
||||
depth: int = 0,
|
||||
) -> str:
|
||||
"""Some softwares do not set the context/conversation field (like Misskey).
|
||||
This means we have to track conversation ourselves. To do set, we fetch
|
||||
This means we have to track conversation ourselves. To do so, we fetch
|
||||
the root of the conversation and either:
|
||||
- use the context field if set
|
||||
- or build a custom conversation ID
|
||||
"""
|
||||
if not obj.in_reply_to or is_root:
|
||||
if obj.ap_context:
|
||||
return obj.ap_context
|
||||
else:
|
||||
# Use the root AP ID if there'no context
|
||||
return f"microblogpub:root:{obj.ap_id}"
|
||||
logger.info(f"Fetching convo root for ap_id={obj.ap_id}/{depth=}")
|
||||
if obj.ap_context:
|
||||
return obj.ap_context
|
||||
|
||||
if not obj.in_reply_to or is_root or depth > 10:
|
||||
# Use the root AP ID if there'no context
|
||||
return f"microblogpub:root:{obj.ap_id}"
|
||||
else:
|
||||
in_reply_to_object: AnyboxObject | RemoteObject | None = (
|
||||
await get_anybox_object_by_ap_id(db_session, obj.in_reply_to)
|
||||
@ -366,16 +483,25 @@ async def fetch_conversation_root(
|
||||
db_session, ap.get_actor_id(raw_reply)
|
||||
)
|
||||
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
||||
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError, ap.NotAnObjectError):
|
||||
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||
except (
|
||||
ap.FetchError,
|
||||
ap.NotAnObjectError,
|
||||
):
|
||||
return await fetch_conversation_root(
|
||||
db_session, obj, is_root=True, depth=depth + 1
|
||||
)
|
||||
except httpx.HTTPStatusError as http_status_error:
|
||||
if 400 <= http_status_error.response.status_code < 500:
|
||||
# We may not have access, in this case consider if root
|
||||
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||
return await fetch_conversation_root(
|
||||
db_session, obj, is_root=True, depth=depth + 1
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
return await fetch_conversation_root(db_session, in_reply_to_object)
|
||||
return await fetch_conversation_root(
|
||||
db_session, in_reply_to_object, depth=depth + 1
|
||||
)
|
||||
|
||||
|
||||
async def send_move(
|
||||
@ -452,6 +578,7 @@ async def send_create(
|
||||
content, tags, mentioned_actors = await markdownify(db_session, source)
|
||||
attachments = []
|
||||
|
||||
in_reply_to_object: AnyboxObject | None = None
|
||||
if in_reply_to:
|
||||
in_reply_to_object = await get_anybox_object_by_ap_id(db_session, in_reply_to)
|
||||
if not in_reply_to_object:
|
||||
@ -469,23 +596,6 @@ async def send_create(
|
||||
context = in_reply_to_object.ap_context
|
||||
conversation = in_reply_to_object.ap_context
|
||||
|
||||
if in_reply_to_object.is_from_outbox:
|
||||
await db_session.execute(
|
||||
update(models.OutboxObject)
|
||||
.where(
|
||||
models.OutboxObject.ap_id == in_reply_to,
|
||||
)
|
||||
.values(replies_count=models.OutboxObject.replies_count + 1)
|
||||
)
|
||||
elif in_reply_to_object.is_from_inbox:
|
||||
await db_session.execute(
|
||||
update(models.InboxObject)
|
||||
.where(
|
||||
models.InboxObject.ap_id == in_reply_to,
|
||||
)
|
||||
.values(replies_count=models.InboxObject.replies_count + 1)
|
||||
)
|
||||
|
||||
for (upload, filename, alt_text) in uploads:
|
||||
attachments.append(upload_to_attachment(upload, filename, alt_text))
|
||||
|
||||
@ -507,6 +617,9 @@ async def send_create(
|
||||
else:
|
||||
raise ValueError(f"Unhandled visibility {visibility}")
|
||||
|
||||
slug = None
|
||||
url = outbox_object_id(note_id)
|
||||
|
||||
extra_obj_attrs = {}
|
||||
if ap_type == "Question":
|
||||
if not poll_answers or len(poll_answers) < 2:
|
||||
@ -536,6 +649,8 @@ async def send_create(
|
||||
if not name:
|
||||
raise ValueError("Article must have a name")
|
||||
|
||||
slug = slugify(name)
|
||||
url = f"{BASE_URL}/articles/{note_id[:7]}/{slug}"
|
||||
extra_obj_attrs = {"name": name}
|
||||
|
||||
obj = {
|
||||
@ -549,8 +664,8 @@ async def send_create(
|
||||
"published": published,
|
||||
"context": context,
|
||||
"conversation": context,
|
||||
"url": outbox_object_id(note_id),
|
||||
"tag": tags,
|
||||
"url": url,
|
||||
"tag": dedup_tags(tags),
|
||||
"summary": content_warning,
|
||||
"inReplyTo": in_reply_to,
|
||||
"sensitive": is_sensitive,
|
||||
@ -563,6 +678,7 @@ async def send_create(
|
||||
obj,
|
||||
source=source,
|
||||
conversation=conversation,
|
||||
slug=slug,
|
||||
)
|
||||
if not outbox_object.id:
|
||||
raise ValueError("Should never happen")
|
||||
@ -570,7 +686,7 @@ async def send_create(
|
||||
for tag in tags:
|
||||
if tag["type"] == "Hashtag":
|
||||
tagged_object = models.TaggedOutboxObject(
|
||||
tag=tag["name"][1:],
|
||||
tag=tag["name"][1:].lower(),
|
||||
outbox_object_id=outbox_object.id,
|
||||
)
|
||||
db_session.add(tagged_object)
|
||||
@ -604,6 +720,31 @@ async def send_create(
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
# Refresh the replies counter if needed
|
||||
if in_reply_to_object:
|
||||
new_replies_count = await _get_replies_count(
|
||||
db_session, in_reply_to_object.ap_id
|
||||
)
|
||||
if in_reply_to_object.is_from_outbox:
|
||||
await db_session.execute(
|
||||
update(models.OutboxObject)
|
||||
.where(
|
||||
models.OutboxObject.ap_id == in_reply_to_object.ap_id,
|
||||
)
|
||||
.values(replies_count=new_replies_count)
|
||||
)
|
||||
elif in_reply_to_object.is_from_inbox:
|
||||
await db_session.execute(
|
||||
update(models.InboxObject)
|
||||
.where(
|
||||
models.InboxObject.ap_id == in_reply_to_object.ap_id,
|
||||
)
|
||||
.values(replies_count=new_replies_count)
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
return note_id
|
||||
|
||||
|
||||
@ -1020,15 +1161,48 @@ async def _handle_delete_activity(
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
async def _get_replies_count(
|
||||
db_session: AsyncSession,
|
||||
replied_object_ap_id: str,
|
||||
) -> int:
|
||||
return (
|
||||
await db_session.scalar(
|
||||
select(func.count(models.InboxObject.id)).where(
|
||||
func.json_extract(models.InboxObject.ap_object, "$.inReplyTo")
|
||||
== replied_object_ap_id,
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
) + (
|
||||
await db_session.scalar(
|
||||
select(func.count(models.OutboxObject.id)).where(
|
||||
func.json_extract(models.OutboxObject.ap_object, "$.inReplyTo")
|
||||
== replied_object_ap_id,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _revert_side_effect_for_deleted_object(
|
||||
db_session: AsyncSession,
|
||||
delete_activity: models.InboxObject,
|
||||
delete_activity: models.InboxObject | None,
|
||||
deleted_ap_object: models.InboxObject,
|
||||
forwarded_by_actor: models.Actor | None,
|
||||
) -> None:
|
||||
is_delete_needs_to_be_forwarded = False
|
||||
|
||||
# Decrement the replies counter if needed
|
||||
# Delete related notifications
|
||||
notif_deletion_result = await db_session.execute(
|
||||
delete(models.Notification)
|
||||
.where(models.Notification.inbox_object_id == deleted_ap_object.id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
logger.info(
|
||||
f"Deleted {notif_deletion_result.rowcount} notifications" # type: ignore
|
||||
)
|
||||
|
||||
# Decrement/refresh the replies counter if needed
|
||||
if deleted_ap_object.in_reply_to:
|
||||
replied_object = await get_anybox_object_by_ap_id(
|
||||
db_session,
|
||||
@ -1040,20 +1214,28 @@ async def _revert_side_effect_for_deleted_object(
|
||||
# also needs to be forwarded
|
||||
is_delete_needs_to_be_forwarded = True
|
||||
|
||||
new_replies_count = await _get_replies_count(
|
||||
db_session, replied_object.ap_id
|
||||
)
|
||||
|
||||
await db_session.execute(
|
||||
update(models.OutboxObject)
|
||||
.where(
|
||||
models.OutboxObject.id == replied_object.id,
|
||||
)
|
||||
.values(replies_count=models.OutboxObject.replies_count - 1)
|
||||
.values(replies_count=new_replies_count - 1)
|
||||
)
|
||||
else:
|
||||
new_replies_count = await _get_replies_count(
|
||||
db_session, replied_object.ap_id
|
||||
)
|
||||
|
||||
await db_session.execute(
|
||||
update(models.InboxObject)
|
||||
.where(
|
||||
models.InboxObject.id == replied_object.id,
|
||||
)
|
||||
.values(replies_count=models.InboxObject.replies_count - 1)
|
||||
.values(replies_count=new_replies_count - 1)
|
||||
)
|
||||
|
||||
if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id:
|
||||
@ -1100,7 +1282,8 @@ async def _revert_side_effect_for_deleted_object(
|
||||
# If it's a local replies, it was forwarded, so we also need to forward
|
||||
# the Delete activity if possible
|
||||
if (
|
||||
delete_activity.activity_object_ap_id == deleted_ap_object.ap_id
|
||||
delete_activity
|
||||
and delete_activity.activity_object_ap_id == deleted_ap_object.ap_id
|
||||
and delete_activity.has_ld_signature
|
||||
and is_delete_needs_to_be_forwarded
|
||||
):
|
||||
@ -1338,6 +1521,13 @@ async def _handle_undo_activity(
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
elif ap_activity_to_undo.ap_type == "Block":
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNBLOCKED,
|
||||
actor_id=from_actor.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
else:
|
||||
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
|
||||
|
||||
@ -1422,7 +1612,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(
|
||||
@ -1431,7 +1622,7 @@ async def _handle_update_activity(
|
||||
)
|
||||
|
||||
# Update the actor
|
||||
from_actor.ap_actor = updated_actor.ap_actor
|
||||
await update_actor_if_needed(db_session, from_actor, updated_actor)
|
||||
elif (ap_type := wrapped_object["type"]) in [
|
||||
"Question",
|
||||
"Note",
|
||||
@ -1454,6 +1645,7 @@ async def _handle_update_activity(
|
||||
# Everything looks correct, update the object in the inbox
|
||||
logger.info(f"Updating {existing_object.ap_id}")
|
||||
existing_object.ap_object = wrapped_object
|
||||
existing_object.updated_at = now()
|
||||
else:
|
||||
# TODO(ts): support updating objects
|
||||
logger.info(f'Cannot update {wrapped_object["type"]}')
|
||||
@ -1464,8 +1656,24 @@ async def _handle_create_activity(
|
||||
from_actor: models.Actor,
|
||||
create_activity: models.InboxObject,
|
||||
forwarded_by_actor: models.Actor | None = None,
|
||||
relates_to_inbox_object: models.InboxObject | None = None,
|
||||
) -> None:
|
||||
logger.info("Processing Create activity")
|
||||
|
||||
# Some PeerTube activities make no sense to process
|
||||
if (
|
||||
ap_object_type := ap.as_list(
|
||||
(await ap.get_object(create_activity.ap_object))["type"]
|
||||
)[0]
|
||||
) in ["CacheFile"]:
|
||||
logger.info(f"Dropping Create activity for {ap_object_type} object")
|
||||
await db_session.delete(create_activity)
|
||||
return None
|
||||
|
||||
if relates_to_inbox_object:
|
||||
logger.warning(f"{relates_to_inbox_object.ap_id} is already in the inbox")
|
||||
return None
|
||||
|
||||
wrapped_object = ap.unwrap_activity(create_activity.ap_object)
|
||||
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
|
||||
raise ValueError("Object actor does not match activity")
|
||||
@ -1483,8 +1691,9 @@ async def _handle_create_activity(
|
||||
logger.warning(
|
||||
f"Got a Delete for {ro.ap_id} from {delete_object.actor.ap_id}??"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.info("Got a Delete for this object, deleting activity")
|
||||
logger.info("Already received a Delete for this object, deleting activity")
|
||||
create_activity.is_deleted = True
|
||||
await db_session.flush()
|
||||
return None
|
||||
@ -1515,6 +1724,14 @@ async def _handle_read_activity(
|
||||
if not wrapped_object_actor.is_blocked:
|
||||
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor)
|
||||
|
||||
# Check if we already know about this object
|
||||
if await get_inbox_object_by_ap_id(
|
||||
db_session,
|
||||
ro.ap_id,
|
||||
):
|
||||
logger.info(f"{ro.ap_id} is already in the inbox, skipping processing")
|
||||
return None
|
||||
|
||||
# Then process it likes it's coming from a forwarded activity
|
||||
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
|
||||
|
||||
@ -1567,6 +1784,8 @@ async def _process_note_object(
|
||||
is_hidden_from_stream=not (
|
||||
(not is_reply and is_from_following) or is_mention or is_local_reply
|
||||
),
|
||||
# We may already have some replies in DB
|
||||
replies_count=await _get_replies_count(db_session, ro.ap_id),
|
||||
)
|
||||
|
||||
db_session.add(inbox_object)
|
||||
@ -1590,20 +1809,28 @@ async def _process_note_object(
|
||||
replied_object, # type: ignore # outbox check below
|
||||
)
|
||||
else:
|
||||
new_replies_count = await _get_replies_count(
|
||||
db_session, replied_object.ap_id
|
||||
)
|
||||
|
||||
await db_session.execute(
|
||||
update(models.OutboxObject)
|
||||
.where(
|
||||
models.OutboxObject.id == replied_object.id,
|
||||
)
|
||||
.values(replies_count=models.OutboxObject.replies_count + 1)
|
||||
.values(replies_count=new_replies_count)
|
||||
)
|
||||
else:
|
||||
new_replies_count = await _get_replies_count(
|
||||
db_session, replied_object.ap_id
|
||||
)
|
||||
|
||||
await db_session.execute(
|
||||
update(models.InboxObject)
|
||||
.where(
|
||||
models.InboxObject.id == replied_object.id,
|
||||
)
|
||||
.values(replies_count=models.InboxObject.replies_count + 1)
|
||||
.values(replies_count=new_replies_count)
|
||||
)
|
||||
|
||||
# This object is a reply of a local object, we may need to forward it
|
||||
@ -1783,6 +2010,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)
|
||||
)
|
||||
@ -1834,6 +2067,28 @@ async def _handle_like_activity(
|
||||
db_session.add(notif)
|
||||
|
||||
|
||||
async def _handle_block_activity(
|
||||
db_session: AsyncSession,
|
||||
actor: models.Actor,
|
||||
block_activity: models.InboxObject,
|
||||
):
|
||||
if block_activity.activity_object_ap_id != LOCAL_ACTOR.ap_id:
|
||||
logger.warning(
|
||||
"Received invalid Block activity "
|
||||
f"{block_activity.activity_object_ap_id=}"
|
||||
)
|
||||
await db_session.delete(block_activity)
|
||||
return
|
||||
|
||||
# Create a notification
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.BLOCKED,
|
||||
actor_id=actor.id,
|
||||
inbox_object_id=block_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
|
||||
async def _process_transient_object(
|
||||
db_session: AsyncSession,
|
||||
raw_object: ap.RawObject,
|
||||
@ -1844,6 +2099,7 @@ async def _process_transient_object(
|
||||
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
|
||||
@ -1854,12 +2110,34 @@ 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:
|
||||
logger.warning("Actor not found")
|
||||
return
|
||||
except httpx.HTTPStatusError:
|
||||
except ap.FetchError:
|
||||
logger.exception("Failed to fetch actor")
|
||||
return
|
||||
|
||||
@ -1867,12 +2145,14 @@ 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
|
||||
|
||||
if actor.is_blocked:
|
||||
logger.warning("Actor {actor.ap_id} is blocked, ignoring object")
|
||||
# If we just blocked an actor, we want to process any undo sent as side
|
||||
# effects
|
||||
if actor.is_blocked and ap.as_list(raw_object["type"])[0] != "Undo":
|
||||
logger.warning(f"Actor {actor.ap_id} is blocked, ignoring object")
|
||||
return None
|
||||
|
||||
raw_object_id = ap.get_id(raw_object)
|
||||
@ -1967,7 +2247,11 @@ async def save_to_inbox(
|
||||
|
||||
if activity_ro.ap_type == "Create":
|
||||
await _handle_create_activity(
|
||||
db_session, actor, inbox_object, forwarded_by_actor=forwarded_by_actor
|
||||
db_session,
|
||||
actor,
|
||||
inbox_object,
|
||||
forwarded_by_actor=forwarded_by_actor,
|
||||
relates_to_inbox_object=relates_to_inbox_object,
|
||||
)
|
||||
elif activity_ro.ap_type == "Read":
|
||||
await _handle_read_activity(db_session, actor, inbox_object)
|
||||
@ -2068,6 +2352,15 @@ async def save_to_inbox(
|
||||
relates_to_outbox_object,
|
||||
relates_to_inbox_object,
|
||||
)
|
||||
elif activity_ro.ap_type == "View":
|
||||
# View is used by Peertube, there's nothing useful we can do with it
|
||||
await db_session.delete(inbox_object)
|
||||
elif activity_ro.ap_type == "Block":
|
||||
await _handle_block_activity(
|
||||
db_session,
|
||||
actor,
|
||||
inbox_object,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Received an unknown {inbox_object.ap_type} object")
|
||||
|
||||
@ -2201,7 +2494,9 @@ async def get_replies_tree(
|
||||
.where(
|
||||
models.InboxObject.conversation
|
||||
== requested_object.conversation,
|
||||
models.InboxObject.ap_type.in_(["Note", "Page", "Article"]),
|
||||
models.InboxObject.ap_type.in_(
|
||||
["Note", "Page", "Article", "Question"]
|
||||
),
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
models.InboxObject.visibility.in_(allowed_visibility),
|
||||
)
|
||||
@ -2219,7 +2514,9 @@ async def get_replies_tree(
|
||||
models.OutboxObject.conversation
|
||||
== requested_object.conversation,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
models.OutboxObject.ap_type.in_(["Note", "Page", "Article"]),
|
||||
models.OutboxObject.ap_type.in_(
|
||||
["Note", "Page", "Article", "Question"]
|
||||
),
|
||||
models.OutboxObject.visibility.in_(allowed_visibility),
|
||||
)
|
||||
.options(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@ -12,8 +13,9 @@ from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from loguru import logger
|
||||
from markdown import markdown
|
||||
from mistletoe import markdown # type: ignore
|
||||
|
||||
from app.customization import _CUSTOM_ROUTES
|
||||
from app.utils.emoji import _load_emojis
|
||||
from app.utils.version import get_version_commit
|
||||
|
||||
@ -90,6 +92,7 @@ class Config(pydantic.BaseModel):
|
||||
summary: str
|
||||
https: bool
|
||||
icon_url: str
|
||||
image_url: str | None = None
|
||||
secret: str
|
||||
debug: bool = False
|
||||
trusted_hosts: list[str] = ["127.0.0.1"]
|
||||
@ -102,12 +105,20 @@ class Config(pydantic.BaseModel):
|
||||
emoji: str | None = None
|
||||
also_known_as: str | None = None
|
||||
|
||||
hides_followers: bool = False
|
||||
hides_following: bool = False
|
||||
|
||||
inbox_retention_days: int = 15
|
||||
|
||||
custom_content_security_policy: str | None = None
|
||||
|
||||
# Config items to make tests easier
|
||||
sqlalchemy_database: str | None = None
|
||||
key_path: str | None = None
|
||||
|
||||
# Only set when the app is served on a non-root path
|
||||
id: str | None = None
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
try:
|
||||
@ -142,20 +153,26 @@ CONFIG = load_config()
|
||||
DOMAIN = CONFIG.domain
|
||||
_SCHEME = "https" if CONFIG.https else "http"
|
||||
ID = f"{_SCHEME}://{DOMAIN}"
|
||||
|
||||
# When running the app on a path, the ID maybe set by the config, but in this
|
||||
# case, a valid webfinger must be served on the root domain
|
||||
if CONFIG.id:
|
||||
ID = CONFIG.id
|
||||
USERNAME = CONFIG.username
|
||||
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
|
||||
HIDES_FOLLOWERS = CONFIG.hides_followers
|
||||
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
|
||||
CUSTOM_CONTENT_SECURITY_POLICY = CONFIG.custom_content_security_policy
|
||||
|
||||
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
||||
CUSTOM_FOOTER = (
|
||||
markdown(
|
||||
CONFIG.custom_footer.replace("{version}", VERSION), extensions=["mdx_linkify"]
|
||||
)
|
||||
markdown(CONFIG.custom_footer.replace("{version}", VERSION))
|
||||
if CONFIG.custom_footer
|
||||
else None
|
||||
)
|
||||
@ -172,7 +189,9 @@ if CONFIG.emoji:
|
||||
EMOJIS = CONFIG.emoji
|
||||
|
||||
# Emoji template for the FE
|
||||
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
||||
EMOJI_TPL = (
|
||||
'<img src="{base_url}/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
||||
)
|
||||
|
||||
_load_emojis(ROOT_DIR, BASE_URL)
|
||||
|
||||
@ -181,6 +200,31 @@ CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
|
||||
MOVED_TO = _get_moved_to()
|
||||
|
||||
|
||||
_NavBarItem = tuple[str, str]
|
||||
|
||||
|
||||
class NavBarItems:
|
||||
EXTRA_NAVBAR_ITEMS: list[_NavBarItem] = []
|
||||
INDEX_NAVBAR_ITEM: _NavBarItem | None = None
|
||||
NOTES_PATH = "/"
|
||||
|
||||
|
||||
def load_custom_routes() -> None:
|
||||
try:
|
||||
from data import custom_routes # type: ignore # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
for path, custom_handler in _CUSTOM_ROUTES.items():
|
||||
# If a handler wants to replace the root, move the index to /notes
|
||||
if path == "/":
|
||||
NavBarItems.NOTES_PATH = "/notes"
|
||||
NavBarItems.INDEX_NAVBAR_ITEM = (path, custom_handler.title)
|
||||
else:
|
||||
if custom_handler.show_in_navbar:
|
||||
NavBarItems.EXTRA_NAVBAR_ITEMS.append((path, custom_handler.title))
|
||||
|
||||
|
||||
session_serializer = URLSafeTimedSerializer(
|
||||
CONFIG.secret,
|
||||
salt=f"{ID}.session",
|
||||
@ -195,10 +239,23 @@ def generate_csrf_token() -> str:
|
||||
return csrf_serializer.dumps(secrets.token_hex(16)) # type: ignore
|
||||
|
||||
|
||||
def verify_csrf_token(csrf_token: str = Form()) -> None:
|
||||
def verify_csrf_token(
|
||||
csrf_token: str = Form(),
|
||||
redirect_url: str | None = Form(None),
|
||||
) -> None:
|
||||
please_try_again = "please try again"
|
||||
if redirect_url:
|
||||
please_try_again = f'<a href="{redirect_url}">please try again</a>'
|
||||
try:
|
||||
csrf_serializer.loads(csrf_token, max_age=1800)
|
||||
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
|
||||
logger.exception("Failed to verify CSRF token")
|
||||
raise HTTPException(status_code=403, detail="CSRF error")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"The security token has expired, {please_try_again}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def hmac_sha256():
|
||||
return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)
|
||||
|
112
app/customization.py
Normal file
112
app/customization.py
Normal file
@ -0,0 +1,112 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
_DATA_DIR = Path().parent.resolve() / "data"
|
||||
_Handler = Callable[..., Any]
|
||||
|
||||
|
||||
class HTMLPage:
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
html_file: str,
|
||||
show_in_navbar: bool,
|
||||
) -> None:
|
||||
self.title = title
|
||||
self.html_file = _DATA_DIR / html_file
|
||||
self.show_in_navbar = show_in_navbar
|
||||
|
||||
|
||||
class RawHandler:
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
handler: Any,
|
||||
show_in_navbar: bool,
|
||||
) -> None:
|
||||
self.title = title
|
||||
self.handler = handler
|
||||
self.show_in_navbar = show_in_navbar
|
||||
|
||||
|
||||
_CUSTOM_ROUTES: dict[str, HTMLPage | RawHandler] = {}
|
||||
|
||||
|
||||
def register_html_page(
|
||||
path: str,
|
||||
*,
|
||||
title: str,
|
||||
html_file: str,
|
||||
show_in_navbar: bool = True,
|
||||
) -> None:
|
||||
if path in _CUSTOM_ROUTES:
|
||||
raise ValueError(f"{path} is already registered")
|
||||
|
||||
_CUSTOM_ROUTES[path] = HTMLPage(title, html_file, show_in_navbar)
|
||||
|
||||
|
||||
def register_raw_handler(
|
||||
path: str,
|
||||
*,
|
||||
title: str,
|
||||
handler: _Handler,
|
||||
show_in_navbar: bool = True,
|
||||
) -> None:
|
||||
if path in _CUSTOM_ROUTES:
|
||||
raise ValueError(f"{path} is already registered")
|
||||
|
||||
_CUSTOM_ROUTES[path] = RawHandler(title, handler, show_in_navbar)
|
||||
|
||||
|
||||
class ActivityPubResponse(JSONResponse):
|
||||
media_type = "application/activity+json"
|
||||
|
||||
|
||||
def _custom_page_handler(path: str, html_page: HTMLPage) -> Any:
|
||||
from app import templates
|
||||
from app.actor import LOCAL_ACTOR
|
||||
from app.config import is_activitypub_requested
|
||||
from app.database import AsyncSession
|
||||
from app.database import get_db_session
|
||||
|
||||
async def _handler(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> templates.TemplateResponse | ActivityPubResponse:
|
||||
if path == "/" and is_activitypub_requested(request):
|
||||
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
|
||||
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
"custom_page.html",
|
||||
{
|
||||
"page_content": html_page.html_file.read_text(),
|
||||
"title": html_page.title,
|
||||
},
|
||||
)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def get_custom_router() -> APIRouter | None:
|
||||
if not _CUSTOM_ROUTES:
|
||||
return None
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
for path, handler in _CUSTOM_ROUTES.items():
|
||||
if isinstance(handler, HTMLPage):
|
||||
router.add_api_route(
|
||||
path, _custom_page_handler(path, handler), methods=["GET"]
|
||||
)
|
||||
else:
|
||||
router.add_api_route(path, handler.handler)
|
||||
|
||||
return router
|
@ -88,8 +88,12 @@ def _body_digest(body: bytes) -> str:
|
||||
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
||||
|
||||
|
||||
async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
||||
if cached_key := _KEY_CACHE.get(key_id):
|
||||
async def _get_public_key(
|
||||
db_session: AsyncSession,
|
||||
key_id: str,
|
||||
should_skip_cache: bool = False,
|
||||
) -> Key:
|
||||
if not should_skip_cache and (cached_key := _KEY_CACHE.get(key_id)):
|
||||
logger.info(f"Key {key_id} found in cache")
|
||||
return cached_key
|
||||
|
||||
@ -101,25 +105,25 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
||||
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
||||
)
|
||||
).one_or_none()
|
||||
if existing_actor and existing_actor.public_key_id == key_id:
|
||||
k = Key(existing_actor.ap_id, key_id)
|
||||
k.load_pub(existing_actor.public_key_as_pem)
|
||||
logger.info(f"Found {key_id} on an existing actor")
|
||||
_KEY_CACHE[key_id] = k
|
||||
return k
|
||||
if not should_skip_cache:
|
||||
if existing_actor and existing_actor.public_key_id == key_id:
|
||||
k = Key(existing_actor.ap_id, key_id)
|
||||
k.load_pub(existing_actor.public_key_as_pem)
|
||||
logger.info(f"Found {key_id} on an existing actor")
|
||||
_KEY_CACHE[key_id] = k
|
||||
return k
|
||||
|
||||
# Fetch it
|
||||
from app import activitypub as ap
|
||||
from app.actor import RemoteActor
|
||||
from app.actor import update_actor_if_needed
|
||||
|
||||
# Without signing the request as if it's the first contact, the 2 servers
|
||||
# might race to fetch each other key
|
||||
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
|
||||
@ -136,6 +140,12 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
||||
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
||||
)
|
||||
|
||||
if should_skip_cache and actor["type"] != "Key" and existing_actor:
|
||||
# We had to skip the cache, which means the actor key probably changed
|
||||
# and we want to update our cached version
|
||||
await update_actor_if_needed(db_session, existing_actor, RemoteActor(actor))
|
||||
await db_session.commit()
|
||||
|
||||
_KEY_CACHE[key_id] = k
|
||||
return k
|
||||
|
||||
@ -219,7 +229,17 @@ async def httpsig_checker(
|
||||
has_valid_signature = _verify_h(
|
||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
||||
)
|
||||
# FIXME: fetch/update the user if the signature is wrong
|
||||
|
||||
# If the signature is not valid, we may have to update the cached actor
|
||||
if not has_valid_signature:
|
||||
logger.info("Invalid signature, trying to refresh actor")
|
||||
try:
|
||||
k = await _get_public_key(db_session, hsig["keyId"], should_skip_cache=True)
|
||||
has_valid_signature = _verify_h(
|
||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh actor")
|
||||
|
||||
httpsig_info = HTTPSigInfo(
|
||||
has_valid_signature=has_valid_signature,
|
||||
|
@ -3,7 +3,6 @@ import traceback
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
@ -26,7 +25,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
|
||||
@ -108,6 +107,7 @@ async def process_next_incoming_activity(
|
||||
|
||||
next_activity.tries = next_activity.tries + 1
|
||||
next_activity.last_try = now()
|
||||
await db_session.commit()
|
||||
|
||||
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
|
||||
try:
|
||||
@ -120,13 +120,16 @@ async def process_next_incoming_activity(
|
||||
),
|
||||
timeout=60,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
url = exc._request.url if exc._request else None
|
||||
logger.error(f"Failed, HTTP timeout when fetching {url}")
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
logger.error("Activity took too long to process")
|
||||
await db_session.rollback()
|
||||
await db_session.refresh(next_activity)
|
||||
next_activity.error = traceback.format_exc()
|
||||
_set_next_try(next_activity)
|
||||
except Exception:
|
||||
logger.exception("Failed")
|
||||
await db_session.rollback()
|
||||
await db_session.refresh(next_activity)
|
||||
next_activity.error = traceback.format_exc()
|
||||
_set_next_try(next_activity)
|
||||
else:
|
||||
|
@ -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
|
||||
|
@ -38,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)
|
||||
|
342
app/main.py
342
app/main.py
@ -48,6 +48,7 @@ from app import boxes
|
||||
from app import config
|
||||
from app import httpsig
|
||||
from app import indieauth
|
||||
from app import media
|
||||
from app import micropub
|
||||
from app import models
|
||||
from app import templates
|
||||
@ -63,6 +64,7 @@ from app.config import USER_AGENT
|
||||
from app.config import USERNAME
|
||||
from app.config import is_activitypub_requested
|
||||
from app.config import verify_csrf_token
|
||||
from app.customization import get_custom_router
|
||||
from app.database import AsyncSession
|
||||
from app.database import async_session
|
||||
from app.database import get_db_session
|
||||
@ -135,9 +137,15 @@ class CustomMiddleware:
|
||||
headers["x-frame-options"] = "DENY"
|
||||
headers["permissions-policy"] = "interest-cohort=()"
|
||||
headers["content-security-policy"] = (
|
||||
f"default-src 'self'; "
|
||||
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
|
||||
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
(
|
||||
f"default-src 'self'; "
|
||||
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
|
||||
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
)
|
||||
if not config.CUSTOM_CONTENT_SECURITY_POLICY
|
||||
else config.CUSTOM_CONTENT_SECURITY_POLICY.format(
|
||||
HIGHLIGHT_CSS_HASH=HIGHLIGHT_CSS_HASH
|
||||
)
|
||||
)
|
||||
if not DEBUG:
|
||||
headers["strict-transport-security"] = "max-age=63072000;"
|
||||
@ -192,6 +200,9 @@ app.include_router(admin.unauthenticated_router, prefix="/admin")
|
||||
app.include_router(indieauth.router)
|
||||
app.include_router(micropub.router)
|
||||
app.include_router(webmentions.router)
|
||||
config.load_custom_routes()
|
||||
if custom_router := get_custom_router():
|
||||
app.include_router(custom_router)
|
||||
|
||||
# XXX: order matters, the proxy middleware needs to be last
|
||||
app.add_middleware(CustomMiddleware)
|
||||
@ -243,7 +254,31 @@ class ActivityPubResponse(JSONResponse):
|
||||
media_type = "application/activity+json"
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def redirect_to_remote_instance(
|
||||
request: Request,
|
||||
db_session: AsyncSession,
|
||||
url: str,
|
||||
) -> templates.TemplateResponse:
|
||||
"""
|
||||
Similar to RedirectResponse, but uses a 200 response with HTML.
|
||||
|
||||
Needed for remote redirects on form submission endpoints,
|
||||
since our CSP policy disallows remote form submission.
|
||||
https://github.com/w3c/webappsec-csp/issues/8#issuecomment-810108984
|
||||
"""
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
"redirect_to_remote_instance.html",
|
||||
{
|
||||
"request": request,
|
||||
"url": url,
|
||||
},
|
||||
headers={"Refresh": "0;url=" + url},
|
||||
)
|
||||
|
||||
|
||||
@app.get(config.NavBarItems.NOTES_PATH)
|
||||
async def index(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
@ -403,6 +438,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,
|
||||
@ -413,15 +462,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(
|
||||
@ -460,15 +521,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 = (
|
||||
@ -594,13 +667,75 @@ async def _check_outbox_object_acl(
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
async def _fetch_likes(
|
||||
db_session: AsyncSession,
|
||||
outbox_object: models.OutboxObject,
|
||||
) -> list[models.InboxObject]:
|
||||
return (
|
||||
(
|
||||
await db_session.scalars(
|
||||
select(models.InboxObject)
|
||||
.where(
|
||||
models.InboxObject.ap_type == "Like",
|
||||
models.InboxObject.activity_object_ap_id == outbox_object.ap_id,
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
)
|
||||
.options(joinedload(models.InboxObject.actor))
|
||||
.order_by(models.InboxObject.ap_published_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_shares(
|
||||
db_session: AsyncSession,
|
||||
outbox_object: models.OutboxObject,
|
||||
) -> list[models.InboxObject]:
|
||||
return (
|
||||
(
|
||||
await db_session.scalars(
|
||||
select(models.InboxObject)
|
||||
.filter(
|
||||
models.InboxObject.ap_type == "Announce",
|
||||
models.InboxObject.activity_object_ap_id == outbox_object.ap_id,
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
)
|
||||
.options(joinedload(models.InboxObject.actor))
|
||||
.order_by(models.InboxObject.ap_published_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_webmentions(
|
||||
db_session: AsyncSession,
|
||||
outbox_object: models.OutboxObject,
|
||||
) -> list[models.Webmention]:
|
||||
return (
|
||||
await db_session.scalars(
|
||||
select(models.Webmention)
|
||||
.filter(
|
||||
models.Webmention.outbox_object_id == outbox_object.id,
|
||||
models.Webmention.is_deleted.is_(False),
|
||||
)
|
||||
.limit(10)
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
@app.get("/o/{public_id}")
|
||||
async def outbox_by_public_id(
|
||||
public_id: str,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
|
||||
maybe_object = (
|
||||
(
|
||||
await db_session.execute(
|
||||
@ -627,59 +762,79 @@ async def outbox_by_public_id(
|
||||
if is_activitypub_requested(request):
|
||||
return ActivityPubResponse(maybe_object.ap_object)
|
||||
|
||||
if maybe_object.ap_type == "Article":
|
||||
return RedirectResponse(
|
||||
f"{BASE_URL}/articles/{public_id[:7]}/{maybe_object.slug}",
|
||||
status_code=301,
|
||||
)
|
||||
|
||||
replies_tree = await boxes.get_replies_tree(
|
||||
db_session,
|
||||
maybe_object,
|
||||
is_current_user_admin=is_current_user_admin(request),
|
||||
)
|
||||
|
||||
likes = (
|
||||
likes = await _fetch_likes(db_session, maybe_object)
|
||||
shares = await _fetch_shares(db_session, maybe_object)
|
||||
webmentions = await _fetch_webmentions(db_session, maybe_object)
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
"object.html",
|
||||
{
|
||||
"replies_tree": replies_tree,
|
||||
"outbox_object": maybe_object,
|
||||
"likes": likes,
|
||||
"shares": shares,
|
||||
"webmentions": webmentions,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/articles/{short_id}/{slug}")
|
||||
async def article_by_slug(
|
||||
short_id: str,
|
||||
slug: str,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
|
||||
maybe_object = (
|
||||
(
|
||||
await db_session.scalars(
|
||||
select(models.InboxObject)
|
||||
await db_session.execute(
|
||||
select(models.OutboxObject)
|
||||
.options(
|
||||
joinedload(models.OutboxObject.outbox_object_attachments).options(
|
||||
joinedload(models.OutboxObjectAttachment.upload)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
models.InboxObject.ap_type == "Like",
|
||||
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
models.OutboxObject.public_id.like(f"{short_id}%"),
|
||||
models.OutboxObject.slug == slug,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
)
|
||||
.options(joinedload(models.InboxObject.actor))
|
||||
.order_by(models.InboxObject.ap_published_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
.scalar_one_or_none()
|
||||
)
|
||||
if not maybe_object:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
await _check_outbox_object_acl(request, db_session, maybe_object, httpsig_info)
|
||||
|
||||
if is_activitypub_requested(request):
|
||||
return ActivityPubResponse(maybe_object.ap_object)
|
||||
|
||||
replies_tree = await boxes.get_replies_tree(
|
||||
db_session,
|
||||
maybe_object,
|
||||
is_current_user_admin=is_current_user_admin(request),
|
||||
)
|
||||
|
||||
shares = (
|
||||
(
|
||||
await db_session.scalars(
|
||||
select(models.InboxObject)
|
||||
.filter(
|
||||
models.InboxObject.ap_type == "Announce",
|
||||
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
|
||||
models.InboxObject.is_deleted.is_(False),
|
||||
)
|
||||
.options(joinedload(models.InboxObject.actor))
|
||||
.order_by(models.InboxObject.ap_published_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
webmentions = (
|
||||
await db_session.scalars(
|
||||
select(models.Webmention)
|
||||
.filter(
|
||||
models.Webmention.outbox_object_id == maybe_object.id,
|
||||
models.Webmention.is_deleted.is_(False),
|
||||
)
|
||||
.limit(10)
|
||||
)
|
||||
).all()
|
||||
|
||||
likes = await _fetch_likes(db_session, maybe_object)
|
||||
shares = await _fetch_shares(db_session, maybe_object)
|
||||
webmentions = await _fetch_webmentions(db_session, maybe_object)
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
@ -725,7 +880,7 @@ async def tag_by_name(
|
||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||
where = [
|
||||
models.TaggedOutboxObject.tag == tag,
|
||||
models.TaggedOutboxObject.tag == tag.lower(),
|
||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
]
|
||||
@ -751,7 +906,7 @@ async def tag_by_name(
|
||||
return ActivityPubResponse(
|
||||
{
|
||||
"@context": ap.AS_CTX,
|
||||
"id": BASE_URL + f"/t/{tag}",
|
||||
"id": BASE_URL + f"/t/{tag.lower()}",
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": tagged_count,
|
||||
"orderedItems": [
|
||||
@ -828,9 +983,10 @@ async def get_remote_follow(
|
||||
@app.post("/remote_follow")
|
||||
async def post_remote_follow(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
csrf_check: None = Depends(verify_csrf_token),
|
||||
profile: str = Form(),
|
||||
) -> RedirectResponse:
|
||||
) -> templates.TemplateResponse:
|
||||
if not profile.startswith("@"):
|
||||
profile = f"@{profile}"
|
||||
|
||||
@ -839,9 +995,54 @@ async def post_remote_follow(
|
||||
# TODO(ts): error message to user
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return RedirectResponse(
|
||||
return await redirect_to_remote_instance(
|
||||
request,
|
||||
db_session,
|
||||
remote_follow_template.format(uri=ID),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/remote_interaction")
|
||||
async def remote_interaction(
|
||||
request: Request,
|
||||
ap_id: str,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> templates.TemplateResponse:
|
||||
outbox_object = await boxes.get_outbox_object_by_ap_id(
|
||||
db_session,
|
||||
ap_id,
|
||||
)
|
||||
if not outbox_object:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
"remote_interact.html",
|
||||
{"outbox_object": outbox_object},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/remote_interaction")
|
||||
async def post_remote_interaction(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
csrf_check: None = Depends(verify_csrf_token),
|
||||
profile: str = Form(),
|
||||
ap_id: str = Form(),
|
||||
) -> templates.TemplateResponse:
|
||||
if not profile.startswith("@"):
|
||||
profile = f"@{profile}"
|
||||
|
||||
remote_follow_template = await get_remote_follow_template(profile)
|
||||
if not remote_follow_template:
|
||||
# TODO(ts): error message to user
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return await redirect_to_remote_instance(
|
||||
request,
|
||||
db_session,
|
||||
remote_follow_template.format(uri=ID),
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
|
||||
@ -962,14 +1163,17 @@ def _add_cache_control(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {**headers, "Cache-Control": "max-age=31536000"}
|
||||
|
||||
|
||||
@app.get("/proxy/media/{encoded_url}")
|
||||
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}")
|
||||
async def serve_proxy_media(
|
||||
request: Request,
|
||||
exp: int,
|
||||
sig: str,
|
||||
encoded_url: str,
|
||||
) -> StreamingResponse | PlainTextResponse:
|
||||
# Decode the base64-encoded URL
|
||||
url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
check_url(url)
|
||||
media.verify_proxied_media_sig(exp, url, sig)
|
||||
|
||||
proxy_resp = await _proxy_get(request, url, stream=True)
|
||||
|
||||
@ -1002,9 +1206,11 @@ async def serve_proxy_media(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/proxy/media/{encoded_url}/{size}")
|
||||
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}/{size}")
|
||||
async def serve_proxy_media_resized(
|
||||
request: Request,
|
||||
exp: int,
|
||||
sig: str,
|
||||
encoded_url: str,
|
||||
size: int,
|
||||
) -> PlainTextResponse:
|
||||
@ -1014,6 +1220,7 @@ async def serve_proxy_media_resized(
|
||||
# Decode the base64-encoded URL
|
||||
url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
check_url(url)
|
||||
media.verify_proxied_media_sig(exp, url, sig)
|
||||
|
||||
if cached_resp := _RESIZED_CACHE.get((url, size)):
|
||||
resized_content, resized_mimetype, resp_headers = cached_resp
|
||||
@ -1141,6 +1348,7 @@ async def robots_file():
|
||||
Disallow: /followers
|
||||
Disallow: /following
|
||||
Disallow: /admin
|
||||
Disallow: /remote_interaction
|
||||
Disallow: /remote_follow"""
|
||||
|
||||
|
||||
|
31
app/media.py
31
app/media.py
@ -1,15 +1,44 @@
|
||||
import base64
|
||||
import time
|
||||
|
||||
from app.config import BASE_URL
|
||||
from app.config import hmac_sha256
|
||||
|
||||
SUPPORTED_RESIZE = [50, 740]
|
||||
EXPIRY_PERIOD = 86400
|
||||
EXPIRY_LENGTH = 7
|
||||
|
||||
|
||||
class InvalidProxySignatureError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def proxied_media_sig(expires: int, url: str) -> str:
|
||||
hm = hmac_sha256()
|
||||
hm.update(f"{expires}".encode())
|
||||
hm.update(b"|")
|
||||
hm.update(url.encode())
|
||||
return base64.urlsafe_b64encode(hm.digest()).decode()
|
||||
|
||||
|
||||
def verify_proxied_media_sig(expires: int, url: str, sig: str) -> None:
|
||||
now = int(time.time() / EXPIRY_PERIOD)
|
||||
expected = proxied_media_sig(expires, url)
|
||||
if now > expires or sig != expected:
|
||||
raise InvalidProxySignatureError("invalid or expired media")
|
||||
|
||||
|
||||
def proxied_media_url(url: str) -> str:
|
||||
if url.startswith(BASE_URL):
|
||||
return url
|
||||
expires = int(time.time() / EXPIRY_PERIOD) + EXPIRY_LENGTH
|
||||
sig = proxied_media_sig(expires, url)
|
||||
|
||||
return "/proxy/media/" + base64.urlsafe_b64encode(url.encode()).decode()
|
||||
return (
|
||||
BASE_URL
|
||||
+ f"/proxy/media/{expires}/{sig}/"
|
||||
+ base64.urlsafe_b64encode(url.encode()).decode()
|
||||
)
|
||||
|
||||
|
||||
def resized_media_url(url: str, size: int) -> str:
|
||||
|
@ -75,7 +75,7 @@ class InboxObject(Base, BaseObject):
|
||||
|
||||
ap_actor_id = Column(String, nullable=False)
|
||||
ap_type = Column(String, nullable=False, index=True)
|
||||
ap_id = Column(String, nullable=False, unique=True, index=True)
|
||||
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
||||
ap_context = Column(String, nullable=True)
|
||||
ap_published_at = Column(DateTime(timezone=True), nullable=False)
|
||||
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
||||
@ -158,9 +158,10 @@ class OutboxObject(Base, BaseObject):
|
||||
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
public_id = Column(String, nullable=False, index=True)
|
||||
slug = Column(String, nullable=True, index=True)
|
||||
|
||||
ap_type = Column(String, nullable=False, index=True)
|
||||
ap_id = Column(String, nullable=False, unique=True, index=True)
|
||||
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
||||
ap_context = Column(String, nullable=True)
|
||||
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
||||
|
||||
@ -250,6 +251,8 @@ class OutboxObject(Base, BaseObject):
|
||||
"mediaType": attachment.upload.content_type,
|
||||
"name": attachment.alt or attachment.filename,
|
||||
"url": url,
|
||||
"width": attachment.upload.width,
|
||||
"height": attachment.upload.height,
|
||||
"proxiedUrl": url,
|
||||
"resizedUrl": BASE_URL
|
||||
+ (
|
||||
@ -281,6 +284,13 @@ class OutboxObject(Base, BaseObject):
|
||||
def is_from_outbox(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def url(self) -> str | None:
|
||||
# XXX: rewrite old URL here for compat
|
||||
if self.ap_type == "Article" and self.slug and self.public_id:
|
||||
return f"{BASE_URL}/articles/{self.public_id[:7]}/{self.slug}"
|
||||
return super().url
|
||||
|
||||
|
||||
class Follower(Base):
|
||||
__tablename__ = "follower"
|
||||
@ -551,6 +561,14 @@ class NotificationType(str, enum.Enum):
|
||||
UPDATED_WEBMENTION = "updated_webmention"
|
||||
DELETED_WEBMENTION = "deleted_webmention"
|
||||
|
||||
# incoming
|
||||
BLOCKED = "blocked"
|
||||
UNBLOCKED = "unblocked"
|
||||
|
||||
# outgoing
|
||||
BLOCK = "block"
|
||||
UNBLOCK = "unblock"
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
@ -212,6 +212,7 @@ a {
|
||||
}
|
||||
}
|
||||
#main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
main {
|
||||
@ -220,10 +221,19 @@ main {
|
||||
margin: 30px auto;
|
||||
}
|
||||
|
||||
.main-flex {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
div {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
@ -378,7 +388,7 @@ nav.flexbox {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
a {
|
||||
a:not(.label-btn) {
|
||||
color: $primary-color;
|
||||
text-decoration: none;
|
||||
&:hover, &:active {
|
||||
@ -386,25 +396,31 @@ nav.flexbox {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
a.active {
|
||||
a.active:not(.label-btn) {
|
||||
color: $secondary-color;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// after nav.flexbox to override default behavior
|
||||
a.label-btn {
|
||||
color: $form-text-color;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: $form-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ap-object {
|
||||
margin: 15px 0;
|
||||
padding: 20px;
|
||||
.in-reply-to {
|
||||
color: $muted-color;
|
||||
&:hover {
|
||||
color: $secondary-color;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
nav {
|
||||
color: $muted-color;
|
||||
}
|
||||
.in-reply-to {
|
||||
display: inline;
|
||||
color: $muted-color;
|
||||
}
|
||||
.e-content, .activity-og-meta {
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
@ -509,3 +525,19 @@ nav.flexbox {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.ap-place {
|
||||
h3 {
|
||||
display: inline;
|
||||
font-weight: normal;
|
||||
}
|
||||
h3::after {
|
||||
content: ': ';
|
||||
}
|
||||
}
|
||||
|
218
app/source.py
218
app/source.py
@ -1,74 +1,172 @@
|
||||
import re
|
||||
import typing
|
||||
|
||||
from markdown import markdown
|
||||
from loguru import logger
|
||||
from mistletoe import Document # type: ignore
|
||||
from mistletoe.html_renderer import HTMLRenderer # type: ignore
|
||||
from mistletoe.span_token import SpanToken # type: ignore
|
||||
from pygments import highlight # type: ignore
|
||||
from pygments.formatters import HtmlFormatter # type: ignore
|
||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
||||
from pygments.lexers import guess_lexer # type: ignore
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import webfinger
|
||||
from app.config import BASE_URL
|
||||
from app.config import CODE_HIGHLIGHTING_THEME
|
||||
from app.database import AsyncSession
|
||||
from app.utils import emoji
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from app.actor import Actor
|
||||
|
||||
|
||||
def _set_a_attrs(attrs, new=False):
|
||||
attrs[(None, "target")] = "_blank"
|
||||
attrs[(None, "class")] = "external"
|
||||
attrs[(None, "rel")] = "noopener"
|
||||
attrs[(None, "title")] = attrs[(None, "href")]
|
||||
return attrs
|
||||
|
||||
|
||||
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
||||
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
||||
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
||||
_MENTION_REGEX = re.compile(r"(@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+)")
|
||||
_URL_REGEX = re.compile(
|
||||
"(https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*))" # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
|
||||
tags = []
|
||||
hashtags = re.findall(_HASHTAG_REGEX, content)
|
||||
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
|
||||
for hashtag in hashtags:
|
||||
tag = hashtag[1:]
|
||||
link = f'<a href="{BASE_URL}/t/{tag}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
||||
tags.append(dict(href=f"{BASE_URL}/t/{tag}", name=hashtag, type="Hashtag"))
|
||||
content = content.replace(hashtag, link)
|
||||
return content, tags
|
||||
class AutoLink(SpanToken):
|
||||
parse_inner = False
|
||||
precedence = 1
|
||||
pattern = _URL_REGEX
|
||||
|
||||
def __init__(self, match_obj: re.Match) -> None:
|
||||
self.target = match_obj.group()
|
||||
|
||||
|
||||
async def _mentionify(
|
||||
class Mention(SpanToken):
|
||||
parse_inner = False
|
||||
precedence = 10
|
||||
pattern = _MENTION_REGEX
|
||||
|
||||
def __init__(self, match_obj: re.Match) -> None:
|
||||
self.target = match_obj.group()
|
||||
|
||||
|
||||
class Hashtag(SpanToken):
|
||||
parse_inner = False
|
||||
precedence = 10
|
||||
pattern = _HASHTAG_REGEX
|
||||
|
||||
def __init__(self, match_obj: re.Match) -> None:
|
||||
self.target = match_obj.group()
|
||||
|
||||
|
||||
class CustomRenderer(HTMLRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
mentioned_actors: dict[str, "Actor"] = {},
|
||||
enable_mentionify: bool = True,
|
||||
enable_hashtagify: bool = True,
|
||||
) -> None:
|
||||
extra_tokens = []
|
||||
if enable_mentionify:
|
||||
extra_tokens.append(Mention)
|
||||
if enable_hashtagify:
|
||||
extra_tokens.append(Hashtag)
|
||||
super().__init__(AutoLink, *extra_tokens)
|
||||
|
||||
self.tags: list[dict[str, str]] = []
|
||||
self.mentioned_actors = mentioned_actors
|
||||
|
||||
def render_auto_link(self, token: AutoLink) -> str:
|
||||
template = '<a href="{target}" rel="noopener">{inner}</a>'
|
||||
target = self.escape_url(token.target)
|
||||
return template.format(target=target, inner=target)
|
||||
|
||||
def render_mention(self, token: Mention) -> str:
|
||||
mention = token.target
|
||||
suffix = ""
|
||||
if mention.endswith("."):
|
||||
mention = mention[:-1]
|
||||
suffix = "."
|
||||
actor = self.mentioned_actors.get(mention)
|
||||
if not actor:
|
||||
return mention
|
||||
|
||||
self.tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
||||
|
||||
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>{suffix}' # noqa: E501
|
||||
return link
|
||||
|
||||
def render_hashtag(self, token: Hashtag) -> str:
|
||||
tag = token.target[1:]
|
||||
link = f'<a href="{BASE_URL}/t/{tag.lower()}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
||||
self.tags.append(
|
||||
dict(
|
||||
href=f"{BASE_URL}/t/{tag.lower()}",
|
||||
name=token.target.lower(),
|
||||
type="Hashtag",
|
||||
)
|
||||
)
|
||||
return link
|
||||
|
||||
def render_block_code(self, token: typing.Any) -> str:
|
||||
code = token.children[0].content
|
||||
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
|
||||
return highlight(code, lexer, _FORMATTER)
|
||||
|
||||
|
||||
async def _prefetch_mentioned_actors(
|
||||
db_session: AsyncSession,
|
||||
content: str,
|
||||
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
|
||||
) -> dict[str, "Actor"]:
|
||||
from app import models
|
||||
from app.actor import fetch_actor
|
||||
|
||||
tags = []
|
||||
mentioned_actors = []
|
||||
actors = {}
|
||||
|
||||
for mention in re.findall(_MENTION_REGEX, content):
|
||||
_, username, domain = mention.split("@")
|
||||
actor = (
|
||||
await db_session.execute(
|
||||
select(models.Actor).where(
|
||||
models.Actor.handle == mention,
|
||||
models.Actor.is_deleted.is_(False),
|
||||
if mention in actors:
|
||||
continue
|
||||
|
||||
# XXX: the regex catches stuff like `@toto@example.com.`
|
||||
if mention.endswith("."):
|
||||
mention = mention[:-1]
|
||||
|
||||
try:
|
||||
_, username, domain = mention.split("@")
|
||||
actor = (
|
||||
await db_session.execute(
|
||||
select(models.Actor).where(
|
||||
models.Actor.handle == mention,
|
||||
models.Actor.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not actor:
|
||||
actor_url = await webfinger.get_actor_url(mention)
|
||||
if not actor_url:
|
||||
# FIXME(ts): raise an error?
|
||||
continue
|
||||
actor = await fetch_actor(db_session, actor_url)
|
||||
).scalar_one_or_none()
|
||||
if not actor:
|
||||
actor_url = await webfinger.get_actor_url(mention)
|
||||
if not actor_url:
|
||||
# FIXME(ts): raise an error?
|
||||
continue
|
||||
actor = await fetch_actor(db_session, actor_url)
|
||||
|
||||
mentioned_actors.append(actor)
|
||||
tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
||||
actors[mention] = actor
|
||||
except Exception:
|
||||
logger.exception(f"Failed to prefetch {mention}")
|
||||
|
||||
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
|
||||
content = content.replace(mention, link)
|
||||
return content, tags, mentioned_actors
|
||||
return actors
|
||||
|
||||
|
||||
def hashtagify(
|
||||
content: str,
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tags = []
|
||||
with CustomRenderer(
|
||||
mentioned_actors={},
|
||||
enable_mentionify=False,
|
||||
enable_hashtagify=True,
|
||||
) as renderer:
|
||||
rendered_content = renderer.render(Document(content))
|
||||
tags.extend(renderer.tags)
|
||||
|
||||
# Handle custom emoji
|
||||
tags.extend(emoji.tags(content))
|
||||
|
||||
return rendered_content, tags
|
||||
|
||||
|
||||
async def markdownify(
|
||||
@ -82,17 +180,33 @@ async def markdownify(
|
||||
|
||||
"""
|
||||
tags = []
|
||||
mentioned_actors: list["Actor"] = []
|
||||
if enable_hashtagify:
|
||||
content, hashtag_tags = hashtagify(content)
|
||||
tags.extend(hashtag_tags)
|
||||
mentioned_actors: dict[str, "Actor"] = {}
|
||||
if enable_mentionify:
|
||||
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
|
||||
tags.extend(mention_tags)
|
||||
mentioned_actors = await _prefetch_mentioned_actors(db_session, content)
|
||||
|
||||
with CustomRenderer(
|
||||
mentioned_actors=mentioned_actors,
|
||||
enable_mentionify=enable_mentionify,
|
||||
enable_hashtagify=enable_hashtagify,
|
||||
) as renderer:
|
||||
rendered_content = renderer.render(Document(content))
|
||||
tags.extend(renderer.tags)
|
||||
|
||||
# Handle custom emoji
|
||||
tags.extend(emoji.tags(content))
|
||||
|
||||
content = markdown(content, extensions=["mdx_linkify", "fenced_code"])
|
||||
return rendered_content, dedup_tags(tags), list(mentioned_actors.values())
|
||||
|
||||
return content, tags, mentioned_actors
|
||||
|
||||
def dedup_tags(tags: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||
idx = set()
|
||||
deduped_tags = []
|
||||
for tag in tags:
|
||||
tag_idx = (tag["type"], tag["name"])
|
||||
if tag_idx in idx:
|
||||
continue
|
||||
|
||||
idx.add(tag_idx)
|
||||
deduped_tags.append(tag)
|
||||
|
||||
return deduped_tags
|
||||
|
@ -1,4 +1,3 @@
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from functools import lru_cache
|
||||
@ -39,7 +38,7 @@ from app.utils.highlight import HIGHLIGHT_CSS
|
||||
from app.utils.highlight import highlight
|
||||
|
||||
_templates = Jinja2Templates(
|
||||
directory="app/templates",
|
||||
directory=["data/templates", "app/templates"], # type: ignore # bad typing
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
@ -59,13 +58,8 @@ def _filter_domain(text: str) -> str:
|
||||
|
||||
def _media_proxy_url(url: str | None) -> str:
|
||||
if not url:
|
||||
return "/static/nopic.png"
|
||||
|
||||
if url.startswith(BASE_URL):
|
||||
return url
|
||||
|
||||
encoded_url = base64.urlsafe_b64encode(url.encode()).decode()
|
||||
return f"/proxy/media/{encoded_url}"
|
||||
return BASE_URL + "/static/nopic.png"
|
||||
return proxied_media_url(url)
|
||||
|
||||
|
||||
def is_current_user_admin(request: Request) -> bool:
|
||||
@ -91,6 +85,7 @@ async def render_template(
|
||||
template: str,
|
||||
template_args: dict[str, Any] | None = None,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> TemplateResponse:
|
||||
if template_args is None:
|
||||
template_args = {}
|
||||
@ -135,6 +130,7 @@ async def render_template(
|
||||
**template_args,
|
||||
},
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@ -291,6 +287,10 @@ ALLOWED_ATTRIBUTES: dict[str, list[str] | Callable[[str, str, str], bool]] = {
|
||||
}
|
||||
|
||||
|
||||
def _allow_all_attributes(tag: Any, name: Any, value: Any) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _update_inline_imgs(content):
|
||||
soup = BeautifulSoup(content, "html5lib")
|
||||
@ -320,7 +320,11 @@ def _clean_html(html: str, note: Object) -> str:
|
||||
_update_inline_imgs(highlight(html))
|
||||
),
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
attributes=(
|
||||
_allow_all_attributes
|
||||
if note.ap_id.startswith(config.ID)
|
||||
else ALLOWED_ATTRIBUTES
|
||||
),
|
||||
strip=True,
|
||||
),
|
||||
note,
|
||||
@ -380,7 +384,7 @@ def _html2text(content: str) -> str:
|
||||
|
||||
def _replace_emoji(u: str, _) -> str:
|
||||
filename = "-".join(hex(ord(c))[2:] for c in u)
|
||||
return config.EMOJI_TPL.format(filename=filename, raw=u)
|
||||
return config.EMOJI_TPL.format(base_url=BASE_URL, filename=filename, raw=u)
|
||||
|
||||
|
||||
def _emojify(text: str, is_local: bool) -> str:
|
||||
@ -419,3 +423,7 @@ _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
|
||||
_templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems
|
||||
_templates.env.globals["ICON_URL"] = config.CONFIG.icon_url
|
||||
|
@ -27,7 +27,7 @@
|
||||
{{ utils.actor_action(inbox_object, "followed you") }}
|
||||
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
|
||||
{% elif inbox_object.ap_type == "Like" %}
|
||||
{{ utils.actor_action(inbox_object, "liked one of your post", with_icon=True) }}
|
||||
{{ utils.actor_action(inbox_object, "liked one of your posts", with_icon=True) }}
|
||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||
{% else %}
|
||||
<p>
|
||||
|
@ -90,5 +90,5 @@
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/static/new.js?v={{ JS_HASH }}"></script>
|
||||
<script src="{{ BASE_URL }}/static/new.js?v={{ JS_HASH }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -12,18 +12,16 @@
|
||||
{% for outbox_object in outbox %}
|
||||
|
||||
{% if outbox_object.ap_type == "Announce" %}
|
||||
<div class="actor-action">You shared</div>
|
||||
<div class="actor-action">You shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||
{% elif outbox_object.ap_type == "Like" %}
|
||||
<div class="actor-action">You liked</div>
|
||||
<div class="actor-action">You liked <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||
{% elif outbox_object.ap_type == "Follow" %}
|
||||
<div class="actor-action">You followed</div>
|
||||
<div class="actor-action">You followed <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
||||
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
||||
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
||||
{{ utils.display_object(outbox_object) }}
|
||||
{% else %}
|
||||
Implement {{ outbox_object.ap_type }}
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
|
30
app/templates/custom_page.html
Normal file
30
app/templates/custom_page.html
Normal file
@ -0,0 +1,30 @@
|
||||
{%- import "utils.html" as utils with context -%}
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ title }}</title>
|
||||
{% if request.url.path == "/" %}
|
||||
<link rel="indieauth-metadata" href="{{ url_for("well_known_authorization_server") }}">
|
||||
<link rel="authorization_endpoint" href="{{ url_for("indieauth_authorization_endpoint") }}">
|
||||
<link rel="token_endpoint" href="{{ url_for("indieauth_token_endpoint") }}">
|
||||
<link rel="micropub" href="{{ url_for("micropub_endpoint") }}">
|
||||
<link rel="alternate" href="{{ local_actor.url }}" title="ActivityPub profile" type="application/activity+json">
|
||||
<meta content="profile" property="og:type" />
|
||||
<meta content="{{ local_actor.url }}" property="og:url" />
|
||||
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
||||
<meta content="Homepage" property="og:title" />
|
||||
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
||||
<meta content="{{ ICON_URL }}" property="og:image" />
|
||||
<meta content="summary" property="twitter:card" />
|
||||
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "header.html" %}
|
||||
|
||||
<div class="box">
|
||||
{{ page_content | safe }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -1,12 +1,12 @@
|
||||
{%- import "utils.html" as utils with context -%}
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block main_tag %} class="main-flex"{% endblock %}
|
||||
{% block head %}
|
||||
<title>{{ title }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="centered primary-color">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="centered primary-color box">
|
||||
<h1 class="error-title">{{ title | safe }}</h1>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -25,20 +25,35 @@
|
||||
</div>
|
||||
|
||||
{%- macro header_link(url, text) -%}
|
||||
{% set url_for = request.app.router.url_path_for(url) %}
|
||||
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
|
||||
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||
{% endmacro %}
|
||||
|
||||
{%- macro navbar_item_link(navbar_item) -%}
|
||||
{% set url_for = BASE_URL + navbar_item[0] %}
|
||||
<a href="{{ navbar_item[0] }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ navbar_item[1] }}</a>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="public-top-menu">
|
||||
<nav class="flexbox">
|
||||
<ul>
|
||||
{% if NAVBAR_ITEMS.INDEX_NAVBAR_ITEM %}
|
||||
<li>{{ navbar_item_link(NAVBAR_ITEMS.INDEX_NAVBAR_ITEM) }}</li>
|
||||
{% endif %}
|
||||
<li>{{ header_link("index", "Notes") }}</li>
|
||||
{% if articles_count %}
|
||||
<li>{{ header_link("articles", "Articles") }}</li>
|
||||
{% 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>
|
||||
{% for navbar_item in NAVBAR_ITEMS.EXTRA_NAVBAR_ITEMS %}
|
||||
{{ navbar_item_link(navbar_item) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@
|
||||
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
||||
<meta content="Homepage" property="og:title" />
|
||||
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
||||
<meta content="{{ local_actor.url }}" property="og:image" />
|
||||
<meta content="{{ ICON_URL }}" property="og:image" />
|
||||
<meta content="summary" property="twitter:card" />
|
||||
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
||||
{% endblock %}
|
||||
|
@ -4,22 +4,22 @@
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="/static/css/main.css?v={{ CSS_HASH }}">
|
||||
<link rel="stylesheet" href="{{ BASE_URL }}/static/css/main.css?v={{ CSS_HASH }}">
|
||||
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
|
||||
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
|
||||
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="{{ BASE_URL }}/static/favicon.ico">
|
||||
<style>{{ highlight_css }}</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<main>
|
||||
<main{%- block main_tag %}{%- endblock %}>
|
||||
{% if is_admin %}
|
||||
<div id="admin">
|
||||
{% macro admin_link(url, text) %}
|
||||
{% set url_for = request.app.router.url_path_for(url) %}
|
||||
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
|
||||
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||
{% endmacro %}
|
||||
<div class="admin-menu">
|
||||
<nav class="flexbox">
|
||||
@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
{% if is_admin %}
|
||||
<script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
|
||||
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,15 +1,18 @@
|
||||
{%- import "utils.html" as utils with context -%}
|
||||
{% extends "layout.html" %}
|
||||
{% block main_tag %} class="main-flex"{% endblock %}
|
||||
{% block content %}
|
||||
<div class="centered">
|
||||
{% if error %}
|
||||
<p class="primary-color">Invalid password.</p>
|
||||
{% endif %}
|
||||
<form class="form" action="/admin/login" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
||||
<input type="password" placeholder="password" name="password" autofocus>
|
||||
<input type="submit" value="login">
|
||||
</form>
|
||||
<div>
|
||||
{% if error %}
|
||||
<p class="primary-color">Invalid password.</p>
|
||||
{% endif %}
|
||||
<form class="form" action="{{ BASE_URL }}/admin/login" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
||||
<input type="password" placeholder="password" name="password" autofocus>
|
||||
<input type="submit" value="login">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -20,6 +20,8 @@
|
||||
<div class="box error-box">
|
||||
{% if error.value == "NOT_FOUND" %}
|
||||
<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 %}
|
||||
|
@ -36,6 +36,18 @@
|
||||
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
||||
{{ notif_actor_action(notif, "rejected your follow request") }}
|
||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||
{% elif notif.notification_type.value == "blocked" %}
|
||||
{{ notif_actor_action(notif, "blocked you") }}
|
||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||
{% elif notif.notification_type.value == "unblocked" %}
|
||||
{{ notif_actor_action(notif, "unblocked you") }}
|
||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||
{% elif notif.notification_type.value == "block" %}
|
||||
{{ notif_actor_action(notif, "was blocked") }}
|
||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||
{% elif notif.notification_type.value == "unblock" %}
|
||||
{{ notif_actor_action(notif, "was unblocked") }}
|
||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||
{%- elif notif.notification_type.value == "move" %}
|
||||
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
||||
<div class="actor-action">
|
||||
|
15
app/templates/redirect_to_remote_instance.html
Normal file
15
app/templates/redirect_to_remote_instance.html
Normal file
@ -0,0 +1,15 @@
|
||||
{%- import "utils.html" as utils with context -%}
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ local_actor.display_name }}'s microblog - Redirect</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "header.html" %}
|
||||
|
||||
<div class="box">
|
||||
<p>You are being redirected to your instance: <a href="{{ url }}">{{ url }}</a></p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
26
app/templates/remote_interact.html
Normal file
26
app/templates/remote_interact.html
Normal file
@ -0,0 +1,26 @@
|
||||
{%- import "utils.html" as utils with context -%}
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
<title>Interact from your instance</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "header.html" %}
|
||||
|
||||
<div class="box">
|
||||
<h2>Interact with this object</h2>
|
||||
</div>
|
||||
|
||||
{{ utils.display_object(outbox_object) }}
|
||||
|
||||
<div class="box">
|
||||
<form class="form" action="{{ url_for("post_remote_interaction") }}" method="POST">
|
||||
{{ utils.embed_csrf_token() }}
|
||||
<input type="text" name="profile" placeholder="you@instance.tld" autofocus>
|
||||
<input type="hidden" name="ap_id" value="{{ outbox_object.ap_id }}">
|
||||
<input type="submit" value="interact from your instance">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -1,168 +1,220 @@
|
||||
{% macro embed_csrf_token() %}
|
||||
{% block embed_csrf_token scoped %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro embed_redirect_url(permalink_id=None) %}
|
||||
{% block embed_redirect_url scoped %}
|
||||
<input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}">
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_block_button(actor) %}
|
||||
{% block admin_block_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_block") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url() }}
|
||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||
<input type="submit" value="block">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_unblock_button(actor) %}
|
||||
{% block admin_unblock_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url() }}
|
||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||
<input type="submit" value="unblock">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_follow_button(actor) %}
|
||||
{% block admin_follow_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url() }}
|
||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||
<input type="submit" value="follow">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_accept_incoming_follow_button(notif) %}
|
||||
{% block admin_accept_incoming_follow_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url() }}
|
||||
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
||||
<input type="submit" value="accept follow">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_reject_incoming_follow_button(notif) %}
|
||||
{% block admin_reject_incoming_follow_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url() }}
|
||||
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
||||
<input type="submit" value="reject follow">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_like_button(ap_object_id, permalink_id) %}
|
||||
{% block admin_like_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_like") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="like">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_bookmark_button(ap_object_id, permalink_id) %}
|
||||
{% block admin_bookmark_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="bookmark">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
|
||||
{% block admin_unbookmark_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="unbookmark">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_pin_button(ap_object_id, permalink_id) %}
|
||||
{% block admin_pin_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="pin">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_unpin_button(ap_object_id, permalink_id) %}
|
||||
{% block admin_unpin_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="unpin">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_delete_button(ap_object) %}
|
||||
{% block admin_delete_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
<input type="hidden" name="redirect_url" value="{% if request.url.path.endswith("/" + ap_object.public_id) or (request.url.path == "/admin/object" and request.query_params.ap_id.endswith("/" + ap_object.public_id)) %}{{ request.base_url}}{% else %}{{ request.url }}{% endif %}">
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
|
||||
<input type="submit" value="delete">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_force_delete_button(ap_object_id, permalink_id=None) %}
|
||||
{% block admin_force_delete_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_force_delete") }}" class="object-delete-form" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="local delete">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_announce_button(ap_object_id, permalink_id=None) %}
|
||||
{% block admin_announce_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="share">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %}
|
||||
{% block admin_undo_button scoped %}
|
||||
<form action="{{ request.url_for("admin_actions_undo") }}" method="POST">
|
||||
{{ embed_csrf_token() }}
|
||||
{{ embed_redirect_url(permalink_id) }}
|
||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||
<input type="submit" value="{{ action }}">
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_reply_button(ap_object_id) %}
|
||||
<form action="/admin/new" method="GET">
|
||||
{% block admin_reply_button scoped %}
|
||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
||||
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
|
||||
<button type="submit">reply</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_dm_button(actor_handle) %}
|
||||
<form action="/admin/new" method="GET">
|
||||
{% block admin_dm_button scoped %}
|
||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
||||
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
||||
<input type="hidden" name="with_visibility" value="DIRECT">
|
||||
<button type="submit">direct message</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_mention_button(actor_handle) %}
|
||||
<form action="/admin/new" method="GET">
|
||||
{% block admin_mention_button scoped %}
|
||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
||||
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
||||
<button type="submit">mention</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
{% macro admin_profile_button(ap_actor_id) %}
|
||||
{% block admin_profile_button scoped %}
|
||||
<form action="{{ url_for("admin_profile") }}" method="GET">
|
||||
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
|
||||
<button type="submit">profile</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin_expand_button(ap_object) %}
|
||||
{% block admin_expand_button scoped %}
|
||||
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
|
||||
<form action="{{ url_for("admin_object") }}" method="GET">
|
||||
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
|
||||
<button type="submit">expand</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro display_box_filters(route) %}
|
||||
{% block display_box_filters scoped %}
|
||||
<nav class="flexbox box">
|
||||
<ul>
|
||||
<li>Filter by</li>
|
||||
@ -179,13 +231,17 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro display_tiny_actor_icon(actor) %}
|
||||
{% block display_tiny_actor_icon scoped %}
|
||||
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro actor_action(inbox_object, text, with_icon=False) %}
|
||||
{% block actor_action scoped %}
|
||||
<div class="actor-action">
|
||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
|
||||
{% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}
|
||||
@ -193,9 +249,11 @@
|
||||
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
|
||||
{% block display_actor scoped %}
|
||||
{% set metadata = actors_metadata.get(actor.ap_id) %}
|
||||
|
||||
{% if not embedded %}
|
||||
@ -216,13 +274,25 @@
|
||||
<div>
|
||||
<nav class="flexbox actor-metadata">
|
||||
<ul>
|
||||
{% if metadata.has_blocked_local_actor %}
|
||||
<li>blocked you</li>
|
||||
{% endif %}
|
||||
{% if metadata.is_following %}
|
||||
<li>already following</li>
|
||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||
{% if not with_details %}
|
||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||
{% endif %}
|
||||
{% elif metadata.is_follow_request_sent %}
|
||||
<li>follow request sent</li>
|
||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
||||
{% if metadata.is_follow_request_rejected %}
|
||||
<li>follow request rejected</li>
|
||||
{% if not metadata.has_blocked_local_actor %}
|
||||
<li>{{ admin_follow_button(actor) }}</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li>follow request sent</li>
|
||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
||||
{% endif %}
|
||||
{% elif not actor.moved_to %}
|
||||
<li>{{ admin_follow_button(actor) }}</li>
|
||||
{% endif %}
|
||||
@ -231,7 +301,7 @@
|
||||
{% if not metadata.is_following and not with_details %}
|
||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||
{% endif %}
|
||||
{% elif actor.is_from_db and not with_details %}
|
||||
{% elif actor.is_from_db and not with_details and not metadata.is_following %}
|
||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||
{% endif %}
|
||||
{% if actor.moved_to %}
|
||||
@ -261,6 +331,9 @@
|
||||
<li>rejected</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if with_details %}
|
||||
<li><a href="{{ actor.url }}" class="label-btn">remote profile</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
@ -291,11 +364,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro display_og_meta(object) %}
|
||||
{% block display_og_meta scoped %}
|
||||
{% if object.og_meta %}
|
||||
{% for og_meta in object.og_meta %}
|
||||
{% for og_meta in object.og_meta[:1] %}
|
||||
<div class="activity-og-meta">
|
||||
{% if og_meta.image %}
|
||||
<div>
|
||||
@ -311,22 +386,29 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro display_attachments(object) %}
|
||||
{% block display_attachments scoped %}
|
||||
|
||||
{% for attachment in object.attachments %}
|
||||
{% if attachment.type != "PropertyValue" %}
|
||||
{% set orientation = "unknown" %}
|
||||
{% if attachment.width %}
|
||||
{% set orientation = "portrait" if attachment.width < attachment.height else "landscape" %}
|
||||
{% endif %}
|
||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
||||
<div class="attachment-wrapper">
|
||||
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
|
||||
<div>
|
||||
<div class="sensitive-attachment">
|
||||
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
|
||||
<div class="sensitive-attachment-box">
|
||||
<div class="sensitive-attachment-box attachment-orientation-{{orientation}}">
|
||||
<div></div>
|
||||
{% else %}
|
||||
<div class="attachment-item">
|
||||
<div class="attachment-item attachment-orientation-{{orientation}}">
|
||||
{% endif %}
|
||||
|
||||
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
|
||||
@ -338,11 +420,13 @@
|
||||
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
||||
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
|
||||
{% elif attachment.type == "Link" %}
|
||||
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
|
||||
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
|
||||
{% else %}
|
||||
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
|
||||
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.url }}"{% endif %} class="attachment">
|
||||
{% if attachment.name %}{{ attachment.name }}{% else %}{{ attachment.url | truncate(64, True) }}{% endif %}
|
||||
</a> ({{ attachment.mimetype }})
|
||||
{% endif %}
|
||||
{% if object.sensitive %}
|
||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -350,12 +434,15 @@
|
||||
{% else %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
|
||||
{% block display_object scoped %}
|
||||
{% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %}
|
||||
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question"] %}
|
||||
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question", "Event"] %}
|
||||
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
|
||||
|
||||
{% if is_article_mode %}
|
||||
@ -369,15 +456,37 @@
|
||||
{% endif %}
|
||||
|
||||
{% if object.in_reply_to %}
|
||||
<a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
|
||||
in reply to {{ object.in_reply_to|truncate(64, True) }}
|
||||
</a>
|
||||
<p class="in-reply-to">in reply to <a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" rel="nofollow">
|
||||
this {{ object.ap_type|lower }}
|
||||
</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if object.ap_type == "Article" %}
|
||||
{% if object.ap_type in ["Article", "Event"] %}
|
||||
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if object.ap_type == "Event" %}
|
||||
{% if object.ap_object.get("endTime") and object.ap_object.get("startTime") %}
|
||||
<p>On {{ object.ap_object.startTime | parse_datetime | format_date }}
|
||||
(ends {{ object.ap_object.endTime | parse_datetime | format_date }})</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if object.ap_object.get("location") %}
|
||||
{% set loc = object.ap_object.get("location") %}
|
||||
{% if loc.type == "Place" and loc.latitude and loc.longitude %}
|
||||
<div class="ap-place">
|
||||
<h3>Location</h3>
|
||||
{% if loc.name %}{{ loc.name }}{% endif %}
|
||||
<span class="h-geo">
|
||||
<data class="p-latitude" value="{{ loc.latitude}}"></data>
|
||||
<data class="p-longitude" value="{{ loc.longitude }}"></data>
|
||||
<a href="https://www.openstreetmap.org/?mlat={{ loc.latitude }}&mlon={{ loc.longitude }}#map=16/{{loc.latitude}}/{{loc.longitude}}">{{loc.latitude}},{{loc.longitude}}</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if is_article_mode %}
|
||||
<time class="dt-published muted" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at.strftime("%b %d, %Y") }}</time>
|
||||
{% endif %}
|
||||
@ -442,10 +551,11 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{{ display_og_meta(object) }}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{{ display_og_meta(object) }}
|
||||
|
||||
</div>
|
||||
{% if object.summary %}
|
||||
</div>
|
||||
@ -460,6 +570,16 @@
|
||||
<li>
|
||||
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
||||
</li>
|
||||
|
||||
{% if object.is_from_outbox and is_object_page and not is_admin and not request.url.path.startswith("/remote_interaction") %}
|
||||
<li>
|
||||
<a class="label-btn" href="{{ request.url_for("remote_interaction") }}?ap_id={{ object.ap_id }}">
|
||||
interact from your instance
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not is_article_mode %}
|
||||
<li>
|
||||
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
||||
@ -555,7 +675,7 @@
|
||||
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
||||
<li>
|
||||
{% if object.announced_via_outbox_object_ap_id %}
|
||||
{{ admin_undo_button(object.liked_via_outbox_object_ap_id, "unshare") }}
|
||||
{{ admin_undo_button(object.announced_via_outbox_object_ap_id, "unshare") }}
|
||||
{% else %}
|
||||
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
||||
{% endif %}
|
||||
@ -573,6 +693,11 @@
|
||||
{{ admin_expand_button(object) }}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if object.is_from_inbox %}
|
||||
<li>
|
||||
{{ admin_force_delete_button(object.ap_id) }}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
@ -635,4 +760,5 @@
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
@ -46,7 +46,7 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
|
||||
width = None
|
||||
height = None
|
||||
|
||||
if f.content_type.startswith("image"):
|
||||
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)
|
||||
|
32
app/utils/custom_index_handler.py
Normal file
32
app/utils/custom_index_handler.py
Normal file
@ -0,0 +1,32 @@
|
||||
from typing import Any
|
||||
from typing import Awaitable
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.actor import LOCAL_ACTOR
|
||||
from app.config import is_activitypub_requested
|
||||
from app.database import AsyncSession
|
||||
from app.database import get_db_session
|
||||
|
||||
_Handler = Callable[[Request, AsyncSession], Awaitable[Any]]
|
||||
|
||||
|
||||
def build_custom_index_handler(handler: _Handler) -> _Handler:
|
||||
async def custom_index(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> Any:
|
||||
# Serve the AP actor if requested
|
||||
if is_activitypub_requested(request):
|
||||
return JSONResponse(
|
||||
LOCAL_ACTOR.ap_actor,
|
||||
media_type="application/activity+json",
|
||||
)
|
||||
|
||||
# Defer to the custom handler
|
||||
return await handler(request, db_session)
|
||||
|
||||
return custom_index
|
@ -1,14 +1,18 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import re
|
||||
import signal
|
||||
from concurrent.futures import TimeoutError
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup # type: ignore
|
||||
from loguru import logger
|
||||
from pebble import concurrent # type: ignore
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import activitypub as ap
|
||||
from app import ap_object
|
||||
from app import config
|
||||
from app.actor import LOCAL_ACTOR
|
||||
@ -28,7 +32,11 @@ class OpenGraphMeta(BaseModel):
|
||||
site_name: str
|
||||
|
||||
|
||||
@concurrent.process(timeout=5)
|
||||
def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
||||
# Prevent SIGTERM to bubble up to the worker
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
|
||||
soup = BeautifulSoup(html, "html5lib")
|
||||
ogs = {
|
||||
og.attrs["property"]: og.attrs.get("content")
|
||||
@ -57,6 +65,10 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
||||
return OpenGraphMeta.parse_obj(raw)
|
||||
|
||||
|
||||
def scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
||||
return _scrap_og_meta(url, html).result()
|
||||
|
||||
|
||||
async def external_urls(
|
||||
db_session: AsyncSession,
|
||||
ro: ap_object.RemoteObject | OutboxObject | InboxObject,
|
||||
@ -69,7 +81,12 @@ async def external_urls(
|
||||
tags_hrefs.add(tag_href)
|
||||
if tag.get("type") == "Mention":
|
||||
if tag["href"] != LOCAL_ACTOR.ap_id:
|
||||
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
||||
try:
|
||||
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
||||
except (ap.FetchError, ap.NotAnObjectError):
|
||||
tags_hrefs.add(tag["href"])
|
||||
continue
|
||||
|
||||
tags_hrefs.add(mentioned_actor.url)
|
||||
tags_hrefs.add(mentioned_actor.ap_id)
|
||||
else:
|
||||
@ -81,18 +98,25 @@ async def external_urls(
|
||||
soup = BeautifulSoup(ro.content, "html5lib")
|
||||
for link in soup.find_all("a"):
|
||||
h = link.get("href")
|
||||
ph = urlparse(h)
|
||||
mimetype, _ = mimetypes.guess_type(h)
|
||||
if (
|
||||
ph.scheme in {"http", "https"}
|
||||
and ph.hostname != note_host
|
||||
and is_url_valid(h)
|
||||
and (
|
||||
not mimetype
|
||||
or mimetype.split("/")[0] not in ["image", "video", "audio"]
|
||||
)
|
||||
):
|
||||
urls.add(h)
|
||||
if not h:
|
||||
continue
|
||||
|
||||
try:
|
||||
ph = urlparse(h)
|
||||
mimetype, _ = mimetypes.guess_type(h)
|
||||
if (
|
||||
ph.scheme in {"http", "https"}
|
||||
and ph.hostname != note_host
|
||||
and is_url_valid(h)
|
||||
and (
|
||||
not mimetype
|
||||
or mimetype.split("/")[0] not in ["image", "video", "audio"]
|
||||
)
|
||||
):
|
||||
urls.add(h)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to check {h}")
|
||||
continue
|
||||
|
||||
return urls - tags_hrefs
|
||||
|
||||
@ -113,7 +137,10 @@ async def _og_meta_from_url(url: str) -> OpenGraphMeta | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _scrap_og_meta(url, resp.text)
|
||||
return scrap_og_meta(url, resp.text)
|
||||
except TimeoutError:
|
||||
logger.info(f"Timed out when scraping OG meta for {url}")
|
||||
return None
|
||||
except Exception:
|
||||
logger.info(f"Failed to scrap OG meta for {url}")
|
||||
return None
|
||||
|
8
app/utils/text.py
Normal file
8
app/utils/text.py
Normal file
@ -0,0 +1,8 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
value = unicodedata.normalize("NFKC", text)
|
||||
value = re.sub(r"[^\w\s-]", "", value.lower())
|
||||
return re.sub(r"[-\s]+", "-", value).strip("-_")
|
@ -58,6 +58,10 @@ def is_url_valid(url: str) -> bool:
|
||||
logger.warning(f"{parsed.hostname} is blocked")
|
||||
return False
|
||||
|
||||
if parsed.hostname.endswith(".onion"):
|
||||
logger.warning(f"{url} is an onion service")
|
||||
return False
|
||||
|
||||
ip_address = _getaddrinfo(
|
||||
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
||||
)
|
||||
|
@ -69,5 +69,5 @@ class Worker(Generic[T]):
|
||||
logger.info("stopping loop")
|
||||
|
||||
async def _shutdown(self, sig: signal.Signals) -> None:
|
||||
logger.info(f"Caught {signal=}")
|
||||
logger.info(f"Caught {sig=}")
|
||||
self._stop_event.set()
|
||||
|
@ -12,6 +12,7 @@ async def webfinger(
|
||||
resource: str,
|
||||
) -> dict[str, Any] | None: # noqa: C901
|
||||
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL."""
|
||||
resource = resource.strip()
|
||||
logger.info(f"performing webfinger resolution for {resource}")
|
||||
protos = ["https", "http"]
|
||||
if resource.startswith("http://"):
|
||||
|
1
data/templates/app
Symbolic link
1
data/templates/app
Symbolic link
@ -0,0 +1 @@
|
||||
../../app/templates/
|
@ -5,6 +5,7 @@ admin_password = "$2b$12$OwCyZM33uXQUVrChgER.h.qgFJ4fBp6tdFwArR3Lm1LV8NgMvIxVa"
|
||||
name = "test"
|
||||
summary = "<p>Hello</p>"
|
||||
https = false
|
||||
id = "http://localhost:8000"
|
||||
icon_url = "https://localhost:8000/static/nopic.png"
|
||||
secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
|
||||
debug = true
|
||||
|
@ -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]
|
||||
|
||||
|
@ -11,7 +11,7 @@ For now, there's no image published on Docker Hub, this means you will have to b
|
||||
Clone the repository, replace `you-domain.tld` by your own domain.
|
||||
|
||||
Note that if you want to serve static assets via your reverse proxy (like nginx), clone it in a place
|
||||
where accessible by your reverse proxy user.
|
||||
where it is accessible by your reverse proxy user.
|
||||
|
||||
```bash
|
||||
git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld
|
||||
@ -89,6 +89,12 @@ Setup config.
|
||||
poetry run inv configuration-wizard
|
||||
```
|
||||
|
||||
Setup the database.
|
||||
|
||||
```bash
|
||||
poetry run inv migrate-db
|
||||
```
|
||||
|
||||
Grab your virtualenv path.
|
||||
|
||||
```bash
|
||||
|
2
docs/templates/layout.html
vendored
2
docs/templates/layout.html
vendored
@ -63,7 +63,7 @@ nav a:hover, main a:hover, header p a:hover {
|
||||
max-width: 960px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
pre code {
|
||||
pre {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
display: block;
|
||||
|
@ -29,17 +29,20 @@ You can tweak your profile by tweaking these items:
|
||||
- `summary` (using Markdown)
|
||||
- `icon_url`
|
||||
|
||||
Whenever one of these config items is updated, an `Update` activity will be sent to all know server to update your remote profile.
|
||||
Whenever one of these config items is updated, an `Update` activity will be sent to all known servers to update your remote profile.
|
||||
|
||||
The server will need to be restarted for taking changes into account.
|
||||
|
||||
Before restarting the server, you can ensure you haven't made any mistakes by running the [configuration checking task](/user_guide.html#configuration-checking).
|
||||
|
||||
|
||||
### Profile metadata
|
||||
|
||||
You can add metadata to your profile with the `metadata` config item.
|
||||
|
||||
Markdown is supported in the `value` field.
|
||||
|
||||
Be aware that most other softwares like Mastodon will limit the number of key/value to 4.
|
||||
Be aware that most other software like Mastodon will limit the number of key/value to 4.
|
||||
|
||||
```toml
|
||||
metadata = [
|
||||
@ -58,12 +61,32 @@ 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 = [
|
||||
@ -102,11 +125,25 @@ $primary-color: #e14eea;
|
||||
$secondary-color: #32cd32;
|
||||
```
|
||||
|
||||
See `app/scss/main.scss` to see what variables can be overidden.
|
||||
See `app/scss/main.scss` to see what variables can be overridden.
|
||||
|
||||
#### Custom templates
|
||||
|
||||
If you'd like to customize your instance's theme beyond CSS, you can modify the app's HTML by placing templates in `data/templates` which overwrite the defaults in `app/templates`.
|
||||
|
||||
#### Custom Content Security Policy (CSP)
|
||||
|
||||
You can override the default Content Security Policy by adding a line in `data/profile.toml`:
|
||||
|
||||
```toml
|
||||
custom_content_security_policy = "default-src 'self'; style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
```
|
||||
|
||||
This example will output the default CSP, note that `{HIGHLIGHT_CSS_HASH}` will be dynamically replaced by the correct value (the hash of the CSS needed for syntax highlighting).
|
||||
|
||||
#### Code highlighting theme
|
||||
|
||||
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `profile.toml`:
|
||||
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `data/profile.toml`:
|
||||
|
||||
```toml
|
||||
code_highlighting_theme = "solarized-dark"
|
||||
@ -114,7 +151,7 @@ code_highlighting_theme = "solarized-dark"
|
||||
|
||||
### Blocking servers
|
||||
|
||||
In addition to blocking "single actors" via the admin interface, you can also prevent any communications with whole servers.
|
||||
In addition to blocking "single actors" via the admin interface, you can also prevent any communication with entire servers.
|
||||
|
||||
Add a `blocked_servers` config item into `profile.toml`.
|
||||
|
||||
@ -132,13 +169,13 @@ blocked_servers = [
|
||||
|
||||
Public notes will be visible on the homepage.
|
||||
|
||||
Only the last 20 followers/follows you be showing on the public website.
|
||||
Only the last 20 followers/follows you have will be shown on the public website.
|
||||
|
||||
And only the last 20 interactions (likes/shares/webmentions) will be displayed, to keep things simple/clean.
|
||||
|
||||
## Admin section
|
||||
|
||||
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin`.
|
||||
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin/login`.
|
||||
The password is the one set during the initial configuration.
|
||||
|
||||
### Lookup
|
||||
@ -202,7 +239,7 @@ Receiving a share will trigger a notification, increment the shares counter on t
|
||||
|
||||
Liking an object will notify the author.
|
||||
|
||||
Unlike sharing, liked object are not displayed on the homepage.
|
||||
Unlike sharing, liked objects are not displayed on the homepage.
|
||||
|
||||
Most receiving servers will increment the number of likes.
|
||||
|
||||
@ -212,13 +249,13 @@ Receiving a like will trigger a notification, increment the likes counter on the
|
||||
|
||||
Bookmarks allow you to like objects without notifying the author.
|
||||
|
||||
It is basically a "private like", and allow you to easily access them later.
|
||||
It is basically a "private like", and allows you to easily access them later.
|
||||
|
||||
It will also prevent objects to be pruned.
|
||||
|
||||
### Webmentions
|
||||
|
||||
Sending webmention to ping mentioned websites is done automatically once a public note is authored.
|
||||
Sending webmentions to ping mentioned websites is done automatically once a public note is authored.
|
||||
|
||||
Receiving a webmention will trigger a notification, increment the webmentions counter on the object and the source page will be displayed on the object permalink.
|
||||
|
||||
@ -241,6 +278,8 @@ If you want to move followers from your existing account, ensure it is supported
|
||||
|
||||
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
|
||||
@ -261,7 +300,7 @@ make account=username@domain.tld webfinger
|
||||
|
||||
Edit the config.
|
||||
|
||||
#### Edit the config
|
||||
### Edit the config
|
||||
|
||||
And add a reference to your old/existing account in `profile.toml`:
|
||||
|
||||
@ -273,6 +312,61 @@ Restart the server, and you should be able to complete the move from your existi
|
||||
|
||||
## Tasks
|
||||
|
||||
### Configuration checking
|
||||
|
||||
You can confirm that your configuration file (`data/profile.toml`) is valid using the `check-config`
|
||||
|
||||
#### Python edition
|
||||
|
||||
```bash
|
||||
poetry run inv check-config
|
||||
```
|
||||
|
||||
#### Docker edition
|
||||
|
||||
```bash
|
||||
make check-config
|
||||
```
|
||||
|
||||
### Recompiling CSS files
|
||||
|
||||
You can ensure your custom theme is valid by recompiling the CSS manually using the `compile-scss` task.
|
||||
|
||||
#### Python edition
|
||||
|
||||
```bash
|
||||
poetry run inv compile-scss
|
||||
```
|
||||
|
||||
#### Docker edition
|
||||
|
||||
```bash
|
||||
make compile-scss
|
||||
```
|
||||
|
||||
|
||||
### Password reset
|
||||
|
||||
If have lost your password, you can generate a new one using the `reset-password` task.
|
||||
|
||||
#### Python edition
|
||||
|
||||
```bash
|
||||
# shutdown supervisord
|
||||
poetry run inv reset-password
|
||||
# edit data/profile.toml
|
||||
# restart supervisord
|
||||
```
|
||||
|
||||
#### Docker edition
|
||||
|
||||
```bash
|
||||
docker compose stop
|
||||
make reset-password
|
||||
# edit data/profile.toml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Pruning old data
|
||||
|
||||
You should prune old data from time to time to free disk space.
|
||||
@ -323,6 +417,8 @@ If you want to migrate to another instance, you have the ability to move your ex
|
||||
|
||||
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
|
||||
@ -364,3 +460,11 @@ poetry run inv self-destruct
|
||||
# For a Docker install
|
||||
make self-destruct
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the server is not (re)starting, you can:
|
||||
|
||||
- [Ensure that the configuration is valid](/user_guide.html#configuration-checking)
|
||||
- [Verify if you haven't any syntax error in the custom theme by recompiling the CSS](/user_guide.html#recompiling-css-files)
|
||||
- Look at the log files
|
||||
|
874
poetry.lock
generated
874
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,6 @@ httpx = {extras = ["http2"], version = "^0.23.0"}
|
||||
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
|
||||
alembic = "^1.8.0"
|
||||
bleach = "^5.0.0"
|
||||
Markdown = "^3.3.7"
|
||||
prompt-toolkit = "^3.0.29"
|
||||
tomli-w = "^1.0.0"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -27,7 +26,6 @@ html5lib = "^1.1"
|
||||
mf2py = "^1.1.2"
|
||||
Pygments = "^2.12.0"
|
||||
loguru = "^0.6.0"
|
||||
mdx-linkify = "^2.1"
|
||||
Pillow = "^9.1.1"
|
||||
blurhash-python = "^1.1.3"
|
||||
html2text = "^2020.1.16"
|
||||
@ -44,6 +42,9 @@ invoke = "^1.7.1"
|
||||
boussole = "^2.0.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
||||
Brotli = "^1.0.9"
|
||||
greenlet = "^1.1.3"
|
||||
mistletoe = "^0.9.0"
|
||||
Pebble = "^5.0.2"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
black = "^22.3.0"
|
||||
|
@ -1,19 +1,115 @@
|
||||
import re
|
||||
import shutil
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment
|
||||
from jinja2 import FileSystemLoader
|
||||
from jinja2 import select_autoescape
|
||||
from markdown import markdown
|
||||
from mistletoe import Document # type: ignore
|
||||
from mistletoe import HTMLRenderer # type: ignore
|
||||
from mistletoe import block_token # type: ignore
|
||||
from pygments import highlight # type: ignore
|
||||
from pygments.formatters import HtmlFormatter # type: ignore
|
||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
||||
from pygments.lexers import guess_lexer # type: ignore
|
||||
|
||||
from app.config import VERSION
|
||||
from app.source import CustomRenderer
|
||||
from app.utils.datetime import now
|
||||
|
||||
_FORMATTER = HtmlFormatter()
|
||||
_FORMATTER.noclasses = True
|
||||
|
||||
def markdownify(content: str) -> str:
|
||||
return markdown(
|
||||
content, extensions=["mdx_linkify", "fenced_code", "codehilite", "toc"]
|
||||
)
|
||||
|
||||
class DocRenderer(CustomRenderer):
|
||||
def __init__(
|
||||
self,
|
||||
depth=5,
|
||||
omit_title=True,
|
||||
filter_conds=[],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
enable_mentionify=False,
|
||||
enable_hashtagify=False,
|
||||
)
|
||||
self._headings: list[tuple[int, str, str]] = []
|
||||
self._ids: set[str] = set()
|
||||
self.depth = depth
|
||||
self.omit_title = omit_title
|
||||
self.filter_conds = filter_conds
|
||||
|
||||
@property
|
||||
def toc(self):
|
||||
"""
|
||||
Returns table of contents as a block_token.List instance.
|
||||
"""
|
||||
|
||||
def get_indent(level):
|
||||
if self.omit_title:
|
||||
level -= 1
|
||||
return " " * 4 * (level - 1)
|
||||
|
||||
def build_list_item(heading):
|
||||
level, content, title_id = heading
|
||||
template = '{indent}- <a href="#{id}" rel="nofollow">{content}</a>\n'
|
||||
return template.format(
|
||||
indent=get_indent(level), content=content, id=title_id
|
||||
)
|
||||
|
||||
lines = [build_list_item(heading) for heading in self._headings]
|
||||
items = block_token.tokenize(lines)
|
||||
return items[0]
|
||||
|
||||
def render_heading(self, token):
|
||||
"""
|
||||
Overrides super().render_heading; stores rendered heading first,
|
||||
then returns it.
|
||||
"""
|
||||
template = '<h{level} id="{id}">{inner}</h{level}>'
|
||||
inner = self.render_inner(token)
|
||||
title_id = inner.lower().replace(" ", "-")
|
||||
if title_id in self._ids:
|
||||
i = 1
|
||||
while 1:
|
||||
title_id = f"{title_id}_{i}"
|
||||
if title_id not in self._ids:
|
||||
break
|
||||
self._ids.add(title_id)
|
||||
rendered = template.format(level=token.level, inner=inner, id=title_id)
|
||||
content = self.parse_rendered_heading(rendered)
|
||||
|
||||
if not (
|
||||
self.omit_title
|
||||
and token.level == 1
|
||||
or token.level > self.depth
|
||||
or any(cond(content) for cond in self.filter_conds)
|
||||
):
|
||||
self._headings.append((token.level, content, title_id))
|
||||
return rendered
|
||||
|
||||
@staticmethod
|
||||
def parse_rendered_heading(rendered):
|
||||
"""
|
||||
Helper method; converts rendered heading to plain text.
|
||||
"""
|
||||
return re.sub(r"<.+?>", "", rendered)
|
||||
|
||||
def render_block_code(self, token: typing.Any) -> str:
|
||||
code = token.children[0].content
|
||||
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
|
||||
return highlight(code, lexer, _FORMATTER)
|
||||
|
||||
|
||||
def markdownify(content: str) -> tuple[str, Any]:
|
||||
with DocRenderer() as renderer:
|
||||
rendered_content = renderer.render(Document(content))
|
||||
|
||||
with HTMLRenderer() as html_renderer:
|
||||
toc = html_renderer.render(renderer.toc)
|
||||
|
||||
return rendered_content, toc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@ -30,32 +126,36 @@ def main() -> None:
|
||||
last_updated = now().replace(second=0, microsecond=0).isoformat()
|
||||
|
||||
readme = Path("README.md")
|
||||
content, toc = markdownify(readme.read_text().removeprefix("# microblog.pub"))
|
||||
template.stream(
|
||||
content=markdownify(readme.read_text().removeprefix("# microblog.pub")),
|
||||
content=content,
|
||||
version=VERSION,
|
||||
path="/",
|
||||
last_updated=last_updated,
|
||||
).dump("docs/dist/index.html")
|
||||
|
||||
install = Path("docs/install.md")
|
||||
content, toc = markdownify(install.read_text())
|
||||
template.stream(
|
||||
content=markdownify(install.read_text()),
|
||||
content=content.replace("[TOC]", toc),
|
||||
version=VERSION,
|
||||
path="/installing.html",
|
||||
last_updated=last_updated,
|
||||
).dump("docs/dist/installing.html")
|
||||
|
||||
user_guide = Path("docs/user_guide.md")
|
||||
content, toc = markdownify(user_guide.read_text())
|
||||
template.stream(
|
||||
content=markdownify(user_guide.read_text()),
|
||||
content=content.replace("[TOC]", toc),
|
||||
version=VERSION,
|
||||
path="/user_guide.html",
|
||||
last_updated=last_updated,
|
||||
).dump("docs/dist/user_guide.html")
|
||||
|
||||
developer_guide = Path("docs/developer_guide.md")
|
||||
content, toc = markdownify(developer_guide.read_text())
|
||||
template.stream(
|
||||
content=markdownify(developer_guide.read_text()),
|
||||
content=content.replace("[TOC]", toc),
|
||||
version=VERSION,
|
||||
path="/developer_guide.html",
|
||||
last_updated=last_updated,
|
||||
|
45
tasks.py
45
tasks.py
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import io
|
||||
import shutil
|
||||
import tarfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
@ -45,7 +46,12 @@ def compile_scss(ctx, watch=False):
|
||||
# type: (Context, bool) -> None
|
||||
from app.utils.favicon import build_favicon
|
||||
|
||||
build_favicon()
|
||||
favicon_file = Path("data/favicon.ico")
|
||||
if not favicon_file.exists():
|
||||
build_favicon()
|
||||
else:
|
||||
shutil.copy2(favicon_file, "app/static/favicon.ico")
|
||||
|
||||
theme_file = Path("data/_theme.scss")
|
||||
if not theme_file.exists():
|
||||
theme_file.write_text("// override vars for theming here")
|
||||
@ -264,7 +270,7 @@ def move_to(ctx, moved_to):
|
||||
)
|
||||
return
|
||||
|
||||
await send_move(db_session, moved_to)
|
||||
await send_move(db_session, new_actor.ap_id)
|
||||
|
||||
print("Done")
|
||||
|
||||
@ -312,3 +318,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")
|
||||
|
@ -68,6 +68,20 @@ def build_accept_activity(
|
||||
}
|
||||
|
||||
|
||||
def build_block_activity(
|
||||
from_remote_actor: actor.RemoteActor,
|
||||
for_remote_actor: actor.RemoteActor,
|
||||
outbox_public_id: str | None = None,
|
||||
) -> ap.RawObject:
|
||||
return {
|
||||
"@context": ap.AS_CTX,
|
||||
"type": "Block",
|
||||
"id": from_remote_actor.ap_id + "/block/" + (outbox_public_id or uuid4().hex),
|
||||
"actor": from_remote_actor.ap_id,
|
||||
"object": for_remote_actor.ap_id,
|
||||
}
|
||||
|
||||
|
||||
def build_move_activity(
|
||||
from_remote_actor: actor.RemoteActor,
|
||||
for_remote_object: actor.RemoteActor,
|
||||
|
@ -423,3 +423,53 @@ def test_inbox__move_activity(
|
||||
).scalar_one()
|
||||
assert notif.actor.ap_id == new_ra.ap_id
|
||||
assert notif.inbox_object_id == inbox_activity.id
|
||||
|
||||
|
||||
def test_inbox__block_activity(
|
||||
db: Session,
|
||||
client: TestClient,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
# Given a remote actor
|
||||
ra = setup_remote_actor(respx_mock)
|
||||
|
||||
# Which is followed by the local actor
|
||||
setup_remote_actor_as_following(ra)
|
||||
|
||||
# When receiving a Block activity
|
||||
follow_activity = RemoteObject(
|
||||
factories.build_block_activity(
|
||||
from_remote_actor=ra,
|
||||
for_remote_actor=LOCAL_ACTOR,
|
||||
),
|
||||
ra,
|
||||
)
|
||||
with mock_httpsig_checker(ra):
|
||||
response = client.post(
|
||||
"/inbox",
|
||||
headers={"Content-Type": ap.AS_CTX},
|
||||
json=follow_activity.ap_object,
|
||||
)
|
||||
|
||||
# Then the server returns a 202
|
||||
assert response.status_code == 202
|
||||
|
||||
run_process_next_incoming_activity()
|
||||
|
||||
# And the actor was saved in DB
|
||||
saved_actor = db.execute(select(models.Actor)).scalar_one()
|
||||
assert saved_actor.ap_id == ra.ap_id
|
||||
|
||||
# And the Block activity was saved in the inbox
|
||||
inbox_activity = db.execute(
|
||||
select(models.InboxObject).where(models.InboxObject.ap_type == "Block")
|
||||
).scalar_one()
|
||||
|
||||
# And a notification was created
|
||||
notif = db.execute(
|
||||
select(models.Notification).where(
|
||||
models.Notification.notification_type == models.NotificationType.BLOCKED
|
||||
)
|
||||
).scalar_one()
|
||||
assert notif.actor.ap_id == ra.ap_id
|
||||
assert notif.inbox_object_id == inbox_activity.id
|
||||
|
@ -77,23 +77,29 @@ def test_send_delete__reverts_side_effects(
|
||||
|
||||
# with a note that has existing replies
|
||||
inbox_note = setup_inbox_note(actor)
|
||||
inbox_note.replies_count = 1
|
||||
# with a bogus counter
|
||||
inbox_note.replies_count = 5
|
||||
db.commit()
|
||||
|
||||
# and a local reply
|
||||
outbox_note = setup_outbox_note(
|
||||
# and 2 local replies
|
||||
setup_outbox_note(
|
||||
to=[ap.AS_PUBLIC],
|
||||
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
|
||||
in_reply_to=inbox_note.ap_id,
|
||||
)
|
||||
outbox_note2 = setup_outbox_note(
|
||||
to=[ap.AS_PUBLIC],
|
||||
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
|
||||
in_reply_to=inbox_note.ap_id,
|
||||
)
|
||||
inbox_note.replies_count = inbox_note.replies_count + 1
|
||||
db.commit()
|
||||
|
||||
# When deleting one of the replies
|
||||
response = client.post(
|
||||
"/admin/actions/delete",
|
||||
data={
|
||||
"redirect_url": "http://testserver/",
|
||||
"ap_object_id": outbox_note.ap_id,
|
||||
"ap_object_id": outbox_note2.ap_id,
|
||||
"csrf_token": generate_csrf_token(),
|
||||
},
|
||||
cookies=generate_admin_session_cookies(),
|
||||
@ -108,14 +114,14 @@ def test_send_delete__reverts_side_effects(
|
||||
select(models.OutboxObject).where(models.OutboxObject.ap_type == "Delete")
|
||||
).scalar_one()
|
||||
assert outbox_object.ap_type == "Delete"
|
||||
assert outbox_object.activity_object_ap_id == outbox_note.ap_id
|
||||
assert outbox_object.activity_object_ap_id == outbox_note2.ap_id
|
||||
|
||||
# And an outgoing activity was queued
|
||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||
assert outgoing_activity.recipient == ra.inbox_url
|
||||
|
||||
# And the replies count of the replied object was decremented
|
||||
# And the replies count of the replied object was refreshed correctly
|
||||
db.refresh(inbox_note)
|
||||
assert inbox_note.replies_count == 1
|
||||
|
||||
@ -173,7 +179,7 @@ def test_send_create_activity__with_attachment(
|
||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||
assert outbox_object.ap_type == "Note"
|
||||
assert outbox_object.summary is None
|
||||
assert outbox_object.content == "<p>hello</p>"
|
||||
assert outbox_object.content == "<p>hello</p>\n"
|
||||
assert len(outbox_object.attachments) == 1
|
||||
attachment = outbox_object.attachments[0]
|
||||
assert attachment.type == "Document"
|
||||
@ -221,7 +227,7 @@ def test_send_create_activity__no_content_with_cw_and_attachments(
|
||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||
assert outbox_object.ap_type == "Note"
|
||||
assert outbox_object.summary is None
|
||||
assert outbox_object.content == "<p>cw</p>"
|
||||
assert outbox_object.content == "<p>cw</p>\n"
|
||||
assert len(outbox_object.attachments) == 1
|
||||
|
||||
|
||||
|
@ -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")
|
||||
|
Reference in New Issue
Block a user