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

85 Commits

Author SHA1 Message Date
475e525468 Fix typos in the docs 2022-09-11 10:53:25 +02:00
c1231245a4 Complete self-destruct support 2022-09-11 10:51:08 +02:00
5eb6157c1b More tests for note creation 2022-09-09 22:14:09 +02:00
0f20a1d12f Allow to post note with attachments and a CW 2022-09-08 22:20:16 +02:00
356aace9bc Add move task 2022-09-08 20:57:52 +02:00
a701d3b06e Improve move support 2022-09-08 20:00:02 +02:00
333fa5dc40 Add new notification type for Move activities 2022-09-07 22:21:12 +02:00
032632c4dc Fix template 2022-09-07 21:54:56 +02:00
3641aa0adc Improve movedTo support 2022-09-07 21:29:09 +02:00
eba868e8e5 Fix admin delete in the UI 2022-09-07 19:45:34 +02:00
1bfea16eed Update deps 2022-09-06 21:00:39 +02:00
70120647c2 Tweak Move and outbox prefetch 2022-09-05 21:41:22 +02:00
e454e8fe84 Tweak admin login logic 2022-09-04 09:24:58 +02:00
f7671f0585 Process EXIF orientation for uploaded files 2022-09-03 10:15:37 +02:00
16da166ee1 Tweak queries in tests 2022-09-02 23:47:23 +02:00
d5c27287af Fix admin in reply to link 2022-09-01 21:00:14 +02:00
5f20eab3f1 More work towards support moving/deleting instance 2022-09-01 20:42:20 +02:00
b03daf1274 Fix in reply to link 2022-09-01 20:32:32 +02:00
191ce39d14 Add missing autorestart for supervisord config 2022-09-01 12:35:15 +02:00
6e3066bd9b Fix support for multi-codepoints emoji 2022-09-01 12:23:23 +02:00
0175f21273 Fix mentionify 2022-08-31 19:44:40 +02:00
36d356c97a Update user guide 2022-08-31 19:31:17 +02:00
6384dbcd93 Re-add support for custom emoji 2022-08-31 19:16:03 +02:00
c740813b57 Ensure pinned posts appear on front page before others 2022-08-31 08:19:47 +02:00
0ef2f1f89d Remove surrounding whitespace before processing query
Ran into this issue twice quite by accident with fat-fingering copy/paste on
my phone. If there is any whitespace in front of or trailing after the
lookup query, it returns an "Unexpected error". Stripping the string is the
quick and dirty way to clean it.

I hate modifying the same function argument name in place like that, but it
is valid Python. If you want me to assign it to a separate variable and
replace all the references of "query", let me know.

Thanks!
2022-08-31 08:16:32 +02:00
6d933863d2 Fix outbox delete side effects 2022-08-30 20:05:10 +02:00
8fe6cc9b9d Fix the delete button 2022-08-30 19:09:51 +02:00
4cb499e44d Fix form for new objects 2022-08-30 08:51:02 +02:00
95745374cd 'followers-only' posts are not necessarily deleted, but may not be viewable to the signed-in actor 2022-08-30 08:21:11 +02:00
db8f0cb141 Harden the CSP a bit for values that don't inherit default-src. Set Permissions-Policy. Remove TODO 2022-08-30 08:21:11 +02:00
05f840ecc8 Small typos in docs/install.md 2022-08-30 08:21:11 +02:00
ebdba62a06 No more inline CSS 2022-08-29 21:42:54 +02:00
2fb85e138e Remove inlined JS 2022-08-29 20:11:31 +02:00
b843b29975 Another Makefile fix 2022-08-29 19:44:02 +02:00
4f8bb00d86 Fix Makefile 2022-08-29 19:40:11 +02:00
a02c8cf0bb Fix NGINX setup instructions 2022-08-29 19:28:54 +02:00
ee5265f4dd Small tweaks/typos 2022-08-29 09:09:28 +02:00
727eaa9ee1 Tweak docs 2022-08-28 22:21:22 +02:00
39ca3ed7e2 Revert CSS changes 2022-08-28 19:53:11 +02:00
c67db749dc Tweak CSS 2022-08-28 19:35:51 +02:00
fc0445fcec Add missing template 2022-08-28 19:32:05 +02:00
c275d7064e Tweak supervisord config 2022-08-28 19:08:44 +02:00
1a7e9e4565 Fix OG metadata processing 2022-08-28 19:05:06 +02:00
87f035d298 HTML error page 2022-08-28 17:36:58 +02:00
651682829a Tweak worker shutdown 2022-08-28 12:05:44 +02:00
3f85c851be More share dedup tweak 2022-08-28 11:39:44 +02:00
333e367a5b Improve debug mode 2022-08-28 11:24:46 +02:00
09cdef118c Fix share dedup 2022-08-27 17:28:53 +02:00
00004a3239 Debug share dedup 2022-08-27 11:21:42 +02:00
7283ba134c Tweak templates 2022-08-27 09:45:14 +02:00
c8f3bed065 Tweak inbox display 2022-08-27 09:28:37 +02:00
93e0d073a0 Tweak lookup 2022-08-27 09:24:21 +02:00
e959085d38 Improve shares on homepage 2022-08-27 09:14:16 +02:00
aaf8b811dc Fix mention processing bug 2022-08-27 09:10:14 +02:00
4e445a7207 Prevent replay attacks with TLS1.3 0-RTT 2022-08-26 23:35:58 +02:00
40c4a4413d Tweak media proxy error 2022-08-26 22:04:38 +02:00
dd4773fc27 Fix share dedup 2022-08-26 21:23:16 +02:00
0db6b0e2ba Tweak deps 2022-08-26 21:07:46 +02:00
88cb82c9bb Improve static assets caching 2022-08-26 20:26:41 +02:00
372851caaf Tweak sample nginx conf 2022-08-26 20:25:55 +02:00
e16dbf03e7 Add NGINX tips in the doc 2022-08-26 20:18:59 +02:00
7d4b7f6756 Improve Announce dedup 2022-08-26 19:09:40 +02:00
edf9e28ed1 Tweak cache size 2022-08-26 18:58:21 +02:00
eb9a6024a8 Tweak supervisord config 2022-08-26 18:43:56 +02:00
84203fc66e More webp support 2022-08-26 09:28:00 +02:00
55d82c5843 Also save outbox attachment thumbnails as webp 2022-08-26 09:05:55 +02:00
53a31ae562 Webp support 2022-08-26 08:48:14 +02:00
d21ce3313d Fix notif page 2022-08-26 08:18:51 +02:00
93ee6c435d Tweak notifications 2022-08-26 08:15:49 +02:00
bec40cc050 Pagination for the admin profile page 2022-08-26 08:10:46 +02:00
505abd7da8 Only display tiny actor icon for shares 2022-08-26 07:57:10 +02:00
63073279e1 More actor icons 2022-08-26 07:43:39 +02:00
365e6cc534 Mention Docker disk usage in the install guide 2022-08-25 08:57:30 +02:00
e753fee632 Tweak read more link on notifications page 2022-08-25 08:51:46 +02:00
30cfd6260b Pagination for the notifications page 2022-08-25 08:45:07 +02:00
d43bf54609 Custom footer support 2022-08-24 21:18:30 +02:00
953a6c3b91 Fix empty tag page 2022-08-24 20:52:15 +02:00
ae28cf2294 Improve summary 2022-08-24 20:12:10 +02:00
3b767eae11 Improve version handling 2022-08-24 09:02:20 +02:00
6475714369 Update user guide 2022-08-24 07:52:46 +02:00
0811609e3e Tweak user guide 2022-08-23 19:40:45 +02:00
adcaf95ab2 Tweak supervisord conf for YNH 2022-08-22 19:32:39 +02:00
ce15d2b0c3 HTML error for failed admin login 2022-08-22 18:50:20 +02:00
e047a87620 Tweak supervisord conf for YNH 2022-08-22 18:49:19 +02:00
e55dc652ee Tweak inbox activity processing 2022-08-21 21:06:33 +02:00
56 changed files with 2007 additions and 500 deletions

View File

@ -9,12 +9,23 @@ build:
config:
# Run and remove instantly
-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
update:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
.PHONY: prune-old-data
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 --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
.PHONY: move-to
move-to:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
.PHONY: self-destruct
move-to:
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct

View File

