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

22 Commits

Author SHA1 Message Date
5d95fd44ac Fix webmention discovery 2022-12-04 12:06:15 +01:00
a337b32bcd Blocking server also blocks subdomains 2022-12-04 11:51:52 +01:00
e8fcf5a9a2 Tweak video mode 2022-12-03 19:57:13 +01:00
7525744f82 Test new GIF mode for videos without sound 2022-12-03 19:47:11 +01:00
7d3fc35a24 More proxy client tweaks 2022-12-02 19:40:58 +01:00
73dceee0f5 Fix proxy client 2022-12-02 19:28:59 +01:00
34c7cdb5fb Fix Undo{Announce} recipients 2022-12-02 18:48:23 +01:00
0527e34476 Tweak proxy client 2022-12-02 18:48:05 +01:00
a82f619e89 Revert "fix unshare by getting recipients from Announce activity instead of Undo"
This reverts commit dcd44ec3b6.
2022-12-02 18:12:24 +01:00
a68b3e7318 Don't insert an empty div on the index when there's no pages 2022-11-30 20:11:20 +01:00
436d5ccf1b Tweak in reply to this xyz text 2022-11-30 19:30:26 +01:00
a273f26549 Only show local delete for local replies 2022-11-30 17:49:36 +01:00
9d357446d2 Tweak logging 2022-11-30 17:37:08 +01:00
6cabff21db Document running from subpath 2022-11-30 14:14:09 +01:00
5df4d420de Whitelist object types in the index query
Select the outbox object types that we want to show on the notes page
instead of removing objects that we don't want to show.
That way, it's easier to ensure that there are no objects messing up the
object count/empty checks.

Partially fixes https://todo.sr.ht/~tsileo/microblog.pub/65
2022-11-30 14:10:28 +01:00
68884d9afa Use <details> element for sensitive text
The sensitive text feature was implemented with <label> and hidden
checkbox <input> elements. There were two issues with this
implementation:
1. The user couldn't navigate to the "show/hide more" button using
   keyboard.
2. The label indicates two actions at the same time ("show/hide more"),
   making it unclear what the function of the checkbox was and what the
   current show/collapse state was.

As it is generally preferrable to use built-in HTML elements for the
best semantic, this commit moves to use the <details> and <summary>
elements for the sensitive text feature. The browser will open/collapse
the content in <details> automatically when the user clicks on the
<summary>, and keyboard navigation support is built-in.

This commit also changes the button to display "show more" or "show
less" depending on the state for visual clarity. This button is hidden
from the accessibility tree using `aria-label="false"`, as the <details>
element already exposes its state to the tree and we want to avoid
duplicated information.

A few caveats:
* The "show/hide sensitive content" button for sensitive attachments
  hasn't been changed yet as I'd like to get more feedback about the new
  implementation.
* As the summary/content warning text itself is also part of the
  <summary> tag, the user can now also click on them to toggle the
  visibility of the sensitive text. This may not be desirable as the
  current interface does not make it clear that this could happen; the
  user may try to select some text from the summary and be surprised
  by the sensitive text being expanded. One way to improve this would
  be to add an event listener to the summary text and call
  `preventDefault`, but this would introduce JavaScript code.
2022-11-30 12:26:34 +01:00
46a592b11e Switch back to HTTP1 for the media proxy client 2022-11-30 12:26:31 +01:00
5f0b8f5dfd Tweak media proxy client 2022-11-28 20:58:16 +01:00
5adb2bca9a Revert "Update deps"
This reverts commit 08cc74d928.
2022-11-28 20:35:53 +01:00
08cc74d928 Update deps 2022-11-28 20:30:37 +01:00
578581b4dc More mf2 improvements for shares/reposts 2022-11-27 16:29:49 +01:00
ec36272bb4 Allow to disable certain notification type 2022-11-27 12:11:42 +01:00
17 changed files with 392 additions and 162 deletions

View File

