mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2025-06-05 21:59:23 +02:00
Compare commits
26 Commits
test-fix-t
...
2.0.0-rc.1
Author | SHA1 | Date | |
---|---|---|---|
5d95fd44ac | |||
a337b32bcd | |||
e8fcf5a9a2 | |||
7525744f82 | |||
7d3fc35a24 | |||
73dceee0f5 | |||
34c7cdb5fb | |||
0527e34476 | |||
a82f619e89 | |||
a68b3e7318 | |||
436d5ccf1b | |||
a273f26549 | |||
9d357446d2 | |||
6cabff21db | |||
5df4d420de | |||
68884d9afa | |||
46a592b11e | |||
5f0b8f5dfd | |||
5adb2bca9a | |||
08cc74d928 | |||
578581b4dc | |||
ec36272bb4 | |||
e30e0de10e | |||
e672d9b9f0 | |||
dcd44ec3b6 | |||
71a4ea2425 |
3
AUTHORS
3
AUTHORS
@ -1,8 +1,9 @@
|
||||
Thomas Sileo <t@a4.io>
|
||||
Kevin Wallace <doof@doof.net>
|
||||
Miguel Jacq <mig@mig5.net>
|
||||
Josh Washburne <josh@jodh.us>
|
||||
Alexey Shpakovsky <alexey@shpakovsky.ru>
|
||||
Josh Washburne <josh@jodh.us>
|
||||
Sam <samr1.dev@pm.me>
|
||||
Ash McAllan <acegiak@gmail.com>
|
||||
Cassio Zen <cassio@hey.com>
|
||||
Cocoa <momijizukamori@gmail.com>
|
||||
|
@ -12,6 +12,7 @@ from app import activitypub as ap
|
||||
from app.actor import LOCAL_ACTOR
|
||||
from app.actor import Actor
|
||||
from app.actor import RemoteActor
|
||||
from app.config import ID
|
||||
from app.media import proxied_media_url
|
||||
from app.utils.datetime import now
|
||||
from app.utils.datetime import parse_isoformat
|
||||
@ -212,6 +213,15 @@ class Object:
|
||||
def in_reply_to(self) -> str | None:
|
||||
return self.ap_object.get("inReplyTo")
|
||||
|
||||
@property
|
||||
def is_local_reply(self) -> bool:
|
||||
if not self.in_reply_to:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
self.in_reply_to.startswith(ID) and self.content # Hide votes from Question
|
||||
)
|
||||
|
||||
@property
|
||||
def is_in_reply_to_from_inbox(self) -> bool | None:
|
||||
if not self.in_reply_to:
|
||||
|
206
app/boxes.py
206
app/boxes.py
@ -28,7 +28,6 @@ from app.actor import save_actor
|
||||
from app.actor import update_actor_if_needed
|
||||
from app.ap_object import RemoteObject
|
||||
from app.config import BASE_URL
|
||||
from app.config import BLOCKED_SERVERS
|
||||
from app.config import ID
|
||||
from app.config import MANUALLY_APPROVES_FOLLOWERS
|
||||
from app.config import set_moved_to
|
||||
@ -46,10 +45,22 @@ from app.utils.datetime import now
|
||||
from app.utils.datetime import parse_isoformat
|
||||
from app.utils.facepile import WebmentionReply
|
||||
from app.utils.text import slugify
|
||||
from app.utils.url import is_hostname_blocked
|
||||
|
||||
AnyboxObject = models.InboxObject | models.OutboxObject
|
||||
|
||||
|
||||
def is_notification_enabled(notification_type: models.NotificationType) -> bool:
|
||||
"""Checks if a given notification type is enabled."""
|
||||
if notification_type.value == "pending_incoming_follower":
|
||||
# This one cannot be disabled as it would prevent manually reviewing
|
||||
# follow requests.
|
||||
return True
|
||||
if notification_type.value in config.CONFIG.disabled_notifications:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def allocate_outbox_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
@ -168,12 +179,13 @@ async def send_block(db_session: AsyncSession, ap_actor_id: str) -> None:
|
||||
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)
|
||||
if is_notification_enabled(models.NotificationType.BLOCK):
|
||||
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()
|
||||
|
||||
@ -427,7 +439,9 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
announced_object.announced_via_outbox_object_ap_id = None
|
||||
|
||||
# Send the Undo to the original recipients
|
||||
recipients = await _compute_recipients(db_session, outbox_object.ap_object)
|
||||
recipients = await _compute_recipients(
|
||||
db_session, outbox_object_to_undo.ap_object
|
||||
)
|
||||
for rcp in recipients:
|
||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||
elif outbox_object_to_undo.ap_type == "Block":
|
||||
@ -447,12 +461,13 @@ async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None:
|
||||
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)
|
||||
if is_notification_enabled(models.NotificationType.UNBLOCK):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNBLOCK,
|
||||
actor_id=blocked_actor.id,
|
||||
outbox_object_id=outbox_object.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
else:
|
||||
raise ValueError("Should never happen")
|
||||
@ -1379,7 +1394,7 @@ async def _revert_side_effect_for_deleted_object(
|
||||
.values(likes_count=likes_count - 1)
|
||||
)
|
||||
elif (
|
||||
deleted_ap_object.ap_type == "Annouce"
|
||||
deleted_ap_object.ap_type == "Announce"
|
||||
and deleted_ap_object.activity_object_ap_id
|
||||
):
|
||||
related_object = await get_outbox_object_by_ap_id(
|
||||
@ -1525,11 +1540,12 @@ async def _send_accept(
|
||||
raise ValueError("Should never happen")
|
||||
await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id)
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.NEW_FOLLOWER,
|
||||
actor_id=from_actor.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.NEW_FOLLOWER):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.NEW_FOLLOWER,
|
||||
actor_id=from_actor.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
|
||||
async def send_reject(
|
||||
@ -1568,11 +1584,12 @@ async def _send_reject(
|
||||
raise ValueError("Should never happen")
|
||||
await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id)
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.REJECTED_FOLLOWER,
|
||||
actor_id=from_actor.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.REJECTED_FOLLOWER):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.REJECTED_FOLLOWER,
|
||||
actor_id=from_actor.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
|
||||
async def _handle_undo_activity(
|
||||
@ -1598,11 +1615,12 @@ async def _handle_undo_activity(
|
||||
models.Follower.inbox_object_id == ap_activity_to_undo.id
|
||||
)
|
||||
)
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNFOLLOW,
|
||||
actor_id=from_actor.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.UNFOLLOW):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNFOLLOW,
|
||||
actor_id=from_actor.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
elif ap_activity_to_undo.ap_type == "Like":
|
||||
if not ap_activity_to_undo.activity_object_ap_id:
|
||||
@ -1618,14 +1636,21 @@ async def _handle_undo_activity(
|
||||
)
|
||||
return
|
||||
|
||||
liked_obj.likes_count = models.OutboxObject.likes_count - 1
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNDO_LIKE,
|
||||
actor_id=from_actor.id,
|
||||
outbox_object_id=liked_obj.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
liked_obj.likes_count = (
|
||||
await _get_outbox_likes_count(
|
||||
db_session,
|
||||
liked_obj,
|
||||
)
|
||||
- 1
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.UNDO_LIKE):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNDO_LIKE,
|
||||
actor_id=from_actor.id,
|
||||
outbox_object_id=liked_obj.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
elif ap_activity_to_undo.ap_type == "Announce":
|
||||
if not ap_activity_to_undo.activity_object_ap_id:
|
||||
@ -1643,20 +1668,22 @@ async def _handle_undo_activity(
|
||||
announced_obj_from_outbox.announces_count = (
|
||||
models.OutboxObject.announces_count - 1
|
||||
)
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNDO_ANNOUNCE,
|
||||
actor_id=from_actor.id,
|
||||
outbox_object_id=announced_obj_from_outbox.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.UNDO_ANNOUNCE):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNDO_ANNOUNCE,
|
||||
actor_id=from_actor.id,
|
||||
outbox_object_id=announced_obj_from_outbox.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
elif ap_activity_to_undo.ap_type == "Block":
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNBLOCKED,
|
||||
actor_id=from_actor.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.UNBLOCKED):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UNBLOCKED,
|
||||
actor_id=from_actor.id,
|
||||
inbox_object_id=ap_activity_to_undo.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
else:
|
||||
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
|
||||
|
||||
@ -1720,12 +1747,13 @@ async def _handle_move_activity(
|
||||
else:
|
||||
logger.info(f"Already following target {new_actor_id}")
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.MOVE,
|
||||
actor_id=new_actor.id,
|
||||
inbox_object_id=move_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.MOVE):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.MOVE,
|
||||
actor_id=new_actor.id,
|
||||
inbox_object_id=move_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
|
||||
async def _handle_update_activity(
|
||||
@ -1883,11 +1911,7 @@ async def _process_note_object(
|
||||
|
||||
is_from_following = ro.actor.ap_id in {f.ap_actor_id for f in following}
|
||||
is_reply = bool(ro.in_reply_to)
|
||||
is_local_reply = bool(
|
||||
ro.in_reply_to
|
||||
and ro.in_reply_to.startswith(BASE_URL)
|
||||
and ro.content # Hide votes from Question
|
||||
)
|
||||
is_local_reply = ro.is_local_reply
|
||||
is_mention = False
|
||||
hashtags = []
|
||||
tags = ro.ap_object.get("tag", [])
|
||||
@ -1999,7 +2023,7 @@ async def _process_note_object(
|
||||
inbox_object_id=parent_activity.id,
|
||||
)
|
||||
|
||||
if is_mention:
|
||||
if is_mention and is_notification_enabled(models.NotificationType.MENTION):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.MENTION,
|
||||
actor_id=from_actor.id,
|
||||
@ -2098,13 +2122,14 @@ async def _handle_announce_activity(
|
||||
models.OutboxObject.announces_count + 1
|
||||
)
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.ANNOUNCE,
|
||||
actor_id=actor.id,
|
||||
outbox_object_id=relates_to_outbox_object.id,
|
||||
inbox_object_id=announce_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.ANNOUNCE):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.ANNOUNCE,
|
||||
actor_id=actor.id,
|
||||
outbox_object_id=relates_to_outbox_object.id,
|
||||
inbox_object_id=announce_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
else:
|
||||
# Only show the announce in the stream if it comes from an actor
|
||||
# in the following collection
|
||||
@ -2202,13 +2227,14 @@ async def _handle_like_activity(
|
||||
relates_to_outbox_object,
|
||||
)
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.LIKE,
|
||||
actor_id=actor.id,
|
||||
outbox_object_id=relates_to_outbox_object.id,
|
||||
inbox_object_id=like_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.LIKE):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.LIKE,
|
||||
actor_id=actor.id,
|
||||
outbox_object_id=relates_to_outbox_object.id,
|
||||
inbox_object_id=like_activity.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
|
||||
async def _handle_block_activity(
|
||||
@ -2225,12 +2251,13 @@ async def _handle_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)
|
||||
if is_notification_enabled(models.NotificationType.BLOCKED):
|
||||
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(
|
||||
@ -2285,7 +2312,7 @@ async def save_to_inbox(
|
||||
logger.exception("Failed to fetch actor")
|
||||
return
|
||||
|
||||
if actor.server in BLOCKED_SERVERS:
|
||||
if is_hostname_blocked(actor.server):
|
||||
logger.warning(f"Server {actor.server} is blocked")
|
||||
return
|
||||
|
||||
@ -2433,12 +2460,13 @@ async def save_to_inbox(
|
||||
if activity_ro.ap_type == "Accept"
|
||||
else models.NotificationType.FOLLOW_REQUEST_REJECTED
|
||||
)
|
||||
notif = models.Notification(
|
||||
notification_type=notif_type,
|
||||
actor_id=actor.id,
|
||||
inbox_object_id=inbox_object.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(notif_type):
|
||||
notif = models.Notification(
|
||||
notification_type=notif_type,
|
||||
actor_id=actor.id,
|
||||
inbox_object_id=inbox_object.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
if activity_ro.ap_type == "Accept":
|
||||
following = models.Following(
|
||||
|
@ -44,11 +44,14 @@ except FileNotFoundError:
|
||||
JS_HASH = "none"
|
||||
try:
|
||||
# To keep things simple, we keep a single hash for the 2 files
|
||||
js_data_common = (ROOT_DIR / "app" / "static" / "common-admin.js").read_bytes()
|
||||
js_data_new = (ROOT_DIR / "app" / "static" / "new.js").read_bytes()
|
||||
JS_HASH = hashlib.md5(
|
||||
js_data_common + js_data_new, usedforsecurity=False
|
||||
).hexdigest()
|
||||
dat = b""
|
||||
for j in [
|
||||
ROOT_DIR / "app" / "static" / "common.js",
|
||||
ROOT_DIR / "app" / "static" / "common-admin.js",
|
||||
ROOT_DIR / "app" / "static" / "new.js",
|
||||
]:
|
||||
dat += j.read_bytes()
|
||||
JS_HASH = hashlib.md5(dat, usedforsecurity=False).hexdigest()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@ -120,6 +123,8 @@ class Config(pydantic.BaseModel):
|
||||
|
||||
session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default
|
||||
|
||||
disabled_notifications: list[str] = []
|
||||
|
||||
# Only set when the app is served on a non-root path
|
||||
id: str | None = None
|
||||
|
||||
|
@ -146,9 +146,10 @@ _StreamVisibilityCallback = Callable[[ObjectInfo], bool]
|
||||
|
||||
|
||||
def default_stream_visibility_callback(object_info: ObjectInfo) -> bool:
|
||||
logger.info(f"{object_info=}")
|
||||
return (
|
||||
result = (
|
||||
(not object_info.is_reply and object_info.is_from_following)
|
||||
or object_info.is_mention
|
||||
or object_info.is_local_reply
|
||||
)
|
||||
logger.info(f"{object_info=}/{result=}")
|
||||
return result
|
||||
|
@ -23,12 +23,12 @@ from sqlalchemy import select
|
||||
|
||||
from app import activitypub as ap
|
||||
from app import config
|
||||
from app.config import BLOCKED_SERVERS
|
||||
from app.config import KEY_PATH
|
||||
from app.database import AsyncSession
|
||||
from app.database import get_db_session
|
||||
from app.key import Key
|
||||
from app.utils.datetime import now
|
||||
from app.utils.url import is_hostname_blocked
|
||||
|
||||
_KEY_CACHE: MutableMapping[str, Key] = LFUCache(256)
|
||||
|
||||
@ -184,7 +184,7 @@ async def httpsig_checker(
|
||||
)
|
||||
|
||||
server = urlparse(key_id).hostname
|
||||
if server in BLOCKED_SERVERS:
|
||||
if is_hostname_blocked(server):
|
||||
return HTTPSigInfo(
|
||||
has_valid_signature=False,
|
||||
server=server,
|
||||
|
45
app/main.py
45
app/main.py
@ -285,7 +285,6 @@ async def redirect_to_remote_instance(
|
||||
async def index(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
page: int | None = None,
|
||||
) -> templates.TemplateResponse | ActivityPubResponse:
|
||||
if is_activitypub_requested(request):
|
||||
@ -297,7 +296,7 @@ async def index(
|
||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
models.OutboxObject.is_hidden_from_homepage.is_(False),
|
||||
models.OutboxObject.ap_type != "Article",
|
||||
models.OutboxObject.ap_type.in_(["Announce", "Note", "Video", "Question"]),
|
||||
)
|
||||
q = select(models.OutboxObject).where(*where)
|
||||
total_count = await db_session.scalar(
|
||||
@ -1180,15 +1179,11 @@ async def nodeinfo(
|
||||
)
|
||||
|
||||
|
||||
proxy_client = httpx.AsyncClient(
|
||||
http2=True,
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(timeout=10.0),
|
||||
)
|
||||
|
||||
|
||||
async def _proxy_get(
|
||||
request: starlette.requests.Request, url: str, stream: bool
|
||||
proxy_client: httpx.AsyncClient,
|
||||
request: starlette.requests.Request,
|
||||
url: str,
|
||||
stream: bool,
|
||||
) -> httpx.Response:
|
||||
# Request the URL (and filter request headers)
|
||||
proxy_req = proxy_client.build_request(
|
||||
@ -1235,18 +1230,29 @@ async def serve_proxy_media(
|
||||
exp: int,
|
||||
sig: str,
|
||||
encoded_url: str,
|
||||
background_tasks: fastapi.BackgroundTasks,
|
||||
) -> StreamingResponse | PlainTextResponse:
|
||||
# Decode the base64-encoded URL
|
||||
url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
check_url(url)
|
||||
media.verify_proxied_media_sig(exp, url, sig)
|
||||
|
||||
proxy_resp = await _proxy_get(request, url, stream=True)
|
||||
proxy_client = httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(timeout=10.0),
|
||||
transport=httpx.AsyncHTTPTransport(retries=1),
|
||||
)
|
||||
|
||||
async def _close_proxy_client():
|
||||
await proxy_client.aclose()
|
||||
|
||||
background_tasks.add_task(_close_proxy_client)
|
||||
proxy_resp = await _proxy_get(proxy_client, request, url, stream=True)
|
||||
|
||||
if proxy_resp.status_code >= 300:
|
||||
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
|
||||
await proxy_resp.aclose()
|
||||
return PlainTextResponse(
|
||||
"proxy error",
|
||||
status_code=proxy_resp.status_code,
|
||||
)
|
||||
|
||||
@ -1279,6 +1285,7 @@ async def serve_proxy_media_resized(
|
||||
sig: str,
|
||||
encoded_url: str,
|
||||
size: int,
|
||||
background_tasks: fastapi.BackgroundTasks,
|
||||
) -> PlainTextResponse:
|
||||
if size not in {50, 740}:
|
||||
raise ValueError("Unsupported size")
|
||||
@ -1296,11 +1303,21 @@ async def serve_proxy_media_resized(
|
||||
headers=resp_headers,
|
||||
)
|
||||
|
||||
proxy_resp = await _proxy_get(request, url, stream=False)
|
||||
proxy_client = httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(timeout=10.0),
|
||||
transport=httpx.AsyncHTTPTransport(retries=1),
|
||||
)
|
||||
|
||||
async def _close_proxy_client():
|
||||
await proxy_client.aclose()
|
||||
|
||||
background_tasks.add_task(_close_proxy_client)
|
||||
proxy_resp = await _proxy_get(proxy_client, request, url, stream=False)
|
||||
if proxy_resp.status_code >= 300:
|
||||
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
|
||||
await proxy_resp.aclose()
|
||||
return PlainTextResponse(
|
||||
"proxy error",
|
||||
status_code=proxy_resp.status_code,
|
||||
)
|
||||
|
||||
|
@ -51,17 +51,20 @@ $code-highlight-background: #f0f0f0;
|
||||
.p-summary {
|
||||
display: inline-block;
|
||||
}
|
||||
label {
|
||||
.show-more-btn {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.show-more-state {
|
||||
display: none;
|
||||
summary {
|
||||
display: inline-block;
|
||||
}
|
||||
.show-more-state ~ .obj-content {
|
||||
margin-top: 0;
|
||||
summary::-webkit-details-marker {
|
||||
display: none
|
||||
}
|
||||
.show-more-state:checked ~ .obj-content {
|
||||
display: none;
|
||||
&:not([open]) .show-more-btn::after {
|
||||
content: 'show more';
|
||||
}
|
||||
&[open] .show-more-btn::after {
|
||||
content: 'show less';
|
||||
}
|
||||
}
|
||||
.sensitive-attachment {
|
||||
@ -548,3 +551,22 @@ a.label-btn {
|
||||
.margin-top-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-gif-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-gif-mode + .video-gif-overlay {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 0 3px;
|
||||
font-size: 0.8em;
|
||||
background: rgba(0,0,0,.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
32
app/static/common.js
Normal file
32
app/static/common.js
Normal file
@ -0,0 +1,32 @@
|
||||
function hasAudio (video) {
|
||||
return video.mozHasAudio ||
|
||||
Boolean(video.webkitAudioDecodedByteCount) ||
|
||||
Boolean(video.audioTracks && video.audioTracks.length);
|
||||
}
|
||||
|
||||
function setVideoInGIFMode(video) {
|
||||
if (!hasAudio(video)) {
|
||||
if (typeof video.loop == 'boolean' && video.duration <= 10.0) {
|
||||
video.classList.add("video-gif-mode");
|
||||
video.loop = true;
|
||||
video.controls = false;
|
||||
video.addEventListener("mouseover", () => {
|
||||
video.play();
|
||||
})
|
||||
video.addEventListener("mouseleave", () => {
|
||||
video.pause();
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var items = document.getElementsByTagName("video")
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].duration) {
|
||||
setVideoInGIFMode(items[i]);
|
||||
} else {
|
||||
items[i].addEventListener("loadeddata", function() {
|
||||
setVideoInGIFMode(this);
|
||||
});
|
||||
}
|
||||
}
|
@ -26,24 +26,30 @@
|
||||
<div class="h-feed">
|
||||
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
|
||||
{% for outbox_object in objects %}
|
||||
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
|
||||
{% if outbox_object.ap_type in ["Note", "Video", "Question"] %}
|
||||
{{ utils.display_object(outbox_object) }}
|
||||
{% elif outbox_object.ap_type == "Announce" %}
|
||||
<div class="shared-header"><strong>{{ utils.display_tiny_actor_icon(local_actor) }} {{ local_actor.display_name | clean_html(local_actor) | safe }}</strong> shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||
<div class="h-entry" id="{{ outbox_object.permalink_id }}">
|
||||
<div class="shared-header"><strong><a class="p-author h-card" href="{{ local_actor.url }}">{{ utils.display_tiny_actor_icon(local_actor) }} {{ local_actor.display_name | clean_html(local_actor) | safe }}</a></strong> shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
||||
<div class="h-cite u-repost-of">
|
||||
{{ utils.display_object(outbox_object.relates_to_anybox_object, is_h_entry=False) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
{% if has_previous_page %}
|
||||
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
|
||||
{% endif %}
|
||||
{% if has_previous_page or has_next_page %}
|
||||
<div class="box">
|
||||
{% if has_previous_page %}
|
||||
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next_page %}
|
||||
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if has_next_page %}
|
||||
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
|
@ -55,5 +55,6 @@
|
||||
{% if is_admin %}
|
||||
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script>
|
||||
{% endif %}
|
||||
<script src="{{ BASE_URL }}/static/common.js?v={{ JS_HASH }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -247,7 +247,7 @@
|
||||
|
||||
{% 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="">
|
||||
{% endblock %}
|
||||
{% endmacro %}
|
||||
|
||||
@ -425,13 +425,16 @@
|
||||
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
|
||||
{% if attachment.url not in object.inlined_images %}
|
||||
<a class="media-link" href="{{ attachment.proxied_url }}" target="_blank">
|
||||
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment">
|
||||
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment u-photo">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
|
||||
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
|
||||
<div class="video-wrapper">
|
||||
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="u-video"></video>
|
||||
<div class="video-gif-overlay">GIF</div>
|
||||
</div>
|
||||
{% 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 u-audio"></audio>
|
||||
{% elif attachment.type == "Link" %}
|
||||
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
|
||||
{% else %}
|
||||
@ -467,7 +470,7 @@
|
||||
</div>
|
||||
|
||||
<p class="in-reply-to">in reply to <a href="{{ wm_reply.in_reply_to }}" title="{{ wm_reply.in_reply_to }}" rel="nofollow">
|
||||
this note
|
||||
this object
|
||||
</a></p>
|
||||
|
||||
<div class="obj-content margin-top-20">
|
||||
@ -514,7 +517,7 @@
|
||||
|
||||
{% 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">
|
||||
this {{ object.ap_type|lower }}
|
||||
this object
|
||||
</a></p>
|
||||
{% endif %}
|
||||
|
||||
@ -549,12 +552,13 @@
|
||||
{% endif %}
|
||||
|
||||
{% if object.summary %}
|
||||
<div class="show-more-wrapper">
|
||||
<div class="p-summary">
|
||||
<p>{{ object.summary | clean_html(object) | safe }}</p>
|
||||
</div>
|
||||
<label for="show-more-{{ object.permalink_id }}" class="show-more-btn">show/hide more</label>
|
||||
<input class="show-more-state" type="checkbox" aria-hidden="true" id="show-more-{{ object.permalink_id }}" checked>
|
||||
<details class="show-more-wrapper">
|
||||
<summary>
|
||||
<div class="p-summary">
|
||||
<p>{{ object.summary | clean_html(object) | safe }}</p>
|
||||
</div>
|
||||
<span class="show-more-btn" aria-hidden="true"></span>
|
||||
</summary>
|
||||
{% endif %}
|
||||
<div class="obj-content">
|
||||
<div class="e-content">
|
||||
@ -615,7 +619,7 @@
|
||||
|
||||
</div>
|
||||
{% if object.summary %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div class="activity-attachment">
|
||||
@ -750,7 +754,7 @@
|
||||
{{ admin_expand_button(object) }}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if object.is_from_inbox and not object.announced_via_outbox_object_ap_id %}
|
||||
{% if object.is_from_inbox and not object.announced_via_outbox_object_ap_id and object.is_local_reply %}
|
||||
<li>
|
||||
{{ admin_force_delete_button(object.ap_id) }}
|
||||
</li>
|
||||
|
@ -54,7 +54,7 @@ def is_url_valid(url: str) -> bool:
|
||||
if not parsed.hostname or parsed.hostname.lower() in ["localhost"]:
|
||||
return False
|
||||
|
||||
if parsed.hostname in BLOCKED_SERVERS:
|
||||
if is_hostname_blocked(parsed.hostname):
|
||||
logger.warning(f"{parsed.hostname} is blocked")
|
||||
return False
|
||||
|
||||
@ -81,3 +81,11 @@ def check_url(url: str) -> None:
|
||||
raise InvalidURLError(f'"{url}" is invalid')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def is_hostname_blocked(hostname: str) -> bool:
|
||||
for blocked_hostname in BLOCKED_SERVERS:
|
||||
if hostname == blocked_hostname or hostname.endswith(f".{blocked_hostname}"):
|
||||
return True
|
||||
return False
|
||||
|
@ -24,7 +24,7 @@ async def _discover_webmention_endoint(url: str) -> str | None:
|
||||
follow_redirects=True,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except (httpx.HTTPError, httpx.HTTPStatusError):
|
||||
except Exception:
|
||||
logger.exception(f"Failed to discover webmention endpoint for {url}")
|
||||
return None
|
||||
|
||||
|
@ -17,6 +17,7 @@ from app.boxes import _get_outbox_likes_count
|
||||
from app.boxes import _get_outbox_replies_count
|
||||
from app.boxes import get_outbox_object_by_ap_id
|
||||
from app.boxes import get_outbox_object_by_slug_and_short_id
|
||||
from app.boxes import is_notification_enabled
|
||||
from app.database import AsyncSession
|
||||
from app.database import get_db_session
|
||||
from app.utils import microformats
|
||||
@ -118,12 +119,13 @@ async def webmention_endpoint(
|
||||
db_session, existing_webmention_in_db, mentioned_object
|
||||
)
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.DELETED_WEBMENTION,
|
||||
outbox_object_id=mentioned_object.id,
|
||||
webmention_id=existing_webmention_in_db.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.DELETED_WEBMENTION):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.DELETED_WEBMENTION,
|
||||
outbox_object_id=mentioned_object.id,
|
||||
webmention_id=existing_webmention_in_db.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
@ -144,12 +146,13 @@ async def webmention_endpoint(
|
||||
await db_session.flush()
|
||||
webmention = existing_webmention_in_db
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UPDATED_WEBMENTION,
|
||||
outbox_object_id=mentioned_object.id,
|
||||
webmention_id=existing_webmention_in_db.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.UPDATED_WEBMENTION):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.UPDATED_WEBMENTION,
|
||||
outbox_object_id=mentioned_object.id,
|
||||
webmention_id=existing_webmention_in_db.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
else:
|
||||
new_webmention = models.Webmention(
|
||||
source=source,
|
||||
@ -162,12 +165,13 @@ async def webmention_endpoint(
|
||||
await db_session.flush()
|
||||
webmention = new_webmention
|
||||
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.NEW_WEBMENTION,
|
||||
outbox_object_id=mentioned_object.id,
|
||||
webmention_id=new_webmention.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
if is_notification_enabled(models.NotificationType.NEW_WEBMENTION):
|
||||
notif = models.Notification(
|
||||
notification_type=models.NotificationType.NEW_WEBMENTION,
|
||||
outbox_object_id=mentioned_object.id,
|
||||
webmention_id=new_webmention.id,
|
||||
)
|
||||
db_session.add(notif)
|
||||
|
||||
# Determine the webmention type
|
||||
for item in data.get("items", []):
|
||||
|
@ -191,6 +191,45 @@ http {
|
||||
}
|
||||
```
|
||||
|
||||
## (Advanced) Running from subpath
|
||||
|
||||
It is possible to configure microblogpub to run from subpath.
|
||||
To achieve this, do the following configuration _between_ config and start steps.
|
||||
i.e. _after_ you run `make config` or `poetry run inv configuration-wizard`,
|
||||
but _before_ you run `docker compose up` or `poetry run supervisord`.
|
||||
Changing this settings on an instance which has some posts or was seen by other instances will likely break links to these posts or federation (i.e. links to your instance, posts and profile from other instances).
|
||||
|
||||
The following steps will explain how to configure instance to be available at `https://example.com/subdir`.
|
||||
Change them to your actual domain and subdir.
|
||||
|
||||
* Edit `data/profile.toml` file, add this line:
|
||||
|
||||
id = "https://example.com/subdir"
|
||||
|
||||
* Edit `misc/*-supervisord.conf` file which is relevant to you (it depends on how you start microblogpub - if in doubt, do the same change in all of them) - in `[program:uvicorn]` section, in the line which starts with `command`, add this argument at the very end: ` --root-path /subdir`
|
||||
|
||||
Above two steps are enough to configure microblogpub.
|
||||
Next, you also need to configure reverse proxy.
|
||||
It might slightly differ if you plan to have other services running on the same domain, but for [NGINX config shown above](#reverse-proxy), the following changes are enough:
|
||||
|
||||
* Add subdir to location, so location block starts like this:
|
||||
|
||||
location /subdir {
|
||||
|
||||
* Add `/` at the end of `proxy_pass` directive, like this:
|
||||
|
||||
proxy_pass http://localhost:8000/;
|
||||
|
||||
These two changes will instruct NGINX that requests sent to `https://example.com/subdir/...` should be forwarded to `http://localhost:8000/...`.
|
||||
|
||||
* Inside `server` block, add redirects for well-known URLs (add these lines after `client_max_body_size`, remember to replace `subdir` with your actual subdir!):
|
||||
|
||||
location /.well-known/webfinger { return 301 /subdir$request_uri; }
|
||||
location /.well-known/nodeinfo { return 301 /subdir$request_uri; }
|
||||
location /.well-known/oauth-authorization-server { return 301 /subdir$request_uri; }
|
||||
|
||||
* Optionally, [check robots.txt from a running microblogpub instance](https://microblog.pub/robots.txt) and integrate it into robots.txt file in the root of your server - remember to prepend `subdir` to URLs, so for example `Disallow: /admin` becomes `Disallow: /subdir/admin`.
|
||||
|
||||
## YunoHost edition
|
||||
|
||||
[YunoHost](https://yunohost.org/) support is available (although it is not an official package for now): <https://git.sr.ht/~tsileo/microblog.pub_ynh>.
|
||||
|
@ -98,6 +98,39 @@ privacy_replace = [
|
||||
]
|
||||
```
|
||||
|
||||
### Disabling certain notification types
|
||||
|
||||
All notifications are enabled by default.
|
||||
|
||||
You can disabled specific notifications by adding them to the `disabled_notifications` list.
|
||||
|
||||
This example disables likes and shares notifications:
|
||||
|
||||
```
|
||||
disabled_notifications = ["like", "announce"]
|
||||
```
|
||||
|
||||
#### Available notification types
|
||||
|
||||
- `new_follower`
|
||||
- `rejected_follower`
|
||||
- `unfollow`
|
||||
- `follow_request_accepted`
|
||||
- `follow_request_rejected`
|
||||
- `move`
|
||||
- `like`
|
||||
- `undo_like`
|
||||
- `announce`
|
||||
- `undo_announce`
|
||||
- `mention`
|
||||
- `new_webmention`
|
||||
- `updated_webmention`
|
||||
- `deleted_webmention`
|
||||
- `blocked`
|
||||
- `unblocked`
|
||||
- `block`
|
||||
- `unblock`
|
||||
|
||||
### Customization
|
||||
|
||||
#### Default emoji
|
||||
|
19
tests/test_utils.py
Normal file
19
tests/test_utils.py
Normal file
@ -0,0 +1,19 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.url import is_hostname_blocked
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostname,should_be_blocked",
|
||||
[
|
||||
("example.com", True),
|
||||
("subdomain.example.com", True),
|
||||
("example.xyz", False),
|
||||
],
|
||||
)
|
||||
def test_is_hostname_blocked(hostname: str, should_be_blocked: bool) -> None:
|
||||
with mock.patch("app.utils.url.BLOCKED_SERVERS", ["example.com"]):
|
||||
is_hostname_blocked.cache_clear()
|
||||
assert is_hostname_blocked(hostname) is should_be_blocked
|
Reference in New Issue
Block a user