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

2 Commits

Author SHA1 Message Date
0dac5ec8fd Add missing template for search 2022-11-03 23:06:55 +01:00
ee37803987 Boostrap full-text search for the outbox 2022-11-03 23:03:52 +01:00
31 changed files with 363 additions and 489 deletions

View File

@ -0,0 +1,115 @@
"""Outbox FTS
Revision ID: 368f511ad954
Revises: b28c0551c236
Create Date: 2022-11-02 19:14:37.865923+00:00
"""
from sqlalchemy import insert
from sqlalchemy import select
from sqlalchemy.orm.session import Session
from alembic import op
# revision identifiers, used by Alembic.
revision = '368f511ad954'
down_revision = 'b28c0551c236'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# ### end Alembic commands ###
op.execute(
"CREATE VIRTUAL TABLE outbox_fts USING "
"fts5(summary, name, source, content='');"
)
op.execute(
"CREATE TRIGGER outbox_fts_ai AFTER "
"INSERT ON outbox WHEN new.ap_type in ('Article', 'Note', 'Question') BEGIN"
" INSERT INTO outbox_fts (rowid, source, name, summary)"
" VALUES ("
" new.id, "
" new.source, "
' json_extract(new.ap_object, "$.name"), '
' json_extract(new.ap_object, "$.summary")'
" ); "
"END;"
)
op.execute(
"CREATE TRIGGER outbox_fts_ad AFTER "
"DELETE ON outbox WHEN old.ap_type in ('Article', 'Note', 'Question') BEGIN"
" INSERT INTO outbox_fts (outbox_fts, rowid, source, name, summary)"
" VALUES ("
" 'delete', "
" old.id, "
" old.source, "
' json_extract(old.ap_object, "$.name"), '
' json_extract(old.ap_object, "$.summary")'
" ); "
"END;"
)
op.execute(
"CREATE TRIGGER outbox_fts_au_softdelete AFTER "
"UPDATE ON outbox WHEN new.is_deleted = 1 AND "
"new.ap_type in ('Article', 'Note', 'Question') BEGIN"
" INSERT INTO outbox_fts (outbox_fts, rowid, source, name, summary)"
" VALUES ("
" 'delete', "
" old.id, "
" old.source, "
' json_extract(old.ap_object, "$.name"), '
' json_extract(old.ap_object, "$.summary")'
" ); "
"END; "
)
op.execute(
"CREATE TRIGGER outbox_fts_au AFTER "
"UPDATE ON outbox "
"WHEN (new.source <> old.source OR new.ap_object <> old.ap_object) AND "
"new.ap_type in ('Note', 'Article', 'Quesion') BEGIN"
" INSERT INTO outbox_fts (outbox_fts, rowid, source, name, summary)"
" VALUES ("
" 'delete', "
" old.id, "
" old.source, "
' json_extract(old.ap_object, "$.name"), '
' json_extract(old.ap_object, "$.summary")'
" );"
" INSERT INTO outbox_fts (rowid, source, name, summary)"
" VALUES ("
" new.id, "
" new.source, "
' json_extract(new.ap_object, "$.name"), '
' json_extract(new.ap_object, "$.summary")'
" );"
"END;"
)
from app.models import OutboxObject
from app.models import outbox_fts
sess = Session(op.get_bind())
# Backfill the index
outbox_objects = sess.execute(select(OutboxObject).where(
OutboxObject.ap_type.in_(["Article", "Note", "Question"]))
).scalars()
for outbox_object in outbox_objects:
row = {"source": outbox_object.source, "rowid": outbox_object.id}
if name := outbox_object.ap_object.get("name"):
row["name"] = name
if summary := outbox_object.ap_object.get("summary"):
row["summary"] = summary
sess.execute(insert(outbox_fts).values(row))
sess.commit()
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# ### end Alembic commands ###
op.drop_table('outbox_fts')
op.execute("DROP TRIGGER outbox_fts_ai;")
op.execute("DROP TRIGGER outbox_fts_ad;")
op.execute("DROP TRIGGER outbox_fts_au_softdelete;")
op.execute("DROP TRIGGER outbox_fts_au;")

View File

@ -154,13 +154,6 @@ if ALSO_KNOWN_AS:
if MOVED_TO: if MOVED_TO:
ME["movedTo"] = MOVED_TO ME["movedTo"] = MOVED_TO
if config.CONFIG.image_url:
ME["image"] = {
"mediaType": mimetypes.guess_type(config.CONFIG.image_url)[0],
"type": "Image",
"url": config.CONFIG.image_url,
}
class NotAnObjectError(Exception): class NotAnObjectError(Exception):
def __init__(self, url: str, resp: httpx.Response | None = None) -> None: def __init__(self, url: str, resp: httpx.Response | None = None) -> None:

View File

