diff --git a/.flake8 b/.flake8 index c1b2260e..eef0feff 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -max-line-length = 88 +max-line-length = 120 extend-ignore = E203 exclude = alembic/versions diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..ed7e69af --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,31 @@ +name: Unit Tests +on: push + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository code + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: "3.11" + + - name: Setup poetry + uses: Gr1N/setup-poetry@v8 + + - name: Install dependencies + run: | + poetry install --no-root --no-interaction + + - name: Run lint + run: | + poetry run inv lint + + - name: Run test suite + run: | + poetry run inv tests diff --git a/.gitignore b/.gitignore index f6eb93d9..bf7ff910 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.db +*.log __pycache__/ .mypy_cache/ .pytest_cache/ diff --git a/AUTHORS b/AUTHORS index 3070e0a4..655fc7ba 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,7 +3,9 @@ Kevin Wallace Miguel Jacq Alexey Shpakovsky Josh Washburne +João Costa Sam Ash McAllan Cassio Zen Cocoa +Jane diff --git a/Dockerfile b/Dockerfile index 8d468825..7a442080 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim as python-base +FROM python:3.11-slim as python-base ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ POETRY_HOME="/opt/poetry" \ @@ -14,7 +14,7 @@ RUN apt-get install -y --no-install-recommends curl build-essential gcc libffi-d # rustc is needed to compile Python packages RUN curl https://sh.rustup.rs -sSf | bash -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" -RUN curl -sSL https://install.python-poetry.org | python3 - +RUN curl -sSL https://install.python-poetry.org | python3 - WORKDIR $PYSETUP_PATH COPY poetry.lock pyproject.toml ./ RUN poetry install --only main diff --git a/Makefile b/Makefile index 20db1558..213c2710 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ move-to: .PHONY: self-destruct self-destruct: - -docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct + -docker run --rm --it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct .PHONY: reset-password reset-password: @@ -41,3 +41,7 @@ check-config: .PHONY: compile-scss compile-scss: -docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv compile-scss + +.PHONY: import-mastodon-following-accounts +import-mastodon-following-accounts: + -docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv import-mastodon-following-accounts $(path) diff --git a/alembic/versions/2022_12_12_1926-9b404c47970a_add_option_to_hide_announces_from_actor.py b/alembic/versions/2022_12_12_1926-9b404c47970a_add_option_to_hide_announces_from_actor.py new file mode 100644 index 00000000..059129c3 --- /dev/null +++ b/alembic/versions/2022_12_12_1926-9b404c47970a_add_option_to_hide_announces_from_actor.py @@ -0,0 +1,32 @@ +"""Add option to hide announces from actor + +Revision ID: 9b404c47970a +Revises: fadfd359ce78 +Create Date: 2022-12-12 19:26:36.912763+00:00 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = '9b404c47970a' +down_revision = 'fadfd359ce78' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('actor', schema=None) as batch_op: + batch_op.add_column(sa.Column('are_announces_hidden_from_stream', sa.Boolean(), server_default='0', nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('actor', schema=None) as batch_op: + batch_op.drop_column('are_announces_hidden_from_stream') + + # ### end Alembic commands ### diff --git a/alembic/versions/2022_12_16_1730-4ab54becec04_add_oauth_client.py b/alembic/versions/2022_12_16_1730-4ab54becec04_add_oauth_client.py new file mode 100644 index 00000000..3858bcea --- /dev/null +++ b/alembic/versions/2022_12_16_1730-4ab54becec04_add_oauth_client.py @@ -0,0 +1,48 @@ +"""Add OAuth client + +Revision ID: 4ab54becec04 +Revises: 9b404c47970a +Create Date: 2022-12-16 17:30:54.520477+00:00 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = '4ab54becec04' +down_revision = '9b404c47970a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('oauth_client', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('client_name', sa.String(), nullable=False), + sa.Column('redirect_uris', sa.JSON(), nullable=True), + sa.Column('client_uri', sa.String(), nullable=True), + sa.Column('logo_uri', sa.String(), nullable=True), + sa.Column('scope', sa.String(), nullable=True), + sa.Column('client_id', sa.String(), nullable=False), + sa.Column('client_secret', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('client_secret') + ) + with op.batch_alter_table('oauth_client', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_oauth_client_client_id'), ['client_id'], unique=True) + batch_op.create_index(batch_op.f('ix_oauth_client_id'), ['id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('oauth_client', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_oauth_client_id')) + batch_op.drop_index(batch_op.f('ix_oauth_client_client_id')) + + op.drop_table('oauth_client') + # ### end Alembic commands ### diff --git a/alembic/versions/2022_12_18_1126-a209f0333f5a_add_oauth_refresh_token_support.py b/alembic/versions/2022_12_18_1126-a209f0333f5a_add_oauth_refresh_token_support.py new file mode 100644 index 00000000..8e486b95 --- /dev/null +++ b/alembic/versions/2022_12_18_1126-a209f0333f5a_add_oauth_refresh_token_support.py @@ -0,0 +1,36 @@ +"""Add OAuth refresh token support + +Revision ID: a209f0333f5a +Revises: 4ab54becec04 +Create Date: 2022-12-18 11:26:31.976348+00:00 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'a209f0333f5a' +down_revision = '4ab54becec04' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('indieauth_access_token', schema=None) as batch_op: + batch_op.add_column(sa.Column('refresh_token', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('was_refreshed', sa.Boolean(), server_default='0', nullable=False)) + batch_op.create_index(batch_op.f('ix_indieauth_access_token_refresh_token'), ['refresh_token'], unique=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('indieauth_access_token', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_indieauth_access_token_refresh_token')) + batch_op.drop_column('was_refreshed') + batch_op.drop_column('refresh_token') + + # ### end Alembic commands ### diff --git a/app/actor.py b/app/actor.py index 271b88b4..24a2dcbd 100644 --- a/app/actor.py +++ b/app/actor.py @@ -6,6 +6,7 @@ from typing import Union from urllib.parse import urlparse +import httpx from loguru import logger from sqlalchemy import select from sqlalchemy.orm import joinedload @@ -13,6 +14,9 @@ from app import activitypub as ap from app import media from app.config import BASE_URL +from app.config import USER_AGENT +from app.config import USERNAME +from app.config import WEBFINGER_DOMAIN from app.database import AsyncSession from app.utils.datetime import as_utc from app.utils.datetime import now @@ -27,7 +31,38 @@ def _handle(raw_actor: ap.RawObject) -> str: if not domain.hostname: raise ValueError(f"Invalid actor ID {ap_id}") - return f'@{raw_actor["preferredUsername"]}@{domain.hostname}' # type: ignore + handle = f'@{raw_actor["preferredUsername"]}@{domain.hostname}' # type: ignore + + # TODO: cleanup this + # Next, check for custom webfinger domains + resp: httpx.Response | None = None + for url in { + f"https://{domain.hostname}/.well-known/webfinger", + f"http://{domain.hostname}/.well-known/webfinger", + }: + try: + logger.info(f"Webfinger {handle} at {url}") + resp = httpx.get( + url, + params={"resource": f"acct:{handle[1:]}"}, + headers={ + "User-Agent": USER_AGENT, + }, + follow_redirects=True, + ) + resp.raise_for_status() + break + except Exception: + logger.exception(f"Failed to webfinger {handle}") + + if resp: + try: + json_resp = resp.json() + if json_resp.get("subject", "").startswith("acct:"): + return "@" + json_resp["subject"].removeprefix("acct:") + except Exception: + logger.exception(f"Failed to parse webfinger response for {handle}") + return handle class Actor: @@ -61,7 +96,7 @@ def display_name(self) -> str: return self.name return self.preferred_username - @property + @cached_property def handle(self) -> str: return _handle(self.ap_actor) @@ -143,13 +178,18 @@ def server(self) -> str: class RemoteActor(Actor): - def __init__(self, ap_actor: ap.RawObject) -> None: + def __init__(self, ap_actor: ap.RawObject, handle: str | None = None) -> None: if (ap_type := ap_actor.get("type")) not in ap.ACTOR_TYPES: raise ValueError(f"Unexpected actor type: {ap_type}") self._ap_actor = ap_actor self._ap_type = ap_type + if handle is None: + handle = _handle(ap_actor) + + self._handle = handle + @property def ap_actor(self) -> ap.RawObject: return self._ap_actor @@ -162,8 +202,12 @@ def ap_type(self) -> str: def is_from_db(self) -> bool: return False + @property + def handle(self) -> str: + return self._handle -LOCAL_ACTOR = RemoteActor(ap_actor=ap.ME) + +LOCAL_ACTOR = RemoteActor(ap_actor=ap.ME, handle=f"@{USERNAME}@{WEBFINGER_DOMAIN}") async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "ActorModel": @@ -248,6 +292,22 @@ async def fetch_actor( raise ap.ObjectNotFoundError(actor_id) +async def list_actors(db_session: AsyncSession, limit: int = 100) -> list["ActorModel"]: + from app import models + + return ( + ( + await db_session.scalars( + select(models.Actor) + .where(models.Actor.is_deleted.is_(False)) + .limit(limit) + ) + ) + .unique() + .all() + ) + + async def update_actor_if_needed( db_session: AsyncSession, actor_in_db: "ActorModel", @@ -358,11 +418,11 @@ async def get_actors_metadata( is_following=actor.ap_id in following, is_follower=actor.ap_id in followers, is_follow_request_sent=actor.ap_id in sent_follow_requests, - is_follow_request_rejected=bool( - sent_follow_requests[actor.ap_id] in rejected_follow_requests - ) - if actor.ap_id in sent_follow_requests - else False, + is_follow_request_rejected=( + bool(sent_follow_requests[actor.ap_id] in rejected_follow_requests) + if actor.ap_id in sent_follow_requests + else False + ), outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id), inbox_follow_ap_id=followers.get(actor.ap_id), moved_to=moved_to, diff --git a/app/admin.py b/app/admin.py index 17a06cb9..d8d36518 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,4 +1,5 @@ from datetime import datetime +from urllib.parse import quote import httpx from fastapi import APIRouter @@ -24,6 +25,7 @@ from app.actor import LOCAL_ACTOR from app.actor import fetch_actor from app.actor import get_actors_metadata +from app.actor import list_actors from app.boxes import get_inbox_object_by_ap_id from app.boxes import get_outbox_object_by_ap_id from app.boxes import send_block @@ -51,15 +53,18 @@ async def user_session_or_redirect( if request.method == "POST": form_data = await request.form() if "redirect_url" in form_data: - redirect_url = form_data["redirect_url"] + redirect_url = str(form_data["redirect_url"]) else: - redirect_url = request.url_for("admin_stream") + redirect_url = str(request.url_for("admin_stream")) else: redirect_url = str(request.url) _RedirectToLoginPage = HTTPException( status_code=302, - headers={"Location": request.url_for("login") + f"?redirect={redirect_url}"}, + headers={ + "Location": str(request.url_for("login")) + + f"?redirect={quote(redirect_url)}" + }, ) if not session: @@ -85,7 +90,7 @@ async def user_session_or_redirect( unauthenticated_router = APIRouter() -@router.get("/lookup") +@router.get("/lookup", response_model=None) async def get_lookup( request: Request, query: str | None = None, @@ -94,6 +99,7 @@ async def get_lookup( error = None ap_object = None actors_metadata = {} + actor_recommendations = await list_actors(db_session, 2500) if query: try: ap_object = await lookup(db_session, query) @@ -101,7 +107,7 @@ async def get_lookup( error = ap.FetchErrorTypeEnum.TIMEOUT except (ap.ObjectNotFoundError, ap.ObjectIsGoneError): error = ap.FetchErrorTypeEnum.NOT_FOUND - except (ap.ObjectUnavailableError): + except ap.ObjectUnavailableError: error = ap.FetchErrorTypeEnum.UNAUHTORIZED except Exception: logger.exception(f"Failed to lookup {query}") @@ -116,7 +122,7 @@ async def get_lookup( pass else: return RedirectResponse( - request.url_for("admin_profile") + str(request.url_for("admin_profile")) + f"?actor_id={ap_object.ap_id}", status_code=302, ) @@ -131,7 +137,7 @@ async def get_lookup( ) if requested_object: return RedirectResponse( - request.url_for("admin_object") + str(request.url_for("admin_object")) + f"?ap_id={ap_object.ap_id}#" + requested_object.permalink_id, status_code=302, @@ -149,12 +155,13 @@ async def get_lookup( "query": query, "ap_object": ap_object, "actors_metadata": actors_metadata, + "actor_recommendations": actor_recommendations, "error": error, }, ) -@router.get("/new") +@router.get("/new", response_model=None) async def admin_new( request: Request, query: str | None = None, @@ -186,8 +193,11 @@ async def admin_new( content += f"{in_reply_to_object.actor.handle} " for tag in in_reply_to_object.tags: if tag.get("type") == "Mention" and tag["name"] != LOCAL_ACTOR.handle: - mentioned_actor = await fetch_actor(db_session, tag["href"]) - content += f"{mentioned_actor.handle} " + try: + mentioned_actor = await fetch_actor(db_session, tag["href"]) + content += f"{mentioned_actor.handle} " + except Exception: + logger.exception(f"Failed to lookup {mentioned_actor}") # Copy the content warning if any if in_reply_to_object.summary: @@ -217,7 +227,7 @@ async def admin_new( ) -@router.get("/bookmarks") +@router.get("/bookmarks", response_model=None) async def admin_bookmarks( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -260,7 +270,7 @@ async def admin_bookmarks( ) -@router.get("/stream") +@router.get("/stream", response_model=None) async def admin_stream( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -331,7 +341,7 @@ async def admin_stream( ) -@router.get("/inbox") +@router.get("/inbox", response_model=None) async def admin_inbox( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -419,7 +429,7 @@ async def admin_inbox( ) -@router.get("/direct_messages") +@router.get("/direct_messages", response_model=None) async def admin_direct_messages( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -607,7 +617,7 @@ async def admin_direct_messages( ) -@router.get("/outbox") +@router.get("/outbox", response_model=None) async def admin_outbox( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -680,7 +690,7 @@ async def admin_outbox( ) -@router.get("/notifications") +@router.get("/notifications", response_model=None) async def get_notifications( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -760,7 +770,7 @@ async def get_notifications( return tpl_resp -@router.get("/object") +@router.get("/object", response_model=None) async def admin_object( request: Request, ap_id: str, @@ -784,7 +794,7 @@ async def admin_object( ) -@router.get("/profile") +@router.get("/profile", response_model=None) async def admin_profile( request: Request, actor_id: str, @@ -861,7 +871,7 @@ async def admin_profile( ) -@router.post("/actions/force_delete") +@router.post("/actions/force_delete", response_model=None) async def admin_actions_force_delete( request: Request, ap_object_id: str = Form(), @@ -885,7 +895,7 @@ async def admin_actions_force_delete( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/force_delete_webmention") +@router.post("/actions/force_delete_webmention", response_model=None) async def admin_actions_force_delete_webmention( request: Request, webmention_id: int = Form(), @@ -921,7 +931,7 @@ async def admin_actions_force_delete_webmention( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/follow") +@router.post("/actions/follow", response_model=None) async def admin_actions_follow( request: Request, ap_actor_id: str = Form(), @@ -934,7 +944,7 @@ async def admin_actions_follow( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/block") +@router.post("/actions/block", response_model=None) async def admin_actions_block( request: Request, ap_actor_id: str = Form(), @@ -946,7 +956,7 @@ async def admin_actions_block( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/unblock") +@router.post("/actions/unblock", response_model=None) async def admin_actions_unblock( request: Request, ap_actor_id: str = Form(), @@ -959,7 +969,35 @@ async def admin_actions_unblock( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/delete") +@router.post("/actions/hide_announces", response_model=None) +async def admin_actions_hide_announces( + request: Request, + ap_actor_id: str = Form(), + redirect_url: str = Form(), + csrf_check: None = Depends(verify_csrf_token), + db_session: AsyncSession = Depends(get_db_session), +) -> RedirectResponse: + actor = await fetch_actor(db_session, ap_actor_id) + actor.are_announces_hidden_from_stream = True + await db_session.commit() + return RedirectResponse(redirect_url, status_code=302) + + +@router.post("/actions/show_announces", response_model=None) +async def admin_actions_show_announces( + request: Request, + ap_actor_id: str = Form(), + redirect_url: str = Form(), + csrf_check: None = Depends(verify_csrf_token), + db_session: AsyncSession = Depends(get_db_session), +) -> RedirectResponse: + actor = await fetch_actor(db_session, ap_actor_id) + actor.are_announces_hidden_from_stream = False + await db_session.commit() + return RedirectResponse(redirect_url, status_code=302) + + +@router.post("/actions/delete", response_model=None) async def admin_actions_delete( request: Request, ap_object_id: str = Form(), @@ -971,7 +1009,7 @@ async def admin_actions_delete( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/accept_incoming_follow") +@router.post("/actions/accept_incoming_follow", response_model=None) async def admin_actions_accept_incoming_follow( request: Request, notification_id: int = Form(), @@ -983,7 +1021,7 @@ async def admin_actions_accept_incoming_follow( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/reject_incoming_follow") +@router.post("/actions/reject_incoming_follow", response_model=None) async def admin_actions_reject_incoming_follow( request: Request, notification_id: int = Form(), @@ -995,7 +1033,7 @@ async def admin_actions_reject_incoming_follow( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/like") +@router.post("/actions/like", response_model=None) async def admin_actions_like( request: Request, ap_object_id: str = Form(), @@ -1007,7 +1045,7 @@ async def admin_actions_like( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/undo") +@router.post("/actions/undo", response_model=None) async def admin_actions_undo( request: Request, ap_object_id: str = Form(), @@ -1019,7 +1057,7 @@ async def admin_actions_undo( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/announce") +@router.post("/actions/announce", response_model=None) async def admin_actions_announce( request: Request, ap_object_id: str = Form(), @@ -1031,7 +1069,7 @@ async def admin_actions_announce( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/bookmark") +@router.post("/actions/bookmark", response_model=None) async def admin_actions_bookmark( request: Request, ap_object_id: str = Form(), @@ -1049,7 +1087,7 @@ async def admin_actions_bookmark( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/unbookmark") +@router.post("/actions/unbookmark", response_model=None) async def admin_actions_unbookmark( request: Request, ap_object_id: str = Form(), @@ -1065,7 +1103,7 @@ async def admin_actions_unbookmark( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/pin") +@router.post("/actions/pin", response_model=None) async def admin_actions_pin( request: Request, ap_object_id: str = Form(), @@ -1081,7 +1119,7 @@ async def admin_actions_pin( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/unpin") +@router.post("/actions/unpin", response_model=None) async def admin_actions_unpin( request: Request, ap_object_id: str = Form(), @@ -1097,7 +1135,7 @@ async def admin_actions_unpin( return RedirectResponse(redirect_url, status_code=302) -@router.post("/actions/new") +@router.post("/actions/new", response_model=None) async def admin_actions_new( request: Request, files: list[UploadFile] = [], @@ -1123,15 +1161,28 @@ async def admin_actions_new( content_warning = None if not content: - raise HTTPException(status_code=422, detail="Error: objec must have a content") + raise HTTPException(status_code=422, detail="Error: object must have a content") # XXX: for some reason, no files restuls in an empty single file uploads = [] raw_form_data = await request.form() - if len(files) >= 1 and files[0].filename: + if len(files) >= 1: for f in files: - upload = await save_upload(db_session, f) - uploads.append((upload, f.filename, raw_form_data.get("alt_" + f.filename))) + if f.filename is not None and f.filename != "": + upload = await save_upload(db_session, f) + if upload is not None: + alt = raw_form_data.get("alt_" + f.filename) + uploads.append( + ( + upload, + f.filename, + str(alt) if alt is not None else None, + ) + ) + else: + raise HTTPException( + status_code=422, detail="Error: Unable to process upload" + ) ap_type = "Note" @@ -1142,16 +1193,16 @@ async def admin_actions_new( poll_answers = [] for i in ["1", "2", "3", "4"]: if answer := raw_form_data.get(f"poll_answer_{i}"): - poll_answers.append(answer) + poll_answers.append(str(answer)) if not poll_answers or len(poll_answers) < 2: raise ValueError("Question must have at least 2 answers") - poll_duration_in_minutes = int(raw_form_data["poll_duration"]) + poll_duration_in_minutes = int(str(raw_form_data["poll_duration"])) elif name: ap_type = "Article" - public_id = await boxes.send_create( + public_id, _ = await boxes.send_create( db_session, ap_type=ap_type, source=content, @@ -1171,7 +1222,7 @@ async def admin_actions_new( ) -@router.post("/actions/vote") +@router.post("/actions/vote", response_model=None) async def admin_actions_vote( request: Request, redirect_url: str = Form(), @@ -1180,7 +1231,7 @@ async def admin_actions_vote( db_session: AsyncSession = Depends(get_db_session), ) -> RedirectResponse: form_data = await request.form() - names = form_data.getlist("name") + names = list(map(lambda data: str(data), form_data.getlist("name"))) logger.info(f"{names=}") await boxes.send_vote( db_session, @@ -1190,7 +1241,7 @@ async def admin_actions_vote( return RedirectResponse(redirect_url, status_code=302) -@unauthenticated_router.get("/login") +@unauthenticated_router.get("/login", response_model=None) async def login( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -1209,7 +1260,7 @@ async def login( ) -@unauthenticated_router.post("/login") +@unauthenticated_router.post("/login", response_model=None) async def login_validation( request: Request, password: str = Form(), @@ -1239,7 +1290,7 @@ async def login_validation( return resp -@router.get("/logout") +@router.get("/logout", response_model=None) async def logout( request: Request, ) -> RedirectResponse: diff --git a/app/ap_object.py b/app/ap_object.py index 77586aa7..21f58020 100644 --- a/app/ap_object.py +++ b/app/ap_object.py @@ -119,9 +119,11 @@ def attachments(self) -> list["Attachment"]: Attachment.parse_obj( { "proxiedUrl": proxied_url, - "resizedUrl": proxied_url + "/740" - if obj.get("mediaType", "").startswith("image") - else None, + "resizedUrl": ( + proxied_url + "/740" + if obj.get("mediaType", "").startswith("image") + else None + ), **obj, } ) @@ -282,8 +284,8 @@ class Config: class Attachment(BaseModel): type: str - media_type: str | None - name: str | None + media_type: str | None = None + name: str | None = None url: str # Extra fields for the templates (and only for media) diff --git a/app/boxes.py b/app/boxes.py index 343811e7..9f4c8d20 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -1,4 +1,5 @@ """Actions related to the AP inbox/outbox.""" + import datetime import uuid from collections import defaultdict @@ -28,7 +29,6 @@ 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,6 +46,7 @@ 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 @@ -439,7 +440,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, announced_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": @@ -590,7 +593,7 @@ async def send_create( poll_answers: list[str] | None = None, poll_duration_in_minutes: int | None = None, name: str | None = None, -) -> str: +) -> tuple[str, models.OutboxObject]: note_id = allocate_outbox_id() published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z") context = f"{ID}/contexts/" + uuid.uuid4().hex @@ -616,7 +619,7 @@ async def send_create( context = in_reply_to_object.ap_context conversation = in_reply_to_object.ap_context - for (upload, filename, alt_text) in uploads: + for upload, filename, alt_text in uploads: attachments.append(upload_to_attachment(upload, filename, alt_text)) to = [] @@ -711,7 +714,7 @@ async def send_create( ) db_session.add(tagged_object) - for (upload, filename, alt) in uploads: + for upload, filename, alt in uploads: outbox_object_attachment = models.OutboxObjectAttachment( filename=filename, alt=alt, @@ -765,7 +768,7 @@ async def send_create( await db_session.commit() - return note_id + return note_id, outbox_object async def send_vote( @@ -948,7 +951,7 @@ async def compute_all_known_recipients(db_session: AsyncSession) -> set[str]: } -async def _get_following(db_session: AsyncSession) -> list[models.Follower]: +async def _get_following(db_session: AsyncSession) -> list[models.Following]: return ( ( await db_session.scalars( @@ -2203,7 +2206,10 @@ async def _handle_announce_activity( db_session.add(announced_inbox_object) await db_session.flush() announce_activity.relates_to_inbox_object_id = announced_inbox_object.id - announce_activity.is_hidden_from_stream = not is_from_following + announce_activity.is_hidden_from_stream = ( + not is_from_following + or announce_activity.actor.are_announces_hidden_from_stream + ) async def _handle_like_activity( @@ -2310,7 +2316,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 @@ -2400,12 +2406,12 @@ async def save_to_inbox( ap_published_at=ap_published_at, ap_object=activity_ro.ap_object, visibility=activity_ro.visibility, - relates_to_inbox_object_id=relates_to_inbox_object.id - if relates_to_inbox_object - else None, - relates_to_outbox_object_id=relates_to_outbox_object.id - if relates_to_outbox_object - else None, + relates_to_inbox_object_id=( + relates_to_inbox_object.id if relates_to_inbox_object else None + ), + relates_to_outbox_object_id=( + relates_to_outbox_object.id if relates_to_outbox_object else None + ), activity_object_ap_id=activity_ro.activity_object_ap_id, is_hidden_from_stream=True, ) @@ -2663,17 +2669,26 @@ async def get_replies_tree( if requested_object.conversation is None: tree_nodes = [requested_object] else: + logger.info(f"Requested conversation: '{requested_object.conversation}'") + allowed_visibility = [ap.VisibilityEnum.PUBLIC, ap.VisibilityEnum.UNLISTED] if is_current_user_admin: allowed_visibility = list(ap.VisibilityEnum) + logger.info(f"Allowed visibility: {allowed_visibility}") tree_nodes.extend( ( await db_session.scalars( select(models.InboxObject) .where( - models.InboxObject.conversation - == requested_object.conversation, + ( + models.InboxObject.conversation + == requested_object.conversation + ) + | ( + models.InboxObject.ap_context + == requested_object.conversation + ), models.InboxObject.ap_type.in_( ["Note", "Page", "Article", "Question"] ), @@ -2686,6 +2701,7 @@ async def get_replies_tree( .unique() .all() ) + tree_nodes.extend( ( await db_session.scalars( @@ -2712,7 +2728,7 @@ async def get_replies_tree( nodes_by_in_reply_to = defaultdict(list) for node in tree_nodes: nodes_by_in_reply_to[node.in_reply_to].append(node) - logger.info(nodes_by_in_reply_to) + logger.info(f"Nodes in reply to: {nodes_by_in_reply_to}") if len(nodes_by_in_reply_to.get(None, [])) > 1: raise ValueError(f"Invalid replies tree: {[n.ap_object for n in tree_nodes]}") diff --git a/app/config.py b/app/config.py index 60a4d9c4..475f17e2 100644 --- a/app/config.py +++ b/app/config.py @@ -44,11 +44,14 @@ 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 @@ -101,7 +104,7 @@ class Config(pydantic.BaseModel): manually_approves_followers: bool = False privacy_replace: list[_PrivacyReplace] | None = None metadata: list[_ProfileMetadata] | None = None - code_highlighting_theme = "friendly_grayscale" + code_highlighting_theme: str = "friendly_grayscale" blocked_servers: list[_BlockedServer] = [] custom_footer: str | None = None emoji: str | None = None @@ -114,11 +117,14 @@ class Config(pydantic.BaseModel): custom_content_security_policy: str | None = None + webfinger_domain: str | None = None + # Config items to make tests easier sqlalchemy_database: str | None = None key_path: str | None = None session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default + csrf_token_exp: int = 3600 disabled_notifications: list[str] = [] @@ -165,6 +171,10 @@ def verify_password(pwd: str) -> bool: if CONFIG.id: ID = CONFIG.id USERNAME = CONFIG.username + +# Allow to use @handle@webfinger-domain.tld while hosting the server at domain.tld +WEBFINGER_DOMAIN = CONFIG.webfinger_domain or DOMAIN + MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers HIDES_FOLLOWERS = CONFIG.hides_followers HIDES_FOLLOWING = CONFIG.hides_following @@ -254,7 +264,7 @@ def verify_csrf_token( if redirect_url: please_try_again = f'please try again' try: - csrf_serializer.loads(csrf_token, max_age=1800) + csrf_serializer.loads(csrf_token, max_age=CONFIG.csrf_token_exp) except (itsdangerous.BadData, itsdangerous.SignatureExpired): logger.exception("Failed to verify CSRF token") raise HTTPException( diff --git a/app/httpsig.py b/app/httpsig.py index 9f8ad796..e9f9e690 100644 --- a/app/httpsig.py +++ b/app/httpsig.py @@ -23,12 +23,12 @@ 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, diff --git a/app/incoming_activities.py b/app/incoming_activities.py index 583b2088..94dd0d6e 100644 --- a/app/incoming_activities.py +++ b/app/incoming_activities.py @@ -60,7 +60,7 @@ def _set_next_try( if not outgoing_activity.tries: raise ValueError("Should never happen") - if outgoing_activity.tries == _MAX_RETRIES: + if outgoing_activity.tries >= _MAX_RETRIES: outgoing_activity.is_errored = True outgoing_activity.next_try = None else: diff --git a/app/indieauth.py b/app/indieauth.py index f96e5229..9d2a8dcf 100644 --- a/app/indieauth.py +++ b/app/indieauth.py @@ -10,9 +10,12 @@ from fastapi import HTTPException from fastapi import Request from fastapi.responses import JSONResponse -from fastapi.responses import RedirectResponse +from fastapi.security import HTTPBasic +from fastapi.security import HTTPBasicCredentials from loguru import logger +from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.orm import joinedload from app import config from app import models @@ -21,13 +24,16 @@ from app.config import verify_csrf_token from app.database import AsyncSession from app.database import get_db_session +from app.redirect import redirect from app.utils import indieauth from app.utils.datetime import now +basic_auth = HTTPBasic() + router = APIRouter() -@router.get("/.well-known/oauth-authorization-server") +@router.get("/.well-known/oauth-authorization-server", response_model=None) async def well_known_authorization_server( request: Request, ) -> dict[str, Any]: @@ -38,10 +44,58 @@ async def well_known_authorization_server( "code_challenge_methods_supported": ["S256"], "revocation_endpoint": request.url_for("indieauth_revocation_endpoint"), "revocation_endpoint_auth_methods_supported": ["none"], + "registration_endpoint": request.url_for("oauth_registration_endpoint"), + "introspection_endpoint": request.url_for("oauth_introspection_endpoint"), } -@router.get("/auth") +class OAuthRegisterClientRequest(BaseModel): + client_name: str + redirect_uris: list[str] | str + + client_uri: str | None = None + logo_uri: str | None = None + scope: str | None = None + + +@router.post("/oauth/register", response_model=None) +async def oauth_registration_endpoint( + register_client_request: OAuthRegisterClientRequest, + db_session: AsyncSession = Depends(get_db_session), +) -> JSONResponse: + """Implements OAuth 2.0 Dynamic Registration.""" + + client = models.OAuthClient( + client_name=register_client_request.client_name, + redirect_uris=( + [register_client_request.redirect_uris] + if isinstance(register_client_request.redirect_uris, str) + else register_client_request.redirect_uris + ), + client_uri=register_client_request.client_uri, + logo_uri=register_client_request.logo_uri, + scope=register_client_request.scope, + client_id=secrets.token_hex(16), + client_secret=secrets.token_hex(32), + ) + + db_session.add(client) + await db_session.commit() + + return JSONResponse( + content={ + **register_client_request.dict(), + "client_id_issued_at": int(client.created_at.timestamp()), # type: ignore + "grant_types": ["authorization_code", "refresh_token"], + "client_secret_expires_at": 0, + "client_id": client.client_id, + "client_secret": client.client_secret, + }, + status_code=201, + ) + + +@router.get("/auth", response_model=None) async def indieauth_authorization_endpoint( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -56,12 +110,29 @@ async def indieauth_authorization_endpoint( code_challenge = request.query_params.get("code_challenge", "") code_challenge_method = request.query_params.get("code_challenge_method", "") + # Check if the authorization request is coming from an OAuth client + registered_client = ( + await db_session.scalars( + select(models.OAuthClient).where( + models.OAuthClient.client_id == client_id, + ) + ) + ).one_or_none() + if registered_client: + client = { + "name": registered_client.client_name, + "logo": registered_client.logo_uri, + "url": registered_client.client_uri, + } + else: + client = await indieauth.get_client_id_data(client_id) # type: ignore + return await templates.render_template( db_session, request, "indieauth_flow.html", dict( - client=await indieauth.get_client_id_data(client_id), + client=client, scopes=scope, redirect_uri=redirect_uri, state=state, @@ -74,24 +145,24 @@ async def indieauth_authorization_endpoint( ) -@router.post("/admin/indieauth") +@router.post("/admin/indieauth", response_model=None) async def indieauth_flow( request: Request, db_session: AsyncSession = Depends(get_db_session), csrf_check: None = Depends(verify_csrf_token), _: None = Depends(user_session_or_redirect), -) -> RedirectResponse: +) -> templates.TemplateResponse: form_data = await request.form() logger.info(f"{form_data=}") # Params needed for the redirect - redirect_uri = form_data["redirect_uri"] + redirect_uri = str(form_data["redirect_uri"]) code = secrets.token_urlsafe(32) iss = config.ID + "/" state = form_data["state"] - scope = " ".join(form_data.getlist("scopes")) - client_id = form_data["client_id"] + scope = " ".join(map(lambda data: str(data), form_data.getlist("scopes"))) + client_id = str(form_data["client_id"]) # TODO: Ensure that me is correct # me = form_data.get("me") @@ -99,8 +170,8 @@ async def indieauth_flow( # XXX: should always be code # response_type = form_data["response_type"] - code_challenge = form_data["code_challenge"] - code_challenge_method = form_data["code_challenge_method"] + code_challenge = str(form_data["code_challenge"]) + code_challenge_method = str(form_data["code_challenge_method"]) auth_request = models.IndieAuthAuthorizationRequest( code=code, @@ -114,9 +185,8 @@ async def indieauth_flow( db_session.add(auth_request) await db_session.commit() - return RedirectResponse( - redirect_uri + f"?code={code}&state={state}&iss={iss}", - status_code=302, + return await redirect( + request, db_session, redirect_uri + f"?code={code}&state={state}&iss={iss}" ) @@ -159,7 +229,7 @@ async def _check_auth_code( return True, auth_code_req -@router.post("/auth") +@router.post("/auth", response_model=None) async def indieauth_reedem_auth_code( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -170,11 +240,11 @@ async def indieauth_reedem_auth_code( if grant_type != "authorization_code": raise ValueError(f"Invalid grant_type {grant_type}") - code = form_data["code"] + code = str(form_data["code"]) # These must match the params from the first request - client_id = form_data["client_id"] - redirect_uri = form_data["redirect_uri"] + client_id = str(form_data["client_id"]) + redirect_uri = str(form_data["redirect_uri"]) # code_verifier is optional for backward compat code_verifier = form_data.get("code_verifier") @@ -183,7 +253,7 @@ async def indieauth_reedem_auth_code( code=code, client_id=client_id, redirect_uri=redirect_uri, - code_verifier=code_verifier, + code_verifier=str(code_verifier) if code_verifier is not None else None, ) if is_code_valid: return JSONResponse( @@ -199,7 +269,7 @@ async def indieauth_reedem_auth_code( ) -@router.post("/token") +@router.post("/token", response_model=None) async def indieauth_token_endpoint( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -207,29 +277,54 @@ async def indieauth_token_endpoint( form_data = await request.form() logger.info(f"{form_data=}") grant_type = form_data.get("grant_type", "authorization_code") - if grant_type != "authorization_code": + if grant_type not in ["authorization_code", "refresh_token"]: raise ValueError(f"Invalid grant_type {grant_type}") - code = form_data["code"] - # These must match the params from the first request - client_id = form_data["client_id"] - redirect_uri = form_data["redirect_uri"] - # code_verifier is optional for backward compat + client_id = str(form_data["client_id"]) code_verifier = form_data.get("code_verifier") - is_code_valid, auth_code_request = await _check_auth_code( - db_session, - code=code, - client_id=client_id, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - ) - if not is_code_valid or (auth_code_request and not auth_code_request.scope): - return JSONResponse( - content={"error": "invalid_grant"}, - status_code=400, + if grant_type == "authorization_code": + code = str(form_data["code"]) + redirect_uri = str(form_data["redirect_uri"]) + # code_verifier is optional for backward compat + is_code_valid, auth_code_request = await _check_auth_code( + db_session, + code=code, + client_id=client_id, + redirect_uri=redirect_uri, + code_verifier=str(code_verifier) if code_verifier is not None else None, ) + if not is_code_valid or (auth_code_request and not auth_code_request.scope): + return JSONResponse( + content={"error": "invalid_grant"}, + status_code=400, + ) + + elif grant_type == "refresh_token": + refresh_token = str(form_data["refresh_token"]) + access_token = ( + await db_session.scalars( + select(models.IndieAuthAccessToken) + .where( + models.IndieAuthAccessToken.refresh_token == refresh_token, + models.IndieAuthAccessToken.was_refreshed.is_(False), + ) + .options( + joinedload( + models.IndieAuthAccessToken.indieauth_authorization_request + ) + ) + ) + ).one_or_none() + if not access_token: + raise ValueError("invalid refresh token") + + if access_token.indieauth_authorization_request.client_id != client_id: + raise ValueError("invalid client ID") + + auth_code_request = access_token.indieauth_authorization_request + access_token.was_refreshed = True if not auth_code_request: raise ValueError("Should never happen") @@ -237,6 +332,7 @@ async def indieauth_token_endpoint( access_token = models.IndieAuthAccessToken( indieauth_authorization_request_id=auth_code_request.id, access_token=secrets.token_urlsafe(32), + refresh_token=secrets.token_urlsafe(32), expires_in=3600, scope=auth_code_request.scope, ) @@ -246,6 +342,7 @@ async def indieauth_token_endpoint( return JSONResponse( content={ "access_token": access_token.access_token, + "refresh_token": access_token.refresh_token, "token_type": "Bearer", "scope": auth_code_request.scope, "me": config.ID + "/", @@ -261,8 +358,10 @@ async def _check_access_token( ) -> tuple[bool, models.IndieAuthAccessToken | None]: access_token_info = ( await db_session.scalars( - select(models.IndieAuthAccessToken).where( - models.IndieAuthAccessToken.access_token == token + select(models.IndieAuthAccessToken) + .where(models.IndieAuthAccessToken.access_token == token) + .options( + joinedload(models.IndieAuthAccessToken.indieauth_authorization_request) ) ) ).one_or_none() @@ -285,6 +384,9 @@ async def _check_access_token( @dataclass(frozen=True) class AccessTokenInfo: scopes: list[str] + client_id: str | None + access_token: str + exp: int async def verify_access_token( @@ -297,7 +399,7 @@ async def verify_access_token( if not token: form_data = await request.form() if "access_token" in form_data: - token = form_data.get("access_token") + token = str(form_data["access_token"]) is_token_valid, access_token = await _check_access_token(db_session, token) if not is_token_valid: @@ -311,10 +413,72 @@ async def verify_access_token( return AccessTokenInfo( scopes=access_token.scope.split(), + client_id=( + access_token.indieauth_authorization_request.client_id + if access_token.indieauth_authorization_request + else None + ), + access_token=access_token.access_token, + exp=int( + ( + access_token.created_at.replace(tzinfo=timezone.utc) + + timedelta(seconds=access_token.expires_in) + ).timestamp() + ), + ) + + +async def check_access_token( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> AccessTokenInfo | None: + token = request.headers.get("Authorization", "").removeprefix("Bearer ") + if not token: + return None + + is_token_valid, access_token = await _check_access_token(db_session, token) + if not is_token_valid: + return None + + if not access_token or not access_token.scope: + raise ValueError("Should never happen") + + access_token_info = AccessTokenInfo( + scopes=access_token.scope.split(), + client_id=( + access_token.indieauth_authorization_request.client_id + if access_token.indieauth_authorization_request + else None + ), + access_token=access_token.access_token, + exp=int( + ( + access_token.created_at.replace(tzinfo=timezone.utc) + + timedelta(seconds=access_token.expires_in) + ).timestamp() + ), + ) + + logger.info( + "Authenticated with access token from client_id=" + f"{access_token_info.client_id} scopes={access_token.scope}" ) + return access_token_info + + +async def enforce_access_token( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> AccessTokenInfo: + maybe_access_token_info = await check_access_token(request, db_session) + if not maybe_access_token_info: + raise HTTPException(status_code=401, detail="access token required") -@router.post("/revoke_token") + return maybe_access_token_info + + +@router.post("/revoke_token", response_model=None) async def indieauth_revocation_endpoint( request: Request, token: str = Form(), @@ -333,3 +497,58 @@ async def indieauth_revocation_endpoint( content={}, status_code=200, ) + + +@router.post("/token_introspection", response_model=None) +async def oauth_introspection_endpoint( + request: Request, + credentials: HTTPBasicCredentials = Depends(basic_auth), + db_session: AsyncSession = Depends(get_db_session), + token: str = Form(), +) -> JSONResponse: + registered_client = ( + await db_session.scalars( + select(models.OAuthClient).where( + models.OAuthClient.client_id == credentials.username, + models.OAuthClient.client_secret == credentials.password, + ) + ) + ).one_or_none() + if not registered_client: + raise HTTPException(status_code=401, detail="unauthenticated") + + access_token = ( + await db_session.scalars( + select(models.IndieAuthAccessToken) + .where(models.IndieAuthAccessToken.access_token == token) + .join( + models.IndieAuthAuthorizationRequest, + models.IndieAuthAccessToken.indieauth_authorization_request_id + == models.IndieAuthAuthorizationRequest.id, + ) + .where( + models.IndieAuthAuthorizationRequest.client_id == credentials.username + ) + ) + ).one_or_none() + if not access_token: + return JSONResponse(content={"active": False}) + + is_token_valid, _ = await _check_access_token(db_session, token) + if not is_token_valid: + return JSONResponse(content={"active": False}) + + return JSONResponse( + content={ + "active": True, + "client_id": credentials.username, + "scope": access_token.scope, + "exp": int( + ( + access_token.created_at.replace(tzinfo=timezone.utc) + + timedelta(seconds=access_token.expires_in) + ).timestamp() + ), + }, + status_code=200, + ) diff --git a/app/ldsig.py b/app/ldsig.py index 36685168..2eea9de9 100644 --- a/app/ldsig.py +++ b/app/ldsig.py @@ -23,6 +23,13 @@ def _loader(url, options={}): # See https://github.com/digitalbazaar/pyld/issues/133 options["headers"]["Accept"] = "application/ld+json" + + # XXX: temp fix/hack is it seems to be down for now + if url == "https://w3id.org/identity/v1": + url = ( + "https://raw.githubusercontent.com/web-payments/web-payments.org" + "/master/contexts/identity-v1.jsonld" + ) return requests_loader(url, options) @@ -34,7 +41,7 @@ def _options_hash(doc: ap.RawObject) -> str: for k in ["type", "id", "signatureValue"]: if k in doc: del doc[k] - doc["@context"] = "https://w3id.org/identity/v1" + doc["@context"] = "https://w3id.org/security/v1" normalized = jsonld.normalize( doc, {"algorithm": "URDNA2015", "format": "application/nquads"} ) diff --git a/app/main.py b/app/main.py index 81ce131d..918c1592 100644 --- a/app/main.py +++ b/app/main.py @@ -62,6 +62,7 @@ from app.config import ID from app.config import USER_AGENT from app.config import USERNAME +from app.config import WEBFINGER_DOMAIN from app.config import is_activitypub_requested from app.config import verify_csrf_token from app.customization import get_custom_router @@ -80,8 +81,8 @@ from app.utils.url import check_url from app.webfinger import get_remote_follow_template -# Only images <1MB will be cached, so 64MB of data will be cached -_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(64) +# Only images <1MB will be cached, so 32MB of data will be cached +_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(32) # TODO(ts): @@ -132,9 +133,9 @@ async def send_wrapper(message: Message) -> None: headers = MutableHeaders(scope=message) headers["X-Request-ID"] = request_id headers["x-powered-by"] = "microblogpub" - headers[ - "referrer-policy" - ] = "no-referrer, strict-origin-when-cross-origin" + 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"] = "DENY" @@ -208,8 +209,9 @@ def _check_0rtt_early_data(request: Request) -> None: app.include_router(custom_router) # XXX: order matters, the proxy middleware needs to be last -app.add_middleware(CustomMiddleware) -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=config.CONFIG.trusted_hosts) +# Typechecks disabled due to https://github.com/encode/starlette/discussions/2451 +app.add_middleware(CustomMiddleware) # type: ignore +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=config.CONFIG.trusted_hosts) # type: ignore logger.configure(extra={"request_id": "no_req_id"}) logger.remove() @@ -226,7 +228,7 @@ def _check_0rtt_early_data(request: Request) -> None: async def custom_http_exception_handler( request: Request, exc: StarletteHTTPException, -) -> templates.TemplateResponse | JSONResponse: +) -> templates.TemplateResponse | JSONResponse | Response: accept_value = request.headers.get("accept") if ( accept_value @@ -281,7 +283,7 @@ async def redirect_to_remote_instance( ) -@app.get(config.NavBarItems.NOTES_PATH) +@app.get(config.NavBarItems.NOTES_PATH, response_model=None) async def index( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -340,7 +342,7 @@ async def index( ) -@app.get("/articles") +@app.get("/articles", response_model=None) async def articles( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -454,7 +456,7 @@ async def _empty_followx_collection( } -@app.get("/followers") +@app.get("/followers", response_model=None) async def followers( request: Request, page: bool | None = None, @@ -464,7 +466,12 @@ async def followers( _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), ) -> ActivityPubResponse | templates.TemplateResponse: if is_activitypub_requested(request): - if config.HIDES_FOLLOWERS: + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + + if config.HIDES_FOLLOWERS and not maybe_access_token_info: return ActivityPubResponse( await _empty_followx_collection( db_session=db_session, @@ -486,12 +493,12 @@ async def followers( if config.HIDES_FOLLOWERS and not is_current_user_admin(request): raise HTTPException(status_code=404) - # We only show the most recent 20 followers on the public website + # We only show the most recent 100 followers on the public website followers_result = await db_session.scalars( select(models.Follower) .options(joinedload(models.Follower.actor)) .order_by(models.Follower.created_at.desc()) - .limit(20) + .limit(100) ) followers = followers_result.unique().all() @@ -506,14 +513,11 @@ async def followers( db_session, request, "followers.html", - { - "followers": followers, - "actors_metadata": actors_metadata, - }, + {"followers": followers, "actors_metadata": actors_metadata}, ) -@app.get("/following") +@app.get("/following", response_model=None) async def following( request: Request, page: bool | None = None, @@ -523,7 +527,12 @@ async def following( _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), ) -> ActivityPubResponse | templates.TemplateResponse: if is_activitypub_requested(request): - if config.HIDES_FOLLOWING: + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + + if config.HIDES_FOLLOWING and not maybe_access_token_info: return ActivityPubResponse( await _empty_followx_collection( db_session=db_session, @@ -545,14 +554,14 @@ async def following( if config.HIDES_FOLLOWING and not is_current_user_admin(request): raise HTTPException(status_code=404) - # We only show the most recent 20 follows on the public website + # We only show the most recent 100 follows on the public website following = ( ( await db_session.scalars( select(models.Following) .options(joinedload(models.Following.actor)) .order_by(models.Following.created_at.desc()) - .limit(20) + .limit(100) ) ) .unique() @@ -577,24 +586,36 @@ async def following( ) -@app.get("/outbox") +@app.get("/outbox", response_model=None) async def outbox( + request: Request, db_session: AsyncSession = Depends(get_db_session), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), ) -> ActivityPubResponse: + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + + # Default restrictions unless the request is authenticated with an access token + restricted_where = [ + models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC, + models.OutboxObject.ap_type.in_(["Create", "Note", "Article", "Announce"]), + ] + # By design, we only show the last 20 public activities in the oubox outbox_objects = ( 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_(["Create", "Announce"]), + *([] if maybe_access_token_info else restricted_where), ) .order_by(models.OutboxObject.ap_published_at.desc()) .limit(20) ) ).all() + return ActivityPubResponse( { "@context": ap.AS_EXTENDED_CTX, @@ -609,7 +630,50 @@ async def outbox( ) -@app.get("/featured") +@app.post("/outbox", response_model=None) +async def post_outbox( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + access_token_info: indieauth.AccessTokenInfo = Depends( + indieauth.enforce_access_token + ), +) -> ActivityPubResponse: + payload = await request.json() + logger.info(f"{payload=}") + + if payload.get("type") == "Create": + assert payload["actor"] == ID + obj = payload["object"] + + to_and_cc = obj.get("to", []) + obj.get("cc", []) + if ap.AS_PUBLIC in obj.get("to", []) and ID + "/followers" in to_and_cc: + visibility = ap.VisibilityEnum.PUBLIC + elif ap.AS_PUBLIC in to_and_cc and ID + "/followers" in to_and_cc: + visibility = ap.VisibilityEnum.UNLISTED + else: + visibility = ap.VisibilityEnum.DIRECT + + object_id, outbox_object = await boxes.send_create( + db_session, + ap_type=obj["type"], + source=obj["content"], + uploads=[], + in_reply_to=obj.get("inReplyTo"), + visibility=visibility, + content_warning=obj.get("summary"), + is_sensitive=obj.get("sensitive", False), + ) + else: + raise ValueError("TODO") + + return ActivityPubResponse( + outbox_object.ap_object, + status_code=201, + headers={"Location": boxes.outbox_object_id(object_id)}, + ) + + +@app.get("/featured", response_model=None) async def featured( db_session: AsyncSession = Depends(get_db_session), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), @@ -646,6 +710,14 @@ async def _check_outbox_object_acl( if templates.is_current_user_admin(request): return None + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + if maybe_access_token_info: + # TODO: check scopes + return None + if ap_object.visibility in [ ap.VisibilityEnum.PUBLIC, ap.VisibilityEnum.UNLISTED, @@ -731,7 +803,7 @@ async def _fetch_webmentions( ).all() -@app.get("/o/{public_id}") +@app.get("/o/{public_id}", response_model=None) async def outbox_by_public_id( public_id: str, request: Request, @@ -860,7 +932,7 @@ def _merge_replies( return reply_tree_node -@app.get("/articles/{short_id}/{slug}") +@app.get("/articles/{short_id}/{slug}", response_model=None) async def article_by_slug( short_id: str, slug: str, @@ -910,7 +982,7 @@ async def article_by_slug( ) -@app.get("/o/{public_id}/activity") +@app.get("/o/{public_id}/activity", response_model=None) async def outbox_activity_by_public_id( public_id: str, request: Request, @@ -933,7 +1005,7 @@ async def outbox_activity_by_public_id( return ActivityPubResponse(ap.wrap_object(maybe_object.ap_object)) -@app.get("/t/{tag}") +@app.get("/t/{tag}", response_model=None) async def tag_by_name( tag: str, request: Request, @@ -1005,7 +1077,7 @@ async def tag_by_name( ) -@app.get("/e/{name}") +@app.get("/e/{name}", response_model=None) def emoji_by_name(name: str) -> ActivityPubResponse: try: emoji = EMOJIS_BY_NAME[f":{name}:"] @@ -1015,7 +1087,79 @@ def emoji_by_name(name: str) -> ActivityPubResponse: return ActivityPubResponse({"@context": ap.AS_EXTENDED_CTX, **emoji}) -@app.post("/inbox") +@app.get("/inbox", response_model=None) +async def get_inbox( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + access_token_info: indieauth.AccessTokenInfo = Depends( + indieauth.enforce_access_token + ), + page: bool | None = None, + next_cursor: str | None = None, +) -> ActivityPubResponse: + where = [ + models.InboxObject.ap_type.in_( + ["Create", "Follow", "Like", "Announce", "Undo", "Update"] + ) + ] + total_items = await db_session.scalar( + select(func.count(models.InboxObject.id)).where(*where) + ) + + if not page and not next_cursor: + return ActivityPubResponse( + { + "@context": ap.AS_CTX, + "id": ID + "/inbox", + "first": ID + "/inbox?page=true", + "type": "OrderedCollection", + "totalItems": total_items, + } + ) + + q = ( + select(models.InboxObject) + .where(*where) + .order_by(models.InboxObject.created_at.desc()) + ) # type: ignore + if next_cursor: + q = q.where( + models.InboxObject.created_at + < pagination.decode_cursor(next_cursor) # type: ignore + ) + q = q.limit(20) + + items = [item for item in (await db_session.scalars(q)).all()] + next_cursor = None + if ( + items + and await db_session.scalar( + select(func.count(models.InboxObject.id)).where( + *where, models.InboxObject.created_at < items[-1].created_at + ) + ) + > 0 + ): + next_cursor = pagination.encode_cursor(items[-1].created_at) + + collection_page = { + "@context": ap.AS_CTX, + "id": ( + ID + "/inbox?page=true" + if not next_cursor + else ID + f"/inbox?next_cursor={next_cursor}" + ), + "partOf": ID + "/inbox", + "type": "OrderedCollectionPage", + "orderedItems": [item.ap_object for item in items], + } + if next_cursor: + collection_page["next"] = ID + f"/inbox?next_cursor={next_cursor}" + + return ActivityPubResponse(collection_page) + + +@app.post("/inbox", response_model=None) async def inbox( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -1028,7 +1172,7 @@ async def inbox( return Response(status_code=202) -@app.get("/remote_follow") +@app.get("/remote_follow", response_model=None) async def get_remote_follow( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -1041,7 +1185,7 @@ async def get_remote_follow( ) -@app.post("/remote_follow") +@app.post("/remote_follow", response_model=None) async def post_remote_follow( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -1063,7 +1207,7 @@ async def post_remote_follow( ) -@app.get("/remote_interaction") +@app.get("/remote_interaction", response_model=None) async def remote_interaction( request: Request, ap_id: str, @@ -1084,7 +1228,7 @@ async def remote_interaction( ) -@app.post("/remote_interaction") +@app.post("/remote_interaction", response_model=None) async def post_remote_interaction( request: Request, db_session: AsyncSession = Depends(get_db_session), @@ -1107,15 +1251,19 @@ async def post_remote_interaction( ) -@app.get("/.well-known/webfinger") +@app.get("/.well-known/webfinger", response_model=None) async def wellknown_webfinger(resource: str) -> JSONResponse: """Exposes/servers WebFinger data.""" - if resource not in [f"acct:{USERNAME}@{DOMAIN}", ID]: + if resource not in [ + f"acct:{USERNAME}@{WEBFINGER_DOMAIN}", + ID, + f"acct:{USERNAME}@{DOMAIN}", + ]: logger.info(f"Got invalid req for {resource}") raise HTTPException(status_code=404) out = { - "subject": f"acct:{USERNAME}@{DOMAIN}", + "subject": f"acct:{USERNAME}@{WEBFINGER_DOMAIN}", "aliases": [ID], "links": [ { @@ -1138,7 +1286,7 @@ async def wellknown_webfinger(resource: str) -> JSONResponse: ) -@app.get("/.well-known/nodeinfo") +@app.get("/.well-known/nodeinfo", response_model=None) async def well_known_nodeinfo() -> dict[str, Any]: return { "links": [ @@ -1150,7 +1298,7 @@ async def well_known_nodeinfo() -> dict[str, Any]: } -@app.get("/nodeinfo") +@app.get("/nodeinfo", response_model=None) async def nodeinfo( db_session: AsyncSession = Depends(get_db_session), ): @@ -1179,15 +1327,11 @@ async def nodeinfo( ) -proxy_client = httpx.AsyncClient( - follow_redirects=True, - timeout=httpx.Timeout(timeout=10.0), - transport=httpx.AsyncHTTPTransport(retries=1), -) - - 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( @@ -1228,24 +1372,35 @@ def _add_cache_control(headers: dict[str, str]) -> dict[str, str]: return {**headers, "Cache-Control": "max-age=31536000"} -@app.get("/proxy/media/{exp}/{sig}/{encoded_url}") +@app.get("/proxy/media/{exp}/{sig}/{encoded_url}", response_model=None) async def serve_proxy_media( request: Request, 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, ) @@ -1256,6 +1411,7 @@ async def serve_proxy_media( _filter_proxy_resp_headers( proxy_resp, [ + "content-encoding", "content-length", "content-type", "content-range", @@ -1271,23 +1427,26 @@ async def serve_proxy_media( ) -@app.get("/proxy/media/{exp}/{sig}/{encoded_url}/{size}") +@app.get("/proxy/media/{exp}/{sig}/{encoded_url}/{size}", response_model=None) async def serve_proxy_media_resized( request: Request, exp: int, sig: str, encoded_url: str, size: int, + background_tasks: fastapi.BackgroundTasks, ) -> PlainTextResponse: if size not in {50, 740}: raise ValueError("Unsupported size") + is_webp_supported = "image/webp" in str(request.headers.get("accept")) + # Decode the base64-encoded URL url = base64.urlsafe_b64decode(encoded_url).decode() check_url(url) media.verify_proxied_media_sig(exp, url, sig) - if cached_resp := _RESIZED_CACHE.get((url, size)): + if (cached_resp := _RESIZED_CACHE.get((url, size))) and is_webp_supported: resized_content, resized_mimetype, resp_headers = cached_resp return PlainTextResponse( resized_content, @@ -1295,11 +1454,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, ) @@ -1325,10 +1494,10 @@ async def serve_proxy_media_resized( is_webp = False try: resized_buf = BytesIO() - i.save(resized_buf, format="webp") - is_webp = True + i.save(resized_buf, format="webp" if is_webp_supported else i.format) + is_webp = is_webp_supported except Exception: - logger.exception("Failed to convert to webp") + logger.exception("Failed to create thumbnail") resized_buf = BytesIO() i.save(resized_buf, format=i.format) resized_buf.seek(0) @@ -1361,7 +1530,7 @@ async def serve_proxy_media_resized( ) -@app.get("/attachments/{content_hash}/{filename}") +@app.get("/attachments/{content_hash}/{filename}", response_model=None) async def serve_attachment( content_hash: str, filename: str, @@ -1384,8 +1553,9 @@ async def serve_attachment( ) -@app.get("/attachments/thumbnails/{content_hash}/{filename}") +@app.get("/attachments/thumbnails/{content_hash}/{filename}", response_model=None) async def serve_attachment_thumbnail( + request: Request, content_hash: str, filename: str, db_session: AsyncSession = Depends(get_db_session), @@ -1400,11 +1570,20 @@ async def serve_attachment_thumbnail( if not upload or not upload.has_thumbnail: raise HTTPException(status_code=404) - return FileResponse( - UPLOAD_DIR / (content_hash + "_resized"), - media_type="image/webp", - headers={"Cache-Control": "max-age=31536000"}, - ) + is_webp_supported = "image/webp" in str(request.headers.get("accept")) + + if is_webp_supported: + return FileResponse( + UPLOAD_DIR / (content_hash + "_resized"), + media_type="image/webp", + headers={"Cache-Control": "max-age=31536000"}, + ) + else: + return FileResponse( + UPLOAD_DIR / content_hash, + media_type=upload.content_type, + headers={"Cache-Control": "max-age=31536000"}, + ) @app.get("/robots.txt", response_class=PlainTextResponse) @@ -1464,23 +1643,26 @@ async def json_feed( } ) result = { - "version": "https://jsonfeed.org/version/1", + "version": "https://jsonfeed.org/version/1.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, - }, + "authors": [ + { + "name": LOCAL_ACTOR.display_name, + "url": LOCAL_ACTOR.url, + } + ], "items": data, } if LOCAL_ACTOR.icon_url: - result["author"]["avatar"] = LOCAL_ACTOR.icon_url # type: ignore + result["authors"][0]["avatar"] = LOCAL_ACTOR.icon_url # type: ignore return result async def _gen_rss_feed( db_session: AsyncSession, + is_rss: bool, ): fg = FeedGenerator() fg.id(BASE_URL + "/feed.rss") @@ -1511,8 +1693,12 @@ async def _gen_rss_feed( fe = fg.add_entry() fe.id(outbox_object.url) + if outbox_object.name is not None: + fe.title(outbox_object.name) + elif not is_rss: # Atom feeds require a title + fe.title(outbox_object.url) + fe.link(href=outbox_object.url) - fe.title(outbox_object.url) fe.description(content) fe.content(content) fe.published(outbox_object.ap_published_at.replace(tzinfo=timezone.utc)) @@ -1525,16 +1711,16 @@ async def rss_feed( db_session: AsyncSession = Depends(get_db_session), ) -> PlainTextResponse: return PlainTextResponse( - (await _gen_rss_feed(db_session)).rss_str(), + (await _gen_rss_feed(db_session, is_rss=True)).rss_str(), headers={"Content-Type": "application/rss+xml"}, ) -@app.get("/feed.atom") +@app.get("/feed.atom", response_model=None) async def atom_feed( db_session: AsyncSession = Depends(get_db_session), ) -> PlainTextResponse: return PlainTextResponse( - (await _gen_rss_feed(db_session)).atom_str(), + (await _gen_rss_feed(db_session, is_rss=False)).atom_str(), headers={"Content-Type": "application/atom+xml"}, ) diff --git a/app/micropub.py b/app/micropub.py index 24c63f52..6910b2f8 100644 --- a/app/micropub.py +++ b/app/micropub.py @@ -19,17 +19,18 @@ router = APIRouter() -@router.get("/micropub") +@router.get("/micropub", response_model=None) async def micropub_endpoint( request: Request, access_token_info: AccessTokenInfo = Depends(verify_access_token), db_session: AsyncSession = Depends(get_db_session), ) -> dict[str, Any] | JSONResponse: - if request.query_params.get("q") == "config": + q = request.query_params.get("q") + url = request.query_params.get("url") + if q == "config": return {} - elif request.query_params.get("q") == "source": - url = request.query_params.get("url") + elif q == "source" and url is not None: outbox_object = await get_outbox_object_by_ap_id(db_session, url) if not outbox_object: return JSONResponse( @@ -64,7 +65,7 @@ def _prop_get(dat: dict[str, Any], key: str) -> str: return val -@router.post("/micropub") +@router.post("/micropub", response_model=None) async def post_micropub_endpoint( request: Request, access_token_info: AccessTokenInfo = Depends(verify_access_token), @@ -83,7 +84,7 @@ async def post_micropub_endpoint( if "action" in form_data: if form_data["action"] in ["delete", "update"]: outbox_object = await get_outbox_object_by_ap_id( - db_session, form_data["url"] + db_session, str(form_data["url"]) ) if not outbox_object: return JSONResponse( @@ -129,10 +130,10 @@ async def post_micropub_endpoint( else: h = "entry" if "h" in form_data: - h = form_data["h"] + h = str(form_data["h"]) entry_type = f"h-{h}" - logger.info(f"Creating {entry_type}") + logger.info(f"Creating {entry_type=} with {access_token_info=}") if entry_type != "h-entry": return JSONResponse( @@ -148,9 +149,9 @@ async def post_micropub_endpoint( if is_json: content = _prop_get(form_data["properties"], "content") # type: ignore else: - content = form_data["content"] + content = str(form_data["content"]) - public_id = await send_create( + public_id, _ = await send_create( db_session, "Note", content, @@ -163,6 +164,6 @@ async def post_micropub_endpoint( content={}, status_code=201, headers={ - "Location": request.url_for("outbox_by_public_id", public_id=public_id) + "Location": str(request.url_for("outbox_by_public_id", public_id=public_id)) }, ) diff --git a/app/models.py b/app/models.py index 13b1b0b6..f1b4caaa 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,5 @@ import enum +from datetime import datetime from typing import Any from typing import Optional from typing import Union @@ -54,6 +55,10 @@ class Actor(Base, BaseActor): is_blocked = Column(Boolean, nullable=False, default=False, server_default="0") is_deleted = Column(Boolean, nullable=False, default=False, server_default="0") + are_announces_hidden_from_stream = Column( + Boolean, nullable=False, default=False, server_default="0" + ) + @property def is_from_db(self) -> bool: return True @@ -254,14 +259,16 @@ def attachments(self) -> list[Attachment]: "width": attachment.upload.width, "height": attachment.upload.height, "proxiedUrl": url, - "resizedUrl": BASE_URL - + ( - "/attachments/thumbnails/" - f"{attachment.upload.content_hash}" - f"/{attachment.filename}" - ) - if attachment.upload.has_thumbnail - else None, + "resizedUrl": ( + BASE_URL + + ( + "/attachments/thumbnails/" + f"{attachment.upload.content_hash}" + f"/{attachment.filename}" + ) + if attachment.upload.has_thumbnail + else None + ), } ) ) @@ -432,7 +439,7 @@ class OutboxObjectAttachment(Base): outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False) upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False) - upload = relationship(Upload, uselist=False) + upload: Mapped["Upload"] = relationship(Upload, uselist=False) class IndieAuthAuthorizationRequest(Base): @@ -455,17 +462,45 @@ class IndieAuthAccessToken(Base): __tablename__ = "indieauth_access_token" id = Column(Integer, primary_key=True, index=True) - created_at = Column(DateTime(timezone=True), nullable=False, default=now) + created_at: Mapped[datetime] = Column( + DateTime(timezone=True), nullable=False, default=now + ) # Will be null for personal access tokens indieauth_authorization_request_id = Column( Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True ) + indieauth_authorization_request = relationship( + IndieAuthAuthorizationRequest, + uselist=False, + ) - access_token = Column(String, nullable=False, unique=True, index=True) - expires_in = Column(Integer, nullable=False) + access_token: Mapped[str] = Column(String, nullable=False, unique=True, index=True) + refresh_token = Column(String, nullable=True, unique=True, index=True) + expires_in: Mapped[int] = Column(Integer, nullable=False) scope = Column(String, nullable=False) is_revoked = Column(Boolean, nullable=False, default=False) + was_refreshed = Column(Boolean, nullable=False, default=False, server_default="0") + + +class OAuthClient(Base): + __tablename__ = "oauth_client" + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=now) + + # Request + client_name = Column(String, nullable=False) + redirect_uris: Mapped[list[str]] = Column(JSON, nullable=True) + + # Optional from request + client_uri = Column(String, nullable=True) + logo_uri = Column(String, nullable=True) + scope = Column(String, nullable=True) + + # Response + client_id = Column(String, nullable=False, unique=True, index=True) + client_secret = Column(String, nullable=False, unique=True) @enum.unique diff --git a/app/outgoing_activities.py b/app/outgoing_activities.py index 1d992a91..022334d5 100644 --- a/app/outgoing_activities.py +++ b/app/outgoing_activities.py @@ -151,7 +151,7 @@ def _set_next_try( if not outgoing_activity.tries: raise ValueError("Should never happen") - if outgoing_activity.tries == _MAX_RETRIES: + if outgoing_activity.tries >= _MAX_RETRIES: outgoing_activity.is_errored = True outgoing_activity.next_try = None else: diff --git a/app/prune.py b/app/prune.py index 6cb25acc..75ca89b6 100644 --- a/app/prune.py +++ b/app/prune.py @@ -102,6 +102,8 @@ async def _prune_old_inbox_objects( models.InboxObject.ap_type.in_(["Note"]), ) ), + # Keep Move object as they are linked to notifications + models.InboxObject.ap_type.not_in(["Move"]), # Filter by retention days models.InboxObject.ap_published_at < now() - timedelta(days=INBOX_RETENTION_DAYS), diff --git a/app/redirect.py b/app/redirect.py new file mode 100644 index 00000000..137e80b8 --- /dev/null +++ b/app/redirect.py @@ -0,0 +1,28 @@ +from fastapi import Request + +from app import templates +from app.database import AsyncSession + + +async def redirect( + request: Request, + db_session: AsyncSession, + url: str, +) -> templates.TemplateResponse: + """ + Similar to RedirectResponse, but uses a 200 response with HTML. + + Needed for remote redirects on form submission endpoints, + since our CSP policy disallows remote form submission. + https://github.com/w3c/webappsec-csp/issues/8#issuecomment-810108984 + """ + return await templates.render_template( + db_session, + request, + "redirect.html", + { + "request": request, + "url": url, + }, + headers={"Refresh": "0;url=" + url}, + ) diff --git a/app/scss/main.scss b/app/scss/main.scss index 75e8e90f..8f68247a 100644 --- a/app/scss/main.scss +++ b/app/scss/main.scss @@ -6,6 +6,8 @@ $primary-color: #1d781d; $secondary-color: #781D78; $form-background-color: #ccc; $form-text-color: #333; +$nav-button-background-color: #ccc; +$nav-button-text-color: #333; $muted-color: #555; // solarized comment text $primary-button-text-color: #fff; $code-highlight-background: #f0f0f0; @@ -139,7 +141,7 @@ dl { } } -.shared-header { +.shared-header .actor-action { margin-left: 20px; margin-top: 30px; margin-bottom: -20px; @@ -149,6 +151,9 @@ dl { span { color: $muted-color; } + span.new { + color: $secondary-color; + } } div.highlight { @@ -216,7 +221,7 @@ a { } #main { display: flex; - flex: 1; + flex: 1; } main { width: 100%; @@ -245,7 +250,7 @@ footer { margin: 20px auto; color: $muted-color; p { - margin: 0; + margin: 0; } } .tiny-actor-icon { @@ -294,23 +299,19 @@ footer { } @mixin admin-button() { - font-size: 20px; - line-height: 32px; + font-size: 18px; font-family: $font-stack; - background: $form-background-color; - color: $form-text-color; - border: 1px solid $background; - padding: 8px 10px 5px 10px; + background: $nav-button-background-color; + color: $nav-button-text-color; + border: 0px; + padding: 5px 20px 5px 20px; cursor: pointer; - &:hover { - border: 1px solid $form-text-color; - } + text-transform: uppercase; + box-shadow: #777 0 2px 3px; } -.show-sensitive-btn, .show-more-btn, .label-btn { +.show-sensitive-btn, .show-more-btn, .label-btn, input[type=submit], button[type=submit] { @include admin-button; - padding: 10px 5px; - margin: 20px 0; } .show-hide-sensitive-btn { @@ -366,15 +367,12 @@ ul.poll-items { nav { form { - margin: 15px 0; - } - input[type=submit], button { - @include admin-button; + margin: 10px 0; } } nav.flexbox { - ul { + ul { display: flex; flex-wrap: wrap; align-items: center; @@ -385,7 +383,7 @@ nav.flexbox { } ul li { - margin-right: 20px; + margin-right: 15px; &:last-child { margin-right: 0px; @@ -407,10 +405,10 @@ nav.flexbox { // after nav.flexbox to override default behavior a.label-btn { - color: $form-text-color; + color: $nav-button-text-color; &:hover { text-decoration: none; - color: $form-text-color; + color: $nav-button-text-color; } } @@ -432,8 +430,7 @@ a.label-btn { .activity-attachment { margin: 30px 0 20px 0; img, audio, video { - width: 100%; - max-width: 740px; + max-width: calc(min(740px, 100%)); } } img.inline-img { @@ -459,21 +456,10 @@ a.label-btn { border: 2px dashed $secondary-color; } -.error-box { +.error-box, .scolor { color: $secondary-color; } -.actor-action { - margin-top:20px; - margin-bottom:-20px; - padding: 0 20px; - span { - color: $muted-color; - } - span.new { - color: $secondary-color; - } -} .actor-metadata { color: $muted-color; } @@ -551,3 +537,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; +} diff --git a/app/source.py b/app/source.py index a46707fb..20e98eea 100644 --- a/app/source.py +++ b/app/source.py @@ -3,12 +3,12 @@ from loguru import logger from mistletoe import Document # type: ignore +from mistletoe.block_token import CodeFence # type: ignore from mistletoe.html_renderer import HTMLRenderer # type: ignore from mistletoe.span_token import SpanToken # type: ignore -from pygments import highlight # type: ignore from pygments.formatters import HtmlFormatter # type: ignore from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore -from pygments.lexers import guess_lexer # type: ignore +from pygments.util import ClassNotFound # type: ignore from sqlalchemy import select from app import webfinger @@ -104,10 +104,16 @@ def render_hashtag(self, token: Hashtag) -> str: ) return link - def render_block_code(self, token: typing.Any) -> str: + def render_block_code(self, token: CodeFence) -> str: + lexer_attr = "" + try: + lexer = get_lexer(token.language) + lexer_attr = f' data-microblogpub-lexer="{lexer.aliases[0]}"' + except ClassNotFound: + pass + code = token.children[0].content - lexer = get_lexer(token.language) if token.language else guess_lexer(code) - return highlight(code, lexer, _FORMATTER) + return f"
\n{code}\n
" async def _prefetch_mentioned_actors( diff --git a/app/static/avatar.jpeg b/app/static/avatar.jpeg new file mode 100644 index 00000000..894e28db Binary files /dev/null and b/app/static/avatar.jpeg differ diff --git a/app/static/common.js b/app/static/common.js new file mode 100644 index 00000000..410b96f6 --- /dev/null +++ b/app/static/common.js @@ -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); + }); + } +} diff --git a/app/static/htmx-1_9_12.min.js b/app/static/htmx-1_9_12.min.js new file mode 100644 index 00000000..de5f0f1a --- /dev/null +++ b/app/static/htmx-1_9_12.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/app/static/new.js b/app/static/new.js index 065bad57..a846bf73 100644 --- a/app/static/new.js +++ b/app/static/new.js @@ -51,6 +51,5 @@ files.addEventListener("change", function(e) { } }); // Focus at the end of the textarea -const end = ta.value.length; -ta.setSelectionRange(end, end); +ta.setSelectionRange(ta.value.length, ta.value.length); ta.focus(); diff --git a/app/static/pwa.webmanifest b/app/static/pwa.webmanifest new file mode 100644 index 00000000..740dec67 --- /dev/null +++ b/app/static/pwa.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Microblog.pub", + "short_name": "microblog.pub", + "icons": [ + { + "src": "/static/avatar.jpeg", + "sizes": "400x400", + "type": "image/jpeg" + } + ], + "start_url": "/admin/stream", + "display": "standalone", + "theme_color": "#22ADC2", + "background_color": "#22ADC2" +} + diff --git a/app/templates.py b/app/templates.py index ea4fb268..baee6598 100644 --- a/app/templates.py +++ b/app/templates.py @@ -104,13 +104,15 @@ async def render_template( "csrf_token": generate_csrf_token(), "highlight_css": HIGHLIGHT_CSS, "visibility_enum": ap.VisibilityEnum, - "notifications_count": await db_session.scalar( - select(func.count(models.Notification.id)).where( - models.Notification.is_new.is_(True) + "notifications_count": ( + await db_session.scalar( + select(func.count(models.Notification.id)).where( + models.Notification.is_new.is_(True) + ) ) - ) - if is_admin - else 0, + if is_admin + else 0 + ), "articles_count": await db_session.scalar( select(func.count(models.OutboxObject.id)).where( models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC, @@ -136,7 +138,7 @@ async def render_template( # HTML/templates helper -ALLOWED_TAGS = [ +ALLOWED_TAGS = { "a", "abbr", "acronym", @@ -175,7 +177,7 @@ async def render_template( "img", "div", "span", -] +} ALLOWED_CSS_CLASSES = [ # microformats diff --git a/app/templates/admin_direct_messages.html b/app/templates/admin_direct_messages.html index c6a23196..99c3c142 100644 --- a/app/templates/admin_direct_messages.html +++ b/app/templates/admin_direct_messages.html @@ -2,19 +2,22 @@ {% extends "layout.html" %} {% block head %} + {{ local_actor.display_name }} - Direct messages {% endblock %} {% block content %} {% for anybox_object, convo, actors in threads %} -
- With {% for actor in actors %} - - {{ actor.handle }} - - {% endfor %} +
+
+ With {% for actor in actors %} + + {{ actor.handle }} + + {% endfor %} +
+ {{ utils.display_object(anybox_object, is_h_entry=false) }}
- {{ utils.display_object(anybox_object) }} {% endfor %} {% endblock %} diff --git a/app/templates/admin_inbox.html b/app/templates/admin_inbox.html index 221b1972..2d435cce 100644 --- a/app/templates/admin_inbox.html +++ b/app/templates/admin_inbox.html @@ -1,14 +1,18 @@ {%- import "utils.html" as utils with context -%} +{%- import "components/actor.html" as actor_component -%} +{%- import "components/filters.html" as filters -%} +{%- import "components/pagination.html" as pagination -%} {% extends "layout.html" %} {% block head %} + {{ local_actor.display_name }} - Inbox {% endblock %} {% block content %} {% if show_filters %} -{{ utils.display_box_filters("admin_inbox") }} + {{ filters.filters(url_for("admin_inbox"), request.query_params.filter_by) }} {% endif %} {% if not inbox %} @@ -19,16 +23,26 @@ {% for inbox_object in inbox %} {% if inbox_object.ap_type == "Announce" %} - {{ utils.actor_action(inbox_object, "shared", with_icon=True) }} - {{ utils.display_object(inbox_object.relates_to_anybox_object) }} -{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %} -{{ utils.display_object(inbox_object) }} +
+ {{ actor_component.actor_action(url_for("admin_profile"), inbox_object, "shared", with_icon=True) }} +
+ {{ utils.display_object(inbox_object.relates_to_anybox_object, is_h_entry=False) }} +
+
+{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question", "Event"] %} + {{ utils.display_object(inbox_object) }} {% elif inbox_object.ap_type == "Follow" %} - {{ utils.actor_action(inbox_object, "followed you") }} - {{ utils.display_actor(inbox_object.actor, actors_metadata) }} +
+ {{ actor_component.actor_action(url_for("admin_profile"), inbox_object, "followed you") }} + {{ actor_component.display_actor(request, csrf_token, is_admin, inbox_object.actor, actors_metadata) }} +
{% elif inbox_object.ap_type == "Like" %} - {{ utils.actor_action(inbox_object, "liked one of your posts", with_icon=True) }} - {{ utils.display_object(inbox_object.relates_to_anybox_object) }} +
+ {{ actor_component.actor_action(url_for("admin_profile"), inbox_object, "liked one of your posts", with_icon=True) }} +
+ {{ utils.display_object(inbox_object.relates_to_anybox_object, is_h_entry=False) }} +
+
{% else %}

Implement {{ inbox_object.ap_type }} @@ -37,10 +51,6 @@ {% endif %} {% endfor %} -{% if next_cursor %} -

-{% endif %} +{{ pagination.pagination(request.url._path, next_cursor, request.query_params.filter_by) }} {% endblock %} diff --git a/app/templates/admin_new.html b/app/templates/admin_new.html index ac3b3db3..c577dddf 100644 --- a/app/templates/admin_new.html +++ b/app/templates/admin_new.html @@ -1,7 +1,9 @@ {%- import "utils.html" as utils with context -%} +{%- import "components/hidden_inputs.html" as hidden_inputs -%} {% extends "layout.html" %} {% block head %} + {{ local_actor.display_name }} - New {% endblock %} @@ -13,7 +15,7 @@ {% endif %}
-