forked from forks/microblog.pub
Compare commits
No commits in common. "v2" and "ynh-support" have entirely different histories.
v2
...
ynh-suppor
95 changed files with 2557 additions and 7988 deletions
11
AUTHORS
11
AUTHORS
|
@ -1,11 +0,0 @@
|
||||||
Thomas Sileo <t@a4.io>
|
|
||||||
Kevin Wallace <doof@doof.net>
|
|
||||||
Miguel Jacq <mig@mig5.net>
|
|
||||||
Alexey Shpakovsky <alexey@shpakovsky.ru>
|
|
||||||
Josh Washburne <josh@jodh.us>
|
|
||||||
João Costa <jdpc557@gmail.com>
|
|
||||||
Sam <samr1.dev@pm.me>
|
|
||||||
Ash McAllan <acegiak@gmail.com>
|
|
||||||
Cassio Zen <cassio@hey.com>
|
|
||||||
Cocoa <momijizukamori@gmail.com>
|
|
||||||
Jane <jane@janeirl.dev>
|
|
11
Dockerfile
11
Dockerfile
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.11-slim as python-base
|
FROM python:3.10-slim as python-base
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
POETRY_HOME="/opt/poetry" \
|
POETRY_HOME="/opt/poetry" \
|
||||||
|
@ -10,18 +10,13 @@ ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
|
||||||
|
|
||||||
FROM python-base as builder-base
|
FROM python-base as builder-base
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y --no-install-recommends curl build-essential gcc libffi-dev libssl-dev libxml2-dev libxslt1-dev zlib1g-dev libxslt-dev gcc libjpeg-dev zlib1g-dev libwebp-dev
|
RUN apt-get install -y --no-install-recommends curl build-essential gcc
|
||||||
# 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
|
WORKDIR $PYSETUP_PATH
|
||||||
COPY poetry.lock pyproject.toml ./
|
COPY poetry.lock pyproject.toml ./
|
||||||
RUN poetry install --only main
|
RUN poetry install --no-dev
|
||||||
|
|
||||||
FROM python-base as production
|
FROM python-base as production
|
||||||
RUN apt-get update
|
|
||||||
RUN apt-get install -y --no-install-recommends libjpeg-dev libxslt1-dev libxml2-dev libxslt-dev
|
|
||||||
RUN groupadd --gid 1000 microblogpub \
|
RUN groupadd --gid 1000 microblogpub \
|
||||||
&& useradd --uid 1000 --gid microblogpub --shell /bin/bash microblogpub
|
&& useradd --uid 1000 --gid microblogpub --shell /bin/bash microblogpub
|
||||||
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
|
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
|
||||||
|
|
33
Makefile
33
Makefile
|
@ -9,39 +9,12 @@ build:
|
||||||
config:
|
config:
|
||||||
# Run and remove instantly
|
# Run and remove instantly
|
||||||
-docker run --rm -it --volume `pwd`/data:/app/data microblogpub/microblogpub inv configuration-wizard
|
-docker run --rm -it --volume `pwd`/data:/app/data microblogpub/microblogpub inv configuration-wizard
|
||||||
|
-docker run --env MICROBLOGPUB_CONFIG_FILE=tests.toml --rm -it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv configuration-wizard
|
||||||
|
|
||||||
.PHONY: update
|
.PHONY: update
|
||||||
update:
|
update:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update
|
||||||
|
|
||||||
.PHONY: prune-old-data
|
.PHONY: prune-old-data
|
||||||
prune-old-data:
|
prune-old-data:
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
||||||
|
|
||||||
.PHONY: webfinger
|
|
||||||
webfinger:
|
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
|
|
||||||
|
|
||||||
.PHONY: move-to
|
|
||||||
move-to:
|
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
|
|
||||||
|
|
||||||
.PHONY: self-destruct
|
|
||||||
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:
|
|
||||||
-docker run --rm -it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv reset-password
|
|
||||||
|
|
||||||
.PHONY: check-config
|
|
||||||
check-config:
|
|
||||||
-docker run --rm --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv 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)
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ Instances in the wild:
|
||||||
- [microblog.pub](https://microblog.pub/) (follow to get updated about the project)
|
- [microblog.pub](https://microblog.pub/) (follow to get updated about the project)
|
||||||
- [hexa.ninja](https://hexa.ninja) (theme customization example)
|
- [hexa.ninja](https://hexa.ninja) (theme customization example)
|
||||||
- [testing.microblog.pub](https://testing.microblog.pub/)
|
- [testing.microblog.pub](https://testing.microblog.pub/)
|
||||||
- [Irish Left Archive](https://posts.leftarchive.ie/) (another theme customization example)
|
|
||||||
|
|
||||||
There are still some rough edges, but the server is mostly functional.
|
There are still some rough edges, but the server is mostly functional.
|
||||||
|
|
||||||
|
@ -23,7 +22,7 @@ There are still some rough edges, but the server is mostly functional.
|
||||||
- Author notes in Markdown, with code highlighting support
|
- Author notes in Markdown, with code highlighting support
|
||||||
- Dedicated section for articles/blog posts (enabled when the first article is posted)
|
- Dedicated section for articles/blog posts (enabled when the first article is posted)
|
||||||
- Lightweight
|
- Lightweight
|
||||||
- Uses SQLite, and Python 3.10+
|
- Uses SQLite, and no external dependencies except Python 3.10+
|
||||||
- Can be deployed on small VPS
|
- Can be deployed on small VPS
|
||||||
- Privacy-aware
|
- Privacy-aware
|
||||||
- EXIF metadata (like GPS location) are stripped before storage
|
- EXIF metadata (like GPS location) are stripped before storage
|
||||||
|
@ -59,7 +58,7 @@ All the development takes place on [sourcehut](https://sr.ht/~tsileo/microblog.p
|
||||||
- [Issue tracker](https://todo.sr.ht/~tsileo/microblog.pub)
|
- [Issue tracker](https://todo.sr.ht/~tsileo/microblog.pub)
|
||||||
- [Mailing list](https://sr.ht/~tsileo/microblog.pub/lists)
|
- [Mailing list](https://sr.ht/~tsileo/microblog.pub/lists)
|
||||||
|
|
||||||
Contributions are welcomed, check out the [contributing section of the documentation](https://docs.microblog.pub/developer_guide.html#contributing) for more details.
|
Contributions are welcomed, check out the [documentation](https://docs.microblog.pub) for more details.
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
"""Add a slug field for outbox objects
|
|
||||||
|
|
||||||
Revision ID: b28c0551c236
|
|
||||||
Revises: 604d125ea2fb
|
|
||||||
Create Date: 2022-10-30 14:09:14.540461+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm.session import Session
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'b28c0551c236'
|
|
||||||
down_revision = '604d125ea2fb'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('slug', sa.String(), nullable=True))
|
|
||||||
batch_op.create_index(batch_op.f('ix_outbox_slug'), ['slug'], unique=False)
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
# Backfill the slug for existing articles
|
|
||||||
from app.models import OutboxObject
|
|
||||||
from app.utils.text import slugify
|
|
||||||
sess = Session(op.get_bind())
|
|
||||||
articles = sess.execute(select(OutboxObject).where(
|
|
||||||
OutboxObject.ap_type == "Article")
|
|
||||||
).scalars()
|
|
||||||
for article in articles:
|
|
||||||
title = article.ap_object["name"]
|
|
||||||
article.slug = slugify(title)
|
|
||||||
sess.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('outbox', schema=None) as batch_op:
|
|
||||||
batch_op.drop_index(batch_op.f('ix_outbox_slug'))
|
|
||||||
batch_op.drop_column('slug')
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,32 +0,0 @@
|
||||||
"""Add Webmention.webmention_type
|
|
||||||
|
|
||||||
Revision ID: fadfd359ce78
|
|
||||||
Revises: b28c0551c236
|
|
||||||
Create Date: 2022-11-16 19:42:56.925512+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = 'fadfd359ce78'
|
|
||||||
down_revision = 'b28c0551c236'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('webmention', schema=None) as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('webmention_type', sa.Enum('UNKNOWN', 'LIKE', 'REPLY', 'REPOST', name='webmentiontype'), nullable=True))
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('webmention', schema=None) as batch_op:
|
|
||||||
batch_op.drop_column('webmention_type')
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,32 +0,0 @@
|
||||||
"""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 ###
|
|
|
@ -1,48 +0,0 @@
|
||||||
"""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 ###
|
|
|
@ -1,36 +0,0 @@
|
||||||
"""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 ###
|
|
|
@ -6,15 +6,12 @@ from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.config import ALSO_KNOWN_AS
|
|
||||||
from app.config import AP_CONTENT_TYPE # noqa: F401
|
from app.config import AP_CONTENT_TYPE # noqa: F401
|
||||||
from app.config import MOVED_TO
|
|
||||||
from app.httpsig import auth
|
from app.httpsig import auth
|
||||||
from app.key import get_pubkey_as_pem
|
from app.key import get_pubkey_as_pem
|
||||||
from app.source import dedup_tags
|
|
||||||
from app.source import hashtagify
|
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -35,7 +32,6 @@ AS_EXTENDED_CTX = [
|
||||||
"sensitive": "as:sensitive",
|
"sensitive": "as:sensitive",
|
||||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||||
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
||||||
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
|
|
||||||
# toot
|
# toot
|
||||||
"toot": "http://joinmastodon.org/ns#",
|
"toot": "http://joinmastodon.org/ns#",
|
||||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||||
|
@ -53,26 +49,11 @@ AS_EXTENDED_CTX = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class FetchError(Exception):
|
class ObjectIsGoneError(Exception):
|
||||||
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
|
||||||
resp_part = ""
|
|
||||||
if resp:
|
|
||||||
resp_part = f", got HTTP {resp.status_code}: {resp.text}"
|
|
||||||
message = f"Failed to fetch {url}{resp_part}"
|
|
||||||
super().__init__(message)
|
|
||||||
self.resp = resp
|
|
||||||
self.url = url
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectIsGoneError(FetchError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ObjectNotFoundError(FetchError):
|
class ObjectNotFoundError(Exception):
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectUnavailableError(FetchError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -100,21 +81,6 @@ class VisibilityEnum(str, enum.Enum):
|
||||||
}[key]
|
}[key]
|
||||||
|
|
||||||
|
|
||||||
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
|
||||||
_LOCAL_ACTOR_METADATA = []
|
|
||||||
if config.CONFIG.metadata:
|
|
||||||
for kv in config.CONFIG.metadata:
|
|
||||||
kv_value, kv_tags = hashtagify(kv.value)
|
|
||||||
_LOCAL_ACTOR_METADATA.append(
|
|
||||||
{
|
|
||||||
"name": kv.key,
|
|
||||||
"type": "PropertyValue",
|
|
||||||
"value": kv_value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_LOCAL_ACTOR_TAGS.extend(kv_tags)
|
|
||||||
|
|
||||||
|
|
||||||
ME = {
|
ME = {
|
||||||
"@context": AS_EXTENDED_CTX,
|
"@context": AS_EXTENDED_CTX,
|
||||||
"type": "Person",
|
"type": "Person",
|
||||||
|
@ -126,7 +92,7 @@ ME = {
|
||||||
"outbox": config.BASE_URL + "/outbox",
|
"outbox": config.BASE_URL + "/outbox",
|
||||||
"preferredUsername": config.USERNAME,
|
"preferredUsername": config.USERNAME,
|
||||||
"name": config.CONFIG.name,
|
"name": config.CONFIG.name,
|
||||||
"summary": _LOCAL_ACTOR_SUMMARY,
|
"summary": config.CONFIG.summary,
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
# For compat with servers expecting a sharedInbox...
|
# For compat with servers expecting a sharedInbox...
|
||||||
"sharedInbox": config.BASE_URL
|
"sharedInbox": config.BASE_URL
|
||||||
|
@ -134,35 +100,28 @@ ME = {
|
||||||
},
|
},
|
||||||
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
"url": config.ID + "/", # XXX: the path is important for Mastodon compat
|
||||||
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
"manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
|
||||||
"attachment": _LOCAL_ACTOR_METADATA,
|
"attachment": [
|
||||||
|
{
|
||||||
|
"name": kv.key,
|
||||||
|
"type": "PropertyValue",
|
||||||
|
"value": markdown(kv.value, extensions=["mdx_linkify", "fenced_code"]),
|
||||||
|
}
|
||||||
|
for kv in config.CONFIG.metadata
|
||||||
|
]
|
||||||
|
if config.CONFIG.metadata
|
||||||
|
else [],
|
||||||
|
"icon": {
|
||||||
|
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
||||||
|
"type": "Image",
|
||||||
|
"url": config.CONFIG.icon_url,
|
||||||
|
},
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": f"{config.ID}#main-key",
|
"id": f"{config.ID}#main-key",
|
||||||
"owner": config.ID,
|
"owner": config.ID,
|
||||||
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
||||||
},
|
},
|
||||||
"tag": dedup_tags(_LOCAL_ACTOR_TAGS),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.CONFIG.icon_url:
|
|
||||||
ME["icon"] = {
|
|
||||||
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],
|
|
||||||
"type": "Image",
|
|
||||||
"url": config.CONFIG.icon_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
if ALSO_KNOWN_AS:
|
|
||||||
ME["alsoKnownAs"] = [ALSO_KNOWN_AS]
|
|
||||||
|
|
||||||
if MOVED_TO:
|
|
||||||
ME["movedTo"] = MOVED_TO
|
|
||||||
|
|
||||||
if config.CONFIG.image_url:
|
|
||||||
ME["image"] = {
|
|
||||||
"mediaType": mimetypes.guess_type(config.CONFIG.image_url)[0],
|
|
||||||
"type": "Image",
|
|
||||||
"url": config.CONFIG.image_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NotAnObjectError(Exception):
|
class NotAnObjectError(Exception):
|
||||||
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:
|
||||||
|
@ -194,17 +153,11 @@ async def fetch(
|
||||||
|
|
||||||
# Special handling for deleted object
|
# Special handling for deleted object
|
||||||
if resp.status_code == 410:
|
if resp.status_code == 410:
|
||||||
raise ObjectIsGoneError(url, resp)
|
raise ObjectIsGoneError(f"{url} is gone")
|
||||||
elif resp.status_code in [401, 403]:
|
|
||||||
raise ObjectUnavailableError(url, resp)
|
|
||||||
elif resp.status_code == 404:
|
elif resp.status_code == 404:
|
||||||
raise ObjectNotFoundError(url, resp)
|
raise ObjectNotFoundError(f"{url} not found")
|
||||||
|
|
||||||
try:
|
|
||||||
resp.raise_for_status()
|
|
||||||
except httpx.HTTPError as http_error:
|
|
||||||
raise FetchError(url, resp) from http_error
|
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
|
197
app/actor.py
197
app/actor.py
|
@ -1,25 +1,16 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
|
||||||
from loguru import logger
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app import media
|
from app import media
|
||||||
from app.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.database import AsyncSession
|
||||||
from app.utils.datetime import as_utc
|
|
||||||
from app.utils.datetime import now
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from app.models import Actor as ActorModel
|
from app.models import Actor as ActorModel
|
||||||
|
@ -31,38 +22,7 @@ def _handle(raw_actor: ap.RawObject) -> str:
|
||||||
if not domain.hostname:
|
if not domain.hostname:
|
||||||
raise ValueError(f"Invalid actor ID {ap_id}")
|
raise ValueError(f"Invalid actor ID {ap_id}")
|
||||||
|
|
||||||
handle = f'@{raw_actor["preferredUsername"]}@{domain.hostname}' # type: ignore
|
return 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:
|
class Actor:
|
||||||
|
@ -96,7 +56,7 @@ class Actor:
|
||||||
return self.name
|
return self.name
|
||||||
return self.preferred_username
|
return self.preferred_username
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def handle(self) -> str:
|
def handle(self) -> str:
|
||||||
return _handle(self.ap_actor)
|
return _handle(self.ap_actor)
|
||||||
|
|
||||||
|
@ -118,21 +78,11 @@ class Actor:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon_url(self) -> str | None:
|
def icon_url(self) -> str | None:
|
||||||
if icon := self.ap_actor.get("icon"):
|
return self.ap_actor.get("icon", {}).get("url")
|
||||||
return icon.get("url")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon_media_type(self) -> str | None:
|
def icon_media_type(self) -> str | None:
|
||||||
if icon := self.ap_actor.get("icon"):
|
return self.ap_actor.get("icon", {}).get("mediaType")
|
||||||
return icon.get("mediaType")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def image_url(self) -> str | None:
|
|
||||||
if image := self.ap_actor.get("image"):
|
|
||||||
return image.get("url")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_key_as_pem(self) -> str:
|
def public_key_as_pem(self) -> str:
|
||||||
|
@ -147,18 +97,18 @@ class Actor:
|
||||||
if self.icon_url:
|
if self.icon_url:
|
||||||
return media.proxied_media_url(self.icon_url)
|
return media.proxied_media_url(self.icon_url)
|
||||||
else:
|
else:
|
||||||
return BASE_URL + "/static/nopic.png"
|
return "/static/nopic.png"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resized_icon_url(self) -> str:
|
def resized_icon_url(self) -> str:
|
||||||
if self.icon_url:
|
if self.icon_url:
|
||||||
return media.resized_media_url(self.icon_url, 50)
|
return media.resized_media_url(self.icon_url, 50)
|
||||||
else:
|
else:
|
||||||
return BASE_URL + "/static/nopic.png"
|
return "/static/nopic.png"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tags(self) -> list[ap.RawObject]:
|
def tags(self) -> list[ap.RawObject]:
|
||||||
return ap.as_list(self.ap_actor.get("tag", []))
|
return self.ap_actor.get("tag", [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def followers_collection_id(self) -> str | None:
|
def followers_collection_id(self) -> str | None:
|
||||||
|
@ -168,28 +118,19 @@ class Actor:
|
||||||
def attachments(self) -> list[ap.RawObject]:
|
def attachments(self) -> list[ap.RawObject]:
|
||||||
return ap.as_list(self.ap_actor.get("attachment", []))
|
return ap.as_list(self.ap_actor.get("attachment", []))
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def moved_to(self) -> str | None:
|
|
||||||
return self.ap_actor.get("movedTo")
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def server(self) -> str:
|
def server(self) -> str:
|
||||||
return urlparse(self.ap_id).hostname # type: ignore
|
return urlparse(self.ap_id).hostname # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class RemoteActor(Actor):
|
class RemoteActor(Actor):
|
||||||
def __init__(self, ap_actor: ap.RawObject, handle: str | None = None) -> None:
|
def __init__(self, ap_actor: ap.RawObject) -> None:
|
||||||
if (ap_type := ap_actor.get("type")) not in ap.ACTOR_TYPES:
|
if (ap_type := ap_actor.get("type")) not in ap.ACTOR_TYPES:
|
||||||
raise ValueError(f"Unexpected actor type: {ap_type}")
|
raise ValueError(f"Unexpected actor type: {ap_type}")
|
||||||
|
|
||||||
self._ap_actor = ap_actor
|
self._ap_actor = ap_actor
|
||||||
self._ap_type = ap_type
|
self._ap_type = ap_type
|
||||||
|
|
||||||
if handle is None:
|
|
||||||
handle = _handle(ap_actor)
|
|
||||||
|
|
||||||
self._handle = handle
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ap_actor(self) -> ap.RawObject:
|
def ap_actor(self) -> ap.RawObject:
|
||||||
return self._ap_actor
|
return self._ap_actor
|
||||||
|
@ -202,12 +143,8 @@ class RemoteActor(Actor):
|
||||||
def is_from_db(self) -> bool:
|
def is_from_db(self) -> bool:
|
||||||
return False
|
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":
|
async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "ActorModel":
|
||||||
|
@ -217,9 +154,9 @@ async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "Actor
|
||||||
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
|
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
|
||||||
|
|
||||||
actor = models.Actor(
|
actor = models.Actor(
|
||||||
ap_id=ap.get_id(ap_actor["id"]),
|
ap_id=ap_actor["id"],
|
||||||
ap_actor=ap_actor,
|
ap_actor=ap_actor,
|
||||||
ap_type=ap.as_list(ap_actor["type"])[0],
|
ap_type=ap_actor["type"],
|
||||||
handle=_handle(ap_actor),
|
handle=_handle(ap_actor),
|
||||||
)
|
)
|
||||||
db_session.add(actor)
|
db_session.add(actor)
|
||||||
|
@ -247,64 +184,13 @@ async def fetch_actor(
|
||||||
if existing_actor:
|
if existing_actor:
|
||||||
if existing_actor.is_deleted:
|
if existing_actor.is_deleted:
|
||||||
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
raise ap.ObjectNotFoundError(f"{actor_id} was deleted")
|
||||||
|
return existing_actor
|
||||||
if now() - as_utc(existing_actor.updated_at) > timedelta(hours=24):
|
|
||||||
logger.info(
|
|
||||||
f"Refreshing {actor_id=} last updated {existing_actor.updated_at}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ap_actor = await ap.fetch(actor_id)
|
|
||||||
await update_actor_if_needed(
|
|
||||||
db_session,
|
|
||||||
existing_actor,
|
|
||||||
RemoteActor(ap_actor),
|
|
||||||
)
|
|
||||||
return existing_actor
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to refresh {actor_id}")
|
|
||||||
# If we fail to refresh the actor, return the cached one
|
|
||||||
return existing_actor
|
|
||||||
else:
|
|
||||||
return existing_actor
|
|
||||||
|
|
||||||
if save_if_not_found:
|
|
||||||
ap_actor = await ap.fetch(actor_id)
|
|
||||||
# Some softwares uses URL when we expect ID or uses a different casing
|
|
||||||
# (like Birdsite LIVE) , which mean we may already have it in DB
|
|
||||||
existing_actor_by_url = (
|
|
||||||
await db_session.scalars(
|
|
||||||
select(models.Actor).where(
|
|
||||||
models.Actor.ap_id == ap.get_id(ap_actor),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).one_or_none()
|
|
||||||
if existing_actor_by_url:
|
|
||||||
# Update the actor as we had to fetch it anyway
|
|
||||||
await update_actor_if_needed(
|
|
||||||
db_session,
|
|
||||||
existing_actor_by_url,
|
|
||||||
RemoteActor(ap_actor),
|
|
||||||
)
|
|
||||||
return existing_actor_by_url
|
|
||||||
|
|
||||||
return await save_actor(db_session, ap_actor)
|
|
||||||
else:
|
else:
|
||||||
raise ap.ObjectNotFoundError(actor_id)
|
if save_if_not_found:
|
||||||
|
ap_actor = await ap.fetch(actor_id)
|
||||||
|
return await save_actor(db_session, ap_actor)
|
||||||
async def update_actor_if_needed(
|
else:
|
||||||
db_session: AsyncSession,
|
raise ap.ObjectNotFoundError
|
||||||
actor_in_db: "ActorModel",
|
|
||||||
ra: RemoteActor,
|
|
||||||
) -> None:
|
|
||||||
# Check if we actually need to udpte the actor in DB
|
|
||||||
if _actor_hash(ra) != _actor_hash(actor_in_db):
|
|
||||||
actor_in_db.ap_actor = ra.ap_actor
|
|
||||||
actor_in_db.handle = ra.handle
|
|
||||||
actor_in_db.ap_type = ra.ap_type
|
|
||||||
|
|
||||||
actor_in_db.updated_at = now()
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -313,11 +199,8 @@ class ActorMetadata:
|
||||||
is_following: bool
|
is_following: bool
|
||||||
is_follower: bool
|
is_follower: bool
|
||||||
is_follow_request_sent: bool
|
is_follow_request_sent: bool
|
||||||
is_follow_request_rejected: bool
|
|
||||||
outbox_follow_ap_id: str | None
|
outbox_follow_ap_id: str | None
|
||||||
inbox_follow_ap_id: str | None
|
inbox_follow_ap_id: str | None
|
||||||
moved_to: typing.Optional["ActorModel"]
|
|
||||||
has_blocked_local_actor: bool
|
|
||||||
|
|
||||||
|
|
||||||
ActorsMetadata = dict[str, ActorMetadata]
|
ActorsMetadata = dict[str, ActorMetadata]
|
||||||
|
@ -360,57 +243,17 @@ async def get_actors_metadata(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
rejected_follow_requests = {
|
|
||||||
reject.activity_object_ap_id
|
|
||||||
for reject in await db_session.execute(
|
|
||||||
select(models.InboxObject.activity_object_ap_id).where(
|
|
||||||
models.InboxObject.ap_type == "Reject",
|
|
||||||
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
blocks = {
|
|
||||||
block.ap_actor_id
|
|
||||||
for block in await db_session.execute(
|
|
||||||
select(models.InboxObject.ap_actor_id).where(
|
|
||||||
models.InboxObject.ap_type == "Block",
|
|
||||||
models.InboxObject.undone_by_inbox_object_id.is_(None),
|
|
||||||
models.InboxObject.ap_actor_id.in_(ap_actor_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
idx: ActorsMetadata = {}
|
idx: ActorsMetadata = {}
|
||||||
for actor in actors:
|
for actor in actors:
|
||||||
if not actor.ap_id:
|
if not actor.ap_id:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
moved_to = None
|
|
||||||
if actor.moved_to:
|
|
||||||
try:
|
|
||||||
moved_to = await fetch_actor(
|
|
||||||
db_session,
|
|
||||||
actor.moved_to,
|
|
||||||
save_if_not_found=False,
|
|
||||||
)
|
|
||||||
except ap.ObjectNotFoundError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to fetch {actor.moved_to=}")
|
|
||||||
|
|
||||||
idx[actor.ap_id] = ActorMetadata(
|
idx[actor.ap_id] = ActorMetadata(
|
||||||
ap_actor_id=actor.ap_id,
|
ap_actor_id=actor.ap_id,
|
||||||
is_following=actor.ap_id in following,
|
is_following=actor.ap_id in following,
|
||||||
is_follower=actor.ap_id in followers,
|
is_follower=actor.ap_id in followers,
|
||||||
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
||||||
is_follow_request_rejected=bool(
|
|
||||||
sent_follow_requests[actor.ap_id] in rejected_follow_requests
|
|
||||||
)
|
|
||||||
if actor.ap_id in sent_follow_requests
|
|
||||||
else False,
|
|
||||||
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
||||||
inbox_follow_ap_id=followers.get(actor.ap_id),
|
inbox_follow_ap_id=followers.get(actor.ap_id),
|
||||||
moved_to=moved_to,
|
|
||||||
has_blocked_local_actor=actor.ap_id in blocks,
|
|
||||||
)
|
)
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
|
@ -435,9 +278,6 @@ def _actor_hash(actor: Actor) -> bytes:
|
||||||
if actor.icon_url:
|
if actor.icon_url:
|
||||||
h.update(actor.icon_url.encode())
|
h.update(actor.icon_url.encode())
|
||||||
|
|
||||||
if actor.image_url:
|
|
||||||
h.update(actor.image_url.encode())
|
|
||||||
|
|
||||||
if actor.attachments:
|
if actor.attachments:
|
||||||
for a in actor.attachments:
|
for a in actor.attachments:
|
||||||
if a.get("type") != "PropertyValue":
|
if a.get("type") != "PropertyValue":
|
||||||
|
@ -449,7 +289,4 @@ def _actor_hash(actor: Actor) -> bytes:
|
||||||
h.update(actor.public_key_id.encode())
|
h.update(actor.public_key_id.encode())
|
||||||
h.update(actor.public_key_as_pem.encode())
|
h.update(actor.public_key_as_pem.encode())
|
||||||
|
|
||||||
if actor.moved_to:
|
|
||||||
h.update(actor.moved_to.encode())
|
|
||||||
|
|
||||||
return h.digest()
|
return h.digest()
|
||||||
|
|
273
app/admin.py
273
app/admin.py
|
@ -1,5 +1,4 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
@ -12,7 +11,6 @@ from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy import delete
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
@ -27,11 +25,8 @@ from app.actor import fetch_actor
|
||||||
from app.actor import get_actors_metadata
|
from app.actor import get_actors_metadata
|
||||||
from app.boxes import get_inbox_object_by_ap_id
|
from app.boxes import get_inbox_object_by_ap_id
|
||||||
from app.boxes import get_outbox_object_by_ap_id
|
from app.boxes import get_outbox_object_by_ap_id
|
||||||
from app.boxes import send_block
|
|
||||||
from app.boxes import send_follow
|
from app.boxes import send_follow
|
||||||
from app.boxes import send_unblock
|
|
||||||
from app.config import EMOJIS
|
from app.config import EMOJIS
|
||||||
from app.config import SESSION_TIMEOUT
|
|
||||||
from app.config import generate_csrf_token
|
from app.config import generate_csrf_token
|
||||||
from app.config import session_serializer
|
from app.config import session_serializer
|
||||||
from app.config import verify_csrf_token
|
from app.config import verify_csrf_token
|
||||||
|
@ -39,44 +34,29 @@ from app.config import verify_password
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.lookup import lookup
|
from app.lookup import lookup
|
||||||
from app.templates import is_current_user_admin
|
|
||||||
from app.uploads import save_upload
|
from app.uploads import save_upload
|
||||||
from app.utils import pagination
|
from app.utils import pagination
|
||||||
from app.utils.emoji import EMOJIS_BY_NAME
|
from app.utils.emoji import EMOJIS_BY_NAME
|
||||||
|
|
||||||
|
|
||||||
async def user_session_or_redirect(
|
def user_session_or_redirect(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: str | None = Cookie(default=None),
|
session: str | None = Cookie(default=None),
|
||||||
) -> None:
|
) -> None:
|
||||||
if request.method == "POST":
|
|
||||||
form_data = await request.form()
|
|
||||||
if "redirect_url" in form_data:
|
|
||||||
redirect_url = form_data["redirect_url"]
|
|
||||||
else:
|
|
||||||
redirect_url = request.url_for("admin_stream")
|
|
||||||
else:
|
|
||||||
redirect_url = str(request.url)
|
|
||||||
|
|
||||||
_RedirectToLoginPage = HTTPException(
|
_RedirectToLoginPage = HTTPException(
|
||||||
status_code=302,
|
status_code=302,
|
||||||
headers={
|
headers={"Location": request.url_for("login") + f"?redirect={request.url}"},
|
||||||
"Location": request.url_for("login") + f"?redirect={quote(redirect_url)}"
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
logger.info("No existing admin session")
|
|
||||||
raise _RedirectToLoginPage
|
raise _RedirectToLoginPage
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loaded_session = session_serializer.loads(session, max_age=SESSION_TIMEOUT)
|
loaded_session = session_serializer.loads(session, max_age=3600 * 12)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to validate admin session")
|
|
||||||
raise _RedirectToLoginPage
|
raise _RedirectToLoginPage
|
||||||
|
|
||||||
if not loaded_session.get("is_logged_in"):
|
if not loaded_session.get("is_logged_in"):
|
||||||
logger.info(f"Admin session invalidated: {loaded_session}")
|
|
||||||
raise _RedirectToLoginPage
|
raise _RedirectToLoginPage
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
@ -88,6 +68,16 @@ router = APIRouter(
|
||||||
unauthenticated_router = APIRouter()
|
unauthenticated_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def admin_index(
|
||||||
|
request: Request,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> templates.TemplateResponse:
|
||||||
|
return await templates.render_template(
|
||||||
|
db_session, request, "index.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lookup")
|
@router.get("/lookup")
|
||||||
async def get_lookup(
|
async def get_lookup(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
@ -104,8 +94,6 @@ async def get_lookup(
|
||||||
error = ap.FetchErrorTypeEnum.TIMEOUT
|
error = ap.FetchErrorTypeEnum.TIMEOUT
|
||||||
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
|
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
|
||||||
error = ap.FetchErrorTypeEnum.NOT_FOUND
|
error = ap.FetchErrorTypeEnum.NOT_FOUND
|
||||||
except (ap.ObjectUnavailableError):
|
|
||||||
error = ap.FetchErrorTypeEnum.UNAUHTORIZED
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Failed to lookup {query}")
|
logger.exception(f"Failed to lookup {query}")
|
||||||
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR
|
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR
|
||||||
|
@ -134,9 +122,7 @@ async def get_lookup(
|
||||||
)
|
)
|
||||||
if requested_object:
|
if requested_object:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
request.url_for("admin_object")
|
request.url_for("admin_object") + f"?ap_id={ap_object.ap_id}",
|
||||||
+ f"?ap_id={ap_object.ap_id}#"
|
|
||||||
+ requested_object.permalink_id,
|
|
||||||
status_code=302,
|
status_code=302,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -189,11 +175,8 @@ async def admin_new(
|
||||||
content += f"{in_reply_to_object.actor.handle} "
|
content += f"{in_reply_to_object.actor.handle} "
|
||||||
for tag in in_reply_to_object.tags:
|
for tag in in_reply_to_object.tags:
|
||||||
if tag.get("type") == "Mention" and tag["name"] != LOCAL_ACTOR.handle:
|
if tag.get("type") == "Mention" and tag["name"] != LOCAL_ACTOR.handle:
|
||||||
try:
|
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
||||||
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
content += f"{mentioned_actor.handle} "
|
||||||
content += f"{mentioned_actor.handle} "
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to lookup {mentioned_actor}")
|
|
||||||
|
|
||||||
# Copy the content warning if any
|
# Copy the content warning if any
|
||||||
if in_reply_to_object.summary:
|
if in_reply_to_object.summary:
|
||||||
|
@ -228,7 +211,6 @@ async def admin_bookmarks(
|
||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
# TODO: support pagination
|
|
||||||
stream = (
|
stream = (
|
||||||
(
|
(
|
||||||
await db_session.scalars(
|
await db_session.scalars(
|
||||||
|
@ -353,7 +335,6 @@ async def admin_inbox(
|
||||||
"Update",
|
"Update",
|
||||||
"Undo",
|
"Undo",
|
||||||
"Read",
|
"Read",
|
||||||
"Reject",
|
|
||||||
"Add",
|
"Add",
|
||||||
"Remove",
|
"Remove",
|
||||||
"EmojiReact",
|
"EmojiReact",
|
||||||
|
@ -450,7 +431,6 @@ async def admin_direct_messages(
|
||||||
models.InboxObject.ap_context.is_not(None),
|
models.InboxObject.ap_context.is_not(None),
|
||||||
# Skip transient object like poll relies
|
# Skip transient object like poll relies
|
||||||
models.InboxObject.is_transient.is_(False),
|
models.InboxObject.is_transient.is_(False),
|
||||||
models.InboxObject.is_deleted.is_(False),
|
|
||||||
)
|
)
|
||||||
.group_by(models.InboxObject.ap_context, models.InboxObject.actor_id)
|
.group_by(models.InboxObject.ap_context, models.InboxObject.actor_id)
|
||||||
)
|
)
|
||||||
|
@ -473,7 +453,6 @@ async def admin_direct_messages(
|
||||||
models.OutboxObject.ap_context.is_not(None),
|
models.OutboxObject.ap_context.is_not(None),
|
||||||
# Skip transient object like poll relies
|
# Skip transient object like poll relies
|
||||||
models.OutboxObject.is_transient.is_(False),
|
models.OutboxObject.is_transient.is_(False),
|
||||||
models.OutboxObject.is_deleted.is_(False),
|
|
||||||
)
|
)
|
||||||
.group_by(models.OutboxObject.ap_context)
|
.group_by(models.OutboxObject.ap_context)
|
||||||
)
|
)
|
||||||
|
@ -688,30 +667,15 @@ async def admin_outbox(
|
||||||
|
|
||||||
@router.get("/notifications")
|
@router.get("/notifications")
|
||||||
async def get_notifications(
|
async def get_notifications(
|
||||||
request: Request,
|
request: Request, db_session: AsyncSession = Depends(get_db_session)
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
cursor: str | None = None,
|
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
where = []
|
|
||||||
if cursor:
|
|
||||||
decoded_cursor = pagination.decode_cursor(cursor)
|
|
||||||
where.append(models.Notification.created_at < decoded_cursor)
|
|
||||||
|
|
||||||
page_size = 20
|
|
||||||
remaining_count = await db_session.scalar(
|
|
||||||
select(func.count(models.Notification.id)).where(*where)
|
|
||||||
)
|
|
||||||
|
|
||||||
notifications = (
|
notifications = (
|
||||||
(
|
(
|
||||||
await db_session.scalars(
|
await db_session.scalars(
|
||||||
select(models.Notification)
|
select(models.Notification)
|
||||||
.where(*where)
|
|
||||||
.options(
|
.options(
|
||||||
joinedload(models.Notification.actor),
|
joinedload(models.Notification.actor),
|
||||||
joinedload(models.Notification.inbox_object).options(
|
joinedload(models.Notification.inbox_object),
|
||||||
joinedload(models.InboxObject.actor)
|
|
||||||
),
|
|
||||||
joinedload(models.Notification.outbox_object).options(
|
joinedload(models.Notification.outbox_object).options(
|
||||||
joinedload(
|
joinedload(
|
||||||
models.OutboxObject.outbox_object_attachments
|
models.OutboxObject.outbox_object_attachments
|
||||||
|
@ -720,7 +684,6 @@ async def get_notifications(
|
||||||
joinedload(models.Notification.webmention),
|
joinedload(models.Notification.webmention),
|
||||||
)
|
)
|
||||||
.order_by(models.Notification.created_at.desc())
|
.order_by(models.Notification.created_at.desc())
|
||||||
.limit(page_size)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.unique()
|
.unique()
|
||||||
|
@ -729,42 +692,21 @@ async def get_notifications(
|
||||||
actors_metadata = await get_actors_metadata(
|
actors_metadata = await get_actors_metadata(
|
||||||
db_session, [notif.actor for notif in notifications if notif.actor]
|
db_session, [notif.actor for notif in notifications if notif.actor]
|
||||||
)
|
)
|
||||||
more_unread_count = 0
|
|
||||||
next_cursor = None
|
|
||||||
|
|
||||||
if notifications and remaining_count > page_size:
|
for notif in notifications:
|
||||||
decoded_next_cursor = notifications[-1].created_at
|
notif.is_new = False
|
||||||
next_cursor = pagination.encode_cursor(decoded_next_cursor)
|
await db_session.commit()
|
||||||
|
|
||||||
# If on the "see more" page there's more unread notification, we want
|
return await templates.render_template(
|
||||||
# to display it next to the link
|
|
||||||
more_unread_count = await db_session.scalar(
|
|
||||||
select(func.count(models.Notification.id)).where(
|
|
||||||
models.Notification.is_new.is_(True),
|
|
||||||
models.Notification.created_at < decoded_next_cursor,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Render the template before we change the new flag on notifications
|
|
||||||
tpl_resp = await templates.render_template(
|
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
"notifications.html",
|
"notifications.html",
|
||||||
{
|
{
|
||||||
"notifications": notifications,
|
"notifications": notifications,
|
||||||
"actors_metadata": actors_metadata,
|
"actors_metadata": actors_metadata,
|
||||||
"next_cursor": next_cursor,
|
|
||||||
"more_unread_count": more_unread_count,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if len({notif.id for notif in notifications if notif.is_new}):
|
|
||||||
for notif in notifications:
|
|
||||||
notif.is_new = False
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
return tpl_resp
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/object")
|
@router.get("/object")
|
||||||
async def admin_object(
|
async def admin_object(
|
||||||
|
@ -773,7 +715,7 @@ async def admin_object(
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id)
|
requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id)
|
||||||
if not requested_object or requested_object.is_deleted:
|
if not requested_object:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
replies_tree = await boxes.get_replies_tree(
|
replies_tree = await boxes.get_replies_tree(
|
||||||
|
@ -794,10 +736,8 @@ async def admin_object(
|
||||||
async def admin_profile(
|
async def admin_profile(
|
||||||
request: Request,
|
request: Request,
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
cursor: str | None = None,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
# TODO: show featured/pinned
|
|
||||||
actor = (
|
actor = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(models.Actor).where(models.Actor.ap_id == actor_id)
|
select(models.Actor).where(models.Actor.ap_id == actor_id)
|
||||||
|
@ -808,27 +748,17 @@ async def admin_profile(
|
||||||
|
|
||||||
actors_metadata = await get_actors_metadata(db_session, [actor])
|
actors_metadata = await get_actors_metadata(db_session, [actor])
|
||||||
|
|
||||||
where = [
|
|
||||||
models.InboxObject.is_deleted.is_(False),
|
|
||||||
models.InboxObject.actor_id == actor.id,
|
|
||||||
models.InboxObject.ap_type.in_(
|
|
||||||
["Note", "Article", "Video", "Page", "Announce"]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
if cursor:
|
|
||||||
decoded_cursor = pagination.decode_cursor(cursor)
|
|
||||||
where.append(models.InboxObject.ap_published_at < decoded_cursor)
|
|
||||||
|
|
||||||
page_size = 20
|
|
||||||
remaining_count = await db_session.scalar(
|
|
||||||
select(func.count(models.InboxObject.id)).where(*where)
|
|
||||||
)
|
|
||||||
|
|
||||||
inbox_objects = (
|
inbox_objects = (
|
||||||
(
|
(
|
||||||
await db_session.scalars(
|
await db_session.scalars(
|
||||||
select(models.InboxObject)
|
select(models.InboxObject)
|
||||||
.where(*where)
|
.where(
|
||||||
|
models.InboxObject.is_deleted.is_(False),
|
||||||
|
models.InboxObject.actor_id == actor.id,
|
||||||
|
models.InboxObject.ap_type.in_(
|
||||||
|
["Note", "Article", "Video", "Page", "Announce"]
|
||||||
|
),
|
||||||
|
)
|
||||||
.options(
|
.options(
|
||||||
joinedload(models.InboxObject.relates_to_inbox_object).options(
|
joinedload(models.InboxObject.relates_to_inbox_object).options(
|
||||||
joinedload(models.InboxObject.actor)
|
joinedload(models.InboxObject.actor)
|
||||||
|
@ -841,19 +771,12 @@ async def admin_profile(
|
||||||
joinedload(models.InboxObject.actor),
|
joinedload(models.InboxObject.actor),
|
||||||
)
|
)
|
||||||
.order_by(models.InboxObject.ap_published_at.desc())
|
.order_by(models.InboxObject.ap_published_at.desc())
|
||||||
.limit(page_size)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.unique()
|
.unique()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
next_cursor = (
|
|
||||||
pagination.encode_cursor(inbox_objects[-1].created_at)
|
|
||||||
if inbox_objects and remaining_count > page_size
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
|
@ -862,71 +785,10 @@ async def admin_profile(
|
||||||
"actors_metadata": actors_metadata,
|
"actors_metadata": actors_metadata,
|
||||||
"actor": actor,
|
"actor": actor,
|
||||||
"inbox_objects": inbox_objects,
|
"inbox_objects": inbox_objects,
|
||||||
"next_cursor": next_cursor,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/actions/force_delete")
|
|
||||||
async def admin_actions_force_delete(
|
|
||||||
request: Request,
|
|
||||||
ap_object_id: str = Form(),
|
|
||||||
redirect_url: str = Form(),
|
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
ap_object_to_delete = await get_inbox_object_by_ap_id(db_session, ap_object_id)
|
|
||||||
if not ap_object_to_delete:
|
|
||||||
raise ValueError(f"Cannot find {ap_object_id}")
|
|
||||||
|
|
||||||
logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}")
|
|
||||||
await boxes._revert_side_effect_for_deleted_object(
|
|
||||||
db_session,
|
|
||||||
None,
|
|
||||||
ap_object_to_delete,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
ap_object_to_delete.is_deleted = True
|
|
||||||
await db_session.commit()
|
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/actions/force_delete_webmention")
|
|
||||||
async def admin_actions_force_delete_webmention(
|
|
||||||
request: Request,
|
|
||||||
webmention_id: int = Form(),
|
|
||||||
redirect_url: str = Form(),
|
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
webmention = await boxes.get_webmention_by_id(db_session, webmention_id)
|
|
||||||
if not webmention:
|
|
||||||
raise ValueError(f"Cannot find {webmention_id}")
|
|
||||||
if not webmention.outbox_object:
|
|
||||||
raise ValueError(f"Missing related outbox object for {webmention_id}")
|
|
||||||
|
|
||||||
# TODO: move this
|
|
||||||
logger.info(f"Deleting {webmention_id}")
|
|
||||||
webmention.is_deleted = True
|
|
||||||
await db_session.flush()
|
|
||||||
from app.webmentions import _handle_webmention_side_effects
|
|
||||||
|
|
||||||
await _handle_webmention_side_effects(
|
|
||||||
db_session, webmention, webmention.outbox_object
|
|
||||||
)
|
|
||||||
# Delete related notifications
|
|
||||||
notif_deletion_result = await db_session.execute(
|
|
||||||
delete(models.Notification)
|
|
||||||
.where(models.Notification.webmention_id == webmention.id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Deleted {notif_deletion_result.rowcount} notifications" # type: ignore
|
|
||||||
)
|
|
||||||
await db_session.commit()
|
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/actions/follow")
|
@router.post("/actions/follow")
|
||||||
async def admin_actions_follow(
|
async def admin_actions_follow(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
@ -948,7 +810,10 @@ async def admin_actions_block(
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
await send_block(db_session, ap_actor_id)
|
logger.info(f"Blocking {ap_actor_id}")
|
||||||
|
actor = await fetch_actor(db_session, ap_actor_id)
|
||||||
|
actor.is_blocked = True
|
||||||
|
await db_session.commit()
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@ -961,34 +826,8 @@ async def admin_actions_unblock(
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info(f"Unblocking {ap_actor_id}")
|
logger.info(f"Unblocking {ap_actor_id}")
|
||||||
await send_unblock(db_session, ap_actor_id)
|
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/actions/hide_announces")
|
|
||||||
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 = await fetch_actor(db_session, ap_actor_id)
|
||||||
actor.are_announces_hidden_from_stream = True
|
actor.is_blocked = False
|
||||||
await db_session.commit()
|
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/actions/show_announces")
|
|
||||||
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()
|
await db_session.commit()
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
|
|
||||||
|
@ -1135,7 +974,7 @@ async def admin_actions_unpin(
|
||||||
async def admin_actions_new(
|
async def admin_actions_new(
|
||||||
request: Request,
|
request: Request,
|
||||||
files: list[UploadFile] = [],
|
files: list[UploadFile] = [],
|
||||||
content: str | None = Form(None),
|
content: str = Form(),
|
||||||
redirect_url: str = Form(),
|
redirect_url: str = Form(),
|
||||||
in_reply_to: str | None = Form(None),
|
in_reply_to: str | None = Form(None),
|
||||||
content_warning: str | None = Form(None),
|
content_warning: str | None = Form(None),
|
||||||
|
@ -1146,19 +985,6 @@ async def admin_actions_new(
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
if not content and not content_warning:
|
|
||||||
raise HTTPException(status_code=422, detail="Error: object must have a content")
|
|
||||||
|
|
||||||
# Do like Mastodon, if there's only a CW with no content and some attachments,
|
|
||||||
# swap the CW and the content
|
|
||||||
if not content and content_warning and len(files) >= 1:
|
|
||||||
content = content_warning
|
|
||||||
is_sensitive = True
|
|
||||||
content_warning = None
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
raise HTTPException(status_code=422, detail="Error: objec must have a content")
|
|
||||||
|
|
||||||
# XXX: for some reason, no files restuls in an empty single file
|
# XXX: for some reason, no files restuls in an empty single file
|
||||||
uploads = []
|
uploads = []
|
||||||
raw_form_data = await request.form()
|
raw_form_data = await request.form()
|
||||||
|
@ -1185,7 +1011,7 @@ async def admin_actions_new(
|
||||||
elif name:
|
elif name:
|
||||||
ap_type = "Article"
|
ap_type = "Article"
|
||||||
|
|
||||||
public_id, _ = await boxes.send_create(
|
public_id = await boxes.send_create(
|
||||||
db_session,
|
db_session,
|
||||||
ap_type=ap_type,
|
ap_type=ap_type,
|
||||||
source=content,
|
source=content,
|
||||||
|
@ -1228,10 +1054,7 @@ async def admin_actions_vote(
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse | RedirectResponse:
|
) -> templates.TemplateResponse:
|
||||||
if is_current_user_admin(request):
|
|
||||||
return RedirectResponse(request.url_for("admin_stream"), status_code=302)
|
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
|
@ -1249,25 +1072,11 @@ async def login_validation(
|
||||||
password: str = Form(),
|
password: str = Form(),
|
||||||
redirect: str | None = Form(None),
|
redirect: str | None = Form(None),
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
) -> RedirectResponse:
|
||||||
) -> RedirectResponse | templates.TemplateResponse:
|
|
||||||
if not verify_password(password):
|
if not verify_password(password):
|
||||||
logger.warning("Invalid password")
|
raise HTTPException(status_code=401)
|
||||||
return await templates.render_template(
|
|
||||||
db_session,
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"error": "Invalid password",
|
|
||||||
"csrf_token": generate_csrf_token(),
|
|
||||||
"redirect": request.query_params.get("redirect", ""),
|
|
||||||
},
|
|
||||||
status_code=403,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = RedirectResponse(
|
resp = RedirectResponse(redirect or "/admin/stream", status_code=302)
|
||||||
redirect or request.url_for("admin_stream"), status_code=302
|
|
||||||
)
|
|
||||||
resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501
|
resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
from mistletoe import markdown # type: ignore
|
from markdown import markdown
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app.actor import LOCAL_ACTOR
|
from app.actor import LOCAL_ACTOR
|
||||||
from app.actor import Actor
|
from app.actor import Actor
|
||||||
from app.actor import RemoteActor
|
from app.actor import RemoteActor
|
||||||
from app.config import ID
|
|
||||||
from app.media import proxied_media_url
|
from app.media import proxied_media_url
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
from app.utils.datetime import parse_isoformat
|
from app.utils.datetime import parse_isoformat
|
||||||
|
@ -97,9 +95,6 @@ class Object:
|
||||||
def attachments(self) -> list["Attachment"]:
|
def attachments(self) -> list["Attachment"]:
|
||||||
attachments = []
|
attachments = []
|
||||||
for obj in ap.as_list(self.ap_object.get("attachment", [])):
|
for obj in ap.as_list(self.ap_object.get("attachment", [])):
|
||||||
if obj.get("type") == "PropertyValue":
|
|
||||||
continue
|
|
||||||
|
|
||||||
if obj.get("type") == "Link":
|
if obj.get("type") == "Link":
|
||||||
attachments.append(
|
attachments.append(
|
||||||
Attachment.parse_obj(
|
Attachment.parse_obj(
|
||||||
|
@ -160,7 +155,7 @@ class Object:
|
||||||
@cached_property
|
@cached_property
|
||||||
def url(self) -> str | None:
|
def url(self) -> str | None:
|
||||||
obj_url = self.ap_object.get("url")
|
obj_url = self.ap_object.get("url")
|
||||||
if isinstance(obj_url, str) and obj_url:
|
if isinstance(obj_url, str):
|
||||||
return obj_url
|
return obj_url
|
||||||
elif obj_url:
|
elif obj_url:
|
||||||
for u in ap.as_list(obj_url):
|
for u in ap.as_list(obj_url):
|
||||||
|
@ -180,7 +175,7 @@ class Object:
|
||||||
|
|
||||||
# PeerTube returns the content as markdown
|
# PeerTube returns the content as markdown
|
||||||
if self.ap_object.get("mediaType") == "text/markdown":
|
if self.ap_object.get("mediaType") == "text/markdown":
|
||||||
content = markdown(content)
|
content = markdown(content, extensions=["mdx_linkify"])
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
@ -213,22 +208,6 @@ class Object:
|
||||||
def in_reply_to(self) -> str | None:
|
def in_reply_to(self) -> str | None:
|
||||||
return self.ap_object.get("inReplyTo")
|
return self.ap_object.get("inReplyTo")
|
||||||
|
|
||||||
@property
|
|
||||||
def is_local_reply(self) -> bool:
|
|
||||||
if not self.in_reply_to:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return bool(
|
|
||||||
self.in_reply_to.startswith(ID) and self.content # Hide votes from Question
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_in_reply_to_from_inbox(self) -> bool | None:
|
|
||||||
if not self.in_reply_to:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return not self.in_reply_to.startswith(LOCAL_ACTOR.ap_id)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_ld_signature(self) -> bool:
|
def has_ld_signature(self) -> bool:
|
||||||
return bool(self.ap_object.get("signature"))
|
return bool(self.ap_object.get("signature"))
|
||||||
|
@ -290,20 +269,6 @@ class Attachment(BaseModel):
|
||||||
proxied_url: str | None = None
|
proxied_url: str | None = None
|
||||||
resized_url: str | None = None
|
resized_url: str | None = None
|
||||||
|
|
||||||
width: int | None = None
|
|
||||||
height: int | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mimetype(self) -> str:
|
|
||||||
mimetype = self.media_type
|
|
||||||
if not mimetype:
|
|
||||||
mimetype, _ = mimetypes.guess_type(self.url)
|
|
||||||
|
|
||||||
if not mimetype:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
return mimetype.split("/")[-1]
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteObject(Object):
|
class RemoteObject(Object):
|
||||||
def __init__(self, raw_object: ap.RawObject, actor: Actor):
|
def __init__(self, raw_object: ap.RawObject, actor: Actor):
|
||||||
|
|
862
app/boxes.py
862
app/boxes.py
File diff suppressed because it is too large
Load diff
140
app/config.py
140
app/config.py
|
@ -1,5 +1,4 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -13,13 +12,8 @@ from fastapi import HTTPException
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from itsdangerous import URLSafeTimedSerializer
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from mistletoe import markdown # type: ignore
|
|
||||||
|
|
||||||
from app.customization import _CUSTOM_ROUTES
|
|
||||||
from app.customization import _StreamVisibilityCallback
|
|
||||||
from app.customization import default_stream_visibility_callback
|
|
||||||
from app.utils.emoji import _load_emojis
|
from app.utils.emoji import _load_emojis
|
||||||
from app.utils.version import get_version_commit
|
|
||||||
|
|
||||||
ROOT_DIR = Path().parent.resolve()
|
ROOT_DIR = Path().parent.resolve()
|
||||||
|
|
||||||
|
@ -30,7 +24,7 @@ VERSION_COMMIT = "dev"
|
||||||
try:
|
try:
|
||||||
from app._version import VERSION_COMMIT # type: ignore
|
from app._version import VERSION_COMMIT # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
VERSION_COMMIT = get_version_commit()
|
pass
|
||||||
|
|
||||||
# Force reloading cache when the CSS is updated
|
# Force reloading cache when the CSS is updated
|
||||||
CSS_HASH = "none"
|
CSS_HASH = "none"
|
||||||
|
@ -40,34 +34,6 @@ try:
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Force reloading cache when the JS is changed
|
|
||||||
JS_HASH = "none"
|
|
||||||
try:
|
|
||||||
# To keep things simple, we keep a single hash for the 2 files
|
|
||||||
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
|
|
||||||
|
|
||||||
MOVED_TO_FILE = ROOT_DIR / "data" / "moved_to.dat"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_moved_to() -> str | None:
|
|
||||||
if not MOVED_TO_FILE.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
return MOVED_TO_FILE.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def set_moved_to(moved_to: str) -> None:
|
|
||||||
MOVED_TO_FILE.write_text(moved_to)
|
|
||||||
|
|
||||||
|
|
||||||
VERSION = f"2.0.0+{VERSION_COMMIT}"
|
VERSION = f"2.0.0+{VERSION_COMMIT}"
|
||||||
USER_AGENT = f"microblogpub/{VERSION}"
|
USER_AGENT = f"microblogpub/{VERSION}"
|
||||||
|
@ -96,8 +62,7 @@ class Config(pydantic.BaseModel):
|
||||||
name: str
|
name: str
|
||||||
summary: str
|
summary: str
|
||||||
https: bool
|
https: bool
|
||||||
icon_url: str | None = None
|
icon_url: str
|
||||||
image_url: str | None = None
|
|
||||||
secret: str
|
secret: str
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
trusted_hosts: list[str] = ["127.0.0.1"]
|
trusted_hosts: list[str] = ["127.0.0.1"]
|
||||||
|
@ -106,31 +71,13 @@ class Config(pydantic.BaseModel):
|
||||||
metadata: list[_ProfileMetadata] | None = None
|
metadata: list[_ProfileMetadata] | None = None
|
||||||
code_highlighting_theme = "friendly_grayscale"
|
code_highlighting_theme = "friendly_grayscale"
|
||||||
blocked_servers: list[_BlockedServer] = []
|
blocked_servers: list[_BlockedServer] = []
|
||||||
custom_footer: str | None = None
|
|
||||||
emoji: str | None = None
|
|
||||||
also_known_as: str | None = None
|
|
||||||
|
|
||||||
hides_followers: bool = False
|
|
||||||
hides_following: bool = False
|
|
||||||
|
|
||||||
inbox_retention_days: int = 15
|
inbox_retention_days: int = 15
|
||||||
|
|
||||||
custom_content_security_policy: str | None = None
|
|
||||||
|
|
||||||
webfinger_domain: str | None = None
|
|
||||||
|
|
||||||
# Config items to make tests easier
|
# Config items to make tests easier
|
||||||
sqlalchemy_database: str | None = None
|
sqlalchemy_database: str | None = None
|
||||||
key_path: str | None = None
|
key_path: str | None = None
|
||||||
|
|
||||||
session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default
|
|
||||||
csrf_token_exp: int = 3600
|
|
||||||
|
|
||||||
disabled_notifications: list[str] = []
|
|
||||||
|
|
||||||
# Only set when the app is served on a non-root path
|
|
||||||
id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> Config:
|
def load_config() -> Config:
|
||||||
try:
|
try:
|
||||||
|
@ -165,34 +112,15 @@ CONFIG = load_config()
|
||||||
DOMAIN = CONFIG.domain
|
DOMAIN = CONFIG.domain
|
||||||
_SCHEME = "https" if CONFIG.https else "http"
|
_SCHEME = "https" if CONFIG.https else "http"
|
||||||
ID = f"{_SCHEME}://{DOMAIN}"
|
ID = f"{_SCHEME}://{DOMAIN}"
|
||||||
|
|
||||||
# When running the app on a path, the ID maybe set by the config, but in this
|
|
||||||
# case, a valid webfinger must be served on the root domain
|
|
||||||
if CONFIG.id:
|
|
||||||
ID = CONFIG.id
|
|
||||||
USERNAME = CONFIG.username
|
USERNAME = CONFIG.username
|
||||||
|
|
||||||
# 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
|
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
|
||||||
HIDES_FOLLOWERS = CONFIG.hides_followers
|
|
||||||
HIDES_FOLLOWING = CONFIG.hides_following
|
|
||||||
PRIVACY_REPLACE = None
|
PRIVACY_REPLACE = None
|
||||||
if CONFIG.privacy_replace:
|
if CONFIG.privacy_replace:
|
||||||
PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace}
|
PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace}
|
||||||
|
|
||||||
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
|
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
|
||||||
ALSO_KNOWN_AS = CONFIG.also_known_as
|
|
||||||
CUSTOM_CONTENT_SECURITY_POLICY = CONFIG.custom_content_security_policy
|
|
||||||
|
|
||||||
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
||||||
SESSION_TIMEOUT = CONFIG.session_timeout
|
|
||||||
CUSTOM_FOOTER = (
|
|
||||||
markdown(CONFIG.custom_footer.replace("{version}", VERSION))
|
|
||||||
if CONFIG.custom_footer
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
BASE_URL = ID
|
BASE_URL = ID
|
||||||
DEBUG = CONFIG.debug
|
DEBUG = CONFIG.debug
|
||||||
|
@ -202,45 +130,13 @@ KEY_PATH = (
|
||||||
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
|
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
|
||||||
)
|
)
|
||||||
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
|
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
|
||||||
if CONFIG.emoji:
|
|
||||||
EMOJIS = CONFIG.emoji
|
|
||||||
|
|
||||||
# Emoji template for the FE
|
# Emoji template for the FE
|
||||||
EMOJI_TPL = (
|
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
||||||
'<img src="{base_url}/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
|
||||||
)
|
|
||||||
|
|
||||||
_load_emojis(ROOT_DIR, BASE_URL)
|
_load_emojis(ROOT_DIR, BASE_URL)
|
||||||
|
|
||||||
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
|
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
|
||||||
|
|
||||||
MOVED_TO = _get_moved_to()
|
|
||||||
|
|
||||||
|
|
||||||
_NavBarItem = tuple[str, str]
|
|
||||||
|
|
||||||
|
|
||||||
class NavBarItems:
|
|
||||||
EXTRA_NAVBAR_ITEMS: list[_NavBarItem] = []
|
|
||||||
INDEX_NAVBAR_ITEM: _NavBarItem | None = None
|
|
||||||
NOTES_PATH = "/"
|
|
||||||
|
|
||||||
|
|
||||||
def load_custom_routes() -> None:
|
|
||||||
try:
|
|
||||||
from data import custom_routes # type: ignore # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for path, custom_handler in _CUSTOM_ROUTES.items():
|
|
||||||
# If a handler wants to replace the root, move the index to /notes
|
|
||||||
if path == "/":
|
|
||||||
NavBarItems.NOTES_PATH = "/notes"
|
|
||||||
NavBarItems.INDEX_NAVBAR_ITEM = (path, custom_handler.title)
|
|
||||||
else:
|
|
||||||
if custom_handler.show_in_navbar:
|
|
||||||
NavBarItems.EXTRA_NAVBAR_ITEMS.append((path, custom_handler.title))
|
|
||||||
|
|
||||||
|
|
||||||
session_serializer = URLSafeTimedSerializer(
|
session_serializer = URLSafeTimedSerializer(
|
||||||
CONFIG.secret,
|
CONFIG.secret,
|
||||||
|
@ -256,34 +152,10 @@ def generate_csrf_token() -> str:
|
||||||
return csrf_serializer.dumps(secrets.token_hex(16)) # type: ignore
|
return csrf_serializer.dumps(secrets.token_hex(16)) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def verify_csrf_token(
|
def verify_csrf_token(csrf_token: str = Form()) -> None:
|
||||||
csrf_token: str = Form(),
|
|
||||||
redirect_url: str | None = Form(None),
|
|
||||||
) -> None:
|
|
||||||
please_try_again = "please try again"
|
|
||||||
if redirect_url:
|
|
||||||
please_try_again = f'<a href="{redirect_url}">please try again</a>'
|
|
||||||
try:
|
try:
|
||||||
csrf_serializer.loads(csrf_token, max_age=CONFIG.csrf_token_exp)
|
csrf_serializer.loads(csrf_token, max_age=1800)
|
||||||
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
|
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
|
||||||
logger.exception("Failed to verify CSRF token")
|
logger.exception("Failed to verify CSRF token")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=403, detail="CSRF error")
|
||||||
status_code=403,
|
|
||||||
detail=f"The security token has expired, {please_try_again}",
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def hmac_sha256() -> hmac.HMAC:
|
|
||||||
return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)
|
|
||||||
|
|
||||||
|
|
||||||
stream_visibility_callback: _StreamVisibilityCallback
|
|
||||||
try:
|
|
||||||
from data.stream import ( # type: ignore # noqa: F401, E501
|
|
||||||
custom_stream_visibility_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
stream_visibility_callback = custom_stream_visibility_callback
|
|
||||||
except ImportError:
|
|
||||||
stream_visibility_callback = default_stream_visibility_callback
|
|
||||||
|
|
|
@ -1,155 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Any
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi import Request
|
|
||||||
from loguru import logger
|
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.ap_object import RemoteObject
|
|
||||||
|
|
||||||
|
|
||||||
_DATA_DIR = Path().parent.resolve() / "data"
|
|
||||||
_Handler = Callable[..., Any]
|
|
||||||
|
|
||||||
|
|
||||||
class HTMLPage:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
html_file: str,
|
|
||||||
show_in_navbar: bool,
|
|
||||||
) -> None:
|
|
||||||
self.title = title
|
|
||||||
self.html_file = _DATA_DIR / html_file
|
|
||||||
self.show_in_navbar = show_in_navbar
|
|
||||||
|
|
||||||
|
|
||||||
class RawHandler:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
handler: Any,
|
|
||||||
show_in_navbar: bool,
|
|
||||||
) -> None:
|
|
||||||
self.title = title
|
|
||||||
self.handler = handler
|
|
||||||
self.show_in_navbar = show_in_navbar
|
|
||||||
|
|
||||||
|
|
||||||
_CUSTOM_ROUTES: dict[str, HTMLPage | RawHandler] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register_html_page(
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
html_file: str,
|
|
||||||
show_in_navbar: bool = True,
|
|
||||||
) -> None:
|
|
||||||
if path in _CUSTOM_ROUTES:
|
|
||||||
raise ValueError(f"{path} is already registered")
|
|
||||||
|
|
||||||
_CUSTOM_ROUTES[path] = HTMLPage(title, html_file, show_in_navbar)
|
|
||||||
|
|
||||||
|
|
||||||
def register_raw_handler(
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
handler: _Handler,
|
|
||||||
show_in_navbar: bool = True,
|
|
||||||
) -> None:
|
|
||||||
if path in _CUSTOM_ROUTES:
|
|
||||||
raise ValueError(f"{path} is already registered")
|
|
||||||
|
|
||||||
_CUSTOM_ROUTES[path] = RawHandler(title, handler, show_in_navbar)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityPubResponse(JSONResponse):
|
|
||||||
media_type = "application/activity+json"
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_page_handler(path: str, html_page: HTMLPage) -> Any:
|
|
||||||
from app import templates
|
|
||||||
from app.actor import LOCAL_ACTOR
|
|
||||||
from app.config import is_activitypub_requested
|
|
||||||
from app.database import AsyncSession
|
|
||||||
from app.database import get_db_session
|
|
||||||
|
|
||||||
async def _handler(
|
|
||||||
request: Request,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> templates.TemplateResponse | ActivityPubResponse:
|
|
||||||
if path == "/" and is_activitypub_requested(request):
|
|
||||||
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
|
|
||||||
|
|
||||||
return await templates.render_template(
|
|
||||||
db_session,
|
|
||||||
request,
|
|
||||||
"custom_page.html",
|
|
||||||
{
|
|
||||||
"page_content": html_page.html_file.read_text(),
|
|
||||||
"title": html_page.title,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return _handler
|
|
||||||
|
|
||||||
|
|
||||||
def get_custom_router() -> APIRouter | None:
|
|
||||||
if not _CUSTOM_ROUTES:
|
|
||||||
return None
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
for path, handler in _CUSTOM_ROUTES.items():
|
|
||||||
if isinstance(handler, HTMLPage):
|
|
||||||
router.add_api_route(
|
|
||||||
path, _custom_page_handler(path, handler), methods=["GET"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
router.add_api_route(path, handler.handler)
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ObjectInfo:
|
|
||||||
# Is it a reply?
|
|
||||||
is_reply: bool
|
|
||||||
|
|
||||||
# Is it a reply to an outbox object
|
|
||||||
is_local_reply: bool
|
|
||||||
|
|
||||||
# Is the object mentioning the local actor
|
|
||||||
is_mention: bool
|
|
||||||
|
|
||||||
# Is it from someone the local actor is following
|
|
||||||
is_from_following: bool
|
|
||||||
|
|
||||||
# List of hashtags, e.g. #microblogpub
|
|
||||||
hashtags: list[str]
|
|
||||||
|
|
||||||
# @dev@microblog.pub
|
|
||||||
actor_handle: str
|
|
||||||
|
|
||||||
remote_object: "RemoteObject"
|
|
||||||
|
|
||||||
|
|
||||||
_StreamVisibilityCallback = Callable[[ObjectInfo], bool]
|
|
||||||
|
|
||||||
|
|
||||||
def default_stream_visibility_callback(object_info: ObjectInfo) -> bool:
|
|
||||||
result = (
|
|
||||||
(not object_info.is_reply and object_info.is_from_following)
|
|
||||||
or object_info.is_mention
|
|
||||||
or object_info.is_local_reply
|
|
||||||
)
|
|
||||||
logger.info(f"{object_info=}/{result=}")
|
|
||||||
return result
|
|
|
@ -9,7 +9,6 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from app.config import DB_PATH
|
from app.config import DB_PATH
|
||||||
from app.config import DEBUG
|
|
||||||
from app.config import SQLALCHEMY_DATABASE_URL
|
from app.config import SQLALCHEMY_DATABASE_URL
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
|
@ -19,7 +18,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
|
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
|
||||||
async_engine = create_async_engine(
|
async_engine = create_async_engine(
|
||||||
DATABASE_URL, future=True, echo=DEBUG, connect_args={"timeout": 15}
|
DATABASE_URL, future=True, echo=False, connect_args={"timeout": 15}
|
||||||
)
|
)
|
||||||
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -23,12 +22,12 @@ from sqlalchemy import select
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.config import BLOCKED_SERVERS
|
||||||
from app.config import KEY_PATH
|
from app.config import KEY_PATH
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.key import Key
|
from app.key import Key
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
from app.utils.url import is_hostname_blocked
|
|
||||||
|
|
||||||
_KEY_CACHE: MutableMapping[str, Key] = LFUCache(256)
|
_KEY_CACHE: MutableMapping[str, Key] = LFUCache(256)
|
||||||
|
|
||||||
|
@ -89,12 +88,8 @@ def _body_digest(body: bytes) -> str:
|
||||||
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
async def _get_public_key(
|
async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
||||||
db_session: AsyncSession,
|
if cached_key := _KEY_CACHE.get(key_id):
|
||||||
key_id: str,
|
|
||||||
should_skip_cache: bool = False,
|
|
||||||
) -> Key:
|
|
||||||
if not should_skip_cache and (cached_key := _KEY_CACHE.get(key_id)):
|
|
||||||
logger.info(f"Key {key_id} found in cache")
|
logger.info(f"Key {key_id} found in cache")
|
||||||
return cached_key
|
return cached_key
|
||||||
|
|
||||||
|
@ -106,25 +101,25 @@ async def _get_public_key(
|
||||||
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
select(models.Actor).where(models.Actor.ap_id == key_id.split("#")[0])
|
||||||
)
|
)
|
||||||
).one_or_none()
|
).one_or_none()
|
||||||
if not should_skip_cache:
|
if existing_actor and existing_actor.public_key_id == key_id:
|
||||||
if existing_actor and existing_actor.public_key_id == key_id:
|
k = Key(existing_actor.ap_id, key_id)
|
||||||
k = Key(existing_actor.ap_id, key_id)
|
k.load_pub(existing_actor.public_key_as_pem)
|
||||||
k.load_pub(existing_actor.public_key_as_pem)
|
logger.info(f"Found {key_id} on an existing actor")
|
||||||
logger.info(f"Found {key_id} on an existing actor")
|
_KEY_CACHE[key_id] = k
|
||||||
_KEY_CACHE[key_id] = k
|
return k
|
||||||
return k
|
|
||||||
|
|
||||||
# Fetch it
|
# Fetch it
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app.actor import RemoteActor
|
|
||||||
from app.actor import update_actor_if_needed
|
|
||||||
|
|
||||||
# Without signing the request as if it's the first contact, the 2 servers
|
# Without signing the request as if it's the first contact, the 2 servers
|
||||||
# might race to fetch each other key
|
# might race to fetch each other key
|
||||||
try:
|
try:
|
||||||
actor = await ap.fetch(key_id, disable_httpsig=True)
|
actor = await ap.fetch(key_id, disable_httpsig=True)
|
||||||
except ap.ObjectUnavailableError:
|
except httpx.HTTPStatusError as http_err:
|
||||||
actor = await ap.fetch(key_id, disable_httpsig=False)
|
if http_err.response.status_code in [401, 403]:
|
||||||
|
actor = await ap.fetch(key_id, disable_httpsig=False)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
if actor["type"] == "Key":
|
if actor["type"] == "Key":
|
||||||
# The Key is not embedded in the Person
|
# The Key is not embedded in the Person
|
||||||
|
@ -135,18 +130,11 @@ async def _get_public_key(
|
||||||
k.load_pub(actor["publicKey"]["publicKeyPem"])
|
k.load_pub(actor["publicKey"]["publicKeyPem"])
|
||||||
|
|
||||||
# Ensure the right key was fetch
|
# Ensure the right key was fetch
|
||||||
# TODO: some server have the key ID `http://` but fetching it return `https`
|
|
||||||
if key_id not in [k.key_id(), k.owner]:
|
if key_id not in [k.key_id(), k.owner]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_skip_cache and actor["type"] != "Key" and existing_actor:
|
|
||||||
# We had to skip the cache, which means the actor key probably changed
|
|
||||||
# and we want to update our cached version
|
|
||||||
await update_actor_if_needed(db_session, existing_actor, RemoteActor(actor))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
_KEY_CACHE[key_id] = k
|
_KEY_CACHE[key_id] = k
|
||||||
return k
|
return k
|
||||||
|
|
||||||
|
@ -184,7 +172,7 @@ async def httpsig_checker(
|
||||||
)
|
)
|
||||||
|
|
||||||
server = urlparse(key_id).hostname
|
server = urlparse(key_id).hostname
|
||||||
if is_hostname_blocked(server):
|
if server in BLOCKED_SERVERS:
|
||||||
return HTTPSigInfo(
|
return HTTPSigInfo(
|
||||||
has_valid_signature=False,
|
has_valid_signature=False,
|
||||||
server=server,
|
server=server,
|
||||||
|
@ -199,32 +187,6 @@ async def httpsig_checker(
|
||||||
server=server,
|
server=server,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to drop Delete activity spams early on, this prevent making an extra
|
|
||||||
# HTTP requests trying to fetch an unavailable actor to verify the HTTP sig
|
|
||||||
try:
|
|
||||||
if request.method == "POST" and request.url.path.endswith("/inbox"):
|
|
||||||
from app import models # TODO: solve this circular import
|
|
||||||
|
|
||||||
activity = json.loads(body)
|
|
||||||
actor_id = ap.get_id(activity["actor"])
|
|
||||||
if (
|
|
||||||
ap.as_list(activity["type"])[0] == "Delete"
|
|
||||||
and actor_id == ap.get_id(activity["object"])
|
|
||||||
and not (
|
|
||||||
await db_session.scalars(
|
|
||||||
select(models.Actor).where(
|
|
||||||
models.Actor.ap_id == actor_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).one_or_none()
|
|
||||||
):
|
|
||||||
logger.info(f"Dropping Delete activity early for {body=}")
|
|
||||||
raise fastapi.HTTPException(status_code=202)
|
|
||||||
except fastapi.HTTPException as http_exc:
|
|
||||||
raise http_exc
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to check for Delete spam")
|
|
||||||
|
|
||||||
# logger.debug(f"hsig={hsig}")
|
# logger.debug(f"hsig={hsig}")
|
||||||
signed_string, signature_date = _build_signed_string(
|
signed_string, signature_date = _build_signed_string(
|
||||||
hsig["headers"],
|
hsig["headers"],
|
||||||
|
@ -253,23 +215,10 @@ async def httpsig_checker(
|
||||||
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
|
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
|
||||||
return HTTPSigInfo(has_valid_signature=False)
|
return HTTPSigInfo(has_valid_signature=False)
|
||||||
|
|
||||||
has_valid_signature = _verify_h(
|
|
||||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
# If the signature is not valid, we may have to update the cached actor
|
|
||||||
if not has_valid_signature:
|
|
||||||
logger.info("Invalid signature, trying to refresh actor")
|
|
||||||
try:
|
|
||||||
k = await _get_public_key(db_session, hsig["keyId"], should_skip_cache=True)
|
|
||||||
has_valid_signature = _verify_h(
|
|
||||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to refresh actor")
|
|
||||||
|
|
||||||
httpsig_info = HTTPSigInfo(
|
httpsig_info = HTTPSigInfo(
|
||||||
has_valid_signature=has_valid_signature,
|
has_valid_signature=_verify_h(
|
||||||
|
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
||||||
|
),
|
||||||
signed_by_ap_actor_id=k.owner,
|
signed_by_ap_actor_id=k.owner,
|
||||||
server=server,
|
server=server,
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,6 +3,7 @@ import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
@ -25,7 +26,7 @@ async def new_ap_incoming_activity(
|
||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
) -> models.IncomingActivity | None:
|
) -> models.IncomingActivity | None:
|
||||||
ap_id: str
|
ap_id: str
|
||||||
if "id" not in raw_object or ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
|
if "id" not in raw_object:
|
||||||
if "@context" not in raw_object:
|
if "@context" not in raw_object:
|
||||||
logger.warning(f"Dropping invalid object: {raw_object}")
|
logger.warning(f"Dropping invalid object: {raw_object}")
|
||||||
return None
|
return None
|
||||||
|
@ -60,7 +61,7 @@ def _set_next_try(
|
||||||
if not outgoing_activity.tries:
|
if not outgoing_activity.tries:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
if outgoing_activity.tries >= _MAX_RETRIES:
|
if outgoing_activity.tries == _MAX_RETRIES:
|
||||||
outgoing_activity.is_errored = True
|
outgoing_activity.is_errored = True
|
||||||
outgoing_activity.next_try = None
|
outgoing_activity.next_try = None
|
||||||
else:
|
else:
|
||||||
|
@ -107,29 +108,22 @@ async def process_next_incoming_activity(
|
||||||
|
|
||||||
next_activity.tries = next_activity.tries + 1
|
next_activity.tries = next_activity.tries + 1
|
||||||
next_activity.last_try = now()
|
next_activity.last_try = now()
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
|
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
|
||||||
try:
|
try:
|
||||||
async with db_session.begin_nested():
|
async with db_session.begin_nested():
|
||||||
await asyncio.wait_for(
|
await save_to_inbox(
|
||||||
save_to_inbox(
|
db_session,
|
||||||
db_session,
|
next_activity.ap_object,
|
||||||
next_activity.ap_object,
|
next_activity.sent_by_ap_actor_id,
|
||||||
next_activity.sent_by_ap_actor_id,
|
|
||||||
),
|
|
||||||
timeout=60,
|
|
||||||
)
|
)
|
||||||
except asyncio.exceptions.TimeoutError:
|
except httpx.TimeoutException as exc:
|
||||||
logger.error("Activity took too long to process")
|
url = exc._request.url if exc._request else None
|
||||||
await db_session.rollback()
|
logger.error(f"Failed, HTTP timeout when fetching {url}")
|
||||||
await db_session.refresh(next_activity)
|
|
||||||
next_activity.error = traceback.format_exc()
|
next_activity.error = traceback.format_exc()
|
||||||
_set_next_try(next_activity)
|
_set_next_try(next_activity)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed")
|
logger.exception("Failed")
|
||||||
await db_session.rollback()
|
|
||||||
await db_session.refresh(next_activity)
|
|
||||||
next_activity.error = traceback.format_exc()
|
next_activity.error = traceback.format_exc()
|
||||||
_set_next_try(next_activity)
|
_set_next_try(next_activity)
|
||||||
else:
|
else:
|
||||||
|
|
267
app/indieauth.py
267
app/indieauth.py
|
@ -10,12 +10,9 @@ from fastapi import Form
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.security import HTTPBasic
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.security import HTTPBasicCredentials
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app import models
|
from app import models
|
||||||
|
@ -24,12 +21,9 @@ from app.admin import user_session_or_redirect
|
||||||
from app.config import verify_csrf_token
|
from app.config import verify_csrf_token
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.redirect import redirect
|
|
||||||
from app.utils import indieauth
|
from app.utils import indieauth
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
|
|
||||||
basic_auth = HTTPBasic()
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,55 +38,9 @@ async def well_known_authorization_server(
|
||||||
"code_challenge_methods_supported": ["S256"],
|
"code_challenge_methods_supported": ["S256"],
|
||||||
"revocation_endpoint": request.url_for("indieauth_revocation_endpoint"),
|
"revocation_endpoint": request.url_for("indieauth_revocation_endpoint"),
|
||||||
"revocation_endpoint_auth_methods_supported": ["none"],
|
"revocation_endpoint_auth_methods_supported": ["none"],
|
||||||
"registration_endpoint": request.url_for("oauth_registration_endpoint"),
|
|
||||||
"introspection_endpoint": request.url_for("oauth_introspection_endpoint"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
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")
|
@router.get("/auth")
|
||||||
async def indieauth_authorization_endpoint(
|
async def indieauth_authorization_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
@ -108,29 +56,12 @@ async def indieauth_authorization_endpoint(
|
||||||
code_challenge = request.query_params.get("code_challenge", "")
|
code_challenge = request.query_params.get("code_challenge", "")
|
||||||
code_challenge_method = request.query_params.get("code_challenge_method", "")
|
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(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
"indieauth_flow.html",
|
"indieauth_flow.html",
|
||||||
dict(
|
dict(
|
||||||
client=client,
|
client=await indieauth.get_client_id_data(client_id),
|
||||||
scopes=scope,
|
scopes=scope,
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
|
@ -149,7 +80,7 @@ async def indieauth_flow(
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
_: None = Depends(user_session_or_redirect),
|
_: None = Depends(user_session_or_redirect),
|
||||||
) -> templates.TemplateResponse:
|
) -> RedirectResponse:
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
logger.info(f"{form_data=}")
|
logger.info(f"{form_data=}")
|
||||||
|
|
||||||
|
@ -183,8 +114,9 @@ async def indieauth_flow(
|
||||||
db_session.add(auth_request)
|
db_session.add(auth_request)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
return await redirect(
|
return RedirectResponse(
|
||||||
request, db_session, redirect_uri + f"?code={code}&state={state}&iss={iss}"
|
redirect_uri + f"?code={code}&state={state}&iss={iss}",
|
||||||
|
status_code=302,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -275,54 +207,29 @@ async def indieauth_token_endpoint(
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
logger.info(f"{form_data=}")
|
logger.info(f"{form_data=}")
|
||||||
grant_type = form_data.get("grant_type", "authorization_code")
|
grant_type = form_data.get("grant_type", "authorization_code")
|
||||||
if grant_type not in ["authorization_code", "refresh_token"]:
|
if grant_type != "authorization_code":
|
||||||
raise ValueError(f"Invalid grant_type {grant_type}")
|
raise ValueError(f"Invalid grant_type {grant_type}")
|
||||||
|
|
||||||
|
code = form_data["code"]
|
||||||
|
|
||||||
# These must match the params from the first request
|
# These must match the params from the first request
|
||||||
client_id = form_data["client_id"]
|
client_id = form_data["client_id"]
|
||||||
|
redirect_uri = form_data["redirect_uri"]
|
||||||
|
# code_verifier is optional for backward compat
|
||||||
code_verifier = form_data.get("code_verifier")
|
code_verifier = form_data.get("code_verifier")
|
||||||
|
|
||||||
if grant_type == "authorization_code":
|
is_code_valid, auth_code_request = await _check_auth_code(
|
||||||
code = form_data["code"]
|
db_session,
|
||||||
redirect_uri = form_data["redirect_uri"]
|
code=code,
|
||||||
# code_verifier is optional for backward compat
|
client_id=client_id,
|
||||||
is_code_valid, auth_code_request = await _check_auth_code(
|
redirect_uri=redirect_uri,
|
||||||
db_session,
|
code_verifier=code_verifier,
|
||||||
code=code,
|
)
|
||||||
client_id=client_id,
|
if not is_code_valid or (auth_code_request and not auth_code_request.scope):
|
||||||
redirect_uri=redirect_uri,
|
return JSONResponse(
|
||||||
code_verifier=code_verifier,
|
content={"error": "invalid_grant"},
|
||||||
|
status_code=400,
|
||||||
)
|
)
|
||||||
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 = 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:
|
if not auth_code_request:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
@ -330,7 +237,6 @@ async def indieauth_token_endpoint(
|
||||||
access_token = models.IndieAuthAccessToken(
|
access_token = models.IndieAuthAccessToken(
|
||||||
indieauth_authorization_request_id=auth_code_request.id,
|
indieauth_authorization_request_id=auth_code_request.id,
|
||||||
access_token=secrets.token_urlsafe(32),
|
access_token=secrets.token_urlsafe(32),
|
||||||
refresh_token=secrets.token_urlsafe(32),
|
|
||||||
expires_in=3600,
|
expires_in=3600,
|
||||||
scope=auth_code_request.scope,
|
scope=auth_code_request.scope,
|
||||||
)
|
)
|
||||||
|
@ -340,7 +246,6 @@ async def indieauth_token_endpoint(
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"access_token": access_token.access_token,
|
"access_token": access_token.access_token,
|
||||||
"refresh_token": access_token.refresh_token,
|
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"scope": auth_code_request.scope,
|
"scope": auth_code_request.scope,
|
||||||
"me": config.ID + "/",
|
"me": config.ID + "/",
|
||||||
|
@ -356,10 +261,8 @@ async def _check_access_token(
|
||||||
) -> tuple[bool, models.IndieAuthAccessToken | None]:
|
) -> tuple[bool, models.IndieAuthAccessToken | None]:
|
||||||
access_token_info = (
|
access_token_info = (
|
||||||
await db_session.scalars(
|
await db_session.scalars(
|
||||||
select(models.IndieAuthAccessToken)
|
select(models.IndieAuthAccessToken).where(
|
||||||
.where(models.IndieAuthAccessToken.access_token == token)
|
models.IndieAuthAccessToken.access_token == token
|
||||||
.options(
|
|
||||||
joinedload(models.IndieAuthAccessToken.indieauth_authorization_request)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).one_or_none()
|
).one_or_none()
|
||||||
|
@ -373,7 +276,7 @@ async def _check_access_token(
|
||||||
if now() > access_token_info.created_at.replace(tzinfo=timezone.utc) + timedelta(
|
if now() > access_token_info.created_at.replace(tzinfo=timezone.utc) + timedelta(
|
||||||
seconds=access_token_info.expires_in
|
seconds=access_token_info.expires_in
|
||||||
):
|
):
|
||||||
logger.info("Access token has expired")
|
logger.info("Access token is expired")
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
return True, access_token_info
|
return True, access_token_info
|
||||||
|
@ -382,9 +285,6 @@ async def _check_access_token(
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AccessTokenInfo:
|
class AccessTokenInfo:
|
||||||
scopes: list[str]
|
scopes: list[str]
|
||||||
client_id: str | None
|
|
||||||
access_token: str
|
|
||||||
exp: int
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_access_token(
|
async def verify_access_token(
|
||||||
|
@ -411,71 +311,9 @@ async def verify_access_token(
|
||||||
|
|
||||||
return AccessTokenInfo(
|
return AccessTokenInfo(
|
||||||
scopes=access_token.scope.split(),
|
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")
|
|
||||||
|
|
||||||
return maybe_access_token_info
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/revoke_token")
|
@router.post("/revoke_token")
|
||||||
async def indieauth_revocation_endpoint(
|
async def indieauth_revocation_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
@ -495,58 +333,3 @@ async def indieauth_revocation_endpoint(
|
||||||
content={},
|
content={},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token_introspection")
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
|
@ -23,13 +23,6 @@ requests_loader = pyld.documentloader.requests.requests_document_loader()
|
||||||
def _loader(url, options={}):
|
def _loader(url, options={}):
|
||||||
# See https://github.com/digitalbazaar/pyld/issues/133
|
# See https://github.com/digitalbazaar/pyld/issues/133
|
||||||
options["headers"]["Accept"] = "application/ld+json"
|
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)
|
return requests_loader(url, options)
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,7 +34,7 @@ def _options_hash(doc: ap.RawObject) -> str:
|
||||||
for k in ["type", "id", "signatureValue"]:
|
for k in ["type", "id", "signatureValue"]:
|
||||||
if k in doc:
|
if k in doc:
|
||||||
del doc[k]
|
del doc[k]
|
||||||
doc["@context"] = "https://w3id.org/security/v1"
|
doc["@context"] = "https://w3id.org/identity/v1"
|
||||||
normalized = jsonld.normalize(
|
normalized = jsonld.normalize(
|
||||||
doc, {"algorithm": "URDNA2015", "format": "application/nquads"}
|
doc, {"algorithm": "URDNA2015", "format": "application/nquads"}
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,7 +10,6 @@ from app.source import _MENTION_REGEX
|
||||||
|
|
||||||
|
|
||||||
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
|
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
|
||||||
query = query.strip()
|
|
||||||
if query.startswith("@") or _MENTION_REGEX.match("@" + query):
|
if query.startswith("@") or _MENTION_REGEX.match("@" + query):
|
||||||
query = await webfinger.get_actor_url(query) # type: ignore # None check below
|
query = await webfinger.get_actor_url(query) # type: ignore # None check below
|
||||||
|
|
||||||
|
@ -38,9 +37,4 @@ async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
|
||||||
if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES:
|
if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES:
|
||||||
return RemoteActor(ap_obj)
|
return RemoteActor(ap_obj)
|
||||||
else:
|
else:
|
||||||
# Some software return objects wrapped in a Create activity (like
|
|
||||||
# python-federation)
|
|
||||||
if ap.as_list(ap_obj["type"])[0] == "Create":
|
|
||||||
ap_obj = await ap.get_object(ap_obj)
|
|
||||||
|
|
||||||
return await RemoteObject.from_raw_object(ap_obj)
|
return await RemoteObject.from_raw_object(ap_obj)
|
||||||
|
|
832
app/main.py
832
app/main.py
File diff suppressed because it is too large
Load diff
31
app/media.py
31
app/media.py
|
@ -1,44 +1,15 @@
|
||||||
import base64
|
import base64
|
||||||
import time
|
|
||||||
|
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import hmac_sha256
|
|
||||||
|
|
||||||
SUPPORTED_RESIZE = [50, 740]
|
SUPPORTED_RESIZE = [50, 740]
|
||||||
EXPIRY_PERIOD = 86400
|
|
||||||
EXPIRY_LENGTH = 7
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidProxySignatureError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def proxied_media_sig(expires: int, url: str) -> str:
|
|
||||||
hm = hmac_sha256()
|
|
||||||
hm.update(f"{expires}".encode())
|
|
||||||
hm.update(b"|")
|
|
||||||
hm.update(url.encode())
|
|
||||||
return base64.urlsafe_b64encode(hm.digest()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_proxied_media_sig(expires: int, url: str, sig: str) -> None:
|
|
||||||
now = int(time.time() / EXPIRY_PERIOD)
|
|
||||||
expected = proxied_media_sig(expires, url)
|
|
||||||
if now > expires or sig != expected:
|
|
||||||
raise InvalidProxySignatureError("invalid or expired media")
|
|
||||||
|
|
||||||
|
|
||||||
def proxied_media_url(url: str) -> str:
|
def proxied_media_url(url: str) -> str:
|
||||||
if url.startswith(BASE_URL):
|
if url.startswith(BASE_URL):
|
||||||
return url
|
return url
|
||||||
expires = int(time.time() / EXPIRY_PERIOD) + EXPIRY_LENGTH
|
|
||||||
sig = proxied_media_sig(expires, url)
|
|
||||||
|
|
||||||
return (
|
return "/proxy/media/" + base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
BASE_URL
|
|
||||||
+ f"/proxy/media/{expires}/{sig}/"
|
|
||||||
+ base64.urlsafe_b64encode(url.encode()).decode()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resized_media_url(url: str, size: int) -> str:
|
def resized_media_url(url: str, size: int) -> str:
|
||||||
|
|
|
@ -132,7 +132,7 @@ async def post_micropub_endpoint(
|
||||||
h = form_data["h"]
|
h = form_data["h"]
|
||||||
entry_type = f"h-{h}"
|
entry_type = f"h-{h}"
|
||||||
|
|
||||||
logger.info(f"Creating {entry_type=} with {access_token_info=}")
|
logger.info(f"Creating {entry_type}")
|
||||||
|
|
||||||
if entry_type != "h-entry":
|
if entry_type != "h-entry":
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
@ -150,7 +150,7 @@ async def post_micropub_endpoint(
|
||||||
else:
|
else:
|
||||||
content = form_data["content"]
|
content = form_data["content"]
|
||||||
|
|
||||||
public_id, _ = await send_create(
|
public_id = await send_create(
|
||||||
db_session,
|
db_session,
|
||||||
"Note",
|
"Note",
|
||||||
content,
|
content,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
@ -46,7 +45,7 @@ class Actor(Base, BaseActor):
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||||
|
|
||||||
ap_id: Mapped[str] = Column(String, unique=True, nullable=False, index=True)
|
ap_id = Column(String, unique=True, nullable=False, index=True)
|
||||||
ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
||||||
ap_type = Column(String, nullable=False)
|
ap_type = Column(String, nullable=False)
|
||||||
|
|
||||||
|
@ -55,10 +54,6 @@ class Actor(Base, BaseActor):
|
||||||
is_blocked = Column(Boolean, nullable=False, default=False, server_default="0")
|
is_blocked = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
is_deleted = 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
|
@property
|
||||||
def is_from_db(self) -> bool:
|
def is_from_db(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
@ -80,7 +75,7 @@ class InboxObject(Base, BaseObject):
|
||||||
|
|
||||||
ap_actor_id = Column(String, nullable=False)
|
ap_actor_id = Column(String, nullable=False)
|
||||||
ap_type = Column(String, nullable=False, index=True)
|
ap_type = Column(String, nullable=False, index=True)
|
||||||
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
ap_id = Column(String, nullable=False, unique=True, index=True)
|
||||||
ap_context = Column(String, nullable=True)
|
ap_context = Column(String, nullable=True)
|
||||||
ap_published_at = Column(DateTime(timezone=True), nullable=False)
|
ap_published_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
||||||
|
@ -131,7 +126,7 @@ class InboxObject(Base, BaseObject):
|
||||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
|
replies_count = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
|
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
@ -163,10 +158,9 @@ class OutboxObject(Base, BaseObject):
|
||||||
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
is_hidden_from_homepage = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
public_id = Column(String, nullable=False, index=True)
|
public_id = Column(String, nullable=False, index=True)
|
||||||
slug = Column(String, nullable=True, index=True)
|
|
||||||
|
|
||||||
ap_type = Column(String, nullable=False, index=True)
|
ap_type = Column(String, nullable=False, index=True)
|
||||||
ap_id: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
ap_id = Column(String, nullable=False, unique=True, index=True)
|
||||||
ap_context = Column(String, nullable=True)
|
ap_context = Column(String, nullable=True)
|
||||||
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
ap_object: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
||||||
|
|
||||||
|
@ -182,7 +176,7 @@ class OutboxObject(Base, BaseObject):
|
||||||
|
|
||||||
likes_count = Column(Integer, nullable=False, default=0)
|
likes_count = Column(Integer, nullable=False, default=0)
|
||||||
announces_count = Column(Integer, nullable=False, default=0)
|
announces_count = Column(Integer, nullable=False, default=0)
|
||||||
replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
|
replies_count = Column(Integer, nullable=False, default=0)
|
||||||
webmentions_count: Mapped[int] = Column(
|
webmentions_count: Mapped[int] = Column(
|
||||||
Integer, nullable=False, default=0, server_default="0"
|
Integer, nullable=False, default=0, server_default="0"
|
||||||
)
|
)
|
||||||
|
@ -256,8 +250,6 @@ class OutboxObject(Base, BaseObject):
|
||||||
"mediaType": attachment.upload.content_type,
|
"mediaType": attachment.upload.content_type,
|
||||||
"name": attachment.alt or attachment.filename,
|
"name": attachment.alt or attachment.filename,
|
||||||
"url": url,
|
"url": url,
|
||||||
"width": attachment.upload.width,
|
|
||||||
"height": attachment.upload.height,
|
|
||||||
"proxiedUrl": url,
|
"proxiedUrl": url,
|
||||||
"resizedUrl": BASE_URL
|
"resizedUrl": BASE_URL
|
||||||
+ (
|
+ (
|
||||||
|
@ -289,13 +281,6 @@ class OutboxObject(Base, BaseObject):
|
||||||
def is_from_outbox(self) -> bool:
|
def is_from_outbox(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self) -> str | None:
|
|
||||||
# XXX: rewrite old URL here for compat
|
|
||||||
if self.ap_type == "Article" and self.slug and self.public_id:
|
|
||||||
return f"{BASE_URL}/articles/{self.public_id[:7]}/{self.slug}"
|
|
||||||
return super().url
|
|
||||||
|
|
||||||
|
|
||||||
class Follower(Base):
|
class Follower(Base):
|
||||||
__tablename__ = "follower"
|
__tablename__ = "follower"
|
||||||
|
@ -437,7 +422,7 @@ class OutboxObjectAttachment(Base):
|
||||||
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
||||||
|
|
||||||
upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False)
|
upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False)
|
||||||
upload: Mapped["Upload"] = relationship(Upload, uselist=False)
|
upload = relationship(Upload, uselist=False)
|
||||||
|
|
||||||
|
|
||||||
class IndieAuthAuthorizationRequest(Base):
|
class IndieAuthAuthorizationRequest(Base):
|
||||||
|
@ -460,53 +445,17 @@ class IndieAuthAccessToken(Base):
|
||||||
__tablename__ = "indieauth_access_token"
|
__tablename__ = "indieauth_access_token"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
created_at: Mapped[datetime] = Column(
|
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||||
DateTime(timezone=True), nullable=False, default=now
|
|
||||||
)
|
|
||||||
|
|
||||||
# Will be null for personal access tokens
|
# Will be null for personal access tokens
|
||||||
indieauth_authorization_request_id = Column(
|
indieauth_authorization_request_id = Column(
|
||||||
Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True
|
Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True
|
||||||
)
|
)
|
||||||
indieauth_authorization_request = relationship(
|
|
||||||
IndieAuthAuthorizationRequest,
|
|
||||||
uselist=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
access_token = Column(String, nullable=False, unique=True, index=True)
|
||||||
refresh_token = Column(String, nullable=True, unique=True, index=True)
|
expires_in = Column(Integer, nullable=False)
|
||||||
expires_in: Mapped[int] = Column(Integer, nullable=False)
|
|
||||||
scope = Column(String, nullable=False)
|
scope = Column(String, nullable=False)
|
||||||
is_revoked = Column(Boolean, nullable=False, default=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
|
|
||||||
class WebmentionType(str, enum.Enum):
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
LIKE = "like"
|
|
||||||
REPLY = "reply"
|
|
||||||
REPOST = "repost"
|
|
||||||
|
|
||||||
|
|
||||||
class Webmention(Base):
|
class Webmention(Base):
|
||||||
|
@ -525,8 +474,6 @@ class Webmention(Base):
|
||||||
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
||||||
outbox_object = relationship(OutboxObject, uselist=False)
|
outbox_object = relationship(OutboxObject, uselist=False)
|
||||||
|
|
||||||
webmention_type = Column(Enum(WebmentionType), nullable=True)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def as_facepile_item(self) -> webmentions.Webmention | None:
|
def as_facepile_item(self) -> webmentions.Webmention | None:
|
||||||
if not self.source_microformats:
|
if not self.source_microformats:
|
||||||
|
@ -536,7 +483,6 @@ class Webmention(Base):
|
||||||
self.source_microformats["items"], self.source
|
self.source_microformats["items"], self.source
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# TODO: return a facepile with the unknown image
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to generate facefile item for Webmention id={self.id}"
|
f"Failed to generate facefile item for Webmention id={self.id}"
|
||||||
)
|
)
|
||||||
|
@ -591,8 +537,6 @@ class NotificationType(str, enum.Enum):
|
||||||
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
|
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
|
||||||
FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
|
FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
|
||||||
|
|
||||||
MOVE = "move"
|
|
||||||
|
|
||||||
LIKE = "like"
|
LIKE = "like"
|
||||||
UNDO_LIKE = "undo_like"
|
UNDO_LIKE = "undo_like"
|
||||||
|
|
||||||
|
@ -605,14 +549,6 @@ class NotificationType(str, enum.Enum):
|
||||||
UPDATED_WEBMENTION = "updated_webmention"
|
UPDATED_WEBMENTION = "updated_webmention"
|
||||||
DELETED_WEBMENTION = "deleted_webmention"
|
DELETED_WEBMENTION = "deleted_webmention"
|
||||||
|
|
||||||
# incoming
|
|
||||||
BLOCKED = "blocked"
|
|
||||||
UNBLOCKED = "unblocked"
|
|
||||||
|
|
||||||
# outgoing
|
|
||||||
BLOCK = "block"
|
|
||||||
UNBLOCK = "unblock"
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base):
|
class Notification(Base):
|
||||||
__tablename__ = "notifications"
|
__tablename__ = "notifications"
|
||||||
|
|
|
@ -67,7 +67,6 @@ async def _send_actor_update_if_needed(
|
||||||
logger.info("Will send an Update for the local actor")
|
logger.info("Will send an Update for the local actor")
|
||||||
|
|
||||||
from app.boxes import allocate_outbox_id
|
from app.boxes import allocate_outbox_id
|
||||||
from app.boxes import compute_all_known_recipients
|
|
||||||
from app.boxes import outbox_object_id
|
from app.boxes import outbox_object_id
|
||||||
from app.boxes import save_outbox_object
|
from app.boxes import save_outbox_object
|
||||||
|
|
||||||
|
@ -86,8 +85,24 @@ async def _send_actor_update_if_needed(
|
||||||
|
|
||||||
# Send the update to the followers collection and all the actor we have ever
|
# Send the update to the followers collection and all the actor we have ever
|
||||||
# contacted
|
# contacted
|
||||||
recipients = await compute_all_known_recipients(db_session)
|
followers = (
|
||||||
for rcp in recipients:
|
(
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.Follower).options(joinedload(models.Follower.actor))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for rcp in {
|
||||||
|
follower.actor.shared_inbox_url or follower.actor.inbox_url
|
||||||
|
for follower in followers
|
||||||
|
} | {
|
||||||
|
row.recipient
|
||||||
|
for row in await db_session.execute(
|
||||||
|
select(func.distinct(models.OutgoingActivity.recipient).label("recipient"))
|
||||||
|
)
|
||||||
|
}: # type: ignore
|
||||||
await new_outgoing_activity(
|
await new_outgoing_activity(
|
||||||
db_session,
|
db_session,
|
||||||
recipient=rcp,
|
recipient=rcp,
|
||||||
|
@ -151,7 +166,7 @@ def _set_next_try(
|
||||||
if not outgoing_activity.tries:
|
if not outgoing_activity.tries:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
if outgoing_activity.tries >= _MAX_RETRIES:
|
if outgoing_activity.tries == _MAX_RETRIES:
|
||||||
outgoing_activity.is_errored = True
|
outgoing_activity.is_errored = True
|
||||||
outgoing_activity.next_try = None
|
outgoing_activity.next_try = None
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -102,8 +102,6 @@ async def _prune_old_inbox_objects(
|
||||||
models.InboxObject.ap_type.in_(["Note"]),
|
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
|
# Filter by retention days
|
||||||
models.InboxObject.ap_published_at
|
models.InboxObject.ap_published_at
|
||||||
< now() - timedelta(days=INBOX_RETENTION_DAYS),
|
< now() - timedelta(days=INBOX_RETENTION_DAYS),
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
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},
|
|
||||||
)
|
|
|
@ -13,58 +13,21 @@ $code-highlight-background: #f0f0f0;
|
||||||
// Load custom theme
|
// Load custom theme
|
||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
.primary-color {
|
|
||||||
color: $primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
#admin {
|
|
||||||
.admin-menu {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-top-menu {
|
|
||||||
margin: 30px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.width-95 {
|
|
||||||
width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bold {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-new {
|
|
||||||
textarea {
|
|
||||||
font-size: 1.2em;
|
|
||||||
width: 95%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-more-wrapper {
|
.show-more-wrapper {
|
||||||
.p-summary {
|
.p-summary {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.show-more-btn {
|
label {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
summary {
|
.show-more-state {
|
||||||
display: inline-block;
|
display: none;
|
||||||
}
|
}
|
||||||
summary::-webkit-details-marker {
|
.show-more-state ~ .obj-content {
|
||||||
display: none
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
&:not([open]) .show-more-btn::after {
|
.show-more-state:checked ~ .obj-content {
|
||||||
content: 'show more';
|
display: none;
|
||||||
}
|
|
||||||
&[open] .show-more-btn::after {
|
|
||||||
content: 'show less';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sensitive-attachment {
|
.sensitive-attachment {
|
||||||
|
@ -98,6 +61,13 @@ blockquote {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poll-bar {
|
||||||
|
width:100%;height:20px;
|
||||||
|
line {
|
||||||
|
stroke: $secondary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.light-background {
|
.light-background {
|
||||||
background: $light-background;
|
background: $light-background;
|
||||||
}
|
}
|
||||||
|
@ -146,9 +116,6 @@ dl {
|
||||||
strong {
|
strong {
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
}
|
}
|
||||||
span {
|
|
||||||
color: $muted-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.highlight {
|
div.highlight {
|
||||||
|
@ -215,7 +182,6 @@ a {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#main {
|
#main {
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
|
@ -223,36 +189,11 @@ main {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-flex {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
div {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tiny-actor-icon {
|
|
||||||
max-width: 24px;
|
|
||||||
max-height: 24px;
|
|
||||||
position: relative;
|
|
||||||
top: 5px;
|
|
||||||
}
|
}
|
||||||
.actor-box {
|
.actor-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -276,9 +217,6 @@ footer {
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
li {
|
li {
|
||||||
display: block;
|
display: block;
|
||||||
span {
|
|
||||||
padding-right:10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -313,57 +251,6 @@ footer {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-hide-sensitive-btn {
|
|
||||||
display:inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-margin-top {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-right {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.poll-items {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
li {
|
|
||||||
display: block;
|
|
||||||
p {
|
|
||||||
margin: 20px 0 10px 0;
|
|
||||||
.poll-vote {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-bar {
|
|
||||||
width:100%;height:20px;
|
|
||||||
line {
|
|
||||||
stroke: $secondary-color;
|
|
||||||
stroke-width: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-wrapper {
|
|
||||||
.attachment-item {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
img.attachment {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
a.attachment {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
audio.attachment {
|
|
||||||
width: 480px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
form {
|
form {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
|
@ -391,7 +278,7 @@ nav.flexbox {
|
||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a:not(.label-btn) {
|
a {
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
&:hover, &:active {
|
&:hover, &:active {
|
||||||
|
@ -399,29 +286,23 @@ nav.flexbox {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.active:not(.label-btn) {
|
a.active {
|
||||||
color: $secondary-color;
|
color: $secondary-color;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// after nav.flexbox to override default behavior
|
|
||||||
a.label-btn {
|
|
||||||
color: $form-text-color;
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
color: $form-text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-object {
|
.ap-object {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
nav {
|
|
||||||
color: $muted-color;
|
|
||||||
}
|
|
||||||
.in-reply-to {
|
.in-reply-to {
|
||||||
display: inline;
|
color: $muted-color;
|
||||||
|
&:hover {
|
||||||
|
color: $secondary-color;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nav {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
.e-content, .activity-og-meta {
|
.e-content, .activity-og-meta {
|
||||||
|
@ -430,9 +311,10 @@ a.label-btn {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.activity-attachment {
|
.activity-attachment {
|
||||||
margin: 30px 0 20px 0;
|
margin: 30px 0;
|
||||||
img, audio, video {
|
img, audio, video {
|
||||||
max-width: calc(min(740px, 100%));
|
width: 100%;
|
||||||
|
max-width: 740px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
img.inline-img {
|
img.inline-img {
|
||||||
|
@ -440,25 +322,11 @@ a.label-btn {
|
||||||
max-width: 740px;
|
max-width: 740px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-og-meta {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
img {
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 100px;
|
|
||||||
}
|
|
||||||
small {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-object-expanded {
|
.ap-object-expanded {
|
||||||
border: 2px dashed $secondary-color;
|
border: 2px dashed $secondary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box, .scolor {
|
.error-box {
|
||||||
color: $secondary-color;
|
color: $secondary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -469,9 +337,6 @@ a.label-btn {
|
||||||
span {
|
span {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
span.new {
|
|
||||||
color: $secondary-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.actor-metadata {
|
.actor-metadata {
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
|
@ -479,93 +344,3 @@ a.label-btn {
|
||||||
.emoji, .custom-emoji {
|
.emoji, .custom-emoji {
|
||||||
max-width: 25px;
|
max-width: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.indieauth-box {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 20px;
|
|
||||||
|
|
||||||
.indieauth-logo {
|
|
||||||
flex: initial;
|
|
||||||
width: 100px;
|
|
||||||
img {
|
|
||||||
max-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.indieauth-details {
|
|
||||||
flex: 1;
|
|
||||||
div {
|
|
||||||
padding-left: 20px;
|
|
||||||
a {
|
|
||||||
font-size: 1.2em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.public-interactions {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 20px;
|
|
||||||
.interactions-block {
|
|
||||||
flex: 0 1 30%;
|
|
||||||
max-width: 50%;
|
|
||||||
.facepile-wrapper {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 20px;
|
|
||||||
row-gap: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 20px;
|
|
||||||
a {
|
|
||||||
height: 50px;
|
|
||||||
img {
|
|
||||||
max-width: 50px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.and-x-more {
|
|
||||||
display: inline-block;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-title {
|
|
||||||
a {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ap-place {
|
|
||||||
h3 {
|
|
||||||
display: inline;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
h3::after {
|
|
||||||
content: ': ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
247
app/source.py
247
app/source.py
|
@ -1,218 +1,93 @@
|
||||||
import re
|
import re
|
||||||
import typing
|
|
||||||
|
|
||||||
from loguru import logger
|
from markdown import markdown
|
||||||
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.formatters import HtmlFormatter # type: ignore
|
|
||||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
|
||||||
from pygments.util import ClassNotFound # type: ignore
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import models
|
||||||
from app import webfinger
|
from app import webfinger
|
||||||
|
from app.actor import Actor
|
||||||
|
from app.actor import fetch_actor
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import CODE_HIGHLIGHTING_THEME
|
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.utils import emoji
|
from app.utils import emoji
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from app.actor import Actor
|
|
||||||
|
|
||||||
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
def _set_a_attrs(attrs, new=False):
|
||||||
|
attrs[(None, "target")] = "_blank"
|
||||||
|
attrs[(None, "class")] = "external"
|
||||||
|
attrs[(None, "rel")] = "noopener"
|
||||||
|
attrs[(None, "title")] = attrs[(None, "href")]
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
_HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
||||||
_MENTION_REGEX = re.compile(r"(@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+)")
|
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
||||||
_URL_REGEX = re.compile(
|
|
||||||
"(https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*))" # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AutoLink(SpanToken):
|
async def _hashtagify(
|
||||||
parse_inner = False
|
db_session: AsyncSession, content: str
|
||||||
precedence = 1
|
|
||||||
pattern = _URL_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class Mention(SpanToken):
|
|
||||||
parse_inner = False
|
|
||||||
precedence = 10
|
|
||||||
pattern = _MENTION_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class Hashtag(SpanToken):
|
|
||||||
parse_inner = False
|
|
||||||
precedence = 10
|
|
||||||
pattern = _HASHTAG_REGEX
|
|
||||||
|
|
||||||
def __init__(self, match_obj: re.Match) -> None:
|
|
||||||
self.target = match_obj.group()
|
|
||||||
|
|
||||||
|
|
||||||
class CustomRenderer(HTMLRenderer):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
mentioned_actors: dict[str, "Actor"] = {},
|
|
||||||
enable_mentionify: bool = True,
|
|
||||||
enable_hashtagify: bool = True,
|
|
||||||
) -> None:
|
|
||||||
extra_tokens = []
|
|
||||||
if enable_mentionify:
|
|
||||||
extra_tokens.append(Mention)
|
|
||||||
if enable_hashtagify:
|
|
||||||
extra_tokens.append(Hashtag)
|
|
||||||
super().__init__(AutoLink, *extra_tokens)
|
|
||||||
|
|
||||||
self.tags: list[dict[str, str]] = []
|
|
||||||
self.mentioned_actors = mentioned_actors
|
|
||||||
|
|
||||||
def render_auto_link(self, token: AutoLink) -> str:
|
|
||||||
template = '<a href="{target}" rel="noopener">{inner}</a>'
|
|
||||||
target = self.escape_url(token.target)
|
|
||||||
return template.format(target=target, inner=target)
|
|
||||||
|
|
||||||
def render_mention(self, token: Mention) -> str:
|
|
||||||
mention = token.target
|
|
||||||
suffix = ""
|
|
||||||
if mention.endswith("."):
|
|
||||||
mention = mention[:-1]
|
|
||||||
suffix = "."
|
|
||||||
actor = self.mentioned_actors.get(mention)
|
|
||||||
if not actor:
|
|
||||||
return mention
|
|
||||||
|
|
||||||
self.tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
|
||||||
|
|
||||||
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>{suffix}' # noqa: E501
|
|
||||||
return link
|
|
||||||
|
|
||||||
def render_hashtag(self, token: Hashtag) -> str:
|
|
||||||
tag = token.target[1:]
|
|
||||||
link = f'<a href="{BASE_URL}/t/{tag.lower()}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
|
||||||
self.tags.append(
|
|
||||||
dict(
|
|
||||||
href=f"{BASE_URL}/t/{tag.lower()}",
|
|
||||||
name=token.target.lower(),
|
|
||||||
type="Hashtag",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return link
|
|
||||||
|
|
||||||
def render_block_code(self, token: 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
|
|
||||||
return f"<pre><code{lexer_attr}>\n{code}\n</code></pre>"
|
|
||||||
|
|
||||||
|
|
||||||
async def _prefetch_mentioned_actors(
|
|
||||||
db_session: AsyncSession,
|
|
||||||
content: str,
|
|
||||||
) -> dict[str, "Actor"]:
|
|
||||||
from app import models
|
|
||||||
from app.actor import fetch_actor
|
|
||||||
|
|
||||||
actors = {}
|
|
||||||
|
|
||||||
for mention in re.findall(_MENTION_REGEX, content):
|
|
||||||
if mention in actors:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# XXX: the regex catches stuff like `@toto@example.com.`
|
|
||||||
if mention.endswith("."):
|
|
||||||
mention = mention[:-1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
_, username, domain = mention.split("@")
|
|
||||||
actor = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(models.Actor).where(
|
|
||||||
models.Actor.handle == mention,
|
|
||||||
models.Actor.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if not actor:
|
|
||||||
actor_url = await webfinger.get_actor_url(mention)
|
|
||||||
if not actor_url:
|
|
||||||
# FIXME(ts): raise an error?
|
|
||||||
continue
|
|
||||||
actor = await fetch_actor(db_session, actor_url)
|
|
||||||
|
|
||||||
actors[mention] = actor
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to prefetch {mention}")
|
|
||||||
|
|
||||||
return actors
|
|
||||||
|
|
||||||
|
|
||||||
def hashtagify(
|
|
||||||
content: str,
|
|
||||||
) -> tuple[str, list[dict[str, str]]]:
|
) -> tuple[str, list[dict[str, str]]]:
|
||||||
tags = []
|
tags = []
|
||||||
with CustomRenderer(
|
hashtags = re.findall(_HASHTAG_REGEX, content)
|
||||||
mentioned_actors={},
|
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
|
||||||
enable_mentionify=False,
|
for hashtag in hashtags:
|
||||||
enable_hashtagify=True,
|
tag = hashtag[1:]
|
||||||
) as renderer:
|
link = f'<a href="{BASE_URL}/t/{tag}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>' # noqa: E501
|
||||||
rendered_content = renderer.render(Document(content))
|
tags.append(dict(href=f"{BASE_URL}/t/{tag}", name=hashtag, type="Hashtag"))
|
||||||
tags.extend(renderer.tags)
|
content = content.replace(hashtag, link)
|
||||||
|
return content, tags
|
||||||
|
|
||||||
# Handle custom emoji
|
|
||||||
tags.extend(emoji.tags(content))
|
|
||||||
|
|
||||||
return rendered_content, tags
|
async def _mentionify(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
content: str,
|
||||||
|
) -> tuple[str, list[dict[str, str]], list[Actor]]:
|
||||||
|
tags = []
|
||||||
|
mentioned_actors = []
|
||||||
|
for mention in re.findall(_MENTION_REGEX, content):
|
||||||
|
_, username, domain = mention.split("@")
|
||||||
|
actor = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(models.Actor).where(models.Actor.handle == mention)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not actor:
|
||||||
|
actor_url = await webfinger.get_actor_url(mention)
|
||||||
|
if not actor_url:
|
||||||
|
# FIXME(ts): raise an error?
|
||||||
|
continue
|
||||||
|
actor = await fetch_actor(db_session, actor_url)
|
||||||
|
|
||||||
|
mentioned_actors.append(actor)
|
||||||
|
tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
|
||||||
|
|
||||||
|
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
|
||||||
|
content = content.replace(mention, link)
|
||||||
|
return content, tags, mentioned_actors
|
||||||
|
|
||||||
|
|
||||||
async def markdownify(
|
async def markdownify(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
content: str,
|
content: str,
|
||||||
enable_mentionify: bool = True,
|
mentionify: bool = True,
|
||||||
enable_hashtagify: bool = True,
|
hashtagify: bool = True,
|
||||||
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
|
) -> tuple[str, list[dict[str, str]], list[Actor]]:
|
||||||
"""
|
"""
|
||||||
>>> content, tags = markdownify("Hello")
|
>>> content, tags = markdownify("Hello")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tags = []
|
tags = []
|
||||||
mentioned_actors: dict[str, "Actor"] = {}
|
mentioned_actors: list[Actor] = []
|
||||||
if enable_mentionify:
|
if hashtagify:
|
||||||
mentioned_actors = await _prefetch_mentioned_actors(db_session, content)
|
content, hashtag_tags = await _hashtagify(db_session, content)
|
||||||
|
tags.extend(hashtag_tags)
|
||||||
with CustomRenderer(
|
if mentionify:
|
||||||
mentioned_actors=mentioned_actors,
|
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
|
||||||
enable_mentionify=enable_mentionify,
|
tags.extend(mention_tags)
|
||||||
enable_hashtagify=enable_hashtagify,
|
|
||||||
) as renderer:
|
|
||||||
rendered_content = renderer.render(Document(content))
|
|
||||||
tags.extend(renderer.tags)
|
|
||||||
|
|
||||||
# Handle custom emoji
|
# Handle custom emoji
|
||||||
tags.extend(emoji.tags(content))
|
tags.extend(emoji.tags(content))
|
||||||
|
|
||||||
return rendered_content, dedup_tags(tags), list(mentioned_actors.values())
|
content = markdown(content, extensions=["mdx_linkify", "fenced_code"])
|
||||||
|
|
||||||
|
return content, tags, mentioned_actors
|
||||||
def dedup_tags(tags: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
||||||
idx = set()
|
|
||||||
deduped_tags = []
|
|
||||||
for tag in tags:
|
|
||||||
tag_idx = (tag["type"], tag["name"])
|
|
||||||
if tag_idx in idx:
|
|
||||||
continue
|
|
||||||
|
|
||||||
idx.add(tag_idx)
|
|
||||||
deduped_tags.append(tag)
|
|
||||||
|
|
||||||
return deduped_tags
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
document.addEventListener('DOMContentLoaded', (ev) => {
|
|
||||||
// Add confirm to "delete" button next to outbox objects
|
|
||||||
var forms = document.getElementsByClassName("object-delete-form")
|
|
||||||
for (var i = 0; i < forms.length; i++) {
|
|
||||||
forms[i].addEventListener('submit', (ev) => {
|
|
||||||
if (!confirm('Do you really want to delete this object?')) {
|
|
||||||
ev.preventDefault();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,32 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
@ -25,9 +26,8 @@ from app.actor import LOCAL_ACTOR
|
||||||
from app.ap_object import Attachment
|
from app.ap_object import Attachment
|
||||||
from app.ap_object import Object
|
from app.ap_object import Object
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import CUSTOM_FOOTER
|
from app.config import CSS_HASH
|
||||||
from app.config import DEBUG
|
from app.config import DEBUG
|
||||||
from app.config import SESSION_TIMEOUT
|
|
||||||
from app.config import VERSION
|
from app.config import VERSION
|
||||||
from app.config import generate_csrf_token
|
from app.config import generate_csrf_token
|
||||||
from app.config import session_serializer
|
from app.config import session_serializer
|
||||||
|
@ -39,7 +39,7 @@ from app.utils.highlight import HIGHLIGHT_CSS
|
||||||
from app.utils.highlight import highlight
|
from app.utils.highlight import highlight
|
||||||
|
|
||||||
_templates = Jinja2Templates(
|
_templates = Jinja2Templates(
|
||||||
directory=["data/templates", "app/templates"], # type: ignore # bad typing
|
directory="app/templates",
|
||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
lstrip_blocks=True,
|
lstrip_blocks=True,
|
||||||
)
|
)
|
||||||
|
@ -59,8 +59,13 @@ def _filter_domain(text: str) -> str:
|
||||||
|
|
||||||
def _media_proxy_url(url: str | None) -> str:
|
def _media_proxy_url(url: str | None) -> str:
|
||||||
if not url:
|
if not url:
|
||||||
return BASE_URL + "/static/nopic.png"
|
return "/static/nopic.png"
|
||||||
return proxied_media_url(url)
|
|
||||||
|
if url.startswith(BASE_URL):
|
||||||
|
return url
|
||||||
|
|
||||||
|
encoded_url = base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
|
return f"/proxy/media/{encoded_url}"
|
||||||
|
|
||||||
|
|
||||||
def is_current_user_admin(request: Request) -> bool:
|
def is_current_user_admin(request: Request) -> bool:
|
||||||
|
@ -70,10 +75,10 @@ def is_current_user_admin(request: Request) -> bool:
|
||||||
try:
|
try:
|
||||||
loaded_session = session_serializer.loads(
|
loaded_session = session_serializer.loads(
|
||||||
session_cookie,
|
session_cookie,
|
||||||
max_age=SESSION_TIMEOUT,
|
max_age=3600 * 12,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to validate session timeout")
|
pass
|
||||||
else:
|
else:
|
||||||
is_admin = loaded_session.get("is_logged_in")
|
is_admin = loaded_session.get("is_logged_in")
|
||||||
|
|
||||||
|
@ -85,8 +90,6 @@ async def render_template(
|
||||||
request: Request,
|
request: Request,
|
||||||
template: str,
|
template: str,
|
||||||
template_args: dict[str, Any] | None = None,
|
template_args: dict[str, Any] | None = None,
|
||||||
status_code: int = 200,
|
|
||||||
headers: dict[str, str] | None = None,
|
|
||||||
) -> TemplateResponse:
|
) -> TemplateResponse:
|
||||||
if template_args is None:
|
if template_args is None:
|
||||||
template_args = {}
|
template_args = {}
|
||||||
|
@ -100,6 +103,7 @@ async def render_template(
|
||||||
"request": request,
|
"request": request,
|
||||||
"debug": DEBUG,
|
"debug": DEBUG,
|
||||||
"microblogpub_version": VERSION,
|
"microblogpub_version": VERSION,
|
||||||
|
"css_hash": CSS_HASH,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"csrf_token": generate_csrf_token(),
|
"csrf_token": generate_csrf_token(),
|
||||||
"highlight_css": HIGHLIGHT_CSS,
|
"highlight_css": HIGHLIGHT_CSS,
|
||||||
|
@ -127,11 +131,8 @@ async def render_template(
|
||||||
select(func.count(models.Following.id))
|
select(func.count(models.Following.id))
|
||||||
),
|
),
|
||||||
"actor_types": ap.ACTOR_TYPES,
|
"actor_types": ap.ACTOR_TYPES,
|
||||||
"custom_footer": CUSTOM_FOOTER,
|
|
||||||
**template_args,
|
**template_args,
|
||||||
},
|
},
|
||||||
status_code=status_code,
|
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -288,10 +289,6 @@ ALLOWED_ATTRIBUTES: dict[str, list[str] | Callable[[str, str, str], bool]] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _allow_all_attributes(tag: Any, name: Any, value: Any) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=256)
|
@lru_cache(maxsize=256)
|
||||||
def _update_inline_imgs(content):
|
def _update_inline_imgs(content):
|
||||||
soup = BeautifulSoup(content, "html5lib")
|
soup = BeautifulSoup(content, "html5lib")
|
||||||
|
@ -321,11 +318,7 @@ def _clean_html(html: str, note: Object) -> str:
|
||||||
_update_inline_imgs(highlight(html))
|
_update_inline_imgs(highlight(html))
|
||||||
),
|
),
|
||||||
tags=ALLOWED_TAGS,
|
tags=ALLOWED_TAGS,
|
||||||
attributes=(
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
_allow_all_attributes
|
|
||||||
if note.ap_id.startswith(config.ID)
|
|
||||||
else ALLOWED_ATTRIBUTES
|
|
||||||
),
|
|
||||||
strip=True,
|
strip=True,
|
||||||
),
|
),
|
||||||
note,
|
note,
|
||||||
|
@ -336,14 +329,6 @@ def _clean_html(html: str, note: Object) -> str:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _clean_html_wm(html: str) -> str:
|
|
||||||
return bleach.clean(
|
|
||||||
html,
|
|
||||||
attributes=ALLOWED_ATTRIBUTES,
|
|
||||||
strip=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _timeago(original_dt: datetime) -> str:
|
def _timeago(original_dt: datetime) -> str:
|
||||||
dt = original_dt
|
dt = original_dt
|
||||||
if dt.tzinfo:
|
if dt.tzinfo:
|
||||||
|
@ -392,8 +377,8 @@ def _html2text(content: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _replace_emoji(u: str, _) -> str:
|
def _replace_emoji(u: str, _) -> str:
|
||||||
filename = "-".join(hex(ord(c))[2:] for c in u)
|
filename = hex(ord(u))[2:]
|
||||||
return config.EMOJI_TPL.format(base_url=BASE_URL, filename=filename, raw=u)
|
return config.EMOJI_TPL.format(filename=filename, raw=u)
|
||||||
|
|
||||||
|
|
||||||
def _emojify(text: str, is_local: bool) -> str:
|
def _emojify(text: str, is_local: bool) -> str:
|
||||||
|
@ -420,7 +405,6 @@ def _poll_item_pct(item: ap.RawObject, voters_count: int) -> int:
|
||||||
_templates.env.filters["domain"] = _filter_domain
|
_templates.env.filters["domain"] = _filter_domain
|
||||||
_templates.env.filters["media_proxy_url"] = _media_proxy_url
|
_templates.env.filters["media_proxy_url"] = _media_proxy_url
|
||||||
_templates.env.filters["clean_html"] = _clean_html
|
_templates.env.filters["clean_html"] = _clean_html
|
||||||
_templates.env.filters["clean_html_wm"] = _clean_html_wm
|
|
||||||
_templates.env.filters["timeago"] = _timeago
|
_templates.env.filters["timeago"] = _timeago
|
||||||
_templates.env.filters["format_date"] = _format_date
|
_templates.env.filters["format_date"] = _format_date
|
||||||
_templates.env.filters["has_media_type"] = _has_media_type
|
_templates.env.filters["has_media_type"] = _has_media_type
|
||||||
|
@ -430,10 +414,3 @@ _templates.env.filters["pluralize"] = _pluralize
|
||||||
_templates.env.filters["parse_datetime"] = _parse_datetime
|
_templates.env.filters["parse_datetime"] = _parse_datetime
|
||||||
_templates.env.filters["poll_item_pct"] = _poll_item_pct
|
_templates.env.filters["poll_item_pct"] = _poll_item_pct
|
||||||
_templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url
|
_templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url
|
||||||
_templates.env.globals["JS_HASH"] = config.JS_HASH
|
|
||||||
_templates.env.globals["CSS_HASH"] = config.CSS_HASH
|
|
||||||
_templates.env.globals["BASE_URL"] = config.BASE_URL
|
|
||||||
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
|
|
||||||
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING
|
|
||||||
_templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems
|
|
||||||
_templates.env.globals["ICON_URL"] = config.CONFIG.icon_url
|
|
||||||
|
|
|
@ -10,9 +10,7 @@
|
||||||
{% for anybox_object, convo, actors in threads %}
|
{% for anybox_object, convo, actors in threads %}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
With {% for actor in actors %}
|
With {% for actor in actors %}
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ actor.ap_id }}">
|
<a href="">{{ actor.handle }}</a>
|
||||||
{{ actor.handle }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(anybox_object) }}
|
{{ utils.display_object(anybox_object) }}
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
{% for inbox_object in inbox %}
|
{% for inbox_object in inbox %}
|
||||||
{% if inbox_object.ap_type == "Announce" %}
|
{% if inbox_object.ap_type == "Announce" %}
|
||||||
{{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
|
{{ utils.actor_action(inbox_object, "shared") }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
|
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
|
||||||
{{ utils.display_object(inbox_object) }}
|
{{ utils.display_object(inbox_object) }}
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
{{ utils.actor_action(inbox_object, "followed you") }}
|
{{ utils.actor_action(inbox_object, "followed you") }}
|
||||||
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
|
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
|
||||||
{% elif inbox_object.ap_type == "Like" %}
|
{% elif inbox_object.ap_type == "Like" %}
|
||||||
{{ utils.actor_action(inbox_object, "liked one of your posts", with_icon=True) }}
|
{{ utils.actor_action(inbox_object, "liked one of your post") }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
<form class="form admin-new" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
||||||
{{ utils.embed_csrf_token() }}
|
{{ utils.embed_csrf_token() }}
|
||||||
{{ utils.embed_redirect_url() }}
|
{{ utils.embed_redirect_url() }}
|
||||||
<p>
|
<p>
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
|
|
||||||
{% if request.query_params.type == "Article" %}
|
{% if request.query_params.type == "Article" %}
|
||||||
<p>
|
<p>
|
||||||
<input type="text" class="width-95" name="name" placeholder="Title">
|
<input type="text" style="width:95%" name="name" placeholder="Title">
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@
|
||||||
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
|
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!">{{ content }}</textarea>
|
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
|
||||||
|
|
||||||
{% if request.query_params.type == "Question" %}
|
{% if request.query_params.type == "Question" %}
|
||||||
<p>
|
<p>
|
||||||
|
@ -69,20 +69,20 @@
|
||||||
</p>
|
</p>
|
||||||
{% for i in ["1", "2", "3", "4"] %}
|
{% for i in ["1", "2", "3", "4"] %}
|
||||||
<p>
|
<p>
|
||||||
<input type="text" name="poll_answer_{{ i }}" class="width-95" placeholder="Option {{ i }}, leave empty to disable">
|
<input type="text" name="poll_answer_{{ i }}" style="width:95%;" placeholder="Option {{ i }}, leave empty to disable">
|
||||||
</p>
|
</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)"{% if content_warning %} value="{{ content_warning }}"{% endif %} class="width-95">
|
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)"{% if content_warning %} value="{{ content_warning }}"{% endif %} style="width:95%;">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
|
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
|
||||||
</p>
|
</p>
|
||||||
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
|
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
|
||||||
<p>
|
<p>
|
||||||
<input id="files" name="files" type="file" class="width-95" multiple>
|
<input id="files" name="files" type="file" multiple style="width:95%;">
|
||||||
</p>
|
</p>
|
||||||
<div id="alts"></div>
|
<div id="alts"></div>
|
||||||
<p>
|
<p>
|
||||||
|
@ -90,5 +90,5 @@
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ BASE_URL }}/static/new.js?v={{ JS_HASH }}"></script>
|
<script src="/static/new.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -12,16 +12,18 @@
|
||||||
{% for outbox_object in outbox %}
|
{% for outbox_object in outbox %}
|
||||||
|
|
||||||
{% if outbox_object.ap_type == "Announce" %}
|
{% if outbox_object.ap_type == "Announce" %}
|
||||||
<div class="actor-action">You shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You shared</div>
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
{% elif outbox_object.ap_type == "Like" %}
|
{% elif outbox_object.ap_type == "Like" %}
|
||||||
<div class="actor-action">You liked <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You liked</div>
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
{% elif outbox_object.ap_type == "Follow" %}
|
{% elif outbox_object.ap_type == "Follow" %}
|
||||||
<div class="actor-action">You followed <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
<div class="actor-action">You followed</div>
|
||||||
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
{{ utils.display_actor(outbox_object.relates_to_actor, actors_metadata) }}
|
||||||
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
{% elif outbox_object.ap_type in ["Article", "Note", "Video", "Question"] %}
|
||||||
{{ utils.display_object(outbox_object) }}
|
{{ utils.display_object(outbox_object) }}
|
||||||
|
{% else %}
|
||||||
|
Implement {{ outbox_object.ap_type }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -9,21 +9,10 @@
|
||||||
{{ utils.display_actor(actor, actors_metadata, with_details=True) }}
|
{{ utils.display_actor(actor, actors_metadata, with_details=True) }}
|
||||||
{% for inbox_object in inbox_objects %}
|
{% for inbox_object in inbox_objects %}
|
||||||
{% if inbox_object.ap_type == "Announce" %}
|
{% if inbox_object.ap_type == "Announce" %}
|
||||||
{{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
|
{{ utils.actor_action(inbox_object, "shared") }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ utils.display_object(inbox_object) }}
|
{{ utils.display_object(inbox_object) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if next_cursor %}
|
|
||||||
<div class="box">
|
|
||||||
<p>
|
|
||||||
<a href="{{ request.url._path }}?actor_id={{ request.query_params.actor_id }}&cursor={{ next_cursor }}">
|
|
||||||
See more
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -11,8 +11,8 @@
|
||||||
<ul class="h-feed" id="articles">
|
<ul class="h-feed" id="articles">
|
||||||
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
|
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
|
||||||
{% for outbox_object in objects %}
|
{% for outbox_object in objects %}
|
||||||
<li class="h-entry">
|
<li>
|
||||||
<time class="muted dt-published" datetime="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</time> <a href="{{ outbox_object.url }}" class="u-url u-uid p-name">{{ outbox_object.name }}</a>
|
<span class="muted" style="padding-right:10px;">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</span> <a href="{{ outbox_object.url }}">{{ outbox_object.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
{%- import "utils.html" as utils with context -%}
|
|
||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ title }}</title>
|
|
||||||
{% if request.url.path == "/" %}
|
|
||||||
<link rel="indieauth-metadata" href="{{ url_for("well_known_authorization_server") }}">
|
|
||||||
<link rel="authorization_endpoint" href="{{ url_for("indieauth_authorization_endpoint") }}">
|
|
||||||
<link rel="token_endpoint" href="{{ url_for("indieauth_token_endpoint") }}">
|
|
||||||
<link rel="micropub" href="{{ url_for("micropub_endpoint") }}">
|
|
||||||
<link rel="alternate" href="{{ local_actor.url }}" title="ActivityPub profile" type="application/activity+json">
|
|
||||||
<meta content="profile" property="og:type" />
|
|
||||||
<meta content="{{ local_actor.url }}" property="og:url" />
|
|
||||||
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
|
||||||
<meta content="Homepage" property="og:title" />
|
|
||||||
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
|
||||||
<meta content="{{ ICON_URL }}" property="og:image" />
|
|
||||||
<meta content="summary" property="twitter:card" />
|
|
||||||
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% include "header.html" %}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
{{ page_content | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
|
@ -1,12 +0,0 @@
|
||||||
{%- import "utils.html" as utils with context -%}
|
|
||||||
{% extends "layout.html" %}
|
|
||||||
{% block main_tag %} class="main-flex"{% endblock %}
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ title }}</title>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="centered primary-color box">
|
|
||||||
<h1 class="error-title">{{ title | safe }}</h1>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>{{ local_actor.display_name }}'s followers</title>
|
<title>{{ local_actor.display_name }}'s followers</title>
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>{{ local_actor.display_name }}'s follows</title>
|
<title>{{ local_actor.display_name }}'s follows</title>
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
@ -25,35 +25,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{%- macro header_link(url, text) -%}
|
{%- macro header_link(url, text) -%}
|
||||||
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
|
{% set url_for = request.app.router.url_path_for(url) %}
|
||||||
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{%- macro navbar_item_link(navbar_item) -%}
|
<div style="margin:30px 0 0 0;">
|
||||||
{% set url_for = BASE_URL + navbar_item[0] %}
|
|
||||||
<a href="{{ navbar_item[0] }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ navbar_item[1] }}</a>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
<div class="public-top-menu">
|
|
||||||
<nav class="flexbox">
|
<nav class="flexbox">
|
||||||
<ul>
|
<ul>
|
||||||
{% if NAVBAR_ITEMS.INDEX_NAVBAR_ITEM %}
|
|
||||||
<li>{{ navbar_item_link(NAVBAR_ITEMS.INDEX_NAVBAR_ITEM) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>{{ header_link("index", "Notes") }}</li>
|
<li>{{ header_link("index", "Notes") }}</li>
|
||||||
{% if articles_count %}
|
{% if articles_count %}
|
||||||
<li>{{ header_link("articles", "Articles") }}</li>
|
<li>{{ header_link("articles", "Articles") }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not HIDES_FOLLOWERS or is_admin %}
|
|
||||||
<li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li>
|
<li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li>
|
||||||
{% endif %}
|
|
||||||
{% if not HIDES_FOLLOWING or is_admin %}
|
|
||||||
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
|
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
|
||||||
{% endif %}
|
|
||||||
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
|
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
|
||||||
{% for navbar_item in NAVBAR_ITEMS.EXTRA_NAVBAR_ITEMS %}
|
|
||||||
{{ navbar_item_link(navbar_item) }}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
||||||
<meta content="Homepage" property="og:title" />
|
<meta content="Homepage" property="og:title" />
|
||||||
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
|
||||||
<meta content="{{ ICON_URL }}" property="og:image" />
|
<meta content="{{ local_actor.url }}" property="og:image" />
|
||||||
<meta content="summary" property="twitter:card" />
|
<meta content="summary" property="twitter:card" />
|
||||||
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
<meta content="{{ local_actor.handle }}" property="profile:username" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -21,40 +21,26 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "header.html" %}
|
{% include "header.html" %}
|
||||||
|
|
||||||
{% if objects %}
|
<div class="h-feed">
|
||||||
|
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
|
||||||
<div class="h-feed">
|
{% for outbox_object in objects %}
|
||||||
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
|
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
|
||||||
{% for outbox_object in objects %}
|
{{ utils.display_object(outbox_object) }}
|
||||||
{% if outbox_object.ap_type in ["Note", "Video", "Question"] %}
|
{% elif outbox_object.ap_type == "Announce" %}
|
||||||
{{ utils.display_object(outbox_object) }}
|
<div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div>
|
||||||
{% elif outbox_object.ap_type == "Announce" %}
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
<div class="h-entry" id="{{ outbox_object.permalink_id }}">
|
|
||||||
<div class="shared-header"><strong><a class="p-author h-card" href="{{ local_actor.url }}">{{ utils.display_tiny_actor_icon(local_actor) }} {{ local_actor.display_name | clean_html(local_actor) | safe }}</a></strong> shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
|
||||||
<div class="h-cite u-repost-of">
|
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object, is_h_entry=False) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if has_previous_page or has_next_page %}
|
|
||||||
<div class="box">
|
|
||||||
{% if has_previous_page %}
|
|
||||||
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if has_next_page %}
|
|
||||||
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>Nothing to see here yet!</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
{% if has_previous_page %}
|
||||||
|
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_next_page %}
|
||||||
|
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -2,20 +2,16 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class"indieauth-box">
|
<div style="display:flex;column-gap: 20px;">
|
||||||
{% if client.logo %}
|
{% if client.logo %}
|
||||||
<div class="indieauth-logo">
|
<div style="flex:initial;width:100px;">
|
||||||
<img src="{{client.logo | media_proxy_url }}" alt="{{ client.name }} logo">
|
<img src="{{client.logo | media_proxy_url }}" style="max-width:100px;" alt="{{ client.name }} logo">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="indieauth-details">
|
<div style="flex:1;">
|
||||||
<div>
|
<div style="padding-left: 20px;">
|
||||||
{% if client.url %}
|
<a class="lcolor" style="font-size:1.2em;font-weight:600;" href="{{ client.url }}">{{ client.name }}</a>
|
||||||
<a class="scolor" href="{{ client.url }}">{{ client.name }}</a>
|
<p>wants you to login as <strong class="lcolor">{{ me }}</strong> with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>
|
||||||
{% else %}
|
|
||||||
<span class="scolor">{{ client.name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
<p>wants you to login{% if me %} as <strong class="lcolor">{{ me }}</strong>{% endif %} with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>
|
|
||||||
|
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('indieauth_flow') }}" class="form">
|
<form method="POST" action="{{ url_for('indieauth_flow') }}" class="form">
|
||||||
|
|
|
@ -4,24 +4,26 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<link rel="stylesheet" href="{{ BASE_URL }}/static/css/main.css?v={{ CSS_HASH }}">
|
<link rel="stylesheet" href="/static/css/main.css?v={{ css_hash }}">
|
||||||
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
|
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
|
||||||
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
|
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
|
||||||
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
|
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
|
||||||
<link rel="icon" type="image/x-icon" href="{{ BASE_URL }}/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
<style>{{ highlight_css }}</style>
|
<style>
|
||||||
|
{{ highlight_css }}
|
||||||
|
</style>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<main{%- block main_tag %}{%- endblock %}>
|
<main>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<div id="admin">
|
<div id="admin">
|
||||||
{% macro admin_link(url, text) %}
|
{% macro admin_link(url, text) %}
|
||||||
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %}
|
{% set url_for = request.app.router.url_path_for(url) %}
|
||||||
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
<div class="admin-menu">
|
<div style="margin-bottom:30px;padding: 0 20px;">
|
||||||
<nav class="flexbox">
|
<nav class="flexbox">
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ admin_link("index", "Public") }}</li>
|
<li>{{ admin_link("index", "Public") }}</li>
|
||||||
|
@ -45,16 +47,8 @@
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{% if custom_footer %}
|
Powered by <a href="https://docs.microblog.pub">microblog.pub</a> <small class="microblogpub-version"><code>{{ microblogpub_version }}</code></small> and the <a href="https://activitypub.rocks/">ActivityPub</a> protocol. <a href="{{ url_for("login") }}">Admin</a>.
|
||||||
{{ custom_footer | safe }}
|
|
||||||
{% else %}
|
|
||||||
Powered by <a href="https://docs.microblog.pub">microblog.pub</a> <small class="microblogpub-version"><code>{{ microblogpub_version }}</code></small> and the <a href="https://activitypub.rocks/">ActivityPub</a> protocol. <a href="{{ url_for("login") }}">Admin</a>.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{% if is_admin %}
|
|
||||||
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script>
|
|
||||||
{% endif %}
|
|
||||||
<script src="{{ BASE_URL }}/static/common.js?v={{ JS_HASH }}"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,21 +1,14 @@
|
||||||
{%- import "utils.html" as utils with context -%}
|
{%- import "utils.html" as utils with context -%}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block head %}
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
{% endblock %}
|
|
||||||
{% block main_tag %} class="main-flex"{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="centered">
|
<div style="display:grid;height:80%;">
|
||||||
<div>
|
<div style="margin:auto;">
|
||||||
{% if error %}
|
<form class="form" action="/admin/login" method="POST">
|
||||||
<p class="primary-color">Invalid password.</p>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
{% endif %}
|
<input type="hidden" name="redirect" value="{{ redirect }}">
|
||||||
<form class="form" action="{{ BASE_URL }}/admin/login" method="POST">
|
<input type="password" placeholder="password" name="password" autofocus>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="submit" value="login">
|
||||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
</form>
|
||||||
<input type="password" placeholder="password" name="password" autofocus>
|
</div>
|
||||||
<input type="submit" value="login">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -19,9 +19,7 @@
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="box error-box">
|
<div class="box error-box">
|
||||||
{% if error.value == "NOT_FOUND" %}
|
{% if error.value == "NOT_FOUND" %}
|
||||||
<p>The remote object is unavailable.</p>
|
<p>The remote object was deleted.</p>
|
||||||
{% elif error.value == "UNAUTHORIZED" %}
|
|
||||||
<p>Missing permissions to fetch the remote object.</p>
|
|
||||||
{% elif error.value == "TIMEOUT" %}
|
{% elif error.value == "TIMEOUT" %}
|
||||||
<p>Lookup timed out, please try refreshing the page.</p>
|
<p>Lookup timed out, please try refreshing the page.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -5,14 +5,10 @@
|
||||||
<title>{{ local_actor.display_name }} - Notifications</title>
|
<title>{{ local_actor.display_name }} - Notifications</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% macro notif_actor_action(notif, text, with_icon=False) %}
|
{% macro notif_actor_action(notif, text) %}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">{{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
|
||||||
{% if with_icon %}{{ utils.display_tiny_actor_icon(notif.actor) }}{% endif %} {{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
|
|
||||||
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
|
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
|
||||||
{% if notif.is_new %}
|
|
||||||
<span class="new">new</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
@ -39,37 +35,17 @@
|
||||||
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
||||||
{{ notif_actor_action(notif, "rejected your follow request") }}
|
{{ notif_actor_action(notif, "rejected your follow request") }}
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||||
{% elif notif.notification_type.value == "blocked" %}
|
|
||||||
{{ notif_actor_action(notif, "blocked you") }}
|
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
|
||||||
{% elif notif.notification_type.value == "unblocked" %}
|
|
||||||
{{ notif_actor_action(notif, "unblocked you") }}
|
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
|
||||||
{% elif notif.notification_type.value == "block" %}
|
|
||||||
{{ notif_actor_action(notif, "was blocked") }}
|
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
|
||||||
{% elif notif.notification_type.value == "unblock" %}
|
|
||||||
{{ notif_actor_action(notif, "was unblocked") }}
|
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
|
||||||
{%- elif notif.notification_type.value == "move" and notif.inbox_object %}
|
|
||||||
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
|
||||||
<div class="actor-action">
|
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.inbox_object.actor.ap_id }}">
|
|
||||||
{{ utils.display_tiny_actor_icon(notif.inbox_object.actor) }} {{ notif.inbox_object.actor.display_name | clean_html(notif.inbox_object.actor) | safe }}</a> has moved to
|
|
||||||
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
|
|
||||||
</div>
|
|
||||||
{{ utils.display_actor(notif.actor) }}
|
|
||||||
{% elif notif.notification_type.value == "like" %}
|
{% elif notif.notification_type.value == "like" %}
|
||||||
{{ notif_actor_action(notif, "liked a post", with_icon=True) }}
|
{{ notif_actor_action(notif, "liked a post") }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "undo_like" %}
|
{% elif notif.notification_type.value == "undo_like" %}
|
||||||
{{ notif_actor_action(notif, "unliked a post", with_icon=True) }}
|
{{ notif_actor_action(notif, "unliked a post") }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "announce" %}
|
{% elif notif.notification_type.value == "announce" %}
|
||||||
{{ notif_actor_action(notif, "shared a post", with_icon=True) }}
|
{{ notif_actor_action(notif, "shared a post") }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "undo_announce" %}
|
{% elif notif.notification_type.value == "undo_announce" %}
|
||||||
{{ notif_actor_action(notif, "unshared a post", with_icon=True) }}
|
{{ notif_actor_action(notif, "unshared a post") }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "mention" %}
|
{% elif notif.notification_type.value == "mention" %}
|
||||||
{{ notif_actor_action(notif, "mentioned you") }}
|
{{ notif_actor_action(notif, "mentioned you") }}
|
||||||
|
@ -81,7 +57,7 @@
|
||||||
{% if facepile_item %}
|
{% if facepile_item %}
|
||||||
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "updated_webmention" %}
|
{% elif notif.notification_type.value == "updated_webmention" %}
|
||||||
|
@ -91,7 +67,7 @@
|
||||||
{% if facepile_item %}
|
{% if facepile_item %}
|
||||||
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "deleted_webmention" %}
|
{% elif notif.notification_type.value == "deleted_webmention" %}
|
||||||
|
@ -101,7 +77,7 @@
|
||||||
{% if facepile_item %}
|
{% if facepile_item %}
|
||||||
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -112,15 +88,4 @@
|
||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if next_cursor %}
|
|
||||||
<div class="box">
|
|
||||||
<p>
|
|
||||||
<a href="{{ request.url._path }}?cursor={{ next_cursor }}">
|
|
||||||
See more{% if more_unread_count %} ({{ more_unread_count }} unread left){% endif %}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -3,11 +3,7 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% if outbox_object %}
|
{% if outbox_object %}
|
||||||
{% if outbox_object.content %}
|
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %}
|
||||||
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %}
|
|
||||||
{% else %}
|
|
||||||
{% set excerpt = outbox_object.summary | html2text | trim | truncate(50) %}
|
|
||||||
{% endif %}
|
|
||||||
<title>{% if outbox_object.name %}{{ outbox_object.name }}{% else %}{{ local_actor.display_name }}: "{{ excerpt }}"{% endif %}</title>
|
<title>{% if outbox_object.name %}{{ outbox_object.name }}{% else %}{{ local_actor.display_name }}: "{{ excerpt }}"{% endif %}</title>
|
||||||
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
|
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
|
||||||
<link rel="alternate" href="{{ request.url }}" type="application/activity+json">
|
<link rel="alternate" href="{{ request.url }}" type="application/activity+json">
|
||||||
|
@ -15,7 +11,7 @@
|
||||||
<meta content="article" property="og:type" />
|
<meta content="article" property="og:type" />
|
||||||
<meta content="{{ outbox_object.url }}" property="og:url" />
|
<meta content="{{ outbox_object.url }}" property="og:url" />
|
||||||
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
|
||||||
<meta content="{% if outbox_object.name %}{{ outbox_object.name }}{% else %}Note{% endif %}" property="og:title" />
|
<meta content="{% if outbox_object.name %}{{ name }}{% else %}Note{% endif %}" property="og:title" />
|
||||||
<meta content="{{ excerpt }}" property="og:description" />
|
<meta content="{{ excerpt }}" property="og:description" />
|
||||||
<meta content="{{ local_actor.icon_url }}" property="og:image" />
|
<meta content="{{ local_actor.icon_url }}" property="og:image" />
|
||||||
<meta content="summary" property="twitter:card" />
|
<meta content="summary" property="twitter:card" />
|
||||||
|
@ -31,16 +27,9 @@
|
||||||
{% macro display_replies_tree(replies_tree_node) %}
|
{% macro display_replies_tree(replies_tree_node) %}
|
||||||
|
|
||||||
{% if replies_tree_node.is_requested %}
|
{% if replies_tree_node.is_requested %}
|
||||||
{{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=webmentions, expanded=not replies_tree_node.is_root, is_object_page=True, is_h_entry=False) }}
|
{{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=webmentions, expanded=not replies_tree_node.is_root, is_object_page=True) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if replies_tree_node.wm_reply %}
|
{{ utils.display_object(replies_tree_node.ap_object) }}
|
||||||
{# u-comment h-cite is displayed by default for webmention #}
|
|
||||||
{{ utils.display_webmention_reply(replies_tree_node.wm_reply) }}
|
|
||||||
{% else %}
|
|
||||||
<div class="u-comment h-cite">
|
|
||||||
{{ utils.display_object(replies_tree_node.ap_object, is_h_entry=False) }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for child in replies_tree_node.children %}
|
{% for child in replies_tree_node.children %}
|
||||||
|
@ -49,8 +38,6 @@
|
||||||
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="h-entry">
|
|
||||||
{{ display_replies_tree(replies_tree) }}
|
{{ display_replies_tree(replies_tree) }}
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
{%- import "utils.html" as utils with context -%}
|
|
||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ local_actor.display_name }}'s microblog - Redirect</title>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% include "header.html" %}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<p>You are being redirected to: <a href="{{ url }}">{{ url }}</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
|
@ -1,15 +0,0 @@
|
||||||
{%- import "utils.html" as utils with context -%}
|
|
||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ local_actor.display_name }}'s microblog - Redirect</title>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% include "header.html" %}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<p>You are being redirected to your instance: <a href="{{ url }}">{{ url }}</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Remote follow {{ local_actor.display_name }}</title>
|
<title>Remote follow {{ local_actor.display_name }}</title>
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
{%- import "utils.html" as utils with context -%}
|
|
||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>Interact from your instance</title>
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% include "header.html" %}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<h2>Interact with this object</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ utils.display_object(outbox_object) }}
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<form class="form" action="{{ url_for("post_remote_interaction") }}" method="POST">
|
|
||||||
{{ utils.embed_csrf_token() }}
|
|
||||||
<input type="text" name="profile" placeholder="you@instance.tld" autofocus>
|
|
||||||
<input type="hidden" name="ap_id" value="{{ outbox_object.ap_id }}">
|
|
||||||
<input type="submit" value="interact from your instance">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
|
@ -1,254 +1,167 @@
|
||||||
{% macro embed_csrf_token() %}
|
{% macro embed_csrf_token() %}
|
||||||
{% block embed_csrf_token scoped %}
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro embed_redirect_url(permalink_id=None) %}
|
{% macro embed_redirect_url(permalink_id=None) %}
|
||||||
{% block embed_redirect_url scoped %}
|
|
||||||
<input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}">
|
<input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}">
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_block_button(actor) %}
|
{% macro admin_block_button(actor) %}
|
||||||
{% block admin_block_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_block") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_block") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||||
<input type="submit" value="block">
|
<input type="submit" value="block">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_unblock_button(actor) %}
|
{% macro admin_unblock_button(actor) %}
|
||||||
{% block admin_unblock_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||||
<input type="submit" value="unblock">
|
<input type="submit" value="unblock">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_hide_shares_button(actor) %}
|
|
||||||
{% block admin_hide_shares_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_hide_announces") }}" method="POST">
|
|
||||||
{{ embed_csrf_token() }}
|
|
||||||
{{ embed_redirect_url() }}
|
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
|
||||||
<input type="submit" value="hide shares">
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro admin_show_shares_button(actor) %}
|
|
||||||
{% block admin_show_shares_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_show_announces") }}" method="POST">
|
|
||||||
{{ embed_csrf_token() }}
|
|
||||||
{{ embed_redirect_url() }}
|
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
|
||||||
<input type="submit" value="show shares">
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
|
|
||||||
{% macro admin_follow_button(actor) %}
|
{% macro admin_follow_button(actor) %}
|
||||||
{% block admin_follow_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
|
||||||
<input type="submit" value="follow">
|
<input type="submit" value="follow">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_accept_incoming_follow_button(notif) %}
|
{% macro admin_accept_incoming_follow_button(notif) %}
|
||||||
{% block admin_accept_incoming_follow_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
||||||
<input type="submit" value="accept follow">
|
<input type="submit" value="accept follow">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_reject_incoming_follow_button(notif) %}
|
{% macro admin_reject_incoming_follow_button(notif) %}
|
||||||
{% block admin_reject_incoming_follow_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
<input type="hidden" name="notification_id" value="{{ notif.id }}">
|
||||||
<input type="submit" value="reject follow">
|
<input type="submit" value="reject follow">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_like_button(ap_object_id, permalink_id) %}
|
{% macro admin_like_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_like_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_like") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_like") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="like">
|
<input type="submit" value="like">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_bookmark_button(ap_object_id, permalink_id) %}
|
{% macro admin_bookmark_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_bookmark_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="bookmark">
|
<input type="submit" value="bookmark">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
|
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_unbookmark_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="unbookmark">
|
<input type="submit" value="unbookmark">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_pin_button(ap_object_id, permalink_id) %}
|
{% macro admin_pin_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_pin_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="pin">
|
<input type="submit" value="pin">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_unpin_button(ap_object_id, permalink_id) %}
|
{% macro admin_unpin_button(ap_object_id, permalink_id) %}
|
||||||
{% block admin_unpin_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="unpin">
|
<input type="submit" value="unpin">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_delete_button(ap_object) %}
|
{% macro admin_delete_button(ap_object_id) %}
|
||||||
{% block admin_delete_button scoped %}
|
<form action="{{ request.url_for("admin_actions_delete") }}" method="POST" onsubmit="return confirm('Do you really want to delete this object?');">
|
||||||
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
|
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
<input type="hidden" name="redirect_url" value="{% if request.url.path.endswith("/" + ap_object.public_id) or (request.url.path == "/admin/object" and request.query_params.ap_id.endswith("/" + ap_object.public_id)) %}{{ request.base_url}}{% else %}{{ request.url }}{% endif %}">
|
{{ embed_redirect_url() }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="delete">
|
<input type="submit" value="delete">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro admin_force_delete_button(ap_object_id, permalink_id=None) %}
|
|
||||||
{% block admin_force_delete_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_force_delete") }}" class="object-delete-form" method="POST">
|
|
||||||
{{ embed_csrf_token() }}
|
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
|
||||||
<input type="submit" value="local delete">
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro admin_force_delete_webmention_button(webmention_id, permalink_id=None) %}
|
|
||||||
{% block admin_force_delete_webmention_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_force_delete_webmention") }}" class="object-delete-form" method="POST">
|
|
||||||
{{ embed_csrf_token() }}
|
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
|
||||||
<input type="hidden" name="webmention_id" value="{{ webmention_id }}">
|
|
||||||
<input type="submit" value="local delete">
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_announce_button(ap_object_id, permalink_id=None) %}
|
{% macro admin_announce_button(ap_object_id, permalink_id=None) %}
|
||||||
{% block admin_announce_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="share">
|
<input type="submit" value="share">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %}
|
{% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %}
|
||||||
{% block admin_undo_button scoped %}
|
|
||||||
<form action="{{ request.url_for("admin_actions_undo") }}" method="POST">
|
<form action="{{ request.url_for("admin_actions_undo") }}" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url(permalink_id) }}
|
{{ embed_redirect_url(permalink_id) }}
|
||||||
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
|
||||||
<input type="submit" value="{{ action }}">
|
<input type="submit" value="{{ action }}">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_reply_button(ap_object_id) %}
|
{% macro admin_reply_button(ap_object_id) %}
|
||||||
{% block admin_reply_button scoped %}
|
<form action="/admin/new" method="GET">
|
||||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
|
||||||
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
|
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
|
||||||
<button type="submit">reply</button>
|
<button type="submit">reply</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_dm_button(actor_handle) %}
|
{% macro admin_dm_button(actor_handle) %}
|
||||||
{% block admin_dm_button scoped %}
|
<form action="/admin/new" method="GET">
|
||||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
|
||||||
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
||||||
<input type="hidden" name="with_visibility" value="DIRECT">
|
<input type="hidden" name="with_visibility" value="DIRECT">
|
||||||
<button type="submit">direct message</button>
|
<button type="submit">direct message</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_mention_button(actor_handle) %}
|
{% macro admin_mention_button(actor_handle) %}
|
||||||
{% block admin_mention_button scoped %}
|
<form action="/admin/new" method="GET">
|
||||||
<form action="{{ BASE_URL }}/admin/new" method="GET">
|
|
||||||
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
<input type="hidden" name="with_content" value="{{ actor_handle }}">
|
||||||
<button type="submit">mention</button>
|
<button type="submit">mention</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% macro admin_profile_button(ap_actor_id) %}
|
{% macro admin_profile_button(ap_actor_id) %}
|
||||||
{% block admin_profile_button scoped %}
|
|
||||||
<form action="{{ url_for("admin_profile") }}" method="GET">
|
<form action="{{ url_for("admin_profile") }}" method="GET">
|
||||||
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
|
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
|
||||||
<button type="submit">profile</button>
|
<button type="submit">profile</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_expand_button(ap_object) %}
|
{% macro admin_expand_button(ap_object_id) %}
|
||||||
{% block admin_expand_button scoped %}
|
|
||||||
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
|
|
||||||
<form action="{{ url_for("admin_object") }}" method="GET">
|
<form action="{{ url_for("admin_object") }}" method="GET">
|
||||||
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
|
<input type="hidden" name="ap_id" value="{{ ap_object_id }}">
|
||||||
<button type="submit">expand</button>
|
<button type="submit">expand</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_box_filters(route) %}
|
{% macro display_box_filters(route) %}
|
||||||
{% block display_box_filters scoped %}
|
|
||||||
<nav class="flexbox box">
|
<nav class="flexbox box">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Filter by</li>
|
<li>Filter by</li>
|
||||||
|
@ -265,29 +178,17 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_tiny_actor_icon(actor) %}
|
{% macro actor_action(inbox_object, text) %}
|
||||||
{% block display_tiny_actor_icon scoped %}
|
|
||||||
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="">
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro actor_action(inbox_object, text, with_icon=False) %}
|
|
||||||
{% block actor_action scoped %}
|
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">{{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}</a> {{ text }}
|
||||||
{% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}
|
|
||||||
</a> {{ text }}
|
|
||||||
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
|
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
|
||||||
{% block display_actor scoped %}
|
|
||||||
{% set metadata = actors_metadata.get(actor.ap_id) %}
|
{% set metadata = actors_metadata.get(actor.ap_id) %}
|
||||||
|
|
||||||
{% if not embedded %}
|
{% if not embedded %}
|
||||||
|
@ -298,7 +199,7 @@
|
||||||
<div class="icon-box">
|
<div class="icon-box">
|
||||||
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
|
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ actor.url }}" class="u-url">
|
<a href="{{ actor.url }}" class="u-url" style="">
|
||||||
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
|
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
|
||||||
<div class="actor-handle p-name">{{ actor.handle }}</div>
|
<div class="actor-handle p-name">{{ actor.handle }}</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -308,26 +209,14 @@
|
||||||
<div>
|
<div>
|
||||||
<nav class="flexbox actor-metadata">
|
<nav class="flexbox actor-metadata">
|
||||||
<ul>
|
<ul>
|
||||||
{% if metadata.has_blocked_local_actor %}
|
|
||||||
<li>blocked you</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if metadata.is_following %}
|
{% if metadata.is_following %}
|
||||||
<li>already following</li>
|
<li>already following</li>
|
||||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "unfollow")}}</li>
|
||||||
{% if not with_details %}
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% elif metadata.is_follow_request_sent %}
|
{% elif metadata.is_follow_request_sent %}
|
||||||
{% if metadata.is_follow_request_rejected %}
|
<li>follow request sent</li>
|
||||||
<li>follow request rejected</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
||||||
{% if not metadata.has_blocked_local_actor %}
|
{% else %}
|
||||||
<li>{{ admin_follow_button(actor) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<li>follow request sent</li>
|
|
||||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% elif not actor.moved_to %}
|
|
||||||
<li>{{ admin_follow_button(actor) }}</li>
|
<li>{{ admin_follow_button(actor) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if metadata.is_follower %}
|
{% if metadata.is_follower %}
|
||||||
|
@ -335,11 +224,7 @@
|
||||||
{% if not metadata.is_following and not with_details %}
|
{% if not metadata.is_following and not with_details %}
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif actor.is_from_db and not with_details and not metadata.is_following %}
|
</li>
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if actor.moved_to %}
|
|
||||||
<li>has moved to {% if metadata.moved_to %}<a href="{{ url_for("admin_profile") }}?actor_id={{ actor.moved_to }}">{{ metadata.moved_to.handle }}</a>{% else %}<a href="{{ url_for("get_lookup") }}?query={{ actor.moved_to }}">{{ actor.moved_to }}</a>{% endif %}</li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if actor.is_from_db %}
|
{% if actor.is_from_db %}
|
||||||
{% if actor.is_blocked %}
|
{% if actor.is_blocked %}
|
||||||
|
@ -365,14 +250,6 @@
|
||||||
<li>rejected</li>
|
<li>rejected</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if actor.are_announces_hidden_from_stream %}
|
|
||||||
<li>{{ admin_show_shares_button(actor) }}</li>
|
|
||||||
{% else %}
|
|
||||||
<li>{{ admin_hide_shares_button(actor) }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if with_details %}
|
|
||||||
<li><a href="{{ actor.url }}" class="label-btn">remote profile</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -403,74 +280,58 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_og_meta(object) %}
|
{% macro display_og_meta(object) %}
|
||||||
{% block display_og_meta scoped %}
|
|
||||||
{% if object.og_meta %}
|
{% if object.og_meta %}
|
||||||
{% for og_meta in object.og_meta[:1] %}
|
{% for og_meta in object.og_meta %}
|
||||||
<div class="activity-og-meta">
|
<div class="activity-og-meta" style="display:flex;column-gap: 20px;margin:20px 0;">
|
||||||
{% if og_meta.image %}
|
{% if og_meta.image %}
|
||||||
<div>
|
<div>
|
||||||
<img src="{{ og_meta.image | media_proxy_url }}">
|
<img src="{{ og_meta.image | media_proxy_url }}" style="max-width:200px;max-height:100px;">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
|
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
|
||||||
{% if og_meta.site_name %}
|
{% if og_meta.site_name %}
|
||||||
<small>{{ og_meta.site_name }}</small>
|
<small style="display:block;">{{ og_meta.site_name }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro display_attachments(object) %}
|
{% macro display_attachments(object) %}
|
||||||
{% block display_attachments scoped %}
|
|
||||||
|
|
||||||
{% for attachment in object.attachments %}
|
{% for attachment in object.attachments %}
|
||||||
{% if attachment.type != "PropertyValue" %}
|
|
||||||
{% set orientation = "unknown" %}
|
|
||||||
{% if attachment.width %}
|
|
||||||
{% set orientation = "portrait" if attachment.width < attachment.height else "landscape" %}
|
|
||||||
{% endif %}
|
|
||||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
||||||
<div class="attachment-wrapper">
|
<div>
|
||||||
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
|
<label for="{{attachment.proxied_url}}" class="label-btn" style="display:inline-block;">show/hide sensitive content</label>
|
||||||
<div>
|
<div>
|
||||||
<div class="sensitive-attachment">
|
<div class="sensitive-attachment">
|
||||||
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
|
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
|
||||||
<div class="sensitive-attachment-box attachment-orientation-{{orientation}}">
|
<div class="sensitive-attachment-box">
|
||||||
<div></div>
|
<div></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="attachment-item attachment-orientation-{{orientation}}">
|
<div style="margin-top:20px;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
|
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
|
||||||
{% if attachment.url not in object.inlined_images %}
|
{% if attachment.url not in object.inlined_images %}
|
||||||
<a class="media-link" href="{{ attachment.proxied_url }}" target="_blank">
|
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment" style="margin:0;">
|
||||||
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment u-photo">
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
|
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
|
||||||
<div class="video-wrapper">
|
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
|
||||||
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="u-video"></video>
|
|
||||||
<div class="video-gif-overlay">GIF</div>
|
|
||||||
</div>
|
|
||||||
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
||||||
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment u-audio"></audio>
|
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} style="width:480px;" class="attachment"></audio>
|
||||||
{% elif attachment.type == "Link" %}
|
{% elif attachment.type == "Link" %}
|
||||||
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url | truncate(64, True) }}</a> ({{ attachment.mimetype}})
|
<a href="{{ attachment.url }}" class="attachment" style="display:inline-block;margin-bottom: 15px;">{{ attachment.url }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.url }}"{% endif %} class="attachment">
|
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
|
||||||
{% if attachment.name %}{{ attachment.name }}{% else %}{{ attachment.url | truncate(64, True) }}{% endif %}
|
|
||||||
</a> ({{ attachment.mimetype }})
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
{% if object.sensitive %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -478,60 +339,13 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro display_webmention_reply(wm_reply) %}
|
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
|
||||||
{% block display_webmention_reply scoped %}
|
|
||||||
|
|
||||||
<div class="ap-object u-comment h-cite">
|
|
||||||
<div class="actor-box h-card p-author">
|
|
||||||
<div class="icon-box">
|
|
||||||
<img src="{{ wm_reply.face.picture_url }}" alt="{{ wm_reply.face.name }}'s avatar" class="actor-icon u-photo">
|
|
||||||
</div>
|
|
||||||
<a href="{{ wm_reply.face.url }}" class="u-url">
|
|
||||||
<div><strong class="p-name">{{ wm_reply.face.name | clean_html_wm | safe }}</strong></div>
|
|
||||||
<div class="actor-handle">{{ wm_reply.face.url | truncate(64, True) }}</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="in-reply-to">in reply to <a href="{{ wm_reply.in_reply_to }}" title="{{ wm_reply.in_reply_to }}" rel="nofollow">
|
|
||||||
this object
|
|
||||||
</a></p>
|
|
||||||
|
|
||||||
<div class="obj-content margin-top-20">
|
|
||||||
<div class="e-content">
|
|
||||||
{{ wm_reply.content | clean_html_wm | safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flexbox activity-bar margin-top-20">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<div><a href="{{ wm_reply.url }}" rel="nofollow" class="object-permalink u-url u-uid">permalink</a></div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<time class="dt-published" datetime="{{ wm_reply.published_at.replace(microsecond=0).isoformat() }}" title="{{ wm_reply.published_at.replace(microsecond=0).isoformat() }}">{{ wm_reply.published_at | timeago }}</time>
|
|
||||||
</li>
|
|
||||||
{% if is_admin %}
|
|
||||||
<li>
|
|
||||||
{{ admin_force_delete_webmention_button(wm_reply.webmention_id) }}
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False, is_h_entry=True) %}
|
|
||||||
{% block display_object scoped %}
|
|
||||||
{% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %}
|
{% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %}
|
||||||
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question", "Event"] %}
|
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question"] %}
|
||||||
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}{% if is_h_entry %}h-entry{% endif %}" id="{{ object.permalink_id }}">
|
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
|
||||||
|
|
||||||
{% if is_article_mode %}
|
{% if is_article_mode %}
|
||||||
<data class="h-card">
|
<data class="h-card">
|
||||||
|
@ -544,35 +358,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.in_reply_to %}
|
{% if object.in_reply_to %}
|
||||||
<p class="in-reply-to">in reply to <a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" rel="nofollow">
|
<a href="{% if is_admin %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
|
||||||
this object
|
in reply to {{ object.in_reply_to|truncate(64, True) }}
|
||||||
</a></p>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_type in ["Article", "Event"] %}
|
{% if object.ap_type == "Article" %}
|
||||||
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
|
<h2 class="p-name" style="margin-top:0;">{{ object.name }}</h2>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if object.ap_type == "Event" %}
|
|
||||||
{% if object.ap_object.get("endTime") and object.ap_object.get("startTime") %}
|
|
||||||
<p>On {{ object.ap_object.startTime | parse_datetime | format_date }}
|
|
||||||
(ends {{ object.ap_object.endTime | parse_datetime | format_date }})</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if object.ap_object.get("location") %}
|
|
||||||
{% set loc = object.ap_object.get("location") %}
|
|
||||||
{% if loc.type == "Place" and loc.latitude and loc.longitude %}
|
|
||||||
<div class="ap-place">
|
|
||||||
<h3>Location</h3>
|
|
||||||
{% if loc.name %}{{ loc.name }}{% endif %}
|
|
||||||
<span class="h-geo">
|
|
||||||
<data class="p-latitude" value="{{ loc.latitude}}"></data>
|
|
||||||
<data class="p-longitude" value="{{ loc.longitude }}"></data>
|
|
||||||
<a href="https://www.openstreetmap.org/?mlat={{ loc.latitude }}&mlon={{ loc.longitude }}#map=16/{{loc.latitude}}/{{loc.longitude}}">{{loc.latitude}},{{loc.longitude}}</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_article_mode %}
|
{% if is_article_mode %}
|
||||||
|
@ -580,13 +372,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.summary %}
|
{% if object.summary %}
|
||||||
<details class="show-more-wrapper">
|
<div class="show-more-wrapper">
|
||||||
<summary>
|
<div class="p-summary">
|
||||||
<div class="p-summary">
|
<p>{{ object.summary | clean_html(object) | safe }}</p>
|
||||||
<p>{{ object.summary | clean_html(object) | safe }}</p>
|
</div>
|
||||||
</div>
|
<label for="show-more-{{ object.permalink_id }}" class="show-more-btn">show/hide more</label>
|
||||||
<span class="show-more-btn" aria-hidden="true"></span>
|
<input class="show-more-state" type="checkbox" aria-hidden="true" id="show-more-{{ object.permalink_id }}" checked>
|
||||||
</summary>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="obj-content">
|
<div class="obj-content">
|
||||||
<div class="e-content">
|
<div class="e-content">
|
||||||
|
@ -603,11 +394,11 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.poll_items %}
|
{% if object.poll_items %}
|
||||||
<ul class="poll-items">
|
<ul style="list-style-type: none;padding:0;">
|
||||||
{% for item in object.poll_items %}
|
{% for item in object.poll_items %}
|
||||||
<li>
|
<li style="display:block;">
|
||||||
{% set pct = item | poll_item_pct(object.poll_voters_count) %}
|
{% set pct = item | poll_item_pct(object.poll_voters_count) %}
|
||||||
<p>
|
<p style="margin:20px 0 10px 0;">
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
<input type="{% if object.is_one_of_poll %}radio{% else %}checkbox{% endif %}" name="name" value="{{ item.name }}" id="{{object.permalink_id}}-{{item.name}}">
|
<input type="{% if object.is_one_of_poll %}radio{% else %}checkbox{% endif %}" name="name" value="{{ item.name }}" id="{{object.permalink_id}}-{{item.name}}">
|
||||||
<label for="{{object.permalink_id}}-{{item.name}}">
|
<label for="{{object.permalink_id}}-{{item.name}}">
|
||||||
|
@ -616,17 +407,17 @@
|
||||||
{{ item.name | clean_html(object) | safe }}
|
{{ item.name | clean_html(object) | safe }}
|
||||||
|
|
||||||
{% if object.voted_for_answers and item.name in object.voted_for_answers %}
|
{% if object.voted_for_answers and item.name in object.voted_for_answers %}
|
||||||
<span class="muted poll-vote">you voted for this answer</span>
|
<span class="muted" style="padding-left:20px;">you voted for this answer</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="float-right">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
|
<span style="float:right;">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
|
||||||
</p>
|
</p>
|
||||||
<svg class="poll-bar">
|
<svg class="poll-bar">
|
||||||
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px"></line>
|
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px" style="stroke-width: 20px;"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -640,17 +431,16 @@
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{{ display_og_meta(object) }}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ display_og_meta(object) }}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% if object.summary %}
|
{% if object.summary %}
|
||||||
</details>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="activity-attachment">
|
<div class="activity-attachment" style="margin-bottom:20px;">
|
||||||
{{ display_attachments(object) }}
|
{{ display_attachments(object) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -659,16 +449,6 @@
|
||||||
<li>
|
<li>
|
||||||
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
<div><a href="{{ object.url }}"{% if object.is_from_inbox %} rel="nofollow"{% endif %} class="object-permalink u-url u-uid">permalink</a></div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if object.is_from_outbox and is_object_page and not is_admin and not request.url.path.startswith("/remote_interaction") %}
|
|
||||||
<li>
|
|
||||||
<a class="label-btn" href="{{ request.url_for("remote_interaction") }}?ap_id={{ object.ap_id }}">
|
|
||||||
interact from your instance
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{% if not is_article_mode %}
|
{% if not is_article_mode %}
|
||||||
<li>
|
<li>
|
||||||
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
<time class="dt-published" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at | timeago }}</time>
|
||||||
|
@ -716,7 +496,7 @@
|
||||||
|
|
||||||
{% if (object.is_from_outbox or is_admin) and object.replies_count %}
|
{% if (object.is_from_outbox or is_admin) and object.replies_count %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% if is_admin and not object.is_from_outbox %}{{ url_for("admin_object") }}?ap_id={{ object.ap_id }}{% if object.in_reply_to %}#{{ object.permalink_id }}{% endif %}{% else %}{{ object.url }}{% endif %}"><strong>{{ object.replies_count }}</strong> repl{{ object.replies_count | pluralize("y", "ies") }}</a>
|
<a href="{% if is_admin and not object.is_from_outbox %}{{ url_for("admin_object") }}?ap_id={{ object.ap_id }}{% else %}{{ object.url }}{% endif %}"><strong>{{ object.replies_count }}</strong> repl{{ object.replies_count | pluralize("y", "ies") }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -728,7 +508,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% if object.is_from_outbox %}
|
{% if object.is_from_outbox %}
|
||||||
<li>
|
<li>
|
||||||
{{ admin_delete_button(object) }}
|
{{ admin_delete_button(object.ap_id) }}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
|
@ -764,7 +544,7 @@
|
||||||
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
{% if object.visibility in [visibility_enum.PUBLIC, visibility_enum.UNLISTED] %}
|
||||||
<li>
|
<li>
|
||||||
{% if object.announced_via_outbox_object_ap_id %}
|
{% if object.announced_via_outbox_object_ap_id %}
|
||||||
{{ admin_undo_button(object.announced_via_outbox_object_ap_id, "unshare") }}
|
{{ admin_undo_button(object.liked_via_outbox_object_ap_id, "unshare") }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
{{ admin_announce_button(object.ap_id, permalink_id=object.permalink_id) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -779,12 +559,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.is_from_inbox or object.is_from_outbox %}
|
{% if object.is_from_inbox or object.is_from_outbox %}
|
||||||
<li>
|
<li>
|
||||||
{{ admin_expand_button(object) }}
|
{{ admin_expand_button(object.ap_id) }}
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if object.is_from_inbox and not object.announced_via_outbox_object_ap_id and object.is_local_reply %}
|
|
||||||
<li>
|
|
||||||
{{ admin_force_delete_button(object.ap_id) }}
|
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -793,17 +568,17 @@
|
||||||
|
|
||||||
|
|
||||||
{% if likes or shares or webmentions %}
|
{% if likes or shares or webmentions %}
|
||||||
<div class="public-interactions">
|
<div style="display: flex;column-gap: 20px;flex-wrap: wrap;margin-top:20px;">
|
||||||
{% if likes %}
|
{% if likes %}
|
||||||
<div class="interactions-block">Likes
|
<div style="flex: 0 1 30%;max-width: 50%;">Likes
|
||||||
<div class="facepile-wrapper">
|
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
|
||||||
{% for like in likes %}
|
{% for like in likes %}
|
||||||
<a href="{% if is_admin and like.ap_actor_id %}{{ url_for("admin_profile") }}?actor_id={{ like.ap_actor_id }}{% else %}{{ like.url }}{% endif %}" title="{{ like.name }}" rel="noreferrer">
|
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" style="height:50px;" rel="noreferrer">
|
||||||
<img src="{{ like.picture_url }}" alt="{{ like.name }}">
|
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}" style="max-width:50px;">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if object.likes_count > likes | length %}
|
{% if object.likes_count > likes | length %}
|
||||||
<div class="and-x-more">
|
<div style="display:inline-block;align-self:center;">
|
||||||
and {{ object.likes_count - likes | length }} more.
|
and {{ object.likes_count - likes | length }} more.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -812,15 +587,15 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if shares %}
|
{% if shares %}
|
||||||
<div class="interactions-block">Shares
|
<div style="flex: 0 1 30%;max-width: 50%;">Shares
|
||||||
<div class="facepile-wrapper">
|
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
|
||||||
{% for share in shares %}
|
{% for share in shares %}
|
||||||
<a href="{% if is_admin and share.ap_actor_id %}{{ url_for("admin_profile") }}?actor_id={{ share.ap_actor_id }}{% else %}{{ share.url }}{% endif %}" title="{{ share.name }}" rel="noreferrer">
|
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" style="height:50px;" rel="noreferrer">
|
||||||
<img src="{{ share.picture_url }}" alt="{{ share.name }}">
|
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}" style="max-width:50px;">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if object.announces_count > shares | length %}
|
{% if object.announces_count > shares | length %}
|
||||||
<div class="and-x-more">
|
<div style="display:inline-block;align-self:center;">
|
||||||
and {{ object.announces_count - shares | length }} more.
|
and {{ object.announces_count - shares | length }} more.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -829,13 +604,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if webmentions %}
|
{% if webmentions %}
|
||||||
<div class="interactions-block">Webmentions
|
<div style="flex: 0 1 30%;max-width: 50%;">Webmentions
|
||||||
<div class="facepile-wrapper">
|
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
|
||||||
{% for webmention in webmentions %}
|
{% for webmention in webmentions %}
|
||||||
{% set wm = webmention.as_facepile_item %}
|
{% set wm = webmention.as_facepile_item %}
|
||||||
{% if wm %}
|
{% if wm %}
|
||||||
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" rel="noreferrer">
|
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" style="height:50px;" rel="noreferrer">
|
||||||
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}">
|
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}" style="max-width:50px;">
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -849,5 +624,4 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import blurhash # type: ignore
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL import ImageOps
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
|
@ -46,13 +45,11 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
|
||||||
width = None
|
width = None
|
||||||
height = None
|
height = None
|
||||||
|
|
||||||
if f.content_type.startswith("image") and not f.content_type == "image/gif":
|
if f.content_type.startswith("image"):
|
||||||
with Image.open(f.file) as _original_image:
|
image_blurhash = blurhash.encode(f.file, x_components=4, y_components=3)
|
||||||
# Fix image orientation (as we will remove the info from the EXIF
|
f.file.seek(0)
|
||||||
# metadata)
|
|
||||||
original_image = ImageOps.exif_transpose(_original_image)
|
|
||||||
|
|
||||||
# Re-creating the image drop the EXIF metadata
|
with Image.open(f.file) as original_image:
|
||||||
destination_image = Image.new(
|
destination_image = Image.new(
|
||||||
original_image.mode,
|
original_image.mode,
|
||||||
original_image.size,
|
original_image.size,
|
||||||
|
@ -60,18 +57,15 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
|
||||||
destination_image.putdata(original_image.getdata())
|
destination_image.putdata(original_image.getdata())
|
||||||
destination_image.save(
|
destination_image.save(
|
||||||
dest_filename,
|
dest_filename,
|
||||||
format=_original_image.format, # type: ignore
|
format=original_image.format,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(dest_filename, "rb") as dest_f:
|
|
||||||
image_blurhash = blurhash.encode(dest_f, x_components=4, y_components=3)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
width, height = destination_image.size
|
width, height = original_image.size
|
||||||
destination_image.thumbnail((740, 740))
|
original_image.thumbnail((740, 740))
|
||||||
destination_image.save(
|
original_image.save(
|
||||||
UPLOAD_DIR / f"{content_hash}_resized",
|
UPLOAD_DIR / f"{content_hash}_resized",
|
||||||
format="webp",
|
format=original_image.format,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
from typing import Awaitable
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from app.actor import LOCAL_ACTOR
|
|
||||||
from app.config import is_activitypub_requested
|
|
||||||
from app.database import AsyncSession
|
|
||||||
from app.database import get_db_session
|
|
||||||
|
|
||||||
_Handler = Callable[[Request, AsyncSession], Awaitable[Any]]
|
|
||||||
|
|
||||||
|
|
||||||
def build_custom_index_handler(handler: _Handler) -> _Handler:
|
|
||||||
async def custom_index(
|
|
||||||
request: Request,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> Any:
|
|
||||||
# Serve the AP actor if requested
|
|
||||||
if is_activitypub_requested(request):
|
|
||||||
return JSONResponse(
|
|
||||||
LOCAL_ACTOR.ap_actor,
|
|
||||||
media_type="application/activity+json",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Defer to the custom handler
|
|
||||||
return await handler(request, db_session)
|
|
||||||
|
|
||||||
return custom_index
|
|
|
@ -23,8 +23,6 @@ def _load_emojis(root_dir: Path, base_url: str) -> None:
|
||||||
mt = mimetypes.guess_type(emoji.name)[0]
|
mt = mimetypes.guess_type(emoji.name)[0]
|
||||||
if mt and mt.startswith("image/"):
|
if mt and mt.startswith("image/"):
|
||||||
name = emoji.name.split(".")[0]
|
name = emoji.name.split(".")[0]
|
||||||
if not re.match(EMOJI_REGEX, f":{name}:"):
|
|
||||||
continue
|
|
||||||
ap_emoji: "RawObject" = {
|
ap_emoji: "RawObject" = {
|
||||||
"type": "Emoji",
|
"type": "Emoji",
|
||||||
"name": f":{name}:",
|
"name": f":{name}:",
|
||||||
|
|
|
@ -1,172 +0,0 @@
|
||||||
import datetime
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timezone
|
|
||||||
from typing import Any
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from app import media
|
|
||||||
from app.models import InboxObject
|
|
||||||
from app.models import Webmention
|
|
||||||
from app.utils.datetime import parse_isoformat
|
|
||||||
from app.utils.url import must_make_abs
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Face:
|
|
||||||
ap_actor_id: str | None
|
|
||||||
url: str
|
|
||||||
name: str
|
|
||||||
picture_url: str
|
|
||||||
created_at: datetime.datetime
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_inbox_object(cls, like: InboxObject) -> "Face":
|
|
||||||
return cls(
|
|
||||||
ap_actor_id=like.actor.ap_id,
|
|
||||||
url=like.actor.url, # type: ignore
|
|
||||||
name=like.actor.handle, # type: ignore
|
|
||||||
picture_url=like.actor.resized_icon_url,
|
|
||||||
created_at=like.created_at, # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_webmention(cls, webmention: Webmention) -> Optional["Face"]:
|
|
||||||
items = webmention.source_microformats.get("items", []) # type: ignore
|
|
||||||
for item in items:
|
|
||||||
if item["type"][0] == "h-card":
|
|
||||||
try:
|
|
||||||
return cls(
|
|
||||||
ap_actor_id=None,
|
|
||||||
url=(
|
|
||||||
must_make_abs(
|
|
||||||
item["properties"]["url"][0], webmention.source
|
|
||||||
)
|
|
||||||
if item["properties"].get("url")
|
|
||||||
else webmention.source
|
|
||||||
),
|
|
||||||
name=item["properties"]["name"][0],
|
|
||||||
picture_url=media.resized_media_url(
|
|
||||||
must_make_abs(
|
|
||||||
item["properties"]["photo"][0], webmention.source
|
|
||||||
), # type: ignore
|
|
||||||
50,
|
|
||||||
),
|
|
||||||
created_at=webmention.created_at, # type: ignore
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
f"Failed to build Face for webmention id={webmention.id}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
elif item["type"][0] == "h-entry":
|
|
||||||
author = item["properties"]["author"][0]
|
|
||||||
try:
|
|
||||||
return cls(
|
|
||||||
ap_actor_id=None,
|
|
||||||
url=webmention.source,
|
|
||||||
name=author["properties"]["name"][0],
|
|
||||||
picture_url=media.resized_media_url(
|
|
||||||
must_make_abs(
|
|
||||||
author["properties"]["photo"][0], webmention.source
|
|
||||||
), # type: ignore
|
|
||||||
50,
|
|
||||||
),
|
|
||||||
created_at=webmention.created_at, # type: ignore
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
f"Failed to build Face for webmention id={webmention.id}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def merge_faces(faces: list[Face]) -> list[Face]:
|
|
||||||
return sorted(
|
|
||||||
faces,
|
|
||||||
key=lambda f: f.created_at,
|
|
||||||
reverse=True,
|
|
||||||
)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_face(webmention: Webmention, items: list[dict[str, Any]]) -> Face | None:
|
|
||||||
for item in items:
|
|
||||||
if item["type"][0] == "h-card":
|
|
||||||
try:
|
|
||||||
return Face(
|
|
||||||
ap_actor_id=None,
|
|
||||||
url=(
|
|
||||||
must_make_abs(item["properties"]["url"][0], webmention.source)
|
|
||||||
if item["properties"].get("url")
|
|
||||||
else webmention.source
|
|
||||||
),
|
|
||||||
name=item["properties"]["name"][0],
|
|
||||||
picture_url=media.resized_media_url(
|
|
||||||
must_make_abs(
|
|
||||||
item["properties"]["photo"][0], webmention.source
|
|
||||||
), # type: ignore
|
|
||||||
50,
|
|
||||||
),
|
|
||||||
created_at=webmention.created_at, # type: ignore
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
f"Failed to build Face for webmention id={webmention.id}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WebmentionReply:
|
|
||||||
face: Face
|
|
||||||
content: str
|
|
||||||
url: str
|
|
||||||
published_at: datetime.datetime
|
|
||||||
in_reply_to: str
|
|
||||||
webmention_id: int
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_webmention(cls, webmention: Webmention) -> Optional["WebmentionReply"]:
|
|
||||||
items = webmention.source_microformats.get("items", []) # type: ignore
|
|
||||||
for item in items:
|
|
||||||
if item["type"][0] == "h-entry":
|
|
||||||
try:
|
|
||||||
face = _parse_face(webmention, item["properties"].get("author", []))
|
|
||||||
if not face:
|
|
||||||
logger.info(
|
|
||||||
"Failed to build WebmentionReply/Face for "
|
|
||||||
f"webmention id={webmention.id}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
if "published" in item["properties"]:
|
|
||||||
published_at = (
|
|
||||||
parse_isoformat(item["properties"]["published"][0])
|
|
||||||
.astimezone(timezone.utc)
|
|
||||||
.replace(tzinfo=None)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
published_at = webmention.created_at # type: ignore
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
face=face,
|
|
||||||
content=item["properties"]["content"][0]["html"],
|
|
||||||
url=must_make_abs(
|
|
||||||
item["properties"]["url"][0], webmention.source
|
|
||||||
),
|
|
||||||
published_at=published_at,
|
|
||||||
in_reply_to=webmention.target, # type: ignore
|
|
||||||
webmention_id=webmention.id, # type: ignore
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
f"Failed to build Face for webmention id={webmention.id}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
return None
|
|
|
@ -1,5 +1,3 @@
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
|
@ -13,9 +11,6 @@ from app.config import CODE_HIGHLIGHTING_THEME
|
||||||
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
||||||
|
|
||||||
HIGHLIGHT_CSS = _FORMATTER.get_style_defs()
|
HIGHLIGHT_CSS = _FORMATTER.get_style_defs()
|
||||||
HIGHLIGHT_CSS_HASH = base64.b64encode(
|
|
||||||
hashlib.sha256(HIGHLIGHT_CSS.encode()).digest()
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(256)
|
@lru_cache(256)
|
||||||
|
@ -32,22 +27,23 @@ def highlight(html: str) -> str:
|
||||||
|
|
||||||
# If this comes from a microblog.pub instance we may have the language
|
# If this comes from a microblog.pub instance we may have the language
|
||||||
# in the class name
|
# in the class name
|
||||||
if "data-microblogpub-lexer" in code.attrs:
|
if "class" in code.attrs and code.attrs["class"][0].startswith("language-"):
|
||||||
try:
|
try:
|
||||||
lexer = get_lexer_by_name(code.attrs["data-microblogpub-lexer"])
|
lexer = get_lexer_by_name(
|
||||||
|
code.attrs["class"][0].removeprefix("language-")
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
lexer = guess_lexer(code_content)
|
lexer = guess_lexer(code_content)
|
||||||
|
|
||||||
# Replace the code with Pygment output
|
|
||||||
# XXX: the HTML escaping causes issue with Python type annotations
|
|
||||||
code_content = code_content.replace(") -> ", ") -> ")
|
|
||||||
code.parent.replaceWith(
|
|
||||||
BeautifulSoup(
|
|
||||||
phighlight(code_content, lexer, _FORMATTER), "html5lib"
|
|
||||||
).body.next
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
code.name = "div"
|
lexer = guess_lexer(code_content)
|
||||||
code["class"] = code.get("class", []) + ["highlight"]
|
|
||||||
|
# Replace the code with Pygment output
|
||||||
|
# XXX: the HTML escaping causes issue with Python type annotations
|
||||||
|
code_content = code_content.replace(") -> ", ") -> ")
|
||||||
|
code.parent.replaceWith(
|
||||||
|
BeautifulSoup(
|
||||||
|
phighlight(code_content, lexer, _FORMATTER), "html5lib"
|
||||||
|
).body.next
|
||||||
|
)
|
||||||
|
|
||||||
return soup.body.encode_contents().decode()
|
return soup.body.encode_contents().decode()
|
||||||
|
|
|
@ -10,7 +10,7 @@ from app.utils.url import make_abs
|
||||||
class IndieAuthClient:
|
class IndieAuthClient:
|
||||||
logo: str | None
|
logo: str | None
|
||||||
name: str
|
name: str
|
||||||
url: str | None
|
url: str
|
||||||
|
|
||||||
|
|
||||||
def _get_prop(props: dict[str, Any], name: str, default=None) -> Any:
|
def _get_prop(props: dict[str, Any], name: str, default=None) -> Any:
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from app.webfinger import get_actor_url
|
|
||||||
|
|
||||||
|
|
||||||
def _load_mastodon_following_accounts_csv_file(path: str) -> list[str]:
|
|
||||||
handles = []
|
|
||||||
for line in Path(path).read_text().splitlines()[1:]:
|
|
||||||
handle = line.split(",")[0]
|
|
||||||
handles.append(handle)
|
|
||||||
|
|
||||||
return handles
|
|
||||||
|
|
||||||
|
|
||||||
async def get_actor_urls_from_following_accounts_csv_file(
|
|
||||||
path: str,
|
|
||||||
) -> list[tuple[str, str]]:
|
|
||||||
actor_urls = []
|
|
||||||
for handle in _load_mastodon_following_accounts_csv_file(path):
|
|
||||||
try:
|
|
||||||
actor_url = await get_actor_url(handle)
|
|
||||||
except Exception:
|
|
||||||
logger.error("Failed to fetch actor URL for {handle=}")
|
|
||||||
else:
|
|
||||||
if actor_url:
|
|
||||||
actor_urls.append((handle, actor_url))
|
|
||||||
else:
|
|
||||||
logger.info(f"No actor URL found for {handle=}")
|
|
||||||
|
|
||||||
return actor_urls
|
|
|
@ -1,18 +1,13 @@
|
||||||
import asyncio
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import signal
|
|
||||||
from concurrent.futures import TimeoutError
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pebble import concurrent # type: ignore
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import activitypub as ap
|
|
||||||
from app import ap_object
|
from app import ap_object
|
||||||
from app import config
|
from app import config
|
||||||
from app.actor import LOCAL_ACTOR
|
from app.actor import LOCAL_ACTOR
|
||||||
|
@ -32,11 +27,7 @@ class OpenGraphMeta(BaseModel):
|
||||||
site_name: str
|
site_name: str
|
||||||
|
|
||||||
|
|
||||||
@concurrent.process(timeout=5)
|
|
||||||
def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
||||||
# Prevent SIGTERM to bubble up to the worker
|
|
||||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(html, "html5lib")
|
soup = BeautifulSoup(html, "html5lib")
|
||||||
ogs = {
|
ogs = {
|
||||||
og.attrs["property"]: og.attrs.get("content")
|
og.attrs["property"]: og.attrs.get("content")
|
||||||
|
@ -45,7 +36,7 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
||||||
# FIXME some page have no <title>
|
# FIXME some page have no <title>
|
||||||
raw = {
|
raw = {
|
||||||
"url": url,
|
"url": url,
|
||||||
"title": soup.find("title").text.strip(),
|
"title": soup.find("title").text,
|
||||||
"image": None,
|
"image": None,
|
||||||
"description": None,
|
"description": None,
|
||||||
"site_name": urlparse(url).hostname,
|
"site_name": urlparse(url).hostname,
|
||||||
|
@ -62,20 +53,9 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
||||||
if u := raw.get(maybe_rel):
|
if u := raw.get(maybe_rel):
|
||||||
raw[maybe_rel] = make_abs(u, url)
|
raw[maybe_rel] = make_abs(u, url)
|
||||||
|
|
||||||
if not is_url_valid(raw[maybe_rel]):
|
|
||||||
logger.info(f"Invalid url {raw[maybe_rel]}")
|
|
||||||
if maybe_rel == "url":
|
|
||||||
raw["url"] = url
|
|
||||||
elif maybe_rel == "image":
|
|
||||||
raw["image"] = None
|
|
||||||
|
|
||||||
return OpenGraphMeta.parse_obj(raw)
|
return OpenGraphMeta.parse_obj(raw)
|
||||||
|
|
||||||
|
|
||||||
def scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
|
||||||
return _scrap_og_meta(url, html).result()
|
|
||||||
|
|
||||||
|
|
||||||
async def external_urls(
|
async def external_urls(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
ro: ap_object.RemoteObject | OutboxObject | InboxObject,
|
ro: ap_object.RemoteObject | OutboxObject | InboxObject,
|
||||||
|
@ -88,12 +68,7 @@ async def external_urls(
|
||||||
tags_hrefs.add(tag_href)
|
tags_hrefs.add(tag_href)
|
||||||
if tag.get("type") == "Mention":
|
if tag.get("type") == "Mention":
|
||||||
if tag["href"] != LOCAL_ACTOR.ap_id:
|
if tag["href"] != LOCAL_ACTOR.ap_id:
|
||||||
try:
|
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
||||||
mentioned_actor = await fetch_actor(db_session, tag["href"])
|
|
||||||
except (ap.FetchError, ap.NotAnObjectError):
|
|
||||||
tags_hrefs.add(tag["href"])
|
|
||||||
continue
|
|
||||||
|
|
||||||
tags_hrefs.add(mentioned_actor.url)
|
tags_hrefs.add(mentioned_actor.url)
|
||||||
tags_hrefs.add(mentioned_actor.ap_id)
|
tags_hrefs.add(mentioned_actor.ap_id)
|
||||||
else:
|
else:
|
||||||
|
@ -105,25 +80,18 @@ async def external_urls(
|
||||||
soup = BeautifulSoup(ro.content, "html5lib")
|
soup = BeautifulSoup(ro.content, "html5lib")
|
||||||
for link in soup.find_all("a"):
|
for link in soup.find_all("a"):
|
||||||
h = link.get("href")
|
h = link.get("href")
|
||||||
if not h:
|
ph = urlparse(h)
|
||||||
continue
|
mimetype, _ = mimetypes.guess_type(h)
|
||||||
|
if (
|
||||||
try:
|
ph.scheme in {"http", "https"}
|
||||||
ph = urlparse(h)
|
and ph.hostname != note_host
|
||||||
mimetype, _ = mimetypes.guess_type(h)
|
and is_url_valid(h)
|
||||||
if (
|
and (
|
||||||
ph.scheme in {"http", "https"}
|
not mimetype
|
||||||
and ph.hostname != note_host
|
or mimetype.split("/")[0] not in ["image", "video", "audio"]
|
||||||
and is_url_valid(h)
|
)
|
||||||
and (
|
):
|
||||||
not mimetype
|
urls.add(h)
|
||||||
or mimetype.split("/")[0] not in ["image", "video", "audio"]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
urls.add(h)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to check {h}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return urls - tags_hrefs
|
return urls - tags_hrefs
|
||||||
|
|
||||||
|
@ -144,10 +112,7 @@ async def _og_meta_from_url(url: str) -> OpenGraphMeta | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return scrap_og_meta(url, resp.text)
|
return _scrap_og_meta(url, resp.text)
|
||||||
except TimeoutError:
|
|
||||||
logger.info(f"Timed out when scraping OG meta for {url}")
|
|
||||||
return None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.info(f"Failed to scrap OG meta for {url}")
|
logger.info(f"Failed to scrap OG meta for {url}")
|
||||||
return None
|
return None
|
||||||
|
@ -159,21 +124,9 @@ async def og_meta_from_note(
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
og_meta = []
|
og_meta = []
|
||||||
urls = await external_urls(db_session, ro)
|
urls = await external_urls(db_session, ro)
|
||||||
logger.debug(f"Lookig OG metadata in {urls=}")
|
|
||||||
for url in urls:
|
for url in urls:
|
||||||
logger.debug(f"Processing {url}")
|
|
||||||
try:
|
try:
|
||||||
maybe_og_meta = None
|
maybe_og_meta = await _og_meta_from_url(url)
|
||||||
try:
|
|
||||||
maybe_og_meta = await asyncio.wait_for(
|
|
||||||
_og_meta_from_url(url),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.info(f"Timing out fetching {url}")
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed scrap OG meta for {url}")
|
|
||||||
|
|
||||||
if maybe_og_meta:
|
if maybe_og_meta:
|
||||||
og_meta.append(maybe_og_meta.dict())
|
og_meta.append(maybe_og_meta.dict())
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
import re
|
|
||||||
import unicodedata
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(text: str) -> str:
|
|
||||||
value = unicodedata.normalize("NFKC", text)
|
|
||||||
value = re.sub(r"[^\w\s-]", "", value.lower())
|
|
||||||
return re.sub(r"[-\s]+", "-", value).strip("-_")
|
|
|
@ -21,13 +21,6 @@ def make_abs(url: str | None, parent: str) -> str | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def must_make_abs(url: str | None, parent: str) -> str:
|
|
||||||
abs_url = make_abs(url, parent)
|
|
||||||
if not abs_url:
|
|
||||||
raise ValueError("missing URL")
|
|
||||||
return abs_url
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidURLError(Exception):
|
class InvalidURLError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -61,14 +54,10 @@ def is_url_valid(url: str) -> bool:
|
||||||
if not parsed.hostname or parsed.hostname.lower() in ["localhost"]:
|
if not parsed.hostname or parsed.hostname.lower() in ["localhost"]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if is_hostname_blocked(parsed.hostname):
|
if parsed.hostname in BLOCKED_SERVERS:
|
||||||
logger.warning(f"{parsed.hostname} is blocked")
|
logger.warning(f"{parsed.hostname} is blocked")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if parsed.hostname.endswith(".onion"):
|
|
||||||
logger.warning(f"{url} is an onion service")
|
|
||||||
return False
|
|
||||||
|
|
||||||
ip_address = _getaddrinfo(
|
ip_address = _getaddrinfo(
|
||||||
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
parsed.hostname, parsed.port or (80 if parsed.scheme == "http" else 443)
|
||||||
)
|
)
|
||||||
|
@ -88,11 +77,3 @@ def check_url(url: str) -> None:
|
||||||
raise InvalidURLError(f'"{url}" is invalid')
|
raise InvalidURLError(f'"{url}" is invalid')
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=256)
|
|
||||||
def is_hostname_blocked(hostname: str) -> bool:
|
|
||||||
for blocked_hostname in BLOCKED_SERVERS:
|
|
||||||
if hostname == blocked_hostname or hostname.endswith(f".{blocked_hostname}"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def get_version_commit() -> str:
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
|
|
||||||
.split()[0]
|
|
||||||
.decode()
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return "dev"
|
|
|
@ -24,7 +24,7 @@ async def _discover_webmention_endoint(url: str) -> str | None:
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except Exception:
|
except (httpx.HTTPError, httpx.HTTPStatusError):
|
||||||
logger.exception(f"Failed to discover webmention endpoint for {url}")
|
logger.exception(f"Failed to discover webmention endpoint for {url}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
@ -54,20 +54,15 @@ class Worker(Generic[T]):
|
||||||
{task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
{task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
logger.info(f"Waiting for tasks to finish {done=}/{pending=}")
|
logger.info(f"Waiting for tasks to finish {done=}/{pending=}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||||
logger.info(f"Cancelling {len(tasks)} tasks")
|
logger.info(f"Cancelling {len(tasks)} tasks")
|
||||||
[task.cancel() for task in tasks]
|
[task.cancel() for task in tasks]
|
||||||
|
|
||||||
try:
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
await asyncio.wait_for(
|
|
||||||
asyncio.gather(*tasks, return_exceptions=True),
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.info("Tasks failed to cancel")
|
|
||||||
|
|
||||||
logger.info("stopping loop")
|
logger.info("stopping loop")
|
||||||
|
|
||||||
async def _shutdown(self, sig: signal.Signals) -> None:
|
async def _shutdown(self, sig: signal.Signals) -> None:
|
||||||
logger.info(f"Caught {sig=}")
|
logger.info(f"Caught {signal=}")
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
from markdown import markdown # type: ignore
|
||||||
|
|
||||||
from app.key import generate_key
|
from app.key import generate_key
|
||||||
|
|
||||||
|
@ -43,7 +44,7 @@ def setup_config_file(
|
||||||
dat["username"] = username
|
dat["username"] = username
|
||||||
dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
dat["name"] = name
|
dat["name"] = name
|
||||||
dat["summary"] = summary
|
dat["summary"] = markdown(summary)
|
||||||
dat["https"] = True
|
dat["https"] = True
|
||||||
proto = "https"
|
proto = "https"
|
||||||
dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png'
|
dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png'
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
@ -9,85 +8,31 @@ from app import config
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
|
|
||||||
|
|
||||||
async def get_webfinger_via_host_meta(host: str) -> str | None:
|
|
||||||
resp: httpx.Response | None = None
|
|
||||||
is_404 = False
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
for i, proto in enumerate({"http", "https"}):
|
|
||||||
try:
|
|
||||||
url = f"{proto}://{host}/.well-known/host-meta"
|
|
||||||
check_url(url)
|
|
||||||
resp = await client.get(
|
|
||||||
url,
|
|
||||||
headers={
|
|
||||||
"User-Agent": config.USER_AGENT,
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
break
|
|
||||||
except httpx.HTTPStatusError as http_error:
|
|
||||||
logger.exception("HTTP error")
|
|
||||||
if http_error.response.status_code in [403, 404, 410]:
|
|
||||||
is_404 = True
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except httpx.HTTPError:
|
|
||||||
logger.exception("req failed")
|
|
||||||
# If we tried https first and the domain is "http only"
|
|
||||||
if i == 0:
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
|
|
||||||
if is_404:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if resp:
|
|
||||||
tree = ET.fromstring(resp.text)
|
|
||||||
maybe_link = tree.find(
|
|
||||||
"./{http://docs.oasis-open.org/ns/xri/xrd-1.0}Link[@rel='lrdd']"
|
|
||||||
)
|
|
||||||
if maybe_link is not None:
|
|
||||||
return maybe_link.attrib.get("template")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def webfinger(
|
async def webfinger(
|
||||||
resource: str,
|
resource: str,
|
||||||
webfinger_url: str | None = None,
|
|
||||||
) -> dict[str, Any] | None: # noqa: C901
|
) -> dict[str, Any] | None: # noqa: C901
|
||||||
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL."""
|
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL."""
|
||||||
resource = resource.strip()
|
|
||||||
logger.info(f"performing webfinger resolution for {resource}")
|
logger.info(f"performing webfinger resolution for {resource}")
|
||||||
urls = []
|
protos = ["https", "http"]
|
||||||
host = None
|
if resource.startswith("http://"):
|
||||||
if webfinger_url:
|
protos.reverse()
|
||||||
urls = [webfinger_url]
|
host = urlparse(resource).netloc
|
||||||
|
elif resource.startswith("https://"):
|
||||||
|
host = urlparse(resource).netloc
|
||||||
else:
|
else:
|
||||||
if resource.startswith("http://"):
|
if resource.startswith("acct:"):
|
||||||
host = urlparse(resource).netloc
|
resource = resource[5:]
|
||||||
url = f"http://{host}/.well-known/webfinger"
|
if resource.startswith("@"):
|
||||||
elif resource.startswith("https://"):
|
resource = resource[1:]
|
||||||
host = urlparse(resource).netloc
|
_, host = resource.split("@", 1)
|
||||||
url = f"https://{host}/.well-known/webfinger"
|
resource = "acct:" + resource
|
||||||
else:
|
|
||||||
protos = ["https", "http"]
|
|
||||||
_, host = resource.split("@", 1)
|
|
||||||
urls = [f"{proto}://{host}/.well-known/webfinger" for proto in protos]
|
|
||||||
|
|
||||||
if resource.startswith("acct:"):
|
|
||||||
resource = resource[5:]
|
|
||||||
if resource.startswith("@"):
|
|
||||||
resource = resource[1:]
|
|
||||||
resource = "acct:" + resource
|
|
||||||
|
|
||||||
is_404 = False
|
is_404 = False
|
||||||
|
|
||||||
resp: httpx.Response | None = None
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for i, url in enumerate(urls):
|
for i, proto in enumerate(protos):
|
||||||
try:
|
try:
|
||||||
|
url = f"{proto}://{host}/.well-known/webfinger"
|
||||||
check_url(url)
|
check_url(url)
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
url,
|
url,
|
||||||
|
@ -111,20 +56,10 @@ async def webfinger(
|
||||||
if i == 0:
|
if i == 0:
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
|
|
||||||
if is_404:
|
if is_404:
|
||||||
if not webfinger_url and host:
|
|
||||||
if webfinger_url := (await get_webfinger_via_host_meta(host)):
|
|
||||||
return await webfinger(
|
|
||||||
resource,
|
|
||||||
webfinger_url=webfinger_url,
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if resp:
|
return resp.json()
|
||||||
return resp.json()
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_remote_follow_template(resource: str) -> str | None:
|
async def get_remote_follow_template(resource: str) -> str | None:
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
@ -8,21 +6,13 @@ from fastapi import HTTPException
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import models
|
from app import models
|
||||||
from app.boxes import _get_outbox_announces_count
|
|
||||||
from app.boxes import _get_outbox_likes_count
|
|
||||||
from app.boxes import _get_outbox_replies_count
|
|
||||||
from app.boxes import get_outbox_object_by_ap_id
|
from app.boxes import get_outbox_object_by_ap_id
|
||||||
from app.boxes import get_outbox_object_by_slug_and_short_id
|
|
||||||
from app.boxes import is_notification_enabled
|
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.utils import microformats
|
from app.utils import microformats
|
||||||
from app.utils.facepile import Face
|
|
||||||
from app.utils.facepile import WebmentionReply
|
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
from app.utils.url import is_url_valid
|
from app.utils.url import is_url_valid
|
||||||
|
|
||||||
|
@ -57,7 +47,6 @@ async def webmention_endpoint(
|
||||||
|
|
||||||
check_url(source)
|
check_url(source)
|
||||||
check_url(target)
|
check_url(target)
|
||||||
parsed_target_url = urlparse(target)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Invalid webmention request")
|
logger.exception("Invalid webmention request")
|
||||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
@ -76,16 +65,6 @@ async def webmention_endpoint(
|
||||||
logger.info("Found existing Webmention, will try to update or delete")
|
logger.info("Found existing Webmention, will try to update or delete")
|
||||||
|
|
||||||
mentioned_object = await get_outbox_object_by_ap_id(db_session, target)
|
mentioned_object = await get_outbox_object_by_ap_id(db_session, target)
|
||||||
|
|
||||||
if not mentioned_object and parsed_target_url.path.startswith("/articles/"):
|
|
||||||
try:
|
|
||||||
_, _, short_id, slug = parsed_target_url.path.split("/")
|
|
||||||
mentioned_object = await get_outbox_object_by_slug_and_short_id(
|
|
||||||
db_session, slug, short_id
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to match {target}")
|
|
||||||
|
|
||||||
if not mentioned_object:
|
if not mentioned_object:
|
||||||
logger.info(f"Invalid target {target=}")
|
logger.info(f"Invalid target {target=}")
|
||||||
|
|
||||||
|
@ -111,21 +90,15 @@ async def webmention_endpoint(
|
||||||
logger.warning(f"target {target=} not found in source")
|
logger.warning(f"target {target=} not found in source")
|
||||||
if existing_webmention_in_db:
|
if existing_webmention_in_db:
|
||||||
logger.info("Deleting existing Webmention")
|
logger.info("Deleting existing Webmention")
|
||||||
|
mentioned_object.webmentions_count = mentioned_object.webmentions_count - 1
|
||||||
existing_webmention_in_db.is_deleted = True
|
existing_webmention_in_db.is_deleted = True
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
# Revert side effects
|
notif = models.Notification(
|
||||||
await _handle_webmention_side_effects(
|
notification_type=models.NotificationType.DELETED_WEBMENTION,
|
||||||
db_session, existing_webmention_in_db, mentioned_object
|
outbox_object_id=mentioned_object.id,
|
||||||
|
webmention_id=existing_webmention_in_db.id,
|
||||||
)
|
)
|
||||||
|
db_session.add(notif)
|
||||||
if is_notification_enabled(models.NotificationType.DELETED_WEBMENTION):
|
|
||||||
notif = models.Notification(
|
|
||||||
notification_type=models.NotificationType.DELETED_WEBMENTION,
|
|
||||||
outbox_object_id=mentioned_object.id,
|
|
||||||
webmention_id=existing_webmention_in_db.id,
|
|
||||||
)
|
|
||||||
db_session.add(notif)
|
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
|
@ -137,96 +110,36 @@ async def webmention_endpoint(
|
||||||
else:
|
else:
|
||||||
return JSONResponse(content={}, status_code=200)
|
return JSONResponse(content={}, status_code=200)
|
||||||
|
|
||||||
webmention_type = models.WebmentionType.UNKNOWN
|
|
||||||
webmention: models.Webmention
|
|
||||||
if existing_webmention_in_db:
|
if existing_webmention_in_db:
|
||||||
# Undelete if needed
|
# Undelete if needed
|
||||||
existing_webmention_in_db.is_deleted = False
|
existing_webmention_in_db.is_deleted = False
|
||||||
existing_webmention_in_db.source_microformats = data
|
existing_webmention_in_db.source_microformats = data
|
||||||
await db_session.flush()
|
|
||||||
webmention = existing_webmention_in_db
|
|
||||||
|
|
||||||
if is_notification_enabled(models.NotificationType.UPDATED_WEBMENTION):
|
notif = models.Notification(
|
||||||
notif = models.Notification(
|
notification_type=models.NotificationType.UPDATED_WEBMENTION,
|
||||||
notification_type=models.NotificationType.UPDATED_WEBMENTION,
|
outbox_object_id=mentioned_object.id,
|
||||||
outbox_object_id=mentioned_object.id,
|
webmention_id=existing_webmention_in_db.id,
|
||||||
webmention_id=existing_webmention_in_db.id,
|
)
|
||||||
)
|
db_session.add(notif)
|
||||||
db_session.add(notif)
|
|
||||||
else:
|
else:
|
||||||
new_webmention = models.Webmention(
|
new_webmention = models.Webmention(
|
||||||
source=source,
|
source=source,
|
||||||
target=target,
|
target=target,
|
||||||
source_microformats=data,
|
source_microformats=data,
|
||||||
outbox_object_id=mentioned_object.id,
|
outbox_object_id=mentioned_object.id,
|
||||||
webmention_type=webmention_type,
|
|
||||||
)
|
)
|
||||||
db_session.add(new_webmention)
|
db_session.add(new_webmention)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
webmention = new_webmention
|
|
||||||
|
|
||||||
if is_notification_enabled(models.NotificationType.NEW_WEBMENTION):
|
notif = models.Notification(
|
||||||
notif = models.Notification(
|
notification_type=models.NotificationType.NEW_WEBMENTION,
|
||||||
notification_type=models.NotificationType.NEW_WEBMENTION,
|
outbox_object_id=mentioned_object.id,
|
||||||
outbox_object_id=mentioned_object.id,
|
webmention_id=new_webmention.id,
|
||||||
webmention_id=new_webmention.id,
|
)
|
||||||
)
|
db_session.add(notif)
|
||||||
db_session.add(notif)
|
|
||||||
|
|
||||||
# Determine the webmention type
|
mentioned_object.webmentions_count = mentioned_object.webmentions_count + 1
|
||||||
for item in data.get("items", []):
|
|
||||||
if target in item.get("properties", {}).get(
|
|
||||||
"in-reply-to", []
|
|
||||||
) and WebmentionReply.from_webmention(webmention):
|
|
||||||
webmention_type = models.WebmentionType.REPLY
|
|
||||||
break
|
|
||||||
elif target in item.get("properties", {}).get(
|
|
||||||
"like-of", []
|
|
||||||
) and Face.from_webmention(webmention):
|
|
||||||
webmention_type = models.WebmentionType.LIKE
|
|
||||||
break
|
|
||||||
elif target in item.get("properties", {}).get(
|
|
||||||
"repost-of", []
|
|
||||||
) and Face.from_webmention(webmention):
|
|
||||||
webmention_type = models.WebmentionType.REPOST
|
|
||||||
break
|
|
||||||
|
|
||||||
if webmention_type != models.WebmentionType.UNKNOWN:
|
|
||||||
webmention.webmention_type = webmention_type
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
# Handle side effect
|
|
||||||
await _handle_webmention_side_effects(db_session, webmention, mentioned_object)
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
return JSONResponse(content={}, status_code=200)
|
return JSONResponse(content={}, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_webmention_side_effects(
|
|
||||||
db_session: AsyncSession,
|
|
||||||
webmention: models.Webmention,
|
|
||||||
mentioned_object: models.OutboxObject,
|
|
||||||
) -> None:
|
|
||||||
if webmention.webmention_type == models.WebmentionType.UNKNOWN:
|
|
||||||
# TODO: recount everything
|
|
||||||
mentioned_object.webmentions_count = await db_session.scalar(
|
|
||||||
select(func.count(models.Webmention.id)).where(
|
|
||||||
models.Webmention.is_deleted.is_(False),
|
|
||||||
models.Webmention.outbox_object_id == mentioned_object.id,
|
|
||||||
models.Webmention.webmention_type == models.WebmentionType.UNKNOWN,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif webmention.webmention_type == models.WebmentionType.LIKE:
|
|
||||||
mentioned_object.likes_count = await _get_outbox_likes_count(
|
|
||||||
db_session, mentioned_object
|
|
||||||
)
|
|
||||||
elif webmention.webmention_type == models.WebmentionType.REPOST:
|
|
||||||
mentioned_object.announces_count = await _get_outbox_announces_count(
|
|
||||||
db_session, mentioned_object
|
|
||||||
)
|
|
||||||
elif webmention.webmention_type == models.WebmentionType.REPLY:
|
|
||||||
mentioned_object.replies_count = await _get_outbox_replies_count(
|
|
||||||
db_session, mentioned_object
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unhandled {webmention.webmention_type} webmention")
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
../../app/templates/
|
|
|
@ -5,7 +5,6 @@ admin_password = "$2b$12$OwCyZM33uXQUVrChgER.h.qgFJ4fBp6tdFwArR3Lm1LV8NgMvIxVa"
|
||||||
name = "test"
|
name = "test"
|
||||||
summary = "<p>Hello</p>"
|
summary = "<p>Hello</p>"
|
||||||
https = false
|
https = false
|
||||||
id = "http://localhost:8000"
|
|
||||||
icon_url = "https://localhost:8000/static/nopic.png"
|
icon_url = "https://localhost:8000/static/nopic.png"
|
||||||
secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
|
secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
|
||||||
debug = true
|
debug = true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# Developer's guide
|
# Developer's guide
|
||||||
|
|
||||||
This guide assumes you have some knowledge of [ActivityPub](https://activitypub.rocks/).
|
This guide assume you have some knoweldge of [ActivityPub](https://activitypub.rocks/).
|
||||||
|
|
||||||
[TOC]
|
[TOC]
|
||||||
|
|
||||||
|
@ -10,13 +10,12 @@ Microblog.pub is a "modern" Python application with "old-school" server-rendered
|
||||||
|
|
||||||
- [Poetry](https://python-poetry.org/) is used for dependency management.
|
- [Poetry](https://python-poetry.org/) is used for dependency management.
|
||||||
- Most of the code is asynchronous, using [asyncio](https://docs.python.org/3/library/asyncio.html).
|
- Most of the code is asynchronous, using [asyncio](https://docs.python.org/3/library/asyncio.html).
|
||||||
- SQLite3 for data storage
|
- SQLite3 is the default database.
|
||||||
|
|
||||||
The server has 3 components:
|
The server has 2 components:
|
||||||
|
|
||||||
- The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates)
|
- The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates)
|
||||||
- One process that takes care of sending "outgoing activities"
|
- An additional process that takes care of sending "outgoing actities"
|
||||||
- One process that takes care of processing "incoming activities"
|
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
|
@ -30,7 +29,7 @@ inv -l
|
||||||
|
|
||||||
### Media storage
|
### Media storage
|
||||||
|
|
||||||
The uploads are stored in the `data/` directory, using a simple content-addressed storage system (file contents hash is BLOB filename).
|
The uploads are stored in the `data/` directory, using a simple content-addressed storage (file contents hash is the name of the store BLOB).
|
||||||
Files metadata are stored in the database.
|
Files metadata are stored in the database.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
@ -58,24 +57,3 @@ And check out the result by starting a static server using Python standard libra
|
||||||
cd docs/dist
|
cd docs/dist
|
||||||
python -m http.server 8001
|
python -m http.server 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions/patches are welcome, but please start a discussion in a [ticket](https://todo.sr.ht/~tsileo/microblog.pub) or a [thread in the mailing list](https://lists.sr.ht/~tsileo/microblog.pub-devel) before working on anything consequent.
|
|
||||||
|
|
||||||
### Patches
|
|
||||||
|
|
||||||
Please ensure your code passes the code quality checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
inv autoformat
|
|
||||||
inv lint
|
|
||||||
```
|
|
||||||
|
|
||||||
And that the tests suite is passing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
inv tests
|
|
||||||
```
|
|
||||||
|
|
||||||
Please also consider adding new test cases if needed.
|
|
||||||
|
|
112
docs/install.md
112
docs/install.md
|
@ -11,7 +11,7 @@ For now, there's no image published on Docker Hub, this means you will have to b
|
||||||
Clone the repository, replace `you-domain.tld` by your own domain.
|
Clone the repository, replace `you-domain.tld` by your own domain.
|
||||||
|
|
||||||
Note that if you want to serve static assets via your reverse proxy (like nginx), clone it in a place
|
Note that if you want to serve static assets via your reverse proxy (like nginx), clone it in a place
|
||||||
where it is accessible by your reverse proxy user.
|
where accessible by your reverse proxy user.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld
|
git clone https://git.sr.ht/~tsileo/microblog.pub your-domain.tld
|
||||||
|
@ -55,12 +55,6 @@ docker compose stop
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
As you probably already know, Docker can (and will) eat a lot of disk space, when updating you should [prune old images](https://docs.docker.com/config/pruning/#prune-images) from time to time:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker image prune -a --filter "until=24h"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Python developer edition
|
## Python developer edition
|
||||||
|
|
||||||
Assuming you have a working **Python 3.10+** environment.
|
Assuming you have a working **Python 3.10+** environment.
|
||||||
|
@ -89,12 +83,6 @@ Setup config.
|
||||||
poetry run inv configuration-wizard
|
poetry run inv configuration-wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
Setup the database.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run inv migrate-db
|
|
||||||
```
|
|
||||||
|
|
||||||
Grab your virtualenv path.
|
Grab your virtualenv path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -111,7 +99,7 @@ Setup a reverse proxy (see the next section).
|
||||||
|
|
||||||
### Updating
|
### Updating
|
||||||
|
|
||||||
To update microblogpub locally, pull the remote changes and run the `update` task to regenerate the CSS and run any DB migrations.
|
To update microblogpub locally, pull the remote changes and run the `update` task to regeneratee the CSS and run any DB migrations.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
|
@ -142,11 +130,6 @@ server {
|
||||||
# [...]
|
# [...]
|
||||||
}
|
}
|
||||||
|
|
||||||
# This should be outside the `server` block
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionally, you can serve static files using NGINX directly, with an additional `location` block.
|
Optionally, you can serve static files using NGINX directly, with an additional `location` block.
|
||||||
|
@ -164,99 +147,8 @@ server {
|
||||||
# path for static files
|
# path for static files
|
||||||
rewrite ^/static/(.*) /$1 break;
|
rewrite ^/static/(.*) /$1 break;
|
||||||
root /path/to/your-domain.tld/app/static/;
|
root /path/to/your-domain.tld/app/static/;
|
||||||
expires 1y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# [...]
|
# [...]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### NGINX config tips
|
|
||||||
|
|
||||||
Enable HTTP2 (which is disabled by default):
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
# [...]
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Tweak `/etc/nginx/nginx.conf` and add gzip compression for ActivityPub responses:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
http {
|
|
||||||
# [...]
|
|
||||||
gzip_types text/plain text/css application/json application/javascript application/activity+json application/octet-stream;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## (Advanced) Running on a subdomain
|
|
||||||
|
|
||||||
It is possible to run microblogpub on a subdomain (`sub.domain.tld`) while being reachable from the root root domain (`domain.tld`) using the `name@domain.tld` handle.
|
|
||||||
|
|
||||||
This requires forwarding/proxying requests from the root domain to the subdomain, for example using NGINX:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location /.well-known/webfinger {
|
|
||||||
add_header Access-Control-Allow-Origin '*';
|
|
||||||
return 301 https://sub.domain.tld$request_uri;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And updating `data/profile.toml` to specify the root domain as the webfinger domain:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
webfinger_domain = "domain.tld"
|
|
||||||
```
|
|
||||||
|
|
||||||
Once configured correctly, people will be able to follow you using `name@domain.tld`, while using `sub.domain.tld` for the web interface.
|
|
||||||
|
|
||||||
|
|
||||||
## (Advanced) Running from subpath
|
|
||||||
|
|
||||||
It is possible to configure microblogpub to run from subpath.
|
|
||||||
To achieve this, do the following configuration _between_ config and start steps.
|
|
||||||
i.e. _after_ you run `make config` or `poetry run inv configuration-wizard`,
|
|
||||||
but _before_ you run `docker compose up` or `poetry run supervisord`.
|
|
||||||
Changing this settings on an instance which has some posts or was seen by other instances will likely break links to these posts or federation (i.e. links to your instance, posts and profile from other instances).
|
|
||||||
|
|
||||||
The following steps will explain how to configure instance to be available at `https://example.com/subdir`.
|
|
||||||
Change them to your actual domain and subdir.
|
|
||||||
|
|
||||||
* Edit `data/profile.toml` file, add this line:
|
|
||||||
|
|
||||||
id = "https://example.com/subdir"
|
|
||||||
|
|
||||||
* Edit `misc/*-supervisord.conf` file which is relevant to you (it depends on how you start microblogpub - if in doubt, do the same change in all of them) - in `[program:uvicorn]` section, in the line which starts with `command`, add this argument at the very end: ` --root-path /subdir`
|
|
||||||
|
|
||||||
Above two steps are enough to configure microblogpub.
|
|
||||||
Next, you also need to configure reverse proxy.
|
|
||||||
It might slightly differ if you plan to have other services running on the same domain, but for [NGINX config shown above](#reverse-proxy), the following changes are enough:
|
|
||||||
|
|
||||||
* Add subdir to location, so location block starts like this:
|
|
||||||
|
|
||||||
location /subdir {
|
|
||||||
|
|
||||||
* Add `/` at the end of `proxy_pass` directive, like this:
|
|
||||||
|
|
||||||
proxy_pass http://localhost:8000/;
|
|
||||||
|
|
||||||
These two changes will instruct NGINX that requests sent to `https://example.com/subdir/...` should be forwarded to `http://localhost:8000/...`.
|
|
||||||
|
|
||||||
* Inside `server` block, add redirects for well-known URLs (add these lines after `client_max_body_size`, remember to replace `subdir` with your actual subdir!):
|
|
||||||
|
|
||||||
location /.well-known/webfinger { return 301 /subdir$request_uri; }
|
|
||||||
location /.well-known/nodeinfo { return 301 /subdir$request_uri; }
|
|
||||||
location /.well-known/oauth-authorization-server { return 301 /subdir$request_uri; }
|
|
||||||
|
|
||||||
* Optionally, [check robots.txt from a running microblogpub instance](https://microblog.pub/robots.txt) and integrate it into robots.txt file in the root of your server - remember to prepend `subdir` to URLs, so for example `Disallow: /admin` becomes `Disallow: /subdir/admin`.
|
|
||||||
|
|
||||||
## YunoHost edition
|
|
||||||
|
|
||||||
[YunoHost](https://yunohost.org/) support is available (although it is not an official package for now): <https://git.sr.ht/~tsileo/microblog.pub_ynh>.
|
|
||||||
|
|
||||||
## Available tutorial/guides
|
|
||||||
|
|
||||||
- [Opalstack](https://community.opalstack.com/d/1055-howto-install-and-run-microblogpub-on-opalstack), thanks to [@defulmere@mastodon.social](https://mastodon.online/@defulmere).
|
|
||||||
|
|
2
docs/templates/layout.html
vendored
2
docs/templates/layout.html
vendored
|
@ -63,7 +63,7 @@ nav a:hover, main a:hover, header p a:hover {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 50px auto;
|
margin: 50px auto;
|
||||||
}
|
}
|
||||||
pre {
|
pre code {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -25,138 +25,33 @@ As these two config items define your ActivityPub handle `@handle@domain`.
|
||||||
|
|
||||||
You can tweak your profile by tweaking these items:
|
You can tweak your profile by tweaking these items:
|
||||||
|
|
||||||
- `name`: The name shown with your profile.
|
- `name`
|
||||||
- `summary`: The summary or 'bio' part of your profile, written in Markdown.
|
- `summary` (using Markdown)
|
||||||
- `icon_url`: Your profile image or avatar.
|
- `icon_url`
|
||||||
- `image_url`: This provides a 'header' or 'banner' image. Note that it is not shown by the default Microblog.pub templates. It will be used by Mastodon (which uses a 3:1 ratio image) and Pleroma. Pixelfed and Peertube, for example, don't show these images by default.
|
|
||||||
|
|
||||||
Whenever one of these config items is updated, an `Update` activity will be sent to all known servers to update your remote profile.
|
Whenever one of these config items is updated, an `Update` activity will be sent to all know server to update your remote profile.
|
||||||
|
|
||||||
The server will need to be restarted for taking changes into account.
|
|
||||||
|
|
||||||
Before restarting the server, you can ensure you haven't made any mistakes by running the [configuration checking task](/user_guide.html#configuration-checking).
|
|
||||||
|
|
||||||
Note that currently `image_url` is not used anywhere in microblog.pub itself, but other clients/servers do occasionally use it when showing remote profiles as a background image.
|
|
||||||
Also, this image _can_ be used in microblog.pub - just add this:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<img src="{{ local_actor.image_url | media_proxy_url }}">
|
|
||||||
```
|
|
||||||
|
|
||||||
to an appropriate place of your template (most likely, `header.html`).
|
|
||||||
For more information, see a section about [custom templates](/user_guide.html#custom-templates) further in this document.
|
|
||||||
|
|
||||||
### Profile metadata
|
|
||||||
|
|
||||||
You can add metadata to your profile with the `metadata` config item.
|
|
||||||
|
|
||||||
Markdown is supported in the `value` field.
|
|
||||||
|
|
||||||
Be aware that most other software like Mastodon will limit the number of key/value to 4.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
metadata = [
|
|
||||||
{key = "Documentation", value = "[https://docs.microblog.pub](https://docs.microblog.pub)"},
|
|
||||||
{key = "Source code", value = "[https://sr.ht/~tsileo/microblog.pub/](https://sr.ht/~tsileo/microblog.pub/)"},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manually approving followers
|
|
||||||
|
|
||||||
If you wish to manually approve followers, add this config item to `profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
manually_approves_followers = true
|
|
||||||
```
|
|
||||||
|
|
||||||
The default value is `false`.
|
|
||||||
|
|
||||||
### Hiding followers
|
|
||||||
|
|
||||||
If you wish to hide your followers, add this config item to `profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
hides_followers = true
|
|
||||||
```
|
|
||||||
|
|
||||||
The default value is `false`.
|
|
||||||
|
|
||||||
### Hiding who you are following
|
|
||||||
|
|
||||||
If you wish to hide who you are following, add this config item to `profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
hides_following = true
|
|
||||||
```
|
|
||||||
|
|
||||||
The default value is `false`.
|
|
||||||
|
|
||||||
### Privacy replace
|
### Privacy replace
|
||||||
|
|
||||||
You can define domains to be rewritten to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
|
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
|
||||||
or [Nitter](https://nitter.net/about).
|
or [Nitter](https://nitter.net/about).
|
||||||
|
|
||||||
To do so, add these extra config items. This is a sample config that rewrite URLs for Twitter, Youtube, Reddit and Medium:
|
To do so, just add as these extra config items, this is a sample config that rewrite URLs for Twitter, Youtube, Reddit and Medium:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
privacy_replace = [
|
privacy_replace = [
|
||||||
{domain = "youtube.com", replace_by = "yewtu.be"},
|
{domain = "youtube.com", replace_by = "yewtu.be"},
|
||||||
{domain = "youtu.be", replace_by = "yewtu.be"},
|
|
||||||
{domain = "twitter.com", replace_by = "nitter.fdn.fr"},
|
{domain = "twitter.com", replace_by = "nitter.fdn.fr"},
|
||||||
{domain = "medium.com", replace_by = "scribe.rip"},
|
{domain = "medium.com", replace_by = "scribe.rip"},
|
||||||
{domain = "reddit.com", replace_by = "teddit.net"},
|
{domain = "reddit.com", replace_by = "teddit.net"},
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Disabling certain notification types
|
|
||||||
|
|
||||||
All notifications are enabled by default.
|
|
||||||
|
|
||||||
You can disabled specific notifications by adding them to the `disabled_notifications` list.
|
|
||||||
|
|
||||||
This example disables likes and shares notifications:
|
|
||||||
|
|
||||||
```
|
|
||||||
disabled_notifications = ["like", "announce"]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Available notification types
|
|
||||||
|
|
||||||
- `new_follower`
|
|
||||||
- `rejected_follower`
|
|
||||||
- `unfollow`
|
|
||||||
- `follow_request_accepted`
|
|
||||||
- `follow_request_rejected`
|
|
||||||
- `move`
|
|
||||||
- `like`
|
|
||||||
- `undo_like`
|
|
||||||
- `announce`
|
|
||||||
- `undo_announce`
|
|
||||||
- `mention`
|
|
||||||
- `new_webmention`
|
|
||||||
- `updated_webmention`
|
|
||||||
- `deleted_webmention`
|
|
||||||
- `blocked`
|
|
||||||
- `unblocked`
|
|
||||||
- `block`
|
|
||||||
- `unblock`
|
|
||||||
|
|
||||||
### Customization
|
### Customization
|
||||||
|
|
||||||
#### Default emoji
|
|
||||||
|
|
||||||
If you don't like cats, or need more emoji, you can add your favorite emoji in `profile.toml` and it will replace the default ones:
|
|
||||||
|
|
||||||
```
|
|
||||||
emoji = "🙂🐹📌"
|
|
||||||
```
|
|
||||||
|
|
||||||
You can copy/paste them from [getemoji.com](https://getemoji.com/).
|
|
||||||
|
|
||||||
#### Custom emoji
|
#### Custom emoji
|
||||||
|
|
||||||
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
|
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
|
||||||
Do not use exotic characters in filename - only letters, numbers, and underscore symbol `_` are allowed.
|
|
||||||
|
|
||||||
#### Custom CSS
|
#### Custom CSS
|
||||||
|
|
||||||
|
@ -169,82 +64,19 @@ $primary-color: #e14eea;
|
||||||
$secondary-color: #32cd32;
|
$secondary-color: #32cd32;
|
||||||
```
|
```
|
||||||
|
|
||||||
See `app/scss/main.scss` to see what variables can be overridden.
|
See `app/scss/main.scss` to see what variables can be overidden.
|
||||||
|
|
||||||
You will need to [recompile CSS](#recompiling-css-files) after doing any CSS changes (for actual css files to be updates) and restart microblog.pub (for css link in HTML documents to be updated with a new checksum - otherwise, browsers that downloaded old CSS will keep using it).
|
|
||||||
|
|
||||||
#### Custom favicon
|
|
||||||
|
|
||||||
By default, microblog.pub favicon is a square of `$primary-color` CSS color (see above section on how to redefine CSS colors).
|
|
||||||
You can change it to any icon you like - just save a desired file as `data/favicon.ico`.
|
|
||||||
After that, run the "[recompile CSS](#recompiling-css-files)" task to copy it to `app/static/favicon.ico`.
|
|
||||||
|
|
||||||
#### Custom templates
|
|
||||||
|
|
||||||
If you'd like to customize your instance's theme beyond CSS, you can modify the app's HTML by placing templates in `data/templates` which overwrite the defaults in `app/templates`.
|
|
||||||
|
|
||||||
Templates are written using [Jinja](https://jinja.palletsprojects.com/en/latest/templates/) templating language.
|
|
||||||
Moreover, `utils.html` has scoped blocks around the body of every macro.
|
|
||||||
This allows macros to be overridden individually in `data/templates/utils.html`, without copying the whole file.
|
|
||||||
For example, to only override the display of a specific actor's name/icon, you can create `data/templates/utils.html` file with following content:
|
|
||||||
|
|
||||||
```jinja
|
|
||||||
{% extends "app/utils.html" %}
|
|
||||||
|
|
||||||
{% block display_actor %}
|
|
||||||
{% if actor.ap_id == "https://me.example.com" %}
|
|
||||||
<!-- custom actor display -->
|
|
||||||
{% else %}
|
|
||||||
{{ super() }}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Custom Content Security Policy (CSP)
|
|
||||||
|
|
||||||
You can override the default Content Security Policy by adding a line in `data/profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
custom_content_security_policy = "default-src 'self'; style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
|
||||||
```
|
|
||||||
|
|
||||||
This example will output the default CSP, note that `{HIGHLIGHT_CSS_HASH}` will be dynamically replaced by the correct value (the hash of the CSS needed for syntax highlighting).
|
|
||||||
|
|
||||||
#### Code highlighting theme
|
|
||||||
|
|
||||||
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `data/profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
code_highlighting_theme = "solarized-dark"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Blocking servers
|
|
||||||
|
|
||||||
In addition to blocking "single actors" via the admin interface, you can also prevent any communication with entire servers.
|
|
||||||
|
|
||||||
Add a `blocked_servers` config item into `profile.toml`.
|
|
||||||
|
|
||||||
The `reason` field is just there to help you document/remember why a server was blocked.
|
|
||||||
|
|
||||||
You should unfollow any account from a server before blocking it.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
blocked_servers = [
|
|
||||||
{hostname = "bad.tld", reason = "Bot spam"},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Public website
|
## Public website
|
||||||
|
|
||||||
Public notes will be visible on the homepage.
|
Public notes will be visible on the homepage.
|
||||||
|
|
||||||
Only the last 20 followers/follows you have will be shown on the public website.
|
Only the last 20 followers/follows you be showing on the public website.
|
||||||
|
|
||||||
And only the last 20 interactions (likes/shares/webmentions) will be displayed, to keep things simple/clean.
|
And only the last 20 interactions (likes/shares/webmentions) will be displayed, to keep things simple/clean.
|
||||||
|
|
||||||
## Admin section
|
## Admin section
|
||||||
|
|
||||||
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin/login`.
|
You can login to the admin section by clicking on the `Admin` link in the footer or by visiting `https://yourdomain.tld/admin`.
|
||||||
The password is the one set during the initial configuration.
|
The password is the one set during the initial configuration.
|
||||||
|
|
||||||
### Lookup
|
### Lookup
|
||||||
|
@ -297,36 +129,35 @@ microblog.pub supports the most common interactions supported by the Fediverse.
|
||||||
|
|
||||||
### Shares
|
### Shares
|
||||||
|
|
||||||
Sharing (or announcing) an object will relay it to your followers and notify the author.
|
Sharing an object will relay it to your followers and notify the author.
|
||||||
It will also be displayed on the homepage.
|
It will also be displayed on the homepage.
|
||||||
|
|
||||||
Most receiving servers will increment the number of shares.
|
Most receiving servers will increment the number of shares.
|
||||||
|
|
||||||
Receiving a share will trigger a notification, increment the shares counter on the object and the actor avatar will be displayed on the object permalink.
|
TODO receiving
|
||||||
|
|
||||||
### Likes
|
### Likes
|
||||||
|
|
||||||
Liking an object will notify the author.
|
Liking an object will notify the author.
|
||||||
|
Unkike sharing, liked object are not displayed on the homepage.
|
||||||
Unlike sharing, liked objects are not displayed on the homepage.
|
|
||||||
|
|
||||||
Most receiving servers will increment the number of likes.
|
Most receiving servers will increment the number of likes.
|
||||||
|
|
||||||
Receiving a like will trigger a notification, increment the likes counter on the object and the actor avatar will be displayed on the object permalink.
|
TODO receiving
|
||||||
|
|
||||||
### Bookmarks
|
### Bookmarks
|
||||||
|
|
||||||
Bookmarks allow you to like objects without notifying the author.
|
Bookmarks allow you to like objects without notifying the author.
|
||||||
|
|
||||||
It is basically a "private like", and allows you to easily access them later.
|
It is basically a "private like", and allow you to easily access them later.
|
||||||
|
|
||||||
It will also prevent objects to be pruned.
|
TODO receiving
|
||||||
|
|
||||||
### Webmentions
|
### Webmentions
|
||||||
|
|
||||||
Sending webmentions to ping mentioned websites is done automatically once a public note is authored.
|
Sending webmention to ping mentioned website is done automatically once a note is authored, see TODO.
|
||||||
|
|
||||||
Receiving a webmention will trigger a notification, increment the webmentions counter on the object and the source page will be displayed on the object permalink.
|
TODO side-effect of receiving a webmention.
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
|
@ -340,225 +171,3 @@ All the data generated by the server is located in the `data/` directory:
|
||||||
- Uploaded media
|
- Uploaded media
|
||||||
|
|
||||||
Restoring is as easy as adding your backed up `data/` directory into a fresh deployment.
|
Restoring is as easy as adding your backed up `data/` directory into a fresh deployment.
|
||||||
|
|
||||||
## Moving from another instance
|
|
||||||
|
|
||||||
If you want to move followers from your existing account, ensure it is supported in your software documentation.
|
|
||||||
|
|
||||||
For [Mastodon you can look at Moving or leaving accounts](https://docs.joinmastodon.org/user/moving/).
|
|
||||||
|
|
||||||
If you wish to move **to** another instance, see [Moving to another instance](/user_guide.html#moving-to-another-instance).
|
|
||||||
|
|
||||||
First you need to grab the "ActivityPub actor URL" for your existing account:
|
|
||||||
|
|
||||||
### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Python install
|
|
||||||
poetry run inv webfinger username@instance-you-want-to-move-from.tld
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit the config.
|
|
||||||
|
|
||||||
### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Docker install
|
|
||||||
make account=username@instance-you-want-to-move-from.tld webfinger
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit the config.
|
|
||||||
|
|
||||||
### Edit the config
|
|
||||||
|
|
||||||
And add a reference to your old/existing account in `profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
also_known_as = "https://instance-you-want-to-move-form.tld/users/username"
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart the server, and you should be able to complete the move from your existing account.
|
|
||||||
|
|
||||||
Note that if you already have a redirect in place on Mastodon, you may have to remove it before initiating the migration.
|
|
||||||
|
|
||||||
## Import follows from Mastodon
|
|
||||||
|
|
||||||
You can import the list of follows/following accounts from Mastodon.
|
|
||||||
|
|
||||||
It requires downloading the "Follows" CSV file from your Mastodon instance via "Settings" / "Import and export" / "Data export".
|
|
||||||
|
|
||||||
Then you need to run the import task:
|
|
||||||
|
|
||||||
### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Python install
|
|
||||||
poetry run inv import-mastodon-following-accounts following_accounts.csv
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Docker install
|
|
||||||
make path=following_accounts.csv import-mastodon-following-accounts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### Configuration checking
|
|
||||||
|
|
||||||
You can confirm that your configuration file (`data/profile.toml`) is valid using the `check-config`
|
|
||||||
|
|
||||||
#### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run inv check-config
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make check-config
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recompiling CSS files
|
|
||||||
|
|
||||||
You can ensure your custom theme is valid by recompiling the CSS manually using the `compile-scss` task.
|
|
||||||
|
|
||||||
#### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run inv compile-scss
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make compile-scss
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Password reset
|
|
||||||
|
|
||||||
If have lost your password, you can generate a new one using the `reset-password` task.
|
|
||||||
|
|
||||||
#### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# shutdown supervisord
|
|
||||||
poetry run inv reset-password
|
|
||||||
# edit data/profile.toml
|
|
||||||
# restart supervisord
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop
|
|
||||||
make reset-password
|
|
||||||
# edit data/profile.toml
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pruning old data
|
|
||||||
|
|
||||||
You should prune old data from time to time to free disk space.
|
|
||||||
|
|
||||||
The default retention for the inbox data is 15 days.
|
|
||||||
|
|
||||||
It's configurable via the `inbox_retention_days` config item in `profile.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
inbox_retention_days = 30
|
|
||||||
```
|
|
||||||
|
|
||||||
Data owned by the server will never be deleted (at least for now), along with:
|
|
||||||
|
|
||||||
- bookmarked objects
|
|
||||||
- liked objects
|
|
||||||
- shared objects
|
|
||||||
- inbox objects mentioning the local actor
|
|
||||||
- objects related to local conversations (i.e. direct messages, replies)
|
|
||||||
|
|
||||||
For now, it's recommended to make a backup before running the task in case it deletes unwanted data.
|
|
||||||
|
|
||||||
You should shutdown the server before running the task.
|
|
||||||
|
|
||||||
#### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# shutdown supervisord
|
|
||||||
cp -r data/microblogpub.db data/microblogpub.db.bak
|
|
||||||
poetry run inv prune-old-data
|
|
||||||
# relaunch supervisord and ensure it works as expected
|
|
||||||
rm data/microblogpub.db.bak
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop
|
|
||||||
cp -r data/microblogpub.db data/microblogpub.db.bak
|
|
||||||
make prune-old-data
|
|
||||||
docker compose up -d
|
|
||||||
rm data/microblogpub.db.bak
|
|
||||||
```
|
|
||||||
|
|
||||||
### Moving to another instance
|
|
||||||
|
|
||||||
If you want to migrate to another instance, you have the ability to move your existing followers to your new account.
|
|
||||||
|
|
||||||
Your new account should reference the existing one, refer to your software configuration (for example [Moving or leaving accounts from the Mastodon doc](https://docs.joinmastodon.org/user/moving/)).
|
|
||||||
|
|
||||||
If you wish to move **from** another instance, see [Moving from another instance](/user_guide.html#moving-from-another-instance).
|
|
||||||
|
|
||||||
Execute the Move task:
|
|
||||||
|
|
||||||
#### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Python install
|
|
||||||
poetry run inv move-to username@domain.tld
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Docker install
|
|
||||||
make account=username@domain.tld move-to
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deleting the instance
|
|
||||||
|
|
||||||
If you want to delete your instance, you can request other instances to delete your remote profile.
|
|
||||||
|
|
||||||
Note that this is a best-effort delete as some instances may not delete your data.
|
|
||||||
|
|
||||||
The command won't remove any local data, it just broadcasts account deletion messages to all known servers.
|
|
||||||
|
|
||||||
After executing the command, you should let the server run until all the outgoing delete tasks are sent.
|
|
||||||
|
|
||||||
Once deleted, you won't be able to use your instance anymore, but you will be able to perform a fresh re-install of any ActivityPub software.
|
|
||||||
|
|
||||||
#### Python edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Python install
|
|
||||||
poetry run inv self-destruct
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker edition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For a Docker install
|
|
||||||
make self-destruct
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
If the server is not (re)starting, you can:
|
|
||||||
|
|
||||||
- [Ensure that the configuration is valid](/user_guide.html#configuration-checking).
|
|
||||||
- [Verify if you haven't any syntax error in the custom theme by recompiling the CSS](/user_guide.html#recompiling-css-files).
|
|
||||||
- Look at the log files (in `data/uvicorn.log`, `data/incoming.log` and `data/outgoing.log`).
|
|
||||||
- If the CSS is not working, ensure your reverse proxy is serving the static file correctly.
|
|
||||||
|
|
|
@ -4,10 +4,11 @@ logfile=/dev/null
|
||||||
logfile_maxbytes=0
|
logfile_maxbytes=0
|
||||||
pidfile=data/supervisord.pid
|
pidfile=data/supervisord.pid
|
||||||
|
|
||||||
[program:uvicorn]
|
[fcgi-program:uvicorn]
|
||||||
command=uvicorn app.main:app --no-server-header --host 0.0.0.0
|
socket=tcp://0.0.0.0:8000
|
||||||
numprocs=1
|
command=uvicorn app.main:app --no-server-header --fd 0
|
||||||
autorestart=true
|
numprocs=2
|
||||||
|
process_name=uvicorn-%(process_num)d
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=data/uvicorn.log
|
stdout_logfile=data/uvicorn.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=50MB
|
||||||
|
@ -15,7 +16,6 @@ stdout_logfile_maxbytes=50MB
|
||||||
[program:incoming_worker]
|
[program:incoming_worker]
|
||||||
command=inv process-incoming-activities
|
command=inv process-incoming-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
autorestart=true
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=data/incoming.log
|
stdout_logfile=data/incoming.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=50MB
|
||||||
|
@ -23,7 +23,6 @@ stdout_logfile_maxbytes=50MB
|
||||||
[program:outgoing_worker]
|
[program:outgoing_worker]
|
||||||
command=inv process-outgoing-activities
|
command=inv process-outgoing-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
autorestart=true
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=data/outgoing.log
|
stdout_logfile=data/outgoing.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=50MB
|
||||||
|
|
|
@ -1,25 +1,24 @@
|
||||||
[supervisord]
|
[supervisord]
|
||||||
|
|
||||||
[program:uvicorn]
|
[fcgi-program:uvicorn]
|
||||||
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
|
socket=tcp://localhost:8000
|
||||||
numprocs=1
|
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0
|
||||||
autorestart=true
|
numprocs=2
|
||||||
|
process_name=uvicorn-%(process_num)d
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=uvicorn.log
|
stdout_logfile=uvicorn.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:incoming_worker]
|
[program:incoming_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
autorestart=true
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=incoming_worker.log
|
stdout_logfile=incoming_worker.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:outgoing_worker]
|
[program:outgoing_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
autorestart=true
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=outgoing_worker.log
|
stdout_logfile=outgoing_worker.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=0
|
||||||
|
|
|
@ -1,26 +1,24 @@
|
||||||
[supervisord]
|
[supervisord]
|
||||||
|
|
||||||
[program:uvicorn]
|
[fcgi-program:uvicorn]
|
||||||
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
|
socket=tcp://localhost:%(ENV_UVICORN_PORT)s
|
||||||
numprocs=1
|
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0
|
||||||
autorestart=true
|
numprocs=2
|
||||||
process_name=uvicorn-%(process_num)d
|
process_name=uvicorn-%(process_num)d
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=%(ENV_LOG_PATH)s/uvicorn.log
|
stdout_logfile=uvicorn.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:incoming_worker]
|
[program:incoming_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
autorestart=true
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=%(ENV_LOG_PATH)s/incoming.log
|
stdout_logfile=incoming_worker.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:outgoing_worker]
|
[program:outgoing_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
autorestart=true
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=%(ENV_LOG_PATH)s/outgoing.log
|
stdout_logfile=outgoing_worker.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
|
|
3709
poetry.lock
generated
3709
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -9,15 +9,17 @@ license = "AGPL-3.0"
|
||||||
python = "^3.10"
|
python = "^3.10"
|
||||||
Jinja2 = "^3.1.2"
|
Jinja2 = "^3.1.2"
|
||||||
fastapi = "^0.78.0"
|
fastapi = "^0.78.0"
|
||||||
|
uvicorn = "^0.17.6"
|
||||||
pycryptodome = "^3.14.1"
|
pycryptodome = "^3.14.1"
|
||||||
bcrypt = "^3.2.2"
|
bcrypt = "^3.2.2"
|
||||||
itsdangerous = "^2.1.2"
|
itsdangerous = "^2.1.2"
|
||||||
python-multipart = "^0.0.5"
|
python-multipart = "^0.0.5"
|
||||||
tomli = "^2.0.1"
|
tomli = "^2.0.1"
|
||||||
httpx = {version = "0.23.0", extras = ["http2"]}
|
httpx = {extras = ["http2"], version = "^0.23.0"}
|
||||||
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
|
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.39"}
|
||||||
alembic = "^1.8.0"
|
alembic = "^1.8.0"
|
||||||
bleach = "^5.0.0"
|
bleach = "^5.0.0"
|
||||||
|
Markdown = "^3.3.7"
|
||||||
prompt-toolkit = "^3.0.29"
|
prompt-toolkit = "^3.0.29"
|
||||||
tomli-w = "^1.0.0"
|
tomli-w = "^1.0.0"
|
||||||
python-dateutil = "^2.8.2"
|
python-dateutil = "^2.8.2"
|
||||||
|
@ -26,6 +28,7 @@ html5lib = "^1.1"
|
||||||
mf2py = "^1.1.2"
|
mf2py = "^1.1.2"
|
||||||
Pygments = "^2.12.0"
|
Pygments = "^2.12.0"
|
||||||
loguru = "^0.6.0"
|
loguru = "^0.6.0"
|
||||||
|
mdx-linkify = "^2.1"
|
||||||
Pillow = "^9.1.1"
|
Pillow = "^9.1.1"
|
||||||
blurhash-python = "^1.1.3"
|
blurhash-python = "^1.1.3"
|
||||||
html2text = "^2020.1.16"
|
html2text = "^2020.1.16"
|
||||||
|
@ -40,11 +43,6 @@ asgiref = "^3.5.2"
|
||||||
supervisor = "^4.2.4"
|
supervisor = "^4.2.4"
|
||||||
invoke = "^1.7.1"
|
invoke = "^1.7.1"
|
||||||
boussole = "^2.0.0"
|
boussole = "^2.0.0"
|
||||||
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
|
||||||
Brotli = "^1.0.9"
|
|
||||||
greenlet = "^1.1.3"
|
|
||||||
mistletoe = "^0.9.0"
|
|
||||||
Pebble = "^5.0.2"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^22.3.0"
|
black = "^22.3.0"
|
||||||
|
|
|
@ -1,115 +1,19 @@
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import typing
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from jinja2 import Environment
|
from jinja2 import Environment
|
||||||
from jinja2 import FileSystemLoader
|
from jinja2 import FileSystemLoader
|
||||||
from jinja2 import select_autoescape
|
from jinja2 import select_autoescape
|
||||||
from mistletoe import Document # type: ignore
|
from markdown import markdown
|
||||||
from mistletoe import HTMLRenderer # type: ignore
|
|
||||||
from mistletoe import block_token # type: ignore
|
|
||||||
from pygments import highlight # type: ignore
|
|
||||||
from pygments.formatters import HtmlFormatter # type: ignore
|
|
||||||
from pygments.lexers import get_lexer_by_name as get_lexer # type: ignore
|
|
||||||
from pygments.lexers import guess_lexer # type: ignore
|
|
||||||
|
|
||||||
from app.config import VERSION
|
from app.config import VERSION
|
||||||
from app.source import CustomRenderer
|
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
|
|
||||||
_FORMATTER = HtmlFormatter()
|
|
||||||
_FORMATTER.noclasses = True
|
|
||||||
|
|
||||||
|
def markdownify(content: str) -> str:
|
||||||
class DocRenderer(CustomRenderer):
|
return markdown(
|
||||||
def __init__(
|
content, extensions=["mdx_linkify", "fenced_code", "codehilite", "toc"]
|
||||||
self,
|
)
|
||||||
depth=5,
|
|
||||||
omit_title=True,
|
|
||||||
filter_conds=[],
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
enable_mentionify=False,
|
|
||||||
enable_hashtagify=False,
|
|
||||||
)
|
|
||||||
self._headings: list[tuple[int, str, str]] = []
|
|
||||||
self._ids: set[str] = set()
|
|
||||||
self.depth = depth
|
|
||||||
self.omit_title = omit_title
|
|
||||||
self.filter_conds = filter_conds
|
|
||||||
|
|
||||||
@property
|
|
||||||
def toc(self):
|
|
||||||
"""
|
|
||||||
Returns table of contents as a block_token.List instance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_indent(level):
|
|
||||||
if self.omit_title:
|
|
||||||
level -= 1
|
|
||||||
return " " * 4 * (level - 1)
|
|
||||||
|
|
||||||
def build_list_item(heading):
|
|
||||||
level, content, title_id = heading
|
|
||||||
template = '{indent}- <a href="#{id}" rel="nofollow">{content}</a>\n'
|
|
||||||
return template.format(
|
|
||||||
indent=get_indent(level), content=content, id=title_id
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [build_list_item(heading) for heading in self._headings]
|
|
||||||
items = block_token.tokenize(lines)
|
|
||||||
return items[0]
|
|
||||||
|
|
||||||
def render_heading(self, token):
|
|
||||||
"""
|
|
||||||
Overrides super().render_heading; stores rendered heading first,
|
|
||||||
then returns it.
|
|
||||||
"""
|
|
||||||
template = '<h{level} id="{id}">{inner}</h{level}>'
|
|
||||||
inner = self.render_inner(token)
|
|
||||||
title_id = inner.lower().replace(" ", "-")
|
|
||||||
if title_id in self._ids:
|
|
||||||
i = 1
|
|
||||||
while 1:
|
|
||||||
title_id = f"{title_id}_{i}"
|
|
||||||
if title_id not in self._ids:
|
|
||||||
break
|
|
||||||
self._ids.add(title_id)
|
|
||||||
rendered = template.format(level=token.level, inner=inner, id=title_id)
|
|
||||||
content = self.parse_rendered_heading(rendered)
|
|
||||||
|
|
||||||
if not (
|
|
||||||
self.omit_title
|
|
||||||
and token.level == 1
|
|
||||||
or token.level > self.depth
|
|
||||||
or any(cond(content) for cond in self.filter_conds)
|
|
||||||
):
|
|
||||||
self._headings.append((token.level, content, title_id))
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_rendered_heading(rendered):
|
|
||||||
"""
|
|
||||||
Helper method; converts rendered heading to plain text.
|
|
||||||
"""
|
|
||||||
return re.sub(r"<.+?>", "", rendered)
|
|
||||||
|
|
||||||
def render_block_code(self, token: typing.Any) -> str:
|
|
||||||
code = token.children[0].content
|
|
||||||
lexer = get_lexer(token.language) if token.language else guess_lexer(code)
|
|
||||||
return highlight(code, lexer, _FORMATTER)
|
|
||||||
|
|
||||||
|
|
||||||
def markdownify(content: str) -> tuple[str, Any]:
|
|
||||||
with DocRenderer() as renderer:
|
|
||||||
rendered_content = renderer.render(Document(content))
|
|
||||||
|
|
||||||
with HTMLRenderer() as html_renderer:
|
|
||||||
toc = html_renderer.render(renderer.toc)
|
|
||||||
|
|
||||||
return rendered_content, toc
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
@ -126,36 +30,32 @@ def main() -> None:
|
||||||
last_updated = now().replace(second=0, microsecond=0).isoformat()
|
last_updated = now().replace(second=0, microsecond=0).isoformat()
|
||||||
|
|
||||||
readme = Path("README.md")
|
readme = Path("README.md")
|
||||||
content, toc = markdownify(readme.read_text().removeprefix("# microblog.pub"))
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content,
|
content=markdownify(readme.read_text().removeprefix("# microblog.pub")),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/",
|
path="/",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
).dump("docs/dist/index.html")
|
).dump("docs/dist/index.html")
|
||||||
|
|
||||||
install = Path("docs/install.md")
|
install = Path("docs/install.md")
|
||||||
content, toc = markdownify(install.read_text())
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content.replace("[TOC]", toc),
|
content=markdownify(install.read_text()),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/installing.html",
|
path="/installing.html",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
).dump("docs/dist/installing.html")
|
).dump("docs/dist/installing.html")
|
||||||
|
|
||||||
user_guide = Path("docs/user_guide.md")
|
user_guide = Path("docs/user_guide.md")
|
||||||
content, toc = markdownify(user_guide.read_text())
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content.replace("[TOC]", toc),
|
content=markdownify(user_guide.read_text()),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/user_guide.html",
|
path="/user_guide.html",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
).dump("docs/dist/user_guide.html")
|
).dump("docs/dist/user_guide.html")
|
||||||
|
|
||||||
developer_guide = Path("docs/developer_guide.md")
|
developer_guide = Path("docs/developer_guide.md")
|
||||||
content, toc = markdownify(developer_guide.read_text())
|
|
||||||
template.stream(
|
template.stream(
|
||||||
content=content.replace("[TOC]", toc),
|
content=markdownify(developer_guide.read_text()),
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
path="/developer_guide.html",
|
path="/developer_guide.html",
|
||||||
last_updated=last_updated,
|
last_updated=last_updated,
|
||||||
|
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
from markdown import markdown # type: ignore
|
||||||
from prompt_toolkit import prompt
|
from prompt_toolkit import prompt
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
|
||||||
|
@ -57,13 +58,15 @@ def main() -> None:
|
||||||
prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt()
|
prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt()
|
||||||
).decode()
|
).decode()
|
||||||
dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"])
|
dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"])
|
||||||
dat["summary"] = prompt(
|
dat["summary"] = markdown(
|
||||||
(
|
prompt(
|
||||||
"summary (short description, in markdown, "
|
(
|
||||||
"press [CTRL] + [SPACE] to submit):\n"
|
"summary (short description, in markdown, "
|
||||||
),
|
"press [CTRL] + [SPACE] to submit):\n"
|
||||||
key_bindings=_kb,
|
),
|
||||||
multiline=True,
|
key_bindings=_kb,
|
||||||
|
multiline=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
dat["https"] = True
|
dat["https"] = True
|
||||||
proto = "https"
|
proto = "https"
|
||||||
|
@ -75,10 +78,9 @@ def main() -> None:
|
||||||
proto = "http"
|
proto = "http"
|
||||||
|
|
||||||
print("Note that you can put your icon/avatar in the static/ directory")
|
print("Note that you can put your icon/avatar in the static/ directory")
|
||||||
if icon_url := prompt(
|
dat["icon_url"] = prompt(
|
||||||
"icon URL: ", default=f'{proto}://{dat["domain"]}/static/nopic.png'
|
"icon URL: ", default=f'{proto}://{dat["domain"]}/static/nopic.png'
|
||||||
):
|
)
|
||||||
dat["icon_url"] = icon_url
|
|
||||||
dat["secret"] = os.urandom(16).hex()
|
dat["secret"] = os.urandom(16).hex()
|
||||||
|
|
||||||
with config_file.open("w") as f:
|
with config_file.open("w") as f:
|
||||||
|
|
226
tasks.py
226
tasks.py
|
@ -1,50 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import shutil
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
from collections import namedtuple
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from inspect import getfullargspec
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import invoke # type: ignore
|
|
||||||
from invoke import Context # type: ignore
|
from invoke import Context # type: ignore
|
||||||
from invoke import run # type: ignore
|
from invoke import run # type: ignore
|
||||||
from invoke import task # type: ignore
|
from invoke import task # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def fix_annotations():
|
|
||||||
"""
|
|
||||||
Pyinvoke doesn't accept annotations by default, this fix that
|
|
||||||
Based on: @zelo's fix in https://github.com/pyinvoke/invoke/pull/606
|
|
||||||
Context in: https://github.com/pyinvoke/invoke/issues/357
|
|
||||||
Python 3.11 https://github.com/pyinvoke/invoke/issues/833
|
|
||||||
"""
|
|
||||||
|
|
||||||
ArgSpec = namedtuple("ArgSpec", ["args", "defaults"])
|
|
||||||
|
|
||||||
def patched_inspect_getargspec(func):
|
|
||||||
spec = getfullargspec(func)
|
|
||||||
return ArgSpec(spec.args, spec.defaults)
|
|
||||||
|
|
||||||
org_task_argspec = invoke.tasks.Task.argspec
|
|
||||||
|
|
||||||
def patched_task_argspec(*args, **kwargs):
|
|
||||||
with patch(
|
|
||||||
target="inspect.getargspec", new=patched_inspect_getargspec, create=True
|
|
||||||
):
|
|
||||||
return org_task_argspec(*args, **kwargs)
|
|
||||||
|
|
||||||
invoke.tasks.Task.argspec = patched_task_argspec
|
|
||||||
|
|
||||||
|
|
||||||
fix_annotations()
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def generate_db_migration(ctx, message):
|
def generate_db_migration(ctx, message):
|
||||||
# type: (Context, str) -> None
|
# type: (Context, str) -> None
|
||||||
|
@ -78,12 +46,7 @@ def compile_scss(ctx, watch=False):
|
||||||
# type: (Context, bool) -> None
|
# type: (Context, bool) -> None
|
||||||
from app.utils.favicon import build_favicon
|
from app.utils.favicon import build_favicon
|
||||||
|
|
||||||
favicon_file = Path("data/favicon.ico")
|
build_favicon()
|
||||||
if not favicon_file.exists():
|
|
||||||
build_favicon()
|
|
||||||
else:
|
|
||||||
shutil.copy2(favicon_file, "app/static/favicon.ico")
|
|
||||||
|
|
||||||
theme_file = Path("data/_theme.scss")
|
theme_file = Path("data/_theme.scss")
|
||||||
if not theme_file.exists():
|
if not theme_file.exists():
|
||||||
theme_file.write_text("// override vars for theming here")
|
theme_file.write_text("// override vars for theming here")
|
||||||
|
@ -201,12 +164,14 @@ def stats(ctx):
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def embed_version() -> Generator[None, None, None]:
|
def embed_version() -> Generator[None, None, None]:
|
||||||
from app.utils.version import get_version_commit
|
|
||||||
|
|
||||||
version_file = Path("app/_version.py")
|
version_file = Path("app/_version.py")
|
||||||
version_file.unlink(missing_ok=True)
|
version_file.unlink(missing_ok=True)
|
||||||
version_commit = get_version_commit()
|
version = (
|
||||||
version_file.write_text(f'VERSION_COMMIT = "{version_commit}"')
|
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
|
||||||
|
.split()[0]
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
version_file.write_text(f'VERSION_COMMIT = "{version}"')
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
@ -228,109 +193,6 @@ def prune_old_data(ctx):
|
||||||
asyncio.run(run_prune_old_data())
|
asyncio.run(run_prune_old_data())
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def webfinger(ctx, account):
|
|
||||||
# type: (Context, str) -> None
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from app.source import _MENTION_REGEX
|
|
||||||
from app.webfinger import get_actor_url
|
|
||||||
|
|
||||||
logger.disable("app")
|
|
||||||
if not account.startswith("@"):
|
|
||||||
account = f"@{account}"
|
|
||||||
if not _MENTION_REGEX.match(account):
|
|
||||||
print(f"Invalid acccount {account}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Resolving {account}")
|
|
||||||
try:
|
|
||||||
maybe_actor_url = asyncio.run(get_actor_url(account))
|
|
||||||
if maybe_actor_url:
|
|
||||||
print(f"SUCCESS: {maybe_actor_url}")
|
|
||||||
else:
|
|
||||||
print(f"ERROR: Failed to resolve {account}")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"ERROR: Failed to resolve {account}")
|
|
||||||
print("".join(traceback.format_exception(exc)))
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def move_to(ctx, moved_to):
|
|
||||||
# type: (Context, str) -> None
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from app.actor import LOCAL_ACTOR
|
|
||||||
from app.actor import fetch_actor
|
|
||||||
from app.boxes import send_move
|
|
||||||
from app.database import async_session
|
|
||||||
from app.source import _MENTION_REGEX
|
|
||||||
from app.webfinger import get_actor_url
|
|
||||||
|
|
||||||
logger.disable("app")
|
|
||||||
|
|
||||||
if not moved_to.startswith("@"):
|
|
||||||
moved_to = f"@{moved_to}"
|
|
||||||
if not _MENTION_REGEX.match(moved_to):
|
|
||||||
print(f"Invalid acccount {moved_to}")
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _send_move():
|
|
||||||
print(f"Initiating move to {moved_to}")
|
|
||||||
async with async_session() as db_session:
|
|
||||||
try:
|
|
||||||
moved_to_actor_id = await get_actor_url(moved_to)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"ERROR: Failed to resolve {moved_to}")
|
|
||||||
print("".join(traceback.format_exception(exc)))
|
|
||||||
return
|
|
||||||
|
|
||||||
if not moved_to_actor_id:
|
|
||||||
print("ERROR: Failed to resolve {moved_to}")
|
|
||||||
return
|
|
||||||
|
|
||||||
new_actor = await fetch_actor(db_session, moved_to_actor_id)
|
|
||||||
|
|
||||||
if LOCAL_ACTOR.ap_id not in new_actor.ap_actor.get("alsoKnownAs", []):
|
|
||||||
print(
|
|
||||||
f"{new_actor.handle}/{moved_to_actor_id} is missing "
|
|
||||||
f"{LOCAL_ACTOR.ap_id} in alsoKnownAs"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await send_move(db_session, new_actor.ap_id)
|
|
||||||
|
|
||||||
print("Done")
|
|
||||||
|
|
||||||
asyncio.run(_send_move())
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def self_destruct(ctx):
|
|
||||||
# type: (Context) -> None
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from app.boxes import send_self_destruct
|
|
||||||
from app.database import async_session
|
|
||||||
|
|
||||||
logger.disable("app")
|
|
||||||
|
|
||||||
async def _send_self_destruct():
|
|
||||||
if input("Initiating self destruct, type yes to confirm: ") != "yes":
|
|
||||||
print("Aborting")
|
|
||||||
|
|
||||||
async with async_session() as db_session:
|
|
||||||
await send_self_destruct(db_session)
|
|
||||||
|
|
||||||
print("Done")
|
|
||||||
|
|
||||||
asyncio.run(_send_self_destruct())
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def yunohost_config(
|
def yunohost_config(
|
||||||
ctx,
|
ctx,
|
||||||
|
@ -350,75 +212,3 @@ def yunohost_config(
|
||||||
summary=summary,
|
summary=summary,
|
||||||
password=password,
|
password=password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def reset_password(ctx):
|
|
||||||
# type: (Context) -> None
|
|
||||||
import bcrypt
|
|
||||||
from prompt_toolkit import prompt
|
|
||||||
|
|
||||||
new_password = bcrypt.hashpw(
|
|
||||||
prompt("New admin password: ", is_password=True).encode(), bcrypt.gensalt()
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("Update data/profile.toml with:")
|
|
||||||
print(f'admin_password = "{new_password}"')
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def check_config(ctx):
|
|
||||||
# type: (Context) -> None
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
logger.disable("app")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app import config # noqa: F401
|
|
||||||
except Exception as exc:
|
|
||||||
print("Config error, please fix data/profile.toml:\n")
|
|
||||||
print("".join(traceback.format_exception(exc)))
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("Config is OK")
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def import_mastodon_following_accounts(ctx, path):
|
|
||||||
# type: (Context, str) -> None
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from app.boxes import _get_following
|
|
||||||
from app.boxes import _send_follow
|
|
||||||
from app.database import async_session
|
|
||||||
from app.utils.mastodon import get_actor_urls_from_following_accounts_csv_file
|
|
||||||
|
|
||||||
async def _import_following() -> int:
|
|
||||||
count = 0
|
|
||||||
async with async_session() as db_session:
|
|
||||||
followings = {
|
|
||||||
following.ap_actor_id for following in await _get_following(db_session)
|
|
||||||
}
|
|
||||||
for (
|
|
||||||
handle,
|
|
||||||
actor_url,
|
|
||||||
) in await get_actor_urls_from_following_accounts_csv_file(path):
|
|
||||||
if actor_url in followings:
|
|
||||||
logger.info(f"Already following {handle}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(f"Importing {actor_url=}")
|
|
||||||
|
|
||||||
await _send_follow(db_session, actor_url)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
count = asyncio.run(_import_following())
|
|
||||||
logger.info(f"Import done, {count} follow requests sent")
|
|
||||||
|
|
|
@ -68,20 +68,6 @@ def build_accept_activity(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_block_activity(
|
|
||||||
from_remote_actor: actor.RemoteActor,
|
|
||||||
for_remote_actor: actor.RemoteActor,
|
|
||||||
outbox_public_id: str | None = None,
|
|
||||||
) -> ap.RawObject:
|
|
||||||
return {
|
|
||||||
"@context": ap.AS_CTX,
|
|
||||||
"type": "Block",
|
|
||||||
"id": from_remote_actor.ap_id + "/block/" + (outbox_public_id or uuid4().hex),
|
|
||||||
"actor": from_remote_actor.ap_id,
|
|
||||||
"object": for_remote_actor.ap_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_move_activity(
|
def build_move_activity(
|
||||||
from_remote_actor: actor.RemoteActor,
|
from_remote_actor: actor.RemoteActor,
|
||||||
for_remote_object: actor.RemoteActor,
|
for_remote_object: actor.RemoteActor,
|
||||||
|
@ -98,13 +84,12 @@ def build_move_activity(
|
||||||
|
|
||||||
|
|
||||||
def build_note_object(
|
def build_note_object(
|
||||||
from_remote_actor: actor.RemoteActor | models.Actor,
|
from_remote_actor: actor.RemoteActor,
|
||||||
outbox_public_id: str | None = None,
|
outbox_public_id: str | None = None,
|
||||||
content: str = "Hello",
|
content: str = "Hello",
|
||||||
to: list[str] = None,
|
to: list[str] = None,
|
||||||
cc: list[str] = None,
|
cc: list[str] = None,
|
||||||
tags: list[ap.RawObject] = None,
|
tags: list[ap.RawObject] = None,
|
||||||
in_reply_to: str | None = None,
|
|
||||||
) -> ap.RawObject:
|
) -> ap.RawObject:
|
||||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
|
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
|
||||||
|
@ -123,8 +108,8 @@ def build_note_object(
|
||||||
"url": from_remote_actor.ap_id + "/note/" + note_id,
|
"url": from_remote_actor.ap_id + "/note/" + note_id,
|
||||||
"tag": tags or [],
|
"tag": tags or [],
|
||||||
"summary": None,
|
"summary": None,
|
||||||
|
"inReplyTo": None,
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"inReplyTo": in_reply_to,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -20,16 +20,12 @@ async def test_fetch_actor(async_db_session: AsyncSession, respx_mock) -> None:
|
||||||
public_key="pk",
|
public_key="pk",
|
||||||
)
|
)
|
||||||
respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor))
|
respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor))
|
||||||
respx_mock.get(
|
|
||||||
"https://example.com/.well-known/webfinger",
|
|
||||||
params={"resource": "acct%3Atoto%40example.com"},
|
|
||||||
).mock(return_value=httpx.Response(200, json={"subject": "acct:toto@example.com"}))
|
|
||||||
|
|
||||||
# When fetching this actor for the first time
|
# When fetching this actor for the first time
|
||||||
saved_actor = await fetch_actor(async_db_session, ra.ap_id)
|
saved_actor = await fetch_actor(async_db_session, ra.ap_id)
|
||||||
|
|
||||||
# Then it has been fetched and saved in DB
|
# Then it has been fetched and saved in DB
|
||||||
assert respx.calls.call_count == 2
|
assert respx.calls.call_count == 1
|
||||||
assert (
|
assert (
|
||||||
await async_db_session.execute(select(models.Actor))
|
await async_db_session.execute(select(models.Actor))
|
||||||
).scalar_one().ap_id == saved_actor.ap_id
|
).scalar_one().ap_id == saved_actor.ap_id
|
||||||
|
@ -42,7 +38,7 @@ async def test_fetch_actor(async_db_session: AsyncSession, respx_mock) -> None:
|
||||||
assert (
|
assert (
|
||||||
await async_db_session.execute(select(func.count(models.Actor.id)))
|
await async_db_session.execute(select(func.count(models.Actor.id)))
|
||||||
).scalar_one() == 1
|
).scalar_one() == 1
|
||||||
assert respx.calls.call_count == 2
|
assert respx.calls.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_sqlalchemy_factory(db: Session) -> None:
|
def test_sqlalchemy_factory(db: Session) -> None:
|
||||||
|
@ -56,4 +52,4 @@ def test_sqlalchemy_factory(db: Session) -> None:
|
||||||
ap_actor=ra.ap_actor,
|
ap_actor=ra.ap_actor,
|
||||||
ap_id=ra.ap_id,
|
ap_id=ra.ap_id,
|
||||||
)
|
)
|
||||||
assert actor_in_db.id == db.execute(select(models.Actor)).scalar_one().id
|
assert actor_in_db.id == db.query(models.Actor).one().id
|
||||||
|
|
|
@ -75,7 +75,7 @@ def test_inbox_incoming_follow_request(
|
||||||
assert inbox_object.ap_object == follow_activity.ap_object
|
assert inbox_object.ap_object == follow_activity.ap_object
|
||||||
|
|
||||||
# And a follower was internally created
|
# And a follower was internally created
|
||||||
follower = db.execute(select(models.Follower)).scalar_one()
|
follower = db.query(models.Follower).one()
|
||||||
assert follower.ap_actor_id == ra.ap_id
|
assert follower.ap_actor_id == ra.ap_id
|
||||||
assert follower.actor_id == saved_actor.id
|
assert follower.actor_id == saved_actor.id
|
||||||
assert follower.inbox_object_id == inbox_object.id
|
assert follower.inbox_object_id == inbox_object.id
|
||||||
|
@ -414,62 +414,3 @@ def test_inbox__move_activity(
|
||||||
)
|
)
|
||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
|
|
||||||
# And a notification was created
|
|
||||||
notif = db.execute(
|
|
||||||
select(models.Notification).where(
|
|
||||||
models.Notification.notification_type == models.NotificationType.MOVE
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert notif.actor.ap_id == new_ra.ap_id
|
|
||||||
assert notif.inbox_object_id == inbox_activity.id
|
|
||||||
|
|
||||||
|
|
||||||
def test_inbox__block_activity(
|
|
||||||
db: Session,
|
|
||||||
client: TestClient,
|
|
||||||
respx_mock: respx.MockRouter,
|
|
||||||
) -> None:
|
|
||||||
# Given a remote actor
|
|
||||||
ra = setup_remote_actor(respx_mock)
|
|
||||||
|
|
||||||
# Which is followed by the local actor
|
|
||||||
setup_remote_actor_as_following(ra)
|
|
||||||
|
|
||||||
# When receiving a Block activity
|
|
||||||
follow_activity = RemoteObject(
|
|
||||||
factories.build_block_activity(
|
|
||||||
from_remote_actor=ra,
|
|
||||||
for_remote_actor=LOCAL_ACTOR,
|
|
||||||
),
|
|
||||||
ra,
|
|
||||||
)
|
|
||||||
with mock_httpsig_checker(ra):
|
|
||||||
response = client.post(
|
|
||||||
"/inbox",
|
|
||||||
headers={"Content-Type": ap.AS_CTX},
|
|
||||||
json=follow_activity.ap_object,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then the server returns a 202
|
|
||||||
assert response.status_code == 202
|
|
||||||
|
|
||||||
run_process_next_incoming_activity()
|
|
||||||
|
|
||||||
# And the actor was saved in DB
|
|
||||||
saved_actor = db.execute(select(models.Actor)).scalar_one()
|
|
||||||
assert saved_actor.ap_id == ra.ap_id
|
|
||||||
|
|
||||||
# And the Block activity was saved in the inbox
|
|
||||||
inbox_activity = db.execute(
|
|
||||||
select(models.InboxObject).where(models.InboxObject.ap_type == "Block")
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
# And a notification was created
|
|
||||||
notif = db.execute(
|
|
||||||
select(models.Notification).where(
|
|
||||||
models.Notification.notification_type == models.NotificationType.BLOCKED
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert notif.actor.ap_id == ra.ap_id
|
|
||||||
assert notif.inbox_object_id == inbox_activity.id
|
|
||||||
|
|
|
@ -2,17 +2,13 @@ from unittest import mock
|
||||||
|
|
||||||
import respx
|
import respx
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app import models
|
from app import models
|
||||||
from app import webfinger
|
from app import webfinger
|
||||||
from app.actor import LOCAL_ACTOR
|
|
||||||
from app.config import generate_csrf_token
|
from app.config import generate_csrf_token
|
||||||
from tests.utils import generate_admin_session_cookies
|
from tests.utils import generate_admin_session_cookies
|
||||||
from tests.utils import setup_inbox_note
|
|
||||||
from tests.utils import setup_outbox_note
|
|
||||||
from tests.utils import setup_remote_actor
|
from tests.utils import setup_remote_actor
|
||||||
from tests.utils import setup_remote_actor_as_follower
|
from tests.utils import setup_remote_actor_as_follower
|
||||||
|
|
||||||
|
@ -53,184 +49,16 @@ def test_send_follow_request(
|
||||||
assert response.headers.get("Location") == "http://testserver/"
|
assert response.headers.get("Location") == "http://testserver/"
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Follow"
|
assert outbox_object.ap_type == "Follow"
|
||||||
assert outbox_object.activity_object_ap_id == ra.ap_id
|
assert outbox_object.activity_object_ap_id == ra.ap_id
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
outgoing_activity = db.query(models.OutgoingActivity).one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == ra.inbox_url
|
assert outgoing_activity.recipient == ra.inbox_url
|
||||||
|
|
||||||
|
|
||||||
def test_send_delete__reverts_side_effects(
|
|
||||||
db: Session,
|
|
||||||
client: TestClient,
|
|
||||||
respx_mock: respx.MockRouter,
|
|
||||||
) -> None:
|
|
||||||
# given a remote actor
|
|
||||||
ra = setup_remote_actor(respx_mock)
|
|
||||||
|
|
||||||
# who is a follower
|
|
||||||
follower = setup_remote_actor_as_follower(ra)
|
|
||||||
actor = follower.actor
|
|
||||||
|
|
||||||
# with a note that has existing replies
|
|
||||||
inbox_note = setup_inbox_note(actor)
|
|
||||||
# with a bogus counter
|
|
||||||
inbox_note.replies_count = 5
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# and 2 local replies
|
|
||||||
setup_outbox_note(
|
|
||||||
to=[ap.AS_PUBLIC],
|
|
||||||
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
|
|
||||||
in_reply_to=inbox_note.ap_id,
|
|
||||||
)
|
|
||||||
outbox_note2 = setup_outbox_note(
|
|
||||||
to=[ap.AS_PUBLIC],
|
|
||||||
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
|
|
||||||
in_reply_to=inbox_note.ap_id,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# When deleting one of the replies
|
|
||||||
response = client.post(
|
|
||||||
"/admin/actions/delete",
|
|
||||||
data={
|
|
||||||
"redirect_url": "http://testserver/",
|
|
||||||
"ap_object_id": outbox_note2.ap_id,
|
|
||||||
"csrf_token": generate_csrf_token(),
|
|
||||||
},
|
|
||||||
cookies=generate_admin_session_cookies(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then the server returns a 302
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.headers.get("Location") == "http://testserver/"
|
|
||||||
|
|
||||||
# And the Delete activity was created in the outbox
|
|
||||||
outbox_object = db.execute(
|
|
||||||
select(models.OutboxObject).where(models.OutboxObject.ap_type == "Delete")
|
|
||||||
).scalar_one()
|
|
||||||
assert outbox_object.ap_type == "Delete"
|
|
||||||
assert outbox_object.activity_object_ap_id == outbox_note2.ap_id
|
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
|
||||||
assert outgoing_activity.recipient == ra.inbox_url
|
|
||||||
|
|
||||||
# And the replies count of the replied object was refreshed correctly
|
|
||||||
db.refresh(inbox_note)
|
|
||||||
assert inbox_note.replies_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_create_activity__no_content(
|
|
||||||
db: Session,
|
|
||||||
client: TestClient,
|
|
||||||
respx_mock: respx.MockRouter,
|
|
||||||
) -> None:
|
|
||||||
# given a remote actor
|
|
||||||
ra = setup_remote_actor(respx_mock)
|
|
||||||
|
|
||||||
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
|
|
||||||
response = client.post(
|
|
||||||
"/admin/actions/new",
|
|
||||||
data={
|
|
||||||
"redirect_url": "http://testserver/",
|
|
||||||
"visibility": ap.VisibilityEnum.PUBLIC.name,
|
|
||||||
"csrf_token": generate_csrf_token(),
|
|
||||||
},
|
|
||||||
cookies=generate_admin_session_cookies(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then the server returns a 422
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_create_activity__with_attachment(
|
|
||||||
db: Session,
|
|
||||||
client: TestClient,
|
|
||||||
respx_mock: respx.MockRouter,
|
|
||||||
) -> None:
|
|
||||||
# given a remote actor
|
|
||||||
ra = setup_remote_actor(respx_mock)
|
|
||||||
|
|
||||||
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
|
|
||||||
response = client.post(
|
|
||||||
"/admin/actions/new",
|
|
||||||
data={
|
|
||||||
"content": "hello",
|
|
||||||
"redirect_url": "http://testserver/",
|
|
||||||
"visibility": ap.VisibilityEnum.PUBLIC.name,
|
|
||||||
"csrf_token": generate_csrf_token(),
|
|
||||||
},
|
|
||||||
files=[
|
|
||||||
("files", ("attachment.txt", "hello")),
|
|
||||||
],
|
|
||||||
cookies=generate_admin_session_cookies(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then the server returns a 302
|
|
||||||
assert response.status_code == 302
|
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
|
||||||
assert outbox_object.ap_type == "Note"
|
|
||||||
assert outbox_object.summary is None
|
|
||||||
assert outbox_object.content == "<p>hello</p>\n"
|
|
||||||
assert len(outbox_object.attachments) == 1
|
|
||||||
attachment = outbox_object.attachments[0]
|
|
||||||
assert attachment.type == "Document"
|
|
||||||
|
|
||||||
attachment_response = client.get(attachment.url)
|
|
||||||
assert attachment_response.status_code == 200
|
|
||||||
assert attachment_response.content == b"hello"
|
|
||||||
|
|
||||||
upload = db.execute(select(models.Upload)).scalar_one()
|
|
||||||
assert upload.content_hash == (
|
|
||||||
"324dcf027dd4a30a932c441f365a25e86b173defa4b8e58948253471b81b72cf"
|
|
||||||
)
|
|
||||||
|
|
||||||
outbox_attachment = db.execute(select(models.OutboxObjectAttachment)).scalar_one()
|
|
||||||
assert outbox_attachment.upload_id == upload.id
|
|
||||||
assert outbox_attachment.outbox_object_id == outbox_object.id
|
|
||||||
assert outbox_attachment.filename == "attachment.txt"
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_create_activity__no_content_with_cw_and_attachments(
|
|
||||||
db: Session,
|
|
||||||
client: TestClient,
|
|
||||||
respx_mock: respx.MockRouter,
|
|
||||||
) -> None:
|
|
||||||
# given a remote actor
|
|
||||||
ra = setup_remote_actor(respx_mock)
|
|
||||||
|
|
||||||
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
|
|
||||||
response = client.post(
|
|
||||||
"/admin/actions/new",
|
|
||||||
data={
|
|
||||||
"content_warning": "cw",
|
|
||||||
"redirect_url": "http://testserver/",
|
|
||||||
"visibility": ap.VisibilityEnum.PUBLIC.name,
|
|
||||||
"csrf_token": generate_csrf_token(),
|
|
||||||
},
|
|
||||||
files={"files": ("attachment.txt", "hello")},
|
|
||||||
cookies=generate_admin_session_cookies(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then the server returns a 302
|
|
||||||
assert response.status_code == 302
|
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
|
||||||
assert outbox_object.ap_type == "Note"
|
|
||||||
assert outbox_object.summary is None
|
|
||||||
assert outbox_object.content == "<p>cw</p>\n"
|
|
||||||
assert len(outbox_object.attachments) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_create_activity__no_followers_and_with_mention(
|
def test_send_create_activity__no_followers_and_with_mention(
|
||||||
db: Session,
|
db: Session,
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
|
@ -255,11 +83,11 @@ def test_send_create_activity__no_followers_and_with_mention(
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
outgoing_activity = db.query(models.OutgoingActivity).one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == ra.inbox_url
|
assert outgoing_activity.recipient == ra.inbox_url
|
||||||
|
|
||||||
|
@ -291,11 +119,11 @@ def test_send_create_activity__with_followers(
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
outgoing_activity = db.query(models.OutgoingActivity).one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
||||||
|
@ -331,7 +159,7 @@ def test_send_create_activity__question__one_of(
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Question"
|
assert outbox_object.ap_type == "Question"
|
||||||
assert outbox_object.is_one_of_poll is True
|
assert outbox_object.is_one_of_poll is True
|
||||||
assert len(outbox_object.poll_items) == 2
|
assert len(outbox_object.poll_items) == 2
|
||||||
|
@ -339,7 +167,7 @@ def test_send_create_activity__question__one_of(
|
||||||
assert outbox_object.is_poll_ended is False
|
assert outbox_object.is_poll_ended is False
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
outgoing_activity = db.query(models.OutgoingActivity).one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
||||||
|
@ -377,7 +205,7 @@ def test_send_create_activity__question__any_of(
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Question"
|
assert outbox_object.ap_type == "Question"
|
||||||
assert outbox_object.is_one_of_poll is False
|
assert outbox_object.is_one_of_poll is False
|
||||||
assert len(outbox_object.poll_items) == 4
|
assert len(outbox_object.poll_items) == 4
|
||||||
|
@ -385,7 +213,7 @@ def test_send_create_activity__question__any_of(
|
||||||
assert outbox_object.is_poll_ended is False
|
assert outbox_object.is_poll_ended is False
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
outgoing_activity = db.query(models.OutgoingActivity).one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
||||||
|
@ -418,11 +246,11 @@ def test_send_create_activity__article(
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Article"
|
assert outbox_object.ap_type == "Article"
|
||||||
assert outbox_object.ap_object["name"] == "Article"
|
assert outbox_object.ap_object["name"] == "Article"
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
outgoing_activity = db.query(models.OutgoingActivity).one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
@ -33,19 +31,7 @@ def test_followers__ap(client, db) -> None:
|
||||||
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
|
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
||||||
json_resp = response.json()
|
assert response.json()["id"].endswith("/followers")
|
||||||
assert json_resp["id"].endswith("/followers")
|
|
||||||
assert "first" in json_resp
|
|
||||||
|
|
||||||
|
|
||||||
def test_followers__ap_hides_followers(client, db) -> None:
|
|
||||||
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
|
|
||||||
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
|
||||||
json_resp = response.json()
|
|
||||||
assert json_resp["id"].endswith("/followers")
|
|
||||||
assert "first" not in json_resp
|
|
||||||
|
|
||||||
|
|
||||||
def test_followers__html(client, db) -> None:
|
def test_followers__html(client, db) -> None:
|
||||||
|
@ -54,40 +40,14 @@ def test_followers__html(client, db) -> None:
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
|
||||||
|
|
||||||
def test_followers__html_hides_followers(client, db) -> None:
|
|
||||||
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
|
|
||||||
response = client.get("/followers", headers={"Accept": "text/html"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
|
|
||||||
|
|
||||||
def test_following__ap(client, db) -> None:
|
def test_following__ap(client, db) -> None:
|
||||||
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
|
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
||||||
json_resp = response.json()
|
assert response.json()["id"].endswith("/following")
|
||||||
assert json_resp["id"].endswith("/following")
|
|
||||||
assert "first" in json_resp
|
|
||||||
|
|
||||||
|
|
||||||
def test_following__ap_hides_following(client, db) -> None:
|
|
||||||
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
|
|
||||||
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
|
||||||
json_resp = response.json()
|
|
||||||
assert json_resp["id"].endswith("/following")
|
|
||||||
assert "first" not in json_resp
|
|
||||||
|
|
||||||
|
|
||||||
def test_following__html(client, db) -> None:
|
def test_following__html(client, db) -> None:
|
||||||
response = client.get("/following")
|
response = client.get("/following")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
|
||||||
|
|
||||||
def test_following__html_hides_following(client, db) -> None:
|
|
||||||
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
|
|
||||||
response = client.get("/following", headers={"Accept": "text/html"})
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
|
@ -36,7 +35,7 @@ def test_tags__note_with_tag(db: Session, client: TestClient) -> None:
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
outbox_object = db.query(models.OutboxObject).one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
assert len(outbox_object.tags) == 1
|
assert len(outbox_object.tags) == 1
|
||||||
emoji_tag = outbox_object.tags[0]
|
emoji_tag = outbox_object.tags[0]
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.utils.url import is_hostname_blocked
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"hostname,should_be_blocked",
|
|
||||||
[
|
|
||||||
("example.com", True),
|
|
||||||
("subdomain.example.com", True),
|
|
||||||
("example.xyz", False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_is_hostname_blocked(hostname: str, should_be_blocked: bool) -> None:
|
|
||||||
with mock.patch("app.utils.url.BLOCKED_SERVERS", ["example.com"]):
|
|
||||||
is_hostname_blocked.cache_clear()
|
|
||||||
assert is_hostname_blocked(hostname) is should_be_blocked
|
|
|
@ -169,53 +169,6 @@ def setup_remote_actor_as_following_and_follower(
|
||||||
return following, follower
|
return following, follower
|
||||||
|
|
||||||
|
|
||||||
def setup_outbox_note(
|
|
||||||
content: str = "Hello",
|
|
||||||
to: list[str] = None,
|
|
||||||
cc: list[str] = None,
|
|
||||||
tags: list[ap.RawObject] = None,
|
|
||||||
in_reply_to: str | None = None,
|
|
||||||
) -> models.OutboxObject:
|
|
||||||
note_id = uuid4().hex
|
|
||||||
note_from_outbox = RemoteObject(
|
|
||||||
factories.build_note_object(
|
|
||||||
from_remote_actor=LOCAL_ACTOR,
|
|
||||||
outbox_public_id=note_id,
|
|
||||||
content=content,
|
|
||||||
to=to,
|
|
||||||
cc=cc,
|
|
||||||
tags=tags,
|
|
||||||
in_reply_to=in_reply_to,
|
|
||||||
),
|
|
||||||
LOCAL_ACTOR,
|
|
||||||
)
|
|
||||||
return factories.OutboxObjectFactory.from_remote_object(note_id, note_from_outbox)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_inbox_note(
|
|
||||||
actor: models.Actor,
|
|
||||||
content: str = "Hello",
|
|
||||||
to: list[str] = None,
|
|
||||||
cc: list[str] = None,
|
|
||||||
tags: list[ap.RawObject] = None,
|
|
||||||
in_reply_to: str | None = None,
|
|
||||||
) -> models.OutboxObject:
|
|
||||||
note_id = uuid4().hex
|
|
||||||
note_from_outbox = RemoteObject(
|
|
||||||
factories.build_note_object(
|
|
||||||
from_remote_actor=actor,
|
|
||||||
outbox_public_id=note_id,
|
|
||||||
content=content,
|
|
||||||
to=to,
|
|
||||||
cc=cc,
|
|
||||||
tags=tags,
|
|
||||||
in_reply_to=in_reply_to,
|
|
||||||
),
|
|
||||||
actor,
|
|
||||||
)
|
|
||||||
return factories.InboxObjectFactory.from_remote_object(note_from_outbox, actor)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_inbox_delete(
|
def setup_inbox_delete(
|
||||||
actor: models.Actor, deleted_object_ap_id: str
|
actor: models.Actor, deleted_object_ap_id: str
|
||||||
) -> models.InboxObject:
|
) -> models.InboxObject:
|
||||||
|
|
Loading…
Reference in a new issue