@ -82,21 +82,11 @@ class Actor:
@property @property
def icon_url(self) -> str | None: def icon_url(self) -> str | None:
if icon := self.ap_actor.get("icon"): return self.ap_actor.get("icon", {}).get("url")
return icon.get("url")
return None
@property @property
def icon_media_type(self) -> str | None: def icon_media_type(self) -> str | None:
if icon := self.ap_actor.get("icon"): return self.ap_actor.get("icon", {}).get("mediaType")
return icon.get("mediaType")
return None
@property
def image_url(self) -> str | None:
if image := self.ap_actor.get("image"):
return image.get("url")
return None
@property @property
def public_key_as_pem(self) -> str: def public_key_as_pem(self) -> str:
@ -224,23 +214,24 @@ async def fetch_actor(
if save_if_not_found: if save_if_not_found:
ap_actor = await ap.fetch(actor_id) ap_actor = await ap.fetch(actor_id)
# Some softwares uses URL when we expect ID or uses a different casing # Some softwares uses URL when we expect ID
# (like Birdsite LIVE) , which mean we may already have it in DB if actor_id == ap_actor.get("url"):
existing_actor_by_url = ( # Which mean we may already have it in DB
await db_session.scalars( existing_actor_by_url = (
select(models.Actor).where( await db_session.scalars(
models.Actor.ap_id == ap.get_id(ap_actor), select(models.Actor).where(
models.Actor.ap_id == ap.get_id(ap_actor),
)
) )
) ).one_or_none()
).one_or_none() if existing_actor_by_url:
if existing_actor_by_url: # Update the actor as we had to fetch it anyway
# Update the actor as we had to fetch it anyway await update_actor_if_needed(
await update_actor_if_needed( db_session,
db_session, existing_actor_by_url,
existing_actor_by_url, RemoteActor(ap_actor),
RemoteActor(ap_actor), )
) return existing_actor_by_url
return existing_actor_by_url
return await save_actor(db_session, ap_actor) return await save_actor(db_session, ap_actor)
else: else:
@ -390,9 +381,6 @@ def _actor_hash(actor: Actor) -> bytes:
if actor.icon_url: if actor.icon_url:
h.update(actor.icon_url.encode()) h.update(actor.icon_url.encode())
if actor.image_url:
h.update(actor.image_url.encode())
if actor.attachments: if actor.attachments:
for a in actor.attachments: for a in actor.attachments:
if a.get("type") != "PropertyValue": if a.get("type") != "PropertyValue":

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Any
import httpx import httpx
from fastapi import APIRouter from fastapi import APIRouter
@ -149,6 +150,57 @@ async def get_lookup(
) )
@router.get("/search")
async def admin_search(
request: Request,
query: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> templates.TemplateResponse | RedirectResponse:
results: list[Any] = []
if query:
results = (
(
await db_session.execute(
select(models.OutboxObject)
.join(
models.outbox_fts,
models.outbox_fts.c.rowid == models.OutboxObject.id,
)
.options(
joinedload(
models.OutboxObject.outbox_object_attachments
).options(joinedload(models.OutboxObjectAttachment.upload)),
joinedload(models.OutboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor),
),
joinedload(
models.OutboxObject.relates_to_outbox_object
).options(
joinedload(
models.OutboxObject.outbox_object_attachments
).options(joinedload(models.OutboxObjectAttachment.upload)),
),
)
.where(models.outbox_fts.c.outbox_fts.op("MATCH")(query))
.limit(20)
)
) # type: ignore
.unique()
.scalars()
)
return await templates.render_template(
db_session,
request,
"admin_search.html",
{
"query": query,
"results": results,
},
)
@router.get("/new") @router.get("/new")
async def admin_new( async def admin_new(
request: Request, request: Request,
@ -850,30 +902,6 @@ async def admin_profile(
) )
@router.post("/actions/force_delete")
async def admin_actions_force_delete(
request: Request,
ap_object_id: str = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
ap_object_to_delete = await get_inbox_object_by_ap_id(db_session, ap_object_id)
if not ap_object_to_delete:
raise ValueError(f"Cannot find {ap_object_id}")
logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}")
await boxes._revert_side_effect_for_deleted_object(
db_session,
None,
ap_object_to_delete,
None,
)
ap_object_to_delete.is_deleted = True
await db_session.commit()
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/follow") @router.post("/actions/follow")
async def admin_actions_follow( async def admin_actions_follow(
request: Request, request: Request,

View File

@ -96,9 +96,6 @@ class Object:
def attachments(self) -> list["Attachment"]: def attachments(self) -> list["Attachment"]:
attachments = [] attachments = []
for obj in ap.as_list(self.ap_object.get("attachment", [])): for obj in ap.as_list(self.ap_object.get("attachment", [])):
if obj.get("type") == "PropertyValue":
continue
if obj.get("type") == "Link": if obj.get("type") == "Link":
attachments.append( attachments.append(
Attachment.parse_obj( Attachment.parse_obj(
@ -280,9 +277,6 @@ class Attachment(BaseModel):
proxied_url: str | None = None proxied_url: str | None = None
resized_url: str | None = None resized_url: str | None = None
width: int | None = None
height: int | None = None
@property @property
def mimetype(self) -> str: def mimetype(self) -> str:
mimetype = self.media_type mimetype = self.media_type

View File

@ -1186,7 +1186,7 @@ async def _get_replies_count(
async def _revert_side_effect_for_deleted_object( async def _revert_side_effect_for_deleted_object(
db_session: AsyncSession, db_session: AsyncSession,
delete_activity: models.InboxObject | None, delete_activity: models.InboxObject,
deleted_ap_object: models.InboxObject, deleted_ap_object: models.InboxObject,
forwarded_by_actor: models.Actor | None, forwarded_by_actor: models.Actor | None,
) -> None: ) -> None:
@ -1223,7 +1223,7 @@ async def _revert_side_effect_for_deleted_object(
.where( .where(
models.OutboxObject.id == replied_object.id, models.OutboxObject.id == replied_object.id,
) )
.values(replies_count=new_replies_count - 1) .values(replies_count=new_replies_count)
) )
else: else:
new_replies_count = await _get_replies_count( new_replies_count = await _get_replies_count(
@ -1235,7 +1235,7 @@ async def _revert_side_effect_for_deleted_object(
.where( .where(
models.InboxObject.id == replied_object.id, models.InboxObject.id == replied_object.id,
) )
.values(replies_count=new_replies_count - 1) .values(replies_count=new_replies_count)
) )
if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id: if deleted_ap_object.ap_type == "Like" and deleted_ap_object.activity_object_ap_id:
@ -1282,8 +1282,7 @@ async def _revert_side_effect_for_deleted_object(
# If it's a local replies, it was forwarded, so we also need to forward # If it's a local replies, it was forwarded, so we also need to forward
# the Delete activity if possible # the Delete activity if possible
if ( if (
delete_activity delete_activity.activity_object_ap_id == deleted_ap_object.ap_id
and delete_activity.activity_object_ap_id == deleted_ap_object.ap_id
and delete_activity.has_ld_signature and delete_activity.has_ld_signature
and is_delete_needs_to_be_forwarded and is_delete_needs_to_be_forwarded
): ):

View File

@ -1,5 +1,4 @@
import hashlib import hashlib
import hmac
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
@ -92,7 +91,6 @@ class Config(pydantic.BaseModel):
summary: str summary: str
https: bool https: bool
icon_url: str icon_url: str
image_url: str | None = None
secret: str secret: str
debug: bool = False debug: bool = False
trusted_hosts: list[str] = ["127.0.0.1"] trusted_hosts: list[str] = ["127.0.0.1"]
@ -110,15 +108,10 @@ class Config(pydantic.BaseModel):
inbox_retention_days: int = 15 inbox_retention_days: int = 15
custom_content_security_policy: str | None = None
# Config items to make tests easier # Config items to make tests easier
sqlalchemy_database: str | None = None sqlalchemy_database: str | None = None
key_path: str | None = None key_path: str | None = None
# Only set when the app is served on a non-root path
id: str | None = None
def load_config() -> Config: def load_config() -> Config:
try: try:
@ -153,11 +146,6 @@ CONFIG = load_config()
DOMAIN = CONFIG.domain DOMAIN = CONFIG.domain
_SCHEME = "https" if CONFIG.https else "http" _SCHEME = "https" if CONFIG.https else "http"
ID = f"{_SCHEME}://{DOMAIN}" ID = f"{_SCHEME}://{DOMAIN}"
# When running the app on a path, the ID maybe set by the config, but in this
# case, a valid webfinger must be served on the root domain
if CONFIG.id:
ID = CONFIG.id
USERNAME = CONFIG.username USERNAME = CONFIG.username
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
HIDES_FOLLOWERS = CONFIG.hides_followers HIDES_FOLLOWERS = CONFIG.hides_followers
@ -168,7 +156,6 @@ if CONFIG.privacy_replace:
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers} BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
ALSO_KNOWN_AS = CONFIG.also_known_as ALSO_KNOWN_AS = CONFIG.also_known_as
CUSTOM_CONTENT_SECURITY_POLICY = CONFIG.custom_content_security_policy
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
CUSTOM_FOOTER = ( CUSTOM_FOOTER = (
@ -189,9 +176,7 @@ if CONFIG.emoji:
EMOJIS = CONFIG.emoji EMOJIS = CONFIG.emoji
# Emoji template for the FE # Emoji template for the FE
EMOJI_TPL = ( EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
'<img src="{base_url}/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
)
_load_emojis(ROOT_DIR, BASE_URL) _load_emojis(ROOT_DIR, BASE_URL)
@ -255,7 +240,3 @@ def verify_csrf_token(
detail=f"The security token has expired, {please_try_again}", detail=f"The security token has expired, {please_try_again}",
) )
return None return None
def hmac_sha256():
return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)

View File

@ -3,6 +3,7 @@ import traceback
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
import httpx
from loguru import logger from loguru import logger
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy import select from sqlalchemy import select
@ -107,7 +108,6 @@ async def process_next_incoming_activity(
next_activity.tries = next_activity.tries + 1 next_activity.tries = next_activity.tries + 1
next_activity.last_try = now() next_activity.last_try = now()
await db_session.commit()
if next_activity.ap_object and next_activity.sent_by_ap_actor_id: if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
try: try:
@ -120,16 +120,13 @@ async def process_next_incoming_activity(
), ),
timeout=60, timeout=60,
) )
except asyncio.exceptions.TimeoutError: except httpx.TimeoutException as exc:
logger.error("Activity took too long to process") url = exc._request.url if exc._request else None
await db_session.rollback() logger.error(f"Failed, HTTP timeout when fetching {url}")
await db_session.refresh(next_activity)
next_activity.error = traceback.format_exc() next_activity.error = traceback.format_exc()
_set_next_try(next_activity) _set_next_try(next_activity)
except Exception: except Exception:
logger.exception("Failed") logger.exception("Failed")
await db_session.rollback()
await db_session.refresh(next_activity)
next_activity.error = traceback.format_exc() next_activity.error = traceback.format_exc()
_set_next_try(next_activity) _set_next_try(next_activity)
else: else:

View File

@ -48,7 +48,6 @@ from app import boxes
from app import config from app import config
from app import httpsig from app import httpsig
from app import indieauth from app import indieauth
from app import media
from app import micropub from app import micropub
from app import models from app import models
from app import templates from app import templates
@ -137,15 +136,9 @@ class CustomMiddleware:
headers["x-frame-options"] = "DENY" headers["x-frame-options"] = "DENY"
headers["permissions-policy"] = "interest-cohort=()" headers["permissions-policy"] = "interest-cohort=()"
headers["content-security-policy"] = ( headers["content-security-policy"] = (
( f"default-src 'self'; "
f"default-src 'self'; " f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; " f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
)
if not config.CUSTOM_CONTENT_SECURITY_POLICY
else config.CUSTOM_CONTENT_SECURITY_POLICY.format(
HIGHLIGHT_CSS_HASH=HIGHLIGHT_CSS_HASH
)
) )
if not DEBUG: if not DEBUG:
headers["strict-transport-security"] = "max-age=63072000;" headers["strict-transport-security"] = "max-age=63072000;"
@ -254,30 +247,6 @@ class ActivityPubResponse(JSONResponse):
media_type = "application/activity+json" media_type = "application/activity+json"
async def redirect_to_remote_instance(
request: Request,
db_session: AsyncSession,
url: str,
) -> templates.TemplateResponse:
"""
Similar to RedirectResponse, but uses a 200 response with HTML.
Needed for remote redirects on form submission endpoints,
since our CSP policy disallows remote form submission.
https://github.com/w3c/webappsec-csp/issues/8#issuecomment-810108984
"""
return await templates.render_template(
db_session,
request,
"redirect_to_remote_instance.html",
{
"request": request,
"url": url,
},
headers={"Refresh": "0;url=" + url},
)
@app.get(config.NavBarItems.NOTES_PATH) @app.get(config.NavBarItems.NOTES_PATH)
async def index( async def index(
request: Request, request: Request,
@ -764,7 +733,7 @@ async def outbox_by_public_id(
if maybe_object.ap_type == "Article": if maybe_object.ap_type == "Article":
return RedirectResponse( return RedirectResponse(
f"{BASE_URL}/articles/{public_id[:7]}/{maybe_object.slug}", f"/articles/{public_id[:7]}/{maybe_object.slug}",
status_code=301, status_code=301,
) )
@ -983,10 +952,9 @@ async def get_remote_follow(
@app.post("/remote_follow") @app.post("/remote_follow")
async def post_remote_follow( async def post_remote_follow(
request: Request, request: Request,
db_session: AsyncSession = Depends(get_db_session),
csrf_check: None = Depends(verify_csrf_token), csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(), profile: str = Form(),
) -> templates.TemplateResponse: ) -> RedirectResponse:
if not profile.startswith("@"): if not profile.startswith("@"):
profile = f"@{profile}" profile = f"@{profile}"
@ -995,10 +963,9 @@ async def post_remote_follow(
# TODO(ts): error message to user # TODO(ts): error message to user
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return await redirect_to_remote_instance( return RedirectResponse(
request,
db_session,
remote_follow_template.format(uri=ID), remote_follow_template.format(uri=ID),
status_code=302,
) )
@ -1026,11 +993,10 @@ async def remote_interaction(
@app.post("/remote_interaction") @app.post("/remote_interaction")
async def post_remote_interaction( async def post_remote_interaction(
request: Request, request: Request,
db_session: AsyncSession = Depends(get_db_session),
csrf_check: None = Depends(verify_csrf_token), csrf_check: None = Depends(verify_csrf_token),
profile: str = Form(), profile: str = Form(),
ap_id: str = Form(), ap_id: str = Form(),
) -> templates.TemplateResponse: ) -> RedirectResponse:
if not profile.startswith("@"): if not profile.startswith("@"):
profile = f"@{profile}" profile = f"@{profile}"
@ -1039,10 +1005,9 @@ async def post_remote_interaction(
# TODO(ts): error message to user # TODO(ts): error message to user
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return await redirect_to_remote_instance( return RedirectResponse(
request, remote_follow_template.format(uri=ap_id),
db_session, status_code=302,
remote_follow_template.format(uri=ID),
) )
@ -1163,17 +1128,14 @@ def _add_cache_control(headers: dict[str, str]) -> dict[str, str]:
return {**headers, "Cache-Control": "max-age=31536000"} return {**headers, "Cache-Control": "max-age=31536000"}
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}") @app.get("/proxy/media/{encoded_url}")
async def serve_proxy_media( async def serve_proxy_media(
request: Request, request: Request,
exp: int,
sig: str,
encoded_url: str, encoded_url: str,
) -> StreamingResponse | PlainTextResponse: ) -> StreamingResponse | PlainTextResponse:
# Decode the base64-encoded URL # Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode() url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url) check_url(url)
media.verify_proxied_media_sig(exp, url, sig)
proxy_resp = await _proxy_get(request, url, stream=True) proxy_resp = await _proxy_get(request, url, stream=True)
@ -1206,11 +1168,9 @@ async def serve_proxy_media(
) )
@app.get("/proxy/media/{exp}/{sig}/{encoded_url}/{size}") @app.get("/proxy/media/{encoded_url}/{size}")
async def serve_proxy_media_resized( async def serve_proxy_media_resized(
request: Request, request: Request,
exp: int,
sig: str,
encoded_url: str, encoded_url: str,
size: int, size: int,
) -> PlainTextResponse: ) -> PlainTextResponse:
@ -1220,7 +1180,6 @@ async def serve_proxy_media_resized(
# Decode the base64-encoded URL # Decode the base64-encoded URL
url = base64.urlsafe_b64decode(encoded_url).decode() url = base64.urlsafe_b64decode(encoded_url).decode()
check_url(url) check_url(url)
media.verify_proxied_media_sig(exp, url, sig)
if cached_resp := _RESIZED_CACHE.get((url, size)): if cached_resp := _RESIZED_CACHE.get((url, size)):
resized_content, resized_mimetype, resp_headers = cached_resp resized_content, resized_mimetype, resp_headers = cached_resp

View File

@ -1,44 +1,15 @@
import base64 import base64
import time
from app.config import BASE_URL from app.config import BASE_URL
from app.config import hmac_sha256
SUPPORTED_RESIZE = [50, 740] SUPPORTED_RESIZE = [50, 740]
EXPIRY_PERIOD = 86400
EXPIRY_LENGTH = 7
class InvalidProxySignatureError(Exception):
pass
def proxied_media_sig(expires: int, url: str) -> str:
hm = hmac_sha256()
hm.update(f"{expires}".encode())
hm.update(b"|")
hm.update(url.encode())
return base64.urlsafe_b64encode(hm.digest()).decode()
def verify_proxied_media_sig(expires: int, url: str, sig: str) -> None:
now = int(time.time() / EXPIRY_PERIOD)
expected = proxied_media_sig(expires, url)
if now > expires or sig != expected:
raise InvalidProxySignatureError("invalid or expired media")
def proxied_media_url(url: str) -> str: def proxied_media_url(url: str) -> str:
if url.startswith(BASE_URL): if url.startswith(BASE_URL):
return url return url
expires = int(time.time() / EXPIRY_PERIOD) + EXPIRY_LENGTH
sig = proxied_media_sig(expires, url)
return ( return "/proxy/media/" + base64.urlsafe_b64encode(url.encode()).decode()
BASE_URL
+ f"/proxy/media/{expires}/{sig}/"
+ base64.urlsafe_b64encode(url.encode()).decode()
)
def resized_media_url(url: str, size: int) -> str: def resized_media_url(url: str, size: int) -> str:

View File

@ -251,8 +251,6 @@ class OutboxObject(Base, BaseObject):
"mediaType": attachment.upload.content_type, "mediaType": attachment.upload.content_type,
"name": attachment.alt or attachment.filename, "name": attachment.alt or attachment.filename,
"url": url, "url": url,
"width": attachment.upload.width,
"height": attachment.upload.height,
"proxiedUrl": url, "proxiedUrl": url,
"resizedUrl": BASE_URL "resizedUrl": BASE_URL
+ ( + (

View File

@ -531,13 +531,3 @@ a.label-btn {
text-decoration: underline; text-decoration: underline;
} }
} }
.ap-place {
h3 {
display: inline;
font-weight: normal;
}
h3::after {
content: ': ';
}
}

View File

@ -1,7 +1,6 @@
import re import re
import typing import typing
from loguru import logger
from mistletoe import Document # type: ignore from mistletoe import Document # type: ignore
from mistletoe.html_renderer import HTMLRenderer # type: ignore from mistletoe.html_renderer import HTMLRenderer # type: ignore
from mistletoe.span_token import SpanToken # type: ignore from mistletoe.span_token import SpanToken # type: ignore
@ -79,17 +78,13 @@ class CustomRenderer(HTMLRenderer):
def render_mention(self, token: Mention) -> str: def render_mention(self, token: Mention) -> str:
mention = token.target mention = token.target
suffix = ""
if mention.endswith("."):
mention = mention[:-1]
suffix = "."
actor = self.mentioned_actors.get(mention) actor = self.mentioned_actors.get(mention)
if not actor: if not actor:
return mention return mention
self.tags.append(dict(type="Mention", href=actor.ap_id, name=mention)) self.tags.append(dict(type="Mention", href=actor.ap_id, name=mention))
link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>{suffix}' # noqa: E501 link = f'<span class="h-card"><a href="{actor.url}" class="u-url mention">{actor.handle}</a></span>' # noqa: E501
return link return link
def render_hashtag(self, token: Hashtag) -> str: def render_hashtag(self, token: Hashtag) -> str:
@ -123,30 +118,23 @@ async def _prefetch_mentioned_actors(
if mention in actors: if mention in actors:
continue continue
# XXX: the regex catches stuff like `@toto@example.com.` _, username, domain = mention.split("@")
if mention.endswith("."): actor = (
mention = mention[:-1] await db_session.execute(
select(models.Actor).where(
try: models.Actor.handle == mention,
_, username, domain = mention.split("@") models.Actor.is_deleted.is_(False),
actor = (
await db_session.execute(
select(models.Actor).where(
models.Actor.handle == mention,
models.Actor.is_deleted.is_(False),
)
) )
).scalar_one_or_none() )
if not actor: ).scalar_one_or_none()
actor_url = await webfinger.get_actor_url(mention) if not actor:
if not actor_url: actor_url = await webfinger.get_actor_url(mention)
# FIXME(ts): raise an error? if not actor_url:
continue # FIXME(ts): raise an error?
actor = await fetch_actor(db_session, actor_url) continue
actor = await fetch_actor(db_session, actor_url)
actors[mention] = actor actors[mention] = actor
except Exception:
logger.exception(f"Failed to prefetch {mention}")
return actors return actors

View File

@ -1,3 +1,4 @@
import base64
from datetime import datetime from datetime import datetime
from datetime import timezone from datetime import timezone
from functools import lru_cache from functools import lru_cache
@ -38,7 +39,7 @@ from app.utils.highlight import HIGHLIGHT_CSS
from app.utils.highlight import highlight from app.utils.highlight import highlight
_templates = Jinja2Templates( _templates = Jinja2Templates(
directory=["data/templates", "app/templates"], # type: ignore # bad typing directory="app/templates",
trim_blocks=True, trim_blocks=True,
lstrip_blocks=True, lstrip_blocks=True,
) )
@ -58,8 +59,13 @@ def _filter_domain(text: str) -> str:
def _media_proxy_url(url: str | None) -> str: def _media_proxy_url(url: str | None) -> str:
if not url: if not url:
return BASE_URL + "/static/nopic.png" return "/static/nopic.png"
return proxied_media_url(url)
if url.startswith(BASE_URL):
return url
encoded_url = base64.urlsafe_b64encode(url.encode()).decode()
return f"/proxy/media/{encoded_url}"
def is_current_user_admin(request: Request) -> bool: def is_current_user_admin(request: Request) -> bool:
@ -85,7 +91,6 @@ async def render_template(
template: str, template: str,
template_args: dict[str, Any] | None = None, template_args: dict[str, Any] | None = None,
status_code: int = 200, status_code: int = 200,
headers: dict[str, str] | None = None,
) -> TemplateResponse: ) -> TemplateResponse:
if template_args is None: if template_args is None:
template_args = {} template_args = {}
@ -130,7 +135,6 @@ async def render_template(
**template_args, **template_args,
}, },
status_code=status_code, status_code=status_code,
headers=headers,
) )
@ -384,7 +388,7 @@ def _html2text(content: str) -> str:
def _replace_emoji(u: str, _) -> str: def _replace_emoji(u: str, _) -> str:
filename = "-".join(hex(ord(c))[2:] for c in u) filename = "-".join(hex(ord(c))[2:] for c in u)
return config.EMOJI_TPL.format(base_url=BASE_URL, filename=filename, raw=u) return config.EMOJI_TPL.format(filename=filename, raw=u)
def _emojify(text: str, is_local: bool) -> str: def _emojify(text: str, is_local: bool) -> str:
@ -426,4 +430,3 @@ _templates.env.globals["BASE_URL"] = config.BASE_URL
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS _templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING _templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING
_templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems _templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems
_templates.env.globals["ICON_URL"] = config.CONFIG.icon_url

View File

@ -90,5 +90,5 @@
</p> </p>
</form> </form>
</div> </div>
<script src="{{ BASE_URL }}/static/new.js?v={{ JS_HASH }}"></script> <script src="/static/new.js?v={{ JS_HASH }}"></script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,27 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>{{ local_actor.display_name }} - Search</title>
{% endblock %}
{% block content %}
<div class="box">
<form class="form" action="{{ url_for("admin_search") }}" method="GET">
<input type="text" name="query" value="{{ query if query else "" }}" autofocus>
<select name="what">
<option value="outbox">Outbox</option>
</select>
<input type="submit" value="Search">
</form>
</div>
{% for result in results %}
{{ utils.display_object(result) }}
{% endfor %}
{% endblock %}

View File

@ -14,7 +14,7 @@
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" /> <meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
<meta content="Homepage" property="og:title" /> <meta content="Homepage" property="og:title" />
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" /> <meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
<meta content="{{ ICON_URL }}" property="og:image" /> <meta content="{{ local_actor.url }}" property="og:image" />
<meta content="summary" property="twitter:card" /> <meta content="summary" property="twitter:card" />
<meta content="{{ local_actor.handle }}" property="profile:username" /> <meta content="{{ local_actor.handle }}" property="profile:username" />
{% endif %} {% endif %}

View File

@ -25,13 +25,12 @@
</div> </div>
{%- macro header_link(url, text) -%} {%- macro header_link(url, text) -%}
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %} {% set url_for = request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a> <a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %} {% endmacro %}
{%- macro navbar_item_link(navbar_item) -%} {%- macro navbar_item_link(navbar_item) -%}
{% set url_for = BASE_URL + navbar_item[0] %} <a href="{{ navbar_item[0] }}" {% if request.url.path == navbar_item[0] %}class="active"{% endif %}>{{ navbar_item[1] }}</a>
<a href="{{ navbar_item[0] }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ navbar_item[1] }}</a>
{% endmacro %} {% endmacro %}
<div class="public-top-menu"> <div class="public-top-menu">

View File

@ -13,7 +13,7 @@
<meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" /> <meta content="{{ local_actor.display_name }}'s microblog" property="og:site_name" />
<meta content="Homepage" property="og:title" /> <meta content="Homepage" property="og:title" />
<meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" /> <meta content="{{ local_actor.summary | html2text | trim }}" property="og:description" />
<meta content="{{ ICON_URL }}" property="og:image" /> <meta content="{{ local_actor.url }}" property="og:image" />
<meta content="summary" property="twitter:card" /> <meta content="summary" property="twitter:card" />
<meta content="{{ local_actor.handle }}" property="profile:username" /> <meta content="{{ local_actor.handle }}" property="profile:username" />
{% endblock %} {% endblock %}

View File

@ -4,11 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="{{ BASE_URL }}/static/css/main.css?v={{ CSS_HASH }}"> <link rel="stylesheet" href="/static/css/main.css?v={{ CSS_HASH }}">
<link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" /> <link rel="alternate" title="{{ local_actor.display_name}}'s microblog" type="application/json" href="{{ url_for("json_feed") }}" />
<link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog"> <link rel="alternate" href="{{ url_for("rss_feed") }}" type="application/rss+xml" title="{{ local_actor.display_name}}'s microblog">
<link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog"> <link rel="alternate" href="{{ url_for("atom_feed") }}" type="application/atom+xml" title="{{ local_actor.display_name}}'s microblog">
<link rel="icon" type="image/x-icon" href="{{ BASE_URL }}/static/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>{{ highlight_css }}</style> <style>{{ highlight_css }}</style>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
@ -18,8 +18,8 @@
{% if is_admin %} {% if is_admin %}
<div id="admin"> <div id="admin">
{% macro admin_link(url, text) %} {% macro admin_link(url, text) %}
{% set url_for = BASE_URL + request.app.router.url_path_for(url) %} {% set url_for = request.app.router.url_path_for(url) %}
<a href="{{ url_for }}" {% if BASE_URL + request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a> <a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
{% endmacro %} {% endmacro %}
<div class="admin-menu"> <div class="admin-menu">
<nav class="flexbox"> <nav class="flexbox">
@ -30,6 +30,7 @@
<li>{{ admin_link("admin_inbox", "Inbox") }} / {{ admin_link("admin_outbox", "Outbox") }}</li> <li>{{ admin_link("admin_inbox", "Inbox") }} / {{ admin_link("admin_outbox", "Outbox") }}</li>
<li>{{ admin_link("admin_direct_messages", "DMs") }}</li> <li>{{ admin_link("admin_direct_messages", "DMs") }}</li>
<li>{{ admin_link("get_notifications", "Notifications") }} {% if notifications_count %}({{ notifications_count }}){% endif %}</li> <li>{{ admin_link("get_notifications", "Notifications") }} {% if notifications_count %}({{ notifications_count }}){% endif %}</li>
<li>{{ admin_link("admin_search", "Search") }}</li>
<li>{{ admin_link("get_lookup", "Lookup") }}</li> <li>{{ admin_link("get_lookup", "Lookup") }}</li>
<li>{{ admin_link("admin_bookmarks", "Bookmarks") }}</li> <li>{{ admin_link("admin_bookmarks", "Bookmarks") }}</li>
<li><a href="{{ url_for("logout")}}">Logout</a></li> <li><a href="{{ url_for("logout")}}">Logout</a></li>
@ -53,7 +54,7 @@
</div> </div>
</footer> </footer>
{% if is_admin %} {% if is_admin %}
<script src="{{ BASE_URL }}/static/common-admin.js?v={{ JS_HASH }}"></script> <script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@ -7,7 +7,7 @@
{% if error %} {% if error %}
<p class="primary-color">Invalid password.</p> <p class="primary-color">Invalid password.</p>
{% endif %} {% endif %}
<form class="form" action="{{ BASE_URL }}/admin/login" method="POST"> <form class="form" action="/admin/login" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect" value="{{ redirect }}"> <input type="hidden" name="redirect" value="{{ redirect }}">
<input type="password" placeholder="password" name="password" autofocus> <input type="password" placeholder="password" name="password" autofocus>

View File

@ -1,15 +0,0 @@
{%- import "utils.html" as utils with context -%}
{% extends "layout.html" %}
{% block head %}
<title>{{ local_actor.display_name }}'s microblog - Redirect</title>
{% endblock %}
{% block content %}
{% include "header.html" %}
<div class="box">
<p>You are being redirected to your instance: <a href="{{ url }}">{{ url }}</a></p>
</div>
{% endblock %}

View File

@ -1,220 +1,168 @@
{% macro embed_csrf_token() %} {% macro embed_csrf_token() %}
{% block embed_csrf_token scoped %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro embed_redirect_url(permalink_id=None) %} {% macro embed_redirect_url(permalink_id=None) %}
{% block embed_redirect_url scoped %}
<input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}"> <input type="hidden" name="redirect_url" value="{{ request.url }}{% if permalink_id %}#{{ permalink_id }}{% endif %}">
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_block_button(actor) %} {% macro admin_block_button(actor) %}
{% block admin_block_button scoped %}
<form action="{{ request.url_for("admin_actions_block") }}" method="POST"> <form action="{{ request.url_for("admin_actions_block") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url() }} {{ embed_redirect_url() }}
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}"> <input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
<input type="submit" value="block"> <input type="submit" value="block">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_unblock_button(actor) %} {% macro admin_unblock_button(actor) %}
{% block admin_unblock_button scoped %}
<form action="{{ request.url_for("admin_actions_unblock") }}" method="POST"> <form action="{{ request.url_for("admin_actions_unblock") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url() }} {{ embed_redirect_url() }}
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}"> <input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
<input type="submit" value="unblock"> <input type="submit" value="unblock">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_follow_button(actor) %} {% macro admin_follow_button(actor) %}
{% block admin_follow_button scoped %}
<form action="{{ request.url_for("admin_actions_follow") }}" method="POST"> <form action="{{ request.url_for("admin_actions_follow") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url() }} {{ embed_redirect_url() }}
<input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}"> <input type="hidden" name="ap_actor_id" value="{{ actor.ap_id }}">
<input type="submit" value="follow"> <input type="submit" value="follow">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_accept_incoming_follow_button(notif) %} {% macro admin_accept_incoming_follow_button(notif) %}
{% block admin_accept_incoming_follow_button scoped %}
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST"> <form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url() }} {{ embed_redirect_url() }}
<input type="hidden" name="notification_id" value="{{ notif.id }}"> <input type="hidden" name="notification_id" value="{{ notif.id }}">
<input type="submit" value="accept follow"> <input type="submit" value="accept follow">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_reject_incoming_follow_button(notif) %} {% macro admin_reject_incoming_follow_button(notif) %}
{% block admin_reject_incoming_follow_button scoped %}
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST"> <form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url() }} {{ embed_redirect_url() }}
<input type="hidden" name="notification_id" value="{{ notif.id }}"> <input type="hidden" name="notification_id" value="{{ notif.id }}">
<input type="submit" value="reject follow"> <input type="submit" value="reject follow">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_like_button(ap_object_id, permalink_id) %} {% macro admin_like_button(ap_object_id, permalink_id) %}
{% block admin_like_button scoped %}
<form action="{{ request.url_for("admin_actions_like") }}" method="POST"> <form action="{{ request.url_for("admin_actions_like") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="like"> <input type="submit" value="like">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_bookmark_button(ap_object_id, permalink_id) %} {% macro admin_bookmark_button(ap_object_id, permalink_id) %}
{% block admin_bookmark_button scoped %}
<form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST"> <form action="{{ request.url_for("admin_actions_bookmark") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="bookmark"> <input type="submit" value="bookmark">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_unbookmark_button(ap_object_id, permalink_id) %} {% macro admin_unbookmark_button(ap_object_id, permalink_id) %}
{% block admin_unbookmark_button scoped %}
<form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST"> <form action="{{ request.url_for("admin_actions_unbookmark") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="unbookmark"> <input type="submit" value="unbookmark">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_pin_button(ap_object_id, permalink_id) %} {% macro admin_pin_button(ap_object_id, permalink_id) %}
{% block admin_pin_button scoped %}
<form action="{{ request.url_for("admin_actions_pin") }}" method="POST"> <form action="{{ request.url_for("admin_actions_pin") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="pin"> <input type="submit" value="pin">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_unpin_button(ap_object_id, permalink_id) %} {% macro admin_unpin_button(ap_object_id, permalink_id) %}
{% block admin_unpin_button scoped %}
<form action="{{ request.url_for("admin_actions_unpin") }}" method="POST"> <form action="{{ request.url_for("admin_actions_unpin") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="unpin"> <input type="submit" value="unpin">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_delete_button(ap_object) %} {% macro admin_delete_button(ap_object) %}
{% block admin_delete_button scoped %}
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST"> <form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
<input type="hidden" name="redirect_url" value="{% if request.url.path.endswith("/" + ap_object.public_id) or (request.url.path == "/admin/object" and request.query_params.ap_id.endswith("/" + ap_object.public_id)) %}{{ request.base_url}}{% else %}{{ request.url }}{% endif %}"> <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="hidden" name="ap_object_id" value="{{ ap_object.ap_id }}">
<input type="submit" value="delete"> <input type="submit" value="delete">
</form> </form>
{% endblock %}
{% endmacro %}
{% macro admin_force_delete_button(ap_object_id, permalink_id=None) %}
{% block admin_force_delete_button scoped %}
<form action="{{ request.url_for("admin_actions_force_delete") }}" class="object-delete-form" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="local delete">
</form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_announce_button(ap_object_id, permalink_id=None) %} {% macro admin_announce_button(ap_object_id, permalink_id=None) %}
{% block admin_announce_button scoped %}
<form action="{{ request.url_for("admin_actions_announce") }}" method="POST"> <form action="{{ request.url_for("admin_actions_announce") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="share"> <input type="submit" value="share">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %} {% macro admin_undo_button(ap_object_id, action="undo", permalink_id=None) %}
{% block admin_undo_button scoped %}
<form action="{{ request.url_for("admin_actions_undo") }}" method="POST"> <form action="{{ request.url_for("admin_actions_undo") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
{{ embed_redirect_url(permalink_id) }} {{ embed_redirect_url(permalink_id) }}
<input type="hidden" name="ap_object_id" value="{{ ap_object_id }}"> <input type="hidden" name="ap_object_id" value="{{ ap_object_id }}">
<input type="submit" value="{{ action }}"> <input type="submit" value="{{ action }}">
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_reply_button(ap_object_id) %} {% macro admin_reply_button(ap_object_id) %}
{% block admin_reply_button scoped %} <form action="/admin/new" method="GET">
<form action="{{ BASE_URL }}/admin/new" method="GET">
<input type="hidden" name="in_reply_to" value="{{ ap_object_id }}"> <input type="hidden" name="in_reply_to" value="{{ ap_object_id }}">
<button type="submit">reply</button> <button type="submit">reply</button>
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_dm_button(actor_handle) %} {% macro admin_dm_button(actor_handle) %}
{% block admin_dm_button scoped %} <form action="/admin/new" method="GET">
<form action="{{ BASE_URL }}/admin/new" method="GET">
<input type="hidden" name="with_content" value="{{ actor_handle }}"> <input type="hidden" name="with_content" value="{{ actor_handle }}">
<input type="hidden" name="with_visibility" value="DIRECT"> <input type="hidden" name="with_visibility" value="DIRECT">
<button type="submit">direct message</button> <button type="submit">direct message</button>
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_mention_button(actor_handle) %} {% macro admin_mention_button(actor_handle) %}
{% block admin_mention_button scoped %} <form action="/admin/new" method="GET">
<form action="{{ BASE_URL }}/admin/new" method="GET">
<input type="hidden" name="with_content" value="{{ actor_handle }}"> <input type="hidden" name="with_content" value="{{ actor_handle }}">
<button type="submit">mention</button> <button type="submit">mention</button>
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_profile_button(ap_actor_id) %} {% macro admin_profile_button(ap_actor_id) %}
{% block admin_profile_button scoped %}
<form action="{{ url_for("admin_profile") }}" method="GET"> <form action="{{ url_for("admin_profile") }}" method="GET">
<input type="hidden" name="actor_id" value="{{ ap_actor_id }}"> <input type="hidden" name="actor_id" value="{{ ap_actor_id }}">
<button type="submit">profile</button> <button type="submit">profile</button>
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro admin_expand_button(ap_object) %} {% macro admin_expand_button(ap_object) %}
{% block admin_expand_button scoped %}
{# TODO turn these into a regular link and append permalink ID if it's a reply #} {# TODO turn these into a regular link and append permalink ID if it's a reply #}
<form action="{{ url_for("admin_object") }}" method="GET"> <form action="{{ url_for("admin_object") }}" method="GET">
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}"> <input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
<button type="submit">expand</button> <button type="submit">expand</button>
</form> </form>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_box_filters(route) %} {% macro display_box_filters(route) %}
{% block display_box_filters scoped %}
<nav class="flexbox box"> <nav class="flexbox box">
<ul> <ul>
<li>Filter by</li> <li>Filter by</li>
@ -231,17 +179,13 @@
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_tiny_actor_icon(actor) %} {% macro display_tiny_actor_icon(actor) %}
{% block display_tiny_actor_icon scoped %}
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar"> <img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro actor_action(inbox_object, text, with_icon=False) %} {% macro actor_action(inbox_object, text, with_icon=False) %}
{% block actor_action scoped %}
<div class="actor-action"> <div class="actor-action">
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}"> <a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
{% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }} {% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}
@ -249,11 +193,9 @@
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span> <span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
</div> </div>
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %} {% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
{% block display_actor scoped %}
{% set metadata = actors_metadata.get(actor.ap_id) %} {% set metadata = actors_metadata.get(actor.ap_id) %}
{% if not embedded %} {% if not embedded %}
@ -364,11 +306,9 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_og_meta(object) %} {% macro display_og_meta(object) %}
{% block display_og_meta scoped %}
{% if object.og_meta %} {% if object.og_meta %}
{% for og_meta in object.og_meta[:1] %} {% for og_meta in object.og_meta[:1] %}
<div class="activity-og-meta"> <div class="activity-og-meta">
@ -386,29 +326,22 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_attachments(object) %} {% macro display_attachments(object) %}
{% block display_attachments scoped %}
{% for attachment in object.attachments %} {% for attachment in object.attachments %}
{% if attachment.type != "PropertyValue" %}
{% set orientation = "unknown" %}
{% if attachment.width %}
{% set orientation = "portrait" if attachment.width < attachment.height else "landscape" %}
{% endif %}
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %} {% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
<div class="attachment-wrapper"> <div class="attachment-wrapper">
<label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label> <label for="{{attachment.proxied_url}}" class="label-btn show-hide-sensitive-btn">show/hide sensitive content</label>
<div> <div>
<div class="sensitive-attachment"> <div class="sensitive-attachment">
<input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true"> <input class="sensitive-attachment-state" type="checkbox" id="{{attachment.proxied_url}}" aria-hidden="true">
<div class="sensitive-attachment-box attachment-orientation-{{orientation}}"> <div class="sensitive-attachment-box">
<div></div> <div></div>
{% else %} {% else %}
<div class="attachment-item attachment-orientation-{{orientation}}"> <div class="attachment-item">
{% endif %} {% endif %}
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %} {% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
@ -434,15 +367,12 @@
{% else %} {% else %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% endfor %} {% endfor %}
{% endblock %}
{% endmacro %} {% endmacro %}
{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %} {% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False, actors_metadata={}, is_object_page=False) %}
{% block display_object scoped %}
{% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %} {% set is_article_mode = object.is_from_outbox and object.ap_type == "Article" and is_object_page %}
{% if object.ap_type in ["Note", "Article", "Video", "Page", "Question", "Event"] %} {% if object.ap_type in ["Note", "Article", "Video", "Page", "Question"] %}
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}"> <div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
{% if is_article_mode %} {% if is_article_mode %}
@ -461,32 +391,10 @@
</a></p> </a></p>
{% endif %} {% endif %}
{% if object.ap_type in ["Article", "Event"] %} {% if object.ap_type == "Article" %}
<h2 class="p-name no-margin-top">{{ object.name }}</h2> <h2 class="p-name no-margin-top">{{ object.name }}</h2>
{% endif %} {% endif %}
{% if object.ap_type == "Event" %}
{% if object.ap_object.get("endTime") and object.ap_object.get("startTime") %}
<p>On {{ object.ap_object.startTime | parse_datetime | format_date }}
(ends {{ object.ap_object.endTime | parse_datetime | format_date }})</p>
{% endif %}
{% endif %}
{% if object.ap_object.get("location") %}
{% set loc = object.ap_object.get("location") %}
{% if loc.type == "Place" and loc.latitude and loc.longitude %}
<div class="ap-place">
<h3>Location</h3>
{% if loc.name %}{{ loc.name }}{% endif %}
<span class="h-geo">
<data class="p-latitude" value="{{ loc.latitude}}"></data>
<data class="p-longitude" value="{{ loc.longitude }}"></data>
<a href="https://www.openstreetmap.org/?mlat={{ loc.latitude }}&mlon={{ loc.longitude }}#map=16/{{loc.latitude}}/{{loc.longitude}}">{{loc.latitude}},{{loc.longitude}}</a>
</span>
</div>
{% endif %}
{% endif %}
{% if is_article_mode %} {% if is_article_mode %}
<time class="dt-published muted" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at.strftime("%b %d, %Y") }}</time> <time class="dt-published muted" datetime="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}" title="{{ object.ap_published_at.replace(microsecond=0).isoformat() }}">{{ object.ap_published_at.strftime("%b %d, %Y") }}</time>
{% endif %} {% endif %}
@ -693,11 +601,6 @@
{{ admin_expand_button(object) }} {{ admin_expand_button(object) }}
</li> </li>
{% endif %} {% endif %}
{% if object.is_from_inbox %}
<li>
{{ admin_force_delete_button(object.ap_id) }}
</li>
{% endif %}
</ul> </ul>
</nav> </nav>
{% endif %} {% endif %}
@ -760,5 +663,4 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %}
{% endmacro %} {% endmacro %}