@ -12,6 +12,7 @@ from app import activitypub as ap
from app.actor import LOCAL_ACTOR from app.actor import LOCAL_ACTOR
from app.actor import Actor from app.actor import Actor
from app.actor import RemoteActor from app.actor import RemoteActor
from app.config import ID
from app.media import proxied_media_url from app.media import proxied_media_url
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
@ -212,6 +213,15 @@ class Object:
def in_reply_to(self) -> str | None: def in_reply_to(self) -> str | None:
return self.ap_object.get("inReplyTo") return self.ap_object.get("inReplyTo")
@property
def is_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 @property
def is_in_reply_to_from_inbox(self) -> bool | None: def is_in_reply_to_from_inbox(self) -> bool | None:
if not self.in_reply_to: if not self.in_reply_to:

View File

@ -28,7 +28,6 @@ from app.actor import save_actor
from app.actor import update_actor_if_needed 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 ID from app.config import ID
from app.config import MANUALLY_APPROVES_FOLLOWERS from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.config import set_moved_to from app.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.datetime import parse_isoformat
from app.utils.facepile import WebmentionReply from app.utils.facepile import WebmentionReply
from app.utils.text import slugify from app.utils.text import slugify
from app.utils.url import is_hostname_blocked
AnyboxObject = models.InboxObject | models.OutboxObject 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: def allocate_outbox_id() -> str:
return uuid.uuid4().hex 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) await new_outgoing_activity(db_session, actor.inbox_url, outbox_object.id)
# 4. Create a notification # 4. Create a notification
notif = models.Notification( if is_notification_enabled(models.NotificationType.BLOCK):
notification_type=models.NotificationType.BLOCK, notif = models.Notification(
actor_id=actor.id, notification_type=models.NotificationType.BLOCK,
outbox_object_id=outbox_object.id, actor_id=actor.id,
) outbox_object_id=outbox_object.id,
db_session.add(notif) )
db_session.add(notif)
await db_session.commit() 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 announced_object.announced_via_outbox_object_ap_id = None
# Send the Undo to the original recipients # Send the Undo to the original recipients
recipients = await _compute_recipients(db_session, announced_object.ap_object) recipients = await _compute_recipients(
db_session, outbox_object_to_undo.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": 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, outbox_object.id,
) )
notif = models.Notification( if is_notification_enabled(models.NotificationType.UNBLOCK):
notification_type=models.NotificationType.UNBLOCK, notif = models.Notification(
actor_id=blocked_actor.id, notification_type=models.NotificationType.UNBLOCK,
outbox_object_id=outbox_object.id, actor_id=blocked_actor.id,
) outbox_object_id=outbox_object.id,
db_session.add(notif) )
db_session.add(notif)
else: else:
raise ValueError("Should never happen") raise ValueError("Should never happen")
@ -1525,11 +1540,12 @@ async def _send_accept(
raise ValueError("Should never happen") raise ValueError("Should never happen")
await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id) await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id)
notif = models.Notification( if is_notification_enabled(models.NotificationType.NEW_FOLLOWER):
notification_type=models.NotificationType.NEW_FOLLOWER, notif = models.Notification(
actor_id=from_actor.id, notification_type=models.NotificationType.NEW_FOLLOWER,
) actor_id=from_actor.id,
db_session.add(notif) )
db_session.add(notif)
async def send_reject( async def send_reject(
@ -1568,11 +1584,12 @@ async def _send_reject(
raise ValueError("Should never happen") raise ValueError("Should never happen")
await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id) await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id)
notif = models.Notification( if is_notification_enabled(models.NotificationType.REJECTED_FOLLOWER):
notification_type=models.NotificationType.REJECTED_FOLLOWER, notif = models.Notification(
actor_id=from_actor.id, notification_type=models.NotificationType.REJECTED_FOLLOWER,
) actor_id=from_actor.id,
db_session.add(notif) )
db_session.add(notif)
async def _handle_undo_activity( async def _handle_undo_activity(
@ -1598,11 +1615,12 @@ async def _handle_undo_activity(
models.Follower.inbox_object_id == ap_activity_to_undo.id models.Follower.inbox_object_id == ap_activity_to_undo.id
) )
) )
notif = models.Notification( if is_notification_enabled(models.NotificationType.UNFOLLOW):
notification_type=models.NotificationType.UNFOLLOW, notif = models.Notification(
actor_id=from_actor.id, notification_type=models.NotificationType.UNFOLLOW,
) actor_id=from_actor.id,
db_session.add(notif) )
db_session.add(notif)
elif ap_activity_to_undo.ap_type == "Like": elif ap_activity_to_undo.ap_type == "Like":
if not ap_activity_to_undo.activity_object_ap_id: if not ap_activity_to_undo.activity_object_ap_id:
@ -1618,14 +1636,21 @@ async def _handle_undo_activity(
) )
return return
liked_obj.likes_count = models.OutboxObject.likes_count - 1 liked_obj.likes_count = (
notif = models.Notification( await _get_outbox_likes_count(
notification_type=models.NotificationType.UNDO_LIKE, db_session,
actor_id=from_actor.id, liked_obj,
outbox_object_id=liked_obj.id, )
inbox_object_id=ap_activity_to_undo.id, - 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": elif ap_activity_to_undo.ap_type == "Announce":
if not ap_activity_to_undo.activity_object_ap_id: 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 = ( announced_obj_from_outbox.announces_count = (
models.OutboxObject.announces_count - 1 models.OutboxObject.announces_count - 1
) )
notif = models.Notification( if is_notification_enabled(models.NotificationType.UNDO_ANNOUNCE):
notification_type=models.NotificationType.UNDO_ANNOUNCE, notif = models.Notification(
actor_id=from_actor.id, notification_type=models.NotificationType.UNDO_ANNOUNCE,
outbox_object_id=announced_obj_from_outbox.id, actor_id=from_actor.id,
inbox_object_id=ap_activity_to_undo.id, outbox_object_id=announced_obj_from_outbox.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": elif ap_activity_to_undo.ap_type == "Block":
notif = models.Notification( if is_notification_enabled(models.NotificationType.UNBLOCKED):
notification_type=models.NotificationType.UNBLOCKED, notif = models.Notification(
actor_id=from_actor.id, notification_type=models.NotificationType.UNBLOCKED,
inbox_object_id=ap_activity_to_undo.id, actor_id=from_actor.id,
) inbox_object_id=ap_activity_to_undo.id,
db_session.add(notif) )
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")
@ -1720,12 +1747,13 @@ async def _handle_move_activity(
else: else:
logger.info(f"Already following target {new_actor_id}") logger.info(f"Already following target {new_actor_id}")
notif = models.Notification( if is_notification_enabled(models.NotificationType.MOVE):
notification_type=models.NotificationType.MOVE, notif = models.Notification(
actor_id=new_actor.id, notification_type=models.NotificationType.MOVE,
inbox_object_id=move_activity.id, actor_id=new_actor.id,
) inbox_object_id=move_activity.id,
db_session.add(notif) )
db_session.add(notif)
async def _handle_update_activity( 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_from_following = ro.actor.ap_id in {f.ap_actor_id for f in following}
is_reply = bool(ro.in_reply_to) is_reply = bool(ro.in_reply_to)
is_local_reply = bool( is_local_reply = ro.is_local_reply
ro.in_reply_to
and ro.in_reply_to.startswith(BASE_URL)
and ro.content # Hide votes from Question
)
is_mention = False is_mention = False
hashtags = [] hashtags = []
tags = ro.ap_object.get("tag", []) tags = ro.ap_object.get("tag", [])
@ -1999,7 +2023,7 @@ async def _process_note_object(
inbox_object_id=parent_activity.id, inbox_object_id=parent_activity.id,
) )
if is_mention: if is_mention and is_notification_enabled(models.NotificationType.MENTION):
notif = models.Notification( notif = models.Notification(
notification_type=models.NotificationType.MENTION, notification_type=models.NotificationType.MENTION,
actor_id=from_actor.id, actor_id=from_actor.id,
@ -2098,13 +2122,14 @@ async def _handle_announce_activity(
models.OutboxObject.announces_count + 1 models.OutboxObject.announces_count + 1
) )
notif = models.Notification( if is_notification_enabled(models.NotificationType.ANNOUNCE):
notification_type=models.NotificationType.ANNOUNCE, notif = models.Notification(
actor_id=actor.id, notification_type=models.NotificationType.ANNOUNCE,
outbox_object_id=relates_to_outbox_object.id, actor_id=actor.id,
inbox_object_id=announce_activity.id, outbox_object_id=relates_to_outbox_object.id,
) inbox_object_id=announce_activity.id,
db_session.add(notif) )
db_session.add(notif)
else: else:
# Only show the announce in the stream if it comes from an actor # Only show the announce in the stream if it comes from an actor
# in the following collection # in the following collection
@ -2202,13 +2227,14 @@ async def _handle_like_activity(
relates_to_outbox_object, relates_to_outbox_object,
) )
notif = models.Notification( if is_notification_enabled(models.NotificationType.LIKE):
notification_type=models.NotificationType.LIKE, notif = models.Notification(
actor_id=actor.id, notification_type=models.NotificationType.LIKE,
outbox_object_id=relates_to_outbox_object.id, actor_id=actor.id,
inbox_object_id=like_activity.id, outbox_object_id=relates_to_outbox_object.id,
) inbox_object_id=like_activity.id,
db_session.add(notif) )
db_session.add(notif)
async def _handle_block_activity( async def _handle_block_activity(
@ -2225,12 +2251,13 @@ async def _handle_block_activity(
return return
# Create a notification # Create a notification
notif = models.Notification( if is_notification_enabled(models.NotificationType.BLOCKED):
notification_type=models.NotificationType.BLOCKED, notif = models.Notification(
actor_id=actor.id, notification_type=models.NotificationType.BLOCKED,
inbox_object_id=block_activity.id, actor_id=actor.id,
) inbox_object_id=block_activity.id,
db_session.add(notif) )
db_session.add(notif)
async def _process_transient_object( async def _process_transient_object(
@ -2285,7 +2312,7 @@ async def save_to_inbox(
logger.exception("Failed to fetch actor") logger.exception("Failed to fetch actor")
return return
if actor.server in BLOCKED_SERVERS: if is_hostname_blocked(actor.server):
logger.warning(f"Server {actor.server} is blocked") logger.warning(f"Server {actor.server} is blocked")
return return
@ -2433,12 +2460,13 @@ async def save_to_inbox(
if activity_ro.ap_type == "Accept" if activity_ro.ap_type == "Accept"
else models.NotificationType.FOLLOW_REQUEST_REJECTED else models.NotificationType.FOLLOW_REQUEST_REJECTED
) )
notif = models.Notification( if is_notification_enabled(notif_type):
notification_type=notif_type, notif = models.Notification(
actor_id=actor.id, notification_type=notif_type,
inbox_object_id=inbox_object.id, actor_id=actor.id,
) inbox_object_id=inbox_object.id,
db_session.add(notif) )
db_session.add(notif)
if activity_ro.ap_type == "Accept": if activity_ro.ap_type == "Accept":
following = models.Following( following = models.Following(

View File

@ -44,11 +44,14 @@ except FileNotFoundError:
JS_HASH = "none" JS_HASH = "none"
try: try:
# To keep things simple, we keep a single hash for the 2 files # 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() dat = b""
js_data_new = (ROOT_DIR / "app" / "static" / "new.js").read_bytes() for j in [
JS_HASH = hashlib.md5( ROOT_DIR / "app" / "static" / "common.js",
js_data_common + js_data_new, usedforsecurity=False ROOT_DIR / "app" / "static" / "common-admin.js",
).hexdigest() ROOT_DIR / "app" / "static" / "new.js",
]:
dat += j.read_bytes()
JS_HASH = hashlib.md5(dat, usedforsecurity=False).hexdigest()
except FileNotFoundError: except FileNotFoundError:
pass pass
@ -120,6 +123,8 @@ class Config(pydantic.BaseModel):
session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default 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 # Only set when the app is served on a non-root path
id: str | None = None id: str | None = None

View File

@ -146,9 +146,10 @@ _StreamVisibilityCallback = Callable[[ObjectInfo], bool]
def default_stream_visibility_callback(object_info: ObjectInfo) -> bool: def default_stream_visibility_callback(object_info: ObjectInfo) -> bool:
logger.info(f"{object_info=}") result = (
return (
(not object_info.is_reply and object_info.is_from_following) (not object_info.is_reply and object_info.is_from_following)
or object_info.is_mention or object_info.is_mention
or object_info.is_local_reply or object_info.is_local_reply
) )
logger.info(f"{object_info=}/{result=}")
return result

View File

@ -23,12 +23,12 @@ from sqlalchemy import select
from app import activitypub as ap from app import activitypub as ap
from app import config from app import config
from app.config import BLOCKED_SERVERS
from app.config import KEY_PATH from app.config import KEY_PATH
from app.database import AsyncSession from app.database import AsyncSession
from app.database import get_db_session from app.database import get_db_session
from app.key import Key from app.key import Key
from app.utils.datetime import now from app.utils.datetime import now
from app.utils.url import is_hostname_blocked
_KEY_CACHE: MutableMapping[str, Key] = LFUCache(256) _KEY_CACHE: MutableMapping[str, Key] = LFUCache(256)
@ -184,7 +184,7 @@ async def httpsig_checker(
) )
server = urlparse(key_id).hostname server = urlparse(key_id).hostname
if server in BLOCKED_SERVERS: if is_hostname_blocked(server):
return HTTPSigInfo( return HTTPSigInfo(
has_valid_signature=False, has_valid_signature=False,
server=server, server=server,

View File

@ -296,7 +296,7 @@ async def index(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC, models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False), models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_hidden_from_homepage.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) q = select(models.OutboxObject).where(*where)
total_count = await db_session.scalar( total_count = await db_session.scalar(
@ -1179,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( 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: ) -> httpx.Response:
# Request the URL (and filter request headers) # Request the URL (and filter request headers)
proxy_req = proxy_client.build_request( proxy_req = proxy_client.build_request(
@ -1234,18 +1230,29 @@ async def serve_proxy_media(
exp: int, exp: int,
sig: str, sig: str,
encoded_url: str, encoded_url: str,
background_tasks: fastapi.BackgroundTasks,
) -> 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) 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: if proxy_resp.status_code >= 300:
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}") logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
await proxy_resp.aclose()
return PlainTextResponse( return PlainTextResponse(
"proxy error",
status_code=proxy_resp.status_code, status_code=proxy_resp.status_code,
) )
@ -1278,6 +1285,7 @@ async def serve_proxy_media_resized(
sig: str, sig: str,
encoded_url: str, encoded_url: str,
size: int, size: int,
background_tasks: fastapi.BackgroundTasks,
) -> PlainTextResponse: ) -> PlainTextResponse:
if size not in {50, 740}: if size not in {50, 740}:
raise ValueError("Unsupported size") raise ValueError("Unsupported size")
@ -1295,11 +1303,21 @@ async def serve_proxy_media_resized(
headers=resp_headers, 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: if proxy_resp.status_code >= 300:
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}") logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
await proxy_resp.aclose()
return PlainTextResponse( return PlainTextResponse(
"proxy error",
status_code=proxy_resp.status_code, status_code=proxy_resp.status_code,
) )

View File

@ -51,17 +51,20 @@ $code-highlight-background: #f0f0f0;
.p-summary { .p-summary {
display: inline-block; display: inline-block;
} }
label { .show-more-btn {
margin-left: 5px; margin-left: 5px;
} }
.show-more-state { summary {
display: none; display: inline-block;
} }
.show-more-state ~ .obj-content { summary::-webkit-details-marker {
margin-top: 0; display: none
} }
.show-more-state:checked ~ .obj-content { &:not([open]) .show-more-btn::after {
display: none; content: 'show more';
}
&[open] .show-more-btn::after {
content: 'show less';
} }
} }
.sensitive-attachment { .sensitive-attachment {
@ -548,3 +551,22 @@ a.label-btn {
.margin-top-20 { .margin-top-20 {
margin-top: 20px; 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
View 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);
});
}
}

View File

@ -26,24 +26,30 @@
<div class="h-feed"> <div class="h-feed">
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data> <data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
{% for outbox_object in objects %} {% 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) }} {{ utils.display_object(outbox_object) }}
{% elif outbox_object.ap_type == "Announce" %} {% 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> <div class="h-entry" id="{{ outbox_object.permalink_id }}">
{{ utils.display_object(outbox_object.relates_to_anybox_object) }} <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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
<div class="box"> {% if has_previous_page or has_next_page %}
{% if has_previous_page %} <div class="box">
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a> {% if has_previous_page %}
{% endif %} <a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
{% endif %}
{% if has_next_page %} {% if has_next_page %}
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a> <a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">

View File

@ -55,5 +55,6 @@
{% if is_admin %} {% if is_admin %}
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script> <script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script>
{% endif %} {% endif %}
<script src="{{ BASE_URL }}/static/common.js?v={{ JS_HASH }}"></script>
</body> </body>
</html> </html>

View File

@ -247,7 +247,7 @@
{% macro display_tiny_actor_icon(actor) %} {% macro display_tiny_actor_icon(actor) %}
{% block display_tiny_actor_icon scoped %} {% 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 %} {% endblock %}
{% endmacro %} {% endmacro %}
@ -425,13 +425,16 @@
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %} {% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
{% if attachment.url not in object.inlined_images %} {% if attachment.url not in object.inlined_images %}
<a class="media-link" href="{{ attachment.proxied_url }}" target="_blank"> <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> </a>
{% endif %} {% endif %}
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %} {% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video> <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")) %} {% 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" %} {% 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 | truncate(64, True) }}</a> ({{ attachment.mimetype}})
{% else %} {% else %}
@ -467,7 +470,7 @@
</div> </div>
<p class="in-reply-to">in reply to <a href="{{ wm_reply.in_reply_to }}" title="{{ wm_reply.in_reply_to }}" rel="nofollow"> <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> </a></p>
<div class="obj-content margin-top-20"> <div class="obj-content margin-top-20">
@ -514,7 +517,7 @@
{% 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"> <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> </a></p>
{% endif %} {% endif %}
@ -549,12 +552,13 @@
{% endif %} {% endif %}
{% if object.summary %} {% if object.summary %}
<div class="show-more-wrapper"> <details class="show-more-wrapper">
<div class="p-summary"> <summary>
<p>{{ object.summary | clean_html(object) | safe }}</p> <div class="p-summary">
</div> <p>{{ object.summary | clean_html(object) | safe }}</p>
<label for="show-more-{{ object.permalink_id }}" class="show-more-btn">show/hide more</label> </div>
<input class="show-more-state" type="checkbox" aria-hidden="true" id="show-more-{{ object.permalink_id }}" checked> <span class="show-more-btn" aria-hidden="true"></span>
</summary>
{% endif %} {% endif %}
<div class="obj-content"> <div class="obj-content">
<div class="e-content"> <div class="e-content">
@ -615,7 +619,7 @@
</div> </div>
{% if object.summary %} {% if object.summary %}
</div> </details>
{% endif %} {% endif %}
<div class="activity-attachment"> <div class="activity-attachment">
@ -750,7 +754,7 @@
{{ admin_expand_button(object) }} {{ admin_expand_button(object) }}
</li> </li>
{% endif %} {% 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> <li>
{{ admin_force_delete_button(object.ap_id) }} {{ admin_force_delete_button(object.ap_id) }}
</li> </li>

View File

@ -54,7 +54,7 @@ def is_url_valid(url: str) -> bool:
if not parsed.hostname or parsed.hostname.lower() in ["localhost"]: if not parsed.hostname or parsed.hostname.lower() in ["localhost"]:
return False return False
if parsed.hostname in BLOCKED_SERVERS: if is_hostname_blocked(parsed.hostname):
logger.warning(f"{parsed.hostname} is blocked") logger.warning(f"{parsed.hostname} is blocked")
return False return False
@ -81,3 +81,11 @@ def check_url(url: str) -> None:
raise InvalidURLError(f'"{url}" is invalid') raise InvalidURLError(f'"{url}" is invalid')
return None 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

View File

@ -24,7 +24,7 @@ async def _discover_webmention_endoint(url: str) -> str | None:
follow_redirects=True, follow_redirects=True,
) )
resp.raise_for_status() resp.raise_for_status()
except (httpx.HTTPError, httpx.HTTPStatusError): except Exception:
logger.exception(f"Failed to discover webmention endpoint for {url}") logger.exception(f"Failed to discover webmention endpoint for {url}")
return None return None

View File

@ -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_replies_count
from app.boxes import get_outbox_object_by_ap_id 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 get_outbox_object_by_slug_and_short_id
from app.boxes import is_notification_enabled
from app.database import AsyncSession from app.database import AsyncSession
from app.database import get_db_session from app.database import get_db_session
from app.utils import microformats from app.utils import microformats
@ -118,12 +119,13 @@ async def webmention_endpoint(
db_session, existing_webmention_in_db, mentioned_object db_session, existing_webmention_in_db, mentioned_object
) )
notif = models.Notification( if is_notification_enabled(models.NotificationType.DELETED_WEBMENTION):
notification_type=models.NotificationType.DELETED_WEBMENTION, notif = models.Notification(
outbox_object_id=mentioned_object.id, notification_type=models.NotificationType.DELETED_WEBMENTION,
webmention_id=existing_webmention_in_db.id, outbox_object_id=mentioned_object.id,
) webmention_id=existing_webmention_in_db.id,
db_session.add(notif) )
db_session.add(notif)
await db_session.commit() await db_session.commit()
@ -144,12 +146,13 @@ async def webmention_endpoint(
await db_session.flush() await db_session.flush()
webmention = existing_webmention_in_db webmention = existing_webmention_in_db
notif = models.Notification( if is_notification_enabled(models.NotificationType.UPDATED_WEBMENTION):
notification_type=models.NotificationType.UPDATED_WEBMENTION, notif = models.Notification(
outbox_object_id=mentioned_object.id, notification_type=models.NotificationType.UPDATED_WEBMENTION,
webmention_id=existing_webmention_in_db.id, outbox_object_id=mentioned_object.id,
) webmention_id=existing_webmention_in_db.id,
db_session.add(notif) )
db_session.add(notif)
else: else:
new_webmention = models.Webmention( new_webmention = models.Webmention(
source=source, source=source,
@ -162,12 +165,13 @@ async def webmention_endpoint(
await db_session.flush() await db_session.flush()
webmention = new_webmention webmention = new_webmention
notif = models.Notification( if is_notification_enabled(models.NotificationType.NEW_WEBMENTION):
notification_type=models.NotificationType.NEW_WEBMENTION, notif = models.Notification(
outbox_object_id=mentioned_object.id, notification_type=models.NotificationType.NEW_WEBMENTION,
webmention_id=new_webmention.id, outbox_object_id=mentioned_object.id,
) webmention_id=new_webmention.id,
db_session.add(notif) )
db_session.add(notif)
# Determine the webmention type # Determine the webmention type
for item in data.get("items", []): for item in data.get("items", []):

View File

@ -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 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>. [YunoHost](https://yunohost.org/) support is available (although it is not an official package for now): <https://git.sr.ht/~tsileo/microblog.pub_ynh>.

View File

@ -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 ### Customization
#### Default emoji #### Default emoji

19
tests/test_utils.py Normal file
View 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