mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2025-06-05 21:59:23 +02:00
Compare commits
2 Commits
test-css-t
...
quote-url-
Author | SHA1 | Date | |
---|---|---|---|
0f1fdd3944 | |||
254588f7c0 |
16
Makefile
16
Makefile
@ -12,32 +12,32 @@ config:
|
|||||||
|
|
||||||
.PHONY: update
|
.PHONY: update
|
||||||
update:
|
update:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
|
||||||
|
|
||||||
.PHONY: prune-old-data
|
.PHONY: prune-old-data
|
||||||
prune-old-data:
|
prune-old-data:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
||||||
|
|
||||||
.PHONY: webfinger
|
.PHONY: webfinger
|
||||||
webfinger:
|
webfinger:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
|
||||||
|
|
||||||
.PHONY: move-to
|
.PHONY: move-to
|
||||||
move-to:
|
move-to:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
|
||||||
|
|
||||||
.PHONY: self-destruct
|
.PHONY: self-destruct
|
||||||
self-destruct:
|
self-destruct:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
|
||||||
|
|
||||||
.PHONY: reset-password
|
.PHONY: reset-password
|
||||||
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
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv reset-password
|
||||||
|
|
||||||
.PHONY: check-config
|
.PHONY: check-config
|
||||||
check-config:
|
check-config:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv check-config
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv check-config
|
||||||
|
|
||||||
.PHONY: compile-scss
|
.PHONY: compile-scss
|
||||||
compile-scss:
|
compile-scss:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv compile-scss
|
-docker run --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
|
- Author notes in Markdown, with code highlighting support
|
||||||
- Dedicated section for articles/blog posts (enabled when the first article is posted)
|
- Dedicated section for articles/blog posts (enabled when the first article is posted)
|
||||||
- Lightweight
|
- Lightweight
|
||||||
- Uses SQLite, and Python 3.10+
|
- Uses SQLite, and no external dependencies except Python 3.10+
|
||||||
- Can be deployed on small VPS
|
- Can be deployed on small VPS
|
||||||
- Privacy-aware
|
- Privacy-aware
|
||||||
- EXIF metadata (like GPS location) are stripped before storage
|
- EXIF metadata (like GPS location) are stripped before storage
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
"""Add support for quote URL
|
||||||
|
|
||||||
|
Revision ID: c3027d0e18dc
|
||||||
|
Revises: 604d125ea2fb
|
||||||
|
Create Date: 2022-09-21 07:08:24.568124+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c3027d0e18dc'
|
||||||
|
down_revision = '604d125ea2fb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('quoted_inbox_object_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_quoted_inbox_object_id', 'inbox', ['quoted_inbox_object_id'], ['id'])
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('inbox', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_quoted_inbox_object_id', type_='foreignkey')
|
||||||
|
batch_op.drop_column('quoted_inbox_object_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
@ -1,48 +0,0 @@
|
|||||||
"""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,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.config import ALSO_KNOWN_AS
|
from app.config import ALSO_KNOWN_AS
|
||||||
@ -13,7 +14,6 @@ from app.config import AP_CONTENT_TYPE # noqa: F401
|
|||||||
from app.config import MOVED_TO
|
from app.config import MOVED_TO
|
||||||
from app.httpsig import auth
|
from app.httpsig import auth
|
||||||
from app.key import get_pubkey_as_pem
|
from app.key import get_pubkey_as_pem
|
||||||
from app.source import dedup_tags
|
|
||||||
from app.source import hashtagify
|
from app.source import hashtagify
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
|
|
||||||
@ -53,26 +53,15 @@ AS_EXTENDED_CTX = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class FetchError(Exception):
|
class ObjectIsGoneError(Exception):
|
||||||
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
|
||||||
resp_part = ""
|
|
||||||
if resp:
|
|
||||||
resp_part = f", got HTTP {resp.status_code}: {resp.text}"
|
|
||||||
message = f"Failed to fetch {url}{resp_part}"
|
|
||||||
super().__init__(message)
|
|
||||||
self.resp = resp
|
|
||||||
self.url = url
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectIsGoneError(FetchError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ObjectNotFoundError(FetchError):
|
class ObjectNotFoundError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ObjectUnavailableError(FetchError):
|
class ObjectUnavailableError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -101,19 +90,6 @@ class VisibilityEnum(str, enum.Enum):
|
|||||||
|
|
||||||
|
|
||||||
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
||||||
_LOCAL_ACTOR_METADATA = []
|
|
||||||
if config.CONFIG.metadata:
|
|
||||||
for kv in config.CONFIG.metadata:
|
|
||||||
kv_value, kv_tags = hashtagify(kv.value)
|
|
||||||
_LOCAL_ACTOR_METADATA.append(
|
|
||||||
{
|
|
||||||
"name": kv.key,
|
|
||||||
"type": "PropertyValue",
|
|
||||||
"value": kv_value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_LOCAL_ACTOR_TAGS.extend(kv_tags)
|
|
||||||
|
|
||||||
|
|
||||||
ME = {
|
ME = {
|
||||||
"@context": AS_EXTENDED_CTX,
|
"@context": AS_EXTENDED_CTX,
|
||||||
@ -126,7 +102,7 @@ ME = {
|
|||||||
"outbox": config.BASE_URL + "/outbox",
|
"outbox": config.BASE_URL + "/outbox",
|
||||||
"preferredUsername": config.USERNAME,
|
"preferredUsername": config.USERNAME,
|
||||||
"name": config.CONFIG.name,
|
"name": config.CONFIG.name,
|
||||||
"summary": _LOCAL_ACTOR_SUMMARY,
|
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
# For compat with servers expecting a sharedInbox...
|
# For compat with servers expecting a sharedInbox...
|
||||||
"sharedInbox": config.BASE_URL
|
"sharedInbox": config.BASE_URL
|
||||||
@ -134,7 +110,16 @@ ME = {
|
|||||||
},
|
},
|
||||||
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
||||||
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
||||||
"attachment": _LOCAL_ACTOR_METADATA,
|
"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 [],
|
||||||
"icon": {
|
"icon": {
|
||||||
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
||||||
"type": "Image",
|
"type": "Image",
|
||||||
@ -145,7 +130,7 @@ ME = {
|
|||||||
"owner": config.ID,
|
"owner": config.ID,
|
||||||
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
||||||
},
|
},
|
||||||
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
|
"tag": _LOCAL_ACTOR_TAGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ALSO_KNOWN_AS:
|
if ALSO_KNOWN_AS:
|
||||||
@ -185,17 +170,13 @@ async def fetch(
|
|||||||
|
|
||||||
# Special handling for deleted object
|
# Special handling for deleted object
|
||||||
if resp.status_code == 410:
|
if resp.status_code == 410:
|
||||||
raise ObjectIsGoneError(url, resp)
|
raise ObjectIsGoneError(f"{url} is gone")
|
||||||
elif resp.status_code in [401, 403]:
|
elif resp.status_code in [401, 403]:
|
||||||
raise ObjectUnavailableError(url, resp)
|
raise ObjectUnavailableError(f"not allowed to fetch {url}")
|
||||||
elif resp.status_code == 404:
|
elif resp.status_code == 404:
|
||||||
raise ObjectNotFoundError(url, resp)
|
raise ObjectNotFoundError(f"{url} not found")
|
||||||
|
|
||||||
try:
|
|
||||||
resp.raise_for_status()
|
|
||||||
except httpx.HTTPError as http_error:
|
|
||||||
raise FetchError(url, resp) from http_error
|
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
106
app/actor.py
106
app/actor.py
@ -1,7 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@ -13,8 +12,6 @@ from sqlalchemy.orm import joinedload
|
|||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app import media
|
from app import media
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.utils.datetime import as_utc
|
|
||||||
from app.utils.datetime import now
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from app.models import Actor as ActorModel
|
from app.models import Actor as ActorModel
|
||||||
@ -192,65 +189,26 @@ async def fetch_actor(
|
|||||||
if existing_actor:
|
if existing_actor:
|
||||||
if existing_actor.is_deleted:
|
if existing_actor.is_deleted:
|
||||||
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
||||||
|
return existing_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:
|
|
||||||
return existing_actor
|
|
||||||
|
|
||||||
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:
|
|
||||||
# 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:
|
else:
|
||||||
raise ap.ObjectNotFoundError(actor_id)
|
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)
|
||||||
async def update_actor_if_needed(
|
else:
|
||||||
db_session: AsyncSession,
|
raise ap.ObjectNotFoundError
|
||||||
actor_in_db: "ActorModel",
|
|
||||||
ra: RemoteActor,
|
|
||||||
) -> None:
|
|
||||||
# Check if we actually need to udpte the actor in DB
|
|
||||||
if _actor_hash(ra) != _actor_hash(actor_in_db):
|
|
||||||
actor_in_db.ap_actor = ra.ap_actor
|
|
||||||
actor_in_db.handle = ra.handle
|
|
||||||
actor_in_db.ap_type = ra.ap_type
|
|
||||||
|
|
||||||
actor_in_db.updated_at = now()
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -259,11 +217,9 @@ class ActorMetadata:
|
|||||||
is_following: bool
|
is_following: bool
|
||||||
is_follower: bool
|
is_follower: bool
|
||||||
is_follow_request_sent: bool
|
is_follow_request_sent: bool
|
||||||
is_follow_request_rejected: bool
|
|
||||||
outbox_follow_ap_id: str | None
|
outbox_follow_ap_id: str | None
|
||||||
inbox_follow_ap_id: str | None
|
inbox_follow_ap_id: str | None
|
||||||
moved_to: typing.Optional["ActorModel"]
|
moved_to: typing.Optional["ActorModel"]
|
||||||
has_blocked_local_actor: bool
|
|
||||||
|
|
||||||
|
|
||||||
ActorsMetadata = dict[str, ActorMetadata]
|
ActorsMetadata = dict[str, ActorMetadata]
|
||||||
@ -306,26 +262,6 @@ async def get_actors_metadata(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
rejected_follow_requests = {
|
|
||||||
reject.activity_object_ap_id
|
|
||||||
for reject in await db_session.execute(
|
|
||||||
select(models.InboxObject.activity_object_ap_id).where(
|
|
||||||
models.InboxObject.ap_type == "Reject",
|
|
||||||
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
blocks = {
|
|
||||||
block.ap_actor_id
|
|
||||||
for block in await db_session.execute(
|
|
||||||
select(models.InboxObject.ap_actor_id).where(
|
|
||||||
models.InboxObject.ap_type == "Block",
|
|
||||||
models.InboxObject.undone_by_inbox_object_id.is_(None),
|
|
||||||
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
idx: ActorsMetadata = {}
|
idx: ActorsMetadata = {}
|
||||||
for actor in actors:
|
for actor in actors:
|
||||||
if not actor.ap_id:
|
if not actor.ap_id:
|
||||||
@ -348,15 +284,9 @@ async def get_actors_metadata(
|
|||||||
is_following=actor.ap_id in following,
|
is_following=actor.ap_id in following,
|
||||||
is_follower=actor.ap_id in followers,
|
is_follower=actor.ap_id in followers,
|
||||||
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
||||||
is_follow_request_rejected=bool(
|
|
||||||
sent_follow_requests[actor.ap_id] in rejected_follow_requests
|
|
||||||
)
|
|
||||||
if actor.ap_id in sent_follow_requests
|
|
||||||
else False,
|
|
||||||
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
||||||
inbox_follow_ap_id=followers.get(actor.ap_id),
|
inbox_follow_ap_id=followers.get(actor.ap_id),
|
||||||
moved_to=moved_to,
|
moved_to=moved_to,
|
||||||
has_blocked_local_actor=actor.ap_id in blocks,
|
|
||||||
)
|
)
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
|
12
app/admin.py
12
app/admin.py
@ -25,9 +25,7 @@ from app.actor import fetch_actor
|
|||||||
from app.actor import get_actors_metadata
|
from app.actor import get_actors_metadata
|
||||||
from app.boxes import get_inbox_object_by_ap_id
|
from app.boxes import get_inbox_object_by_ap_id
|
||||||
from app.boxes import get_outbox_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_follow
|
||||||
from app.boxes import send_unblock
|
|
||||||
from app.config import EMOJIS
|
from app.config import EMOJIS
|
||||||
from app.config import generate_csrf_token
|
from app.config import generate_csrf_token
|
||||||
from app.config import session_serializer
|
from app.config import session_serializer
|
||||||
@ -342,7 +340,6 @@ async def admin_inbox(
|
|||||||
"Update",
|
"Update",
|
||||||
"Undo",
|
"Undo",
|
||||||
"Read",
|
"Read",
|
||||||
"Reject",
|
|
||||||
"Add",
|
"Add",
|
||||||
"Remove",
|
"Remove",
|
||||||
"EmojiReact",
|
"EmojiReact",
|
||||||
@ -871,7 +868,10 @@ async def admin_actions_block(
|
|||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
await send_block(db_session, ap_actor_id)
|
logger.info(f"Blocking {ap_actor_id}")
|
||||||
|
actor = await fetch_actor(db_session, ap_actor_id)
|
||||||
|
actor.is_blocked = True
|
||||||
|
await db_session.commit()
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@ -884,7 +884,9 @@ async def admin_actions_unblock(
|
|||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info(f"Unblocking {ap_actor_id}")
|
logger.info(f"Unblocking {ap_actor_id}")
|
||||||
await send_unblock(db_session, ap_actor_id)
|
actor = await fetch_actor(db_session, ap_actor_id)
|
||||||
|
actor.is_blocked = False
|
||||||
|
await db_session.commit()
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
from mistletoe import markdown # type: ignore
|
from loguru import logger
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app.actor import LOCAL_ACTOR
|
from app.actor import LOCAL_ACTOR
|
||||||
@ -75,6 +76,10 @@ class Object:
|
|||||||
def tags(self) -> list[ap.RawObject]:
|
def tags(self) -> list[ap.RawObject]:
|
||||||
return ap.as_list(self.ap_object.get("tag", []))
|
return ap.as_list(self.ap_object.get("tag", []))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote_url(self) -> str | None:
|
||||||
|
return self.ap_object.get("quoteUrl")
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def inlined_images(self) -> set[str]:
|
def inlined_images(self) -> set[str]:
|
||||||
image_urls: set[str] = set()
|
image_urls: set[str] = set()
|
||||||
@ -96,9 +101,6 @@ class Object:
|
|||||||
def attachments(self) -> list["Attachment"]:
|
def attachments(self) -> list["Attachment"]:
|
||||||
attachments = []
|
attachments = []
|
||||||
for obj in ap.as_list(self.ap_object.get("attachment", [])):
|
for obj in ap.as_list(self.ap_object.get("attachment", [])):
|
||||||
if obj.get("type") == "PropertyValue":
|
|
||||||
continue
|
|
||||||
|
|
||||||
if obj.get("type") == "Link":
|
if obj.get("type") == "Link":
|
||||||
attachments.append(
|
attachments.append(
|
||||||
Attachment.parse_obj(
|
Attachment.parse_obj(
|
||||||
@ -159,7 +161,7 @@ class Object:
|
|||||||
@cached_property
|
@cached_property
|
||||||
def url(self) -> str | None:
|
def url(self) -> str | None:
|
||||||
obj_url = self.ap_object.get("url")
|
obj_url = self.ap_object.get("url")
|
||||||
if isinstance(obj_url, str) and obj_url:
|
if isinstance(obj_url, str):
|
||||||
return obj_url
|
return obj_url
|
||||||
elif obj_url:
|
elif obj_url:
|
||||||
for u in ap.as_list(obj_url):
|
for u in ap.as_list(obj_url):
|
||||||
@ -179,7 +181,7 @@ class Object:
|
|||||||
|
|
||||||
# PeerTube returns the content as markdown
|
# PeerTube returns the content as markdown
|
||||||
if self.ap_object.get("mediaType") == "text/markdown":
|
if self.ap_object.get("mediaType") == "text/markdown":
|
||||||
content = markdown(content)
|
content = markdown(content, extensions=["mdx_linkify"])
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@ -280,22 +282,17 @@ class Attachment(BaseModel):
|
|||||||
proxied_url: str | None = None
|
proxied_url: str | None = None
|
||||||
resized_url: str | None = None
|
resized_url: str | None = None
|
||||||
|
|
||||||
@property
|
|
||||||
def mimetype(self) -> str:
|
|
||||||
mimetype = self.media_type
|
|
||||||
if not mimetype:
|
|
||||||
mimetype, _ = mimetypes.guess_type(self.url)
|
|
||||||
|
|
||||||
if not mimetype:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
return mimetype.split("/")[-1]
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteObject(Object):
|
class RemoteObject(Object):
|
||||||
def __init__(self, raw_object: ap.RawObject, actor: Actor):
|
def __init__(
|
||||||
|
self,
|
||||||
|
raw_object: ap.RawObject,
|
||||||
|
actor: Actor,
|
||||||
|
quoted_object: Object | None = None,
|
||||||
|
):
|
||||||
self._raw_object = raw_object
|
self._raw_object = raw_object
|
||||||
self._actor = actor
|
self._actor = actor
|
||||||
|
self._quoted_object = quoted_object
|
||||||
|
|
||||||
if self._actor.ap_id != ap.get_actor_id(self._raw_object):
|
if self._actor.ap_id != ap.get_actor_id(self._raw_object):
|
||||||
raise ValueError(f"Invalid actor {self._actor.ap_id}")
|
raise ValueError(f"Invalid actor {self._actor.ap_id}")
|
||||||
@ -305,6 +302,7 @@ class RemoteObject(Object):
|
|||||||
cls,
|
cls,
|
||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
actor: Actor | None = None,
|
actor: Actor | None = None,
|
||||||
|
fetch_quoted_url: bool = True,
|
||||||
):
|
):
|
||||||
# Pre-fetch the actor
|
# Pre-fetch the actor
|
||||||
actor_id = ap.get_actor_id(raw_object)
|
actor_id = ap.get_actor_id(raw_object)
|
||||||
@ -321,7 +319,17 @@ class RemoteObject(Object):
|
|||||||
ap_actor=await ap.fetch(ap.get_actor_id(raw_object)),
|
ap_actor=await ap.fetch(ap.get_actor_id(raw_object)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(raw_object, _actor)
|
quoted_object: Object | None = None
|
||||||
|
if quote_url := raw_object.get("quoteUrl"):
|
||||||
|
try:
|
||||||
|
quoted_object = await RemoteObject.from_raw_object(
|
||||||
|
await ap.fetch(quote_url),
|
||||||
|
fetch_quoted_url=fetch_quoted_url,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to fetch {quote_url=}")
|
||||||
|
|
||||||
|
return cls(raw_object, _actor, quoted_object=quoted_object)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def og_meta(self) -> list[dict[str, Any]] | None:
|
def og_meta(self) -> list[dict[str, Any]] | None:
|
||||||
@ -334,3 +342,9 @@ class RemoteObject(Object):
|
|||||||
@property
|
@property
|
||||||
def actor(self) -> Actor:
|
def actor(self) -> Actor:
|
||||||
return self._actor
|
return self._actor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_object(self) -> Optional["RemoteObject"]:
|
||||||
|
if self._quoted_object:
|
||||||
|
return self._quoted_object
|
||||||
|
return None
|
||||||
|
281
app/boxes.py
281
app/boxes.py
@ -24,7 +24,6 @@ from app.actor import Actor
|
|||||||
from app.actor import RemoteActor
|
from app.actor import RemoteActor
|
||||||
from app.actor import fetch_actor
|
from app.actor import fetch_actor
|
||||||
from app.actor import save_actor
|
from app.actor import save_actor
|
||||||
from app.actor import update_actor_if_needed
|
|
||||||
from app.ap_object import RemoteObject
|
from app.ap_object import RemoteObject
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import BLOCKED_SERVERS
|
from app.config import BLOCKED_SERVERS
|
||||||
@ -33,7 +32,6 @@ from app.config import MANUALLY_APPROVES_FOLLOWERS
|
|||||||
from app.config import set_moved_to
|
from app.config import set_moved_to
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.outgoing_activities import new_outgoing_activity
|
from app.outgoing_activities import new_outgoing_activity
|
||||||
from app.source import dedup_tags
|
|
||||||
from app.source import markdownify
|
from app.source import markdownify
|
||||||
from app.uploads import upload_to_attachment
|
from app.uploads import upload_to_attachment
|
||||||
from app.utils import opengraph
|
from app.utils import opengraph
|
||||||
@ -41,7 +39,6 @@ from app.utils import webmentions
|
|||||||
from app.utils.datetime import as_utc
|
from app.utils.datetime import as_utc
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
from app.utils.datetime import parse_isoformat
|
from app.utils.datetime import parse_isoformat
|
||||||
from app.utils.text import slugify
|
|
||||||
|
|
||||||
AnyboxObject = models.InboxObject | models.OutboxObject
|
AnyboxObject = models.InboxObject | models.OutboxObject
|
||||||
|
|
||||||
@ -64,7 +61,6 @@ async def save_outbox_object(
|
|||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
is_transient: bool = False,
|
is_transient: bool = False,
|
||||||
conversation: str | None = None,
|
conversation: str | None = None,
|
||||||
slug: str | None = None,
|
|
||||||
) -> models.OutboxObject:
|
) -> models.OutboxObject:
|
||||||
ro = await RemoteObject.from_raw_object(raw_object)
|
ro = await RemoteObject.from_raw_object(raw_object)
|
||||||
|
|
||||||
@ -84,7 +80,6 @@ async def save_outbox_object(
|
|||||||
source=source,
|
source=source,
|
||||||
is_transient=is_transient,
|
is_transient=is_transient,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
slug=slug,
|
|
||||||
)
|
)
|
||||||
db_session.add(outbox_object)
|
db_session.add(outbox_object)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
@ -93,87 +88,6 @@ async def save_outbox_object(
|
|||||||
return 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:
|
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)
|
outbox_object_to_delete = await get_outbox_object_by_ap_id(db_session, ap_object_id)
|
||||||
if not outbox_object_to_delete:
|
if not outbox_object_to_delete:
|
||||||
@ -350,7 +264,7 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||||||
if not outbox_object_to_undo:
|
if not outbox_object_to_undo:
|
||||||
raise ValueError(f"{ap_object_id} not found in the outbox")
|
raise ValueError(f"{ap_object_id} not found in the outbox")
|
||||||
|
|
||||||
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce", "Block"]:
|
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce"]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Cannot build Undo for {outbox_object_to_undo.ap_type} activity"
|
f"Cannot build Undo for {outbox_object_to_undo.ap_type} activity"
|
||||||
)
|
)
|
||||||
@ -374,7 +288,6 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
|
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
|
||||||
outbox_object_to_undo.is_deleted = True
|
|
||||||
|
|
||||||
if outbox_object_to_undo.ap_type == "Follow":
|
if outbox_object_to_undo.ap_type == "Follow":
|
||||||
if not outbox_object_to_undo.activity_object_ap_id:
|
if not outbox_object_to_undo.activity_object_ap_id:
|
||||||
@ -423,30 +336,6 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||||||
recipients = await _compute_recipients(db_session, outbox_object.ap_object)
|
recipients = await _compute_recipients(db_session, outbox_object.ap_object)
|
||||||
for rcp in recipients:
|
for rcp in recipients:
|
||||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||||
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:
|
else:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
@ -457,7 +346,6 @@ async def fetch_conversation_root(
|
|||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
obj: AnyboxObject | RemoteObject,
|
obj: AnyboxObject | RemoteObject,
|
||||||
is_root: bool = False,
|
is_root: bool = False,
|
||||||
depth: int = 0,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Some softwares do not set the context/conversation field (like Misskey).
|
"""Some softwares do not set the context/conversation field (like Misskey).
|
||||||
This means we have to track conversation ourselves. To do so, we fetch
|
This means we have to track conversation ourselves. To do so, we fetch
|
||||||
@ -465,13 +353,12 @@ async def fetch_conversation_root(
|
|||||||
- use the context field if set
|
- use the context field if set
|
||||||
- or build a custom conversation ID
|
- or build a custom conversation ID
|
||||||
"""
|
"""
|
||||||
logger.info(f"Fetching convo root for ap_id={obj.ap_id}/{depth=}")
|
if not obj.in_reply_to or is_root:
|
||||||
if obj.ap_context:
|
if obj.ap_context:
|
||||||
return obj.ap_context
|
return obj.ap_context
|
||||||
|
else:
|
||||||
if not obj.in_reply_to or is_root or depth > 10:
|
# Use the root AP ID if there'no context
|
||||||
# Use the root AP ID if there'no context
|
return f"microblogpub:root:{obj.ap_id}"
|
||||||
return f"microblogpub:root:{obj.ap_id}"
|
|
||||||
else:
|
else:
|
||||||
in_reply_to_object: AnyboxObject | RemoteObject | None = (
|
in_reply_to_object: AnyboxObject | RemoteObject | None = (
|
||||||
await get_anybox_object_by_ap_id(db_session, obj.in_reply_to)
|
await get_anybox_object_by_ap_id(db_session, obj.in_reply_to)
|
||||||
@ -484,24 +371,20 @@ async def fetch_conversation_root(
|
|||||||
)
|
)
|
||||||
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
||||||
except (
|
except (
|
||||||
ap.FetchError,
|
ap.ObjectNotFoundError,
|
||||||
|
ap.ObjectIsGoneError,
|
||||||
ap.NotAnObjectError,
|
ap.NotAnObjectError,
|
||||||
|
ap.ObjectUnavailableError,
|
||||||
):
|
):
|
||||||
return await fetch_conversation_root(
|
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||||
db_session, obj, is_root=True, depth=depth + 1
|
|
||||||
)
|
|
||||||
except httpx.HTTPStatusError as http_status_error:
|
except httpx.HTTPStatusError as http_status_error:
|
||||||
if 400 <= http_status_error.response.status_code < 500:
|
if 400 <= http_status_error.response.status_code < 500:
|
||||||
# We may not have access, in this case consider if root
|
# We may not have access, in this case consider if root
|
||||||
return await fetch_conversation_root(
|
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||||
db_session, obj, is_root=True, depth=depth + 1
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return await fetch_conversation_root(
|
return await fetch_conversation_root(db_session, in_reply_to_object)
|
||||||
db_session, in_reply_to_object, depth=depth + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_move(
|
async def send_move(
|
||||||
@ -617,9 +500,6 @@ async def send_create(
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Unhandled visibility {visibility}")
|
raise ValueError(f"Unhandled visibility {visibility}")
|
||||||
|
|
||||||
slug = None
|
|
||||||
url = outbox_object_id(note_id)
|
|
||||||
|
|
||||||
extra_obj_attrs = {}
|
extra_obj_attrs = {}
|
||||||
if ap_type == "Question":
|
if ap_type == "Question":
|
||||||
if not poll_answers or len(poll_answers) < 2:
|
if not poll_answers or len(poll_answers) < 2:
|
||||||
@ -649,8 +529,6 @@ async def send_create(
|
|||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Article must have a 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}
|
extra_obj_attrs = {"name": name}
|
||||||
|
|
||||||
obj = {
|
obj = {
|
||||||
@ -664,8 +542,8 @@ async def send_create(
|
|||||||
"published": published,
|
"published": published,
|
||||||
"context": context,
|
"context": context,
|
||||||
"conversation": context,
|
"conversation": context,
|
||||||
"url": url,
|
"url": outbox_object_id(note_id),
|
||||||
"tag": dedup_tags(tags),
|
"tag": tags,
|
||||||
"summary": content_warning,
|
"summary": content_warning,
|
||||||
"inReplyTo": in_reply_to,
|
"inReplyTo": in_reply_to,
|
||||||
"sensitive": is_sensitive,
|
"sensitive": is_sensitive,
|
||||||
@ -678,7 +556,6 @@ async def send_create(
|
|||||||
obj,
|
obj,
|
||||||
source=source,
|
source=source,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
slug=slug,
|
|
||||||
)
|
)
|
||||||
if not outbox_object.id:
|
if not outbox_object.id:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
@ -686,7 +563,7 @@ async def send_create(
|
|||||||
for tag in tags:
|
for tag in tags:
|
||||||
if tag["type"] == "Hashtag":
|
if tag["type"] == "Hashtag":
|
||||||
tagged_object = models.TaggedOutboxObject(
|
tagged_object = models.TaggedOutboxObject(
|
||||||
tag=tag["name"][1:].lower(),
|
tag=tag["name"][1:],
|
||||||
outbox_object_id=outbox_object.id,
|
outbox_object_id=outbox_object.id,
|
||||||
)
|
)
|
||||||
db_session.add(tagged_object)
|
db_session.add(tagged_object)
|
||||||
@ -1192,17 +1069,7 @@ async def _revert_side_effect_for_deleted_object(
|
|||||||
) -> None:
|
) -> None:
|
||||||
is_delete_needs_to_be_forwarded = False
|
is_delete_needs_to_be_forwarded = False
|
||||||
|
|
||||||
# Delete related notifications
|
# Decrement the replies counter if needed
|
||||||
notif_deletion_result = await db_session.execute(
|
|
||||||
delete(models.Notification)
|
|
||||||
.where(models.Notification.inbox_object_id == deleted_ap_object.id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Deleted {notif_deletion_result.rowcount} notifications" # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decrement/refresh the replies counter if needed
|
|
||||||
if deleted_ap_object.in_reply_to:
|
if deleted_ap_object.in_reply_to:
|
||||||
replied_object = await get_anybox_object_by_ap_id(
|
replied_object = await get_anybox_object_by_ap_id(
|
||||||
db_session,
|
db_session,
|
||||||
@ -1520,13 +1387,6 @@ async def _handle_undo_activity(
|
|||||||
inbox_object_id=ap_activity_to_undo.id,
|
inbox_object_id=ap_activity_to_undo.id,
|
||||||
)
|
)
|
||||||
db_session.add(notif)
|
db_session.add(notif)
|
||||||
elif ap_activity_to_undo.ap_type == "Block":
|
|
||||||
notif = models.Notification(
|
|
||||||
notification_type=models.NotificationType.UNBLOCKED,
|
|
||||||
actor_id=from_actor.id,
|
|
||||||
inbox_object_id=ap_activity_to_undo.id,
|
|
||||||
)
|
|
||||||
db_session.add(notif)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
|
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
|
||||||
|
|
||||||
@ -1621,7 +1481,7 @@ async def _handle_update_activity(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update the actor
|
# Update the actor
|
||||||
await update_actor_if_needed(db_session, from_actor, updated_actor)
|
from_actor.ap_actor = updated_actor.ap_actor
|
||||||
elif (ap_type := wrapped_object["type"]) in [
|
elif (ap_type := wrapped_object["type"]) in [
|
||||||
"Question",
|
"Question",
|
||||||
"Note",
|
"Note",
|
||||||
@ -1644,7 +1504,6 @@ async def _handle_update_activity(
|
|||||||
# Everything looks correct, update the object in the inbox
|
# Everything looks correct, update the object in the inbox
|
||||||
logger.info(f"Updating {existing_object.ap_id}")
|
logger.info(f"Updating {existing_object.ap_id}")
|
||||||
existing_object.ap_object = wrapped_object
|
existing_object.ap_object = wrapped_object
|
||||||
existing_object.updated_at = now()
|
|
||||||
else:
|
else:
|
||||||
# TODO(ts): support updating objects
|
# TODO(ts): support updating objects
|
||||||
logger.info(f'Cannot update {wrapped_object["type"]}')
|
logger.info(f'Cannot update {wrapped_object["type"]}')
|
||||||
@ -1655,24 +1514,8 @@ async def _handle_create_activity(
|
|||||||
from_actor: models.Actor,
|
from_actor: models.Actor,
|
||||||
create_activity: models.InboxObject,
|
create_activity: models.InboxObject,
|
||||||
forwarded_by_actor: models.Actor | None = None,
|
forwarded_by_actor: models.Actor | None = None,
|
||||||
relates_to_inbox_object: models.InboxObject | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info("Processing Create activity")
|
logger.info("Processing Create activity")
|
||||||
|
|
||||||
# Some PeerTube activities make no sense to process
|
|
||||||
if (
|
|
||||||
ap_object_type := ap.as_list(
|
|
||||||
(await ap.get_object(create_activity.ap_object))["type"]
|
|
||||||
)[0]
|
|
||||||
) in ["CacheFile"]:
|
|
||||||
logger.info(f"Dropping Create activity for {ap_object_type} object")
|
|
||||||
await db_session.delete(create_activity)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if relates_to_inbox_object:
|
|
||||||
logger.warning(f"{relates_to_inbox_object.ap_id} is already in the inbox")
|
|
||||||
return None
|
|
||||||
|
|
||||||
wrapped_object = ap.unwrap_activity(create_activity.ap_object)
|
wrapped_object = ap.unwrap_activity(create_activity.ap_object)
|
||||||
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
|
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
|
||||||
raise ValueError("Object actor does not match activity")
|
raise ValueError("Object actor does not match activity")
|
||||||
@ -1723,14 +1566,6 @@ async def _handle_read_activity(
|
|||||||
if not wrapped_object_actor.is_blocked:
|
if not wrapped_object_actor.is_blocked:
|
||||||
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor)
|
ro = RemoteObject(wrapped_object, actor=wrapped_object_actor)
|
||||||
|
|
||||||
# Check if we already know about this object
|
|
||||||
if await get_inbox_object_by_ap_id(
|
|
||||||
db_session,
|
|
||||||
ro.ap_id,
|
|
||||||
):
|
|
||||||
logger.info(f"{ro.ap_id} is already in the inbox, skipping processing")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Then process it likes it's coming from a forwarded activity
|
# Then process it likes it's coming from a forwarded activity
|
||||||
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
|
await _process_note_object(db_session, read_activity, wrapped_object_actor, ro)
|
||||||
|
|
||||||
@ -1741,8 +1576,11 @@ async def _process_note_object(
|
|||||||
from_actor: models.Actor,
|
from_actor: models.Actor,
|
||||||
ro: RemoteObject,
|
ro: RemoteObject,
|
||||||
forwarded_by_actor: models.Actor | None = None,
|
forwarded_by_actor: models.Actor | None = None,
|
||||||
) -> None:
|
process_quoted_url: bool = True,
|
||||||
if parent_activity.ap_type not in ["Create", "Read"]:
|
) -> models.InboxObject:
|
||||||
|
if process_quoted_url and parent_activity.quote_url == ro.ap_id:
|
||||||
|
logger.info(f"Processing quoted URL for {parent_activity.ap_id}")
|
||||||
|
elif parent_activity.ap_type not in ["Create", "Read"]:
|
||||||
raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}")
|
raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}")
|
||||||
|
|
||||||
ap_published_at = now()
|
ap_published_at = now()
|
||||||
@ -1785,6 +1623,7 @@ async def _process_note_object(
|
|||||||
),
|
),
|
||||||
# We may already have some replies in DB
|
# We may already have some replies in DB
|
||||||
replies_count=await _get_replies_count(db_session, ro.ap_id),
|
replies_count=await _get_replies_count(db_session, ro.ap_id),
|
||||||
|
quoted_inbox_object_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
db_session.add(inbox_object)
|
db_session.add(inbox_object)
|
||||||
@ -1865,6 +1704,28 @@ async def _process_note_object(
|
|||||||
)
|
)
|
||||||
db_session.add(notif)
|
db_session.add(notif)
|
||||||
|
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
if ro.quote_url and process_quoted_url:
|
||||||
|
try:
|
||||||
|
quoted_raw_object = await ap.fetch(ro.quote_url)
|
||||||
|
quoted_object_actor = await fetch_actor(
|
||||||
|
db_session, ap.get_actor_id(quoted_raw_object)
|
||||||
|
)
|
||||||
|
quoted_ro = RemoteObject(quoted_raw_object, quoted_object_actor)
|
||||||
|
quoted_inbox_object = await _process_note_object(
|
||||||
|
db_session,
|
||||||
|
inbox_object,
|
||||||
|
from_actor=quoted_object_actor,
|
||||||
|
ro=quoted_ro,
|
||||||
|
process_quoted_url=False,
|
||||||
|
)
|
||||||
|
inbox_object.quoted_inbox_object_id = quoted_inbox_object.id
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process quoted object")
|
||||||
|
|
||||||
|
return inbox_object
|
||||||
|
|
||||||
|
|
||||||
async def _handle_vote_answer(
|
async def _handle_vote_answer(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
@ -2066,28 +1927,6 @@ async def _handle_like_activity(
|
|||||||
db_session.add(notif)
|
db_session.add(notif)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_block_activity(
|
|
||||||
db_session: AsyncSession,
|
|
||||||
actor: models.Actor,
|
|
||||||
block_activity: models.InboxObject,
|
|
||||||
):
|
|
||||||
if block_activity.activity_object_ap_id != LOCAL_ACTOR.ap_id:
|
|
||||||
logger.warning(
|
|
||||||
"Received invalid Block activity "
|
|
||||||
f"{block_activity.activity_object_ap_id=}"
|
|
||||||
)
|
|
||||||
await db_session.delete(block_activity)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create a notification
|
|
||||||
notif = models.Notification(
|
|
||||||
notification_type=models.NotificationType.BLOCKED,
|
|
||||||
actor_id=actor.id,
|
|
||||||
inbox_object_id=block_activity.id,
|
|
||||||
)
|
|
||||||
db_session.add(notif)
|
|
||||||
|
|
||||||
|
|
||||||
async def _process_transient_object(
|
async def _process_transient_object(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
@ -2136,7 +1975,7 @@ async def save_to_inbox(
|
|||||||
except ap.ObjectNotFoundError:
|
except ap.ObjectNotFoundError:
|
||||||
logger.warning("Actor not found")
|
logger.warning("Actor not found")
|
||||||
return
|
return
|
||||||
except ap.FetchError:
|
except httpx.HTTPStatusError:
|
||||||
logger.exception("Failed to fetch actor")
|
logger.exception("Failed to fetch actor")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -2148,10 +1987,8 @@ async def save_to_inbox(
|
|||||||
await _process_transient_object(db_session, raw_object, actor)
|
await _process_transient_object(db_session, raw_object, actor)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# If we just blocked an actor, we want to process any undo sent as side
|
if actor.is_blocked:
|
||||||
# effects
|
logger.warning("Actor {actor.ap_id} is blocked, ignoring object")
|
||||||
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
|
return None
|
||||||
|
|
||||||
raw_object_id = ap.get_id(raw_object)
|
raw_object_id = ap.get_id(raw_object)
|
||||||
@ -2246,11 +2083,7 @@ async def save_to_inbox(
|
|||||||
|
|
||||||
if activity_ro.ap_type == "Create":
|
if activity_ro.ap_type == "Create":
|
||||||
await _handle_create_activity(
|
await _handle_create_activity(
|
||||||
db_session,
|
db_session, actor, inbox_object, forwarded_by_actor=forwarded_by_actor
|
||||||
actor,
|
|
||||||
inbox_object,
|
|
||||||
forwarded_by_actor=forwarded_by_actor,
|
|
||||||
relates_to_inbox_object=relates_to_inbox_object,
|
|
||||||
)
|
)
|
||||||
elif activity_ro.ap_type == "Read":
|
elif activity_ro.ap_type == "Read":
|
||||||
await _handle_read_activity(db_session, actor, inbox_object)
|
await _handle_read_activity(db_session, actor, inbox_object)
|
||||||
@ -2354,12 +2187,6 @@ async def save_to_inbox(
|
|||||||
elif activity_ro.ap_type == "View":
|
elif activity_ro.ap_type == "View":
|
||||||
# View is used by Peertube, there's nothing useful we can do with it
|
# View is used by Peertube, there's nothing useful we can do with it
|
||||||
await db_session.delete(inbox_object)
|
await db_session.delete(inbox_object)
|
||||||
elif activity_ro.ap_type == "Block":
|
|
||||||
await _handle_block_activity(
|
|
||||||
db_session,
|
|
||||||
actor,
|
|
||||||
inbox_object,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Received an unknown {inbox_object.ap_type} object")
|
logger.warning(f"Received an unknown {inbox_object.ap_type} object")
|
||||||
|
|
||||||
@ -2493,9 +2320,7 @@ async def get_replies_tree(
|
|||||||
.where(
|
.where(
|
||||||
models.InboxObject.conversation
|
models.InboxObject.conversation
|
||||||
== requested_object.conversation,
|
== requested_object.conversation,
|
||||||
models.InboxObject.ap_type.in_(
|
models.InboxObject.ap_type.in_(["Note", "Page", "Article"]),
|
||||||
["Note", "Page", "Article", "Question"]
|
|
||||||
),
|
|
||||||
models.InboxObject.is_deleted.is_(False),
|
models.InboxObject.is_deleted.is_(False),
|
||||||
models.InboxObject.visibility.in_(allowed_visibility),
|
models.InboxObject.visibility.in_(allowed_visibility),
|
||||||
)
|
)
|
||||||
@ -2513,9 +2338,7 @@ async def get_replies_tree(
|
|||||||
models.OutboxObject.conversation
|
models.OutboxObject.conversation
|
||||||
== requested_object.conversation,
|
== requested_object.conversation,
|
||||||
models.OutboxObject.is_deleted.is_(False),
|
models.OutboxObject.is_deleted.is_(False),
|
||||||
models.OutboxObject.ap_type.in_(
|
models.OutboxObject.ap_type.in_(["Note", "Page", "Article"]),
|
||||||
["Note", "Page", "Article", "Question"]
|
|
||||||
),
|
|
||||||
models.OutboxObject.visibility.in_(allowed_visibility),
|
models.OutboxObject.visibility.in_(allowed_visibility),
|
||||||
)
|
)
|
||||||
.options(
|
.options(
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -13,9 +12,8 @@ from fastapi import HTTPException
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from itsdangerous import URLSafeTimedSerializer
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from mistletoe import markdown # type: ignore
|
from markdown import markdown
|
||||||
|
|
||||||
from app.customization import _CUSTOM_ROUTES
|
|
||||||
from app.utils.emoji import _load_emojis
|
from app.utils.emoji import _load_emojis
|
||||||
from app.utils.version import get_version_commit
|
from app.utils.version import get_version_commit
|
||||||
|
|
||||||
@ -109,15 +107,10 @@ class Config(pydantic.BaseModel):
|
|||||||
|
|
||||||
inbox_retention_days: int = 15
|
inbox_retention_days: int = 15
|
||||||
|
|
||||||
custom_content_security_policy: str | None = None
|
|
||||||
|
|
||||||
# Config items to make tests easier
|
# Config items to make tests easier
|
||||||
sqlalchemy_database: str | None = None
|
sqlalchemy_database: str | None = None
|
||||||
key_path: 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:
|
def load_config() -> Config:
|
||||||
try:
|
try:
|
||||||
@ -152,11 +145,6 @@ CONFIG = load_config()
|
|||||||
DOMAIN = CONFIG.domain
|
DOMAIN = CONFIG.domain
|
||||||
_SCHEME = "https" if CONFIG.https else "http"
|
_SCHEME = "https" if CONFIG.https else "http"
|
||||||
ID = f"{_SCHEME}://{DOMAIN}"
|
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
|
USERNAME = CONFIG.username
|
||||||
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
|
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
|
||||||
HIDES_FOLLOWERS = CONFIG.hides_followers
|
HIDES_FOLLOWERS = CONFIG.hides_followers
|
||||||
@ -167,11 +155,12 @@ if CONFIG.privacy_replace:
|
|||||||
|
|
||||||
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
|
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
|
||||||
ALSO_KNOWN_AS = CONFIG.also_known_as
|
ALSO_KNOWN_AS = CONFIG.also_known_as
|
||||||
CUSTOM_CONTENT_SECURITY_POLICY = CONFIG.custom_content_security_policy
|
|
||||||
|
|
||||||
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
||||||
CUSTOM_FOOTER = (
|
CUSTOM_FOOTER = (
|
||||||
markdown(CONFIG.custom_footer.replace("{version}", VERSION))
|
markdown(
|
||||||
|
CONFIG.custom_footer.replace("{version}", VERSION), extensions=["mdx_linkify"]
|
||||||
|
)
|
||||||
if CONFIG.custom_footer
|
if CONFIG.custom_footer
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@ -188,9 +177,7 @@ if CONFIG.emoji:
|
|||||||
EMOJIS = CONFIG.emoji
|
EMOJIS = CONFIG.emoji
|
||||||
|
|
||||||
# Emoji template for the FE
|
# Emoji template for the FE
|
||||||
EMOJI_TPL = (
|
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
||||||
'<img src="{base_url}/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
|
||||||
)
|
|
||||||
|
|
||||||
_load_emojis(ROOT_DIR, BASE_URL)
|
_load_emojis(ROOT_DIR, BASE_URL)
|
||||||
|
|
||||||
@ -199,31 +186,6 @@ CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
|
|||||||
MOVED_TO = _get_moved_to()
|
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(
|
session_serializer = URLSafeTimedSerializer(
|
||||||
CONFIG.secret,
|
CONFIG.secret,
|
||||||
salt=f"{ID}.session",
|
salt=f"{ID}.session",
|
||||||
@ -254,7 +216,3 @@ def verify_csrf_token(
|
|||||||
detail=f"The security token has expired, {please_try_again}",
|
detail=f"The security token has expired, {please_try_again}",
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def hmac_sha256():
|
|
||||||
return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)
|
|
||||||
|
@ -1,112 +0,0 @@
|
|||||||
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,12 +88,8 @@ def _body_digest(body: bytes) -> str:
|
|||||||
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
async def _get_public_key(
|
async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
||||||
db_session: AsyncSession,
|
if cached_key := _KEY_CACHE.get(key_id):
|
||||||
key_id: str,
|
|
||||||
should_skip_cache: bool = False,
|
|
||||||
) -> Key:
|
|
||||||
if not should_skip_cache and (cached_key := _KEY_CACHE.get(key_id)):
|
|
||||||
logger.info(f"Key {key_id} found in cache")
|
logger.info(f"Key {key_id} found in cache")
|
||||||
return cached_key
|
return cached_key
|
||||||
|
|
||||||
@ -105,18 +101,15 @@ async def _get_public_key(
|
|||||||
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
||||||
)
|
)
|
||||||
).one_or_none()
|
).one_or_none()
|
||||||
if not should_skip_cache:
|
if existing_actor and existing_actor.public_key_id == key_id:
|
||||||
if existing_actor and existing_actor.public_key_id == key_id:
|
k = Key(existing_actor.ap_id, key_id)
|
||||||
k = Key(existing_actor.ap_id, key_id)
|
k.load_pub(existing_actor.public_key_as_pem)
|
||||||
k.load_pub(existing_actor.public_key_as_pem)
|
logger.info(f"Found {key_id} on an existing actor")
|
||||||
logger.info(f"Found {key_id} on an existing actor")
|
_KEY_CACHE[key_id] = k
|
||||||
_KEY_CACHE[key_id] = k
|
return k
|
||||||
return k
|
|
||||||
|
|
||||||
# Fetch it
|
# Fetch it
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app.actor import RemoteActor
|
|
||||||
from app.actor import update_actor_if_needed
|
|
||||||
|
|
||||||
# Without signing the request as if it's the first contact, the 2 servers
|
# Without signing the request as if it's the first contact, the 2 servers
|
||||||
# might race to fetch each other key
|
# might race to fetch each other key
|
||||||
@ -140,12 +133,6 @@ async def _get_public_key(
|
|||||||
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_skip_cache and actor["type"] != "Key" and existing_actor:
|
|
||||||
# We had to skip the cache, which means the actor key probably changed
|
|
||||||
# and we want to update our cached version
|
|
||||||
await update_actor_if_needed(db_session, existing_actor, RemoteActor(actor))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
_KEY_CACHE[key_id] = k
|
_KEY_CACHE[key_id] = k
|
||||||
return k
|
return k
|
||||||
|
|
||||||
@ -229,17 +216,7 @@ async def httpsig_checker(
|
|||||||
has_valid_signature = _verify_h(
|
has_valid_signature = _verify_h(
|
||||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
||||||
)
|
)
|
||||||
|
# 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(
|
httpsig_info = HTTPSigInfo(
|
||||||
has_valid_signature=has_valid_signature,
|
has_valid_signature=has_valid_signature,
|
||||||
|
238
app/main.py
238
app/main.py
@ -48,7 +48,6 @@ from app import boxes
|
|||||||
from app import config
|
from app import config
|
||||||
from app import httpsig
|
from app import httpsig
|
||||||
from app import indieauth
|
from app import indieauth
|
||||||
from app import media
|
|
||||||
from app import micropub
|
from app import micropub
|
||||||
from app import models
|
from app import models
|
||||||
from app import templates
|
from app import templates
|
||||||
@ -64,7 +63,6 @@ from app.config import USER_AGENT
|
|||||||
from app.config import USERNAME
|
from app.config import USERNAME
|
||||||
from app.config import is_activitypub_requested
|
from app.config import is_activitypub_requested
|
||||||
from app.config import verify_csrf_token
|
from app.config import verify_csrf_token
|
||||||
from app.customization import get_custom_router
|
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.database import async_session
|
from app.database import async_session
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
@ -137,15 +135,9 @@ class CustomMiddleware:
|
|||||||
headers["x-frame-options"] = "DENY"
|
headers["x-frame-options"] = "DENY"
|
||||||
headers["permissions-policy"] = "interest-cohort=()"
|
headers["permissions-policy"] = "interest-cohort=()"
|
||||||
headers["content-security-policy"] = (
|
headers["content-security-policy"] = (
|
||||||
(
|
f"default-src 'self'; "
|
||||||
f"default-src 'self'; "
|
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
|
||||||
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
|
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||||
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:
|
if not DEBUG:
|
||||||
headers["strict-transport-security"] = "max-age=63072000;"
|
headers["strict-transport-security"] = "max-age=63072000;"
|
||||||
@ -200,9 +192,6 @@ app.include_router(admin.unauthenticated_router, prefix="/admin")
|
|||||||
app.include_router(indieauth.router)
|
app.include_router(indieauth.router)
|
||||||
app.include_router(micropub.router)
|
app.include_router(micropub.router)
|
||||||
app.include_router(webmentions.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
|
# XXX: order matters, the proxy middleware needs to be last
|
||||||
app.add_middleware(CustomMiddleware)
|
app.add_middleware(CustomMiddleware)
|
||||||
@ -254,7 +243,7 @@ class ActivityPubResponse(JSONResponse):
|
|||||||
media_type = "application/activity+json"
|
media_type = "application/activity+json"
|
||||||
|
|
||||||
|
|
||||||
@app.get(config.NavBarItems.NOTES_PATH)
|
@app.get("/")
|
||||||
async def index(
|
async def index(
|
||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
@ -643,75 +632,13 @@ async def _check_outbox_object_acl(
|
|||||||
raise HTTPException(status_code=404)
|
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}")
|
@app.get("/o/{public_id}")
|
||||||
async def outbox_by_public_id(
|
async def outbox_by_public_id(
|
||||||
public_id: str,
|
public_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
) -> ActivityPubResponse | templates.TemplateResponse | RedirectResponse:
|
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||||
maybe_object = (
|
maybe_object = (
|
||||||
(
|
(
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
@ -738,79 +665,59 @@ async def outbox_by_public_id(
|
|||||||
if is_activitypub_requested(request):
|
if is_activitypub_requested(request):
|
||||||
return ActivityPubResponse(maybe_object.ap_object)
|
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(
|
replies_tree = await boxes.get_replies_tree(
|
||||||
db_session,
|
db_session,
|
||||||
maybe_object,
|
maybe_object,
|
||||||
is_current_user_admin=is_current_user_admin(request),
|
is_current_user_admin=is_current_user_admin(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
likes = await _fetch_likes(db_session, maybe_object)
|
likes = (
|
||||||
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.execute(
|
await db_session.scalars(
|
||||||
select(models.OutboxObject)
|
select(models.InboxObject)
|
||||||
.options(
|
|
||||||
joinedload(models.OutboxObject.outbox_object_attachments).options(
|
|
||||||
joinedload(models.OutboxObjectAttachment.upload)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
models.OutboxObject.public_id.like(f"{short_id}%"),
|
models.InboxObject.ap_type == "Like",
|
||||||
models.OutboxObject.slug == slug,
|
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
|
||||||
models.OutboxObject.is_deleted.is_(False),
|
models.InboxObject.is_deleted.is_(False),
|
||||||
)
|
)
|
||||||
|
.options(joinedload(models.InboxObject.actor))
|
||||||
|
.order_by(models.InboxObject.ap_published_at.desc())
|
||||||
|
.limit(10)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.unique()
|
.unique()
|
||||||
.scalar_one_or_none()
|
.all()
|
||||||
)
|
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
likes = await _fetch_likes(db_session, maybe_object)
|
shares = (
|
||||||
shares = await _fetch_shares(db_session, maybe_object)
|
(
|
||||||
webmentions = await _fetch_webmentions(db_session, maybe_object)
|
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()
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
@ -856,7 +763,7 @@ async def tag_by_name(
|
|||||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||||
where = [
|
where = [
|
||||||
models.TaggedOutboxObject.tag == tag.lower(),
|
models.TaggedOutboxObject.tag == tag,
|
||||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||||
models.OutboxObject.is_deleted.is_(False),
|
models.OutboxObject.is_deleted.is_(False),
|
||||||
]
|
]
|
||||||
@ -882,7 +789,7 @@ async def tag_by_name(
|
|||||||
return ActivityPubResponse(
|
return ActivityPubResponse(
|
||||||
{
|
{
|
||||||
"@context": ap.AS_CTX,
|
"@context": ap.AS_CTX,
|
||||||
"id": BASE_URL + f"/t/{tag.lower()}",
|
"id": BASE_URL + f"/t/{tag}",
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"totalItems": tagged_count,
|
"totalItems": tagged_count,
|
||||||
"orderedItems": [
|
"orderedItems": [
|
||||||
@ -976,48 +883,6 @@ async def post_remote_follow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/remote_interaction")
|
|
||||||
async def remote_interaction(
|
|
||||||
request: Request,
|
|
||||||
ap_id: str,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> templates.TemplateResponse:
|
|
||||||
outbox_object = await boxes.get_outbox_object_by_ap_id(
|
|
||||||
db_session,
|
|
||||||
ap_id,
|
|
||||||
)
|
|
||||||
if not outbox_object:
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
|
|
||||||
return await templates.render_template(
|
|
||||||
db_session,
|
|
||||||
request,
|
|
||||||
"remote_interact.html",
|
|
||||||
{"outbox_object": outbox_object},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/remote_interaction")
|
|
||||||
async def post_remote_interaction(
|
|
||||||
request: Request,
|
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
|
||||||
profile: str = Form(),
|
|
||||||
ap_id: str = Form(),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
if not profile.startswith("@"):
|
|
||||||
profile = f"@{profile}"
|
|
||||||
|
|
||||||
remote_follow_template = await get_remote_follow_template(profile)
|
|
||||||
if not remote_follow_template:
|
|
||||||
# TODO(ts): error message to user
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
|
|
||||||
return RedirectResponse(
|
|
||||||
remote_follow_template.format(uri=ap_id),
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/.well-known/webfinger")
|
@app.get("/.well-known/webfinger")
|
||||||
async def wellknown_webfinger(resource: str) -> JSONResponse:
|
async def wellknown_webfinger(resource: str) -> JSONResponse:
|
||||||
"""Exposes/servers WebFinger data."""
|
"""Exposes/servers WebFinger data."""
|
||||||
@ -1135,17 +1000,14 @@ def _add_cache_control(headers: dict[str, str]) -> dict[str, str]:
|
|||||||
return {**headers, "Cache-Control": "max-age=31536000"}
|
return {**headers, "Cache-Control": "max-age=31536000"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}")
|
@app.get("/proxy/media/{encoded_url}")
|
||||||
async def serve_proxy_media(
|
async def serve_proxy_media(
|
||||||
request: Request,
|
request: Request,
|
||||||
exp: int,
|
|
||||||
sig: str,
|
|
||||||
encoded_url: str,
|
encoded_url: str,
|
||||||
) -> StreamingResponse | PlainTextResponse:
|
) -> StreamingResponse | PlainTextResponse:
|
||||||
# Decode the base64-encoded URL
|
# Decode the base64-encoded URL
|
||||||
url = base64.urlsafe_b64decode(encoded_url).decode()
|
url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||||
check_url(url)
|
check_url(url)
|
||||||
media.verify_proxied_media_sig(exp, url, sig)
|
|
||||||
|
|
||||||
proxy_resp = await _proxy_get(request, url, stream=True)
|
proxy_resp = await _proxy_get(request, url, stream=True)
|
||||||
|
|
||||||
@ -1178,11 +1040,9 @@ async def serve_proxy_media(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}/{size}")
|
@app.get("/proxy/media/{encoded_url}/{size}")
|
||||||
async def serve_proxy_media_resized(
|
async def serve_proxy_media_resized(
|
||||||
request: Request,
|
request: Request,
|
||||||
exp: int,
|
|
||||||
sig: str,
|
|
||||||
encoded_url: str,
|
encoded_url: str,
|
||||||
size: int,
|
size: int,
|
||||||
) -> PlainTextResponse:
|
) -> PlainTextResponse:
|
||||||
@ -1192,7 +1052,6 @@ async def serve_proxy_media_resized(
|
|||||||
# Decode the base64-encoded URL
|
# Decode the base64-encoded URL
|
||||||
url = base64.urlsafe_b64decode(encoded_url).decode()
|
url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||||
check_url(url)
|
check_url(url)
|
||||||
media.verify_proxied_media_sig(exp, url, sig)
|
|
||||||
|
|
||||||
if cached_resp := _RESIZED_CACHE.get((url, size)):
|
if cached_resp := _RESIZED_CACHE.get((url, size)):
|
||||||
resized_content, resized_mimetype, resp_headers = cached_resp
|
resized_content, resized_mimetype, resp_headers = cached_resp
|
||||||
@ -1320,7 +1179,6 @@ async def robots_file():
|
|||||||
Disallow: /followers
|
Disallow: /followers
|
||||||
Disallow: /following
|
Disallow: /following
|
||||||
Disallow: /admin
|
Disallow: /admin
|
||||||
Disallow: /remote_interaction
|
|
||||||
Disallow: /remote_follow"""
|
Disallow: /remote_follow"""
|
||||||
|
|
||||||
|
|
||||||
|
31
app/media.py
31
app/media.py
@ -1,44 +1,15 @@
|
|||||||
import base64
|
import base64
|
||||||
import time
|
|
||||||
|
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import hmac_sha256
|
|
||||||
|
|
||||||
SUPPORTED_RESIZE = [50, 740]
|
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:
|
def proxied_media_url(url: str) -> str:
|
||||||
if url.startswith(BASE_URL):
|
if url.startswith(BASE_URL):
|
||||||
return url
|
return url
|
||||||
expires = int(time.time() / EXPIRY_PERIOD) + EXPIRY_LENGTH
|
|
||||||
sig = proxied_media_sig(expires, url)
|
|
||||||
|
|
||||||
return (
|
return "/proxy/media/" + base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
BASE_URL
|
|
||||||
+ f"/proxy/media/{expires}/{sig}/"
|
|
||||||
+ base64.urlsafe_b64encode(url.encode()).decode()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resized_media_url(url: str, size: int) -> str:
|
def resized_media_url(url: str, size: int) -> str:
|
||||||
|
@ -113,6 +113,18 @@ class InboxObject(Base, BaseObject):
|
|||||||
uselist=False,
|
uselist=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
quoted_inbox_object_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("inbox.id", name="fk_quoted_inbox_object_id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
quoted_inbox_object: Mapped[Optional["InboxObject"]] = relationship(
|
||||||
|
"InboxObject",
|
||||||
|
foreign_keys=quoted_inbox_object_id,
|
||||||
|
remote_side=id,
|
||||||
|
uselist=False,
|
||||||
|
)
|
||||||
|
|
||||||
undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
|
undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
|
||||||
|
|
||||||
# Link the oubox AP ID to allow undo without any extra query
|
# Link the oubox AP ID to allow undo without any extra query
|
||||||
@ -147,6 +159,12 @@ class InboxObject(Base, BaseObject):
|
|||||||
def is_from_inbox(self) -> bool:
|
def is_from_inbox(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_object(self) -> Optional["InboxObject"]:
|
||||||
|
if self.quoted_inbox_object_id:
|
||||||
|
return self.quoted_inbox_object
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class OutboxObject(Base, BaseObject):
|
class OutboxObject(Base, BaseObject):
|
||||||
__tablename__ = "outbox"
|
__tablename__ = "outbox"
|
||||||
@ -158,7 +176,6 @@ class OutboxObject(Base, BaseObject):
|
|||||||
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
public_id = Column(String, nullable=False, index=True)
|
public_id = Column(String, nullable=False, index=True)
|
||||||
slug = Column(String, nullable=True, index=True)
|
|
||||||
|
|
||||||
ap_type = Column(String, nullable=False, index=True)
|
ap_type = Column(String, nullable=False, index=True)
|
||||||
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
||||||
@ -283,11 +300,8 @@ class OutboxObject(Base, BaseObject):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str | None:
|
def quoted_object(self) -> Optional["InboxObject"]:
|
||||||
# XXX: rewrite old URL here for compat
|
return None
|
||||||
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):
|
class Follower(Base):
|
||||||
@ -559,14 +573,6 @@ class NotificationType(str, enum.Enum):
|
|||||||
UPDATED_WEBMENTION = "updated_webmention"
|
UPDATED_WEBMENTION = "updated_webmention"
|
||||||
DELETED_WEBMENTION = "deleted_webmention"
|
DELETED_WEBMENTION = "deleted_webmention"
|
||||||
|
|
||||||
# incoming
|
|
||||||
BLOCKED = "blocked"
|
|
||||||
UNBLOCKED = "unblocked"
|
|
||||||
|
|
||||||
# outgoing
|
|
||||||
BLOCK = "block"
|
|
||||||
UNBLOCK = "unblock"
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base):
|
class Notification(Base):
|
||||||
__tablename__ = "notifications"
|
__tablename__ = "notifications"
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
$font-stack: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell,
|
$font-stack: Helvetica, sans-serif;
|
||||||
Ubuntu, roboto, noto, arial, sans-serif;
|
|
||||||
|
|
||||||
$background: #ddd;
|
$background: #ddd;
|
||||||
$light-background: #e6e6e6;
|
$light-background: #e6e6e6;
|
||||||
$text-color: #111;
|
$text-color: #111;
|
||||||
$primary-color: #1d781d;
|
$primary-color: #1d781d;
|
||||||
$secondary-color: #781d78;
|
$secondary-color: #781D78;
|
||||||
$form-background-color: #ccc;
|
$form-background-color: #ccc;
|
||||||
$form-text-color: #333;
|
$form-text-color: #333;
|
||||||
$muted-color: #555; // solarized comment text
|
$muted-color: #555; // solarized comment text
|
||||||
@ -15,46 +13,6 @@ $code-highlight-background: #f0f0f0;
|
|||||||
// Load custom theme
|
// Load custom theme
|
||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
/* Box sizing rules */
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Set core root defaults */
|
|
||||||
html:focus-within {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inherit fonts for inputs and buttons */
|
|
||||||
input,
|
|
||||||
button,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
max-width: 90ch;
|
|
||||||
padding: 3em 1em;
|
|
||||||
margin: auto;
|
|
||||||
line-height: 1.75;
|
|
||||||
font-size: 1.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: $font-stack;
|
|
||||||
background: $background;
|
|
||||||
color: $text-color;
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-color {
|
.primary-color {
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
}
|
}
|
||||||
@ -112,7 +70,7 @@ a {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.sensitive-attachment-state:checked ~ .sensitive-attachment-box div {
|
.sensitive-attachment-state:checked ~ .sensitive-attachment-box div {
|
||||||
display: none;
|
display:none;
|
||||||
}
|
}
|
||||||
.sensitive-attachment-box {
|
.sensitive-attachment-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -126,6 +84,7 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 3px solid $secondary-color;
|
border-left: 3px solid $secondary-color;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
@ -133,11 +92,28 @@ blockquote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light-background {
|
.light-background {
|
||||||
background: $light-background;
|
background: $light-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-stack;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 32px;
|
||||||
|
background: $background;
|
||||||
|
color: $text-color;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
dl {
|
dl {
|
||||||
@ -161,56 +137,53 @@ dl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shared-header {
|
.shared-header {
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
margin-bottom: -20px;
|
margin-bottom: -20px;
|
||||||
strong {
|
strong {
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
}
|
}
|
||||||
span {
|
span {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.highlight {
|
div.highlight {
|
||||||
background: $code-highlight-background;
|
background: $code-highlight-background;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
code, pre {
|
||||||
pre {
|
|
||||||
color: $secondary-color; // #cb4b16; // #268bd2; // #2aa198;
|
color: $secondary-color; // #cb4b16; // #268bd2; // #2aa198;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
input,
|
input, select, textarea {
|
||||||
select,
|
font-size: 20px;
|
||||||
textarea {
|
border: 0;
|
||||||
font-size: 20px;
|
padding: 5px;
|
||||||
border: 0;
|
background: $form-background-color;
|
||||||
padding: 5px;
|
color: $form-text-color;
|
||||||
background: $form-background-color;
|
&:focus {
|
||||||
color: $form-text-color;
|
outline: 1px solid $secondary-color;
|
||||||
&:focus {
|
}
|
||||||
outline: 1px solid $secondary-color;
|
}
|
||||||
|
input[type=submit] {
|
||||||
|
font-size: 20px;
|
||||||
|
outline: none;
|
||||||
|
background: $primary-color;
|
||||||
|
color: $primary-button-text-color;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
input[type="submit"] {
|
|
||||||
font-size: 20px;
|
|
||||||
outline: none;
|
|
||||||
background: $primary-color;
|
|
||||||
color: $primary-button-text-color;
|
|
||||||
padding: 5px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
@ -239,17 +212,18 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#main {
|
#main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 30px auto;
|
max-width: 1000px;
|
||||||
|
margin: 30px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-flex {
|
.main-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.centered {
|
.centered {
|
||||||
@ -263,12 +237,12 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
font-size: 1em;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tiny-actor-icon {
|
.tiny-actor-icon {
|
||||||
@ -280,7 +254,7 @@ footer {
|
|||||||
.actor-box {
|
.actor-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
column-gap: 20px;
|
column-gap: 20px;
|
||||||
margin: 10px 0;
|
margin:10px 0;
|
||||||
.icon-box {
|
.icon-box {
|
||||||
flex: 0 0 50px;
|
flex: 0 0 50px;
|
||||||
}
|
}
|
||||||
@ -294,54 +268,50 @@ footer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#articles {
|
#articles {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin: 30px 0;
|
margin: 30px 0;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
li {
|
li {
|
||||||
display: block;
|
display: block;
|
||||||
span {
|
span {
|
||||||
padding-right: 10px;
|
padding-right:10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#notifications,
|
#notifications, #followers, #following {
|
||||||
#followers,
|
ul {
|
||||||
#following {
|
list-style-type: none;
|
||||||
ul {
|
margin: 0;
|
||||||
list-style-type: none;
|
padding: 0;
|
||||||
margin: 0;
|
}
|
||||||
padding: 0;
|
li {
|
||||||
}
|
display: block;
|
||||||
li {
|
}
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin admin-button() {
|
@mixin admin-button() {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
font-family: $font-stack;
|
font-family: $font-stack;
|
||||||
background: $form-background-color;
|
background: $form-background-color;
|
||||||
color: $form-text-color;
|
color: $form-text-color;
|
||||||
border: 1px solid $background;
|
border: 1px solid $background;
|
||||||
padding: 8px 10px 5px 10px;
|
padding: 8px 10px 5px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 1px solid $form-text-color;
|
border: 1px solid $form-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-sensitive-btn,
|
.show-sensitive-btn, .show-more-btn, .label-btn {
|
||||||
.show-more-btn,
|
@include admin-button;
|
||||||
.label-btn {
|
padding: 10px 5px;
|
||||||
@include admin-button;
|
margin: 20px 0;
|
||||||
padding: 10px 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-hide-sensitive-btn {
|
.show-hide-sensitive-btn {
|
||||||
display: inline-block;
|
display:inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-margin-top {
|
.no-margin-top {
|
||||||
@ -365,13 +335,13 @@ ul.poll-items {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.poll-bar {
|
.poll-bar {
|
||||||
width: 100%;
|
width:100%;height:20px;
|
||||||
height: 20px;
|
line {
|
||||||
line {
|
stroke: $secondary-color;
|
||||||
stroke: $secondary-color;
|
stroke-width: 20px;
|
||||||
stroke-width: 20px;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,85 +362,75 @@ ul.poll-items {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
form {
|
form {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
}
|
}
|
||||||
input[type="submit"],
|
input[type=submit], button {
|
||||||
button {
|
@include admin-button;
|
||||||
@include admin-button;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.flexbox {
|
nav.flexbox {
|
||||||
ul {
|
ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
|
||||||
|
|
||||||
ul li {
|
|
||||||
margin-right: 20px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-right: 0px;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
a:not(.label-btn) {
|
|
||||||
color: $primary-color;
|
|
||||||
text-decoration: none;
|
|
||||||
&:hover,
|
|
||||||
&:active {
|
|
||||||
color: $secondary-color;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.active:not(.label-btn) {
|
|
||||||
color: $secondary-color;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// after nav.flexbox to override default behavior
|
ul li {
|
||||||
a.label-btn {
|
margin-right: 20px;
|
||||||
color: $form-text-color;
|
|
||||||
&:hover {
|
&:last-child {
|
||||||
text-decoration: none;
|
margin-right: 0px;
|
||||||
color: $form-text-color;
|
}
|
||||||
}
|
}
|
||||||
|
a {
|
||||||
|
color: $primary-color;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover, &:active {
|
||||||
|
color: $secondary-color;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.active {
|
||||||
|
color: $secondary-color;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-object {
|
.ap-object {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
nav {
|
.in-reply-to {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
&:hover {
|
||||||
.in-reply-to {
|
color: $secondary-color;
|
||||||
display: inline;
|
text-decoration: underline;
|
||||||
color: $muted-color;
|
}
|
||||||
}
|
|
||||||
.e-content,
|
|
||||||
.activity-og-meta {
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
}
|
nav {
|
||||||
.activity-attachment {
|
color: $muted-color;
|
||||||
margin: 30px 0 20px 0;
|
}
|
||||||
img,
|
.e-content, .activity-og-meta {
|
||||||
audio,
|
a:hover {
|
||||||
video {
|
text-decoration: underline;
|
||||||
width: 100%;
|
}
|
||||||
max-width: 740px;
|
}
|
||||||
|
.activity-attachment {
|
||||||
|
margin: 30px 0 20px 0;
|
||||||
|
img, audio, video {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 740px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.inline-img {
|
||||||
|
display: block;
|
||||||
|
max-width: 740px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
img.inline-img {
|
|
||||||
display: block;
|
|
||||||
max-width: 740px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-og-meta {
|
.activity-og-meta {
|
||||||
@ -487,26 +447,25 @@ a.label-btn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ap-object-expanded {
|
.ap-object-expanded {
|
||||||
border: 2px dashed $secondary-color;
|
border: 2px dashed $secondary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box {
|
.error-box {
|
||||||
color: $secondary-color;
|
color: $secondary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actor-action {
|
.actor-action {
|
||||||
margin-top: 20px;
|
margin-top:20px;
|
||||||
margin-bottom: -20px;
|
margin-bottom:-20px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
span {
|
span {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actor-metadata {
|
.actor-metadata {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
.emoji,
|
.emoji, .custom-emoji {
|
||||||
.custom-emoji {
|
|
||||||
max-width: 25px;
|
max-width: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -566,13 +525,3 @@ a.label-btn {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-place {
|
|
||||||
h3 {
|
|
||||||
display: inline;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
h3::after {
|
|
||||||
content: ': ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
178
app/source.py
178
app/source.py
@ -1,123 +1,52 @@
|
|||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from mistletoe import Document # type: ignore
|
from markdown import markdown
|
||||||
from mistletoe.html_renderer import HTMLRenderer # type: ignore
|
|
||||||
from mistletoe.span_token import SpanToken # type: ignore
|
|
||||||
from pygments import highlight # type: ignore
|
|
||||||
from pygments.formatters import HtmlFormatter # type: ignore
|
|
||||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
|
||||||
from pygments.lexers import guess_lexer # type: ignore
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import webfinger
|
from app import webfinger
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import CODE_HIGHLIGHTING_THEME
|
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.utils import emoji
|
from app.utils import emoji
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from app.actor import Actor
|
from app.actor import Actor
|
||||||
|
|
||||||
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
||||||
_MENTION_REGEX = re.compile(r"(@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+)")
|
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
||||||
_URL_REGEX = re.compile(
|
|
||||||
"(https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*))" # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AutoLink(SpanToken):
|
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
|
||||||
parse_inner = False
|
tags = []
|
||||||
precedence = 1
|
hashtags = re.findall(_HASHTAG_REGEX, content)
|
||||||
pattern = _URL_REGEX
|
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
|
||||||
|
for hashtag in hashtags:
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
tag = hashtag[1:]
|
||||||
self.target = match_obj.group()
|
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 Mention(SpanToken):
|
async def _mentionify(
|
||||||
parse_inner = False
|
|
||||||
precedence = 10
|
|
||||||
pattern = _MENTION_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class Hashtag(SpanToken):
|
|
||||||
parse_inner = False
|
|
||||||
precedence = 10
|
|
||||||
pattern = _HASHTAG_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class CustomRenderer(HTMLRenderer):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
mentioned_actors: dict[str, "Actor"] = {},
|
|
||||||
enable_mentionify: bool = True,
|
|
||||||
enable_hashtagify: bool = True,
|
|
||||||
) -> None:
|
|
||||||
extra_tokens = []
|
|
||||||
if enable_mentionify:
|
|
||||||
extra_tokens.append(Mention)
|
|
||||||
if enable_hashtagify:
|
|
||||||
extra_tokens.append(Hashtag)
|
|
||||||
super().__init__(AutoLink, *extra_tokens)
|
|
||||||
|
|
||||||
self.tags: list[dict[str, str]] = []
|
|
||||||
self.mentioned_actors = mentioned_actors
|
|
||||||
|
|
||||||
def render_auto_link(self, token: AutoLink) -> str:
|
|
||||||
template = '<a href="{target}" rel="noopener">{inner}</a>'
|
|
||||||
target = self.escape_url(token.target)
|
|
||||||
return template.format(target=target, inner=target)
|
|
||||||
|
|
||||||
def render_mention(self, token: Mention) -> str:
|
|
||||||
mention = token.target
|
|
||||||
actor = self.mentioned_actors.get(mention)
|
|
||||||
if not actor:
|
|
||||||
return mention
|
|
||||||
|
|
||||||
self.tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
|
||||||
|
|
||||||
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
|
|
||||||
return link
|
|
||||||
|
|
||||||
def render_hashtag(self, token: Hashtag) -> str:
|
|
||||||
tag = token.target[1:]
|
|
||||||
link = f'<a href="{BASE_URL}/t/{tag.lower()}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
|
||||||
self.tags.append(
|
|
||||||
dict(
|
|
||||||
href=f"{BASE_URL}/t/{tag.lower()}",
|
|
||||||
name=token.target.lower(),
|
|
||||||
type="Hashtag",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return link
|
|
||||||
|
|
||||||
def render_block_code(self, token: typing.Any) -> str:
|
|
||||||
code = token.children[0].content
|
|
||||||
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
|
|
||||||
return highlight(code, lexer, _FORMATTER)
|
|
||||||
|
|
||||||
|
|
||||||
async def _prefetch_mentioned_actors(
|
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
content: str,
|
content: str,
|
||||||
) -> dict[str, "Actor"]:
|
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
|
||||||
from app import models
|
from app import models
|
||||||
from app.actor import fetch_actor
|
from app.actor import fetch_actor
|
||||||
|
|
||||||
actors = {}
|
tags = []
|
||||||
|
mentioned_actors = []
|
||||||
for mention in re.findall(_MENTION_REGEX, content):
|
for mention in re.findall(_MENTION_REGEX, content):
|
||||||
if mention in actors:
|
|
||||||
continue
|
|
||||||
|
|
||||||
_, username, domain = mention.split("@")
|
_, username, domain = mention.split("@")
|
||||||
actor = (
|
actor = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
@ -134,27 +63,12 @@ async def _prefetch_mentioned_actors(
|
|||||||
continue
|
continue
|
||||||
actor = await fetch_actor(db_session, actor_url)
|
actor = await fetch_actor(db_session, actor_url)
|
||||||
|
|
||||||
actors[mention] = actor
|
mentioned_actors.append(actor)
|
||||||
|
tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
||||||
|
|
||||||
return actors
|
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
|
||||||
def hashtagify(
|
|
||||||
content: str,
|
|
||||||
) -> tuple[str, list[dict[str, str]]]:
|
|
||||||
tags = []
|
|
||||||
with CustomRenderer(
|
|
||||||
mentioned_actors={},
|
|
||||||
enable_mentionify=False,
|
|
||||||
enable_hashtagify=True,
|
|
||||||
) as renderer:
|
|
||||||
rendered_content = renderer.render(Document(content))
|
|
||||||
tags.extend(renderer.tags)
|
|
||||||
|
|
||||||
# Handle custom emoji
|
|
||||||
tags.extend(emoji.tags(content))
|
|
||||||
|
|
||||||
return rendered_content, tags
|
|
||||||
|
|
||||||
|
|
||||||
async def markdownify(
|
async def markdownify(
|
||||||
@ -168,33 +82,17 @@ async def markdownify(
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
tags = []
|
tags = []
|
||||||
mentioned_actors: dict[str, "Actor"] = {}
|
mentioned_actors: list["Actor"] = []
|
||||||
|
if enable_hashtagify:
|
||||||
|
content, hashtag_tags = hashtagify(content)
|
||||||
|
tags.extend(hashtag_tags)
|
||||||
if enable_mentionify:
|
if enable_mentionify:
|
||||||
mentioned_actors = await _prefetch_mentioned_actors(db_session, content)
|
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
|
||||||
|
tags.extend(mention_tags)
|
||||||
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
|
# Handle custom emoji
|
||||||
tags.extend(emoji.tags(content))
|
tags.extend(emoji.tags(content))
|
||||||
|
|
||||||
return rendered_content, dedup_tags(tags), list(mentioned_actors.values())
|
content = markdown(content, extensions=["mdx_linkify", "fenced_code"])
|
||||||
|
|
||||||
|
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,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@ -38,7 +39,7 @@ from app.utils.highlight import HIGHLIGHT_CSS
|
|||||||
from app.utils.highlight import highlight
|
from app.utils.highlight import highlight
|
||||||
|
|
||||||
_templates = Jinja2Templates(
|
_templates = Jinja2Templates(
|
||||||
directory=["data/templates", "app/templates"], # type: ignore # bad typing
|
directory="app/templates",
|
||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
lstrip_blocks=True,
|
lstrip_blocks=True,
|
||||||
)
|
)
|
||||||
@ -58,8 +59,13 @@ def _filter_domain(text: str) -> str:
|
|||||||
|
|
||||||
def _media_proxy_url(url: str | None) -> str:
|
def _media_proxy_url(url: str | None) -> str:
|
||||||
if not url:
|
if not url:
|
||||||
return BASE_URL + "/static/nopic.png"
|
return "/static/nopic.png"
|
||||||
return proxied_media_url(url)
|
|
||||||
|
if url.startswith(BASE_URL):
|
||||||
|
return url
|
||||||
|
|
||||||
|
encoded_url = base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
|
return f"/proxy/media/{encoded_url}"
|
||||||
|
|
||||||
|
|
||||||
def is_current_user_admin(request: Request) -> bool:
|
def is_current_user_admin(request: Request) -> bool:
|
||||||
@ -285,10 +291,6 @@ 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)
|
@lru_cache(maxsize=256)
|
||||||
def _update_inline_imgs(content):
|
def _update_inline_imgs(content):
|
||||||
soup = BeautifulSoup(content, "html5lib")
|
soup = BeautifulSoup(content, "html5lib")
|
||||||
@ -318,11 +320,7 @@ def _clean_html(html: str, note: Object) -> str:
|
|||||||
_update_inline_imgs(highlight(html))
|
_update_inline_imgs(highlight(html))
|
||||||
),
|
),
|
||||||
tags=ALLOWED_TAGS,
|
tags=ALLOWED_TAGS,
|
||||||
attributes=(
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
_allow_all_attributes
|
|
||||||
if note.ap_id.startswith(config.ID)
|
|
||||||
else ALLOWED_ATTRIBUTES
|
|
||||||
),
|
|
||||||
strip=True,
|
strip=True,
|
||||||
),
|
),
|
||||||
note,
|
note,
|
||||||
@ -382,7 +380,7 @@ def _html2text(content: str) -> str:
|
|||||||
|
|
||||||
def _replace_emoji(u: str, _) -> str:
|
def _replace_emoji(u: str, _) -> str:
|
||||||
filename = "-".join(hex(ord(c))[2:] for c in u)
|
filename = "-".join(hex(ord(c))[2:] for c in u)
|
||||||
return config.EMOJI_TPL.format(base_url=BASE_URL, filename=filename, raw=u)
|
return config.EMOJI_TPL.format(filename=filename, raw=u)
|
||||||
|
|
||||||
|
|
||||||
def _emojify(text: str, is_local: bool) -> str:
|
def _emojify(text: str, is_local: bool) -> str:
|
||||||
@ -423,5 +421,3 @@ _templates.env.globals["CSS_HASH"] = config.CSS_HASH
|
|||||||
_templates.env.globals["BASE_URL"] = config.BASE_URL
|
_templates.env.globals["BASE_URL"] = config.BASE_URL
|
||||||
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
|
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
|
||||||
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING
|
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING
|
||||||
_templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems
|
|
||||||
_templates.env.globals["ICON_URL"] = config.CONFIG.icon_url
|
|
||||||
|
@ -90,5 +90,5 @@
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ BASE_URL }}/static/new.js?v={{ JS_HASH }}"></script>
|
<script src="/static/new.js?v={{ JS_HASH }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -12,16 +12,18 @@
|
|||||||
{% for outbox_object in outbox %}
|
{% for outbox_object in outbox %}
|
||||||
|
|
||||||
{% if outbox_object.ap_type == "Announce" %}
|
{% if outbox_object.ap_type == "Announce" %}
|
||||||
<div class="actor-action">You shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You shared</div>
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
{% elif outbox_object.ap_type == "Like" %}
|
{% elif outbox_object.ap_type == "Like" %}
|
||||||
<div class="actor-action">You liked <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You liked</div>
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
{% elif outbox_object.ap_type == "Follow" %}
|
{% elif outbox_object.ap_type == "Follow" %}
|
||||||
<div class="actor-action">You followed <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You followed</div>
|
||||||
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
||||||
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
||||||
{{ utils.display_object(outbox_object) }}
|
{{ utils.display_object(outbox_object) }}
|
||||||
|
{% else %}
|
||||||
|
Implement {{ outbox_object.ap_type }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
{%- 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 %}
|
|
@ -25,21 +25,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{%- macro header_link(url, text) -%}
|
{%- macro header_link(url, text) -%}
|
||||||
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
|
{% set url_for = request.app.router.url_path_for(url) %}
|
||||||
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{%- 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 %}
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="public-top-menu">
|
<div class="public-top-menu">
|
||||||
<nav class="flexbox">
|
<nav class="flexbox">
|
||||||
<ul>
|
<ul>
|
||||||
{% if NAVBAR_ITEMS.INDEX_NAVBAR_ITEM %}
|
|
||||||
<li>{{ navbar_item_link(NAVBAR_ITEMS.INDEX_NAVBAR_ITEM) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>{{ header_link("index", "Notes") }}</li>
|
<li>{{ header_link("index", "Notes") }}</li>
|
||||||
{% if articles_count %}
|
{% if articles_count %}
|
||||||
<li>{{ header_link("articles", "Articles") }}</li>
|
<li>{{ header_link("articles", "Articles") }}</li>
|
||||||
@ -51,9 +43,6 @@
|
|||||||
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
|
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
|
<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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
||||||
<meta content="Homepage" property="og:title" />
|
<meta content="Homepage" property="og:title" />
|
||||||
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
||||||
<meta content="{{ ICON_URL }}" property="og:image" />
|
<meta content="{{ local_actor.url }}" property="og:image" />
|
||||||
<meta content="summary" property="twitter:card" />
|
<meta content="summary" property="twitter:card" />
|
||||||
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -4,11 +4,11 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<link rel="stylesheet" href="{{ BASE_URL }}/static/css/main.css?v={{ CSS_HASH }}">
|
<link rel="stylesheet" href="/static/css/main.css?v={{ CSS_HASH }}">
|
||||||
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
|
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
|
||||||
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
|
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
|
||||||
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
|
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
|
||||||
<link rel="icon" type="image/x-icon" href="{{ BASE_URL }}/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
<style>{{ highlight_css }}</style>
|
<style>{{ highlight_css }}</style>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@ -18,8 +18,8 @@
|
|||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<div id="admin">
|
<div id="admin">
|
||||||
{% macro admin_link(url, text) %}
|
{% macro admin_link(url, text) %}
|
||||||
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
|
{% set url_for = request.app.router.url_path_for(url) %}
|
||||||
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
<div class="admin-menu">
|
<div class="admin-menu">
|
||||||
<nav class="flexbox">
|
<nav class="flexbox">
|
||||||
@ -53,7 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script>
|
<script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="primary-color">Invalid password.</p>
|
<p class="primary-color">Invalid password.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="form" action="{{ BASE_URL }}/admin/login" method="POST">
|
<form class="form" action="/admin/login" method="POST">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
<input type="hidden" name="redirect" value="{{ redirect }}">
|
||||||
<input type="password" placeholder="password" name="password" autofocus>
|
<input type="password" placeholder="password" name="password" autofocus>
|
||||||
|
@ -36,18 +36,6 @@
|
|||||||
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
||||||
{{ notif_actor_action(notif, "rejected your follow request") }}
|
{{ notif_actor_action(notif, "rejected your follow request") }}
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||||
{% elif notif.notification_type.value == "blocked" %}
|
|
||||||
{{ notif_actor_action(notif, "blocked you") }}
|
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
|
||||||
{% elif notif.notification_type.value == "unblocked" %}
|
|
||||||
{{ notif_actor_action(notif, "unblocked you") }}
|
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
|
||||||
{% elif notif.notification_type.value == "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" %}
|
{%- elif notif.notification_type.value == "move" %}
|
||||||
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
{%- 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,209 +1,168 @@
|
|||||||
{% macro embed_csrf_token() %}
|
{% macro embed_csrf_token() %}
|
||||||
{% block embed_csrf_token scoped %}
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro embed_redirect_url(permalink_id=None) %}
|
{% 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 %}">
|
<input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}">
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_block_button(actor) %}
|
{% macro admin_block_button(actor) %}
|
||||||
{% block admin_block_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_block") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_block") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||||
<input type="submit" value="block">
|
<input type="submit" value="block">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_unblock_button(actor) %}
|
{% macro admin_unblock_button(actor) %}
|
||||||
{% block admin_unblock_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||||
<input type="submit" value="unblock">
|
<input type="submit" value="unblock">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_follow_button(actor) %}
|
{% macro admin_follow_button(actor) %}
|
||||||
{% block admin_follow_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||||
<input type="submit" value="follow">
|
<input type="submit" value="follow">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_accept_incoming_follow_button(notif) %}
|
{% 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">
|
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
||||||
<input type="submit" value="accept follow">
|
<input type="submit" value="accept follow">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_reject_incoming_follow_button(notif) %}
|
{% 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">
|
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
||||||
<input type="submit" value="reject follow">
|
<input type="submit" value="reject follow">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_like_button(ap_object_id, permalink_id) %}
|
{% macro admin_like_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_like_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_like") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_like") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="like">
|
<input type="submit" value="like">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_bookmark_button(ap_object_id, permalink_id) %}
|
{% macro admin_bookmark_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_bookmark_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="bookmark">
|
<input type="submit" value="bookmark">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
|
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_unbookmark_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="unbookmark">
|
<input type="submit" value="unbookmark">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_pin_button(ap_object_id, permalink_id) %}
|
{% macro admin_pin_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_pin_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="pin">
|
<input type="submit" value="pin">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_unpin_button(ap_object_id, permalink_id) %}
|
{% macro admin_unpin_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_unpin_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="unpin">
|
<input type="submit" value="unpin">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_delete_button(ap_object) %}
|
{% 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">
|
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ 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="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="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
|
||||||
<input type="submit" value="delete">
|
<input type="submit" value="delete">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_announce_button(ap_object_id, permalink_id=None) %}
|
{% 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">
|
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="share">
|
<input type="submit" value="share">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %}
|
{% 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">
|
<form action="{{ request.url_for("admin_actions_undo") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="{{ action }}">
|
<input type="submit" value="{{ action }}">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_reply_button(ap_object_id) %}
|
{% macro admin_reply_button(ap_object_id) %}
|
||||||
{% block admin_reply_button scoped %}
|
<form action="/admin/new" method="GET">
|
||||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
|
||||||
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
|
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
|
||||||
<button type="submit">reply</button>
|
<button type="submit">reply</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_dm_button(actor_handle) %}
|
{% macro admin_dm_button(actor_handle) %}
|
||||||
{% block admin_dm_button scoped %}
|
<form action="/admin/new" method="GET">
|
||||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
|
||||||
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
||||||
<input type="hidden" name="with_visibility" value="DIRECT">
|
<input type="hidden" name="with_visibility" value="DIRECT">
|
||||||
<button type="submit">direct message</button>
|
<button type="submit">direct message</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_mention_button(actor_handle) %}
|
{% macro admin_mention_button(actor_handle) %}
|
||||||
{% block admin_mention_button scoped %}
|
<form action="/admin/new" method="GET">
|
||||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
|
||||||
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
||||||
<button type="submit">mention</button>
|
<button type="submit">mention</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% macro admin_profile_button(ap_actor_id) %}
|
{% macro admin_profile_button(ap_actor_id) %}
|
||||||
{% block admin_profile_button scoped %}
|
|
||||||
<form action="{{ url_for("admin_profile") }}" method="GET">
|
<form action="{{ url_for("admin_profile") }}" method="GET">
|
||||||
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
|
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
|
||||||
<button type="submit">profile</button>
|
<button type="submit">profile</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_expand_button(ap_object) %}
|
{% 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 #}
|
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
|
||||||
<form action="{{ url_for("admin_object") }}" method="GET">
|
<form action="{{ url_for("admin_object") }}" method="GET">
|
||||||
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
|
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
|
||||||
<button type="submit">expand</button>
|
<button type="submit">expand</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_box_filters(route) %}
|
{% macro display_box_filters(route) %}
|
||||||
{% block display_box_filters scoped %}
|
|
||||||
<nav class="flexbox box">
|
<nav class="flexbox box">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Filter by</li>
|
<li>Filter by</li>
|
||||||
@ -220,17 +179,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_tiny_actor_icon(actor) %}
|
{% 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">
|
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro actor_action(inbox_object, text, with_icon=False) %}
|
{% macro actor_action(inbox_object, text, with_icon=False) %}
|
||||||
{% block actor_action scoped %}
|
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
|
<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 }}
|
{% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}
|
||||||
@ -238,11 +193,9 @@
|
|||||||
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
|
{% 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) %}
|
{% set metadata = actors_metadata.get(actor.ap_id) %}
|
||||||
|
|
||||||
{% if not embedded %}
|
{% if not embedded %}
|
||||||
@ -263,25 +216,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<nav class="flexbox actor-metadata">
|
<nav class="flexbox actor-metadata">
|
||||||
<ul>
|
<ul>
|
||||||
{% if metadata.has_blocked_local_actor %}
|
|
||||||
<li>blocked you</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if metadata.is_following %}
|
{% if metadata.is_following %}
|
||||||
<li>already following</li>
|
<li>already following</li>
|
||||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
||||||
{% if not with_details %}
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% elif metadata.is_follow_request_sent %}
|
{% elif metadata.is_follow_request_sent %}
|
||||||
{% if metadata.is_follow_request_rejected %}
|
<li>follow request sent</li>
|
||||||
<li>follow request rejected</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</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 %}
|
{% elif not actor.moved_to %}
|
||||||
<li>{{ admin_follow_button(actor) }}</li>
|
<li>{{ admin_follow_button(actor) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -290,7 +231,7 @@
|
|||||||
{% if not metadata.is_following and not with_details %}
|
{% if not metadata.is_following and not with_details %}
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif actor.is_from_db and not with_details and not metadata.is_following %}
|
{% elif actor.is_from_db and not with_details %}
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if actor.moved_to %}
|
{% if actor.moved_to %}
|
||||||
@ -320,9 +261,6 @@
|
|||||||
<li>rejected</li>
|
<li>rejected</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if with_details %}
|
|
||||||
<li><a href="{{ actor.url }}" class="label-btn">remote profile</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -353,11 +291,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_og_meta(object) %}
|
{% macro display_og_meta(object) %}
|
||||||
{% block display_og_meta scoped %}
|
|
||||||
{% if object.og_meta %}
|
{% if object.og_meta %}
|
||||||
{% for og_meta in object.og_meta[:1] %}
|
{% for og_meta in object.og_meta[:1] %}
|
||||||
<div class="activity-og-meta">
|
<div class="activity-og-meta">
|
||||||
@ -375,15 +311,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro display_attachments(object) %}
|
{% macro display_attachments(object) %}
|
||||||
{% block display_attachments scoped %}
|
|
||||||
|
|
||||||
{% for attachment in object.attachments %}
|
{% for attachment in object.attachments %}
|
||||||
{% if attachment.type != "PropertyValue" %}
|
|
||||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
||||||
<div class="attachment-wrapper">
|
<div class="attachment-wrapper">
|
||||||
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
|
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
|
||||||
@ -405,13 +338,11 @@
|
|||||||
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
||||||
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
|
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
|
||||||
{% elif attachment.type == "Link" %}
|
{% elif attachment.type == "Link" %}
|
||||||
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
|
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.url }}"{% endif %} class="attachment">
|
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
|
||||||
{% if attachment.name %}{{ attachment.name }}{% else %}{{ attachment.url | truncate(64, True) }}{% endif %}
|
|
||||||
</a> ({{ attachment.mimetype }})
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
{% if object.sensitive %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -419,15 +350,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
|
{% 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 %}
|
{% 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", "Event"] %}
|
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question"] %}
|
||||||
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
|
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
|
||||||
|
|
||||||
{% if is_article_mode %}
|
{% if is_article_mode %}
|
||||||
@ -441,37 +369,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.in_reply_to %}
|
{% if object.in_reply_to %}
|
||||||
<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">
|
<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">
|
||||||
this {{ object.ap_type|lower }}
|
in reply to {{ object.in_reply_to|truncate(64, True) }}
|
||||||
</a></p>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_type in ["Article", "Event"] %}
|
{% if object.ap_type == "Article" %}
|
||||||
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
|
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
@ -489,6 +395,13 @@
|
|||||||
{{ object.content | clean_html(object) | safe }}
|
{{ object.content | clean_html(object) | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if object.quoted_object %}
|
||||||
|
<div class="ap-object-expanded ap-quoted-object">
|
||||||
|
{{ display_object(object.quoted_object) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if object.ap_type == "Question" %}
|
{% if object.ap_type == "Question" %}
|
||||||
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
{% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %}
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
@ -555,16 +468,6 @@
|
|||||||
<li>
|
<li>
|
||||||
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if object.is_from_outbox and is_object_page and not is_admin and not request.url.path.startswith("/remote_interaction") %}
|
|
||||||
<li>
|
|
||||||
<a class="label-btn" href="{{ request.url_for("remote_interaction") }}?ap_id={{ object.ap_id }}">
|
|
||||||
interact from your instance
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{% if not is_article_mode %}
|
{% if not is_article_mode %}
|
||||||
<li>
|
<li>
|
||||||
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
||||||
@ -660,7 +563,7 @@
|
|||||||
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
||||||
<li>
|
<li>
|
||||||
{% if object.announced_via_outbox_object_ap_id %}
|
{% if object.announced_via_outbox_object_ap_id %}
|
||||||
{{ admin_undo_button(object.announced_via_outbox_object_ap_id, "unshare") }}
|
{{ admin_undo_button(object.liked_via_outbox_object_ap_id, "unshare") }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -740,5 +643,4 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
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
|
|
@ -9,7 +9,6 @@ from bs4 import BeautifulSoup # type: ignore
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import activitypub as ap
|
|
||||||
from app import ap_object
|
from app import ap_object
|
||||||
from app import config
|
from app import config
|
||||||
from app.actor import LOCAL_ACTOR
|
from app.actor import LOCAL_ACTOR
|
||||||
@ -67,15 +66,11 @@ async def external_urls(
|
|||||||
tags_hrefs = set()
|
tags_hrefs = set()
|
||||||
for tag in ro.tags:
|
for tag in ro.tags:
|
||||||
if tag_href := tag.get("href"):
|
if tag_href := tag.get("href"):
|
||||||
tags_hrefs.add(tag_href)
|
if tag_href and tag_href not in filter(None, [ro.quote_url]):
|
||||||
|
tags_hrefs.add(tag_href)
|
||||||
if tag.get("type") == "Mention":
|
if tag.get("type") == "Mention":
|
||||||
if tag["href"] != LOCAL_ACTOR.ap_id:
|
if tag["href"] != LOCAL_ACTOR.ap_id:
|
||||||
try:
|
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
||||||
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
|
||||||
except (ap.FetchError, ap.NotAnObjectError):
|
|
||||||
tags_hrefs.add(tag["href"])
|
|
||||||
continue
|
|
||||||
|
|
||||||
tags_hrefs.add(mentioned_actor.url)
|
tags_hrefs.add(mentioned_actor.url)
|
||||||
tags_hrefs.add(mentioned_actor.ap_id)
|
tags_hrefs.add(mentioned_actor.ap_id)
|
||||||
else:
|
else:
|
||||||
@ -90,22 +85,18 @@ async def external_urls(
|
|||||||
if not h:
|
if not h:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
ph = urlparse(h)
|
||||||
ph = urlparse(h)
|
mimetype, _ = mimetypes.guess_type(h)
|
||||||
mimetype, _ = mimetypes.guess_type(h)
|
if (
|
||||||
if (
|
ph.scheme in {"http", "https"}
|
||||||
ph.scheme in {"http", "https"}
|
and ph.hostname != note_host
|
||||||
and ph.hostname != note_host
|
and is_url_valid(h)
|
||||||
and is_url_valid(h)
|
and (
|
||||||
and (
|
not mimetype
|
||||||
not mimetype
|
or mimetype.split("/")[0] not in ["image", "video", "audio"]
|
||||||
or mimetype.split("/")[0] not in ["image", "video", "audio"]
|
)
|
||||||
)
|
):
|
||||||
):
|
urls.add(h)
|
||||||
urls.add(h)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to check {h}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return urls - tags_hrefs
|
return urls - tags_hrefs
|
||||||
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
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,10 +58,6 @@ def is_url_valid(url: str) -> bool:
|
|||||||
logger.warning(f"{parsed.hostname} is blocked")
|
logger.warning(f"{parsed.hostname} is blocked")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if parsed.hostname.endswith(".onion"):
|
|
||||||
logger.warning(f"{url} is an onion service")
|
|
||||||
return False
|
|
||||||
|
|
||||||
ip_address = _getaddrinfo(
|
ip_address = _getaddrinfo(
|
||||||
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
||||||
)
|
)
|
||||||
|
@ -1 +0,0 @@
|
|||||||
../../app/templates/
|
|
@ -5,7 +5,6 @@ admin_password = "$2b$12$OwCyZM33uXQUVrChgER.h.qgFJ4fBp6tdFwArR3Lm1LV8NgMvIxVa"
|
|||||||
name = "test"
|
name = "test"
|
||||||
summary = "<p>Hello</p>"
|
summary = "<p>Hello</p>"
|
||||||
https = false
|
https = false
|
||||||
id = "http://localhost:8000"
|
|
||||||
icon_url = "https://localhost:8000/static/nopic.png"
|
icon_url = "https://localhost:8000/static/nopic.png"
|
||||||
secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
|
secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
|
||||||
debug = true
|
debug = true
|
||||||
|
@ -89,12 +89,6 @@ Setup config.
|
|||||||
poetry run inv configuration-wizard
|
poetry run inv configuration-wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
Setup the database.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run inv migrate-db
|
|
||||||
```
|
|
||||||
|
|
||||||
Grab your virtualenv path.
|
Grab your virtualenv path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
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;
|
max-width: 960px;
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
}
|
}
|
||||||
pre {
|
pre code {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -127,23 +127,9 @@ $secondary-color: #32cd32;
|
|||||||
|
|
||||||
See `app/scss/main.scss` to see what variables can be overridden.
|
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
|
#### Code highlighting theme
|
||||||
|
|
||||||
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `data/profile.toml`:
|
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `profile.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
code_highlighting_theme = "solarized-dark"
|
code_highlighting_theme = "solarized-dark"
|
||||||
@ -347,13 +333,13 @@ make compile-scss
|
|||||||
|
|
||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
If have lost your password, you can generate a new one using the `reset-password` task.
|
If have lost your password, you can generate a new one using the `password-reset` task.
|
||||||
|
|
||||||
#### Python edition
|
#### Python edition
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# shutdown supervisord
|
# shutdown supervisord
|
||||||
poetry run inv reset-password
|
poetry run inv password-reset
|
||||||
# edit data/profile.toml
|
# edit data/profile.toml
|
||||||
# restart supervisord
|
# restart supervisord
|
||||||
```
|
```
|
||||||
@ -362,7 +348,7 @@ poetry run inv reset-password
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose stop
|
docker compose stop
|
||||||
make reset-password
|
make password-reset
|
||||||
# edit data/profile.toml
|
# edit data/profile.toml
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
726
poetry.lock
generated
726
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,7 @@ httpx = {extras = ["http2"], version = "^0.23.0"}
|
|||||||
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
|
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
|
||||||
alembic = "^1.8.0"
|
alembic = "^1.8.0"
|
||||||
bleach = "^5.0.0"
|
bleach = "^5.0.0"
|
||||||
|
Markdown = "^3.3.7"
|
||||||
prompt-toolkit = "^3.0.29"
|
prompt-toolkit = "^3.0.29"
|
||||||
tomli-w = "^1.0.0"
|
tomli-w = "^1.0.0"
|
||||||
python-dateutil = "^2.8.2"
|
python-dateutil = "^2.8.2"
|
||||||
@ -26,6 +27,7 @@ html5lib = "^1.1"
|
|||||||
mf2py = "^1.1.2"
|
mf2py = "^1.1.2"
|
||||||
Pygments = "^2.12.0"
|
Pygments = "^2.12.0"
|
||||||
loguru = "^0.6.0"
|
loguru = "^0.6.0"
|
||||||
|
mdx-linkify = "^2.1"
|
||||||
Pillow = "^9.1.1"
|
Pillow = "^9.1.1"
|
||||||
blurhash-python = "^1.1.3"
|
blurhash-python = "^1.1.3"
|
||||||
html2text = "^2020.1.16"
|
html2text = "^2020.1.16"
|
||||||
@ -43,7 +45,6 @@ boussole = "^2.0.0"
|
|||||||
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
||||||
Brotli = "^1.0.9"
|
Brotli = "^1.0.9"
|
||||||
greenlet = "^1.1.3"
|
greenlet = "^1.1.3"
|
||||||
mistletoe = "^0.9.0"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^22.3.0"
|
black = "^22.3.0"
|
||||||
|
@ -1,115 +1,19 @@
|
|||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import typing
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from jinja2 import Environment
|
from jinja2 import Environment
|
||||||
from jinja2 import FileSystemLoader
|
from jinja2 import FileSystemLoader
|
||||||
from jinja2 import select_autoescape
|
from jinja2 import select_autoescape
|
||||||
from mistletoe import Document # type: ignore
|
from markdown import markdown
|
||||||
from mistletoe import HTMLRenderer # type: ignore
|
|
||||||
from mistletoe import block_token # type: ignore
|
|
||||||
from pygments import highlight # type: ignore
|
|
||||||
from pygments.formatters import HtmlFormatter # type: ignore
|
|
||||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
|
||||||
from pygments.lexers import guess_lexer # type: ignore
|
|
||||||
|
|
||||||
from app.config import VERSION
|
from app.config import VERSION
|
||||||
from app.source import CustomRenderer
|
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
|
|
||||||
_FORMATTER = HtmlFormatter()
|
|
||||||
_FORMATTER.noclasses = True
|
|
||||||
|
|
||||||
|
def markdownify(content: str) -> str:
|
||||||
class DocRenderer(CustomRenderer):
|
return markdown(
|
||||||
def __init__(
|
content, extensions=["mdx_linkify", "fenced_code", "codehilite", "toc"]
|
||||||
self,
|
)
|
||||||
depth=5,
|
|
||||||
omit_title=True,
|
|
||||||
filter_conds=[],
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
enable_mentionify=False,
|
|
||||||
enable_hashtagify=False,
|
|
||||||
)
|
|
||||||
self._headings: list[tuple[int, str, str]] = []
|
|
||||||
self._ids: set[str] = set()
|
|
||||||
self.depth = depth
|
|
||||||
self.omit_title = omit_title
|
|
||||||
self.filter_conds = filter_conds
|
|
||||||
|
|
||||||
@property
|
|
||||||
def toc(self):
|
|
||||||
"""
|
|
||||||
Returns table of contents as a block_token.List instance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_indent(level):
|
|
||||||
if self.omit_title:
|
|
||||||
level -= 1
|
|
||||||
return " " * 4 * (level - 1)
|
|
||||||
|
|
||||||
def build_list_item(heading):
|
|
||||||
level, content, title_id = heading
|
|
||||||
template = '{indent}- <a href="#{id}" rel="nofollow">{content}</a>\n'
|
|
||||||
return template.format(
|
|
||||||
indent=get_indent(level), content=content, id=title_id
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [build_list_item(heading) for heading in self._headings]
|
|
||||||
items = block_token.tokenize(lines)
|
|
||||||
return items[0]
|
|
||||||
|
|
||||||
def render_heading(self, token):
|
|
||||||
"""
|
|
||||||
Overrides super().render_heading; stores rendered heading first,
|
|
||||||
then returns it.
|
|
||||||
"""
|
|
||||||
template = '<h{level} id="{id}">{inner}</h{level}>'
|
|
||||||
inner = self.render_inner(token)
|
|
||||||
title_id = inner.lower().replace(" ", "-")
|
|
||||||
if title_id in self._ids:
|
|
||||||
i = 1
|
|
||||||
while 1:
|
|
||||||
title_id = f"{title_id}_{i}"
|
|
||||||
if title_id not in self._ids:
|
|
||||||
break
|
|
||||||
self._ids.add(title_id)
|
|
||||||
rendered = template.format(level=token.level, inner=inner, id=title_id)
|
|
||||||
content = self.parse_rendered_heading(rendered)
|
|
||||||
|
|
||||||
if not (
|
|
||||||
self.omit_title
|
|
||||||
and token.level == 1
|
|
||||||
or token.level > self.depth
|
|
||||||
or any(cond(content) for cond in self.filter_conds)
|
|
||||||
):
|
|
||||||
self._headings.append((token.level, content, title_id))
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_rendered_heading(rendered):
|
|
||||||
"""
|
|
||||||
Helper method; converts rendered heading to plain text.
|
|
||||||
"""
|
|
||||||
return re.sub(r"<.+?>", "", rendered)
|
|
||||||
|
|
||||||
def render_block_code(self, token: typing.Any) -> str:
|
|
||||||
code = token.children[0].content
|
|
||||||
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
|
|
||||||
return highlight(code, lexer, _FORMATTER)
|
|
||||||
|
|
||||||
|
|
||||||
def markdownify(content: str) -> tuple[str, Any]:
|
|
||||||
with DocRenderer() as renderer:
|
|
||||||
rendered_content = renderer.render(Document(content))
|
|
||||||
|
|
||||||
with HTMLRenderer() as html_renderer:
|
|
||||||
toc = html_renderer.render(renderer.toc)
|
|
||||||
|
|
||||||
return rendered_content, toc
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@ -126,36 +30,32 @@ def main() -> None:
|
|||||||
last_updated = now().replace(second=0, microsecond=0).isoformat()
|
last_updated = now().replace(second=0, microsecond=0).isoformat()
|
||||||
|
|
||||||
readme = Path("README.md")
|
readme = Path("README.md")
|
||||||
content, toc = markdownify(readme.read_text().removeprefix("# microblog.pub"))
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content,
|
content=markdownify(readme.read_text().removeprefix("# microblog.pub")),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/",
|
path="/",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
).dump("docs/dist/index.html")
|
).dump("docs/dist/index.html")
|
||||||
|
|
||||||
install = Path("docs/install.md")
|
install = Path("docs/install.md")
|
||||||
content, toc = markdownify(install.read_text())
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content.replace("[TOC]", toc),
|
content=markdownify(install.read_text()),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/installing.html",
|
path="/installing.html",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
).dump("docs/dist/installing.html")
|
).dump("docs/dist/installing.html")
|
||||||
|
|
||||||
user_guide = Path("docs/user_guide.md")
|
user_guide = Path("docs/user_guide.md")
|
||||||
content, toc = markdownify(user_guide.read_text())
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content.replace("[TOC]", toc),
|
content=markdownify(user_guide.read_text()),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/user_guide.html",
|
path="/user_guide.html",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
).dump("docs/dist/user_guide.html")
|
).dump("docs/dist/user_guide.html")
|
||||||
|
|
||||||
developer_guide = Path("docs/developer_guide.md")
|
developer_guide = Path("docs/developer_guide.md")
|
||||||
content, toc = markdownify(developer_guide.read_text())
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content.replace("[TOC]", toc),
|
content=markdownify(developer_guide.read_text()),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/developer_guide.html",
|
path="/developer_guide.html",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
|
8
tasks.py
8
tasks.py
@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import shutil
|
|
||||||
import tarfile
|
import tarfile
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -46,12 +45,7 @@ def compile_scss(ctx, watch=False):
|
|||||||
# type: (Context, bool) -> None
|
# type: (Context, bool) -> None
|
||||||
from app.utils.favicon import build_favicon
|
from app.utils.favicon import build_favicon
|
||||||
|
|
||||||
favicon_file = Path("data/favicon.ico")
|
build_favicon()
|
||||||
if not favicon_file.exists():
|
|
||||||
build_favicon()
|
|
||||||
else:
|
|
||||||
shutil.copy2(favicon_file, "app/static/favicon.ico")
|
|
||||||
|
|
||||||
theme_file = Path("data/_theme.scss")
|
theme_file = Path("data/_theme.scss")
|
||||||
if not theme_file.exists():
|
if not theme_file.exists():
|
||||||
theme_file.write_text("// override vars for theming here")
|
theme_file.write_text("// override vars for theming here")
|
||||||
|
@ -68,20 +68,6 @@ def build_accept_activity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_block_activity(
|
|
||||||
from_remote_actor: actor.RemoteActor,
|
|
||||||
for_remote_actor: actor.RemoteActor,
|
|
||||||
outbox_public_id: str | None = None,
|
|
||||||
) -> ap.RawObject:
|
|
||||||
return {
|
|
||||||
"@context": ap.AS_CTX,
|
|
||||||
"type": "Block",
|
|
||||||
"id": from_remote_actor.ap_id + "/block/" + (outbox_public_id or uuid4().hex),
|
|
||||||
"actor": from_remote_actor.ap_id,
|
|
||||||
"object": for_remote_actor.ap_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_move_activity(
|
def build_move_activity(
|
||||||
from_remote_actor: actor.RemoteActor,
|
from_remote_actor: actor.RemoteActor,
|
||||||
for_remote_object: actor.RemoteActor,
|
for_remote_object: actor.RemoteActor,
|
||||||
|
@ -423,53 +423,3 @@ def test_inbox__move_activity(
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert notif.actor.ap_id == new_ra.ap_id
|
assert notif.actor.ap_id == new_ra.ap_id
|
||||||
assert notif.inbox_object_id == inbox_activity.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
|
|
||||||
|
@ -179,7 +179,7 @@ def test_send_create_activity__with_attachment(
|
|||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
assert outbox_object.summary is None
|
assert outbox_object.summary is None
|
||||||
assert outbox_object.content == "<p>hello</p>\n"
|
assert outbox_object.content == "<p>hello</p>"
|
||||||
assert len(outbox_object.attachments) == 1
|
assert len(outbox_object.attachments) == 1
|
||||||
attachment = outbox_object.attachments[0]
|
attachment = outbox_object.attachments[0]
|
||||||
assert attachment.type == "Document"
|
assert attachment.type == "Document"
|
||||||
@ -227,7 +227,7 @@ def test_send_create_activity__no_content_with_cw_and_attachments(
|
|||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
assert outbox_object.summary is None
|
assert outbox_object.summary is None
|
||||||
assert outbox_object.content == "<p>cw</p>\n"
|
assert outbox_object.content == "<p>cw</p>"
|
||||||
assert len(outbox_object.attachments) == 1
|
assert len(outbox_object.attachments) == 1
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user