@ -9,9 +9,12 @@ from loguru import logger
from markdown import markdown
from app import config
from app.config import ALSO_KNOWN_AS
from app.config import AP_CONTENT_TYPE # noqa: F401
from app.config import MOVED_TO
from app.httpsig import auth
from app.key import get_pubkey_as_pem
from app.source import hashtagify
from app.utils.url import check_url
if TYPE_CHECKING:
@ -32,6 +35,7 @@ AS_EXTENDED_CTX = [
"sensitive": "as:sensitive",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
# toot
"toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"},
@ -81,6 +85,8 @@ class VisibilityEnum(str, enum.Enum):
}[key]
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
ME = {
"@context": AS_EXTENDED_CTX,
"type": "Person",
@ -92,7 +98,7 @@ ME = {
"outbox": config.BASE_URL + "/outbox",
"preferredUsername": config.USERNAME,
"name": config.CONFIG.name,
"summary": config.CONFIG.summary,
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
"endpoints": {
# For compat with servers expecting a sharedInbox...
"sharedInbox": config.BASE_URL
@ -120,8 +126,15 @@ ME = {
"owner": config.ID,
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
},
"tag": _LOCAL_ACTOR_TAGS,
}
if ALSO_KNOWN_AS:
ME["alsoKnownAs"] = [ALSO_KNOWN_AS]
if MOVED_TO:
ME["movedTo"] = MOVED_TO
class NotAnObjectError(Exception):
def __init__(self, url: str, resp: httpx.Response | None = None) -> None:

View File

@ -5,6 +5,7 @@ from functools import cached_property
from typing import Union
from urllib.parse import urlparse
from loguru import logger
from sqlalchemy import select
from sqlalchemy.orm import joinedload
@ -118,6 +119,10 @@ class Actor:
def attachments(self) -> list[ap.RawObject]:
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
def server(self) -> str:
return urlparse(self.ap_id).hostname # type: ignore
@ -154,9 +159,9 @@ async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "Actor
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
actor = models.Actor(
ap_id=ap_actor["id"],
ap_id=ap.get_id(ap_actor["id"]),
ap_actor=ap_actor,
ap_type=ap_actor["type"],
ap_type=ap.as_list(ap_actor["type"])[0],
handle=_handle(ap_actor),
)
db_session.add(actor)
@ -188,6 +193,19 @@ async def fetch_actor(
else:
if save_if_not_found:
ap_actor = await ap.fetch(actor_id)
# Some softwares uses URL when we expect ID
if actor_id == ap_actor.get("url"):
# 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:
return existing_actor_by_url
return await save_actor(db_session, ap_actor)
else:
raise ap.ObjectNotFoundError
@ -201,6 +219,7 @@ class ActorMetadata:
is_follow_request_sent: bool
outbox_follow_ap_id: str | None
inbox_follow_ap_id: str | None
moved_to: typing.Optional["ActorModel"]
ActorsMetadata = dict[str, ActorMetadata]
@ -247,6 +266,19 @@ async def get_actors_metadata(
for actor in actors:
if not actor.ap_id:
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(
ap_actor_id=actor.ap_id,
is_following=actor.ap_id in following,
@ -254,6 +286,7 @@ async def get_actors_metadata(
is_follow_request_sent=actor.ap_id in sent_follow_requests,
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
inbox_follow_ap_id=followers.get(actor.ap_id),
moved_to=moved_to,
)
return idx
@ -289,4 +322,7 @@ def _actor_hash(actor: Actor) -> bytes:
h.update(actor.public_key_id.encode())
h.update(actor.public_key_as_pem.encode())
if actor.moved_to:
h.update(actor.moved_to.encode())
return h.digest()

View File

@ -34,6 +34,7 @@ from app.config import verify_password
from app.database import AsyncSession
from app.database import get_db_session
from app.lookup import lookup
from app.templates import is_current_user_admin
from app.uploads import save_upload
from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME
@ -68,16 +69,6 @@ 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")
async def get_lookup(
request: Request,
@ -122,7 +113,9 @@ async def get_lookup(
)
if requested_object:
return RedirectResponse(
request.url_for("admin_object") + f"?ap_id={ap_object.ap_id}",
request.url_for("admin_object")
+ f"?ap_id={ap_object.ap_id}#"
+ requested_object.permalink_id,
status_code=302,
)
@ -211,6 +204,7 @@ async def admin_bookmarks(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
# TODO: support pagination
stream = (
(
await db_session.scalars(
@ -667,15 +661,30 @@ async def admin_outbox(
@router.get("/notifications")
async def get_notifications(
request: Request, db_session: AsyncSession = Depends(get_db_session)
request: Request,
db_session: AsyncSession = Depends(get_db_session),
cursor: str | None = None,
) -> 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 = (
(
await db_session.scalars(
select(models.Notification)
.where(*where)
.options(
joinedload(models.Notification.actor),
joinedload(models.Notification.inbox_object),
joinedload(models.Notification.inbox_object).options(
joinedload(models.InboxObject.actor)
),
joinedload(models.Notification.outbox_object).options(
joinedload(
models.OutboxObject.outbox_object_attachments
@ -684,6 +693,7 @@ async def get_notifications(
joinedload(models.Notification.webmention),
)
.order_by(models.Notification.created_at.desc())
.limit(page_size)
)
)
.unique()
@ -697,6 +707,21 @@ async def get_notifications(
notif.is_new = False
await db_session.commit()
more_unread_count = 0
next_cursor = None
if notifications and remaining_count > page_size:
decoded_next_cursor = notifications[-1].created_at
next_cursor = pagination.encode_cursor(decoded_next_cursor)
# If on the "see more" page there's more unread notification, we want
# 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,
)
)
return await templates.render_template(
db_session,
request,
@ -704,6 +729,8 @@ async def get_notifications(
{
"notifications": notifications,
"actors_metadata": actors_metadata,
"next_cursor": next_cursor,
"more_unread_count": more_unread_count,
},
)
@ -715,7 +742,7 @@ async def admin_object(
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id)
if not requested_object:
if not requested_object or requested_object.is_deleted:
raise HTTPException(status_code=404)
replies_tree = await boxes.get_replies_tree(
@ -736,8 +763,10 @@ async def admin_object(
async def admin_profile(
request: Request,
actor_id: str,
cursor: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
# TODO: show featured/pinned
actor = (
await db_session.execute(
select(models.Actor).where(models.Actor.ap_id == actor_id)
@ -748,17 +777,27 @@ async def admin_profile(
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 = (
(
await db_session.scalars(
select(models.InboxObject)
.where(
models.InboxObject.is_deleted.is_(False),
models.InboxObject.actor_id == actor.id,
models.InboxObject.ap_type.in_(
["Note", "Article", "Video", "Page", "Announce"]
),
)
.where(*where)
.options(
joinedload(models.InboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor)
@ -771,12 +810,19 @@ async def admin_profile(
joinedload(models.InboxObject.actor),
)
.order_by(models.InboxObject.ap_published_at.desc())
.limit(page_size)
)
)
.unique()
.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(
db_session,
request,
@ -785,6 +831,7 @@ async def admin_profile(
"actors_metadata": actors_metadata,
"actor": actor,
"inbox_objects": inbox_objects,
"next_cursor": next_cursor,
},
)
@ -974,7 +1021,7 @@ async def admin_actions_unpin(
async def admin_actions_new(
request: Request,
files: list[UploadFile] = [],
content: str = Form(),
content: str | None = Form(None),
redirect_url: str = Form(),
in_reply_to: str | None = Form(None),
content_warning: str | None = Form(None),
@ -985,6 +1032,19 @@ async def admin_actions_new(
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> 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
uploads = []
raw_form_data = await request.form()
@ -1054,7 +1114,10 @@ async def admin_actions_vote(
async def login(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse:
) -> templates.TemplateResponse | RedirectResponse:
if is_current_user_admin(request):
return RedirectResponse(request.url_for("admin_stream"), status_code=302)
return await templates.render_template(
db_session,
request,
@ -1072,11 +1135,25 @@ async def login_validation(
password: str = Form(),
redirect: str | None = Form(None),
csrf_check: None = Depends(verify_csrf_token),
) -> RedirectResponse:
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse | templates.TemplateResponse:
if not verify_password(password):
raise HTTPException(status_code=401)
logger.warning("Invalid password")
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(redirect or "/admin/stream", status_code=302)
resp = RedirectResponse(
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
return resp

View File

@ -208,6 +208,13 @@ class Object:
def in_reply_to(self) -> str | None:
return self.ap_object.get("inReplyTo")
@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
def has_ld_signature(self) -> bool:
return bool(self.ap_object.get("signature"))

View File

@ -29,6 +29,7 @@ from app.config import BASE_URL
from app.config import BLOCKED_SERVERS
from app.config import ID
from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.config import set_moved_to
from app.database import AsyncSession
from app.outgoing_activities import new_outgoing_activity
from app.source import markdownify
@ -93,6 +94,7 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
raise ValueError(f"{ap_object_id} not found in the outbox")
delete_id = allocate_outbox_id()
# FIXME addressing
delete = {
"@context": ap.AS_EXTENDED_CTX,
"id": outbox_object_id(delete_id),
@ -122,6 +124,19 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id)
# Revert side effects
if outbox_object_to_delete.in_reply_to:
replied_object = await get_anybox_object_by_ap_id(
db_session, outbox_object_to_delete.in_reply_to
)
if replied_object:
replied_object.replies_count = replied_object.replies_count - 1
if replied_object.replies_count < 0:
logger.warning("negative replies count for {replied_object.ap_id}")
replied_object.replies_count = 0
else:
logger.info(f"{outbox_object_to_delete.in_reply_to} not found")
await db_session.commit()
@ -351,7 +366,7 @@ async def fetch_conversation_root(
db_session, ap.get_actor_id(raw_reply)
)
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError, ap.NotAnObjectError):
return await fetch_conversation_root(db_session, obj, is_root=True)
except httpx.HTTPStatusError as http_status_error:
if 400 <= http_status_error.response.status_code < 500:
@ -363,6 +378,59 @@ async def fetch_conversation_root(
return await fetch_conversation_root(db_session, in_reply_to_object)
async def send_move(
db_session: AsyncSession,
target: str,
) -> None:
move_id = allocate_outbox_id()
obj = {
"@context": ap.AS_CTX,
"type": "Move",
"id": outbox_object_id(move_id),
"actor": LOCAL_ACTOR.ap_id,
"object": LOCAL_ACTOR.ap_id,
"target": target,
}
outbox_object = await save_outbox_object(db_session, move_id, obj)
if not outbox_object.id:
raise ValueError("Should never happen")
recipients = await _get_followers_recipients(db_session)
for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id)
# Store the moved to in order to update the profile
set_moved_to(target)
await db_session.commit()
async def send_self_destruct(db_session: AsyncSession) -> None:
delete_id = allocate_outbox_id()
delete = {
"@context": ap.AS_EXTENDED_CTX,
"id": outbox_object_id(delete_id),
"type": "Delete",
"actor": ID,
"object": ID,
"to": [ap.AS_PUBLIC],
}
outbox_object = await save_outbox_object(
db_session,
delete_id,
delete,
)
if not outbox_object.id:
raise ValueError("Should never happen")
recipients = await compute_all_known_recipients(db_session)
for rcp in recipients:
await new_outgoing_activity(db_session, rcp, outbox_object.id)
await db_session.commit()
async def send_create(
db_session: AsyncSession,
ap_type: str,
@ -708,6 +776,17 @@ async def _compute_recipients(
return recipients
async def compute_all_known_recipients(db_session: AsyncSession) -> set[str]:
return {
actor.shared_inbox_url or actor.inbox_url
for actor in (
await db_session.scalars(
select(models.Actor).where(models.Actor.is_deleted.is_(False))
)
).all()
}
async def _get_following(db_session: AsyncSession) -> list[models.Follower]:
return (
(
@ -859,7 +938,7 @@ async def _handle_delete_activity(
except ap.ObjectNotFoundError:
pass
if ap_object_to_delete is None:
if ap_object_to_delete is None or not ap_object_to_delete.is_from_db:
logger.info(
"Received Delete for an unknown object "
f"{delete_activity.activity_object_ap_id}"
@ -1281,13 +1360,16 @@ async def _handle_move_activity(
return None
# Fetch the target account
new_actor_id = move_activity.ap_object.get("target")
if not new_actor_id:
target = move_activity.ap_object.get("target")
if not target:
logger.warning("Missing target")
return None
new_actor_id = ap.get_id(target)
new_actor = await fetch_actor(db_session, new_actor_id)
logger.info(f"Moving {old_actor_id} to {new_actor_id}")
# Ensure the target account references the old account
if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])):
logger.warning(
@ -1310,7 +1392,21 @@ async def _handle_move_activity(
await _send_undo(db_session, following.outbox_object.ap_id)
# Follow the new one
await _send_follow(db_session, new_actor_id)
if not (
await db_session.execute(
select(models.Following).where(models.Following.ap_actor_id == new_actor_id)
)
).scalar():
await _send_follow(db_session, new_actor_id)
else:
logger.info(f"Already following target {new_actor_id}")
notif = models.Notification(
notification_type=models.NotificationType.MOVE,
actor_id=new_actor.id,
inbox_object_id=move_activity.id,
)
db_session.add(notif)
async def _handle_update_activity(
@ -1654,10 +1750,32 @@ async def _handle_announce_activity(
# We already know about this object, show the announce in the
# stream if it's not already there, from an followed actor
# and if we haven't seen it recently
skip_delta = timedelta(hours=1)
delta_from_original = now() - as_utc(
relates_to_inbox_object.ap_published_at # type: ignore
)
dup_count = 0
if (
now() - as_utc(relates_to_inbox_object.ap_published_at) # type: ignore
) > timedelta(hours=1):
not relates_to_inbox_object.is_hidden_from_stream
and delta_from_original < skip_delta
) or (
dup_count := (
await db_session.scalar(
select(func.count(models.InboxObject.id)).where(
models.InboxObject.ap_type == "Announce",
models.InboxObject.ap_published_at > now() - skip_delta,
models.InboxObject.relates_to_inbox_object_id
== relates_to_inbox_object.id,
models.InboxObject.is_hidden_from_stream.is_(False),
)
)
)
) > 0:
logger.info(f"Deduping Announce {delta_from_original=}/{dup_count=}")
announce_activity.is_hidden_from_stream = True
else:
announce_activity.is_hidden_from_stream = not is_from_following
else:
# Save it as an inbox object
if not announce_activity.activity_object_ap_id:
@ -1721,6 +1839,7 @@ async def _process_transient_object(
raw_object: ap.RawObject,
from_actor: models.Actor,
) -> None:
# TODO: track featured/pinned objects for actors
ap_type = raw_object["type"]
if ap_type in ["Add", "Remove"]:
logger.info(f"Dropping unsupported {ap_type} object")
@ -1766,7 +1885,13 @@ async def save_to_inbox(
)
forwarded_by_actor = await fetch_actor(db_session, sent_by_ap_actor_id)
if not (await ldsig.verify_signature(db_session, raw_object)):
is_sig_verified = False
try:
is_sig_verified = await ldsig.verify_signature(db_session, raw_object)
except Exception:
logger.exception("Failed to verify LD sig")
if not is_sig_verified:
logger.warning(
f"Failed to verify LD sig, fetching remote object {raw_object_id}"
)
@ -1956,7 +2081,7 @@ async def _prefetch_actor_outbox(
"""Try to fetch some notes to fill the stream"""
saved = 0
outbox = await ap.parse_collection(actor.outbox_url, limit=20)
for activity in outbox:
for activity in outbox[:20]:
activity_id = ap.get_id(activity)
raw_activity = await ap.fetch(activity_id)
if ap.as_list(raw_activity["type"])[0] == "Create":
@ -1969,7 +2094,8 @@ async def _prefetch_actor_outbox(
if not saved_inbox_object.in_reply_to:
saved_inbox_object.is_hidden_from_stream = False
saved += 1
saved += 1
if saved >= 5:
break

View File

@ -12,8 +12,10 @@ from fastapi import HTTPException
from fastapi import Request
from itsdangerous import URLSafeTimedSerializer
from loguru import logger
from markdown import markdown
from app.utils.emoji import _load_emojis
from app.utils.version import get_version_commit
ROOT_DIR = Path().parent.resolve()
@ -24,7 +26,7 @@ VERSION_COMMIT = "dev"
try:
from app._version import VERSION_COMMIT # type: ignore
except ImportError:
pass
VERSION_COMMIT = get_version_commit()
# Force reloading cache when the CSS is updated
CSS_HASH = "none"
@ -34,6 +36,31 @@ try:
except FileNotFoundError:
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
js_data_common = (ROOT_DIR / "app" / "static" / "common-admin.js").read_bytes()
js_data_new = (ROOT_DIR / "app" / "static" / "new.js").read_bytes()
JS_HASH = hashlib.md5(
js_data_common + js_data_new, usedforsecurity=False
).hexdigest()
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}"
USER_AGENT = f"microblogpub/{VERSION}"
@ -71,6 +98,9 @@ class Config(pydantic.BaseModel):
metadata: list[_ProfileMetadata] | None = None
code_highlighting_theme = "friendly_grayscale"
blocked_servers: list[_BlockedServer] = []
custom_footer: str | None = None
emoji: str | None = None
also_known_as: str | None = None
inbox_retention_days: int = 15
@ -119,8 +149,16 @@ if 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}
ALSO_KNOWN_AS = CONFIG.also_known_as
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
CUSTOM_FOOTER = (
markdown(
CONFIG.custom_footer.replace("{version}", VERSION), extensions=["mdx_linkify"]
)
if CONFIG.custom_footer
else None
)
BASE_URL = ID
DEBUG = CONFIG.debug
@ -130,6 +168,9 @@ KEY_PATH = (
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
)
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
if CONFIG.emoji:
EMOJIS = CONFIG.emoji
# Emoji template for the FE
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
@ -137,6 +178,8 @@ _load_emojis(ROOT_DIR, BASE_URL)
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
MOVED_TO = _get_moved_to()
session_serializer = URLSafeTimedSerializer(
CONFIG.secret,

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import DB_PATH
from app.config import DEBUG
from app.config import SQLALCHEMY_DATABASE_URL
engine = create_engine(
@ -18,7 +19,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
async_engine = create_async_engine(
DATABASE_URL, future=True, echo=False, connect_args={"timeout": 15}
DATABASE_URL, future=True, echo=DEBUG, connect_args={"timeout": 15}
)
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)

View File

@ -130,6 +130,7 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
k.load_pub(actor["publicKey"]["publicKeyPem"])
# 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]:
raise ValueError(
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
@ -215,10 +216,13 @@ async def httpsig_checker(
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
return HTTPSigInfo(has_valid_signature=False)
has_valid_signature = _verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
)
# FIXME: fetch/update the user if the signature is wrong
httpsig_info = HTTPSigInfo(
has_valid_signature=_verify_h(
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
),
has_valid_signature=has_valid_signature,
signed_by_ap_actor_id=k.owner,
server=server,
)

View File

@ -112,10 +112,13 @@ async def process_next_incoming_activity(
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
try:
async with db_session.begin_nested():
await save_to_inbox(
db_session,
next_activity.ap_object,
next_activity.sent_by_ap_actor_id,
await asyncio.wait_for(
save_to_inbox(
db_session,
next_activity.ap_object,
next_activity.sent_by_ap_actor_id,
),
timeout=60,
)
except httpx.TimeoutException as exc:
url = exc._request.url if exc._request else None

View File

@ -10,6 +10,7 @@ from app.source import _MENTION_REGEX
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
query = query.strip()
if query.startswith("@") or _MENTION_REGEX.match("@" + query):
query = await webfinger.get_actor_url(query) # type: ignore # None check below

View File

@ -8,6 +8,7 @@ from typing import Any
from typing import MutableMapping
from typing import Type
import fastapi
import httpx
import starlette
from asgiref.typing import ASGI3Application
@ -20,6 +21,7 @@ from fastapi import FastAPI
from fastapi import Form
from fastapi import Request
from fastapi import Response
from fastapi.exception_handlers import http_exception_handler
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse
from fastapi.responses import PlainTextResponse
@ -35,6 +37,7 @@ from sqlalchemy.orm import joinedload
from starlette.background import BackgroundTask
from starlette.datastructures import Headers
from starlette.datastructures import MutableHeaders
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse
from starlette.types import Message
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # type: ignore
@ -61,20 +64,25 @@ from app.config import USERNAME
from app.config import is_activitypub_requested
from app.config import verify_csrf_token
from app.database import AsyncSession
from app.database import async_session
from app.database import get_db_session
from app.incoming_activities import new_ap_incoming_activity
from app.templates import is_current_user_admin
from app.uploads import UPLOAD_DIR
from app.utils import pagination
from app.utils.emoji import EMOJIS_BY_NAME
from app.utils.highlight import HIGHLIGHT_CSS_HASH
from app.utils.url import check_url
from app.webfinger import get_remote_follow_template
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(32)
# Only images <1MB will be cached, so 64MB of data will be cached
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(64)
# TODO(ts):
# Next:
# - self-destruct + move support and actions/tasks for
# - doc for prune/move/delete
# - fix issue with followers from a blocked server (skip it?)
# - allow to share old notes
# - only show 10 most recent threads in DMs
@ -118,21 +126,21 @@ class CustomMiddleware:
# And add the security headers
headers = MutableHeaders(scope=message)
headers["X-Request-ID"] = request_id
headers["Server"] = "microblogpub"
headers["x-powered-by"] = "microblogpub"
headers[
"referrer-policy"
] = "no-referrer, strict-origin-when-cross-origin"
headers["x-content-type-options"] = "nosniff"
headers["x-xss-protection"] = "1; mode=block"
headers["x-frame-options"] = "SAMEORIGIN"
# TODO(ts): disallow inline CSS?
headers[
"content-security-policy"
] = "default-src 'self'; style-src 'self' 'unsafe-inline';"
headers["x-frame-options"] = "DENY"
headers["permissions-policy"] = "interest-cohort=()"
headers["content-security-policy"] = (
f"default-src 'self'; "
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
)
if not DEBUG:
headers[
"strict-transport-security"
] = "max-age=63072000; includeSubdomains"
headers["strict-transport-security"] = "max-age=63072000;"
await send(message) # type: ignore
@ -164,7 +172,15 @@ class CustomMiddleware:
return None
app = FastAPI(docs_url=None, redoc_url=None)
def _check_0rtt_early_data(request: Request) -> None:
"""Disable TLS1.3 0-RTT requests for non-GET."""
if request.headers.get("Early-Data", None) == "1" and request.method != "GET":
raise fastapi.HTTPException(status_code=425, detail="Too early")
app = FastAPI(
docs_url=None, redoc_url=None, dependencies=[Depends(_check_0rtt_early_data)]
)
app.mount(
"/custom_emoji",
StaticFiles(directory="data/custom_emoji"),
@ -192,6 +208,37 @@ logger_format = (
logger.add(sys.stdout, format=logger_format, level="DEBUG" if DEBUG else "INFO")
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(
request: Request,
exc: StarletteHTTPException,
) -> templates.TemplateResponse | JSONResponse:
accept_value = request.headers.get("accept")
if (
accept_value
and accept_value.startswith("text/html")
and 400 <= exc.status_code < 600
):
async with async_session() as db_session:
title = (
{
404: "Oops, nothing to see here",
500: "Oops, something went wrong",
}
).get(exc.status_code, exc.detail)
try:
return await templates.render_template(
db_session,
request,
"error.html",
{"title": title},
status_code=exc.status_code,
)
finally:
await db_session.close()
return await http_exception_handler(request, exc)
class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json"
@ -204,6 +251,7 @@ async def index(
page: int | None = None,
) -> templates.TemplateResponse | ActivityPubResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
page = page or 1
@ -234,6 +282,7 @@ async def index(
),
),
)
.order_by(models.OutboxObject.is_pinned.desc())
.order_by(models.OutboxObject.ap_published_at.desc())
.offset(page_offset)
.limit(page_size)
@ -685,10 +734,10 @@ async def tag_by_name(
.join(models.TaggedOutboxObject)
.where(*where)
)
if not tagged_count:
raise HTTPException(status_code=404)
if is_activitypub_requested(request):
if not tagged_count:
raise HTTPException(status_code=404)
outbox_object_ids = await db_session.execute(
select(models.OutboxObject.ap_id)
.join(
@ -736,6 +785,7 @@ async def tag_by_name(
"request": request,
"objects": outbox_objects,
},
status_code=200 if len(outbox_objects) else 404,
)
@ -904,29 +954,49 @@ def _filter_proxy_resp_headers(
}
def _strip_content_type(headers: dict[str, str]) -> dict[str, str]:
return {k: v for k, v in headers.items() if k.lower() != "content-type"}
def _add_cache_control(headers: dict[str, str]) -> dict[str, str]:
return {**headers, "Cache-Control": "max-age=31536000"}
@app.get("/proxy/media/{encoded_url}")
async def serve_proxy_media(request: Request, encoded_url: str) -> StreamingResponse:
async def serve_proxy_media(
request: Request,
encoded_url: str,
) -> StreamingResponse | PlainTextResponse:
# Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url)
proxy_resp = await _proxy_get(request, url, stream=True)
if proxy_resp.status_code >= 300:
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
return PlainTextResponse(
"proxy error",
status_code=proxy_resp.status_code,
)
return StreamingResponse(
proxy_resp.aiter_raw(),
status_code=proxy_resp.status_code,
headers=_filter_proxy_resp_headers(
proxy_resp,
[
"content-length",
"content-type",
"content-range",
"accept-ranges" "etag",
"cache-control",
"expires",
"date",
"last-modified",
],
headers=_add_cache_control(
_filter_proxy_resp_headers(
proxy_resp,
[
"content-length",
"content-type",
"content-range",
"accept-ranges",
"etag",
"expires",
"date",
"last-modified",
],
)
),
background=BackgroundTask(proxy_resp.aclose),
)
@ -954,22 +1024,24 @@ async def serve_proxy_media_resized(
)
proxy_resp = await _proxy_get(request, url, stream=False)
if proxy_resp.status_code != 200:
if proxy_resp.status_code >= 300:
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
return PlainTextResponse(
proxy_resp.content,
"proxy error",
status_code=proxy_resp.status_code,
)
# Filter the headers
proxy_resp_headers = _filter_proxy_resp_headers(
proxy_resp,
[
"content-type",
"etag",
"cache-control",
"expires",
"last-modified",
],
proxy_resp_headers = _add_cache_control(
_filter_proxy_resp_headers(
proxy_resp,
[
"content-type",
"etag",
"expires",
"last-modified",
],
)
)
try:
@ -978,23 +1050,31 @@ async def serve_proxy_media_resized(
if getattr(i, "is_animated", False):
raise ValueError
i.thumbnail((size, size))
resized_buf = BytesIO()
i.save(resized_buf, format=i.format)
is_webp = False
try:
resized_buf = BytesIO()
i.save(resized_buf, format="webp")
is_webp = True
except Exception:
logger.exception("Failed to convert to webp")
resized_buf = BytesIO()
i.save(resized_buf, format=i.format)
resized_buf.seek(0)
resized_content = resized_buf.read()
resized_mimetype = i.get_format_mimetype() # type: ignore
resized_mimetype = (
"image/webp" if is_webp else i.get_format_mimetype() # type: ignore
)
# Only cache images < 1MB
if len(resized_content) < 2**20:
_RESIZED_CACHE[(url, size)] = (
resized_content,
resized_mimetype,
proxy_resp_headers,
_strip_content_type(proxy_resp_headers),
)
return PlainTextResponse(
resized_content,
media_type=resized_mimetype,
headers=proxy_resp_headers,
headers=_strip_content_type(proxy_resp_headers),
)
except ValueError:
return PlainTextResponse(
@ -1028,6 +1108,7 @@ async def serve_attachment(
return FileResponse(
UPLOAD_DIR / content_hash,
media_type=upload.content_type,
headers={"Cache-Control": "max-age=31536000"},
)
@ -1049,7 +1130,8 @@ async def serve_attachment_thumbnail(
return FileResponse(
UPLOAD_DIR / (content_hash + "_resized"),
media_type=upload.content_type,
media_type="image/webp",
headers={"Cache-Control": "max-age=31536000"},
)

View File

@ -45,7 +45,7 @@ class Actor(Base, BaseActor):
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
ap_id = Column(String, unique=True, nullable=False, index=True)
ap_id: Mapped[str] = Column(String, unique=True, nullable=False, index=True)
ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False)
ap_type = Column(String, nullable=False)
@ -126,7 +126,7 @@ class InboxObject(Base, BaseObject):
is_deleted = Column(Boolean, nullable=False, default=False)
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
replies_count = Column(Integer, nullable=False, default=0)
replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
@ -176,7 +176,7 @@ class OutboxObject(Base, BaseObject):
likes_count = Column(Integer, nullable=False, default=0)
announces_count = Column(Integer, nullable=False, default=0)
replies_count = Column(Integer, nullable=False, default=0)
replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
webmentions_count: Mapped[int] = Column(
Integer, nullable=False, default=0, server_default="0"
)
@ -537,6 +537,8 @@ class NotificationType(str, enum.Enum):
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
MOVE = "move"
LIKE = "like"
UNDO_LIKE = "undo_like"

View File

@ -67,6 +67,7 @@ async def _send_actor_update_if_needed(
logger.info("Will send an Update for the local actor")
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 save_outbox_object
@ -85,24 +86,8 @@ async def _send_actor_update_if_needed(
# Send the update to the followers collection and all the actor we have ever
# contacted
followers = (
(
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
recipients = await compute_all_known_recipients(db_session)
for rcp in recipients:
await new_outgoing_activity(
db_session,
recipient=rcp,

View File

@ -13,6 +13,40 @@ $code-highlight-background: #f0f0f0;
// Load custom theme
@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 {
.p-summary {
display: inline-block;
@ -61,13 +95,6 @@ blockquote {
color: $muted-color;
}
.poll-bar {
width:100%;height:20px;
line {
stroke: $secondary-color;
}
}
.light-background {
background: $light-background;
}
@ -116,6 +143,9 @@ dl {
strong {
color: $primary-color;
}
span {
color: $muted-color;
}
}
div.highlight {
@ -189,11 +219,27 @@ main {
max-width: 1000px;
margin: 30px auto;
}
.centered {
display: flex;
justify-content: center;
align-items: center;
}
footer {
width: 100%;
max-width: 1000px;
margin: 20px auto;
color: $muted-color;
width: 100%;
max-width: 1000px;
margin: 20px auto;
color: $muted-color;
p {
margin: 0;
}
}
.tiny-actor-icon {
max-width: 24px;
max-height: 24px;
position: relative;
top: 5px;
}
.actor-box {
display: flex;
@ -217,6 +263,9 @@ footer {
padding: 0 20px;
li {
display: block;
span {
padding-right:10px;
}
}
}
@ -251,6 +300,57 @@ footer {
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 {
form {
margin: 15px 0;
@ -311,7 +411,7 @@ nav.flexbox {
}
}
.activity-attachment {
margin: 30px 0;
margin: 30px 0 20px 0;
img, audio, video {
width: 100%;
max-width: 740px;
@ -322,6 +422,20 @@ nav.flexbox {
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 {
border: 2px dashed $secondary-color;
}
@ -344,3 +458,54 @@ nav.flexbox {
.emoji, .custom-emoji {
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;
}
}
}
}

View File

@ -1,16 +1,17 @@
import re
import typing
from markdown import markdown
from sqlalchemy import select
from app import models
from app import webfinger
from app.actor import Actor
from app.actor import fetch_actor
from app.config import BASE_URL
from app.database import AsyncSession
from app.utils import emoji
if typing.TYPE_CHECKING:
from app.actor import Actor
def _set_a_attrs(attrs, new=False):
attrs[(None, "target")] = "_blank"
@ -24,9 +25,7 @@ _HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
async def _hashtagify(
db_session: AsyncSession, content: str
) -> tuple[str, list[dict[str, str]]]:
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
tags = []
hashtags = re.findall(_HASHTAG_REGEX, content)
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
@ -41,14 +40,20 @@ async def _hashtagify(
async def _mentionify(
db_session: AsyncSession,
content: str,
) -> tuple[str, list[dict[str, str]], list[Actor]]:
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
from app import models
from app.actor import fetch_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)
select(models.Actor).where(
models.Actor.handle == mention,
models.Actor.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if not actor:
@ -69,19 +74,19 @@ async def _mentionify(
async def markdownify(
db_session: AsyncSession,
content: str,
mentionify: bool = True,
hashtagify: bool = True,
) -> tuple[str, list[dict[str, str]], list[Actor]]:
enable_mentionify: bool = True,
enable_hashtagify: bool = True,
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
"""
>>> content, tags = markdownify("Hello")
"""
tags = []
mentioned_actors: list[Actor] = []
if hashtagify:
content, hashtag_tags = await _hashtagify(db_session, content)
mentioned_actors: list["Actor"] = []
if enable_hashtagify:
content, hashtag_tags = hashtagify(content)
tags.extend(hashtag_tags)
if mentionify:
if enable_mentionify:
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
tags.extend(mention_tags)

View File

@ -0,0 +1,11 @@
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();
};
});
}
});

View File

@ -26,7 +26,7 @@ from app.actor import LOCAL_ACTOR
from app.ap_object import Attachment
from app.ap_object import Object
from app.config import BASE_URL
from app.config import CSS_HASH
from app.config import CUSTOM_FOOTER
from app.config import DEBUG
from app.config import VERSION
from app.config import generate_csrf_token
@ -90,6 +90,7 @@ async def render_template(
request: Request,
template: str,
template_args: dict[str, Any] | None = None,
status_code: int = 200,
) -> TemplateResponse:
if template_args is None:
template_args = {}
@ -103,7 +104,6 @@ async def render_template(
"request": request,
"debug": DEBUG,
"microblogpub_version": VERSION,
"css_hash": CSS_HASH,
"is_admin": is_admin,
"csrf_token": generate_csrf_token(),
"highlight_css": HIGHLIGHT_CSS,
@ -131,8 +131,10 @@ async def render_template(
select(func.count(models.Following.id))
),
"actor_types": ap.ACTOR_TYPES,
"custom_footer": CUSTOM_FOOTER,
**template_args,
},
status_code=status_code,
)
@ -377,7 +379,7 @@ def _html2text(content: str) -> str:
def _replace_emoji(u: str, _) -> str:
filename = hex(ord(u))[2:]
filename = "-".join(hex(ord(c))[2:] for c in u)
return config.EMOJI_TPL.format(filename=filename, raw=u)
@ -414,3 +416,6 @@ _templates.env.filters["pluralize"] = _pluralize
_templates.env.filters["parse_datetime"] = _parse_datetime
_templates.env.filters["poll_item_pct"] = _poll_item_pct
_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

View File

@ -10,7 +10,9 @@
{% for anybox_object, convo, actors in threads %}
<div class="actor-action">
With {% for actor in actors %}
<a href="">{{ actor.handle }}</a>
<a href="{{ url_for("admin_profile") }}?actor_id={{ actor.ap_id }}">
{{ actor.handle }}
</a>
{% endfor %}
</div>
{{ utils.display_object(anybox_object) }}

View File

@ -19,7 +19,7 @@
{% for inbox_object in inbox %}
{% if inbox_object.ap_type == "Announce" %}
{{ utils.actor_action(inbox_object, "shared") }}
{{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
{{ utils.display_object(inbox_object) }}
@ -27,7 +27,7 @@
{{ utils.actor_action(inbox_object, "followed you") }}
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
{% elif inbox_object.ap_type == "Like" %}
{{ utils.actor_action(inbox_object, "liked one of your post") }}
{{ utils.actor_action(inbox_object, "liked one of your post", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% else %}
<p>

View File

@ -25,7 +25,7 @@
</nav>
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
<form class="form admin-new" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
{{ utils.embed_csrf_token() }}
{{ utils.embed_redirect_url() }}
<p>
@ -38,7 +38,7 @@
{% if request.query_params.type == "Article" %}
<p>
<input type="text" style="width:95%" name="name" placeholder="Title">
<input type="text" class="width-95" name="name" placeholder="Title">
</p>
{% endif %}
@ -49,7 +49,7 @@
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
{% endfor %}
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!">{{ content }}</textarea>
{% if request.query_params.type == "Question" %}
<p>
@ -69,20 +69,20 @@
</p>
{% for i in ["1", "2", "3", "4"] %}
<p>
<input type="text" name="poll_answer_{{ i }}" style="width:95%;" placeholder="Option {{ i }}, leave empty to disable">
<input type="text" name="poll_answer_{{ i }}" class="width-95" placeholder="Option {{ i }}, leave empty to disable">
</p>
{% endfor %}
{% endif %}
<p>
<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%;">
<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">
</p>
<p>
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
</p>
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
<p>
<input id="files" name="files" type="file" multiple style="width:95%;">
<input id="files" name="files" type="file" class="width-95" multiple>
</p>
<div id="alts"></div>
<p>
@ -90,5 +90,5 @@
</p>
</form>
</div>
<script src="/static/new.js"></script>
<script src="/static/new.js?v={{ JS_HASH }}"></script>
{% endblock %}

View File

@ -9,10 +9,21 @@
{{ utils.display_actor(actor, actors_metadata, with_details=True) }}
{% for inbox_object in inbox_objects %}
{% if inbox_object.ap_type == "Announce" %}
{{ utils.actor_action(inbox_object, "shared") }}
{{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
{% else %}
{{ utils.display_object(inbox_object) }}
{% endif %}
{% 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 %}

View File

@ -12,7 +12,7 @@
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
{% for outbox_object in objects %}
<li>
<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>
<span class="muted">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</span> <a href="{{ outbox_object.url }}">{{ outbox_object.name }}</a>
</li>
{% endfor %}
</ul>

12
app/templates/error.html Normal file
View File

@ -0,0 +1,12 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>{{ title }}</title>
{% endblock %}
{% block content %}
<div class="centered primary-color">
<h1>{{ title }}</h1>
</div>
{% endblock %}

View File

@ -29,7 +29,7 @@
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %}
<div style="margin:30px 0 0 0;">
<div class="public-top-menu">
<nav class="flexbox">
<ul>
<li>{{ header_link("index", "Notes") }}</li>

View File

@ -21,26 +21,34 @@
{% block content %}
{% include "header.html" %}
<div class="h-feed">
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
{% for outbox_object in objects %}
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }}
{% elif outbox_object.ap_type == "Announce" %}
<div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% if objects %}
<div class="h-feed">
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
{% for outbox_object in objects %}
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
{{ utils.display_object(outbox_object) }}
{% elif outbox_object.ap_type == "Announce" %}
<div class="shared-header"><strong>{{ utils.display_tiny_actor_icon(local_actor) }} {{ local_actor.display_name | clean_html(local_actor) | safe }}</strong> shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
{% 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>
{% else %}
<div class="empty-state">
<p>Nothing to see here yet!</p>
</div>
{% endif %}
{% endfor %}
</div>
<div class="box">
{% if has_previous_page %}
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
{% endif %}
{% if has_next_page %}
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
{% endif %}
</div>
{% endblock %}

View File

@ -2,15 +2,15 @@
{% extends "layout.html" %}
{% block content %}
<div class="box">
<div style="display:flex;column-gap: 20px;">
<div class"indieauth-box">
{% if client.logo %}
<div style="flex:initial;width:100px;">
<img src="{{client.logo | media_proxy_url }}" style="max-width:100px;" alt="{{ client.name }} logo">
<div class="indieauth-logo">
<img src="{{client.logo | media_proxy_url }}" alt="{{ client.name }} logo">
</div>
{% endif %}
<div style="flex:1;">
<div style="padding-left: 20px;">
<a class="lcolor" style="font-size:1.2em;font-weight:600;" href="{{ client.url }}">{{ client.name }}</a>
<div class="indieauth-details">
<div>
<a class="lcolor" 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>

View File

@ -4,14 +4,12 @@
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="/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" 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="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
{{ highlight_css }}
</style>
<style>{{ highlight_css }}</style>
{% block head %}{% endblock %}
</head>
<body>
@ -23,7 +21,7 @@
{% set url_for = request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %}
<div style="margin-bottom:30px;padding: 0 20px;">
<div class="admin-menu">
<nav class="flexbox">
<ul>
<li>{{ admin_link("index", "Public") }}</li>
@ -47,8 +45,15 @@
<footer class="footer">
<div class="box">
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>.
{% if custom_footer %}
{{ 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>
</footer>
{% if is_admin %}
<script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
{% endif %}
</body>
</html>

View File

@ -1,8 +1,10 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block content %}
<div style="display:grid;height:80%;">
<div style="margin:auto;">
<div class="centered">
{% if error %}
<p class="primary-color">Invalid password.</p>
{% endif %}
<form class="form" action="/admin/login" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect" value="{{ redirect }}">
@ -10,5 +12,4 @@
<input type="submit" value="login">
</form>
</div>
</div>
{% endblock %}

View File

@ -19,7 +19,7 @@
{% if error %}
<div class="box error-box">
{% if error.value == "NOT_FOUND" %}
<p>The remote object was deleted.</p>
<p>The remote object is unavailable.</p>
{% elif error.value == "TIMEOUT" %}
<p>Lookup timed out, please try refreshing the page.</p>
{% else %}

View File

@ -5,9 +5,10 @@
<title>{{ local_actor.display_name }} - Notifications</title>
{% endblock %}
{% macro notif_actor_action(notif, text) %}
{% macro notif_actor_action(notif, text, with_icon=False) %}
<div class="actor-action">
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">{{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">
{% 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>
</div>
{% endmacro %}
@ -35,18 +36,26 @@
{%- elif notif.notification_type.value == "follow_request_rejected" %}
{{ notif_actor_action(notif, "rejected your follow request") }}
{{ utils.display_actor(notif.actor, actors_metadata) }}
{%- elif notif.notification_type.value == "move" %}
{# 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" %}
{{ notif_actor_action(notif, "liked a post") }}
{{ notif_actor_action(notif, "liked a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "undo_like" %}
{{ notif_actor_action(notif, "unliked a post") }}
{{ notif_actor_action(notif, "unliked a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "announce" %}
{{ notif_actor_action(notif, "shared a post") }}
{{ notif_actor_action(notif, "shared a post", with_icon=True) }}
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "undo_announce" %}
{{ notif_actor_action(notif, "unshared a post") }}
{{ utils.display_object(notif.outbox_object) }}
{{ utils.display_object(notif.outbox_object, with_icon=True) }}
{% elif notif.notification_type.value == "mention" %}
{{ notif_actor_action(notif, "mentioned you") }}
{{ utils.display_object(notif.inbox_object) }}
@ -57,7 +66,7 @@
{% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% endif %}
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
</div>
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "updated_webmention" %}
@ -67,7 +76,7 @@
{% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% endif %}
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
</div>
{{ utils.display_object(notif.outbox_object) }}
{% elif notif.notification_type.value == "deleted_webmention" %}
@ -77,7 +86,7 @@
{% if facepile_item %}
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
{% endif %}
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
</div>
{{ utils.display_object(notif.outbox_object) }}
{% else %}
@ -88,4 +97,15 @@
</div>
{%- endfor %}
</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 %}

View File

@ -3,7 +3,11 @@
{% block head %}
{% if outbox_object %}
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %}
{% if outbox_object.content %}
{% 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>
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
<link rel="alternate" href="{{ request.url }}" type="application/activity+json">

View File

@ -96,11 +96,11 @@
</form>
{% endmacro %}
{% macro admin_delete_button(ap_object_id) %}
<form action="{{ request.url_for("admin_actions_delete") }}" method="POST" onsubmit="return confirm('Do you really want to delete this object?');">
{% macro admin_delete_button(ap_object) %}
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<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 %}">
<input type="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
<input type="submit" value="delete">
</form>
{% endmacro %}
@ -154,9 +154,10 @@
</form>
{% endmacro %}
{% macro admin_expand_button(ap_object_id) %}
{% macro admin_expand_button(ap_object) %}
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
<form action="{{ url_for("admin_object") }}" method="GET">
<input type="hidden" name="ap_id" value="{{ ap_object_id }}">
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
<button type="submit">expand</button>
</form>
{% endmacro %}
@ -180,9 +181,15 @@
</nav>
{% endmacro %}
{% macro actor_action(inbox_object, text) %}
{% macro display_tiny_actor_icon(actor) %}
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
{% endmacro %}
{% macro actor_action(inbox_object, text, with_icon=False) %}
<div class="actor-action">
<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 }}
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
{% 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>
</div>
@ -199,7 +206,7 @@
<div class="icon-box">
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
</div>
<a href="{{ actor.url }}" class="u-url" style="">
<a href="{{ actor.url }}" class="u-url">
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
<div class="actor-handle p-name">{{ actor.handle }}</div>
</a>
@ -216,7 +223,7 @@
{% elif metadata.is_follow_request_sent %}
<li>follow request sent</li>
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
{% else %}
{% elif not actor.moved_to %}
<li>{{ admin_follow_button(actor) }}</li>
{% endif %}
{% if metadata.is_follower %}
@ -224,7 +231,11 @@
{% if not metadata.is_following and not with_details %}
<li>{{ admin_profile_button(actor.ap_id) }}</li>
{% endif %}
</li>
{% elif actor.is_from_db and not with_details %}
<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 %}
{% if actor.is_from_db %}
{% if actor.is_blocked %}
@ -285,16 +296,16 @@
{% macro display_og_meta(object) %}
{% if object.og_meta %}
{% for og_meta in object.og_meta %}
<div class="activity-og-meta" style="display:flex;column-gap: 20px;margin:20px 0;">
<div class="activity-og-meta">
{% if og_meta.image %}
<div>
<img src="{{ og_meta.image | media_proxy_url }}" style="max-width:200px;max-height:100px;">
<img src="{{ og_meta.image | media_proxy_url }}">
</div>
{% endif %}
<div>
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
{% if og_meta.site_name %}
<small style="display:block;">{{ og_meta.site_name }}</small>
<small>{{ og_meta.site_name }}</small>
{% endif %}
</div>
</div>
@ -307,27 +318,27 @@
{% for attachment in object.attachments %}
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
<div>
<label for="{{attachment.proxied_url}}" class="label-btn" style="display:inline-block;">show/hide sensitive content</label>
<div class="attachment-wrapper">
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
<div>
<div class="sensitive-attachment">
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
<div class="sensitive-attachment-box">
<div></div>
{% else %}
<div style="margin-top:20px;">
<div class="attachment-item">
{% endif %}
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
{% if attachment.url not in object.inlined_images %}
<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">
{% endif %}
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
{% 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 %} style="width:480px;" class="attachment"></audio>
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
{% elif attachment.type == "Link" %}
<a href="{{ attachment.url }}" class="attachment" style="display:inline-block;margin-bottom: 15px;">{{ attachment.url }}</a>
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
{% else %}
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
{% endif %}
@ -358,13 +369,13 @@
{% endif %}
{% if object.in_reply_to %}
<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">
<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 }}" class="in-reply-to" rel="nofollow">
in reply to {{ object.in_reply_to|truncate(64, True) }}
</a>
{% endif %}
{% if object.ap_type == "Article" %}
<h2 class="p-name" style="margin-top:0;">{{ object.name }}</h2>
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
{% endif %}
{% if is_article_mode %}
@ -394,11 +405,11 @@
{% endif %}
{% if object.poll_items %}
<ul style="list-style-type: none;padding:0;">
<ul class="poll-items">
{% for item in object.poll_items %}
<li style="display:block;">
<li>
{% set pct = item | poll_item_pct(object.poll_voters_count) %}
<p style="margin:20px 0 10px 0;">
<p>
{% 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}}">
<label for="{{object.permalink_id}}-{{item.name}}">
@ -407,17 +418,17 @@
{{ item.name | clean_html(object) | safe }}
{% if object.voted_for_answers and item.name in object.voted_for_answers %}
<span class="muted" style="padding-left:20px;">you voted for this answer</span>
<span class="muted poll-vote">you voted for this answer</span>
{% endif %}
{% if can_vote %}
</label>
{% endif %}
<span style="float:right;">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
<span class="float-right">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
</p>
<svg class="poll-bar">
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px" style="stroke-width: 20px;"></line>
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px"></line>
</svg>
</li>
{% endfor %}
@ -440,7 +451,7 @@
</div>
{% endif %}
<div class="activity-attachment" style="margin-bottom:20px;">
<div class="activity-attachment">
{{ display_attachments(object) }}
</div>
@ -496,7 +507,7 @@
{% if (object.is_from_outbox or is_admin) and object.replies_count %}
<li>
<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>
<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>
</li>
{% endif %}
@ -508,7 +519,7 @@
<ul>
{% if object.is_from_outbox %}
<li>
{{ admin_delete_button(object.ap_id) }}
{{ admin_delete_button(object) }}
</li>
<li>
@ -559,7 +570,7 @@
{% endif %}
{% if object.is_from_inbox or object.is_from_outbox %}
<li>
{{ admin_expand_button(object.ap_id) }}
{{ admin_expand_button(object) }}
</li>
{% endif %}
</ul>
@ -568,17 +579,17 @@
{% if likes or shares or webmentions %}
<div style="display: flex;column-gap: 20px;flex-wrap: wrap;margin-top:20px;">
<div class="public-interactions">
{% if likes %}
<div style="flex: 0 1 30%;max-width: 50%;">Likes
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
<div class="interactions-block">Likes
<div class="facepile-wrapper">
{% for like in likes %}
<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.actor.resized_icon_url }}" alt="{{ like.actor.handle}}" style="max-width:50px;">
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" rel="noreferrer">
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}">
</a>
{% endfor %}
{% if object.likes_count > likes | length %}
<div style="display:inline-block;align-self:center;">
<div class="and-x-more">
and {{ object.likes_count - likes | length }} more.
</div>
{% endif %}
@ -587,15 +598,15 @@
{% endif %}
{% if shares %}
<div style="flex: 0 1 30%;max-width: 50%;">Shares
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
<div class="interactions-block">Shares
<div class="facepile-wrapper">
{% for share in shares %}
<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.actor.resized_icon_url }}" alt="{{ share.actor.handle}}" style="max-width:50px;">
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" rel="noreferrer">
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}">
</a>
{% endfor %}
{% if object.announces_count > shares | length %}
<div style="display:inline-block;align-self:center;">
<div class="and-x-more">
and {{ object.announces_count - shares | length }} more.
</div>
{% endif %}
@ -604,13 +615,13 @@
{% endif %}
{% if webmentions %}
<div style="flex: 0 1 30%;max-width: 50%;">Webmentions
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
<div class="interactions-block">Webmentions
<div class="facepile-wrapper">
{% for webmention in webmentions %}
{% set wm = webmention.as_facepile_item %}
{% if wm %}
<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 }}" style="max-width:50px;">
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" rel="noreferrer">
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}">
</a>
{% endif %}
{% endfor %}

View File

@ -5,6 +5,7 @@ import blurhash # type: ignore
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from PIL import ImageOps
from sqlalchemy import select
from app import activitypub as ap
@ -46,10 +47,12 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
height = None
if f.content_type.startswith("image"):
image_blurhash = blurhash.encode(f.file, x_components=4, y_components=3)
f.file.seek(0)
with Image.open(f.file) as _original_image:
# Fix image orientation (as we will remove the info from the EXIF
# metadata)
original_image = ImageOps.exif_transpose(_original_image)
with Image.open(f.file) as original_image:
# Re-creating the image drop the EXIF metadata
destination_image = Image.new(
original_image.mode,
original_image.size,
@ -57,15 +60,18 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
destination_image.putdata(original_image.getdata())
destination_image.save(
dest_filename,
format=original_image.format,
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:
width, height = original_image.size
original_image.thumbnail((740, 740))
original_image.save(
width, height = destination_image.size
destination_image.thumbnail((740, 740))
destination_image.save(
UPLOAD_DIR / f"{content_hash}_resized",
format=original_image.format,
format="webp",
)
except Exception:
logger.exception(

View File

@ -1,3 +1,5 @@
import base64
import hashlib
from functools import lru_cache
from bs4 import BeautifulSoup # type: ignore
@ -11,6 +13,9 @@ from app.config import CODE_HIGHLIGHTING_THEME
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
HIGHLIGHT_CSS = _FORMATTER.get_style_defs()
HIGHLIGHT_CSS_HASH = base64.b64encode(
hashlib.sha256(HIGHLIGHT_CSS.encode()).digest()
).decode()
@lru_cache(256)

View File

@ -1,3 +1,4 @@
import asyncio
import mimetypes
import re
from typing import Any
@ -36,7 +37,7 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
# FIXME some page have no <title>
raw = {
"url": url,
"title": soup.find("title").text,
"title": soup.find("title").text.strip(),
"image": None,
"description": None,
"site_name": urlparse(url).hostname,
@ -124,9 +125,21 @@ async def og_meta_from_note(
) -> list[dict[str, Any]]:
og_meta = []
urls = await external_urls(db_session, ro)
logger.debug(f"Lookig OG metadata in {urls=}")
for url in urls:
logger.debug(f"Processing {url}")
try:
maybe_og_meta = await _og_meta_from_url(url)
maybe_og_meta = None
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:
og_meta.append(maybe_og_meta.dict())
except httpx.HTTPError:

12
app/utils/version.py Normal file
View File

@ -0,0 +1,12 @@
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"

View File

@ -54,12 +54,17 @@ class Worker(Generic[T]):
{task, stop_task}, return_when=asyncio.FIRST_COMPLETED
)
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()]
logger.info(f"Cancelling {len(tasks)} tasks")
[task.cancel() for task in tasks]
await asyncio.gather(*tasks, return_exceptions=True)
try:
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")

View File

@ -6,7 +6,6 @@ from typing import Any
import bcrypt
import tomli_w
from markdown import markdown # type: ignore
from app.key import generate_key
@ -44,7 +43,7 @@ def setup_config_file(
dat["username"] = username
dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
dat["name"] = name
dat["summary"] = markdown(summary)
dat["summary"] = summary
dat["https"] = True
proto = "https"
dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png'

View File

@ -29,6 +29,7 @@ async def webfinger(
is_404 = False
resp: httpx.Response | None = None
async with httpx.AsyncClient() as client:
for i, proto in enumerate(protos):
try:
@ -59,7 +60,10 @@ async def webfinger(
if is_404:
return None
return resp.json()
if resp:
return resp.json()
else:
return None
async def get_remote_follow_template(resource: str) -> str | None:

View File

@ -10,12 +10,13 @@ Microblog.pub is a "modern" Python application with "old-school" server-rendered
- [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).
- SQLite3 is the default database.
- SQLite3 for data storage
The server has 2 components:
The server has 3 components:
- The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates)
- An additional process that takes care of sending "outgoing actities"
- One process that takes care of sending "outgoing activities"
- One process that takes care of processing "incoming activities"
### Tasks
@ -29,7 +30,7 @@ inv -l
### Media storage
The uploads are stored in the `data/` directory, using a simple content-addressed storage (file contents hash is the name of the store BLOB).
The uploads are stored in the `data/` directory, using a simple content-addressed storage system (file contents hash is BLOB filename).
Files metadata are stored in the database.
## Installation

View File

@ -55,6 +55,12 @@ docker compose stop
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
Assuming you have a working **Python 3.10+** environment.
@ -99,7 +105,7 @@ Setup a reverse proxy (see the next section).
### Updating
To update microblogpub locally, pull the remote changes and run the `update` task to regeneratee the CSS and run any DB migrations.
To update microblogpub locally, pull the remote changes and run the `update` task to regenerate the CSS and run any DB migrations.
```bash
git pull
@ -130,6 +136,11 @@ 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.
@ -147,8 +158,33 @@ server {
# path for static files
rewrite ^/static/(.*) /$1 break;
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;
}
```
## YunoHost edition
[YunoHost](https://yunohost.org/) support is a work in progress.

View File

@ -31,6 +31,33 @@ You can tweak your profile by tweaking these items:
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.
### 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 softwares 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`.
### Privacy replace
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
@ -41,6 +68,7 @@ To do so, just add as these extra config items, this is a sample config that rew
```toml
privacy_replace = [
{domain = "youtube.com", replace_by = "yewtu.be"},
{domain = "youtu.be", replace_by = "yewtu.be"},
{domain = "twitter.com", replace_by = "nitter.fdn.fr"},
{domain = "medium.com", replace_by = "scribe.rip"},
{domain = "reddit.com", replace_by = "teddit.net"},
@ -49,6 +77,16 @@ privacy_replace = [
### 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
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
@ -66,6 +104,30 @@ $secondary-color: #32cd32;
See `app/scss/main.scss` to see what variables can be overidden.
#### Code highlighting theme
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `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 communications with whole 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 notes will be visible on the homepage.
@ -129,21 +191,22 @@ microblog.pub supports the most common interactions supported by the Fediverse.
### Shares
Sharing an object will relay it to your followers and notify the author.
Sharing (or announcing) an object will relay it to your followers and notify the author.
It will also be displayed on the homepage.
Most receiving servers will increment the number of shares.
TODO receiving
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.
### Likes
Liking an object will notify the author.
Unkike sharing, liked object are not displayed on the homepage.
Unlike sharing, liked object are not displayed on the homepage.
Most receiving servers will increment the number of likes.
TODO receiving
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.
### Bookmarks
@ -151,13 +214,13 @@ Bookmarks allow you to like objects without notifying the author.
It is basically a "private like", and allow you to easily access them later.
TODO receiving
It will also prevent objects to be pruned.
### Webmentions
Sending webmention to ping mentioned website is done automatically once a note is authored, see TODO.
Sending webmention to ping mentioned websites is done automatically once a public note is authored.
TODO side-effect of receiving a webmention.
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.
## Backup and restore
@ -171,3 +234,133 @@ All the data generated by the server is located in the `data/` directory:
- Uploaded media
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/).
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@domain.tld
```
Edit the config.
### Docker edition
```bash
# For a Docker install
make account=username@domain.tld webfinger
```
Edit the config.
#### Edit the config
And add a reference to your old/existing account in `profile.toml`:
```toml
also_known_as = "my@old-account.com"
```
Restart the server, and you should be able to complete the move from your existing account.
## Tasks
### 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/)).
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
```

View File

@ -4,11 +4,10 @@ logfile=/dev/null
logfile_maxbytes=0
pidfile=data/supervisord.pid
[fcgi-program:uvicorn]
socket=tcp://0.0.0.0:8000
command=uvicorn app.main:app --no-server-header --fd 0
numprocs=2
process_name=uvicorn-%(process_num)d
[program:uvicorn]
command=uvicorn app.main:app --no-server-header --host 0.0.0.0
numprocs=1
autorestart=true
redirect_stderr=true
stdout_logfile=data/uvicorn.log
stdout_logfile_maxbytes=50MB
@ -16,6 +15,7 @@ stdout_logfile_maxbytes=50MB
[program:incoming_worker]
command=inv process-incoming-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=data/incoming.log
stdout_logfile_maxbytes=50MB
@ -23,6 +23,7 @@ stdout_logfile_maxbytes=50MB
[program:outgoing_worker]
command=inv process-outgoing-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=data/outgoing.log
stdout_logfile_maxbytes=50MB

View File

@ -1,24 +1,25 @@
[supervisord]
[fcgi-program:uvicorn]
socket=tcp://localhost:8000
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0
numprocs=2
process_name=uvicorn-%(process_num)d
[program:uvicorn]
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
numprocs=1
autorestart=true
redirect_stderr=true
stdout_logfile=uvicorn.log
stdout_logfile_maxbytes=0
stdout_logfile_maxbytes=50MB
[program:incoming_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=incoming_worker.log
stdout_logfile_maxbytes=0
stdout_logfile_maxbytes=50MB
[program:outgoing_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=outgoing_worker.log
stdout_logfile_maxbytes=0
stdout_logfile_maxbytes=50MB

View File

@ -1,24 +1,26 @@
[supervisord]
[fcgi-program:uvicorn]
socket=tcp://localhost:%(ENV_UVICORN_PORT)s
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0
numprocs=2
[program:uvicorn]
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
numprocs=1
autorestart=true
process_name=uvicorn-%(process_num)d
redirect_stderr=true
stdout_logfile=uvicorn.log
stdout_logfile=%(ENV_LOG_PATH)s/uvicorn.log
stdout_logfile_maxbytes=0
[program:incoming_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=incoming_worker.log
stdout_logfile=%(ENV_LOG_PATH)s/incoming.log
stdout_logfile_maxbytes=0
[program:outgoing_worker]
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
numproc=1
autorestart=true
redirect_stderr=true
stdout_logfile=outgoing_worker.log
stdout_logfile=%(ENV_LOG_PATH)s/outgoing.log
stdout_logfile_maxbytes=0

588
poetry.lock generated
View File

@ -52,14 +52,6 @@ python-versions = ">=3.7"
[package.extras]
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "22.1.0"
@ -106,7 +98,7 @@ lxml = ["lxml"]
[[package]]
name = "black"
version = "22.6.0"
version = "22.8.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
@ -176,6 +168,14 @@ watchdog = ">=0.8.3"
[package.extras]
dev = ["flake8", "pytest", "sphinx", "sphinx-rtd-theme", "livereload", "twine", "packaging"]
[[package]]
name = "brotli"
version = "1.0.9"
description = "Python bindings for the Brotli compression library"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "bs4"
version = "0.0.1"
@ -246,7 +246,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "colorlog"
version = "6.6.0"
version = "6.7.0"
description = "Add colours to the output of Python's logging module."
category = "main"
optional = false
@ -286,7 +286,7 @@ doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
[[package]]
name = "faker"
version = "14.1.0"
version = "14.2.0"
description = "Faker is a Python package that generates fake data for you."
category = "dev"
optional = false
@ -348,7 +348,7 @@ python-versions = ">=3.6"
[[package]]
name = "greenlet"
version = "1.1.2"
version = "1.1.3"
description = "Lightweight in-process concurrent programming"
category = "main"
optional = false
@ -429,6 +429,17 @@ sniffio = ">=1.0.0,<2.0.0"
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "httptools"
version = "0.4.0"
description = "A collection of framework independent HTTP protocol utils."
category = "main"
optional = false
python-versions = ">=3.5.0"
[package.extras]
test = ["Cython (>=0.29.24,<0.30.0)"]
[[package]]
name = "httpx"
version = "0.23.0"
@ -571,7 +582,7 @@ source = ["Cython (>=0.29.7)"]
[[package]]
name = "mako"
version = "1.2.1"
version = "1.2.2"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
category = "main"
optional = false
@ -676,11 +687,11 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pathspec"
version = "0.9.0"
version = "0.10.1"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
python-versions = ">=3.7"
[[package]]
name = "pillow"
@ -720,7 +731,7 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "prompt-toolkit"
version = "3.0.30"
version = "3.0.31"
description = "Library for building powerful interactive command lines in Python"
category = "main"
optional = false
@ -774,18 +785,18 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pydantic"
version = "1.9.2"
version = "1.10.2"
description = "Data validation and settings management using python type hints"
category = "main"
optional = false
python-versions = ">=3.6.1"
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = ">=3.7.4.3"
typing-extensions = ">=4.1.0"
[package.extras]
email = ["email-validator (>=1.0.3)"]
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyflakes"
@ -838,14 +849,13 @@ diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pytest"
version = "7.1.2"
version = "7.1.3"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
@ -882,6 +892,17 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
[package.dependencies]
six = ">=1.5"
[[package]]
name = "python-dotenv"
version = "0.21.0"
description = "Read key-value pairs from a .env file and set them as environment variables"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "python-multipart"
version = "0.0.5"
@ -954,11 +975,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sniffio"
version = "1.2.0"
version = "1.3.0"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.7"
[[package]]
name = "soupsieve"
@ -1002,7 +1023,7 @@ sqlcipher = ["sqlcipher3-binary"]
[[package]]
name = "sqlalchemy2-stubs"
version = "0.0.2a25"
version = "0.0.2a27"
description = "Typing Stubs for SQLAlchemy 1.4"
category = "dev"
optional = false
@ -1089,7 +1110,7 @@ python-versions = "*"
[[package]]
name = "types-markdown"
version = "3.4.0"
version = "3.4.1"
description = "Typing stubs for Markdown"
category = "dev"
optional = false
@ -1148,7 +1169,7 @@ python-versions = ">=3.7"
[[package]]
name = "urllib3"
version = "1.26.11"
version = "1.26.12"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
@ -1156,24 +1177,43 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*,
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "uvicorn"
version = "0.17.6"
version = "0.18.3"
description = "The lightning-fast ASGI server."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
asgiref = ">=3.4.0"
click = ">=7.0"
colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
h11 = ">=0.8"
httptools = {version = ">=0.4.0", optional = true, markers = "extra == \"standard\""}
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
websockets = {version = ">=10.0", optional = true, markers = "extra == \"standard\""}
[package.extras]
standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
standard = ["colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"]
[[package]]
name = "uvloop"
version = "0.16.0"
description = "Fast implementation of asyncio event loop on top of libuv"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
[[package]]
name = "watchdog"
@ -1186,6 +1226,17 @@ python-versions = ">=3.6"
[package.extras]
watchmedo = ["PyYAML (>=3.10)"]
[[package]]
name = "watchfiles"
version = "0.16.1"
description = "Simple, modern and high performance file watching and code reload in python."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
anyio = ">=3.0.0,<4"
[[package]]
name = "wcwidth"
version = "0.2.5"
@ -1202,6 +1253,14 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "websockets"
version = "10.3"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "win32-setctime"
version = "1.1.0"
@ -1216,7 +1275,7 @@ dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "5ee42d968baa21950366d1ca0597fb1e0e45e6e26005f93acbcf8b43cd1fb370"
content-hash = "be26936c88285524ab781d4130c8a97fc940d70dd0a88bf37c61d7db5bdbd232"
[metadata.files]
aiosqlite = [
@ -1232,7 +1291,6 @@ asgiref = [
{file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
{file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
]
atomicwrites = []
attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
@ -1255,29 +1313,29 @@ beautifulsoup4 = [
{file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"},
]
black = [
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"},
{file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"},
{file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"},
{file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"},
{file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"},
{file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"},
{file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"},
{file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"},
{file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"},
{file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"},
{file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"},
{file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"},
{file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"},
{file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"},
{file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"},
{file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"},
{file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"},
{file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"},
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
]
bleach = [
{file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"},
@ -1299,6 +1357,70 @@ blurhash-python = [
boussole = [
{file = "boussole-2.0.0.tar.gz", hash = "sha256:e4907180698339c778669d71b16a77b8d54c97d54e79d7813de1630a9d091a2f"},
]
brotli = [
{file = "Brotli-1.0.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70"},
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b"},
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6"},
{file = "Brotli-1.0.9-cp27-cp27m-win32.whl", hash = "sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa"},
{file = "Brotli-1.0.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452"},
{file = "Brotli-1.0.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7"},
{file = "Brotli-1.0.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9744a863b489c79a73aba014df554b0e7a0fc44ef3f8a0ef2a52919c7d155031"},
{file = "Brotli-1.0.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a72661af47119a80d82fa583b554095308d6a4c356b2a554fdc2799bc19f2a43"},
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ee83d3e3a024a9618e5be64648d6d11c37047ac48adff25f12fa4226cf23d1c"},
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19598ecddd8a212aedb1ffa15763dd52a388518c4550e615aed88dc3753c0f0c"},
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44bb8ff420c1d19d91d79d8c3574b8954288bdff0273bf788954064d260d7ab0"},
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e23281b9a08ec338469268f98f194658abfb13658ee98e2b7f85ee9dd06caa91"},
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3496fc835370da351d37cada4cf744039616a6db7d13c430035e901443a34daa"},
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83bb06a0192cccf1eb8d0a28672a1b79c74c3a8a5f2619625aeb6f28b3a82bb"},
{file = "Brotli-1.0.9-cp310-cp310-win32.whl", hash = "sha256:26d168aac4aaec9a4394221240e8a5436b5634adc3cd1cdf637f6645cecbf181"},
{file = "Brotli-1.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:622a231b08899c864eb87e85f81c75e7b9ce05b001e59bbfbf43d4a71f5f32b2"},
{file = "Brotli-1.0.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4"},
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296"},
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430"},
{file = "Brotli-1.0.9-cp35-cp35m-win32.whl", hash = "sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1"},
{file = "Brotli-1.0.9-cp35-cp35m-win_amd64.whl", hash = "sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea"},
{file = "Brotli-1.0.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f"},
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4"},
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a"},
{file = "Brotli-1.0.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87fdccbb6bb589095f413b1e05734ba492c962b4a45a13ff3408fa44ffe6479b"},
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:6d847b14f7ea89f6ad3c9e3901d1bc4835f6b390a9c71df999b0162d9bb1e20f"},
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:495ba7e49c2db22b046a53b469bbecea802efce200dffb69b93dd47397edc9b6"},
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:4688c1e42968ba52e57d8670ad2306fe92e0169c6f3af0089be75bbac0c64a3b"},
{file = "Brotli-1.0.9-cp36-cp36m-win32.whl", hash = "sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14"},
{file = "Brotli-1.0.9-cp36-cp36m-win_amd64.whl", hash = "sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c"},
{file = "Brotli-1.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126"},
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d"},
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12"},
{file = "Brotli-1.0.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29d1d350178e5225397e28ea1b7aca3648fcbab546d20e7475805437bfb0a130"},
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7bbff90b63328013e1e8cb50650ae0b9bac54ffb4be6104378490193cd60f85a"},
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ec1947eabbaf8e0531e8e899fc1d9876c179fc518989461f5d24e2223395a9e3"},
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d"},
{file = "Brotli-1.0.9-cp37-cp37m-win32.whl", hash = "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"},
{file = "Brotli-1.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5"},
{file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e16eb9541f3dd1a3e92b89005e37b1257b157b7256df0e36bd7b33b50be73bcb"},
{file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8"},
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb"},
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26"},
{file = "Brotli-1.0.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a674ac10e0a87b683f4fa2b6fa41090edfd686a6524bd8dedbd6138b309175c"},
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e2d9e1cbc1b25e22000328702b014227737756f4b5bf5c485ac1d8091ada078b"},
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b336c5e9cf03c7be40c47b5fd694c43c9f1358a80ba384a21969e0b4e66a9b17"},
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:85f7912459c67eaab2fb854ed2bc1cc25772b300545fe7ed2dc03954da638649"},
{file = "Brotli-1.0.9-cp38-cp38-win32.whl", hash = "sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429"},
{file = "Brotli-1.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f"},
{file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2aad0e0baa04517741c9bb5b07586c642302e5fb3e75319cb62087bd0995ab19"},
{file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cb1e18167792d7d21e21365d7650b72d5081ed476123ff7b8cac7f45189c0c7"},
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b"},
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389"},
{file = "Brotli-1.0.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bf919756d25e4114ace16a8ce91eb340eb57a08e2c6950c3cebcbe3dff2a5e7"},
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e4c4e92c14a57c9bd4cb4be678c25369bf7a092d55fd0866f759e425b9660806"},
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e48f4234f2469ed012a98f4b7874e7f7e173c167bed4934912a29e03167cf6b1"},
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ed4c92a0665002ff8ea852353aeb60d9141eb04109e88928026d3c8a9e5433c"},
{file = "Brotli-1.0.9-cp39-cp39-win32.whl", hash = "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3"},
{file = "Brotli-1.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761"},
{file = "Brotli-1.0.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9749a124280a0ada4187a6cfd1ffd35c350fb3af79c706589d98e088c5044267"},
{file = "Brotli-1.0.9-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:76ffebb907bec09ff511bb3acc077695e2c32bc2142819491579a695f77ffd4d"},
{file = "Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438"},
]
bs4 = [
{file = "bs4-0.0.1.tar.gz", hash = "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"},
]
@ -1389,8 +1511,8 @@ colorama = [
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
colorlog = [
{file = "colorlog-6.6.0-py2.py3-none-any.whl", hash = "sha256:351c51e866c86c3217f08e4b067a7974a678be78f07f85fc2d55b8babde6d94e"},
{file = "colorlog-6.6.0.tar.gz", hash = "sha256:344f73204009e4c83c5b6beb00b3c45dc70fcdae3c80db919e0a4171d006fde8"},
{file = "colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662"},
{file = "colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5"},
]
emoji = [
{file = "emoji-1.7.0.tar.gz", hash = "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1"},
@ -1400,8 +1522,8 @@ factory-boy = [
{file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
]
faker = [
{file = "Faker-14.1.0-py3-none-any.whl", hash = "sha256:067a03f64e555261610e69277536072997b4576dbf84b113faef3c06d85b466b"},
{file = "Faker-14.1.0.tar.gz", hash = "sha256:0e00bfa1eadf1493f15662edb181222fea4847764cf3f9ff3e66ee0f95c9a644"},
{file = "Faker-14.2.0-py3-none-any.whl", hash = "sha256:e02c55a5b0586caaf913cc6c254b3de178e08b031c5922e590fd033ebbdbfd02"},
{file = "Faker-14.2.0.tar.gz", hash = "sha256:6db56e2c43a2b74250d1c332ef25fef7dc07dcb6c5fab5329dd7b4467b8ed7b9"},
]
fastapi = [
{file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"},
@ -1416,61 +1538,60 @@ flake8 = [
]
frozendict = []
greenlet = [
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
{file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"},
{file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"},
{file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"},
{file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"},
{file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"},
{file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"},
{file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"},
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"},
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"},
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"},
{file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"},
{file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"},
{file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"},
{file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"},
{file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"},
{file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"},
{file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"},
{file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"},
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"},
{file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"},
{file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"},
{file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"},
{file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"},
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"},
{file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"},
{file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"},
{file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"},
{file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"},
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"},
{file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"},
{file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"},
{file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"},
{file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"},
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"},
{file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"},
{file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"},
{file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"},
{file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"},
{file = "greenlet-1.1.3-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:8c287ae7ac921dfde88b1c125bd9590b7ec3c900c2d3db5197f1286e144e712b"},
{file = "greenlet-1.1.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:870a48007872d12e95a996fca3c03a64290d3ea2e61076aa35d3b253cf34cd32"},
{file = "greenlet-1.1.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7c5227963409551ae4a6938beb70d56bf1918c554a287d3da6853526212fbe0a"},
{file = "greenlet-1.1.3-cp27-cp27m-win32.whl", hash = "sha256:9fae214f6c43cd47f7bef98c56919b9222481e833be2915f6857a1e9e8a15318"},
{file = "greenlet-1.1.3-cp27-cp27m-win_amd64.whl", hash = "sha256:de431765bd5fe62119e0bc6bc6e7b17ac53017ae1782acf88fcf6b7eae475a49"},
{file = "greenlet-1.1.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:510c3b15587afce9800198b4b142202b323bf4b4b5f9d6c79cb9a35e5e3c30d2"},
{file = "greenlet-1.1.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:9951dcbd37850da32b2cb6e391f621c1ee456191c6ae5528af4a34afe357c30e"},
{file = "greenlet-1.1.3-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:07c58e169bbe1e87b8bbf15a5c1b779a7616df9fd3e61cadc9d691740015b4f8"},
{file = "greenlet-1.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df02fdec0c533301497acb0bc0f27f479a3a63dcdc3a099ae33a902857f07477"},
{file = "greenlet-1.1.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c88e134d51d5e82315a7c32b914a58751b7353eb5268dbd02eabf020b4c4700"},
{file = "greenlet-1.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b41d19c0cfe5c259fe6c539fd75051cd39a5d33d05482f885faf43f7f5e7d26"},
{file = "greenlet-1.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:6f5d4b2280ceea76c55c893827961ed0a6eadd5a584a7c4e6e6dd7bc10dfdd96"},
{file = "greenlet-1.1.3-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:184416e481295832350a4bf731ba619a92f5689bf5d0fa4341e98b98b1265bd7"},
{file = "greenlet-1.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd0404d154084a371e6d2bafc787201612a1359c2dee688ae334f9118aa0bf47"},
{file = "greenlet-1.1.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a43bbfa9b6cfdfaeefbd91038dde65ea2c421dc387ed171613df340650874f2"},
{file = "greenlet-1.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce5b64dfe8d0cca407d88b0ee619d80d4215a2612c1af8c98a92180e7109f4b5"},
{file = "greenlet-1.1.3-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:903fa5716b8fbb21019268b44f73f3748c41d1a30d71b4a49c84b642c2fed5fa"},
{file = "greenlet-1.1.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:0118817c9341ef2b0f75f5af79ac377e4da6ff637e5ee4ac91802c0e379dadb4"},
{file = "greenlet-1.1.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:466ce0928e33421ee84ae04c4ac6f253a3a3e6b8d600a79bd43fd4403e0a7a76"},
{file = "greenlet-1.1.3-cp35-cp35m-win32.whl", hash = "sha256:65ad1a7a463a2a6f863661329a944a5802c7129f7ad33583dcc11069c17e622c"},
{file = "greenlet-1.1.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7532a46505470be30cbf1dbadb20379fb481244f1ca54207d7df3bf0bbab6a20"},
{file = "greenlet-1.1.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:caff52cb5cd7626872d9696aee5b794abe172804beb7db52eed1fd5824b63910"},
{file = "greenlet-1.1.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:db41f3845eb579b544c962864cce2c2a0257fe30f0f1e18e51b1e8cbb4e0ac6d"},
{file = "greenlet-1.1.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:e8533f5111704d75de3139bf0b8136d3a6c1642c55c067866fa0a51c2155ee33"},
{file = "greenlet-1.1.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537e4baf0db67f382eb29255a03154fcd4984638303ff9baaa738b10371fa57"},
{file = "greenlet-1.1.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8bfd36f368efe0ab2a6aa3db7f14598aac454b06849fb633b762ddbede1db90"},
{file = "greenlet-1.1.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0877a9a2129a2c56a2eae2da016743db7d9d6a05d5e1c198f1b7808c602a30e"},
{file = "greenlet-1.1.3-cp36-cp36m-win32.whl", hash = "sha256:88b04e12c9b041a1e0bcb886fec709c488192638a9a7a3677513ac6ba81d8e79"},
{file = "greenlet-1.1.3-cp36-cp36m-win_amd64.whl", hash = "sha256:4f166b4aca8d7d489e82d74627a7069ab34211ef5ebb57c300ec4b9337b60fc0"},
{file = "greenlet-1.1.3-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:cd16a89efe3a003029c87ff19e9fba635864e064da646bc749fc1908a4af18f3"},
{file = "greenlet-1.1.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5b756e6730ea59b2745072e28ad27f4c837084688e6a6b3633c8b1e509e6ae0e"},
{file = "greenlet-1.1.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:9b2f7d0408ddeb8ea1fd43d3db79a8cefaccadd2a812f021333b338ed6b10aba"},
{file = "greenlet-1.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44b4817c34c9272c65550b788913620f1fdc80362b209bc9d7dd2f40d8793080"},
{file = "greenlet-1.1.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d58a5a71c4c37354f9e0c24c9c8321f0185f6945ef027460b809f4bb474bfe41"},
{file = "greenlet-1.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dd51d2650e70c6c4af37f454737bf4a11e568945b27f74b471e8e2a9fd21268"},
{file = "greenlet-1.1.3-cp37-cp37m-win32.whl", hash = "sha256:048d2bed76c2aa6de7af500ae0ea51dd2267aec0e0f2a436981159053d0bc7cc"},
{file = "greenlet-1.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:77e41db75f9958f2083e03e9dd39da12247b3430c92267df3af77c83d8ff9eed"},
{file = "greenlet-1.1.3-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:1626185d938d7381631e48e6f7713e8d4b964be246073e1a1d15c2f061ac9f08"},
{file = "greenlet-1.1.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:1ec2779774d8e42ed0440cf8bc55540175187e8e934f2be25199bf4ed948cd9e"},
{file = "greenlet-1.1.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f2f908239b7098799b8845e5936c2ccb91d8c2323be02e82f8dcb4a80dcf4a25"},
{file = "greenlet-1.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b181e9aa6cb2f5ec0cacc8cee6e5a3093416c841ba32c185c30c160487f0380"},
{file = "greenlet-1.1.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cf45e339cabea16c07586306a31cfcc5a3b5e1626d365714d283732afed6809"},
{file = "greenlet-1.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6200a11f003ec26815f7e3d2ded01b43a3810be3528dd760d2f1fa777490c3cd"},
{file = "greenlet-1.1.3-cp38-cp38-win32.whl", hash = "sha256:db5b25265010a1b3dca6a174a443a0ed4c4ab12d5e2883a11c97d6e6d59b12f9"},
{file = "greenlet-1.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:095a980288fe05adf3d002fbb180c99bdcf0f930e220aa66fcd56e7914a38202"},
{file = "greenlet-1.1.3-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:cbc1eb55342cbac8f7ec159088d54e2cfdd5ddf61c87b8bbe682d113789331b2"},
{file = "greenlet-1.1.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:694ffa7144fa5cc526c8f4512665003a39fa09ef00d19bbca5c8d3406db72fbe"},
{file = "greenlet-1.1.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:aa741c1a8a8cc25eb3a3a01a62bdb5095a773d8c6a86470bde7f607a447e7905"},
{file = "greenlet-1.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3a669f11289a8995d24fbfc0e63f8289dd03c9aaa0cc8f1eab31d18ca61a382"},
{file = "greenlet-1.1.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76a53bfa10b367ee734b95988bd82a9a5f0038a25030f9f23bbbc005010ca600"},
{file = "greenlet-1.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fb0aa7f6996879551fd67461d5d3ab0c3c0245da98be90c89fcb7a18d437403"},
{file = "greenlet-1.1.3-cp39-cp39-win32.whl", hash = "sha256:5fbe1ab72b998ca77ceabbae63a9b2e2dc2d963f4299b9b278252ddba142d3f1"},
{file = "greenlet-1.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:ffe73f9e7aea404722058405ff24041e59d31ca23d1da0895af48050a07b6932"},
{file = "greenlet-1.1.3.tar.gz", hash = "sha256:bcb6c6dd1d6be6d38d6db283747d07fda089ff8c559a835236560a4410340455"},
]
h11 = [
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
@ -1490,6 +1611,42 @@ httpcore = [
{file = "httpcore-0.15.0-py3-none-any.whl", hash = "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6"},
{file = "httpcore-0.15.0.tar.gz", hash = "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b"},
]
httptools = [
{file = "httptools-0.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5"},
{file = "httptools-0.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23"},
{file = "httptools-0.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed"},
{file = "httptools-0.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683"},
{file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c"},
{file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919"},
{file = "httptools-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe"},
{file = "httptools-0.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd"},
{file = "httptools-0.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c"},
{file = "httptools-0.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e"},
{file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d"},
{file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae"},
{file = "httptools-0.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777"},
{file = "httptools-0.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111"},
{file = "httptools-0.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1"},
{file = "httptools-0.4.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0"},
{file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af"},
{file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4"},
{file = "httptools-0.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe"},
{file = "httptools-0.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b"},
{file = "httptools-0.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a"},
{file = "httptools-0.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48"},
{file = "httptools-0.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad"},
{file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3"},
{file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409"},
{file = "httptools-0.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de"},
{file = "httptools-0.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890"},
{file = "httptools-0.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055"},
{file = "httptools-0.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855"},
{file = "httptools-0.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722"},
{file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424"},
{file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d"},
{file = "httptools-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83"},
{file = "httptools-0.4.0.tar.gz", hash = "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff"},
]
httpx = [
{file = "httpx-0.23.0-py3-none-any.whl", hash = "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b"},
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
@ -1612,8 +1769,8 @@ lxml = [
{file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"},
]
mako = [
{file = "Mako-1.2.1-py3-none-any.whl", hash = "sha256:df3921c3081b013c8a2d5ff03c18375651684921ae83fd12e64800b7da923257"},
{file = "Mako-1.2.1.tar.gz", hash = "sha256:f054a5ff4743492f1aa9ecc47172cb33b42b9d993cffcc146c9de17e717b0307"},
{file = "Mako-1.2.2-py3-none-any.whl", hash = "sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534"},
{file = "Mako-1.2.2.tar.gz", hash = "sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f"},
]
markdown = []
markupsafe = [
@ -1702,8 +1859,8 @@ packaging = [
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
]
pillow = [
{file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"},
@ -1774,8 +1931,8 @@ pluggy = [
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
prompt-toolkit = [
{file = "prompt_toolkit-3.0.30-py3-none-any.whl", hash = "sha256:d8916d3f62a7b67ab353a952ce4ced6a1d2587dfe9ef8ebc30dd7c386751f289"},
{file = "prompt_toolkit-3.0.30.tar.gz", hash = "sha256:859b283c50bde45f5f97829f77a4674d1c1fcd88539364f1b28a37805cfd89c0"},
{file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"},
{file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"},
]
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
@ -1826,41 +1983,42 @@ pycryptodome = [
{file = "pycryptodome-3.15.0.tar.gz", hash = "sha256:9135dddad504592bcc18b0d2d95ce86c3a5ea87ec6447ef25cfedea12d6018b8"},
]
pydantic = [
{file = "pydantic-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c9e04a6cdb7a363d7cb3ccf0efea51e0abb48e180c0d31dca8d247967d85c6e"},
{file = "pydantic-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafe841be1103f340a24977f61dee76172e4ae5f647ab9e7fd1e1fca51524f08"},
{file = "pydantic-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afacf6d2a41ed91fc631bade88b1d319c51ab5418870802cedb590b709c5ae3c"},
{file = "pydantic-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ee0d69b2a5b341fc7927e92cae7ddcfd95e624dfc4870b32a85568bd65e6131"},
{file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ff68fc85355532ea77559ede81f35fff79a6a5543477e168ab3a381887caea76"},
{file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c0f5e142ef8217019e3eef6ae1b6b55f09a7a15972958d44fbd228214cede567"},
{file = "pydantic-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:615661bfc37e82ac677543704437ff737418e4ea04bef9cf11c6d27346606044"},
{file = "pydantic-1.9.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:328558c9f2eed77bd8fffad3cef39dbbe3edc7044517f4625a769d45d4cf7555"},
{file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd446bdb7755c3a94e56d7bdfd3ee92396070efa8ef3a34fab9579fe6aa1d84"},
{file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0b214e57623a535936005797567231a12d0da0c29711eb3514bc2b3cd008d0f"},
{file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d8ce3fb0841763a89322ea0432f1f59a2d3feae07a63ea2c958b2315e1ae8adb"},
{file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b34ba24f3e2d0b39b43f0ca62008f7ba962cff51efa56e64ee25c4af6eed987b"},
{file = "pydantic-1.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:84d76ecc908d917f4684b354a39fd885d69dd0491be175f3465fe4b59811c001"},
{file = "pydantic-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4de71c718c9756d679420c69f216776c2e977459f77e8f679a4a961dc7304a56"},
{file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5803ad846cdd1ed0d97eb00292b870c29c1f03732a010e66908ff48a762f20e4"},
{file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8c5360a0297a713b4123608a7909e6869e1b56d0e96eb0d792c27585d40757f"},
{file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:cdb4272678db803ddf94caa4f94f8672e9a46bae4a44f167095e4d06fec12979"},
{file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19b5686387ea0d1ea52ecc4cffb71abb21702c5e5b2ac626fd4dbaa0834aa49d"},
{file = "pydantic-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e0b4fb13ad4db4058a7c3c80e2569adbd810c25e6ca3bbd8b2a9cc2cc871d7"},
{file = "pydantic-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91089b2e281713f3893cd01d8e576771cd5bfdfbff5d0ed95969f47ef6d676c3"},
{file = "pydantic-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e631c70c9280e3129f071635b81207cad85e6c08e253539467e4ead0e5b219aa"},
{file = "pydantic-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b3946f87e5cef3ba2e7bd3a4eb5a20385fe36521d6cc1ebf3c08a6697c6cfb3"},
{file = "pydantic-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5565a49effe38d51882cb7bac18bda013cdb34d80ac336428e8908f0b72499b0"},
{file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bd67cb2c2d9602ad159389c29e4ca964b86fa2f35c2faef54c3eb28b4efd36c8"},
{file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4aafd4e55e8ad5bd1b19572ea2df546ccace7945853832bb99422a79c70ce9b8"},
{file = "pydantic-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:d70916235d478404a3fa8c997b003b5f33aeac4686ac1baa767234a0f8ac2326"},
{file = "pydantic-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ca86b525264daa5f6b192f216a0d1e860b7383e3da1c65a1908f9c02f42801"},
{file = "pydantic-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1061c6ee6204f4f5a27133126854948e3b3d51fcc16ead2e5d04378c199b2f44"},
{file = "pydantic-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78578f0c7481c850d1c969aca9a65405887003484d24f6110458fb02cca7747"},
{file = "pydantic-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da164119602212a3fe7e3bc08911a89db4710ae51444b4224c2382fd09ad453"},
{file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ead3cd020d526f75b4188e0a8d71c0dbbe1b4b6b5dc0ea775a93aca16256aeb"},
{file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7d0f183b305629765910eaad707800d2f47c6ac5bcfb8c6397abdc30b69eeb15"},
{file = "pydantic-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1a68f4f65a9ee64b6ccccb5bf7e17db07caebd2730109cb8a95863cfa9c4e55"},
{file = "pydantic-1.9.2-py3-none-any.whl", hash = "sha256:78a4d6bdfd116a559aeec9a4cfe77dda62acc6233f8b56a716edad2651023e5e"},
{file = "pydantic-1.9.2.tar.gz", hash = "sha256:8cb0bc509bfb71305d7a59d00163d5f9fc4530f0881ea32c74ff4f74c85f3d3d"},
{file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"},
{file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"},
{file = "pydantic-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:352aedb1d71b8b0736c6d56ad2bd34c6982720644b0624462059ab29bd6e5912"},
{file = "pydantic-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19b3b9ccf97af2b7519c42032441a891a5e05c68368f40865a90eb88833c2559"},
{file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9069e1b01525a96e6ff49e25876d90d5a563bc31c658289a8772ae186552236"},
{file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:355639d9afc76bcb9b0c3000ddcd08472ae75318a6eb67a15866b87e2efa168c"},
{file = "pydantic-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae544c47bec47a86bc7d350f965d8b15540e27e5aa4f55170ac6a75e5f73b644"},
{file = "pydantic-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4c805731c33a8db4b6ace45ce440c4ef5336e712508b4d9e1aafa617dc9907f"},
{file = "pydantic-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d49f3db871575e0426b12e2f32fdb25e579dea16486a26e5a0474af87cb1ab0a"},
{file = "pydantic-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c90345ec7dd2f1bcef82ce49b6235b40f282b94d3eec47e801baf864d15525"},
{file = "pydantic-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b5ba54d026c2bd2cb769d3468885f23f43710f651688e91f5fb1edcf0ee9283"},
{file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05e00dbebbe810b33c7a7362f231893183bcc4251f3f2ff991c31d5c08240c42"},
{file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2d0567e60eb01bccda3a4df01df677adf6b437958d35c12a3ac3e0f078b0ee52"},
{file = "pydantic-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:c6f981882aea41e021f72779ce2a4e87267458cc4d39ea990729e21ef18f0f8c"},
{file = "pydantic-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4aac8e7103bf598373208f6299fa9a5cfd1fc571f2d40bf1dd1955a63d6eeb5"},
{file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a7b66c3f499108b448f3f004801fcd7d7165fb4200acb03f1c2402da73ce4c"},
{file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bedf309630209e78582ffacda64a21f96f3ed2e51fbf3962d4d488e503420254"},
{file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9300fcbebf85f6339a02c6994b2eb3ff1b9c8c14f502058b5bf349d42447dcf5"},
{file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:216f3bcbf19c726b1cc22b099dd409aa371f55c08800bcea4c44c8f74b73478d"},
{file = "pydantic-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dd3f9a40c16daf323cf913593083698caee97df2804aa36c4b3175d5ac1b92a2"},
{file = "pydantic-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b97890e56a694486f772d36efd2ba31612739bc6f3caeee50e9e7e3ebd2fdd13"},
{file = "pydantic-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9cabf4a7f05a776e7793e72793cd92cc865ea0e83a819f9ae4ecccb1b8aa6116"},
{file = "pydantic-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06094d18dd5e6f2bbf93efa54991c3240964bb663b87729ac340eb5014310624"},
{file = "pydantic-1.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc78cc83110d2f275ec1970e7a831f4e371ee92405332ebfe9860a715f8336e1"},
{file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ee433e274268a4b0c8fde7ad9d58ecba12b069a033ecc4645bb6303c062d2e9"},
{file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c2abc4393dea97a4ccbb4ec7d8658d4e22c4765b7b9b9445588f16c71ad9965"},
{file = "pydantic-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b959f4d8211fc964772b595ebb25f7652da3f22322c007b6fed26846a40685e"},
{file = "pydantic-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c33602f93bfb67779f9c507e4d69451664524389546bacfe1bee13cae6dc7488"},
{file = "pydantic-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5760e164b807a48a8f25f8aa1a6d857e6ce62e7ec83ea5d5c5a802eac81bad41"},
{file = "pydantic-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eb843dcc411b6a2237a694f5e1d649fc66c6064d02b204a7e9d194dff81eb4b"},
{file = "pydantic-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b8795290deaae348c4eba0cebb196e1c6b98bdbe7f50b2d0d9a4a99716342fe"},
{file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e0bedafe4bc165ad0a56ac0bd7695df25c50f76961da29c050712596cf092d6d"},
{file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e05aed07fa02231dbf03d0adb1be1d79cabb09025dd45aa094aa8b4e7b9dcda"},
{file = "pydantic-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c1ba1afb396148bbc70e9eaa8c06c1716fdddabaf86e7027c5988bae2a829ab6"},
{file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"},
{file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"},
]
pyflakes = [
{file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
@ -1878,8 +2036,8 @@ pyparsing = [
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
pytest = [
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
{file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"},
{file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"},
]
pytest-asyncio = [
{file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"},
@ -1890,6 +2048,10 @@ python-dateutil = [
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
python-dotenv = [
{file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"},
{file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"},
]
python-multipart = [
{file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
]
@ -1945,8 +2107,8 @@ six = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
sniffio = [
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
]
soupsieve = [
{file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
@ -1991,8 +2153,8 @@ sqlalchemy = [
{file = "SQLAlchemy-1.4.40.tar.gz", hash = "sha256:44a660506080cc975e1dfa5776fe5f6315ddc626a77b50bf0eee18b0389ea265"},
]
sqlalchemy2-stubs = [
{file = "sqlalchemy2-stubs-0.0.2a25.tar.gz", hash = "sha256:2fbfddfee7fc6b45206dc52e9fe9d91a787efb6af13191debe84dda9b4798abd"},
{file = "sqlalchemy2_stubs-0.0.2a25-py3-none-any.whl", hash = "sha256:9104894cee3159906079c4a31c2a66fbedfc72a381c51dfd1d8ebae8ffd7f2ca"},
{file = "sqlalchemy2-stubs-0.0.2a27.tar.gz", hash = "sha256:f79bce50b7837a2c2374ef4480b41e2b8a8226f313f347dc2a70526a4191db93"},
{file = "sqlalchemy2_stubs-0.0.2a27-py3-none-any.whl", hash = "sha256:6cea12fec3c261f6e0e14a95d2cc4914e373095e68ec4fc2eb473183ac2b17a2"},
]
starlette = [
{file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"},
@ -2014,7 +2176,10 @@ types-cachetools = [
{file = "types_cachetools-5.2.1-py3-none-any.whl", hash = "sha256:b496b7e364ba050c4eaadcc6582f2c9fbb04f8ee7141eb3b311a8589dbd4506a"},
]
types-emoji = []
types-markdown = []
types-markdown = [
{file = "types-Markdown-3.4.1.tar.gz", hash = "sha256:cda9bfd1fcb11e8133a037f8a184e3059ae7389a5f5cc0b53117bf2902aca10d"},
{file = "types_Markdown-3.4.1-py3-none-any.whl", hash = "sha256:2d1e5bfff192c78d6644bc3820fea9c7c7cb42dc87558020728ec5b728448ce2"},
]
types-pillow = [
{file = "types-Pillow-9.2.1.tar.gz", hash = "sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4"},
{file = "types_Pillow-9.2.1-py3-none-any.whl", hash = "sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"},
@ -2033,10 +2198,31 @@ typing-extensions = [
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
]
urllib3 = []
urllib3 = [
{file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
]
uvicorn = [
{file = "uvicorn-0.17.6-py3-none-any.whl", hash = "sha256:19e2a0e96c9ac5581c01eb1a79a7d2f72bb479691acd2b8921fce48ed5b961a6"},
{file = "uvicorn-0.17.6.tar.gz", hash = "sha256:5180f9d059611747d841a4a4c4ab675edf54c8489e97f96d0583ee90ac3bfc23"},
{file = "uvicorn-0.18.3-py3-none-any.whl", hash = "sha256:0abd429ebb41e604ed8d2be6c60530de3408f250e8d2d84967d85ba9e86fe3af"},
{file = "uvicorn-0.18.3.tar.gz", hash = "sha256:9a66e7c42a2a95222f76ec24a4b754c158261c4696e683b9dadc72b590e0311b"},
]
uvloop = [
{file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"},
{file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"},
{file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"},
{file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"},
{file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"},
{file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"},
{file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"},
{file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"},
{file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"},
{file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"},
{file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"},
{file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"},
{file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"},
{file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"},
{file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"},
{file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"},
]
watchdog = [
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},
@ -2065,6 +2251,26 @@ watchdog = [
{file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"},
{file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"},
]
watchfiles = [
{file = "watchfiles-0.16.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:1e41c8b4bf3e07c18aa51775b36b718830fa727929529a7d6e5b38cf845a06b4"},
{file = "watchfiles-0.16.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b2c7ad91a867dd688b9a12097dd6a4f89397b43fccee871152aa67197cc94398"},
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:75a4b9cec1b1c337ea77d4428b29861553d6bf8179923b1bc7e825e217460e2c"},
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a3debb19912072799d7ca53e99fc5f090f77948f5601392623b2a416b4c86be"},
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35f3e411822e14a35f2ef656535aad4e6e79670d6b6ef8e53db958e28916b1fe"},
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9a7a6dc63684ff5ba11f0be0e64f744112c3c7a0baf4ec8f6794f9a6257d21e"},
{file = "watchfiles-0.16.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e939a2693404ac11e055f9d1237db8ad7635e2185a6143bde00116e691ea2983"},
{file = "watchfiles-0.16.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd7d2fd9a8f28066edc8db5278f3632eb94d10596af760fa0601631f32b1a41e"},
{file = "watchfiles-0.16.1-cp37-abi3-win32.whl", hash = "sha256:f91035a273001390093a09e52274a34695b0d15ee8736183b640bbc3b8a432ab"},
{file = "watchfiles-0.16.1-cp37-abi3-win_amd64.whl", hash = "sha256:a8a1809bf910672aa0b7ed6e6045d4fc2cf1e0718b99bc443ef17faa5697b68a"},
{file = "watchfiles-0.16.1-cp37-abi3-win_arm64.whl", hash = "sha256:baa6d0c1c5140e1dcf6ff802dd7b09fcd95b358e50d42fabc83d83f719451c54"},
{file = "watchfiles-0.16.1-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:5741246ae399a03395aa5ee35480083a4f29d58ffd41dd3395594f8805f8cdbc"},
{file = "watchfiles-0.16.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44c6aff58b8a70a26431737e483a54e8e224279b21873388571ed184fe7c91a7"},
{file = "watchfiles-0.16.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d1b2d0cf060e5222a930a3e2f40f6577da1d18c085c32741b98a128dc1e72c"},
{file = "watchfiles-0.16.1-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:70159e759f52b65a50c498182dece80364bfd721e839c254c328cbc7a1716616"},
{file = "watchfiles-0.16.1-pp39-pypy39_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22af3b915f928ef59d427d7228668f87ac8054ed8200808c73fbcaa4f82d5572"},
{file = "watchfiles-0.16.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a6a1ac96edf5bc3f8e36f4462fc1daad0ec3769ff2adb920571e120e37c91c5"},
{file = "watchfiles-0.16.1.tar.gz", hash = "sha256:aed7575e24434c8fec2f2bbb0cecb1521ea1240234d9108db7915a3424d92394"},
]
wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
@ -2073,6 +2279,56 @@ webencodings = [
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
]
websockets = [
{file = "websockets-10.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978"},
{file = "websockets-10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500"},
{file = "websockets-10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b"},
{file = "websockets-10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c"},
{file = "websockets-10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8"},
{file = "websockets-10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677"},
{file = "websockets-10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e"},
{file = "websockets-10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f"},
{file = "websockets-10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47"},
{file = "websockets-10.3-cp310-cp310-win32.whl", hash = "sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae"},
{file = "websockets-10.3-cp310-cp310-win_amd64.whl", hash = "sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079"},
{file = "websockets-10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916"},
{file = "websockets-10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb"},
{file = "websockets-10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79"},
{file = "websockets-10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d"},
{file = "websockets-10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98"},
{file = "websockets-10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e"},
{file = "websockets-10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6"},
{file = "websockets-10.3-cp37-cp37m-win32.whl", hash = "sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1"},
{file = "websockets-10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4"},
{file = "websockets-10.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36"},
{file = "websockets-10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69"},
{file = "websockets-10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd"},
{file = "websockets-10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2"},
{file = "websockets-10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c"},
{file = "websockets-10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e"},
{file = "websockets-10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991"},
{file = "websockets-10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442"},
{file = "websockets-10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76"},
{file = "websockets-10.3-cp38-cp38-win32.whl", hash = "sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559"},
{file = "websockets-10.3-cp38-cp38-win_amd64.whl", hash = "sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d"},
{file = "websockets-10.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094"},
{file = "websockets-10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667"},
{file = "websockets-10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731"},
{file = "websockets-10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9"},
{file = "websockets-10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680"},
{file = "websockets-10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247"},
{file = "websockets-10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af"},
{file = "websockets-10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3"},
{file = "websockets-10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8"},
{file = "websockets-10.3-cp39-cp39-win32.whl", hash = "sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582"},
{file = "websockets-10.3-cp39-cp39-win_amd64.whl", hash = "sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02"},
{file = "websockets-10.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7"},
{file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f"},
{file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4"},
{file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755"},
{file = "websockets-10.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55"},
{file = "websockets-10.3.tar.gz", hash = "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"},
]
win32-setctime = [
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},

View File

@ -9,7 +9,6 @@ license = "AGPL-3.0"
python = "^3.10"
Jinja2 = "^3.1.2"
fastapi = "^0.78.0"
uvicorn = "^0.17.6"
pycryptodome = "^3.14.1"
bcrypt = "^3.2.2"
itsdangerous = "^2.1.2"
@ -43,6 +42,8 @@ asgiref = "^3.5.2"
supervisor = "^4.2.4"
invoke = "^1.7.1"
boussole = "^2.0.0"
uvicorn = {extras = ["standard"], version = "^0.18.3"}
Brotli = "^1.0.9"
[tool.poetry.dev-dependencies]
black = "^22.3.0"

View File

@ -6,7 +6,6 @@ from typing import Any
import bcrypt
import tomli_w
from markdown import markdown # type: ignore
from prompt_toolkit import prompt
from prompt_toolkit.key_binding import KeyBindings
@ -58,15 +57,13 @@ def main() -> None:
prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt()
).decode()
dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"])
dat["summary"] = markdown(
prompt(
(
"summary (short description, in markdown, "
"press [CTRL] + [SPACE] to submit):\n"
),
key_bindings=_kb,
multiline=True,
)
dat["summary"] = prompt(
(
"summary (short description, in markdown, "
"press [CTRL] + [SPACE] to submit):\n"
),
key_bindings=_kb,
multiline=True,
)
dat["https"] = True
proto = "https"

114
tasks.py
View File

@ -1,6 +1,5 @@
import asyncio
import io
import subprocess
import tarfile
from contextlib import contextmanager
from pathlib import Path
@ -164,14 +163,12 @@ def stats(ctx):
@contextmanager
def embed_version() -> Generator[None, None, None]:
from app.utils.version import get_version_commit
version_file = Path("app/_version.py")
version_file.unlink(missing_ok=True)
version = (
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
.split()[0]
.decode()
)
version_file.write_text(f'VERSION_COMMIT = "{version}"')
version_commit = get_version_commit()
version_file.write_text(f'VERSION_COMMIT = "{version_commit}"')
try:
yield
finally:
@ -193,6 +190,109 @@ def prune_old_data(ctx):
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, moved_to)
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
def yunohost_config(
ctx,

View File

@ -84,12 +84,13 @@ def build_move_activity(
def build_note_object(
from_remote_actor: actor.RemoteActor,
from_remote_actor: actor.RemoteActor | models.Actor,
outbox_public_id: str | None = None,
content: str = "Hello",
to: list[str] = None,
cc: list[str] = None,
tags: list[ap.RawObject] = None,
in_reply_to: str | None = None,
) -> ap.RawObject:
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
@ -108,8 +109,8 @@ def build_note_object(
"url": from_remote_actor.ap_id + "/note/" + note_id,
"tag": tags or [],
"summary": None,
"inReplyTo": None,
"sensitive": False,
"inReplyTo": in_reply_to,
}

View File

@ -52,4 +52,4 @@ def test_sqlalchemy_factory(db: Session) -> None:
ap_actor=ra.ap_actor,
ap_id=ra.ap_id,
)
assert actor_in_db.id == db.query(models.Actor).one().id
assert actor_in_db.id == db.execute(select(models.Actor)).scalar_one().id

View File

@ -75,7 +75,7 @@ def test_inbox_incoming_follow_request(
assert inbox_object.ap_object == follow_activity.ap_object
# And a follower was internally created
follower = db.query(models.Follower).one()
follower = db.execute(select(models.Follower)).scalar_one()
assert follower.ap_actor_id == ra.ap_id
assert follower.actor_id == saved_actor.id
assert follower.inbox_object_id == inbox_object.id
@ -414,3 +414,12 @@ def test_inbox__move_activity(
)
== 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

View File

@ -2,13 +2,17 @@ from unittest import mock
import respx
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import activitypub as ap
from app import models
from app import webfinger
from app.actor import LOCAL_ACTOR
from app.config import generate_csrf_token
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_as_follower
@ -49,16 +53,178 @@ def test_send_follow_request(
assert response.headers.get("Location") == "http://testserver/"
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Follow"
assert outbox_object.activity_object_ap_id == ra.ap_id
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
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
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)
inbox_note.replies_count = 1
db.commit()
# and a local reply
outbox_note = setup_outbox_note(
to=[ap.AS_PUBLIC],
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
in_reply_to=inbox_note.ap_id,
)
inbox_note.replies_count = inbox_note.replies_count + 1
db.commit()
response = client.post(
"/admin/actions/delete",
data={
"redirect_url": "http://testserver/",
"ap_object_id": outbox_note.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_note.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 decremented
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>"
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>"
assert len(outbox_object.attachments) == 1
def test_send_create_activity__no_followers_and_with_mention(
db: Session,
client: TestClient,
@ -83,11 +249,11 @@ def test_send_create_activity__no_followers_and_with_mention(
assert response.status_code == 302
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note"
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
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
@ -119,11 +285,11 @@ def test_send_create_activity__with_followers(
assert response.status_code == 302
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note"
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url
@ -159,7 +325,7 @@ def test_send_create_activity__question__one_of(
assert response.status_code == 302
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Question"
assert outbox_object.is_one_of_poll is True
assert len(outbox_object.poll_items) == 2
@ -167,7 +333,7 @@ def test_send_create_activity__question__one_of(
assert outbox_object.is_poll_ended is False
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url
@ -205,7 +371,7 @@ def test_send_create_activity__question__any_of(
assert response.status_code == 302
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Question"
assert outbox_object.is_one_of_poll is False
assert len(outbox_object.poll_items) == 4
@ -213,7 +379,7 @@ def test_send_create_activity__question__any_of(
assert outbox_object.is_poll_ended is False
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url
@ -246,11 +412,11 @@ def test_send_create_activity__article(
assert response.status_code == 302
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Article"
assert outbox_object.ap_object["name"] == "Article"
# And an outgoing activity was queued
outgoing_activity = db.query(models.OutgoingActivity).one()
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id
assert outgoing_activity.recipient == follower.actor.inbox_url

View File

@ -1,4 +1,5 @@
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import activitypub as ap
@ -35,7 +36,7 @@ def test_tags__note_with_tag(db: Session, client: TestClient) -> None:
assert response.status_code == 302
# And the Follow activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one()
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Note"
assert len(outbox_object.tags) == 1
emoji_tag = outbox_object.tags[0]

View File

@ -169,6 +169,53 @@ def setup_remote_actor_as_following_and_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(
actor: models.Actor, deleted_object_ap_id: str
) -> models.InboxObject: