microblog/app/main.py

1182 lines
37 KiB
Python
Raw Normal View History

2022-06-22 18:11:22 +00:00
import base64
import os
import sys
import time
2022-06-27 06:30:29 +00:00
from datetime import timezone
2022-06-23 19:07:20 +00:00
from io import BytesIO
2022-06-22 18:11:22 +00:00
from typing import Any
from typing import MutableMapping
2022-06-22 18:11:22 +00:00
from typing import Type
import httpx
2022-07-19 06:12:49 +00:00
import starlette
2022-07-14 10:13:23 +00:00
from asgiref.typing import ASGI3Application
from asgiref.typing import ASGIReceiveCallable
from asgiref.typing import ASGISendCallable
from asgiref.typing import Scope
2022-06-30 07:25:13 +00:00
from cachetools import LFUCache
2022-06-22 18:11:22 +00:00
from fastapi import Depends
from fastapi import FastAPI
2022-06-26 08:28:21 +00:00
from fastapi import Form
2022-06-22 18:11:22 +00:00
from fastapi import Request
from fastapi import Response
from fastapi.exceptions import HTTPException
2022-06-23 19:07:20 +00:00
from fastapi.responses import FileResponse
2022-06-22 18:11:22 +00:00
from fastapi.responses import PlainTextResponse
2022-06-26 08:28:21 +00:00
from fastapi.responses import RedirectResponse
2022-06-22 18:11:22 +00:00
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
2022-06-27 06:30:29 +00:00
from feedgen.feed import FeedGenerator # type: ignore
2022-06-22 18:11:22 +00:00
from loguru import logger
2022-06-23 19:07:20 +00:00
from PIL import Image
2022-06-29 06:56:39 +00:00
from sqlalchemy import func
from sqlalchemy import select
2022-06-22 18:11:22 +00:00
from sqlalchemy.orm import joinedload
from starlette.background import BackgroundTask
2022-07-15 11:05:08 +00:00
from starlette.datastructures import Headers
2022-07-14 10:13:23 +00:00
from starlette.datastructures import MutableHeaders
2022-06-22 18:11:22 +00:00
from starlette.responses import JSONResponse
2022-07-14 10:13:23 +00:00
from starlette.types import Message
2022-07-15 11:05:08 +00:00
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # type: ignore
2022-06-22 18:11:22 +00:00
from app import activitypub as ap
from app import admin
2022-06-24 20:41:43 +00:00
from app import boxes
2022-06-22 18:11:22 +00:00
from app import config
from app import httpsig
2022-07-10 09:04:28 +00:00
from app import indieauth
from app import micropub
2022-06-22 18:11:22 +00:00
from app import models
from app import templates
2022-07-10 17:19:55 +00:00
from app import webmentions
2022-06-22 18:11:22 +00:00
from app.actor import LOCAL_ACTOR
from app.actor import get_actors_metadata
from app.boxes import public_outbox_objects_count
from app.config import BASE_URL
from app.config import DEBUG
from app.config import DOMAIN
from app.config import ID
from app.config import USER_AGENT
from app.config import USERNAME
from app.config import is_activitypub_requested
2022-06-26 08:28:21 +00:00
from app.config import verify_csrf_token
2022-06-29 18:43:17 +00:00
from app.database import AsyncSession
from app.database import get_db_session
2022-07-14 06:44:04 +00:00
from app.incoming_activities import new_ap_incoming_activity
2022-06-22 18:11:22 +00:00
from app.templates import is_current_user_admin
2022-06-23 19:07:20 +00:00
from app.uploads import UPLOAD_DIR
2022-06-28 18:10:25 +00:00
from app.utils import pagination
2022-06-27 18:55:44 +00:00
from app.utils.emoji import EMOJIS_BY_NAME
2022-07-15 18:50:27 +00:00
from app.utils.url import check_url
2022-06-26 08:28:21 +00:00
from app.webfinger import get_remote_follow_template
2022-06-22 18:11:22 +00:00
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(32)
2022-06-30 07:25:13 +00:00
2022-06-22 18:11:22 +00:00
# TODO(ts):
#
# Next:
2022-08-15 08:50:13 +00:00
# - support Move
# - support actor delete
2022-08-13 20:37:44 +00:00
# - allow to share old notes
# - allow to interact with object not in anybox (i.e. like from a lookup)
2022-08-11 21:10:24 +00:00
# - only show 10 most recent threads in DMs
# - custom CSS for disabled button (e.g. sharing on a direct post)
2022-07-23 17:02:06 +00:00
# - prevent double accept/double follow
2022-07-20 17:57:03 +00:00
# - UI support for updating posts
2022-07-11 18:53:59 +00:00
# - indieauth tweaks
2022-07-09 07:59:25 +00:00
# - API for posting notes
# - allow to block servers
2022-07-09 06:15:33 +00:00
# - FT5 text search
2022-07-15 18:50:27 +00:00
# - support update post with history?
2022-07-20 17:57:03 +00:00
# - cleanup tasks
2022-06-22 18:11:22 +00:00
2022-07-14 10:13:23 +00:00
class CustomMiddleware:
2022-07-15 18:50:27 +00:00
"""Raw ASGI middleware as using starlette base middleware causes issues
with both:
- Jinja2: https://github.com/encode/starlette/issues/472
- async SQLAchemy: https://github.com/tiangolo/fastapi/issues/4719
"""
2022-07-14 10:13:23 +00:00
def __init__(
self,
2022-07-14 13:16:45 +00:00
app: ASGI3Application,
2022-07-14 10:13:23 +00:00
) -> None:
self.app = app
async def __call__(
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
) -> None:
2022-07-14 13:16:45 +00:00
# We only care about HTTP requests
2022-07-14 10:13:23 +00:00
if scope["type"] != "http":
await self.app(scope, receive, send)
return
2022-07-14 17:05:45 +00:00
response_details = {"status_code": None}
2022-07-14 10:13:23 +00:00
start_time = time.perf_counter()
request_id = os.urandom(8).hex()
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
2022-07-14 13:16:45 +00:00
# Extract the HTTP response status code
response_details["status_code"] = message["status"]
# And add the security headers
2022-07-14 10:13:23 +00:00
headers = MutableHeaders(scope=message)
headers["X-Request-ID"] = request_id
headers["Server"] = "microblogpub"
headers[
"referrer-policy"
] = "no-referrer, strict-origin-when-cross-origin"
headers["x-content-type-options"] = "nosniff"
headers["x-xss-protection"] = "1; mode=block"
headers["x-frame-options"] = "SAMEORIGIN"
# TODO(ts): disallow inline CSS?
2022-07-15 18:16:02 +00:00
headers[
"content-security-policy"
2022-07-16 06:21:15 +00:00
] = "default-src 'self'; style-src 'self' 'unsafe-inline';"
2022-07-14 10:13:23 +00:00
if not DEBUG:
headers[
"strict-transport-security"
] = "max-age=63072000; includeSubdomains"
await send(message) # type: ignore
2022-07-14 13:16:45 +00:00
# Make loguru ouput the request ID on every log statement within
# the request
2022-07-14 10:13:23 +00:00
with logger.contextualize(request_id=request_id):
client_host, client_port = scope["client"] # type: ignore
scheme = scope["scheme"]
server_host, server_port = scope["server"] # type: ignore
request_method = scope["method"]
request_path = scope["path"]
2022-07-15 11:05:08 +00:00
headers = Headers(raw=scope["headers"]) # type: ignore
user_agent = headers.get("user-agent")
2022-07-14 10:13:23 +00:00
logger.info(
f"{client_host}:{client_port} - "
2022-07-15 11:05:08 +00:00
f"{request_method} "
f"{scheme}://{server_host}:{server_port}{request_path} - "
f'"{user_agent}"'
2022-07-14 10:13:23 +00:00
)
try:
await self.app(scope, receive, send_wrapper) # type: ignore
finally:
elapsed_time = time.perf_counter() - start_time
logger.info(
2022-07-14 13:16:45 +00:00
f"status_code={response_details['status_code']} "
2022-07-14 10:13:23 +00:00
f"{elapsed_time=:.2f}s"
)
return None
2022-06-22 18:11:22 +00:00
app = FastAPI(docs_url=None, redoc_url=None)
2022-07-16 05:48:24 +00:00
app.mount(
2022-07-29 13:12:48 +00:00
"/custom_emoji",
2022-07-16 05:48:24 +00:00
StaticFiles(directory="data/custom_emoji"),
2022-07-29 13:12:48 +00:00
name="custom_emoji",
2022-07-16 05:48:24 +00:00
)
2022-06-22 18:11:22 +00:00
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(admin.router, prefix="/admin")
app.include_router(admin.unauthenticated_router, prefix="/admin")
2022-07-10 09:04:28 +00:00
app.include_router(indieauth.router)
app.include_router(micropub.router)
2022-07-10 17:19:55 +00:00
app.include_router(webmentions.router)
2022-07-30 06:46:29 +00:00
# XXX: order matters, the proxy middleware needs to be last
2022-07-14 10:13:23 +00:00
app.add_middleware(CustomMiddleware)
2022-07-30 06:46:29 +00:00
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=config.CONFIG.trusted_hosts)
2022-06-22 18:11:22 +00:00
logger.configure(extra={"request_id": "no_req_id"})
logger.remove()
logger_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"{extra[request_id]} - <level>{message}</level>"
)
logger.add(sys.stdout, format=logger_format)
class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json"
@app.get("/")
2022-06-29 18:43:17 +00:00
async def index(
2022-06-22 18:11:22 +00:00
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
page: int | None = None,
2022-06-22 18:11:22 +00:00
) -> templates.TemplateResponse | ActivityPubResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
page = page or 1
2022-06-29 06:56:39 +00:00
where = (
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_hidden_from_homepage.is_(False),
2022-07-25 20:51:53 +00:00
models.OutboxObject.ap_type != "Article",
)
2022-06-29 06:56:39 +00:00
q = select(models.OutboxObject).where(*where)
2022-06-29 18:43:17 +00:00
total_count = await db_session.scalar(
select(func.count(models.OutboxObject.id)).where(*where)
)
2022-06-28 18:10:25 +00:00
page_size = 20
page_offset = (page - 1) * page_size
2022-06-29 18:43:17 +00:00
outbox_objects_result = await db_session.scalars(
q.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
2022-07-03 20:42:14 +00:00
),
joinedload(models.OutboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor),
),
joinedload(models.OutboxObject.relates_to_outbox_object).options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
),
),
2022-06-23 19:07:20 +00:00
)
2022-06-29 18:43:17 +00:00
.order_by(models.OutboxObject.ap_published_at.desc())
.offset(page_offset)
.limit(page_size)
2022-06-22 18:11:22 +00:00
)
2022-06-29 18:43:17 +00:00
outbox_objects = outbox_objects_result.unique().all()
2022-06-22 18:11:22 +00:00
2022-06-29 18:43:17 +00:00
return await templates.render_template(
db_session,
2022-06-22 18:11:22 +00:00
request,
"index.html",
{
"request": request,
"objects": outbox_objects,
"current_page": page,
"has_next_page": page_offset + len(outbox_objects) < total_count,
"has_previous_page": page > 1,
},
2022-06-22 18:11:22 +00:00
)
2022-07-25 20:51:53 +00:00
@app.get("/articles")
async def articles(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
page: int | None = None,
) -> templates.TemplateResponse | ActivityPubResponse:
# TODO: special ActivityPub collection for Article
where = (
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",
)
q = select(models.OutboxObject).where(*where)
outbox_objects_result = await db_session.scalars(
q.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
),
joinedload(models.OutboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor),
),
joinedload(models.OutboxObject.relates_to_outbox_object).options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
),
),
).order_by(models.OutboxObject.ap_published_at.desc())
)
outbox_objects = outbox_objects_result.unique().all()
return await templates.render_template(
db_session,
request,
"articles.html",
{
"request": request,
"objects": outbox_objects,
},
)
2022-06-29 18:43:17 +00:00
async def _build_followx_collection(
db_session: AsyncSession,
2022-06-22 18:11:22 +00:00
model_cls: Type[models.Following | models.Follower],
path: str,
page: bool | None,
next_cursor: str | None,
) -> ap.RawObject:
2022-06-29 18:43:17 +00:00
total_items = await db_session.scalar(select(func.count(model_cls.id)))
2022-06-22 18:11:22 +00:00
if not page and not next_cursor:
return {
"@context": ap.AS_CTX,
"id": ID + path,
"first": ID + path + "?page=true",
"type": "OrderedCollection",
"totalItems": total_items,
}
2022-06-29 06:56:39 +00:00
q = select(model_cls).order_by(model_cls.created_at.desc()) # type: ignore
2022-06-22 18:11:22 +00:00
if next_cursor:
2022-06-29 06:56:39 +00:00
q = q.where(
2022-06-28 18:10:25 +00:00
model_cls.created_at < pagination.decode_cursor(next_cursor) # type: ignore
)
2022-06-22 18:11:22 +00:00
q = q.limit(20)
2022-06-29 18:43:17 +00:00
items = [followx for followx in (await db_session.scalars(q)).all()]
2022-06-22 18:11:22 +00:00
next_cursor = None
if (
items
2022-06-29 18:43:17 +00:00
and await db_session.scalar(
2022-06-29 06:56:39 +00:00
select(func.count(model_cls.id)).where(
model_cls.created_at < items[-1].created_at
)
)
2022-06-22 18:11:22 +00:00
> 0
):
2022-06-28 18:10:25 +00:00
next_cursor = pagination.encode_cursor(items[-1].created_at)
2022-06-22 18:11:22 +00:00
collection_page = {
"@context": ap.AS_CTX,
"id": (
ID + path + "?page=true"
if not next_cursor
else ID + path + f"?next_cursor={next_cursor}"
),
"partOf": ID + path,
"type": "OrderedCollectionPage",
"orderedItems": [item.ap_actor_id for item in items],
}
if next_cursor:
collection_page["next"] = ID + path + f"?next_cursor={next_cursor}"
return collection_page
@app.get("/followers")
2022-06-29 18:43:17 +00:00
async def followers(
2022-06-22 18:11:22 +00:00
request: Request,
page: bool | None = None,
next_cursor: str | None = None,
prev_cursor: str | None = None,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(
2022-06-29 18:43:17 +00:00
await _build_followx_collection(
db_session=db_session,
2022-06-22 18:11:22 +00:00
model_cls=models.Follower,
path="/followers",
page=page,
next_cursor=next_cursor,
)
)
2022-06-28 18:10:25 +00:00
# We only show the most recent 20 followers on the public website
2022-06-29 18:43:17 +00:00
followers_result = await db_session.scalars(
select(models.Follower)
.options(joinedload(models.Follower.actor))
.order_by(models.Follower.created_at.desc())
.limit(20)
2022-06-22 18:11:22 +00:00
)
2022-06-29 18:43:17 +00:00
followers = followers_result.unique().all()
2022-06-22 18:11:22 +00:00
actors_metadata = {}
if is_current_user_admin(request):
2022-06-29 18:43:17 +00:00
actors_metadata = await get_actors_metadata(
db_session,
2022-06-22 18:11:22 +00:00
[f.actor for f in followers],
)
2022-06-29 18:43:17 +00:00
return await templates.render_template(
db_session,
2022-06-22 18:11:22 +00:00
request,
"followers.html",
{
"followers": followers,
"actors_metadata": actors_metadata,
},
)
@app.get("/following")
2022-06-29 18:43:17 +00:00
async def following(
2022-06-22 18:11:22 +00:00
request: Request,
page: bool | None = None,
next_cursor: str | None = None,
prev_cursor: str | None = None,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(
2022-06-29 18:43:17 +00:00
await _build_followx_collection(
db_session=db_session,
2022-06-22 18:11:22 +00:00
model_cls=models.Following,
path="/following",
page=page,
next_cursor=next_cursor,
)
)
2022-06-28 18:10:25 +00:00
# We only show the most recent 20 follows on the public website
2022-06-29 06:56:39 +00:00
following = (
2022-06-29 18:43:17 +00:00
(
await db_session.scalars(
select(models.Following)
.options(joinedload(models.Following.actor))
.order_by(models.Following.created_at.desc())
)
2022-06-29 06:56:39 +00:00
)
.unique()
.all()
2022-06-22 18:11:22 +00:00
)
actors_metadata = {}
if is_current_user_admin(request):
2022-06-29 18:43:17 +00:00
actors_metadata = await get_actors_metadata(
db_session,
2022-06-22 18:11:22 +00:00
[f.actor for f in following],
)
2022-06-29 18:43:17 +00:00
return await templates.render_template(
db_session,
2022-06-22 18:11:22 +00:00
request,
"following.html",
{
"following": following,
"actors_metadata": actors_metadata,
},
)
@app.get("/outbox")
2022-06-29 18:43:17 +00:00
async def outbox(
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse:
2022-06-28 18:10:25 +00:00
# By design, we only show the last 20 public activities in the oubox
2022-06-29 18:43:17 +00:00
outbox_objects = (
await db_session.scalars(
select(models.OutboxObject)
.where(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
2022-08-13 20:37:44 +00:00
models.OutboxObject.ap_type.in_(["Create", "Announce"]),
2022-06-29 18:43:17 +00:00
)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(20)
2022-06-22 18:11:22 +00:00
)
2022-06-29 06:56:39 +00:00
).all()
2022-06-22 18:11:22 +00:00
return ActivityPubResponse(
{
2022-06-28 07:58:33 +00:00
"@context": ap.AS_EXTENDED_CTX,
2022-06-22 18:11:22 +00:00
"id": f"{ID}/outbox",
"type": "OrderedCollection",
"totalItems": len(outbox_objects),
"orderedItems": [
ap.remove_context(ap.wrap_object_if_needed(a.ap_object))
for a in outbox_objects
],
}
)
@app.get("/featured")
2022-06-29 18:43:17 +00:00
async def featured(
db_session: AsyncSession = Depends(get_db_session),
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse:
2022-06-29 18:43:17 +00:00
outbox_objects = (
await db_session.scalars(
select(models.OutboxObject)
.filter(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.is_pinned.is_(True),
)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(5)
)
2022-06-29 06:56:39 +00:00
).all()
return ActivityPubResponse(
{
2022-06-28 07:58:33 +00:00
"@context": ap.AS_EXTENDED_CTX,
"id": f"{ID}/featured",
"type": "OrderedCollection",
"totalItems": len(outbox_objects),
"orderedItems": [ap.remove_context(a.ap_object) for a in outbox_objects],
}
)
2022-06-29 18:43:17 +00:00
async def _check_outbox_object_acl(
2022-06-26 17:00:29 +00:00
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession,
2022-06-26 17:00:29 +00:00
ap_object: models.OutboxObject,
httpsig_info: httpsig.HTTPSigInfo,
) -> None:
2022-06-26 17:00:29 +00:00
if templates.is_current_user_admin(request):
return None
if ap_object.visibility in [
ap.VisibilityEnum.PUBLIC,
ap.VisibilityEnum.UNLISTED,
]:
return None
2022-07-19 06:12:49 +00:00
elif ap_object.visibility == ap.VisibilityEnum.FOLLOWERS_ONLY:
2022-07-19 06:12:49 +00:00
# Is the signing actor a follower?
2022-06-29 18:43:17 +00:00
followers = await boxes.fetch_actor_collection(
db_session, BASE_URL + "/followers"
)
if httpsig_info.signed_by_ap_actor_id in [actor.ap_id for actor in followers]:
return None
2022-07-19 06:12:49 +00:00
elif ap_object.visibility == ap.VisibilityEnum.DIRECT:
2022-07-19 06:12:49 +00:00
# Is the signing actor targeted in the object audience?
audience = ap_object.ap_object.get("to", []) + ap_object.ap_object.get("cc", [])
if httpsig_info.signed_by_ap_actor_id in audience:
return None
raise HTTPException(status_code=404)
2022-06-24 21:09:37 +00:00
@app.get("/o/{public_id}")
2022-06-29 18:43:17 +00:00
async def outbox_by_public_id(
2022-06-24 21:09:37 +00:00
public_id: str,
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
2022-06-24 21:09:37 +00:00
) -> ActivityPubResponse | templates.TemplateResponse:
maybe_object = (
2022-06-29 18:43:17 +00:00
(
await db_session.execute(
select(models.OutboxObject)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.where(
models.OutboxObject.public_id == public_id,
models.OutboxObject.is_deleted.is_(False),
2022-06-29 06:56:39 +00:00
)
2022-06-24 21:09:37 +00:00
)
)
2022-06-29 06:56:39 +00:00
.unique()
.scalar_one_or_none()
2022-06-24 21:09:37 +00:00
)
if not maybe_object:
raise HTTPException(status_code=404)
2022-06-29 18:43:17 +00:00
await _check_outbox_object_acl(request, db_session, maybe_object, httpsig_info)
2022-06-24 21:09:37 +00:00
if is_activitypub_requested(request):
return ActivityPubResponse(maybe_object.ap_object)
2022-06-29 18:43:17 +00:00
replies_tree = await boxes.get_replies_tree(db_session, maybe_object)
2022-06-24 20:41:43 +00:00
2022-06-28 21:47:51 +00:00
likes = (
2022-06-29 18:43:17 +00:00
(
await db_session.scalars(
select(models.InboxObject)
.where(
models.InboxObject.ap_type == "Like",
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
2022-07-07 19:18:20 +00:00
models.InboxObject.is_deleted.is_(False),
2022-06-29 18:43:17 +00:00
)
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
2022-06-29 06:56:39 +00:00
)
2022-06-28 21:47:51 +00:00
)
2022-06-29 06:56:39 +00:00
.unique()
.all()
2022-06-28 21:47:51 +00:00
)
shares = (
2022-06-29 18:43:17 +00:00
(
await db_session.scalars(
select(models.InboxObject)
.filter(
models.InboxObject.ap_type == "Announce",
models.InboxObject.activity_object_ap_id == maybe_object.ap_id,
2022-07-07 19:18:20 +00:00
models.InboxObject.is_deleted.is_(False),
2022-06-29 18:43:17 +00:00
)
.options(joinedload(models.InboxObject.actor))
.order_by(models.InboxObject.ap_published_at.desc())
.limit(10)
2022-06-29 06:56:39 +00:00
)
2022-06-28 21:47:51 +00:00
)
2022-06-29 06:56:39 +00:00
.unique()
.all()
2022-06-28 21:47:51 +00:00
)
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()
2022-06-29 18:43:17 +00:00
return await templates.render_template(
db_session,
2022-06-22 18:11:22 +00:00
request,
"object.html",
{
2022-06-24 21:09:37 +00:00
"replies_tree": replies_tree,
2022-06-22 18:11:22 +00:00
"outbox_object": maybe_object,
2022-06-28 21:47:51 +00:00
"likes": likes,
"shares": shares,
"webmentions": webmentions,
2022-06-22 18:11:22 +00:00
},
)
@app.get("/o/{public_id}/activity")
2022-06-29 18:43:17 +00:00
async def outbox_activity_by_public_id(
2022-06-22 18:11:22 +00:00
public_id: str,
2022-06-26 17:00:29 +00:00
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
2022-06-22 18:11:22 +00:00
) -> ActivityPubResponse:
2022-06-29 18:43:17 +00:00
maybe_object = (
await db_session.execute(
select(models.OutboxObject).where(
models.OutboxObject.public_id == public_id,
models.OutboxObject.is_deleted.is_(False),
)
2022-06-24 20:41:43 +00:00
)
2022-06-29 06:56:39 +00:00
).scalar_one_or_none()
2022-06-22 18:11:22 +00:00
if not maybe_object:
raise HTTPException(status_code=404)
2022-06-29 18:43:17 +00:00
await _check_outbox_object_acl(request, db_session, maybe_object, httpsig_info)
2022-06-22 18:11:22 +00:00
return ActivityPubResponse(ap.wrap_object(maybe_object.ap_object))
@app.get("/t/{tag}")
2022-06-29 18:43:17 +00:00
async def tag_by_name(
2022-06-22 18:11:22 +00:00
tag: str,
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
2022-07-01 17:35:34 +00:00
where = [
models.TaggedOutboxObject.tag == tag,
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
]
tagged_count = await db_session.scalar(
select(func.count(models.OutboxObject.id))
.join(models.TaggedOutboxObject)
.where(*where)
)
if not tagged_count:
raise HTTPException(status_code=404)
2022-07-03 17:17:19 +00:00
if is_activitypub_requested(request):
outbox_object_ids = await db_session.execute(
select(models.OutboxObject.ap_id)
.join(
models.TaggedOutboxObject,
models.TaggedOutboxObject.outbox_object_id == models.OutboxObject.id,
)
.where(*where)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(20)
)
return ActivityPubResponse(
{
"@context": ap.AS_CTX,
"id": BASE_URL + f"/t/{tag}",
"type": "OrderedCollection",
"totalItems": tagged_count,
"orderedItems": [
outbox_object.ap_id for outbox_object in outbox_object_ids
],
}
)
outbox_objects_result = await db_session.scalars(
select(models.OutboxObject)
2022-07-01 17:35:34 +00:00
.where(*where)
2022-07-03 17:17:19 +00:00
.join(
models.TaggedOutboxObject,
models.TaggedOutboxObject.outbox_object_id == models.OutboxObject.id,
)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
2022-07-01 17:35:34 +00:00
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(20)
)
2022-07-03 17:17:19 +00:00
outbox_objects = outbox_objects_result.unique().all()
return await templates.render_template(
db_session,
request,
"index.html",
2022-06-22 18:11:22 +00:00
{
2022-07-03 17:17:19 +00:00
"request": request,
"objects": outbox_objects,
},
2022-06-22 18:11:22 +00:00
)
2022-06-27 18:55:44 +00:00
@app.get("/e/{name}")
def emoji_by_name(name: str) -> ActivityPubResponse:
try:
emoji = EMOJIS_BY_NAME[f":{name}:"]
except KeyError:
raise HTTPException(status_code=404)
2022-06-28 07:58:33 +00:00
return ActivityPubResponse({"@context": ap.AS_EXTENDED_CTX, **emoji})
2022-06-27 18:55:44 +00:00
2022-06-22 18:11:22 +00:00
@app.post("/inbox")
async def inbox(
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
httpsig_info: httpsig.HTTPSigInfo = Depends(httpsig.enforce_httpsig),
) -> Response:
logger.info(f"headers={request.headers}")
payload = await request.json()
logger.info(f"{payload=}")
2022-07-14 06:44:04 +00:00
await new_ap_incoming_activity(db_session, httpsig_info, payload)
return Response(status_code=202)
2022-06-22 18:11:22 +00:00
2022-06-26 08:28:21 +00:00
@app.get("/remote_follow")
2022-06-29 18:43:17 +00:00
async def get_remote_follow(
2022-06-26 08:28:21 +00:00
request: Request,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-26 08:28:21 +00:00
) -> templates.TemplateResponse:
2022-06-29 18:43:17 +00:00
return await templates.render_template(
db_session,
2022-06-26 08:28:21 +00:00
request,
"remote_follow.html",
2022-07-19 06:12:49 +00:00
{},
2022-06-26 08:28:21 +00:00
)
@app.post("/remote_follow")
2022-06-29 18:43:17 +00:00
async def post_remote_follow(
2022-06-26 08:28:21 +00:00
request: Request,
csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(),
) -> RedirectResponse:
if not profile.startswith("@"):
profile = f"@{profile}"
2022-06-29 22:28:07 +00:00
remote_follow_template = await get_remote_follow_template(profile)
2022-06-26 08:28:21 +00:00
if not remote_follow_template:
2022-07-19 06:12:49 +00:00
# TODO(ts): error message to user
2022-06-26 08:28:21 +00:00
raise HTTPException(status_code=404)
return RedirectResponse(
remote_follow_template.format(uri=ID),
status_code=302,
)
2022-06-22 18:11:22 +00:00
@app.get("/.well-known/webfinger")
2022-06-29 18:43:17 +00:00
async def wellknown_webfinger(resource: str) -> JSONResponse:
2022-06-22 18:11:22 +00:00
"""Exposes/servers WebFinger data."""
if resource not in [f"acct:{USERNAME}@{DOMAIN}", ID]:
logger.info(f"Got invalid req for {resource}")
2022-06-22 18:11:22 +00:00
raise HTTPException(status_code=404)
out = {
"subject": f"acct:{USERNAME}@{DOMAIN}",
"aliases": [ID],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": ID,
},
{"rel": "self", "type": "application/activity+json", "href": ID},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
2022-06-26 08:28:21 +00:00
"template": BASE_URL + "/admin/lookup?query={uri}",
2022-06-22 18:11:22 +00:00
},
],
}
return JSONResponse(out, media_type="application/jrd+json; charset=utf-8")
@app.get("/.well-known/nodeinfo")
async def well_known_nodeinfo() -> dict[str, Any]:
return {
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
"href": f"{BASE_URL}/nodeinfo",
}
]
}
@app.get("/nodeinfo")
2022-06-29 18:43:17 +00:00
async def nodeinfo(
db_session: AsyncSession = Depends(get_db_session),
2022-06-22 18:11:22 +00:00
):
2022-06-29 18:43:17 +00:00
local_posts = await public_outbox_objects_count(db_session)
2022-06-22 18:11:22 +00:00
return JSONResponse(
{
"version": "2.1",
"software": {
"name": "microblogpub",
"version": config.VERSION,
2022-07-04 20:06:06 +00:00
"repository": "https://sr.ht/~tsileo/microblog.pub",
"homepage": "https://docs.microblog.pub",
2022-06-22 18:11:22 +00:00
},
"protocols": ["activitypub"],
"services": {"inbound": [], "outbound": []},
"openRegistrations": False,
"usage": {"users": {"total": 1}, "localPosts": local_posts},
"metadata": {
"nodeName": LOCAL_ACTOR.handle,
},
},
media_type=(
"application/json; "
"profile=http://nodeinfo.diaspora.software/ns/schema/2.1#"
),
)
2022-07-13 18:05:15 +00:00
proxy_client = httpx.AsyncClient(follow_redirects=True, http2=True)
2022-06-22 18:11:22 +00:00
2022-07-19 06:12:49 +00:00
async def _proxy_get(
request: starlette.requests.Request, url: str, stream: bool
) -> httpx.Response:
2022-06-22 18:11:22 +00:00
# Request the URL (and filter request headers)
proxy_req = proxy_client.build_request(
request.method,
url,
headers=[
(k, v)
for (k, v) in request.headers.raw
if k.lower()
2022-07-30 07:09:18 +00:00
not in [
b"host",
b"cookie",
b"x-forwarded-for",
b"x-forwarded-proto",
b"x-real-ip",
b"user-agent",
]
2022-06-22 18:11:22 +00:00
]
+ [(b"user-agent", USER_AGENT.encode())],
)
2022-07-19 06:12:49 +00:00
return await proxy_client.send(proxy_req, stream=stream)
def _filter_proxy_resp_headers(
proxy_resp: httpx.Response,
allowed_headers: list[str],
) -> dict[str, str]:
return {
k: v for (k, v) in proxy_resp.headers.items() if k.lower() in allowed_headers
}
@app.get("/proxy/media/{encoded_url}")
async def serve_proxy_media(request: Request, encoded_url: str) -> StreamingResponse:
# Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url)
proxy_resp = await _proxy_get(request, url, stream=True)
2022-06-22 18:11:22 +00:00
return StreamingResponse(
proxy_resp.aiter_raw(),
status_code=proxy_resp.status_code,
2022-07-19 06:12:49 +00:00
headers=_filter_proxy_resp_headers(
proxy_resp,
[
"content-length",
"content-type",
"content-range",
"accept-ranges" "etag",
"cache-control",
"expires",
"date",
"last-modified",
],
),
2022-06-22 18:11:22 +00:00
background=BackgroundTask(proxy_resp.aclose),
)
2022-06-23 19:07:20 +00:00
@app.get("/proxy/media/{encoded_url}/{size}")
2022-06-30 07:25:13 +00:00
async def serve_proxy_media_resized(
2022-06-23 19:07:20 +00:00
request: Request,
encoded_url: str,
size: int,
) -> PlainTextResponse:
if size not in {50, 740}:
raise ValueError("Unsupported size")
# Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode()
2022-07-15 18:50:27 +00:00
check_url(url)
2022-06-30 07:25:13 +00:00
if cached_resp := _RESIZED_CACHE.get((url, size)):
resized_content, resized_mimetype, resp_headers = cached_resp
return PlainTextResponse(
resized_content,
media_type=resized_mimetype,
headers=resp_headers,
)
2022-06-30 07:25:13 +00:00
2022-07-19 06:12:49 +00:00
proxy_resp = await _proxy_get(request, url, stream=False)
if proxy_resp.status_code != 200:
2022-06-23 19:07:20 +00:00
return PlainTextResponse(
proxy_resp.content,
status_code=proxy_resp.status_code,
)
# Filter the headers
2022-07-19 06:12:49 +00:00
proxy_resp_headers = _filter_proxy_resp_headers(
proxy_resp,
[
2022-06-23 19:07:20 +00:00
"content-type",
"etag",
"cache-control",
"expires",
"last-modified",
2022-07-19 06:12:49 +00:00
],
)
2022-06-23 19:07:20 +00:00
try:
out = BytesIO(proxy_resp.content)
i = Image.open(out)
2022-06-30 07:25:13 +00:00
if getattr(i, "is_animated", False):
raise ValueError
2022-06-23 19:07:20 +00:00
i.thumbnail((size, size))
resized_buf = BytesIO()
i.save(resized_buf, format=i.format)
resized_buf.seek(0)
2022-06-30 07:25:13 +00:00
resized_content = resized_buf.read()
resized_mimetype = i.get_format_mimetype() # type: ignore
# Only cache images < 1MB
if len(resized_content) < 2**20:
_RESIZED_CACHE[(url, size)] = (
resized_content,
resized_mimetype,
proxy_resp_headers,
)
2022-06-23 19:07:20 +00:00
return PlainTextResponse(
2022-06-30 07:25:13 +00:00
resized_content,
media_type=resized_mimetype,
2022-06-23 19:07:20 +00:00
headers=proxy_resp_headers,
)
except ValueError:
return PlainTextResponse(
proxy_resp.content,
headers=proxy_resp_headers,
)
2022-06-23 19:07:20 +00:00
except Exception:
logger.exception(f"Failed to resize {url} on the fly")
return PlainTextResponse(
proxy_resp.content,
headers=proxy_resp_headers,
)
@app.get("/attachments/{content_hash}/{filename}")
2022-06-29 18:43:17 +00:00
async def serve_attachment(
2022-06-23 19:07:20 +00:00
content_hash: str,
filename: str,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-23 19:07:20 +00:00
):
2022-06-29 18:43:17 +00:00
upload = (
await db_session.execute(
select(models.Upload).where(
models.Upload.content_hash == content_hash,
)
2022-06-23 19:07:20 +00:00
)
2022-06-29 06:56:39 +00:00
).scalar_one_or_none()
2022-06-23 19:07:20 +00:00
if not upload:
raise HTTPException(status_code=404)
return FileResponse(
UPLOAD_DIR / content_hash,
media_type=upload.content_type,
)
@app.get("/attachments/thumbnails/{content_hash}/{filename}")
2022-06-29 18:43:17 +00:00
async def serve_attachment_thumbnail(
2022-06-23 19:07:20 +00:00
content_hash: str,
filename: str,
2022-06-29 18:43:17 +00:00
db_session: AsyncSession = Depends(get_db_session),
2022-06-23 19:07:20 +00:00
):
2022-06-29 18:43:17 +00:00
upload = (
await db_session.execute(
select(models.Upload).where(
models.Upload.content_hash == content_hash,
)
2022-06-23 19:07:20 +00:00
)
2022-06-29 06:56:39 +00:00
).scalar_one_or_none()
2022-06-23 19:07:20 +00:00
if not upload or not upload.has_thumbnail:
raise HTTPException(status_code=404)
return FileResponse(
UPLOAD_DIR / (content_hash + "_resized"),
media_type=upload.content_type,
)
2022-06-22 18:11:22 +00:00
@app.get("/robots.txt", response_class=PlainTextResponse)
async def robots_file():
return """User-agent: *
Disallow: /followers
Disallow: /following
2022-07-01 17:35:34 +00:00
Disallow: /admin
Disallow: /remote_follow"""
2022-06-27 06:30:29 +00:00
2022-06-29 18:43:17 +00:00
async def _get_outbox_for_feed(db_session: AsyncSession) -> list[models.OutboxObject]:
return (
(
await db_session.scalars(
select(models.OutboxObject)
.where(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
models.OutboxObject.ap_type.in_(["Note", "Article", "Video"]),
)
.options(
joinedload(models.OutboxObject.outbox_object_attachments).options(
joinedload(models.OutboxObjectAttachment.upload)
)
)
.order_by(models.OutboxObject.ap_published_at.desc())
.limit(20)
)
2022-06-27 06:30:29 +00:00
)
2022-06-29 18:43:17 +00:00
.unique()
.all()
)
2022-06-27 06:30:29 +00:00
@app.get("/feed.json")
2022-06-29 18:43:17 +00:00
async def json_feed(
db_session: AsyncSession = Depends(get_db_session),
2022-06-27 06:30:29 +00:00
) -> dict[str, Any]:
2022-06-29 18:43:17 +00:00
outbox_objects = await _get_outbox_for_feed(db_session)
2022-06-27 06:30:29 +00:00
data = []
for outbox_object in outbox_objects:
if not outbox_object.ap_published_at:
raise ValueError(f"{outbox_object} has no published date")
data.append(
{
"id": outbox_object.public_id,
"url": outbox_object.url,
"content_html": outbox_object.content,
"content_text": outbox_object.source,
"date_published": outbox_object.ap_published_at.isoformat(),
"attachments": [
{"url": a.url, "mime_type": a.media_type}
for a in outbox_object.attachments
],
}
)
return {
"version": "https://jsonfeed.org/version/1",
"title": f"{LOCAL_ACTOR.display_name}'s microblog'",
"home_page_url": LOCAL_ACTOR.url,
"feed_url": BASE_URL + "/feed.json",
"author": {
"name": LOCAL_ACTOR.display_name,
"url": LOCAL_ACTOR.url,
"avatar": LOCAL_ACTOR.icon_url,
},
"items": data,
}
2022-06-29 18:43:17 +00:00
async def _gen_rss_feed(
db_session: AsyncSession,
2022-06-27 06:30:29 +00:00
):
fg = FeedGenerator()
fg.id(BASE_URL + "/feed.rss")
fg.title(f"{LOCAL_ACTOR.display_name}'s microblog")
fg.description(f"{LOCAL_ACTOR.display_name}'s microblog")
fg.author({"name": LOCAL_ACTOR.display_name})
fg.link(href=LOCAL_ACTOR.url, rel="alternate")
fg.logo(LOCAL_ACTOR.icon_url)
fg.language("en")
2022-06-29 18:43:17 +00:00
outbox_objects = await _get_outbox_for_feed(db_session)
2022-06-27 06:30:29 +00:00
for outbox_object in outbox_objects:
if not outbox_object.ap_published_at:
raise ValueError(f"{outbox_object} has no published date")
2022-07-15 07:04:17 +00:00
content = outbox_object.content
if content is None:
raise ValueError("Should never happen")
if outbox_object.attachments:
for attachment in outbox_object.attachments:
2022-07-20 18:59:29 +00:00
if attachment.type == "Image" or (
attachment.media_type and attachment.media_type.startswith("image")
2022-07-15 07:04:17 +00:00
):
content += f'<img src="{attachment.url}">'
# TODO(ts): other attachment types
2022-06-27 06:30:29 +00:00
fe = fg.add_entry()
fe.id(outbox_object.url)
fe.link(href=outbox_object.url)
fe.title(outbox_object.url)
2022-07-15 07:04:17 +00:00
fe.description(content)
fe.content(content)
2022-06-27 06:30:29 +00:00
fe.published(outbox_object.ap_published_at.replace(tzinfo=timezone.utc))
return fg
@app.get("/feed.rss")
2022-06-29 18:43:17 +00:00
async def rss_feed(
db_session: AsyncSession = Depends(get_db_session),
2022-06-27 06:30:29 +00:00
) -> PlainTextResponse:
return PlainTextResponse(
2022-06-29 18:43:17 +00:00
(await _gen_rss_feed(db_session)).rss_str(),
2022-06-27 06:30:29 +00:00
headers={"Content-Type": "application/rss+xml"},
)
@app.get("/feed.atom")
2022-06-29 18:43:17 +00:00
async def atom_feed(
db_session: AsyncSession = Depends(get_db_session),
2022-06-27 06:30:29 +00:00
) -> PlainTextResponse:
return PlainTextResponse(
2022-06-29 18:43:17 +00:00
(await _gen_rss_feed(db_session)).atom_str(),
2022-06-27 06:30:29 +00:00
headers={"Content-Type": "application/atom+xml"},
)