View File

@ -1,15 +1,12 @@
import asyncio import asyncio
import mimetypes import mimetypes
import re import re
import signal
from concurrent.futures import TimeoutError
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
from bs4 import BeautifulSoup # type: ignore from bs4 import BeautifulSoup # type: ignore
from loguru import logger from loguru import logger
from pebble import concurrent # type: ignore
from pydantic import BaseModel from pydantic import BaseModel
from app import activitypub as ap from app import activitypub as ap
@ -32,11 +29,7 @@ class OpenGraphMeta(BaseModel):
site_name: str site_name: str
@concurrent.process(timeout=5)
def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None: def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
# Prevent SIGTERM to bubble up to the worker
signal.signal(signal.SIGTERM, signal.SIG_IGN)
soup = BeautifulSoup(html, "html5lib") soup = BeautifulSoup(html, "html5lib")
ogs = { ogs = {
og.attrs["property"]: og.attrs.get("content") og.attrs["property"]: og.attrs.get("content")
@ -65,10 +58,6 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
return OpenGraphMeta.parse_obj(raw) return OpenGraphMeta.parse_obj(raw)
def scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
return _scrap_og_meta(url, html).result()
async def external_urls( async def external_urls(
db_session: AsyncSession, db_session: AsyncSession,
ro: ap_object.RemoteObject | OutboxObject | InboxObject, ro: ap_object.RemoteObject | OutboxObject | InboxObject,
@ -137,10 +126,7 @@ async def _og_meta_from_url(url: str) -> OpenGraphMeta | None:
return None return None
try: try:
return scrap_og_meta(url, resp.text) return _scrap_og_meta(url, resp.text)
except TimeoutError:
logger.info(f"Timed out when scraping OG meta for {url}")
return None
except Exception: except Exception:
logger.info(f"Failed to scrap OG meta for {url}") logger.info(f"Failed to scrap OG meta for {url}")
return None return None

View File

@ -69,5 +69,5 @@ class Worker(Generic[T]):
logger.info("stopping loop") logger.info("stopping loop")
async def _shutdown(self, sig: signal.Signals) -> None: async def _shutdown(self, sig: signal.Signals) -> None:
logger.info(f"Caught {sig=}") logger.info(f"Caught {signal=}")
self._stop_event.set() self._stop_event.set()

View File

@ -12,7 +12,6 @@ async def webfinger(
resource: str, resource: str,
) -> dict[str, Any] | None: # noqa: C901 ) -> dict[str, Any] | None: # noqa: C901
"""Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL.""" """Mastodon-like WebFinger resolution to retrieve the activity stream Actor URL."""
resource = resource.strip()
logger.info(f"performing webfinger resolution for {resource}") logger.info(f"performing webfinger resolution for {resource}")
protos = ["https", "http"] protos = ["https", "http"]
if resource.startswith("http://"): if resource.startswith("http://"):

View File

@ -1 +0,0 @@
../../app/templates/

View File

@ -5,7 +5,6 @@ admin_password = "$2b$12$OwCyZM33uXQUVrChgER.h.qgFJ4fBp6tdFwArR3Lm1LV8NgMvIxVa"
name = "test" name = "test"
summary = "<p>Hello</p>" summary = "<p>Hello</p>"
https = false https = false
id = "http://localhost:8000"
icon_url = "https://localhost:8000/static/nopic.png" icon_url = "https://localhost:8000/static/nopic.png"
secret = "1dd4079e0474d1a519052b8fe3cb5fa6" secret = "1dd4079e0474d1a519052b8fe3cb5fa6"
debug = true debug = true

View File

@ -127,23 +127,9 @@ $secondary-color: #32cd32;
See `app/scss/main.scss` to see what variables can be overridden. See `app/scss/main.scss` to see what variables can be overridden.
#### Custom templates
If you'd like to customize your instance's theme beyond CSS, you can modify the app's HTML by placing templates in `data/templates` which overwrite the defaults in `app/templates`.
#### Custom Content Security Policy (CSP)
You can override the default Content Security Policy by adding a line in `data/profile.toml`:
```toml
custom_content_security_policy = "default-src 'self'; style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
```
This example will output the default CSP, note that `{HIGHLIGHT_CSS_HASH}` will be dynamically replaced by the correct value (the hash of the CSS needed for syntax highlighting).
#### Code highlighting theme #### Code highlighting theme
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `data/profile.toml`: You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `profile.toml`:
```toml ```toml
code_highlighting_theme = "solarized-dark" code_highlighting_theme = "solarized-dark"

168
poetry.lock generated
View File

@ -271,7 +271,7 @@ dev = ["pytest", "coverage", "coveralls"]
[[package]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
version = "1.0.1" version = "1.0.0"
description = "Backport of PEP 654 (exception groups)" description = "Backport of PEP 654 (exception groups)"
category = "dev" category = "dev"
optional = false optional = false
@ -297,7 +297,7 @@ doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
[[package]] [[package]]
name = "faker" name = "faker"
version = "15.2.0" version = "15.1.2"
description = "Faker is a Python package that generates fake data for you." description = "Faker is a Python package that generates fake data for you."
category = "dev" category = "dev"
optional = false optional = false
@ -689,14 +689,6 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[[package]]
name = "pebble"
version = "5.0.2"
description = "Threading and multiprocessing eye-candy."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "9.3.0" version = "9.3.0"
@ -711,15 +703,15 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "2.5.3" version = "2.5.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.extras] [package.extras]
docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.4)", "sphinx (>=5.3)"] docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"] test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
@ -735,7 +727,7 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.32" version = "3.0.31"
description = "Library for building powerful interactive command lines in Python" description = "Library for building powerful interactive command lines in Python"
category = "main" category = "main"
optional = false optional = false
@ -987,7 +979,7 @@ python-versions = ">=3.6"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.43" version = "1.4.42"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -1224,14 +1216,14 @@ watchmedo = ["PyYAML (>=3.10)"]
[[package]] [[package]]
name = "watchfiles" name = "watchfiles"
version = "0.18.1" version = "0.18.0"
description = "Simple, modern and high performance file watching and code reload in python." description = "Simple, modern and high performance file watching and code reload in python."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
anyio = ">=3.0.0" anyio = ">=3.0.0,<4"
[[package]] [[package]]
name = "wcwidth" name = "wcwidth"
@ -1271,7 +1263,7 @@ dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "13a1f5fc3f65c56e753062dca6ab74a50f7270d78a08ebf6297f7b4fa26b5eac" content-hash = "89df524a545a19a20440d1872c93151bbf3f68d3b3d20cc50bc9049dd0e6d25f"
[metadata.files] [metadata.files]
aiosqlite = [ aiosqlite = [
@ -1512,16 +1504,16 @@ emoji = [
{file = "emoji-1.7.0.tar.gz", hash = "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1"}, {file = "emoji-1.7.0.tar.gz", hash = "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1"},
] ]
exceptiongroup = [ exceptiongroup = [
{file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"}, {file = "exceptiongroup-1.0.0-py3-none-any.whl", hash = "sha256:2ac84b496be68464a2da60da518af3785fff8b7ec0d090a581604bc870bdee41"},
{file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"}, {file = "exceptiongroup-1.0.0.tar.gz", hash = "sha256:affbabf13fb6e98988c38d9c5650e701569fe3c1de3233cfb61c5f33774690ad"},
] ]
factory-boy = [ factory-boy = [
{file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"},
{file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
] ]
faker = [ faker = [
{file = "Faker-15.2.0-py3-none-any.whl", hash = "sha256:8066d5ef0ca116469292d947593ae4cdf1d97dd83f557dd8a749826cf960020a"}, {file = "Faker-15.1.2-py3-none-any.whl", hash = "sha256:37c8bfcbd9e0e99cebcd22e70dcf895ff92fb46aa8a32c7772df0c1f1f32ea48"},
{file = "Faker-15.2.0.tar.gz", hash = "sha256:f35b9b47fb84d7334645feba0dd87bbf5aba2b617cd83ec8e1b8c6dcd859a710"}, {file = "Faker-15.1.2.tar.gz", hash = "sha256:39c4e7915813923829675488cafef07ddf11cf59ecbaac518f53dd8e7b0df5cf"},
] ]
fastapi = [ fastapi = [
{file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"}, {file = "fastapi-0.78.0-py3-none-any.whl", hash = "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65"},
@ -1879,10 +1871,6 @@ pathspec = [
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
] ]
pebble = [
{file = "Pebble-5.0.2-py3-none-any.whl", hash = "sha256:61b2dfd52b1a8c083b4e6cf3e0f1ff2e8a430a6283c53969a7057a1c91bed3cd"},
{file = "Pebble-5.0.2.tar.gz", hash = "sha256:9c58c03eaf920c31287444c6fef39dc53baeac9de221ead104f5c9b48e8bd587"},
]
pillow = [ pillow = [
{file = "Pillow-9.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:0b7257127d646ff8676ec8a15520013a698d1fdc48bc2a79ba4e53df792526f2"}, {file = "Pillow-9.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:0b7257127d646ff8676ec8a15520013a698d1fdc48bc2a79ba4e53df792526f2"},
{file = "Pillow-9.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b90f7616ea170e92820775ed47e136208e04c967271c9ef615b6fbd08d9af0e3"}, {file = "Pillow-9.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b90f7616ea170e92820775ed47e136208e04c967271c9ef615b6fbd08d9af0e3"},
@ -1945,16 +1933,16 @@ pillow = [
{file = "Pillow-9.3.0.tar.gz", hash = "sha256:c935a22a557a560108d780f9a0fc426dd7459940dc54faa49d83249c8d3e760f"}, {file = "Pillow-9.3.0.tar.gz", hash = "sha256:c935a22a557a560108d780f9a0fc426dd7459940dc54faa49d83249c8d3e760f"},
] ]
platformdirs = [ platformdirs = [
{file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
] ]
pluggy = [ pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
] ]
prompt-toolkit = [ prompt-toolkit = [
{file = "prompt_toolkit-3.0.32-py3-none-any.whl", hash = "sha256:24becda58d49ceac4dc26232eb179ef2b21f133fecda7eed6018d341766ed76e"}, {file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"},
{file = "prompt_toolkit-3.0.32.tar.gz", hash = "sha256:e7f2129cba4ff3b3656bbdda0e74ee00d2f874a8bcdb9dd16f5fec7b3e173cae"}, {file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"},
] ]
pyaml = [ pyaml = [
{file = "pyaml-21.10.1-py2.py3-none-any.whl", hash = "sha256:19985ed303c3a985de4cf8fd329b6d0a5a5b5c9035ea240eccc709ebacbaf4a0"}, {file = "pyaml-21.10.1-py2.py3-none-any.whl", hash = "sha256:19985ed303c3a985de4cf8fd329b6d0a5a5b5c9035ea240eccc709ebacbaf4a0"},
@ -2133,47 +2121,47 @@ soupsieve = [
{file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"},
] ]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.43-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:491d94879f9ec0dea7e1cb053cd9cc65a28d2467960cf99f7b3c286590406060"}, {file = "SQLAlchemy-1.4.42-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:28e881266a172a4d3c5929182fde6bb6fba22ac93f137d5380cc78a11a9dd124"},
{file = "SQLAlchemy-1.4.43-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eeb55a555eef1a9607c1635bbdddd0b8a2bb9713bcb5bc8da1e8fae8ee46d1d8"}, {file = "SQLAlchemy-1.4.42-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ca9389a00f639383c93ed00333ed763812f80b5ae9e772ea32f627043f8c9c88"},
{file = "SQLAlchemy-1.4.43-cp27-cp27m-win32.whl", hash = "sha256:7d6293010aa0af8bd3b0c9993259f8979db2422d6abf85a31d70ec69cb2ee4dc"}, {file = "SQLAlchemy-1.4.42-cp27-cp27m-win32.whl", hash = "sha256:1d0c23ecf7b3bc81e29459c34a3f4c68ca538de01254e24718a7926810dc39a6"},
{file = "SQLAlchemy-1.4.43-cp27-cp27m-win_amd64.whl", hash = "sha256:27479b5a1e110e64c56b18ffbf8cf99e101572a3d1a43943ea02158f1304108e"}, {file = "SQLAlchemy-1.4.42-cp27-cp27m-win_amd64.whl", hash = "sha256:6c9d004eb78c71dd4d3ce625b80c96a827d2e67af9c0d32b1c1e75992a7916cc"},
{file = "SQLAlchemy-1.4.43-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:13ce4f3a068ec4ef7598d2a77f42adc3d90c76981f5a7c198756b25c4f4a22ea"}, {file = "SQLAlchemy-1.4.42-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9e3a65ce9ed250b2f096f7b559fe3ee92e6605fab3099b661f0397a9ac7c8d95"},
{file = "SQLAlchemy-1.4.43-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:aa12e27cb465b4b006ffb777624fc6023363e01cfed2d3f89d33fb6da80f6de2"}, {file = "SQLAlchemy-1.4.42-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:2e56dfed0cc3e57b2f5c35719d64f4682ef26836b81067ee6cfad062290fd9e2"},
{file = "SQLAlchemy-1.4.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d16aca30fad4753aeb4ebde564bbd4a248b9673e4f879b940f4e806a17be87f"}, {file = "SQLAlchemy-1.4.42-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b42c59ffd2d625b28cdb2ae4cde8488543d428cba17ff672a543062f7caee525"},
{file = "SQLAlchemy-1.4.43-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cde363fb5412ab178f1cc1e596e9cfc396464da8a4fe8e733cc6d6b4e2c23aa9"}, {file = "SQLAlchemy-1.4.42-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22459fc1718785d8a86171bbe7f01b5c9d7297301ac150f508d06e62a2b4e8d2"},
{file = "SQLAlchemy-1.4.43-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4abda3e693d24169221ffc7aa0444ccef3dc43dfeab6ad8665d3836751cd6af7"}, {file = "SQLAlchemy-1.4.42-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df76e9c60879fdc785a34a82bf1e8691716ffac32e7790d31a98d7dec6e81545"},
{file = "SQLAlchemy-1.4.43-cp310-cp310-win32.whl", hash = "sha256:fa46d86a17cccd48c6762df1a60aecf5aaa2e0c0973efacf146c637694b62ffd"}, {file = "SQLAlchemy-1.4.42-cp310-cp310-win32.whl", hash = "sha256:e7e740453f0149437c101ea4fdc7eea2689938c5760d7dcc436c863a12f1f565"},
{file = "SQLAlchemy-1.4.43-cp310-cp310-win_amd64.whl", hash = "sha256:962c7c80c54a42836c47cb0d8a53016986c8584e8d98e90e2ea723a4ed0ba85b"}, {file = "SQLAlchemy-1.4.42-cp310-cp310-win_amd64.whl", hash = "sha256:effc89e606165ca55f04f3f24b86d3e1c605e534bf1a96e4e077ce1b027d0b71"},
{file = "SQLAlchemy-1.4.43-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f6e036714a586f757a3e12ff0798ce9a90aa04a60cff392d8bcacc5ecf79c95e"}, {file = "SQLAlchemy-1.4.42-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:97ff50cd85bb907c2a14afb50157d0d5486a4b4639976b4a3346f34b6d1b5272"},
{file = "SQLAlchemy-1.4.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05d7365c2d1df03a69d90157a3e9b3e7b62088cca8ee6686aed2598659a6e14"}, {file = "SQLAlchemy-1.4.42-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e12c6949bae10f1012ab5c0ea52ab8db99adcb8c7b717938252137cdf694c775"},
{file = "SQLAlchemy-1.4.43-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59bd0ae166253f7fed8c3f4f6265d2637f25d2f6614d00df34d7ee0d95d29c91"}, {file = "SQLAlchemy-1.4.42-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11b2ec26c5d2eefbc3e6dca4ec3d3d95028be62320b96d687b6e740424f83b7d"},
{file = "SQLAlchemy-1.4.43-cp311-cp311-win32.whl", hash = "sha256:0c8a174f23bc021aac97bcb27fbe2ae3d4652d3d23e5768bc2ec3d44e386c7eb"}, {file = "SQLAlchemy-1.4.42-cp311-cp311-win32.whl", hash = "sha256:6045b3089195bc008aee5c273ec3ba9a93f6a55bc1b288841bd4cfac729b6516"},
{file = "SQLAlchemy-1.4.43-cp311-cp311-win_amd64.whl", hash = "sha256:5d5937e1bf7921e4d1acdfad72dd98d9e7f9ea5c52aeb12b3b05b534b527692d"}, {file = "SQLAlchemy-1.4.42-cp311-cp311-win_amd64.whl", hash = "sha256:0501f74dd2745ec38f44c3a3900fb38b9db1ce21586b691482a19134062bf049"},
{file = "SQLAlchemy-1.4.43-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:ed1c950aba723b7a5b702b88f05d883607c587de918d7d8c2014fe7f55cf67e0"}, {file = "SQLAlchemy-1.4.42-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:6e39e97102f8e26c6c8550cb368c724028c575ec8bc71afbbf8faaffe2b2092a"},
{file = "SQLAlchemy-1.4.43-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5438f6c768b7e928f0463777b545965648ba0d55877afd14a4e96d2a99702e7"}, {file = "SQLAlchemy-1.4.42-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15d878929c30e41fb3d757a5853b680a561974a0168cd33a750be4ab93181628"},
{file = "SQLAlchemy-1.4.43-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:41df873cdae1d56fde97a1b4f6ffa118f40e4b2d6a6aa8c25c50eea31ecbeb08"}, {file = "SQLAlchemy-1.4.42-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fa5b7eb2051e857bf83bade0641628efe5a88de189390725d3e6033a1fff4257"},
{file = "SQLAlchemy-1.4.43-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22f46440e61d90100e0f378faac40335fb5bbf278472df0d83dc15b653b9896"}, {file = "SQLAlchemy-1.4.42-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1c5f8182b4f89628d782a183d44db51b5af84abd6ce17ebb9804355c88a7b5"},
{file = "SQLAlchemy-1.4.43-cp36-cp36m-win32.whl", hash = "sha256:529e2cc8af75811114e5ab2eb116fd71b6e252c6bdb32adbfcd5e0c5f6d5ab06"}, {file = "SQLAlchemy-1.4.42-cp36-cp36m-win32.whl", hash = "sha256:a7dd5b7b34a8ba8d181402d824b87c5cee8963cb2e23aa03dbfe8b1f1e417cde"},
{file = "SQLAlchemy-1.4.43-cp36-cp36m-win_amd64.whl", hash = "sha256:c1ced2fae7a1177a36cf94d0a5567452d195d3b4d7d932dd61f123fb15ddf87b"}, {file = "SQLAlchemy-1.4.42-cp36-cp36m-win_amd64.whl", hash = "sha256:5ede1495174e69e273fad68ad45b6d25c135c1ce67723e40f6cf536cb515e20b"},
{file = "SQLAlchemy-1.4.43-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:736d4e706adb3c95a0a7e660073a5213dfae78ff2df6addf8ff2918c83fbeebe"}, {file = "SQLAlchemy-1.4.42-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:9256563506e040daddccaa948d055e006e971771768df3bb01feeb4386c242b0"},
{file = "SQLAlchemy-1.4.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23a4569d3db1ce44370d05c5ad79be4f37915fcc97387aef9da232b95db7b695"}, {file = "SQLAlchemy-1.4.42-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4948b6c5f4e56693bbeff52f574279e4ff972ea3353f45967a14c30fb7ae2beb"},
{file = "SQLAlchemy-1.4.43-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:42bff29eaecbb284f614f4bb265bb0c268625f5b93ce6268f8017811e0afbdde"}, {file = "SQLAlchemy-1.4.42-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1811a0b19a08af7750c0b69e38dec3d46e47c4ec1d74b6184d69f12e1c99a5e0"},
{file = "SQLAlchemy-1.4.43-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee9613b0460dce970414cfc990ca40afe518bc139e697243fcdf890285fb30ac"}, {file = "SQLAlchemy-1.4.42-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b01d9cd2f9096f688c71a3d0f33f3cd0af8549014e66a7a7dee6fc214a7277d"},
{file = "SQLAlchemy-1.4.43-cp37-cp37m-win32.whl", hash = "sha256:dc1e005d490c101d27657481a05765851ab795cc8aedeb8d9425595088b20736"}, {file = "SQLAlchemy-1.4.42-cp37-cp37m-win32.whl", hash = "sha256:bd448b262544b47a2766c34c0364de830f7fb0772d9959c1c42ad61d91ab6565"},
{file = "SQLAlchemy-1.4.43-cp37-cp37m-win_amd64.whl", hash = "sha256:c9a6e878e63286392b262d86d21fe16e6eec12b95ccb0a92c392f2b1e0acca03"}, {file = "SQLAlchemy-1.4.42-cp37-cp37m-win_amd64.whl", hash = "sha256:04f2598c70ea4a29b12d429a80fad3a5202d56dce19dd4916cc46a965a5ca2e9"},
{file = "SQLAlchemy-1.4.43-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:c6de20de7c19b965c007c9da240268dde1451865099ca10f0f593c347041b845"}, {file = "SQLAlchemy-1.4.42-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ab7c158f98de6cb4f1faab2d12973b330c2878d0c6b689a8ca424c02d66e1b3"},
{file = "SQLAlchemy-1.4.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fef01240d32ada9007387afd8e0b2230f99efdc4b57ca6f1d1192fca4fcf6a5"}, {file = "SQLAlchemy-1.4.42-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ee377eb5c878f7cefd633ab23c09e99d97c449dd999df639600f49b74725b80"},
{file = "SQLAlchemy-1.4.43-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b6fd58e25e6cdd2a131d7e97f9713f8f2142360cd40c75af8aa5b83d535f811c"}, {file = "SQLAlchemy-1.4.42-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:934472bb7d8666727746a75670a1f8d91a9cae8c464bba79da30a0f6faccd9e1"},
{file = "SQLAlchemy-1.4.43-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35dc0a5e934c41e282e019c889069b01ff4cd356b2ea452c9985e1542734cfb1"}, {file = "SQLAlchemy-1.4.42-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb94a3d1ba77ff2ef11912192c066f01e68416f554c194d769391638c8ad09a"},
{file = "SQLAlchemy-1.4.43-cp38-cp38-win32.whl", hash = "sha256:fb9a44e7124f72b79023ab04e1c8fcd8f392939ef0d7a75beae8634e15605d30"}, {file = "SQLAlchemy-1.4.42-cp38-cp38-win32.whl", hash = "sha256:f0f574465b78f29f533976c06b913e54ab4980b9931b69aa9d306afff13a9471"},
{file = "SQLAlchemy-1.4.43-cp38-cp38-win_amd64.whl", hash = "sha256:4a791e7a1e5ac33f70a3598f8f34fdd3b60c68593bbb038baf58bc50e02d7468"}, {file = "SQLAlchemy-1.4.42-cp38-cp38-win_amd64.whl", hash = "sha256:a85723c00a636eed863adb11f1e8aaa36ad1c10089537823b4540948a8429798"},
{file = "SQLAlchemy-1.4.43-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:c9b59863e2b1f1e1ebf9ee517f86cdfa82d7049c8d81ad71ab58d442b137bbe9"}, {file = "SQLAlchemy-1.4.42-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5ce6929417d5dce5ad1d3f147db81735a4a0573b8fb36e3f95500a06eaddd93e"},
{file = "SQLAlchemy-1.4.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd80300d81d92661e2488a4bf4383f0c5dc6e7b05fa46d2823e231af4e30539a"}, {file = "SQLAlchemy-1.4.42-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723e3b9374c1ce1b53564c863d1a6b2f1dc4e97b1c178d9b643b191d8b1be738"},
{file = "SQLAlchemy-1.4.43-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c3dde668edea70dc8d55a74d933d5446e5a97786cdd1c67c8e4971c73bd087ad"}, {file = "SQLAlchemy-1.4.42-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:876eb185911c8b95342b50a8c4435e1c625944b698a5b4a978ad2ffe74502908"},
{file = "SQLAlchemy-1.4.43-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b462c070769f0ef06ea5fe65206b970bcf2b59cb3fda2bec2f4729e1be89c13"}, {file = "SQLAlchemy-1.4.42-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd49af453e590884d9cdad3586415922a8e9bb669d874ee1dc55d2bc425aacd"},
{file = "SQLAlchemy-1.4.43-cp39-cp39-win32.whl", hash = "sha256:c1f5bfffc3227d05d90c557b10604962f655b4a83c9f3ad507a81ac8d6847679"}, {file = "SQLAlchemy-1.4.42-cp39-cp39-win32.whl", hash = "sha256:e4ef8cb3c5b326f839bfeb6af5f406ba02ad69a78c7aac0fbeeba994ad9bb48a"},
{file = "SQLAlchemy-1.4.43-cp39-cp39-win_amd64.whl", hash = "sha256:a7fa3e57a7b0476fbcba72b231150503d53dbcbdd23f4a86be5152912a923b6e"}, {file = "SQLAlchemy-1.4.42-cp39-cp39-win_amd64.whl", hash = "sha256:5f966b64c852592469a7eb759615bbd351571340b8b344f1d3fa2478b5a4c934"},
{file = "SQLAlchemy-1.4.43.tar.gz", hash = "sha256:c628697aad7a141da8fc3fd81b4874a711cc84af172e1b1e7bbfadf760446496"}, {file = "SQLAlchemy-1.4.42.tar.gz", hash = "sha256:177e41914c476ed1e1b77fd05966ea88c094053e17a85303c4ce007f88eff363"},
] ]
sqlalchemy2-stubs = [ sqlalchemy2-stubs = [
{file = "sqlalchemy2-stubs-0.0.2a29.tar.gz", hash = "sha256:1bbc6aebd76db7c0351a9f45cc1c4e8ac335ba150094c2af091e8b87b9118419"}, {file = "sqlalchemy2-stubs-0.0.2a29.tar.gz", hash = "sha256:1bbc6aebd76db7c0351a9f45cc1c4e8ac335ba150094c2af091e8b87b9118419"},
@ -2295,24 +2283,24 @@ watchdog = [
{file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"},
] ]
watchfiles = [ watchfiles = [
{file = "watchfiles-0.18.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:9891d3c94272108bcecf5597a592e61105279def1313521e637f2d5acbe08bc9"}, {file = "watchfiles-0.18.0-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:76a4c4a8e25a2c9a4f7fa3d373bbaf5558c17b97b4cf8411d33de368fe6b68a9"},
{file = "watchfiles-0.18.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:7102342d60207fa635e24c02a51c6628bf0472e5fef067f78a612386840407fc"}, {file = "watchfiles-0.18.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5d799614d4c56d29c5ba56f4f619f967210dc10a0d6965b62d326b9e2f72c9e"},
{file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:00ea0081eca5e8e695cffbc3a726bb90da77f4e3f78ce29b86f0d95db4e70ef7"}, {file = "watchfiles-0.18.0-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:39b932b044fef6c43e813e0bef908e0edf185bf7b5d8d53246651cb7ac9efe79"},
{file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8e6db99e49cd7125d8a4c9d33c0735eea7b75a942c6ad68b75be3e91c242fb"}, {file = "watchfiles-0.18.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1686bc4ac40ffde7256b6543b0f9a2cc8b531ae45243786f1d3f1dda2fe39e24"},
{file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc7c726855f04f22ac79131b51bf0c9f728cb2117419ed830a43828b2c4a5fcb"}, {file = "watchfiles-0.18.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:320bcde0adaa972403ed3b70f784409437325a1a4df2de54ba0672203d8847e5"},
{file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbaff354d12235002e62d9d3fa8bcf326a8490c1179aa5c17195a300a9e5952f"}, {file = "watchfiles-0.18.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ba6d8c2f957cae3e888bc250bc60ed09fe869b3f55f09d020ed3fecbefb6a4c"},
{file = "watchfiles-0.18.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:888db233e06907c555eccd10da99b9cd5ed45deca47e41766954292dc9f7b198"}, {file = "watchfiles-0.18.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd4215badad1e3d1ad5fb79f21432dd5157e2e7b0765d27a19dc2a28580c6979"},
{file = "watchfiles-0.18.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:dde79930d1b28f15994ad6613aa2865fc7a403d2bb14585a8714a53233b15717"}, {file = "watchfiles-0.18.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cfdbfc4b6797c28dd1a8524581fed00ca333971b4111af8cd42fb7a92dcdc227"},
{file = "watchfiles-0.18.1-cp37-abi3-win32.whl", hash = "sha256:e2b2bdd26bf8d6ed90763e6020b475f7634f919dbd1730ea1b6f8cb88e21de5d"}, {file = "watchfiles-0.18.0-cp37-abi3-win32.whl", hash = "sha256:8eddc2d19bf6f49aee224072ec0f4f3258125a49f11b5dcff1448e68718a745e"},
{file = "watchfiles-0.18.1-cp37-abi3-win_amd64.whl", hash = "sha256:c541e0f2c3e95e83e4f84561c893284ba984e9d0025352057396d96dceb09f44"}, {file = "watchfiles-0.18.0-cp37-abi3-win_amd64.whl", hash = "sha256:be87c9b1fe2b02105a9ac6d9df7500a110652bbd97cf46b13964eeaef9a6c89c"},
{file = "watchfiles-0.18.1-cp37-abi3-win_arm64.whl", hash = "sha256:9a26272ef3e930330fc0c2c148cc29706cc2c40d25760c7ccea8d768a8feef8b"}, {file = "watchfiles-0.18.0-cp37-abi3-win_arm64.whl", hash = "sha256:184799818c4fa7dbc6a1e4ca20bcbc6b85e4e0db07ce4554ea2f29b75ccd0cdc"},
{file = "watchfiles-0.18.1-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:9fb12a5e2b42e0b53769455ff93546e6bc9ab14007fbd436978d827a95ca5bd1"}, {file = "watchfiles-0.18.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:7f39fcdac5d5b9815a0c2ab9005d39854296b11fa15386a9a69c09cbbc5dde2c"},
{file = "watchfiles-0.18.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:548d6b42303d40264118178053c78820533b683b20dfbb254a8706ca48467357"}, {file = "watchfiles-0.18.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78b1e7c29b92dfc8fc32f15949019232b493767d236c2bff31848df13fdb9e8a"},
{file = "watchfiles-0.18.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0d8fdfebc50ac7569358f5c75f2b98bb473befccf9498cf23b3e39993bb45a"}, {file = "watchfiles-0.18.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27d64a6ed5e0aebef97c70fa3899a6958d4f7f049effc659e7dc3e81f3170a7b"},
{file = "watchfiles-0.18.1-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:0f9a22fff1745e2bb930b1e971c4c5b67ea3b38ae17a6adb9019371f80961219"}, {file = "watchfiles-0.18.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4bbc8bfa0f3871b1867af42837a5635a9c1cbb2b68d039754b4750642c34aaee"},
{file = "watchfiles-0.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b02e7fa03cd4059dd61ff0600080a5a9e7a893a85cb8e5178943533656eec65e"}, {file = "watchfiles-0.18.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3a12e4de5446fb6e286b720d0cb3a080811caf0ef43e556c2db5fe10ef0342"},
{file = "watchfiles-0.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a868ce2c7565137f852bd4c863a164dc81306cae7378dbdbe4e2aca51ddb8857"}, {file = "watchfiles-0.18.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e611a90482ac14ef3ec234c1604ed921d1b0c68970eba82f1cf0d59a3e4eb76"},
{file = "watchfiles-0.18.1.tar.gz", hash = "sha256:4ec0134a5e31797eb3c6c624dbe9354f2a8ee9c720e0b46fc5b7bab472b7c6d4"}, {file = "watchfiles-0.18.0.tar.gz", hash = "sha256:bbe10d134eef1666451382015e48f092c941a6d4562a98ffa1a288f79a897c46"},
] ]
wcwidth = [ wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},

View File

@ -44,7 +44,6 @@ uvicorn = {extras = ["standard"], version = "^0.18.3"}
Brotli = "^1.0.9" Brotli = "^1.0.9"
greenlet = "^1.1.3" greenlet = "^1.1.3"
mistletoe = "^0.9.0" mistletoe = "^0.9.0"
Pebble = "^5.0.2"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
black = "^22.3.0" black = "^22.3.0"