mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2025-06-05 21:59:23 +02:00
Compare commits
97 Commits
ynh-suppor
...
2.0.0-rc.2
Author | SHA1 | Date | |
---|---|---|---|
a6321f52d8 | |||
4e1e4d0ea8 | |||
110f7df962 | |||
4c86cd4be3 | |||
df06defbef | |||
b2f268682c | |||
567595bb4b | |||
91b8bb26b7 | |||
bd4d5a004a | |||
04da8725ed | |||
0c7a19749d | |||
2a37034775 | |||
475e525468 | |||
c1231245a4 | |||
5eb6157c1b | |||
0f20a1d12f | |||
356aace9bc | |||
a701d3b06e | |||
333fa5dc40 | |||
032632c4dc | |||
3641aa0adc | |||
eba868e8e5 | |||
1bfea16eed | |||
70120647c2 | |||
e454e8fe84 | |||
f7671f0585 | |||
16da166ee1 | |||
d5c27287af | |||
5f20eab3f1 | |||
b03daf1274 | |||
191ce39d14 | |||
6e3066bd9b | |||
0175f21273 | |||
36d356c97a | |||
6384dbcd93 | |||
c740813b57 | |||
0ef2f1f89d | |||
6d933863d2 | |||
8fe6cc9b9d | |||
4cb499e44d | |||
95745374cd | |||
db8f0cb141 | |||
05f840ecc8 | |||
ebdba62a06 | |||
2fb85e138e | |||
b843b29975 | |||
4f8bb00d86 | |||
a02c8cf0bb | |||
ee5265f4dd | |||
727eaa9ee1 | |||
39ca3ed7e2 | |||
c67db749dc | |||
fc0445fcec | |||
c275d7064e | |||
1a7e9e4565 | |||
87f035d298 | |||
651682829a | |||
3f85c851be | |||
333e367a5b | |||
09cdef118c | |||
00004a3239 | |||
7283ba134c | |||
c8f3bed065 | |||
93e0d073a0 | |||
e959085d38 | |||
aaf8b811dc | |||
4e445a7207 | |||
40c4a4413d | |||
dd4773fc27 | |||
0db6b0e2ba | |||
88cb82c9bb | |||
372851caaf | |||
e16dbf03e7 | |||
7d4b7f6756 | |||
edf9e28ed1 | |||
eb9a6024a8 | |||
84203fc66e | |||
55d82c5843 | |||
53a31ae562 | |||
d21ce3313d | |||
93ee6c435d | |||
bec40cc050 | |||
505abd7da8 | |||
63073279e1 | |||
365e6cc534 | |||
e753fee632 | |||
30cfd6260b | |||
d43bf54609 | |||
953a6c3b91 | |||
ae28cf2294 | |||
3b767eae11 | |||
6475714369 | |||
0811609e3e | |||
adcaf95ab2 | |||
ce15d2b0c3 | |||
e047a87620 | |||
e55dc652ee |
15
Makefile
15
Makefile
@ -9,12 +9,23 @@ build:
|
|||||||
config:
|
config:
|
||||||
# Run and remove instantly
|
# Run and remove instantly
|
||||||
-docker run --rm -it --volume `pwd`/data:/app/data microblogpub/microblogpub inv configuration-wizard
|
-docker run --rm -it --volume `pwd`/data:/app/data microblogpub/microblogpub inv configuration-wizard
|
||||||
-docker run --env MICROBLOGPUB_CONFIG_FILE=tests.toml --rm -it --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv configuration-wizard
|
|
||||||
|
|
||||||
.PHONY: update
|
.PHONY: update
|
||||||
update:
|
update:
|
||||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv update --no-update-deps
|
||||||
|
|
||||||
.PHONY: prune-old-data
|
.PHONY: prune-old-data
|
||||||
prune-old-data:
|
prune-old-data:
|
||||||
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv prune-old-data
|
||||||
|
|
||||||
|
.PHONY: webfinger
|
||||||
|
webfinger:
|
||||||
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv webfinger $(account)
|
||||||
|
|
||||||
|
.PHONY: move-to
|
||||||
|
move-to:
|
||||||
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv move-to $(account)
|
||||||
|
|
||||||
|
.PHONY: self-destruct
|
||||||
|
move-to:
|
||||||
|
-docker run --volume `pwd`/data:/app/data --volume `pwd`/app/static:/app/app/static microblogpub/microblogpub inv self-destruct
|
||||||
|
@ -9,9 +9,12 @@ from loguru import logger
|
|||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.config import ALSO_KNOWN_AS
|
||||||
from app.config import AP_CONTENT_TYPE # noqa: F401
|
from app.config import AP_CONTENT_TYPE # noqa: F401
|
||||||
|
from app.config import MOVED_TO
|
||||||
from app.httpsig import auth
|
from app.httpsig import auth
|
||||||
from app.key import get_pubkey_as_pem
|
from app.key import get_pubkey_as_pem
|
||||||
|
from app.source import hashtagify
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -32,6 +35,7 @@ AS_EXTENDED_CTX = [
|
|||||||
"sensitive": "as:sensitive",
|
"sensitive": "as:sensitive",
|
||||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||||
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
||||||
|
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
|
||||||
# toot
|
# toot
|
||||||
"toot": "http://joinmastodon.org/ns#",
|
"toot": "http://joinmastodon.org/ns#",
|
||||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||||
@ -57,6 +61,10 @@ class ObjectNotFoundError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectUnavailableError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FetchErrorTypeEnum(str, enum.Enum):
|
class FetchErrorTypeEnum(str, enum.Enum):
|
||||||
TIMEOUT = "TIMEOUT"
|
TIMEOUT = "TIMEOUT"
|
||||||
NOT_FOUND = "NOT_FOUND"
|
NOT_FOUND = "NOT_FOUND"
|
||||||
@ -81,6 +89,8 @@ class VisibilityEnum(str, enum.Enum):
|
|||||||
}[key]
|
}[key]
|
||||||
|
|
||||||
|
|
||||||
|
_LOCAL_ACTOR_SUMMARY, _LOCAL_ACTOR_TAGS = hashtagify(config.CONFIG.summary)
|
||||||
|
|
||||||
ME = {
|
ME = {
|
||||||
"@context": AS_EXTENDED_CTX,
|
"@context": AS_EXTENDED_CTX,
|
||||||
"type": "Person",
|
"type": "Person",
|
||||||
@ -92,7 +102,7 @@ ME = {
|
|||||||
"outbox": config.BASE_URL + "/outbox",
|
"outbox": config.BASE_URL + "/outbox",
|
||||||
"preferredUsername": config.USERNAME,
|
"preferredUsername": config.USERNAME,
|
||||||
"name": config.CONFIG.name,
|
"name": config.CONFIG.name,
|
||||||
"summary": config.CONFIG.summary,
|
"summary": markdown(_LOCAL_ACTOR_SUMMARY, extensions=["mdx_linkify"]),
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
# For compat with servers expecting a sharedInbox...
|
# For compat with servers expecting a sharedInbox...
|
||||||
"sharedInbox": config.BASE_URL
|
"sharedInbox": config.BASE_URL
|
||||||
@ -120,8 +130,15 @@ ME = {
|
|||||||
"owner": config.ID,
|
"owner": config.ID,
|
||||||
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
||||||
},
|
},
|
||||||
|
"tag": _LOCAL_ACTOR_TAGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ALSO_KNOWN_AS:
|
||||||
|
ME["alsoKnownAs"] = [ALSO_KNOWN_AS]
|
||||||
|
|
||||||
|
if MOVED_TO:
|
||||||
|
ME["movedTo"] = MOVED_TO
|
||||||
|
|
||||||
|
|
||||||
class NotAnObjectError(Exception):
|
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:
|
||||||
@ -154,6 +171,8 @@ async def fetch(
|
|||||||
# Special handling for deleted object
|
# Special handling for deleted object
|
||||||
if resp.status_code == 410:
|
if resp.status_code == 410:
|
||||||
raise ObjectIsGoneError(f"{url} is gone")
|
raise ObjectIsGoneError(f"{url} is gone")
|
||||||
|
elif resp.status_code in [401, 403]:
|
||||||
|
raise ObjectUnavailableError(f"not allowed to fetch {url}")
|
||||||
elif resp.status_code == 404:
|
elif resp.status_code == 404:
|
||||||
raise ObjectNotFoundError(f"{url} not found")
|
raise ObjectNotFoundError(f"{url} not found")
|
||||||
|
|
||||||
|
40
app/actor.py
40
app/actor.py
@ -5,6 +5,7 @@ from functools import cached_property
|
|||||||
from typing import Union
|
from typing import Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
@ -118,6 +119,10 @@ class Actor:
|
|||||||
def attachments(self) -> list[ap.RawObject]:
|
def attachments(self) -> list[ap.RawObject]:
|
||||||
return ap.as_list(self.ap_actor.get("attachment", []))
|
return ap.as_list(self.ap_actor.get("attachment", []))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def moved_to(self) -> str | None:
|
||||||
|
return self.ap_actor.get("movedTo")
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def server(self) -> str:
|
def server(self) -> str:
|
||||||
return urlparse(self.ap_id).hostname # type: ignore
|
return urlparse(self.ap_id).hostname # type: ignore
|
||||||
@ -154,9 +159,9 @@ async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "Actor
|
|||||||
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
|
raise ValueError(f"Invalid type {ap_type} for actor {ap_actor}")
|
||||||
|
|
||||||
actor = models.Actor(
|
actor = models.Actor(
|
||||||
ap_id=ap_actor["id"],
|
ap_id=ap.get_id(ap_actor["id"]),
|
||||||
ap_actor=ap_actor,
|
ap_actor=ap_actor,
|
||||||
ap_type=ap_actor["type"],
|
ap_type=ap.as_list(ap_actor["type"])[0],
|
||||||
handle=_handle(ap_actor),
|
handle=_handle(ap_actor),
|
||||||
)
|
)
|
||||||
db_session.add(actor)
|
db_session.add(actor)
|
||||||
@ -188,6 +193,19 @@ async def fetch_actor(
|
|||||||
else:
|
else:
|
||||||
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
|
||||||
|
if actor_id == ap_actor.get("url"):
|
||||||
|
# Which mean we may already have it in DB
|
||||||
|
existing_actor_by_url = (
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.Actor).where(
|
||||||
|
models.Actor.ap_id == ap.get_id(ap_actor),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
if existing_actor_by_url:
|
||||||
|
return existing_actor_by_url
|
||||||
|
|
||||||
return await save_actor(db_session, ap_actor)
|
return await save_actor(db_session, ap_actor)
|
||||||
else:
|
else:
|
||||||
raise ap.ObjectNotFoundError
|
raise ap.ObjectNotFoundError
|
||||||
@ -201,6 +219,7 @@ class ActorMetadata:
|
|||||||
is_follow_request_sent: bool
|
is_follow_request_sent: bool
|
||||||
outbox_follow_ap_id: str | None
|
outbox_follow_ap_id: str | None
|
||||||
inbox_follow_ap_id: str | None
|
inbox_follow_ap_id: str | None
|
||||||
|
moved_to: typing.Optional["ActorModel"]
|
||||||
|
|
||||||
|
|
||||||
ActorsMetadata = dict[str, ActorMetadata]
|
ActorsMetadata = dict[str, ActorMetadata]
|
||||||
@ -247,6 +266,19 @@ async def get_actors_metadata(
|
|||||||
for actor in actors:
|
for actor in actors:
|
||||||
if not actor.ap_id:
|
if not actor.ap_id:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
moved_to = None
|
||||||
|
if actor.moved_to:
|
||||||
|
try:
|
||||||
|
moved_to = await fetch_actor(
|
||||||
|
db_session,
|
||||||
|
actor.moved_to,
|
||||||
|
save_if_not_found=False,
|
||||||
|
)
|
||||||
|
except ap.ObjectNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to fetch {actor.moved_to=}")
|
||||||
|
|
||||||
idx[actor.ap_id] = ActorMetadata(
|
idx[actor.ap_id] = ActorMetadata(
|
||||||
ap_actor_id=actor.ap_id,
|
ap_actor_id=actor.ap_id,
|
||||||
is_following=actor.ap_id in following,
|
is_following=actor.ap_id in following,
|
||||||
@ -254,6 +286,7 @@ async def get_actors_metadata(
|
|||||||
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
is_follow_request_sent=actor.ap_id in sent_follow_requests,
|
||||||
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
outbox_follow_ap_id=sent_follow_requests.get(actor.ap_id),
|
||||||
inbox_follow_ap_id=followers.get(actor.ap_id),
|
inbox_follow_ap_id=followers.get(actor.ap_id),
|
||||||
|
moved_to=moved_to,
|
||||||
)
|
)
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
@ -289,4 +322,7 @@ def _actor_hash(actor: Actor) -> bytes:
|
|||||||
h.update(actor.public_key_id.encode())
|
h.update(actor.public_key_id.encode())
|
||||||
h.update(actor.public_key_as_pem.encode())
|
h.update(actor.public_key_as_pem.encode())
|
||||||
|
|
||||||
|
if actor.moved_to:
|
||||||
|
h.update(actor.moved_to.encode())
|
||||||
|
|
||||||
return h.digest()
|
return h.digest()
|
||||||
|
127
app/admin.py
127
app/admin.py
@ -34,6 +34,7 @@ from app.config import verify_password
|
|||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.lookup import lookup
|
from app.lookup import lookup
|
||||||
|
from app.templates import is_current_user_admin
|
||||||
from app.uploads import save_upload
|
from app.uploads import save_upload
|
||||||
from app.utils import pagination
|
from app.utils import pagination
|
||||||
from app.utils.emoji import EMOJIS_BY_NAME
|
from app.utils.emoji import EMOJIS_BY_NAME
|
||||||
@ -68,16 +69,6 @@ router = APIRouter(
|
|||||||
unauthenticated_router = APIRouter()
|
unauthenticated_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def admin_index(
|
|
||||||
request: Request,
|
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> templates.TemplateResponse:
|
|
||||||
return await templates.render_template(
|
|
||||||
db_session, request, "index.html", {"request": request}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lookup")
|
@router.get("/lookup")
|
||||||
async def get_lookup(
|
async def get_lookup(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -94,6 +85,8 @@ async def get_lookup(
|
|||||||
error = ap.FetchErrorTypeEnum.TIMEOUT
|
error = ap.FetchErrorTypeEnum.TIMEOUT
|
||||||
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
|
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
|
||||||
error = ap.FetchErrorTypeEnum.NOT_FOUND
|
error = ap.FetchErrorTypeEnum.NOT_FOUND
|
||||||
|
except (ap.ObjectUnavailableError):
|
||||||
|
error = ap.FetchErrorTypeEnum.UNAUHTORIZED
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Failed to lookup {query}")
|
logger.exception(f"Failed to lookup {query}")
|
||||||
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR
|
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR
|
||||||
@ -122,7 +115,9 @@ async def get_lookup(
|
|||||||
)
|
)
|
||||||
if requested_object:
|
if requested_object:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
request.url_for("admin_object") + f"?ap_id={ap_object.ap_id}",
|
request.url_for("admin_object")
|
||||||
|
+ f"?ap_id={ap_object.ap_id}#"
|
||||||
|
+ requested_object.permalink_id,
|
||||||
status_code=302,
|
status_code=302,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -211,6 +206,7 @@ async def admin_bookmarks(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
|
# TODO: support pagination
|
||||||
stream = (
|
stream = (
|
||||||
(
|
(
|
||||||
await db_session.scalars(
|
await db_session.scalars(
|
||||||
@ -667,15 +663,30 @@ async def admin_outbox(
|
|||||||
|
|
||||||
@router.get("/notifications")
|
@router.get("/notifications")
|
||||||
async def get_notifications(
|
async def get_notifications(
|
||||||
request: Request, db_session: AsyncSession = Depends(get_db_session)
|
request: Request,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
cursor: str | None = None,
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
|
where = []
|
||||||
|
if cursor:
|
||||||
|
decoded_cursor = pagination.decode_cursor(cursor)
|
||||||
|
where.append(models.Notification.created_at < decoded_cursor)
|
||||||
|
|
||||||
|
page_size = 20
|
||||||
|
remaining_count = await db_session.scalar(
|
||||||
|
select(func.count(models.Notification.id)).where(*where)
|
||||||
|
)
|
||||||
|
|
||||||
notifications = (
|
notifications = (
|
||||||
(
|
(
|
||||||
await db_session.scalars(
|
await db_session.scalars(
|
||||||
select(models.Notification)
|
select(models.Notification)
|
||||||
|
.where(*where)
|
||||||
.options(
|
.options(
|
||||||
joinedload(models.Notification.actor),
|
joinedload(models.Notification.actor),
|
||||||
joinedload(models.Notification.inbox_object),
|
joinedload(models.Notification.inbox_object).options(
|
||||||
|
joinedload(models.InboxObject.actor)
|
||||||
|
),
|
||||||
joinedload(models.Notification.outbox_object).options(
|
joinedload(models.Notification.outbox_object).options(
|
||||||
joinedload(
|
joinedload(
|
||||||
models.OutboxObject.outbox_object_attachments
|
models.OutboxObject.outbox_object_attachments
|
||||||
@ -684,6 +695,7 @@ async def get_notifications(
|
|||||||
joinedload(models.Notification.webmention),
|
joinedload(models.Notification.webmention),
|
||||||
)
|
)
|
||||||
.order_by(models.Notification.created_at.desc())
|
.order_by(models.Notification.created_at.desc())
|
||||||
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.unique()
|
.unique()
|
||||||
@ -697,6 +709,21 @@ async def get_notifications(
|
|||||||
notif.is_new = False
|
notif.is_new = False
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
|
more_unread_count = 0
|
||||||
|
next_cursor = None
|
||||||
|
if notifications and remaining_count > page_size:
|
||||||
|
decoded_next_cursor = notifications[-1].created_at
|
||||||
|
next_cursor = pagination.encode_cursor(decoded_next_cursor)
|
||||||
|
|
||||||
|
# If on the "see more" page there's more unread notification, we want
|
||||||
|
# to display it next to the link
|
||||||
|
more_unread_count = await db_session.scalar(
|
||||||
|
select(func.count(models.Notification.id)).where(
|
||||||
|
models.Notification.is_new.is_(True),
|
||||||
|
models.Notification.created_at < decoded_next_cursor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
@ -704,6 +731,8 @@ async def get_notifications(
|
|||||||
{
|
{
|
||||||
"notifications": notifications,
|
"notifications": notifications,
|
||||||
"actors_metadata": actors_metadata,
|
"actors_metadata": actors_metadata,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"more_unread_count": more_unread_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -715,7 +744,7 @@ async def admin_object(
|
|||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id)
|
requested_object = await boxes.get_anybox_object_by_ap_id(db_session, ap_id)
|
||||||
if not requested_object:
|
if not requested_object or requested_object.is_deleted:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
replies_tree = await boxes.get_replies_tree(
|
replies_tree = await boxes.get_replies_tree(
|
||||||
@ -736,8 +765,10 @@ async def admin_object(
|
|||||||
async def admin_profile(
|
async def admin_profile(
|
||||||
request: Request,
|
request: Request,
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
|
cursor: str | None = None,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse:
|
||||||
|
# TODO: show featured/pinned
|
||||||
actor = (
|
actor = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(models.Actor).where(models.Actor.ap_id == actor_id)
|
select(models.Actor).where(models.Actor.ap_id == actor_id)
|
||||||
@ -748,17 +779,27 @@ async def admin_profile(
|
|||||||
|
|
||||||
actors_metadata = await get_actors_metadata(db_session, [actor])
|
actors_metadata = await get_actors_metadata(db_session, [actor])
|
||||||
|
|
||||||
inbox_objects = (
|
where = [
|
||||||
(
|
|
||||||
await db_session.scalars(
|
|
||||||
select(models.InboxObject)
|
|
||||||
.where(
|
|
||||||
models.InboxObject.is_deleted.is_(False),
|
models.InboxObject.is_deleted.is_(False),
|
||||||
models.InboxObject.actor_id == actor.id,
|
models.InboxObject.actor_id == actor.id,
|
||||||
models.InboxObject.ap_type.in_(
|
models.InboxObject.ap_type.in_(
|
||||||
["Note", "Article", "Video", "Page", "Announce"]
|
["Note", "Article", "Video", "Page", "Announce"]
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
if cursor:
|
||||||
|
decoded_cursor = pagination.decode_cursor(cursor)
|
||||||
|
where.append(models.InboxObject.ap_published_at < decoded_cursor)
|
||||||
|
|
||||||
|
page_size = 20
|
||||||
|
remaining_count = await db_session.scalar(
|
||||||
|
select(func.count(models.InboxObject.id)).where(*where)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
inbox_objects = (
|
||||||
|
(
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.InboxObject)
|
||||||
|
.where(*where)
|
||||||
.options(
|
.options(
|
||||||
joinedload(models.InboxObject.relates_to_inbox_object).options(
|
joinedload(models.InboxObject.relates_to_inbox_object).options(
|
||||||
joinedload(models.InboxObject.actor)
|
joinedload(models.InboxObject.actor)
|
||||||
@ -771,12 +812,19 @@ async def admin_profile(
|
|||||||
joinedload(models.InboxObject.actor),
|
joinedload(models.InboxObject.actor),
|
||||||
)
|
)
|
||||||
.order_by(models.InboxObject.ap_published_at.desc())
|
.order_by(models.InboxObject.ap_published_at.desc())
|
||||||
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.unique()
|
.unique()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
next_cursor = (
|
||||||
|
pagination.encode_cursor(inbox_objects[-1].created_at)
|
||||||
|
if inbox_objects and remaining_count > page_size
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
@ -785,6 +833,7 @@ async def admin_profile(
|
|||||||
"actors_metadata": actors_metadata,
|
"actors_metadata": actors_metadata,
|
||||||
"actor": actor,
|
"actor": actor,
|
||||||
"inbox_objects": inbox_objects,
|
"inbox_objects": inbox_objects,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -974,7 +1023,7 @@ async def admin_actions_unpin(
|
|||||||
async def admin_actions_new(
|
async def admin_actions_new(
|
||||||
request: Request,
|
request: Request,
|
||||||
files: list[UploadFile] = [],
|
files: list[UploadFile] = [],
|
||||||
content: str = Form(),
|
content: str | None = Form(None),
|
||||||
redirect_url: str = Form(),
|
redirect_url: str = Form(),
|
||||||
in_reply_to: str | None = Form(None),
|
in_reply_to: str | None = Form(None),
|
||||||
content_warning: str | None = Form(None),
|
content_warning: str | None = Form(None),
|
||||||
@ -985,6 +1034,19 @@ async def admin_actions_new(
|
|||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
|
if not content and not content_warning:
|
||||||
|
raise HTTPException(status_code=422, detail="Error: object must have a content")
|
||||||
|
|
||||||
|
# Do like Mastodon, if there's only a CW with no content and some attachments,
|
||||||
|
# swap the CW and the content
|
||||||
|
if not content and content_warning and len(files) >= 1:
|
||||||
|
content = content_warning
|
||||||
|
is_sensitive = True
|
||||||
|
content_warning = None
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=422, detail="Error: objec must have a content")
|
||||||
|
|
||||||
# XXX: for some reason, no files restuls in an empty single file
|
# XXX: for some reason, no files restuls in an empty single file
|
||||||
uploads = []
|
uploads = []
|
||||||
raw_form_data = await request.form()
|
raw_form_data = await request.form()
|
||||||
@ -1054,7 +1116,10 @@ async def admin_actions_vote(
|
|||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> templates.TemplateResponse:
|
) -> templates.TemplateResponse | RedirectResponse:
|
||||||
|
if is_current_user_admin(request):
|
||||||
|
return RedirectResponse(request.url_for("admin_stream"), status_code=302)
|
||||||
|
|
||||||
return await templates.render_template(
|
return await templates.render_template(
|
||||||
db_session,
|
db_session,
|
||||||
request,
|
request,
|
||||||
@ -1072,11 +1137,25 @@ async def login_validation(
|
|||||||
password: str = Form(),
|
password: str = Form(),
|
||||||
redirect: str | None = Form(None),
|
redirect: str | None = Form(None),
|
||||||
csrf_check: None = Depends(verify_csrf_token),
|
csrf_check: None = Depends(verify_csrf_token),
|
||||||
) -> RedirectResponse:
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> RedirectResponse | templates.TemplateResponse:
|
||||||
if not verify_password(password):
|
if not verify_password(password):
|
||||||
raise HTTPException(status_code=401)
|
logger.warning("Invalid password")
|
||||||
|
return await templates.render_template(
|
||||||
|
db_session,
|
||||||
|
request,
|
||||||
|
"login.html",
|
||||||
|
{
|
||||||
|
"error": "Invalid password",
|
||||||
|
"csrf_token": generate_csrf_token(),
|
||||||
|
"redirect": request.query_params.get("redirect", ""),
|
||||||
|
},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
resp = RedirectResponse(redirect or "/admin/stream", status_code=302)
|
resp = RedirectResponse(
|
||||||
|
redirect or request.url_for("admin_stream"), status_code=302
|
||||||
|
)
|
||||||
resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501
|
resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
@ -208,6 +208,13 @@ class Object:
|
|||||||
def in_reply_to(self) -> str | None:
|
def in_reply_to(self) -> str | None:
|
||||||
return self.ap_object.get("inReplyTo")
|
return self.ap_object.get("inReplyTo")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_in_reply_to_from_inbox(self) -> bool | None:
|
||||||
|
if not self.in_reply_to:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return not self.in_reply_to.startswith(LOCAL_ACTOR.ap_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_ld_signature(self) -> bool:
|
def has_ld_signature(self) -> bool:
|
||||||
return bool(self.ap_object.get("signature"))
|
return bool(self.ap_object.get("signature"))
|
||||||
|
176
app/boxes.py
176
app/boxes.py
@ -29,6 +29,7 @@ from app.config import BASE_URL
|
|||||||
from app.config import BLOCKED_SERVERS
|
from app.config import BLOCKED_SERVERS
|
||||||
from app.config import ID
|
from app.config import ID
|
||||||
from app.config import MANUALLY_APPROVES_FOLLOWERS
|
from app.config import MANUALLY_APPROVES_FOLLOWERS
|
||||||
|
from app.config import set_moved_to
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.outgoing_activities import new_outgoing_activity
|
from app.outgoing_activities import new_outgoing_activity
|
||||||
from app.source import markdownify
|
from app.source import markdownify
|
||||||
@ -93,6 +94,7 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||||||
raise ValueError(f"{ap_object_id} not found in the outbox")
|
raise ValueError(f"{ap_object_id} not found in the outbox")
|
||||||
|
|
||||||
delete_id = allocate_outbox_id()
|
delete_id = allocate_outbox_id()
|
||||||
|
# FIXME addressing
|
||||||
delete = {
|
delete = {
|
||||||
"@context": ap.AS_EXTENDED_CTX,
|
"@context": ap.AS_EXTENDED_CTX,
|
||||||
"id": outbox_object_id(delete_id),
|
"id": outbox_object_id(delete_id),
|
||||||
@ -122,6 +124,19 @@ async def send_delete(db_session: AsyncSession, ap_object_id: str) -> None:
|
|||||||
for rcp in recipients:
|
for rcp in recipients:
|
||||||
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||||
|
|
||||||
|
# Revert side effects
|
||||||
|
if outbox_object_to_delete.in_reply_to:
|
||||||
|
replied_object = await get_anybox_object_by_ap_id(
|
||||||
|
db_session, outbox_object_to_delete.in_reply_to
|
||||||
|
)
|
||||||
|
if replied_object:
|
||||||
|
replied_object.replies_count = replied_object.replies_count - 1
|
||||||
|
if replied_object.replies_count < 0:
|
||||||
|
logger.warning("negative replies count for {replied_object.ap_id}")
|
||||||
|
replied_object.replies_count = 0
|
||||||
|
else:
|
||||||
|
logger.info(f"{outbox_object_to_delete.in_reply_to} not found")
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
@ -351,7 +366,7 @@ async def fetch_conversation_root(
|
|||||||
db_session, ap.get_actor_id(raw_reply)
|
db_session, ap.get_actor_id(raw_reply)
|
||||||
)
|
)
|
||||||
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
in_reply_to_object = RemoteObject(raw_reply, actor=raw_reply_actor)
|
||||||
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
|
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError, ap.NotAnObjectError):
|
||||||
return await fetch_conversation_root(db_session, obj, is_root=True)
|
return await fetch_conversation_root(db_session, obj, is_root=True)
|
||||||
except httpx.HTTPStatusError as http_status_error:
|
except httpx.HTTPStatusError as http_status_error:
|
||||||
if 400 <= http_status_error.response.status_code < 500:
|
if 400 <= http_status_error.response.status_code < 500:
|
||||||
@ -363,6 +378,59 @@ async def fetch_conversation_root(
|
|||||||
return await fetch_conversation_root(db_session, in_reply_to_object)
|
return await fetch_conversation_root(db_session, in_reply_to_object)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_move(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
target: str,
|
||||||
|
) -> None:
|
||||||
|
move_id = allocate_outbox_id()
|
||||||
|
obj = {
|
||||||
|
"@context": ap.AS_CTX,
|
||||||
|
"type": "Move",
|
||||||
|
"id": outbox_object_id(move_id),
|
||||||
|
"actor": LOCAL_ACTOR.ap_id,
|
||||||
|
"object": LOCAL_ACTOR.ap_id,
|
||||||
|
"target": target,
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox_object = await save_outbox_object(db_session, move_id, obj)
|
||||||
|
if not outbox_object.id:
|
||||||
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
|
recipients = await _get_followers_recipients(db_session)
|
||||||
|
for rcp in recipients:
|
||||||
|
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||||
|
|
||||||
|
# Store the moved to in order to update the profile
|
||||||
|
set_moved_to(target)
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def send_self_destruct(db_session: AsyncSession) -> None:
|
||||||
|
delete_id = allocate_outbox_id()
|
||||||
|
delete = {
|
||||||
|
"@context": ap.AS_EXTENDED_CTX,
|
||||||
|
"id": outbox_object_id(delete_id),
|
||||||
|
"type": "Delete",
|
||||||
|
"actor": ID,
|
||||||
|
"object": ID,
|
||||||
|
"to": [ap.AS_PUBLIC],
|
||||||
|
}
|
||||||
|
outbox_object = await save_outbox_object(
|
||||||
|
db_session,
|
||||||
|
delete_id,
|
||||||
|
delete,
|
||||||
|
)
|
||||||
|
if not outbox_object.id:
|
||||||
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
|
recipients = await compute_all_known_recipients(db_session)
|
||||||
|
for rcp in recipients:
|
||||||
|
await new_outgoing_activity(db_session, rcp, outbox_object.id)
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def send_create(
|
async def send_create(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
ap_type: str,
|
ap_type: str,
|
||||||
@ -708,6 +776,17 @@ async def _compute_recipients(
|
|||||||
return recipients
|
return recipients
|
||||||
|
|
||||||
|
|
||||||
|
async def compute_all_known_recipients(db_session: AsyncSession) -> set[str]:
|
||||||
|
return {
|
||||||
|
actor.shared_inbox_url or actor.inbox_url
|
||||||
|
for actor in (
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.Actor).where(models.Actor.is_deleted.is_(False))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _get_following(db_session: AsyncSession) -> list[models.Follower]:
|
async def _get_following(db_session: AsyncSession) -> list[models.Follower]:
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
@ -859,7 +938,7 @@ async def _handle_delete_activity(
|
|||||||
except ap.ObjectNotFoundError:
|
except ap.ObjectNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if ap_object_to_delete is None:
|
if ap_object_to_delete is None or not ap_object_to_delete.is_from_db:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Received Delete for an unknown object "
|
"Received Delete for an unknown object "
|
||||||
f"{delete_activity.activity_object_ap_id}"
|
f"{delete_activity.activity_object_ap_id}"
|
||||||
@ -1281,13 +1360,16 @@ async def _handle_move_activity(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Fetch the target account
|
# Fetch the target account
|
||||||
new_actor_id = move_activity.ap_object.get("target")
|
target = move_activity.ap_object.get("target")
|
||||||
if not new_actor_id:
|
if not target:
|
||||||
logger.warning("Missing target")
|
logger.warning("Missing target")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
new_actor_id = ap.get_id(target)
|
||||||
new_actor = await fetch_actor(db_session, new_actor_id)
|
new_actor = await fetch_actor(db_session, new_actor_id)
|
||||||
|
|
||||||
|
logger.info(f"Moving {old_actor_id} to {new_actor_id}")
|
||||||
|
|
||||||
# Ensure the target account references the old account
|
# Ensure the target account references the old account
|
||||||
if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])):
|
if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -1310,7 +1392,21 @@ async def _handle_move_activity(
|
|||||||
await _send_undo(db_session, following.outbox_object.ap_id)
|
await _send_undo(db_session, following.outbox_object.ap_id)
|
||||||
|
|
||||||
# Follow the new one
|
# Follow the new one
|
||||||
|
if not (
|
||||||
|
await db_session.execute(
|
||||||
|
select(models.Following).where(models.Following.ap_actor_id == new_actor_id)
|
||||||
|
)
|
||||||
|
).scalar():
|
||||||
await _send_follow(db_session, new_actor_id)
|
await _send_follow(db_session, new_actor_id)
|
||||||
|
else:
|
||||||
|
logger.info(f"Already following target {new_actor_id}")
|
||||||
|
|
||||||
|
notif = models.Notification(
|
||||||
|
notification_type=models.NotificationType.MOVE,
|
||||||
|
actor_id=new_actor.id,
|
||||||
|
inbox_object_id=move_activity.id,
|
||||||
|
)
|
||||||
|
db_session.add(notif)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_update_activity(
|
async def _handle_update_activity(
|
||||||
@ -1326,7 +1422,8 @@ async def _handle_update_activity(
|
|||||||
updated_actor = RemoteActor(wrapped_object)
|
updated_actor = RemoteActor(wrapped_object)
|
||||||
if (
|
if (
|
||||||
from_actor.ap_id != updated_actor.ap_id
|
from_actor.ap_id != updated_actor.ap_id
|
||||||
or from_actor.ap_type != updated_actor.ap_type
|
or ap.as_list(from_actor.ap_type)[0] not in ap.ACTOR_TYPES
|
||||||
|
or ap.as_list(updated_actor.ap_type)[0] not in ap.ACTOR_TYPES
|
||||||
or from_actor.handle != updated_actor.handle
|
or from_actor.handle != updated_actor.handle
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -1654,10 +1751,32 @@ async def _handle_announce_activity(
|
|||||||
# We already know about this object, show the announce in the
|
# We already know about this object, show the announce in the
|
||||||
# stream if it's not already there, from an followed actor
|
# stream if it's not already there, from an followed actor
|
||||||
# and if we haven't seen it recently
|
# and if we haven't seen it recently
|
||||||
|
skip_delta = timedelta(hours=1)
|
||||||
|
delta_from_original = now() - as_utc(
|
||||||
|
relates_to_inbox_object.ap_published_at # type: ignore
|
||||||
|
)
|
||||||
|
dup_count = 0
|
||||||
if (
|
if (
|
||||||
now() - as_utc(relates_to_inbox_object.ap_published_at) # type: ignore
|
not relates_to_inbox_object.is_hidden_from_stream
|
||||||
) > timedelta(hours=1):
|
and delta_from_original < skip_delta
|
||||||
|
) or (
|
||||||
|
dup_count := (
|
||||||
|
await db_session.scalar(
|
||||||
|
select(func.count(models.InboxObject.id)).where(
|
||||||
|
models.InboxObject.ap_type == "Announce",
|
||||||
|
models.InboxObject.ap_published_at > now() - skip_delta,
|
||||||
|
models.InboxObject.relates_to_inbox_object_id
|
||||||
|
== relates_to_inbox_object.id,
|
||||||
|
models.InboxObject.is_hidden_from_stream.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) > 0:
|
||||||
|
logger.info(f"Deduping Announce {delta_from_original=}/{dup_count=}")
|
||||||
|
announce_activity.is_hidden_from_stream = True
|
||||||
|
else:
|
||||||
announce_activity.is_hidden_from_stream = not is_from_following
|
announce_activity.is_hidden_from_stream = not is_from_following
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Save it as an inbox object
|
# Save it as an inbox object
|
||||||
if not announce_activity.activity_object_ap_id:
|
if not announce_activity.activity_object_ap_id:
|
||||||
@ -1665,6 +1784,12 @@ async def _handle_announce_activity(
|
|||||||
announced_raw_object = await ap.fetch(
|
announced_raw_object = await ap.fetch(
|
||||||
announce_activity.activity_object_ap_id
|
announce_activity.activity_object_ap_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Some software return objects wrapped in a Create activity (like
|
||||||
|
# python-federation)
|
||||||
|
if ap.as_list(announced_raw_object["type"])[0] == "Create":
|
||||||
|
announced_raw_object = await ap.get_object(announced_raw_object)
|
||||||
|
|
||||||
announced_actor = await fetch_actor(
|
announced_actor = await fetch_actor(
|
||||||
db_session, ap.get_actor_id(announced_raw_object)
|
db_session, ap.get_actor_id(announced_raw_object)
|
||||||
)
|
)
|
||||||
@ -1721,10 +1846,12 @@ async def _process_transient_object(
|
|||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
from_actor: models.Actor,
|
from_actor: models.Actor,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# TODO: track featured/pinned objects for actors
|
||||||
ap_type = raw_object["type"]
|
ap_type = raw_object["type"]
|
||||||
if ap_type in ["Add", "Remove"]:
|
if ap_type in ["Add", "Remove"]:
|
||||||
logger.info(f"Dropping unsupported {ap_type} object")
|
logger.info(f"Dropping unsupported {ap_type} object")
|
||||||
else:
|
else:
|
||||||
|
# FIXME(ts): handle transient create
|
||||||
logger.warning(f"Received unknown {ap_type} object")
|
logger.warning(f"Received unknown {ap_type} object")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@ -1735,6 +1862,28 @@ async def save_to_inbox(
|
|||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
sent_by_ap_actor_id: str,
|
sent_by_ap_actor_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Special case for server sending the actor as a payload (like python-federation)
|
||||||
|
if ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
|
||||||
|
if ap.get_id(raw_object) == sent_by_ap_actor_id:
|
||||||
|
updated_actor = RemoteActor(raw_object)
|
||||||
|
|
||||||
|
try:
|
||||||
|
actor = await fetch_actor(db_session, sent_by_ap_actor_id)
|
||||||
|
except ap.ObjectNotFoundError:
|
||||||
|
logger.warning("Actor not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update the actor
|
||||||
|
actor.ap_actor = updated_actor.ap_actor
|
||||||
|
await db_session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Reveived an actor payload {raw_object} from " f"{sent_by_ap_actor_id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"]))
|
actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"]))
|
||||||
except ap.ObjectNotFoundError:
|
except ap.ObjectNotFoundError:
|
||||||
@ -1748,7 +1897,7 @@ async def save_to_inbox(
|
|||||||
logger.warning(f"Server {actor.server} is blocked")
|
logger.warning(f"Server {actor.server} is blocked")
|
||||||
return
|
return
|
||||||
|
|
||||||
if "id" not in raw_object:
|
if "id" not in raw_object or not raw_object["id"]:
|
||||||
await _process_transient_object(db_session, raw_object, actor)
|
await _process_transient_object(db_session, raw_object, actor)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -1766,7 +1915,13 @@ async def save_to_inbox(
|
|||||||
)
|
)
|
||||||
forwarded_by_actor = await fetch_actor(db_session, sent_by_ap_actor_id)
|
forwarded_by_actor = await fetch_actor(db_session, sent_by_ap_actor_id)
|
||||||
|
|
||||||
if not (await ldsig.verify_signature(db_session, raw_object)):
|
is_sig_verified = False
|
||||||
|
try:
|
||||||
|
is_sig_verified = await ldsig.verify_signature(db_session, raw_object)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to verify LD sig")
|
||||||
|
|
||||||
|
if not is_sig_verified:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to verify LD sig, fetching remote object {raw_object_id}"
|
f"Failed to verify LD sig, fetching remote object {raw_object_id}"
|
||||||
)
|
)
|
||||||
@ -1956,7 +2111,7 @@ async def _prefetch_actor_outbox(
|
|||||||
"""Try to fetch some notes to fill the stream"""
|
"""Try to fetch some notes to fill the stream"""
|
||||||
saved = 0
|
saved = 0
|
||||||
outbox = await ap.parse_collection(actor.outbox_url, limit=20)
|
outbox = await ap.parse_collection(actor.outbox_url, limit=20)
|
||||||
for activity in outbox:
|
for activity in outbox[:20]:
|
||||||
activity_id = ap.get_id(activity)
|
activity_id = ap.get_id(activity)
|
||||||
raw_activity = await ap.fetch(activity_id)
|
raw_activity = await ap.fetch(activity_id)
|
||||||
if ap.as_list(raw_activity["type"])[0] == "Create":
|
if ap.as_list(raw_activity["type"])[0] == "Create":
|
||||||
@ -1969,6 +2124,7 @@ async def _prefetch_actor_outbox(
|
|||||||
|
|
||||||
if not saved_inbox_object.in_reply_to:
|
if not saved_inbox_object.in_reply_to:
|
||||||
saved_inbox_object.is_hidden_from_stream = False
|
saved_inbox_object.is_hidden_from_stream = False
|
||||||
|
|
||||||
saved += 1
|
saved += 1
|
||||||
|
|
||||||
if saved >= 5:
|
if saved >= 5:
|
||||||
|
@ -12,8 +12,10 @@ from fastapi import HTTPException
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from itsdangerous import URLSafeTimedSerializer
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from markdown import markdown
|
||||||
|
|
||||||
from app.utils.emoji import _load_emojis
|
from app.utils.emoji import _load_emojis
|
||||||
|
from app.utils.version import get_version_commit
|
||||||
|
|
||||||
ROOT_DIR = Path().parent.resolve()
|
ROOT_DIR = Path().parent.resolve()
|
||||||
|
|
||||||
@ -24,7 +26,7 @@ VERSION_COMMIT = "dev"
|
|||||||
try:
|
try:
|
||||||
from app._version import VERSION_COMMIT # type: ignore
|
from app._version import VERSION_COMMIT # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
VERSION_COMMIT = get_version_commit()
|
||||||
|
|
||||||
# Force reloading cache when the CSS is updated
|
# Force reloading cache when the CSS is updated
|
||||||
CSS_HASH = "none"
|
CSS_HASH = "none"
|
||||||
@ -34,6 +36,31 @@ try:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Force reloading cache when the JS is changed
|
||||||
|
JS_HASH = "none"
|
||||||
|
try:
|
||||||
|
# To keep things simple, we keep a single hash for the 2 files
|
||||||
|
js_data_common = (ROOT_DIR / "app" / "static" / "common-admin.js").read_bytes()
|
||||||
|
js_data_new = (ROOT_DIR / "app" / "static" / "new.js").read_bytes()
|
||||||
|
JS_HASH = hashlib.md5(
|
||||||
|
js_data_common + js_data_new, usedforsecurity=False
|
||||||
|
).hexdigest()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
MOVED_TO_FILE = ROOT_DIR / "data" / "moved_to.dat"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_moved_to() -> str | None:
|
||||||
|
if not MOVED_TO_FILE.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return MOVED_TO_FILE.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def set_moved_to(moved_to: str) -> None:
|
||||||
|
MOVED_TO_FILE.write_text(moved_to)
|
||||||
|
|
||||||
|
|
||||||
VERSION = f"2.0.0+{VERSION_COMMIT}"
|
VERSION = f"2.0.0+{VERSION_COMMIT}"
|
||||||
USER_AGENT = f"microblogpub/{VERSION}"
|
USER_AGENT = f"microblogpub/{VERSION}"
|
||||||
@ -71,6 +98,12 @@ class Config(pydantic.BaseModel):
|
|||||||
metadata: list[_ProfileMetadata] | None = None
|
metadata: list[_ProfileMetadata] | None = None
|
||||||
code_highlighting_theme = "friendly_grayscale"
|
code_highlighting_theme = "friendly_grayscale"
|
||||||
blocked_servers: list[_BlockedServer] = []
|
blocked_servers: list[_BlockedServer] = []
|
||||||
|
custom_footer: str | None = None
|
||||||
|
emoji: str | None = None
|
||||||
|
also_known_as: str | None = None
|
||||||
|
|
||||||
|
hides_followers: bool = False
|
||||||
|
hides_following: bool = False
|
||||||
|
|
||||||
inbox_retention_days: int = 15
|
inbox_retention_days: int = 15
|
||||||
|
|
||||||
@ -114,13 +147,23 @@ _SCHEME = "https" if CONFIG.https else "http"
|
|||||||
ID = f"{_SCHEME}://{DOMAIN}"
|
ID = f"{_SCHEME}://{DOMAIN}"
|
||||||
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_FOLLOWING = CONFIG.hides_following
|
||||||
PRIVACY_REPLACE = None
|
PRIVACY_REPLACE = None
|
||||||
if CONFIG.privacy_replace:
|
if CONFIG.privacy_replace:
|
||||||
PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace}
|
PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace}
|
||||||
|
|
||||||
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
|
BLOCKED_SERVERS = {blocked_server.hostname for blocked_server in CONFIG.blocked_servers}
|
||||||
|
ALSO_KNOWN_AS = CONFIG.also_known_as
|
||||||
|
|
||||||
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
INBOX_RETENTION_DAYS = CONFIG.inbox_retention_days
|
||||||
|
CUSTOM_FOOTER = (
|
||||||
|
markdown(
|
||||||
|
CONFIG.custom_footer.replace("{version}", VERSION), extensions=["mdx_linkify"]
|
||||||
|
)
|
||||||
|
if CONFIG.custom_footer
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
BASE_URL = ID
|
BASE_URL = ID
|
||||||
DEBUG = CONFIG.debug
|
DEBUG = CONFIG.debug
|
||||||
@ -130,6 +173,9 @@ KEY_PATH = (
|
|||||||
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
|
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
|
||||||
)
|
)
|
||||||
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
|
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
|
||||||
|
if CONFIG.emoji:
|
||||||
|
EMOJIS = CONFIG.emoji
|
||||||
|
|
||||||
# Emoji template for the FE
|
# Emoji template for the FE
|
||||||
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
|
||||||
|
|
||||||
@ -137,6 +183,8 @@ _load_emojis(ROOT_DIR, BASE_URL)
|
|||||||
|
|
||||||
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
|
CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme
|
||||||
|
|
||||||
|
MOVED_TO = _get_moved_to()
|
||||||
|
|
||||||
|
|
||||||
session_serializer = URLSafeTimedSerializer(
|
session_serializer = URLSafeTimedSerializer(
|
||||||
CONFIG.secret,
|
CONFIG.secret,
|
||||||
|
@ -9,6 +9,7 @@ from sqlalchemy.ext.declarative import declarative_base
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from app.config import DB_PATH
|
from app.config import DB_PATH
|
||||||
|
from app.config import DEBUG
|
||||||
from app.config import SQLALCHEMY_DATABASE_URL
|
from app.config import SQLALCHEMY_DATABASE_URL
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
@ -18,7 +19,7 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|||||||
|
|
||||||
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
|
DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}"
|
||||||
async_engine = create_async_engine(
|
async_engine = create_async_engine(
|
||||||
DATABASE_URL, future=True, echo=False, connect_args={"timeout": 15}
|
DATABASE_URL, future=True, echo=DEBUG, connect_args={"timeout": 15}
|
||||||
)
|
)
|
||||||
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
async_session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
@ -115,11 +115,8 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
|||||||
# might race to fetch each other key
|
# might race to fetch each other key
|
||||||
try:
|
try:
|
||||||
actor = await ap.fetch(key_id, disable_httpsig=True)
|
actor = await ap.fetch(key_id, disable_httpsig=True)
|
||||||
except httpx.HTTPStatusError as http_err:
|
except ap.ObjectUnavailableError:
|
||||||
if http_err.response.status_code in [401, 403]:
|
|
||||||
actor = await ap.fetch(key_id, disable_httpsig=False)
|
actor = await ap.fetch(key_id, disable_httpsig=False)
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
if actor["type"] == "Key":
|
if actor["type"] == "Key":
|
||||||
# The Key is not embedded in the Person
|
# The Key is not embedded in the Person
|
||||||
@ -130,6 +127,7 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
|
|||||||
k.load_pub(actor["publicKey"]["publicKeyPem"])
|
k.load_pub(actor["publicKey"]["publicKeyPem"])
|
||||||
|
|
||||||
# Ensure the right key was fetch
|
# Ensure the right key was fetch
|
||||||
|
# TODO: some server have the key ID `http://` but fetching it return `https`
|
||||||
if key_id not in [k.key_id(), k.owner]:
|
if key_id not in [k.key_id(), k.owner]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
f"failed to fetch requested key {key_id}: got {actor['publicKey']}"
|
||||||
@ -215,10 +213,13 @@ async def httpsig_checker(
|
|||||||
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
|
logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}')
|
||||||
return HTTPSigInfo(has_valid_signature=False)
|
return HTTPSigInfo(has_valid_signature=False)
|
||||||
|
|
||||||
httpsig_info = HTTPSigInfo(
|
has_valid_signature = _verify_h(
|
||||||
has_valid_signature=_verify_h(
|
|
||||||
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
signed_string, base64.b64decode(hsig["signature"]), k.pubkey
|
||||||
),
|
)
|
||||||
|
# FIXME: fetch/update the user if the signature is wrong
|
||||||
|
|
||||||
|
httpsig_info = HTTPSigInfo(
|
||||||
|
has_valid_signature=has_valid_signature,
|
||||||
signed_by_ap_actor_id=k.owner,
|
signed_by_ap_actor_id=k.owner,
|
||||||
server=server,
|
server=server,
|
||||||
)
|
)
|
||||||
|
@ -26,7 +26,7 @@ async def new_ap_incoming_activity(
|
|||||||
raw_object: ap.RawObject,
|
raw_object: ap.RawObject,
|
||||||
) -> models.IncomingActivity | None:
|
) -> models.IncomingActivity | None:
|
||||||
ap_id: str
|
ap_id: str
|
||||||
if "id" not in raw_object:
|
if "id" not in raw_object or ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
|
||||||
if "@context" not in raw_object:
|
if "@context" not in raw_object:
|
||||||
logger.warning(f"Dropping invalid object: {raw_object}")
|
logger.warning(f"Dropping invalid object: {raw_object}")
|
||||||
return None
|
return None
|
||||||
@ -112,10 +112,13 @@ async def process_next_incoming_activity(
|
|||||||
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
|
if next_activity.ap_object and next_activity.sent_by_ap_actor_id:
|
||||||
try:
|
try:
|
||||||
async with db_session.begin_nested():
|
async with db_session.begin_nested():
|
||||||
await save_to_inbox(
|
await asyncio.wait_for(
|
||||||
|
save_to_inbox(
|
||||||
db_session,
|
db_session,
|
||||||
next_activity.ap_object,
|
next_activity.ap_object,
|
||||||
next_activity.sent_by_ap_actor_id,
|
next_activity.sent_by_ap_actor_id,
|
||||||
|
),
|
||||||
|
timeout=60,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
url = exc._request.url if exc._request else None
|
url = exc._request.url if exc._request else None
|
||||||
|
@ -10,6 +10,7 @@ from app.source import _MENTION_REGEX
|
|||||||
|
|
||||||
|
|
||||||
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
|
async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
|
||||||
|
query = query.strip()
|
||||||
if query.startswith("@") or _MENTION_REGEX.match("@" + query):
|
if query.startswith("@") or _MENTION_REGEX.match("@" + query):
|
||||||
query = await webfinger.get_actor_url(query) # type: ignore # None check below
|
query = await webfinger.get_actor_url(query) # type: ignore # None check below
|
||||||
|
|
||||||
@ -37,4 +38,9 @@ async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
|
|||||||
if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES:
|
if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES:
|
||||||
return RemoteActor(ap_obj)
|
return RemoteActor(ap_obj)
|
||||||
else:
|
else:
|
||||||
|
# Some software return objects wrapped in a Create activity (like
|
||||||
|
# python-federation)
|
||||||
|
if ap.as_list(ap_obj["type"])[0] == "Create":
|
||||||
|
ap_obj = await ap.get_object(ap_obj)
|
||||||
|
|
||||||
return await RemoteObject.from_raw_object(ap_obj)
|
return await RemoteObject.from_raw_object(ap_obj)
|
||||||
|
170
app/main.py
170
app/main.py
@ -8,6 +8,7 @@ from typing import Any
|
|||||||
from typing import MutableMapping
|
from typing import MutableMapping
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
|
import fastapi
|
||||||
import httpx
|
import httpx
|
||||||
import starlette
|
import starlette
|
||||||
from asgiref.typing import ASGI3Application
|
from asgiref.typing import ASGI3Application
|
||||||
@ -20,6 +21,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi import Form
|
from fastapi import Form
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
|
from fastapi.exception_handlers import http_exception_handler
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
@ -35,6 +37,7 @@ from sqlalchemy.orm import joinedload
|
|||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from starlette.datastructures import Headers
|
from starlette.datastructures import Headers
|
||||||
from starlette.datastructures import MutableHeaders
|
from starlette.datastructures import MutableHeaders
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from starlette.types import Message
|
from starlette.types import Message
|
||||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # type: ignore
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # type: ignore
|
||||||
@ -61,20 +64,25 @@ from app.config import USERNAME
|
|||||||
from app.config import is_activitypub_requested
|
from app.config import is_activitypub_requested
|
||||||
from app.config import verify_csrf_token
|
from app.config import verify_csrf_token
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
|
from app.database import async_session
|
||||||
from app.database import get_db_session
|
from app.database import get_db_session
|
||||||
from app.incoming_activities import new_ap_incoming_activity
|
from app.incoming_activities import new_ap_incoming_activity
|
||||||
from app.templates import is_current_user_admin
|
from app.templates import is_current_user_admin
|
||||||
from app.uploads import UPLOAD_DIR
|
from app.uploads import UPLOAD_DIR
|
||||||
from app.utils import pagination
|
from app.utils import pagination
|
||||||
from app.utils.emoji import EMOJIS_BY_NAME
|
from app.utils.emoji import EMOJIS_BY_NAME
|
||||||
|
from app.utils.highlight import HIGHLIGHT_CSS_HASH
|
||||||
from app.utils.url import check_url
|
from app.utils.url import check_url
|
||||||
from app.webfinger import get_remote_follow_template
|
from app.webfinger import get_remote_follow_template
|
||||||
|
|
||||||
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(32)
|
# Only images <1MB will be cached, so 64MB of data will be cached
|
||||||
|
_RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCache(64)
|
||||||
|
|
||||||
|
|
||||||
# TODO(ts):
|
# TODO(ts):
|
||||||
# Next:
|
# Next:
|
||||||
|
# - self-destruct + move support and actions/tasks for
|
||||||
|
# - doc for prune/move/delete
|
||||||
# - fix issue with followers from a blocked server (skip it?)
|
# - fix issue with followers from a blocked server (skip it?)
|
||||||
# - allow to share old notes
|
# - allow to share old notes
|
||||||
# - only show 10 most recent threads in DMs
|
# - only show 10 most recent threads in DMs
|
||||||
@ -118,21 +126,21 @@ class CustomMiddleware:
|
|||||||
# And add the security headers
|
# And add the security headers
|
||||||
headers = MutableHeaders(scope=message)
|
headers = MutableHeaders(scope=message)
|
||||||
headers["X-Request-ID"] = request_id
|
headers["X-Request-ID"] = request_id
|
||||||
headers["Server"] = "microblogpub"
|
headers["x-powered-by"] = "microblogpub"
|
||||||
headers[
|
headers[
|
||||||
"referrer-policy"
|
"referrer-policy"
|
||||||
] = "no-referrer, strict-origin-when-cross-origin"
|
] = "no-referrer, strict-origin-when-cross-origin"
|
||||||
headers["x-content-type-options"] = "nosniff"
|
headers["x-content-type-options"] = "nosniff"
|
||||||
headers["x-xss-protection"] = "1; mode=block"
|
headers["x-xss-protection"] = "1; mode=block"
|
||||||
headers["x-frame-options"] = "SAMEORIGIN"
|
headers["x-frame-options"] = "DENY"
|
||||||
# TODO(ts): disallow inline CSS?
|
headers["permissions-policy"] = "interest-cohort=()"
|
||||||
headers[
|
headers["content-security-policy"] = (
|
||||||
"content-security-policy"
|
f"default-src 'self'; "
|
||||||
] = "default-src 'self'; style-src 'self' 'unsafe-inline';"
|
f"style-src 'self' 'sha256-{HIGHLIGHT_CSS_HASH}'; "
|
||||||
|
f"frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||||
|
)
|
||||||
if not DEBUG:
|
if not DEBUG:
|
||||||
headers[
|
headers["strict-transport-security"] = "max-age=63072000;"
|
||||||
"strict-transport-security"
|
|
||||||
] = "max-age=63072000; includeSubdomains"
|
|
||||||
|
|
||||||
await send(message) # type: ignore
|
await send(message) # type: ignore
|
||||||
|
|
||||||
@ -164,7 +172,15 @@ class CustomMiddleware:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None)
|
def _check_0rtt_early_data(request: Request) -> None:
|
||||||
|
"""Disable TLS1.3 0-RTT requests for non-GET."""
|
||||||
|
if request.headers.get("Early-Data", None) == "1" and request.method != "GET":
|
||||||
|
raise fastapi.HTTPException(status_code=425, detail="Too early")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
docs_url=None, redoc_url=None, dependencies=[Depends(_check_0rtt_early_data)]
|
||||||
|
)
|
||||||
app.mount(
|
app.mount(
|
||||||
"/custom_emoji",
|
"/custom_emoji",
|
||||||
StaticFiles(directory="data/custom_emoji"),
|
StaticFiles(directory="data/custom_emoji"),
|
||||||
@ -192,6 +208,37 @@ logger_format = (
|
|||||||
logger.add(sys.stdout, format=logger_format, level="DEBUG" if DEBUG else "INFO")
|
logger.add(sys.stdout, format=logger_format, level="DEBUG" if DEBUG else "INFO")
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def custom_http_exception_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: StarletteHTTPException,
|
||||||
|
) -> templates.TemplateResponse | JSONResponse:
|
||||||
|
accept_value = request.headers.get("accept")
|
||||||
|
if (
|
||||||
|
accept_value
|
||||||
|
and accept_value.startswith("text/html")
|
||||||
|
and 400 <= exc.status_code < 600
|
||||||
|
):
|
||||||
|
async with async_session() as db_session:
|
||||||
|
title = (
|
||||||
|
{
|
||||||
|
404: "Oops, nothing to see here",
|
||||||
|
500: "Oops, something went wrong",
|
||||||
|
}
|
||||||
|
).get(exc.status_code, exc.detail)
|
||||||
|
try:
|
||||||
|
return await templates.render_template(
|
||||||
|
db_session,
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
{"title": title},
|
||||||
|
status_code=exc.status_code,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await db_session.close()
|
||||||
|
return await http_exception_handler(request, exc)
|
||||||
|
|
||||||
|
|
||||||
class ActivityPubResponse(JSONResponse):
|
class ActivityPubResponse(JSONResponse):
|
||||||
media_type = "application/activity+json"
|
media_type = "application/activity+json"
|
||||||
|
|
||||||
@ -204,6 +251,7 @@ async def index(
|
|||||||
page: int | None = None,
|
page: int | None = None,
|
||||||
) -> templates.TemplateResponse | ActivityPubResponse:
|
) -> templates.TemplateResponse | ActivityPubResponse:
|
||||||
if is_activitypub_requested(request):
|
if is_activitypub_requested(request):
|
||||||
|
|
||||||
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
|
return ActivityPubResponse(LOCAL_ACTOR.ap_actor)
|
||||||
|
|
||||||
page = page or 1
|
page = page or 1
|
||||||
@ -234,6 +282,7 @@ async def index(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.order_by(models.OutboxObject.is_pinned.desc())
|
||||||
.order_by(models.OutboxObject.ap_published_at.desc())
|
.order_by(models.OutboxObject.ap_published_at.desc())
|
||||||
.offset(page_offset)
|
.offset(page_offset)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
@ -354,6 +403,20 @@ async def _build_followx_collection(
|
|||||||
return collection_page
|
return collection_page
|
||||||
|
|
||||||
|
|
||||||
|
async def _empty_followx_collection(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
model_cls: Type[models.Following | models.Follower],
|
||||||
|
path: str,
|
||||||
|
) -> ap.RawObject:
|
||||||
|
total_items = await db_session.scalar(select(func.count(model_cls.id)))
|
||||||
|
return {
|
||||||
|
"@context": ap.AS_CTX,
|
||||||
|
"id": ID + path,
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"totalItems": total_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/followers")
|
@app.get("/followers")
|
||||||
async def followers(
|
async def followers(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -364,6 +427,15 @@ async def followers(
|
|||||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||||
if is_activitypub_requested(request):
|
if is_activitypub_requested(request):
|
||||||
|
if config.HIDES_FOLLOWERS:
|
||||||
|
return ActivityPubResponse(
|
||||||
|
await _empty_followx_collection(
|
||||||
|
db_session=db_session,
|
||||||
|
model_cls=models.Follower,
|
||||||
|
path="/followers",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
return ActivityPubResponse(
|
return ActivityPubResponse(
|
||||||
await _build_followx_collection(
|
await _build_followx_collection(
|
||||||
db_session=db_session,
|
db_session=db_session,
|
||||||
@ -374,6 +446,9 @@ async def followers(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if config.HIDES_FOLLOWERS and not is_current_user_admin(request):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
# We only show the most recent 20 followers on the public website
|
# We only show the most recent 20 followers on the public website
|
||||||
followers_result = await db_session.scalars(
|
followers_result = await db_session.scalars(
|
||||||
select(models.Follower)
|
select(models.Follower)
|
||||||
@ -411,6 +486,15 @@ async def following(
|
|||||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||||
if is_activitypub_requested(request):
|
if is_activitypub_requested(request):
|
||||||
|
if config.HIDES_FOLLOWING:
|
||||||
|
return ActivityPubResponse(
|
||||||
|
await _empty_followx_collection(
|
||||||
|
db_session=db_session,
|
||||||
|
model_cls=models.Following,
|
||||||
|
path="/following",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
return ActivityPubResponse(
|
return ActivityPubResponse(
|
||||||
await _build_followx_collection(
|
await _build_followx_collection(
|
||||||
db_session=db_session,
|
db_session=db_session,
|
||||||
@ -421,6 +505,9 @@ async def following(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if config.HIDES_FOLLOWING and not is_current_user_admin(request):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
# We only show the most recent 20 follows on the public website
|
# We only show the most recent 20 follows on the public website
|
||||||
following = (
|
following = (
|
||||||
(
|
(
|
||||||
@ -685,10 +772,10 @@ async def tag_by_name(
|
|||||||
.join(models.TaggedOutboxObject)
|
.join(models.TaggedOutboxObject)
|
||||||
.where(*where)
|
.where(*where)
|
||||||
)
|
)
|
||||||
|
if is_activitypub_requested(request):
|
||||||
if not tagged_count:
|
if not tagged_count:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
if is_activitypub_requested(request):
|
|
||||||
outbox_object_ids = await db_session.execute(
|
outbox_object_ids = await db_session.execute(
|
||||||
select(models.OutboxObject.ap_id)
|
select(models.OutboxObject.ap_id)
|
||||||
.join(
|
.join(
|
||||||
@ -736,6 +823,7 @@ async def tag_by_name(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"objects": outbox_objects,
|
"objects": outbox_objects,
|
||||||
},
|
},
|
||||||
|
status_code=200 if len(outbox_objects) else 404,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -904,29 +992,49 @@ def _filter_proxy_resp_headers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_content_type(headers: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {k: v for k, v in headers.items() if k.lower() != "content-type"}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_cache_control(headers: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {**headers, "Cache-Control": "max-age=31536000"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/proxy/media/{encoded_url}")
|
@app.get("/proxy/media/{encoded_url}")
|
||||||
async def serve_proxy_media(request: Request, encoded_url: str) -> StreamingResponse:
|
async def serve_proxy_media(
|
||||||
|
request: Request,
|
||||||
|
encoded_url: str,
|
||||||
|
) -> StreamingResponse | PlainTextResponse:
|
||||||
# Decode the base64-encoded URL
|
# 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)
|
||||||
|
|
||||||
proxy_resp = await _proxy_get(request, url, stream=True)
|
proxy_resp = await _proxy_get(request, url, stream=True)
|
||||||
|
|
||||||
|
if proxy_resp.status_code >= 300:
|
||||||
|
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
|
||||||
|
return PlainTextResponse(
|
||||||
|
"proxy error",
|
||||||
|
status_code=proxy_resp.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
proxy_resp.aiter_raw(),
|
proxy_resp.aiter_raw(),
|
||||||
status_code=proxy_resp.status_code,
|
status_code=proxy_resp.status_code,
|
||||||
headers=_filter_proxy_resp_headers(
|
headers=_add_cache_control(
|
||||||
|
_filter_proxy_resp_headers(
|
||||||
proxy_resp,
|
proxy_resp,
|
||||||
[
|
[
|
||||||
"content-length",
|
"content-length",
|
||||||
"content-type",
|
"content-type",
|
||||||
"content-range",
|
"content-range",
|
||||||
"accept-ranges" "etag",
|
"accept-ranges",
|
||||||
"cache-control",
|
"etag",
|
||||||
"expires",
|
"expires",
|
||||||
"date",
|
"date",
|
||||||
"last-modified",
|
"last-modified",
|
||||||
],
|
],
|
||||||
|
)
|
||||||
),
|
),
|
||||||
background=BackgroundTask(proxy_resp.aclose),
|
background=BackgroundTask(proxy_resp.aclose),
|
||||||
)
|
)
|
||||||
@ -954,23 +1062,25 @@ async def serve_proxy_media_resized(
|
|||||||
)
|
)
|
||||||
|
|
||||||
proxy_resp = await _proxy_get(request, url, stream=False)
|
proxy_resp = await _proxy_get(request, url, stream=False)
|
||||||
if proxy_resp.status_code != 200:
|
if proxy_resp.status_code >= 300:
|
||||||
|
logger.info(f"failed to proxy {url}, got {proxy_resp.status_code}")
|
||||||
return PlainTextResponse(
|
return PlainTextResponse(
|
||||||
proxy_resp.content,
|
"proxy error",
|
||||||
status_code=proxy_resp.status_code,
|
status_code=proxy_resp.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter the headers
|
# Filter the headers
|
||||||
proxy_resp_headers = _filter_proxy_resp_headers(
|
proxy_resp_headers = _add_cache_control(
|
||||||
|
_filter_proxy_resp_headers(
|
||||||
proxy_resp,
|
proxy_resp,
|
||||||
[
|
[
|
||||||
"content-type",
|
"content-type",
|
||||||
"etag",
|
"etag",
|
||||||
"cache-control",
|
|
||||||
"expires",
|
"expires",
|
||||||
"last-modified",
|
"last-modified",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
out = BytesIO(proxy_resp.content)
|
out = BytesIO(proxy_resp.content)
|
||||||
@ -978,23 +1088,31 @@ async def serve_proxy_media_resized(
|
|||||||
if getattr(i, "is_animated", False):
|
if getattr(i, "is_animated", False):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
i.thumbnail((size, size))
|
i.thumbnail((size, size))
|
||||||
|
is_webp = False
|
||||||
|
try:
|
||||||
|
resized_buf = BytesIO()
|
||||||
|
i.save(resized_buf, format="webp")
|
||||||
|
is_webp = True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to convert to webp")
|
||||||
resized_buf = BytesIO()
|
resized_buf = BytesIO()
|
||||||
i.save(resized_buf, format=i.format)
|
i.save(resized_buf, format=i.format)
|
||||||
resized_buf.seek(0)
|
resized_buf.seek(0)
|
||||||
resized_content = resized_buf.read()
|
resized_content = resized_buf.read()
|
||||||
resized_mimetype = i.get_format_mimetype() # type: ignore
|
resized_mimetype = (
|
||||||
|
"image/webp" if is_webp else i.get_format_mimetype() # type: ignore
|
||||||
|
)
|
||||||
# Only cache images < 1MB
|
# Only cache images < 1MB
|
||||||
if len(resized_content) < 2**20:
|
if len(resized_content) < 2**20:
|
||||||
_RESIZED_CACHE[(url, size)] = (
|
_RESIZED_CACHE[(url, size)] = (
|
||||||
resized_content,
|
resized_content,
|
||||||
resized_mimetype,
|
resized_mimetype,
|
||||||
proxy_resp_headers,
|
_strip_content_type(proxy_resp_headers),
|
||||||
)
|
)
|
||||||
return PlainTextResponse(
|
return PlainTextResponse(
|
||||||
resized_content,
|
resized_content,
|
||||||
media_type=resized_mimetype,
|
media_type=resized_mimetype,
|
||||||
headers=proxy_resp_headers,
|
headers=_strip_content_type(proxy_resp_headers),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return PlainTextResponse(
|
return PlainTextResponse(
|
||||||
@ -1028,6 +1146,7 @@ async def serve_attachment(
|
|||||||
return FileResponse(
|
return FileResponse(
|
||||||
UPLOAD_DIR / content_hash,
|
UPLOAD_DIR / content_hash,
|
||||||
media_type=upload.content_type,
|
media_type=upload.content_type,
|
||||||
|
headers={"Cache-Control": "max-age=31536000"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1049,7 +1168,8 @@ async def serve_attachment_thumbnail(
|
|||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
UPLOAD_DIR / (content_hash + "_resized"),
|
UPLOAD_DIR / (content_hash + "_resized"),
|
||||||
media_type=upload.content_type,
|
media_type="image/webp",
|
||||||
|
headers={"Cache-Control": "max-age=31536000"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ class Actor(Base, BaseActor):
|
|||||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||||
|
|
||||||
ap_id = Column(String, unique=True, nullable=False, index=True)
|
ap_id: Mapped[str] = Column(String, unique=True, nullable=False, index=True)
|
||||||
ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
ap_actor: Mapped[ap.RawObject] = Column(JSON, nullable=False)
|
||||||
ap_type = Column(String, nullable=False)
|
ap_type = Column(String, nullable=False)
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ class InboxObject(Base, BaseObject):
|
|||||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
is_transient = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
replies_count = Column(Integer, nullable=False, default=0)
|
replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
|
og_meta: Mapped[list[dict[str, Any]] | None] = Column(JSON, nullable=True)
|
||||||
|
|
||||||
@ -176,7 +176,7 @@ class OutboxObject(Base, BaseObject):
|
|||||||
|
|
||||||
likes_count = Column(Integer, nullable=False, default=0)
|
likes_count = Column(Integer, nullable=False, default=0)
|
||||||
announces_count = Column(Integer, nullable=False, default=0)
|
announces_count = Column(Integer, nullable=False, default=0)
|
||||||
replies_count = Column(Integer, nullable=False, default=0)
|
replies_count: Mapped[int] = Column(Integer, nullable=False, default=0)
|
||||||
webmentions_count: Mapped[int] = Column(
|
webmentions_count: Mapped[int] = Column(
|
||||||
Integer, nullable=False, default=0, server_default="0"
|
Integer, nullable=False, default=0, server_default="0"
|
||||||
)
|
)
|
||||||
@ -537,6 +537,8 @@ class NotificationType(str, enum.Enum):
|
|||||||
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
|
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
|
||||||
FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
|
FOLLOW_REQUEST_REJECTED = "follow_request_rejected"
|
||||||
|
|
||||||
|
MOVE = "move"
|
||||||
|
|
||||||
LIKE = "like"
|
LIKE = "like"
|
||||||
UNDO_LIKE = "undo_like"
|
UNDO_LIKE = "undo_like"
|
||||||
|
|
||||||
|
@ -67,6 +67,7 @@ async def _send_actor_update_if_needed(
|
|||||||
logger.info("Will send an Update for the local actor")
|
logger.info("Will send an Update for the local actor")
|
||||||
|
|
||||||
from app.boxes import allocate_outbox_id
|
from app.boxes import allocate_outbox_id
|
||||||
|
from app.boxes import compute_all_known_recipients
|
||||||
from app.boxes import outbox_object_id
|
from app.boxes import outbox_object_id
|
||||||
from app.boxes import save_outbox_object
|
from app.boxes import save_outbox_object
|
||||||
|
|
||||||
@ -85,24 +86,8 @@ async def _send_actor_update_if_needed(
|
|||||||
|
|
||||||
# Send the update to the followers collection and all the actor we have ever
|
# Send the update to the followers collection and all the actor we have ever
|
||||||
# contacted
|
# contacted
|
||||||
followers = (
|
recipients = await compute_all_known_recipients(db_session)
|
||||||
(
|
for rcp in recipients:
|
||||||
await db_session.scalars(
|
|
||||||
select(models.Follower).options(joinedload(models.Follower.actor))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.unique()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for rcp in {
|
|
||||||
follower.actor.shared_inbox_url or follower.actor.inbox_url
|
|
||||||
for follower in followers
|
|
||||||
} | {
|
|
||||||
row.recipient
|
|
||||||
for row in await db_session.execute(
|
|
||||||
select(func.distinct(models.OutgoingActivity.recipient).label("recipient"))
|
|
||||||
)
|
|
||||||
}: # type: ignore
|
|
||||||
await new_outgoing_activity(
|
await new_outgoing_activity(
|
||||||
db_session,
|
db_session,
|
||||||
recipient=rcp,
|
recipient=rcp,
|
||||||
|
@ -13,6 +13,40 @@ $code-highlight-background: #f0f0f0;
|
|||||||
// Load custom theme
|
// Load custom theme
|
||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
|
.primary-color {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
#admin {
|
||||||
|
.admin-menu {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-top-menu {
|
||||||
|
margin: 30px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-95 {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-new {
|
||||||
|
textarea {
|
||||||
|
font-size: 1.2em;
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.show-more-wrapper {
|
.show-more-wrapper {
|
||||||
.p-summary {
|
.p-summary {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -61,13 +95,6 @@ blockquote {
|
|||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-bar {
|
|
||||||
width:100%;height:20px;
|
|
||||||
line {
|
|
||||||
stroke: $secondary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.light-background {
|
.light-background {
|
||||||
background: $light-background;
|
background: $light-background;
|
||||||
}
|
}
|
||||||
@ -116,6 +143,9 @@ dl {
|
|||||||
strong {
|
strong {
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
}
|
}
|
||||||
|
span {
|
||||||
|
color: $muted-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.highlight {
|
div.highlight {
|
||||||
@ -189,11 +219,27 @@ main {
|
|||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
color: $muted-color;
|
color: $muted-color;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tiny-actor-icon {
|
||||||
|
max-width: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
position: relative;
|
||||||
|
top: 5px;
|
||||||
}
|
}
|
||||||
.actor-box {
|
.actor-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -217,6 +263,9 @@ footer {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
li {
|
li {
|
||||||
display: block;
|
display: block;
|
||||||
|
span {
|
||||||
|
padding-right:10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,6 +300,57 @@ footer {
|
|||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.show-hide-sensitive-btn {
|
||||||
|
display:inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-margin-top {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-right {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.poll-items {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
li {
|
||||||
|
display: block;
|
||||||
|
p {
|
||||||
|
margin: 20px 0 10px 0;
|
||||||
|
.poll-vote {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-bar {
|
||||||
|
width:100%;height:20px;
|
||||||
|
line {
|
||||||
|
stroke: $secondary-color;
|
||||||
|
stroke-width: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-wrapper {
|
||||||
|
.attachment-item {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
img.attachment {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
a.attachment {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
audio.attachment {
|
||||||
|
width: 480px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
form {
|
form {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
@ -311,7 +411,7 @@ nav.flexbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.activity-attachment {
|
.activity-attachment {
|
||||||
margin: 30px 0;
|
margin: 30px 0 20px 0;
|
||||||
img, audio, video {
|
img, audio, video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 740px;
|
max-width: 740px;
|
||||||
@ -322,6 +422,20 @@ nav.flexbox {
|
|||||||
max-width: 740px;
|
max-width: 740px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-og-meta {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ap-object-expanded {
|
.ap-object-expanded {
|
||||||
border: 2px dashed $secondary-color;
|
border: 2px dashed $secondary-color;
|
||||||
}
|
}
|
||||||
@ -344,3 +458,54 @@ nav.flexbox {
|
|||||||
.emoji, .custom-emoji {
|
.emoji, .custom-emoji {
|
||||||
max-width: 25px;
|
max-width: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indieauth-box {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 20px;
|
||||||
|
|
||||||
|
.indieauth-logo {
|
||||||
|
flex: initial;
|
||||||
|
width: 100px;
|
||||||
|
img {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.indieauth-details {
|
||||||
|
flex: 1;
|
||||||
|
div {
|
||||||
|
padding-left: 20px;
|
||||||
|
a {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-interactions {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 20px;
|
||||||
|
.interactions-block {
|
||||||
|
flex: 0 1 30%;
|
||||||
|
max-width: 50%;
|
||||||
|
.facepile-wrapper {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 20px;
|
||||||
|
row-gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 20px;
|
||||||
|
a {
|
||||||
|
height: 50px;
|
||||||
|
img {
|
||||||
|
max-width: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.and-x-more {
|
||||||
|
display: inline-block;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import re
|
import re
|
||||||
|
import typing
|
||||||
|
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import models
|
|
||||||
from app import webfinger
|
from app import webfinger
|
||||||
from app.actor import Actor
|
|
||||||
from app.actor import fetch_actor
|
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.database import AsyncSession
|
from app.database import AsyncSession
|
||||||
from app.utils import emoji
|
from app.utils import emoji
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from app.actor import Actor
|
||||||
|
|
||||||
|
|
||||||
def _set_a_attrs(attrs, new=False):
|
def _set_a_attrs(attrs, new=False):
|
||||||
attrs[(None, "target")] = "_blank"
|
attrs[(None, "target")] = "_blank"
|
||||||
@ -24,9 +25,7 @@ _HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
|
|||||||
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
_MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
|
||||||
|
|
||||||
|
|
||||||
async def _hashtagify(
|
def hashtagify(content: str) -> tuple[str, list[dict[str, str]]]:
|
||||||
db_session: AsyncSession, content: str
|
|
||||||
) -> tuple[str, list[dict[str, str]]]:
|
|
||||||
tags = []
|
tags = []
|
||||||
hashtags = re.findall(_HASHTAG_REGEX, content)
|
hashtags = re.findall(_HASHTAG_REGEX, content)
|
||||||
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
|
hashtags = sorted(set(hashtags), reverse=True) # unique tags, longest first
|
||||||
@ -41,14 +40,20 @@ async def _hashtagify(
|
|||||||
async def _mentionify(
|
async def _mentionify(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
content: str,
|
content: str,
|
||||||
) -> tuple[str, list[dict[str, str]], list[Actor]]:
|
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
|
||||||
|
from app import models
|
||||||
|
from app.actor import fetch_actor
|
||||||
|
|
||||||
tags = []
|
tags = []
|
||||||
mentioned_actors = []
|
mentioned_actors = []
|
||||||
for mention in re.findall(_MENTION_REGEX, content):
|
for mention in re.findall(_MENTION_REGEX, content):
|
||||||
_, username, domain = mention.split("@")
|
_, username, domain = mention.split("@")
|
||||||
actor = (
|
actor = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(models.Actor).where(models.Actor.handle == mention)
|
select(models.Actor).where(
|
||||||
|
models.Actor.handle == mention,
|
||||||
|
models.Actor.is_deleted.is_(False),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if not actor:
|
if not actor:
|
||||||
@ -69,19 +74,19 @@ async def _mentionify(
|
|||||||
async def markdownify(
|
async def markdownify(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
content: str,
|
content: str,
|
||||||
mentionify: bool = True,
|
enable_mentionify: bool = True,
|
||||||
hashtagify: bool = True,
|
enable_hashtagify: bool = True,
|
||||||
) -> tuple[str, list[dict[str, str]], list[Actor]]:
|
) -> tuple[str, list[dict[str, str]], list["Actor"]]:
|
||||||
"""
|
"""
|
||||||
>>> content, tags = markdownify("Hello")
|
>>> content, tags = markdownify("Hello")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tags = []
|
tags = []
|
||||||
mentioned_actors: list[Actor] = []
|
mentioned_actors: list["Actor"] = []
|
||||||
if hashtagify:
|
if enable_hashtagify:
|
||||||
content, hashtag_tags = await _hashtagify(db_session, content)
|
content, hashtag_tags = hashtagify(content)
|
||||||
tags.extend(hashtag_tags)
|
tags.extend(hashtag_tags)
|
||||||
if mentionify:
|
if enable_mentionify:
|
||||||
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
|
content, mention_tags, mentioned_actors = await _mentionify(db_session, content)
|
||||||
tags.extend(mention_tags)
|
tags.extend(mention_tags)
|
||||||
|
|
||||||
|
11
app/static/common-admin.js
Normal file
11
app/static/common-admin.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', (ev) => {
|
||||||
|
// Add confirm to "delete" button next to outbox objects
|
||||||
|
var forms = document.getElementsByClassName("object-delete-form")
|
||||||
|
for (var i = 0; i < forms.length; i++) {
|
||||||
|
forms[i].addEventListener('submit', (ev) => {
|
||||||
|
if (!confirm('Do you really want to delete this object?')) {
|
||||||
|
ev.preventDefault();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
@ -26,7 +26,7 @@ from app.actor import LOCAL_ACTOR
|
|||||||
from app.ap_object import Attachment
|
from app.ap_object import Attachment
|
||||||
from app.ap_object import Object
|
from app.ap_object import Object
|
||||||
from app.config import BASE_URL
|
from app.config import BASE_URL
|
||||||
from app.config import CSS_HASH
|
from app.config import CUSTOM_FOOTER
|
||||||
from app.config import DEBUG
|
from app.config import DEBUG
|
||||||
from app.config import VERSION
|
from app.config import VERSION
|
||||||
from app.config import generate_csrf_token
|
from app.config import generate_csrf_token
|
||||||
@ -90,6 +90,7 @@ async def render_template(
|
|||||||
request: Request,
|
request: Request,
|
||||||
template: str,
|
template: str,
|
||||||
template_args: dict[str, Any] | None = None,
|
template_args: dict[str, Any] | None = None,
|
||||||
|
status_code: int = 200,
|
||||||
) -> TemplateResponse:
|
) -> TemplateResponse:
|
||||||
if template_args is None:
|
if template_args is None:
|
||||||
template_args = {}
|
template_args = {}
|
||||||
@ -103,7 +104,6 @@ async def render_template(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"debug": DEBUG,
|
"debug": DEBUG,
|
||||||
"microblogpub_version": VERSION,
|
"microblogpub_version": VERSION,
|
||||||
"css_hash": CSS_HASH,
|
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"csrf_token": generate_csrf_token(),
|
"csrf_token": generate_csrf_token(),
|
||||||
"highlight_css": HIGHLIGHT_CSS,
|
"highlight_css": HIGHLIGHT_CSS,
|
||||||
@ -131,8 +131,10 @@ async def render_template(
|
|||||||
select(func.count(models.Following.id))
|
select(func.count(models.Following.id))
|
||||||
),
|
),
|
||||||
"actor_types": ap.ACTOR_TYPES,
|
"actor_types": ap.ACTOR_TYPES,
|
||||||
|
"custom_footer": CUSTOM_FOOTER,
|
||||||
**template_args,
|
**template_args,
|
||||||
},
|
},
|
||||||
|
status_code=status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -377,7 +379,7 @@ def _html2text(content: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _replace_emoji(u: str, _) -> str:
|
def _replace_emoji(u: str, _) -> str:
|
||||||
filename = hex(ord(u))[2:]
|
filename = "-".join(hex(ord(c))[2:] for c in u)
|
||||||
return config.EMOJI_TPL.format(filename=filename, raw=u)
|
return config.EMOJI_TPL.format(filename=filename, raw=u)
|
||||||
|
|
||||||
|
|
||||||
@ -414,3 +416,8 @@ _templates.env.filters["pluralize"] = _pluralize
|
|||||||
_templates.env.filters["parse_datetime"] = _parse_datetime
|
_templates.env.filters["parse_datetime"] = _parse_datetime
|
||||||
_templates.env.filters["poll_item_pct"] = _poll_item_pct
|
_templates.env.filters["poll_item_pct"] = _poll_item_pct
|
||||||
_templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url
|
_templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url
|
||||||
|
_templates.env.globals["JS_HASH"] = config.JS_HASH
|
||||||
|
_templates.env.globals["CSS_HASH"] = config.CSS_HASH
|
||||||
|
_templates.env.globals["BASE_URL"] = config.BASE_URL
|
||||||
|
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
|
||||||
|
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING
|
||||||
|
@ -10,7 +10,9 @@
|
|||||||
{% for anybox_object, convo, actors in threads %}
|
{% for anybox_object, convo, actors in threads %}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
With {% for actor in actors %}
|
With {% for actor in actors %}
|
||||||
<a href="">{{ actor.handle }}</a>
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ actor.ap_id }}">
|
||||||
|
{{ actor.handle }}
|
||||||
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(anybox_object) }}
|
{{ utils.display_object(anybox_object) }}
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
{% for inbox_object in inbox %}
|
{% for inbox_object in inbox %}
|
||||||
{% if inbox_object.ap_type == "Announce" %}
|
{% if inbox_object.ap_type == "Announce" %}
|
||||||
{{ utils.actor_action(inbox_object, "shared") }}
|
{{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
|
{% elif inbox_object.ap_type in ["Article", "Note", "Video", "Page", "Question"] %}
|
||||||
{{ utils.display_object(inbox_object) }}
|
{{ utils.display_object(inbox_object) }}
|
||||||
@ -27,7 +27,7 @@
|
|||||||
{{ utils.actor_action(inbox_object, "followed you") }}
|
{{ utils.actor_action(inbox_object, "followed you") }}
|
||||||
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
|
{{ utils.display_actor(inbox_object.actor, actors_metadata) }}
|
||||||
{% elif inbox_object.ap_type == "Like" %}
|
{% elif inbox_object.ap_type == "Like" %}
|
||||||
{{ utils.actor_action(inbox_object, "liked one of your post") }}
|
{{ utils.actor_action(inbox_object, "liked one of your post", with_icon=True) }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
<form class="form" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
<form class="form admin-new" action="{{ request.url_for("admin_actions_new") }}" enctype="multipart/form-data" method="POST">
|
||||||
{{ utils.embed_csrf_token() }}
|
{{ utils.embed_csrf_token() }}
|
||||||
{{ utils.embed_redirect_url() }}
|
{{ utils.embed_redirect_url() }}
|
||||||
<p>
|
<p>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
{% if request.query_params.type == "Article" %}
|
{% if request.query_params.type == "Article" %}
|
||||||
<p>
|
<p>
|
||||||
<input type="text" style="width:95%" name="name" placeholder="Title">
|
<input type="text" class="width-95" name="name" placeholder="Title">
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -49,7 +49,7 @@
|
|||||||
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
|
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
|
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" placeholder="Hey!">{{ content }}</textarea>
|
||||||
|
|
||||||
{% if request.query_params.type == "Question" %}
|
{% if request.query_params.type == "Question" %}
|
||||||
<p>
|
<p>
|
||||||
@ -69,20 +69,20 @@
|
|||||||
</p>
|
</p>
|
||||||
{% for i in ["1", "2", "3", "4"] %}
|
{% for i in ["1", "2", "3", "4"] %}
|
||||||
<p>
|
<p>
|
||||||
<input type="text" name="poll_answer_{{ i }}" style="width:95%;" placeholder="Option {{ i }}, leave empty to disable">
|
<input type="text" name="poll_answer_{{ i }}" class="width-95" placeholder="Option {{ i }}, leave empty to disable">
|
||||||
</p>
|
</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)"{% if content_warning %} value="{{ content_warning }}"{% endif %} style="width:95%;">
|
<input type="text" name="content_warning" placeholder="content warning (will mark the post as sensitive)"{% if content_warning %} value="{{ content_warning }}"{% endif %} class="width-95">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
|
<input type="checkbox" name="is_sensitive" id="is_sensitive"> <label for="is_sensitive">Mark attachment(s) as sensitive</label>
|
||||||
</p>
|
</p>
|
||||||
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
|
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
|
||||||
<p>
|
<p>
|
||||||
<input id="files" name="files" type="file" multiple style="width:95%;">
|
<input id="files" name="files" type="file" class="width-95" multiple>
|
||||||
</p>
|
</p>
|
||||||
<div id="alts"></div>
|
<div id="alts"></div>
|
||||||
<p>
|
<p>
|
||||||
@ -90,5 +90,5 @@
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/new.js"></script>
|
<script src="/static/new.js?v={{ JS_HASH }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -9,10 +9,21 @@
|
|||||||
{{ utils.display_actor(actor, actors_metadata, with_details=True) }}
|
{{ utils.display_actor(actor, actors_metadata, with_details=True) }}
|
||||||
{% for inbox_object in inbox_objects %}
|
{% for inbox_object in inbox_objects %}
|
||||||
{% if inbox_object.ap_type == "Announce" %}
|
{% if inbox_object.ap_type == "Announce" %}
|
||||||
{{ utils.actor_action(inbox_object, "shared") }}
|
{{ utils.actor_action(inbox_object, "shared", with_icon=True) }}
|
||||||
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
{{ utils.display_object(inbox_object.relates_to_anybox_object) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ utils.display_object(inbox_object) }}
|
{{ utils.display_object(inbox_object) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if next_cursor %}
|
||||||
|
<div class="box">
|
||||||
|
<p>
|
||||||
|
<a href="{{ request.url._path }}?actor_id={{ request.query_params.actor_id }}&cursor={{ next_cursor }}">
|
||||||
|
See more
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
|
<data class="p-name" value="{{ local_actor.display_name}}'s articles"></data>
|
||||||
{% for outbox_object in objects %}
|
{% for outbox_object in objects %}
|
||||||
<li>
|
<li>
|
||||||
<span class="muted" style="padding-right:10px;">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</span> <a href="{{ outbox_object.url }}">{{ outbox_object.name }}</a>
|
<span class="muted">{{ outbox_object.ap_published_at.strftime("%b %d, %Y") }}</span> <a href="{{ outbox_object.url }}">{{ outbox_object.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
12
app/templates/error.html
Normal file
12
app/templates/error.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{%- import "utils.html" as utils with context -%}
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="centered primary-color">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -29,15 +29,19 @@
|
|||||||
<a href="{{ url_for }}" {% if 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 style="margin:30px 0 0 0;">
|
<div class="public-top-menu">
|
||||||
<nav class="flexbox">
|
<nav class="flexbox">
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ header_link("index", "Notes") }}</li>
|
<li>{{ header_link("index", "Notes") }}</li>
|
||||||
{% if articles_count %}
|
{% if articles_count %}
|
||||||
<li>{{ header_link("articles", "Articles") }}</li>
|
<li>{{ header_link("articles", "Articles") }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not HIDES_FOLLOWERS or is_admin %}
|
||||||
<li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li>
|
<li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if not HIDES_FOLLOWING or is_admin %}
|
||||||
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
|
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
|
||||||
|
{% endif %}
|
||||||
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
|
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -21,19 +21,21 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "header.html" %}
|
{% include "header.html" %}
|
||||||
|
|
||||||
<div class="h-feed">
|
{% if objects %}
|
||||||
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
|
|
||||||
{% for outbox_object in objects %}
|
|
||||||
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
|
|
||||||
{{ utils.display_object(outbox_object) }}
|
|
||||||
{% elif outbox_object.ap_type == "Announce" %}
|
|
||||||
<div class="shared-header"><strong>{{ local_actor.display_name }}</strong> shared</div>
|
|
||||||
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
<div class="h-feed">
|
||||||
|
<data class="p-name" value="{{ local_actor.display_name}}'s notes"></data>
|
||||||
|
{% for outbox_object in objects %}
|
||||||
|
{% if outbox_object.ap_type in ["Note", "Article", "Video", "Question"] %}
|
||||||
|
{{ utils.display_object(outbox_object) }}
|
||||||
|
{% elif outbox_object.ap_type == "Announce" %}
|
||||||
|
<div class="shared-header"><strong>{{ utils.display_tiny_actor_icon(local_actor) }} {{ local_actor.display_name | clean_html(local_actor) | safe }}</strong> shared <span title="{{ outbox_object.ap_published_at.isoformat() }}">{{ outbox_object.ap_published_at | timeago }}</span></div>
|
||||||
|
{{ utils.display_object(outbox_object.relates_to_anybox_object) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
{% if has_previous_page %}
|
{% if has_previous_page %}
|
||||||
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
|
<a href="{{ url_for("index") }}?page={{ current_page - 1 }}">Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -41,6 +43,12 @@
|
|||||||
{% if has_next_page %}
|
{% if has_next_page %}
|
||||||
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
|
<a href="{{ url_for("index") }}?page={{ current_page + 1 }}">Next</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Nothing to see here yet!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -2,15 +2,15 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div style="display:flex;column-gap: 20px;">
|
<div class"indieauth-box">
|
||||||
{% if client.logo %}
|
{% if client.logo %}
|
||||||
<div style="flex:initial;width:100px;">
|
<div class="indieauth-logo">
|
||||||
<img src="{{client.logo | media_proxy_url }}" style="max-width:100px;" alt="{{ client.name }} logo">
|
<img src="{{client.logo | media_proxy_url }}" alt="{{ client.name }} logo">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="flex:1;">
|
<div class="indieauth-details">
|
||||||
<div style="padding-left: 20px;">
|
<div>
|
||||||
<a class="lcolor" style="font-size:1.2em;font-weight:600;" href="{{ client.url }}">{{ client.name }}</a>
|
<a class="lcolor" href="{{ client.url }}">{{ client.name }}</a>
|
||||||
<p>wants you to login as <strong class="lcolor">{{ me }}</strong> with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>
|
<p>wants you to login as <strong class="lcolor">{{ me }}</strong> with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,14 +4,12 @@
|
|||||||
<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="/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="/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
<style>
|
<style>{{ highlight_css }}</style>
|
||||||
{{ highlight_css }}
|
|
||||||
</style>
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -23,7 +21,7 @@
|
|||||||
{% set url_for = request.app.router.url_path_for(url) %}
|
{% set url_for = request.app.router.url_path_for(url) %}
|
||||||
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
<a href="{{ url_for }}" {% if request.url.path == url_for %}class="active"{% endif %}>{{ text }}</a>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
<div style="margin-bottom:30px;padding: 0 20px;">
|
<div class="admin-menu">
|
||||||
<nav class="flexbox">
|
<nav class="flexbox">
|
||||||
<ul>
|
<ul>
|
||||||
<li>{{ admin_link("index", "Public") }}</li>
|
<li>{{ admin_link("index", "Public") }}</li>
|
||||||
@ -47,8 +45,15 @@
|
|||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
{% if custom_footer %}
|
||||||
|
{{ custom_footer | safe }}
|
||||||
|
{% else %}
|
||||||
Powered by <a href="https://docs.microblog.pub">microblog.pub</a> <small class="microblogpub-version"><code>{{ microblogpub_version }}</code></small> and the <a href="https://activitypub.rocks/">ActivityPub</a> protocol. <a href="{{ url_for("login") }}">Admin</a>.
|
Powered by <a href="https://docs.microblog.pub">microblog.pub</a> <small class="microblogpub-version"><code>{{ microblogpub_version }}</code></small> and the <a href="https://activitypub.rocks/">ActivityPub</a> protocol. <a href="{{ url_for("login") }}">Admin</a>.
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% if is_admin %}
|
||||||
|
<script src="/static/common-admin.js?v={{ JS_HASH }}"></script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
{%- import "utils.html" as utils with context -%}
|
{%- import "utils.html" as utils with context -%}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:grid;height:80%;">
|
<div class="centered">
|
||||||
<div style="margin:auto;">
|
{% if error %}
|
||||||
|
<p class="primary-color">Invalid password.</p>
|
||||||
|
{% endif %}
|
||||||
<form class="form" action="/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 }}">
|
||||||
@ -10,5 +12,4 @@
|
|||||||
<input type="submit" value="login">
|
<input type="submit" value="login">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -19,7 +19,9 @@
|
|||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="box error-box">
|
<div class="box error-box">
|
||||||
{% if error.value == "NOT_FOUND" %}
|
{% if error.value == "NOT_FOUND" %}
|
||||||
<p>The remote object was deleted.</p>
|
<p>The remote object is unavailable.</p>
|
||||||
|
{% elif error.value == "UNAUTHORIZED" %}
|
||||||
|
<p>Missing permissions to fetch the remote object.</p>
|
||||||
{% elif error.value == "TIMEOUT" %}
|
{% elif error.value == "TIMEOUT" %}
|
||||||
<p>Lookup timed out, please try refreshing the page.</p>
|
<p>Lookup timed out, please try refreshing the page.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -5,9 +5,10 @@
|
|||||||
<title>{{ local_actor.display_name }} - Notifications</title>
|
<title>{{ local_actor.display_name }} - Notifications</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% macro notif_actor_action(notif, text) %}
|
{% macro notif_actor_action(notif, text, with_icon=False) %}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">{{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.actor.ap_id }}">
|
||||||
|
{% if with_icon %}{{ utils.display_tiny_actor_icon(notif.actor) }}{% endif %} {{ notif.actor.display_name | clean_html(notif.actor) | safe }}</a> {{ text }}
|
||||||
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
|
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@ -35,18 +36,26 @@
|
|||||||
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
{%- elif notif.notification_type.value == "follow_request_rejected" %}
|
||||||
{{ notif_actor_action(notif, "rejected your follow request") }}
|
{{ notif_actor_action(notif, "rejected your follow request") }}
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||||
|
{%- elif notif.notification_type.value == "move" %}
|
||||||
|
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
||||||
|
<div class="actor-action">
|
||||||
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.inbox_object.actor.ap_id }}">
|
||||||
|
{{ utils.display_tiny_actor_icon(notif.inbox_object.actor) }} {{ notif.inbox_object.actor.display_name | clean_html(notif.inbox_object.actor) | safe }}</a> has moved to
|
||||||
|
<span title="{{ notif.created_at.isoformat() }}">{{ notif.created_at | timeago }}</span>
|
||||||
|
</div>
|
||||||
|
{{ utils.display_actor(notif.actor) }}
|
||||||
{% elif notif.notification_type.value == "like" %}
|
{% elif notif.notification_type.value == "like" %}
|
||||||
{{ notif_actor_action(notif, "liked a post") }}
|
{{ notif_actor_action(notif, "liked a post", with_icon=True) }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "undo_like" %}
|
{% elif notif.notification_type.value == "undo_like" %}
|
||||||
{{ notif_actor_action(notif, "unliked a post") }}
|
{{ notif_actor_action(notif, "unliked a post", with_icon=True) }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "announce" %}
|
{% elif notif.notification_type.value == "announce" %}
|
||||||
{{ notif_actor_action(notif, "shared a post") }}
|
{{ notif_actor_action(notif, "shared a post", with_icon=True) }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "undo_announce" %}
|
{% elif notif.notification_type.value == "undo_announce" %}
|
||||||
{{ notif_actor_action(notif, "unshared a post") }}
|
{{ notif_actor_action(notif, "unshared a post") }}
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object, with_icon=True) }}
|
||||||
{% elif notif.notification_type.value == "mention" %}
|
{% elif notif.notification_type.value == "mention" %}
|
||||||
{{ notif_actor_action(notif, "mentioned you") }}
|
{{ notif_actor_action(notif, "mentioned you") }}
|
||||||
{{ utils.display_object(notif.inbox_object) }}
|
{{ utils.display_object(notif.inbox_object) }}
|
||||||
@ -57,7 +66,7 @@
|
|||||||
{% if facepile_item %}
|
{% if facepile_item %}
|
||||||
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "updated_webmention" %}
|
{% elif notif.notification_type.value == "updated_webmention" %}
|
||||||
@ -67,7 +76,7 @@
|
|||||||
{% if facepile_item %}
|
{% if facepile_item %}
|
||||||
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% elif notif.notification_type.value == "deleted_webmention" %}
|
{% elif notif.notification_type.value == "deleted_webmention" %}
|
||||||
@ -77,7 +86,7 @@
|
|||||||
{% if facepile_item %}
|
{% if facepile_item %}
|
||||||
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
<a href="{{ facepile_item.actor_url }}">{{ facepile_item.actor_name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a style="font-weight:bold;" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
<a class="bold" href="{{ notif.webmention.source }}">{{ notif.webmention.source }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ utils.display_object(notif.outbox_object) }}
|
{{ utils.display_object(notif.outbox_object) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -88,4 +97,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if next_cursor %}
|
||||||
|
<div class="box">
|
||||||
|
<p>
|
||||||
|
<a href="{{ request.url._path }}?cursor={{ next_cursor }}">
|
||||||
|
See more{% if more_unread_count %} ({{ more_unread_count }} unread left){% endif %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -3,7 +3,11 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% if outbox_object %}
|
{% if outbox_object %}
|
||||||
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %}
|
{% if outbox_object.content %}
|
||||||
|
{% set excerpt = outbox_object.content | html2text | trim | truncate(50) %}
|
||||||
|
{% else %}
|
||||||
|
{% set excerpt = outbox_object.summary | html2text | trim | truncate(50) %}
|
||||||
|
{% endif %}
|
||||||
<title>{% if outbox_object.name %}{{ outbox_object.name }}{% else %}{{ local_actor.display_name }}: "{{ excerpt }}"{% endif %}</title>
|
<title>{% if outbox_object.name %}{{ outbox_object.name }}{% else %}{{ local_actor.display_name }}: "{{ excerpt }}"{% endif %}</title>
|
||||||
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
|
<link rel="webmention" href="{{ url_for("webmention_endpoint") }}">
|
||||||
<link rel="alternate" href="{{ request.url }}" type="application/activity+json">
|
<link rel="alternate" href="{{ request.url }}" type="application/activity+json">
|
||||||
|
@ -96,11 +96,11 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_delete_button(ap_object_id) %}
|
{% macro admin_delete_button(ap_object) %}
|
||||||
<form action="{{ request.url_for("admin_actions_delete") }}" method="POST" onsubmit="return confirm('Do you really want to delete this object?');">
|
<form action="{{ request.url_for("admin_actions_delete") }}" class="object-delete-form" method="POST">
|
||||||
{{ embed_csrf_token() }}
|
{{ embed_csrf_token() }}
|
||||||
{{ embed_redirect_url() }}
|
<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_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>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@ -154,9 +154,10 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro admin_expand_button(ap_object_id) %}
|
{% macro admin_expand_button(ap_object) %}
|
||||||
|
{# TODO turn these into a regular link and append permalink ID if it's a reply #}
|
||||||
<form action="{{ url_for("admin_object") }}" method="GET">
|
<form action="{{ url_for("admin_object") }}" method="GET">
|
||||||
<input type="hidden" name="ap_id" value="{{ ap_object_id }}">
|
<input type="hidden" name="ap_id" value="{{ ap_object.ap_id }}">
|
||||||
<button type="submit">expand</button>
|
<button type="submit">expand</button>
|
||||||
</form>
|
</form>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@ -180,9 +181,15 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro actor_action(inbox_object, text) %}
|
{% macro display_tiny_actor_icon(actor) %}
|
||||||
|
<img class="tiny-actor-icon" src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar">
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro actor_action(inbox_object, text, with_icon=False) %}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">{{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}</a> {{ text }}
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ inbox_object.actor.ap_id }}">
|
||||||
|
{% if with_icon %}{{ display_tiny_actor_icon(inbox_object.actor) }}{% endif %} {{ inbox_object.actor.display_name | clean_html(inbox_object.actor) | safe }}
|
||||||
|
</a> {{ text }}
|
||||||
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
<span title="{{ inbox_object.ap_published_at.isoformat() }}">{{ inbox_object.ap_published_at | timeago }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -199,7 +206,7 @@
|
|||||||
<div class="icon-box">
|
<div class="icon-box">
|
||||||
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
|
<img src="{{ actor.resized_icon_url }}" alt="{{ actor.display_name }}'s avatar" class="actor-icon u-photo">
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ actor.url }}" class="u-url" style="">
|
<a href="{{ actor.url }}" class="u-url">
|
||||||
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
|
<div><strong>{{ actor.display_name | clean_html(actor) | safe }}</strong></div>
|
||||||
<div class="actor-handle p-name">{{ actor.handle }}</div>
|
<div class="actor-handle p-name">{{ actor.handle }}</div>
|
||||||
</a>
|
</a>
|
||||||
@ -216,7 +223,7 @@
|
|||||||
{% elif metadata.is_follow_request_sent %}
|
{% elif metadata.is_follow_request_sent %}
|
||||||
<li>follow request sent</li>
|
<li>follow request sent</li>
|
||||||
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
<li>{{ admin_undo_button(metadata.outbox_follow_ap_id, "undo follow") }}</li>
|
||||||
{% else %}
|
{% elif not actor.moved_to %}
|
||||||
<li>{{ admin_follow_button(actor) }}</li>
|
<li>{{ admin_follow_button(actor) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if metadata.is_follower %}
|
{% if metadata.is_follower %}
|
||||||
@ -224,7 +231,11 @@
|
|||||||
{% if not metadata.is_following and not with_details %}
|
{% if not metadata.is_following and not with_details %}
|
||||||
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
{% elif actor.is_from_db and not with_details %}
|
||||||
|
<li>{{ admin_profile_button(actor.ap_id) }}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if actor.moved_to %}
|
||||||
|
<li>has moved to {% if metadata.moved_to %}<a href="{{ url_for("admin_profile") }}?actor_id={{ actor.moved_to }}">{{ metadata.moved_to.handle }}</a>{% else %}<a href="{{ url_for("get_lookup") }}?query={{ actor.moved_to }}">{{ actor.moved_to }}</a>{% endif %}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if actor.is_from_db %}
|
{% if actor.is_from_db %}
|
||||||
{% if actor.is_blocked %}
|
{% if actor.is_blocked %}
|
||||||
@ -285,16 +296,16 @@
|
|||||||
{% macro display_og_meta(object) %}
|
{% macro display_og_meta(object) %}
|
||||||
{% if object.og_meta %}
|
{% if object.og_meta %}
|
||||||
{% for og_meta in object.og_meta %}
|
{% for og_meta in object.og_meta %}
|
||||||
<div class="activity-og-meta" style="display:flex;column-gap: 20px;margin:20px 0;">
|
<div class="activity-og-meta">
|
||||||
{% if og_meta.image %}
|
{% if og_meta.image %}
|
||||||
<div>
|
<div>
|
||||||
<img src="{{ og_meta.image | media_proxy_url }}" style="max-width:200px;max-height:100px;">
|
<img src="{{ og_meta.image | media_proxy_url }}">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
|
<a href="{{ og_meta.url | privacy_replace_url }}">{{ og_meta.title }}</a>
|
||||||
{% if og_meta.site_name %}
|
{% if og_meta.site_name %}
|
||||||
<small style="display:block;">{{ og_meta.site_name }}</small>
|
<small>{{ og_meta.site_name }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -307,27 +318,27 @@
|
|||||||
|
|
||||||
{% for attachment in object.attachments %}
|
{% for attachment in object.attachments %}
|
||||||
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
{% if object.sensitive and (attachment.type == "Image" or (attachment | has_media_type("image")) or attachment.type == "Video" or (attachment | has_media_type("video"))) %}
|
||||||
<div>
|
<div class="attachment-wrapper">
|
||||||
<label for="{{attachment.proxied_url}}" class="label-btn" style="display:inline-block;">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">
|
<div class="sensitive-attachment-box">
|
||||||
<div></div>
|
<div></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="margin-top:20px;">
|
<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")) %}
|
||||||
{% if attachment.url not in object.inlined_images %}
|
{% if attachment.url not in object.inlined_images %}
|
||||||
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment" style="margin:0;">
|
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} title="{{ attachment.name }}" alt="{{ attachment.name }}"{% endif %} class="attachment">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
|
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
|
||||||
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
|
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %}></video>
|
||||||
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
||||||
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} style="width:480px;" class="attachment"></audio>
|
<audio controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name%} title="{{ attachment.name }}"{% endif %} class="attachment"></audio>
|
||||||
{% elif attachment.type == "Link" %}
|
{% elif attachment.type == "Link" %}
|
||||||
<a href="{{ attachment.url }}" class="attachment" style="display:inline-block;margin-bottom: 15px;">{{ attachment.url }}</a>
|
<a href="{{ attachment.url }}" class="attachment">{{ attachment.url }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
|
<a href="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachment">{{ attachment.url }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -358,13 +369,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.in_reply_to %}
|
{% if object.in_reply_to %}
|
||||||
<a href="{% if is_admin %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
|
<a href="{% if is_admin and object.is_in_reply_to_from_inbox %}{{ url_for("get_lookup") }}?query={% endif %}{{ object.in_reply_to }}" title="{{ object.in_reply_to }}" class="in-reply-to" rel="nofollow">
|
||||||
in reply to {{ object.in_reply_to|truncate(64, True) }}
|
in reply to {{ object.in_reply_to|truncate(64, True) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.ap_type == "Article" %}
|
{% if object.ap_type == "Article" %}
|
||||||
<h2 class="p-name" style="margin-top:0;">{{ object.name }}</h2>
|
<h2 class="p-name no-margin-top">{{ object.name }}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if is_article_mode %}
|
{% if is_article_mode %}
|
||||||
@ -394,11 +405,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.poll_items %}
|
{% if object.poll_items %}
|
||||||
<ul style="list-style-type: none;padding:0;">
|
<ul class="poll-items">
|
||||||
{% for item in object.poll_items %}
|
{% for item in object.poll_items %}
|
||||||
<li style="display:block;">
|
<li>
|
||||||
{% set pct = item | poll_item_pct(object.poll_voters_count) %}
|
{% set pct = item | poll_item_pct(object.poll_voters_count) %}
|
||||||
<p style="margin:20px 0 10px 0;">
|
<p>
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
<input type="{% if object.is_one_of_poll %}radio{% else %}checkbox{% endif %}" name="name" value="{{ item.name }}" id="{{object.permalink_id}}-{{item.name}}">
|
<input type="{% if object.is_one_of_poll %}radio{% else %}checkbox{% endif %}" name="name" value="{{ item.name }}" id="{{object.permalink_id}}-{{item.name}}">
|
||||||
<label for="{{object.permalink_id}}-{{item.name}}">
|
<label for="{{object.permalink_id}}-{{item.name}}">
|
||||||
@ -407,17 +418,17 @@
|
|||||||
{{ item.name | clean_html(object) | safe }}
|
{{ item.name | clean_html(object) | safe }}
|
||||||
|
|
||||||
{% if object.voted_for_answers and item.name in object.voted_for_answers %}
|
{% if object.voted_for_answers and item.name in object.voted_for_answers %}
|
||||||
<span class="muted" style="padding-left:20px;">you voted for this answer</span>
|
<span class="muted poll-vote">you voted for this answer</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_vote %}
|
{% if can_vote %}
|
||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span style="float:right;">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
|
<span class="float-right">{{ pct }}% <span class="muted">({{ item.replies.totalItems }} votes)</span></span>
|
||||||
</p>
|
</p>
|
||||||
<svg class="poll-bar">
|
<svg class="poll-bar">
|
||||||
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px" style="stroke-width: 20px;"></line>
|
<line x1="0" y1="10px" x2="{{ pct or 1 }}%" y2="10px"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -440,7 +451,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="activity-attachment" style="margin-bottom:20px;">
|
<div class="activity-attachment">
|
||||||
{{ display_attachments(object) }}
|
{{ display_attachments(object) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -496,7 +507,7 @@
|
|||||||
|
|
||||||
{% if (object.is_from_outbox or is_admin) and object.replies_count %}
|
{% if (object.is_from_outbox or is_admin) and object.replies_count %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% if is_admin and not object.is_from_outbox %}{{ url_for("admin_object") }}?ap_id={{ object.ap_id }}{% else %}{{ object.url }}{% endif %}"><strong>{{ object.replies_count }}</strong> repl{{ object.replies_count | pluralize("y", "ies") }}</a>
|
<a href="{% if is_admin and not object.is_from_outbox %}{{ url_for("admin_object") }}?ap_id={{ object.ap_id }}{% if object.in_reply_to %}#{{ object.permalink_id }}{% endif %}{% else %}{{ object.url }}{% endif %}"><strong>{{ object.replies_count }}</strong> repl{{ object.replies_count | pluralize("y", "ies") }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -508,7 +519,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{% if object.is_from_outbox %}
|
{% if object.is_from_outbox %}
|
||||||
<li>
|
<li>
|
||||||
{{ admin_delete_button(object.ap_id) }}
|
{{ admin_delete_button(object) }}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
@ -559,7 +570,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if object.is_from_inbox or object.is_from_outbox %}
|
{% if object.is_from_inbox or object.is_from_outbox %}
|
||||||
<li>
|
<li>
|
||||||
{{ admin_expand_button(object.ap_id) }}
|
{{ admin_expand_button(object) }}
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -568,17 +579,17 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if likes or shares or webmentions %}
|
{% if likes or shares or webmentions %}
|
||||||
<div style="display: flex;column-gap: 20px;flex-wrap: wrap;margin-top:20px;">
|
<div class="public-interactions">
|
||||||
{% if likes %}
|
{% if likes %}
|
||||||
<div style="flex: 0 1 30%;max-width: 50%;">Likes
|
<div class="interactions-block">Likes
|
||||||
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
|
<div class="facepile-wrapper">
|
||||||
{% for like in likes %}
|
{% for like in likes %}
|
||||||
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" style="height:50px;" rel="noreferrer">
|
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" rel="noreferrer">
|
||||||
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}" style="max-width:50px;">
|
<img src="{{ like.actor.resized_icon_url }}" alt="{{ like.actor.handle}}">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if object.likes_count > likes | length %}
|
{% if object.likes_count > likes | length %}
|
||||||
<div style="display:inline-block;align-self:center;">
|
<div class="and-x-more">
|
||||||
and {{ object.likes_count - likes | length }} more.
|
and {{ object.likes_count - likes | length }} more.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -587,15 +598,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if shares %}
|
{% if shares %}
|
||||||
<div style="flex: 0 1 30%;max-width: 50%;">Shares
|
<div class="interactions-block">Shares
|
||||||
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
|
<div class="facepile-wrapper">
|
||||||
{% for share in shares %}
|
{% for share in shares %}
|
||||||
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" style="height:50px;" rel="noreferrer">
|
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" rel="noreferrer">
|
||||||
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}" style="max-width:50px;">
|
<img src="{{ share.actor.resized_icon_url }}" alt="{{ share.actor.handle}}">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if object.announces_count > shares | length %}
|
{% if object.announces_count > shares | length %}
|
||||||
<div style="display:inline-block;align-self:center;">
|
<div class="and-x-more">
|
||||||
and {{ object.announces_count - shares | length }} more.
|
and {{ object.announces_count - shares | length }} more.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -604,13 +615,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if webmentions %}
|
{% if webmentions %}
|
||||||
<div style="flex: 0 1 30%;max-width: 50%;">Webmentions
|
<div class="interactions-block">Webmentions
|
||||||
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
|
<div class="facepile-wrapper">
|
||||||
{% for webmention in webmentions %}
|
{% for webmention in webmentions %}
|
||||||
{% set wm = webmention.as_facepile_item %}
|
{% set wm = webmention.as_facepile_item %}
|
||||||
{% if wm %}
|
{% if wm %}
|
||||||
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" style="height:50px;" rel="noreferrer">
|
<a href="{{ wm.url }}" title="{{ wm.actor_name }}" rel="noreferrer">
|
||||||
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}" style="max-width:50px;">
|
<img src="{{ wm.actor_icon_url | media_proxy_url }}" alt="{{ wm.actor_name }}">
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -5,6 +5,7 @@ import blurhash # type: ignore
|
|||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from PIL import ImageOps
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
@ -45,11 +46,13 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
|
|||||||
width = None
|
width = None
|
||||||
height = None
|
height = None
|
||||||
|
|
||||||
if f.content_type.startswith("image"):
|
if f.content_type.startswith("image") and not f.content_type == "image/gif":
|
||||||
image_blurhash = blurhash.encode(f.file, x_components=4, y_components=3)
|
with Image.open(f.file) as _original_image:
|
||||||
f.file.seek(0)
|
# Fix image orientation (as we will remove the info from the EXIF
|
||||||
|
# metadata)
|
||||||
|
original_image = ImageOps.exif_transpose(_original_image)
|
||||||
|
|
||||||
with Image.open(f.file) as original_image:
|
# Re-creating the image drop the EXIF metadata
|
||||||
destination_image = Image.new(
|
destination_image = Image.new(
|
||||||
original_image.mode,
|
original_image.mode,
|
||||||
original_image.size,
|
original_image.size,
|
||||||
@ -57,15 +60,18 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
|
|||||||
destination_image.putdata(original_image.getdata())
|
destination_image.putdata(original_image.getdata())
|
||||||
destination_image.save(
|
destination_image.save(
|
||||||
dest_filename,
|
dest_filename,
|
||||||
format=original_image.format,
|
format=_original_image.format,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with open(dest_filename, "rb") as dest_f:
|
||||||
|
image_blurhash = blurhash.encode(dest_f, x_components=4, y_components=3)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
width, height = original_image.size
|
width, height = destination_image.size
|
||||||
original_image.thumbnail((740, 740))
|
destination_image.thumbnail((740, 740))
|
||||||
original_image.save(
|
destination_image.save(
|
||||||
UPLOAD_DIR / f"{content_hash}_resized",
|
UPLOAD_DIR / f"{content_hash}_resized",
|
||||||
format=original_image.format,
|
format="webp",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from bs4 import BeautifulSoup # type: ignore
|
from bs4 import BeautifulSoup # type: ignore
|
||||||
@ -11,6 +13,9 @@ from app.config import CODE_HIGHLIGHTING_THEME
|
|||||||
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
_FORMATTER = HtmlFormatter(style=CODE_HIGHLIGHTING_THEME)
|
||||||
|
|
||||||
HIGHLIGHT_CSS = _FORMATTER.get_style_defs()
|
HIGHLIGHT_CSS = _FORMATTER.get_style_defs()
|
||||||
|
HIGHLIGHT_CSS_HASH = base64.b64encode(
|
||||||
|
hashlib.sha256(HIGHLIGHT_CSS.encode()).digest()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(256)
|
@lru_cache(256)
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -36,7 +37,7 @@ def _scrap_og_meta(url: str, html: str) -> OpenGraphMeta | None:
|
|||||||
# FIXME some page have no <title>
|
# FIXME some page have no <title>
|
||||||
raw = {
|
raw = {
|
||||||
"url": url,
|
"url": url,
|
||||||
"title": soup.find("title").text,
|
"title": soup.find("title").text.strip(),
|
||||||
"image": None,
|
"image": None,
|
||||||
"description": None,
|
"description": None,
|
||||||
"site_name": urlparse(url).hostname,
|
"site_name": urlparse(url).hostname,
|
||||||
@ -80,6 +81,9 @@ async def external_urls(
|
|||||||
soup = BeautifulSoup(ro.content, "html5lib")
|
soup = BeautifulSoup(ro.content, "html5lib")
|
||||||
for link in soup.find_all("a"):
|
for link in soup.find_all("a"):
|
||||||
h = link.get("href")
|
h = link.get("href")
|
||||||
|
if not h:
|
||||||
|
continue
|
||||||
|
|
||||||
ph = urlparse(h)
|
ph = urlparse(h)
|
||||||
mimetype, _ = mimetypes.guess_type(h)
|
mimetype, _ = mimetypes.guess_type(h)
|
||||||
if (
|
if (
|
||||||
@ -124,9 +128,21 @@ async def og_meta_from_note(
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
og_meta = []
|
og_meta = []
|
||||||
urls = await external_urls(db_session, ro)
|
urls = await external_urls(db_session, ro)
|
||||||
|
logger.debug(f"Lookig OG metadata in {urls=}")
|
||||||
for url in urls:
|
for url in urls:
|
||||||
|
logger.debug(f"Processing {url}")
|
||||||
try:
|
try:
|
||||||
maybe_og_meta = await _og_meta_from_url(url)
|
maybe_og_meta = None
|
||||||
|
try:
|
||||||
|
maybe_og_meta = await asyncio.wait_for(
|
||||||
|
_og_meta_from_url(url),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info(f"Timing out fetching {url}")
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed scrap OG meta for {url}")
|
||||||
|
|
||||||
if maybe_og_meta:
|
if maybe_og_meta:
|
||||||
og_meta.append(maybe_og_meta.dict())
|
og_meta.append(maybe_og_meta.dict())
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
|
12
app/utils/version.py
Normal file
12
app/utils/version.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_commit() -> str:
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
|
||||||
|
.split()[0]
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return "dev"
|
@ -54,12 +54,17 @@ class Worker(Generic[T]):
|
|||||||
{task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
{task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
logger.info(f"Waiting for tasks to finish {done=}/{pending=}")
|
logger.info(f"Waiting for tasks to finish {done=}/{pending=}")
|
||||||
await asyncio.sleep(5)
|
|
||||||
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||||
logger.info(f"Cancelling {len(tasks)} tasks")
|
logger.info(f"Cancelling {len(tasks)} tasks")
|
||||||
[task.cancel() for task in tasks]
|
[task.cancel() for task in tasks]
|
||||||
|
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(*tasks, return_exceptions=True),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Tasks failed to cancel")
|
||||||
|
|
||||||
logger.info("stopping loop")
|
logger.info("stopping loop")
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import tomli_w
|
import tomli_w
|
||||||
from markdown import markdown # type: ignore
|
|
||||||
|
|
||||||
from app.key import generate_key
|
from app.key import generate_key
|
||||||
|
|
||||||
@ -44,7 +43,7 @@ def setup_config_file(
|
|||||||
dat["username"] = username
|
dat["username"] = username
|
||||||
dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
dat["admin_password"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
dat["name"] = name
|
dat["name"] = name
|
||||||
dat["summary"] = markdown(summary)
|
dat["summary"] = summary
|
||||||
dat["https"] = True
|
dat["https"] = True
|
||||||
proto = "https"
|
proto = "https"
|
||||||
dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png'
|
dat["icon_url"] = f'{proto}://{dat["domain"]}/static/nopic.png'
|
||||||
|
@ -29,6 +29,7 @@ async def webfinger(
|
|||||||
|
|
||||||
is_404 = False
|
is_404 = False
|
||||||
|
|
||||||
|
resp: httpx.Response | None = None
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
for i, proto in enumerate(protos):
|
for i, proto in enumerate(protos):
|
||||||
try:
|
try:
|
||||||
@ -59,7 +60,10 @@ async def webfinger(
|
|||||||
if is_404:
|
if is_404:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if resp:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_remote_follow_template(resource: str) -> str | None:
|
async def get_remote_follow_template(resource: str) -> str | None:
|
||||||
|
@ -10,12 +10,13 @@ Microblog.pub is a "modern" Python application with "old-school" server-rendered
|
|||||||
|
|
||||||
- [Poetry](https://python-poetry.org/) is used for dependency management.
|
- [Poetry](https://python-poetry.org/) is used for dependency management.
|
||||||
- Most of the code is asynchronous, using [asyncio](https://docs.python.org/3/library/asyncio.html).
|
- Most of the code is asynchronous, using [asyncio](https://docs.python.org/3/library/asyncio.html).
|
||||||
- SQLite3 is the default database.
|
- SQLite3 for data storage
|
||||||
|
|
||||||
The server has 2 components:
|
The server has 3 components:
|
||||||
|
|
||||||
- The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates)
|
- The web server (powered by [FastAPI](https://fastapi.tiangolo.com/) and [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates)
|
||||||
- An additional process that takes care of sending "outgoing actities"
|
- One process that takes care of sending "outgoing activities"
|
||||||
|
- One process that takes care of processing "incoming activities"
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ inv -l
|
|||||||
|
|
||||||
### Media storage
|
### Media storage
|
||||||
|
|
||||||
The uploads are stored in the `data/` directory, using a simple content-addressed storage (file contents hash is the name of the store BLOB).
|
The uploads are stored in the `data/` directory, using a simple content-addressed storage system (file contents hash is BLOB filename).
|
||||||
Files metadata are stored in the database.
|
Files metadata are stored in the database.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
@ -55,6 +55,12 @@ docker compose stop
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
As you probably already know, Docker can (and will) eat a lot of disk space, when updating you should [prune old images](https://docs.docker.com/config/pruning/#prune-images) from time to time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker image prune -a --filter "until=24h"
|
||||||
|
```
|
||||||
|
|
||||||
## Python developer edition
|
## Python developer edition
|
||||||
|
|
||||||
Assuming you have a working **Python 3.10+** environment.
|
Assuming you have a working **Python 3.10+** environment.
|
||||||
@ -99,7 +105,7 @@ Setup a reverse proxy (see the next section).
|
|||||||
|
|
||||||
### Updating
|
### Updating
|
||||||
|
|
||||||
To update microblogpub locally, pull the remote changes and run the `update` task to regeneratee the CSS and run any DB migrations.
|
To update microblogpub locally, pull the remote changes and run the `update` task to regenerate the CSS and run any DB migrations.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
@ -130,6 +136,11 @@ server {
|
|||||||
# [...]
|
# [...]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# This should be outside the `server` block
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionally, you can serve static files using NGINX directly, with an additional `location` block.
|
Optionally, you can serve static files using NGINX directly, with an additional `location` block.
|
||||||
@ -147,8 +158,33 @@ server {
|
|||||||
# path for static files
|
# path for static files
|
||||||
rewrite ^/static/(.*) /$1 break;
|
rewrite ^/static/(.*) /$1 break;
|
||||||
root /path/to/your-domain.tld/app/static/;
|
root /path/to/your-domain.tld/app/static/;
|
||||||
|
expires 1y;
|
||||||
}
|
}
|
||||||
|
|
||||||
# [...]
|
# [...]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### NGINX config tips
|
||||||
|
|
||||||
|
Enable HTTP2 (which is disabled by default):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
# [...]
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tweak `/etc/nginx/nginx.conf` and add gzip compression for ActivityPub responses:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
http {
|
||||||
|
# [...]
|
||||||
|
gzip_types text/plain text/css application/json application/javascript application/activity+json application/octet-stream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## YunoHost edition
|
||||||
|
|
||||||
|
[YunoHost](https://yunohost.org/) support is a work in progress.
|
||||||
|
@ -31,6 +31,53 @@ You can tweak your profile by tweaking these items:
|
|||||||
|
|
||||||
Whenever one of these config items is updated, an `Update` activity will be sent to all know server to update your remote profile.
|
Whenever one of these config items is updated, an `Update` activity will be sent to all know server to update your remote profile.
|
||||||
|
|
||||||
|
The server will need to be restarted for taking changes into account.
|
||||||
|
|
||||||
|
### Profile metadata
|
||||||
|
|
||||||
|
You can add metadata to your profile with the `metadata` config item.
|
||||||
|
|
||||||
|
Markdown is supported in the `value` field.
|
||||||
|
|
||||||
|
Be aware that most other softwares like Mastodon will limit the number of key/value to 4.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
metadata = [
|
||||||
|
{key = "Documentation", value = "[https://docs.microblog.pub](https://docs.microblog.pub)"},
|
||||||
|
{key = "Source code", value = "[https://sr.ht/~tsileo/microblog.pub/](https://sr.ht/~tsileo/microblog.pub/)"},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manually approving followers
|
||||||
|
|
||||||
|
If you wish to manually approve followers, add this config item to `profile.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
manually_approves_followers = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default value is `false`.
|
||||||
|
|
||||||
|
### Hiding followers
|
||||||
|
|
||||||
|
If you wish to hide your followers, add this config item to `profile.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
hides_followers = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default value is `false`.
|
||||||
|
|
||||||
|
### Hiding following
|
||||||
|
|
||||||
|
If you wish to hide your following, add this config item to `profile.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
hides_following = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default value is `false`.
|
||||||
|
|
||||||
### Privacy replace
|
### Privacy replace
|
||||||
|
|
||||||
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
|
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
|
||||||
@ -41,6 +88,7 @@ To do so, just add as these extra config items, this is a sample config that rew
|
|||||||
```toml
|
```toml
|
||||||
privacy_replace = [
|
privacy_replace = [
|
||||||
{domain = "youtube.com", replace_by = "yewtu.be"},
|
{domain = "youtube.com", replace_by = "yewtu.be"},
|
||||||
|
{domain = "youtu.be", replace_by = "yewtu.be"},
|
||||||
{domain = "twitter.com", replace_by = "nitter.fdn.fr"},
|
{domain = "twitter.com", replace_by = "nitter.fdn.fr"},
|
||||||
{domain = "medium.com", replace_by = "scribe.rip"},
|
{domain = "medium.com", replace_by = "scribe.rip"},
|
||||||
{domain = "reddit.com", replace_by = "teddit.net"},
|
{domain = "reddit.com", replace_by = "teddit.net"},
|
||||||
@ -49,6 +97,16 @@ privacy_replace = [
|
|||||||
|
|
||||||
### Customization
|
### Customization
|
||||||
|
|
||||||
|
#### Default emoji
|
||||||
|
|
||||||
|
If you don't like cats, or need more emoji, you can add your favorite emoji in `profile.toml` and it will replace the default ones:
|
||||||
|
|
||||||
|
```
|
||||||
|
emoji = "🙂🐹📌"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can copy/paste them from [getemoji.com](https://getemoji.com/).
|
||||||
|
|
||||||
#### Custom emoji
|
#### Custom emoji
|
||||||
|
|
||||||
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
|
You can add custom emoji in the `data/custom_emoji` directory and they will be picked automatically.
|
||||||
@ -66,6 +124,30 @@ $secondary-color: #32cd32;
|
|||||||
|
|
||||||
See `app/scss/main.scss` to see what variables can be overidden.
|
See `app/scss/main.scss` to see what variables can be overidden.
|
||||||
|
|
||||||
|
#### Code highlighting theme
|
||||||
|
|
||||||
|
You can switch to one of the [styles supported by Pygments](https://pygments.org/styles/) by adding a line in `profile.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
code_highlighting_theme = "solarized-dark"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocking servers
|
||||||
|
|
||||||
|
In addition to blocking "single actors" via the admin interface, you can also prevent any communications with whole servers.
|
||||||
|
|
||||||
|
Add a `blocked_servers` config item into `profile.toml`.
|
||||||
|
|
||||||
|
The `reason` field is just there to help you document/remember why a server was blocked.
|
||||||
|
|
||||||
|
You should unfollow any account from a server before blocking it.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
blocked_servers = [
|
||||||
|
{hostname = "bad.tld", reason = "Bot spam"},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
## Public website
|
## Public website
|
||||||
|
|
||||||
Public notes will be visible on the homepage.
|
Public notes will be visible on the homepage.
|
||||||
@ -129,21 +211,22 @@ microblog.pub supports the most common interactions supported by the Fediverse.
|
|||||||
|
|
||||||
### Shares
|
### Shares
|
||||||
|
|
||||||
Sharing an object will relay it to your followers and notify the author.
|
Sharing (or announcing) an object will relay it to your followers and notify the author.
|
||||||
It will also be displayed on the homepage.
|
It will also be displayed on the homepage.
|
||||||
|
|
||||||
Most receiving servers will increment the number of shares.
|
Most receiving servers will increment the number of shares.
|
||||||
|
|
||||||
TODO receiving
|
Receiving a share will trigger a notification, increment the shares counter on the object and the actor avatar will be displayed on the object permalink.
|
||||||
|
|
||||||
### Likes
|
### Likes
|
||||||
|
|
||||||
Liking an object will notify the author.
|
Liking an object will notify the author.
|
||||||
Unkike sharing, liked object are not displayed on the homepage.
|
|
||||||
|
Unlike sharing, liked object are not displayed on the homepage.
|
||||||
|
|
||||||
Most receiving servers will increment the number of likes.
|
Most receiving servers will increment the number of likes.
|
||||||
|
|
||||||
TODO receiving
|
Receiving a like will trigger a notification, increment the likes counter on the object and the actor avatar will be displayed on the object permalink.
|
||||||
|
|
||||||
### Bookmarks
|
### Bookmarks
|
||||||
|
|
||||||
@ -151,13 +234,13 @@ Bookmarks allow you to like objects without notifying the author.
|
|||||||
|
|
||||||
It is basically a "private like", and allow you to easily access them later.
|
It is basically a "private like", and allow you to easily access them later.
|
||||||
|
|
||||||
TODO receiving
|
It will also prevent objects to be pruned.
|
||||||
|
|
||||||
### Webmentions
|
### Webmentions
|
||||||
|
|
||||||
Sending webmention to ping mentioned website is done automatically once a note is authored, see TODO.
|
Sending webmention to ping mentioned websites is done automatically once a public note is authored.
|
||||||
|
|
||||||
TODO side-effect of receiving a webmention.
|
Receiving a webmention will trigger a notification, increment the webmentions counter on the object and the source page will be displayed on the object permalink.
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
@ -171,3 +254,137 @@ All the data generated by the server is located in the `data/` directory:
|
|||||||
- Uploaded media
|
- Uploaded media
|
||||||
|
|
||||||
Restoring is as easy as adding your backed up `data/` directory into a fresh deployment.
|
Restoring is as easy as adding your backed up `data/` directory into a fresh deployment.
|
||||||
|
|
||||||
|
## Moving from another instance
|
||||||
|
|
||||||
|
If you want to move followers from your existing account, ensure it is supported in your software documentation.
|
||||||
|
|
||||||
|
For [Mastodon you can look at Moving or leaving accounts](https://docs.joinmastodon.org/user/moving/).
|
||||||
|
|
||||||
|
If you wish to move **to** another instance, see [Moving to another instance](/user_guide.html#moving-to-another-instance).
|
||||||
|
|
||||||
|
First you need to grab the "ActivityPub actor URL" for your existing account:
|
||||||
|
|
||||||
|
### Python edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For a Python install
|
||||||
|
poetry run inv webfinger username@domain.tld
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the config.
|
||||||
|
|
||||||
|
### Docker edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For a Docker install
|
||||||
|
make account=username@domain.tld webfinger
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the config.
|
||||||
|
|
||||||
|
#### Edit the config
|
||||||
|
|
||||||
|
And add a reference to your old/existing account in `profile.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
also_known_as = "my@old-account.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the server, and you should be able to complete the move from your existing account.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Pruning old data
|
||||||
|
|
||||||
|
You should prune old data from time to time to free disk space.
|
||||||
|
|
||||||
|
The default retention for the inbox data is 15 days.
|
||||||
|
|
||||||
|
It's configurable via the `inbox_retention_days` config item in `profile.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
inbox_retention_days = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Data owned by the server will never be deleted (at least for now), along with:
|
||||||
|
|
||||||
|
- bookmarked objects
|
||||||
|
- liked objects
|
||||||
|
- shared objects
|
||||||
|
- inbox objects mentioning the local actor
|
||||||
|
- objects related to local conversations (i.e. direct messages, replies)
|
||||||
|
|
||||||
|
For now, it's recommended to make a backup before running the task in case it deletes unwanted data.
|
||||||
|
|
||||||
|
You should shutdown the server before running the task.
|
||||||
|
|
||||||
|
#### Python edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# shutdown supervisord
|
||||||
|
cp -r data/microblogpub.db data/microblogpub.db.bak
|
||||||
|
poetry run inv prune-old-data
|
||||||
|
# relaunch supervisord and ensure it works as expected
|
||||||
|
rm data/microblogpub.db.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop
|
||||||
|
cp -r data/microblogpub.db data/microblogpub.db.bak
|
||||||
|
make prune-old-data
|
||||||
|
docker compose up -d
|
||||||
|
rm data/microblogpub.db.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Moving to another instance
|
||||||
|
|
||||||
|
If you want to migrate to another instance, you have the ability to move your existing followers to your new account.
|
||||||
|
|
||||||
|
Your new account should reference the existing one, refer to your software configuration (for example [Moving or leaving accounts from the Mastodon doc](https://docs.joinmastodon.org/user/moving/)).
|
||||||
|
|
||||||
|
If you wish to move **from** another instance, see [Moving from another instance](/user_guide.html#moving-from-another-instance).
|
||||||
|
|
||||||
|
Execute the Move task:
|
||||||
|
|
||||||
|
#### Python edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For a Python install
|
||||||
|
poetry run inv move-to username@domain.tld
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For a Docker install
|
||||||
|
make account=username@domain.tld move-to
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting the instance
|
||||||
|
|
||||||
|
If you want to delete your instance, you can request other instances to delete your remote profile.
|
||||||
|
|
||||||
|
Note that this is a best-effort delete as some instances may not delete your data.
|
||||||
|
|
||||||
|
The command won't remove any local data, it just broadcasts account deletion messages to all known servers.
|
||||||
|
|
||||||
|
After executing the command, you should let the server run until all the outgoing delete tasks are sent.
|
||||||
|
|
||||||
|
Once deleted, you won't be able to use your instance anymore, but you will be able to perform a fresh re-install of any ActivityPub software.
|
||||||
|
|
||||||
|
#### Python edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For a Python install
|
||||||
|
poetry run inv self-destruct
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker edition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For a Docker install
|
||||||
|
make self-destruct
|
||||||
|
```
|
||||||
|
@ -4,11 +4,10 @@ logfile=/dev/null
|
|||||||
logfile_maxbytes=0
|
logfile_maxbytes=0
|
||||||
pidfile=data/supervisord.pid
|
pidfile=data/supervisord.pid
|
||||||
|
|
||||||
[fcgi-program:uvicorn]
|
[program:uvicorn]
|
||||||
socket=tcp://0.0.0.0:8000
|
command=uvicorn app.main:app --no-server-header --host 0.0.0.0
|
||||||
command=uvicorn app.main:app --no-server-header --fd 0
|
numprocs=1
|
||||||
numprocs=2
|
autorestart=true
|
||||||
process_name=uvicorn-%(process_num)d
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=data/uvicorn.log
|
stdout_logfile=data/uvicorn.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=50MB
|
||||||
@ -16,6 +15,7 @@ stdout_logfile_maxbytes=50MB
|
|||||||
[program:incoming_worker]
|
[program:incoming_worker]
|
||||||
command=inv process-incoming-activities
|
command=inv process-incoming-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
|
autorestart=true
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=data/incoming.log
|
stdout_logfile=data/incoming.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=50MB
|
||||||
@ -23,6 +23,7 @@ stdout_logfile_maxbytes=50MB
|
|||||||
[program:outgoing_worker]
|
[program:outgoing_worker]
|
||||||
command=inv process-outgoing-activities
|
command=inv process-outgoing-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
|
autorestart=true
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=data/outgoing.log
|
stdout_logfile=data/outgoing.log
|
||||||
stdout_logfile_maxbytes=50MB
|
stdout_logfile_maxbytes=50MB
|
||||||
|
@ -1,24 +1,25 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
|
|
||||||
[fcgi-program:uvicorn]
|
[program:uvicorn]
|
||||||
socket=tcp://localhost:8000
|
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
|
||||||
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0
|
numprocs=1
|
||||||
numprocs=2
|
autorestart=true
|
||||||
process_name=uvicorn-%(process_num)d
|
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=uvicorn.log
|
stdout_logfile=uvicorn.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=50MB
|
||||||
|
|
||||||
[program:incoming_worker]
|
[program:incoming_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
|
autorestart=true
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=incoming_worker.log
|
stdout_logfile=incoming_worker.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=50MB
|
||||||
|
|
||||||
[program:outgoing_worker]
|
[program:outgoing_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
|
autorestart=true
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=outgoing_worker.log
|
stdout_logfile=outgoing_worker.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=50MB
|
||||||
|
@ -1,24 +1,26 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
|
|
||||||
[fcgi-program:uvicorn]
|
[program:uvicorn]
|
||||||
socket=tcp://localhost:%(ENV_UVICORN_PORT)s
|
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header
|
||||||
command=%(ENV_VENV_DIR)s/bin/uvicorn app.main:app --no-server-header --fd 0
|
numprocs=1
|
||||||
numprocs=2
|
autorestart=true
|
||||||
process_name=uvicorn-%(process_num)d
|
process_name=uvicorn-%(process_num)d
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=uvicorn.log
|
stdout_logfile=%(ENV_LOG_PATH)s/uvicorn.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:incoming_worker]
|
[program:incoming_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-incoming-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
|
autorestart=true
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=incoming_worker.log
|
stdout_logfile=%(ENV_LOG_PATH)s/incoming.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:outgoing_worker]
|
[program:outgoing_worker]
|
||||||
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
command=%(ENV_VENV_DIR)s/bin/inv process-outgoing-activities
|
||||||
numproc=1
|
numproc=1
|
||||||
|
autorestart=true
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
||||||
stdout_logfile=outgoing_worker.log
|
stdout_logfile=%(ENV_LOG_PATH)s/outgoing.log
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
|
588
poetry.lock
generated
588
poetry.lock
generated
@ -52,14 +52,6 @@ python-versions = ">=3.7"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomicwrites"
|
|
||||||
version = "1.4.1"
|
|
||||||
description = "Atomic file writes."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "attrs"
|
name = "attrs"
|
||||||
version = "22.1.0"
|
version = "22.1.0"
|
||||||
@ -106,7 +98,7 @@ lxml = ["lxml"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "22.6.0"
|
version = "22.8.0"
|
||||||
description = "The uncompromising code formatter."
|
description = "The uncompromising code formatter."
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@ -176,6 +168,14 @@ watchdog = ">=0.8.3"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
dev = ["flake8", "pytest", "sphinx", "sphinx-rtd-theme", "livereload", "twine", "packaging"]
|
dev = ["flake8", "pytest", "sphinx", "sphinx-rtd-theme", "livereload", "twine", "packaging"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.0.9"
|
||||||
|
description = "Python bindings for the Brotli compression library"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bs4"
|
name = "bs4"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@ -246,7 +246,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorlog"
|
name = "colorlog"
|
||||||
version = "6.6.0"
|
version = "6.7.0"
|
||||||
description = "Add colours to the output of Python's logging module."
|
description = "Add colours to the output of Python's logging module."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -286,7 +286,7 @@ doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faker"
|
name = "faker"
|
||||||
version = "14.1.0"
|
version = "14.2.0"
|
||||||
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
|
||||||
@ -348,7 +348,7 @@ python-versions = ">=3.6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "1.1.2"
|
version = "1.1.3"
|
||||||
description = "Lightweight in-process concurrent programming"
|
description = "Lightweight in-process concurrent programming"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -429,6 +429,17 @@ sniffio = ">=1.0.0,<2.0.0"
|
|||||||
http2 = ["h2 (>=3,<5)"]
|
http2 = ["h2 (>=3,<5)"]
|
||||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.4.0"
|
||||||
|
description = "A collection of framework independent HTTP protocol utils."
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.5.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
test = ["Cython (>=0.29.24,<0.30.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx"
|
name = "httpx"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@ -571,7 +582,7 @@ source = ["Cython (>=0.29.7)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
|
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -676,11 +687,11 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "0.9.0"
|
version = "0.10.1"
|
||||||
description = "Utility library for gitignore style pattern matching of file paths."
|
description = "Utility library for gitignore style pattern matching of file paths."
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
@ -720,7 +731,7 @@ testing = ["pytest", "pytest-benchmark"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prompt-toolkit"
|
name = "prompt-toolkit"
|
||||||
version = "3.0.30"
|
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
|
||||||
@ -774,18 +785,18 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "1.9.2"
|
version = "1.10.2"
|
||||||
description = "Data validation and settings management using python type hints"
|
description = "Data validation and settings management using python type hints"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6.1"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
typing-extensions = ">=3.7.4.3"
|
typing-extensions = ">=4.1.0"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
email = ["email-validator (>=1.0.3)"]
|
|
||||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||||
|
email = ["email-validator (>=1.0.3)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyflakes"
|
name = "pyflakes"
|
||||||
@ -838,14 +849,13 @@ diagrams = ["railroad-diagrams", "jinja2"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "7.1.2"
|
version = "7.1.3"
|
||||||
description = "pytest: simple powerful testing with Python"
|
description = "pytest: simple powerful testing with Python"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
|
||||||
attrs = ">=19.2.0"
|
attrs = ">=19.2.0"
|
||||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||||
iniconfig = "*"
|
iniconfig = "*"
|
||||||
@ -882,6 +892,17 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
six = ">=1.5"
|
six = ">=1.5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "0.21.0"
|
||||||
|
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
cli = ["click (>=5.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-multipart"
|
name = "python-multipart"
|
||||||
version = "0.0.5"
|
version = "0.0.5"
|
||||||
@ -954,11 +975,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
description = "Sniff out which async library your code is running under"
|
description = "Sniff out which async library your code is running under"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.5"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "soupsieve"
|
name = "soupsieve"
|
||||||
@ -1002,7 +1023,7 @@ sqlcipher = ["sqlcipher3-binary"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy2-stubs"
|
name = "sqlalchemy2-stubs"
|
||||||
version = "0.0.2a25"
|
version = "0.0.2a27"
|
||||||
description = "Typing Stubs for SQLAlchemy 1.4"
|
description = "Typing Stubs for SQLAlchemy 1.4"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1089,7 +1110,7 @@ python-versions = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-markdown"
|
name = "types-markdown"
|
||||||
version = "3.4.0"
|
version = "3.4.1"
|
||||||
description = "Typing stubs for Markdown"
|
description = "Typing stubs for Markdown"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1148,7 +1169,7 @@ python-versions = ">=3.7"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "1.26.11"
|
version = "1.26.12"
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1156,24 +1177,43 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*,
|
|||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
|
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
|
||||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"]
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.17.6"
|
version = "0.18.3"
|
||||||
description = "The lightning-fast ASGI server."
|
description = "The lightning-fast ASGI server."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
asgiref = ">=3.4.0"
|
|
||||||
click = ">=7.0"
|
click = ">=7.0"
|
||||||
|
colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
|
||||||
h11 = ">=0.8"
|
h11 = ">=0.8"
|
||||||
|
httptools = {version = ">=0.4.0", optional = true, markers = "extra == \"standard\""}
|
||||||
|
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
|
||||||
|
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
|
||||||
|
uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
|
||||||
|
watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
|
||||||
|
websockets = {version = ">=10.0", optional = true, markers = "extra == \"standard\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
|
standard = ["colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.16.0"
|
||||||
|
description = "Fast implementation of asyncio event loop on top of libuv"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
|
||||||
|
docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
|
||||||
|
test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchdog"
|
name = "watchdog"
|
||||||
@ -1186,6 +1226,17 @@ python-versions = ">=3.6"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
watchmedo = ["PyYAML (>=3.10)"]
|
watchmedo = ["PyYAML (>=3.10)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "0.16.1"
|
||||||
|
description = "Simple, modern and high performance file watching and code reload in python."
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
anyio = ">=3.0.0,<4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wcwidth"
|
name = "wcwidth"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@ -1202,6 +1253,14 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "10.3"
|
||||||
|
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "win32-setctime"
|
name = "win32-setctime"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -1216,7 +1275,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 = "5ee42d968baa21950366d1ca0597fb1e0e45e6e26005f93acbcf8b43cd1fb370"
|
content-hash = "be26936c88285524ab781d4130c8a97fc940d70dd0a88bf37c61d7db5bdbd232"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiosqlite = [
|
aiosqlite = [
|
||||||
@ -1232,7 +1291,6 @@ asgiref = [
|
|||||||
{file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
|
{file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
|
||||||
{file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
|
{file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
|
||||||
]
|
]
|
||||||
atomicwrites = []
|
|
||||||
attrs = [
|
attrs = [
|
||||||
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
|
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
|
||||||
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
|
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
|
||||||
@ -1255,29 +1313,29 @@ beautifulsoup4 = [
|
|||||||
{file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"},
|
{file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"},
|
||||||
]
|
]
|
||||||
black = [
|
black = [
|
||||||
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
|
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"},
|
||||||
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
|
{file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"},
|
||||||
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
|
{file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"},
|
||||||
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
|
{file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"},
|
||||||
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
|
{file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"},
|
||||||
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
|
{file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"},
|
||||||
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
|
{file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"},
|
||||||
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
|
{file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"},
|
||||||
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
|
{file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"},
|
||||||
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
|
{file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"},
|
||||||
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
|
{file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"},
|
||||||
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
|
{file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"},
|
||||||
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
|
{file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"},
|
||||||
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
|
{file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"},
|
||||||
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
|
{file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"},
|
||||||
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
|
{file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"},
|
||||||
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
|
{file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"},
|
||||||
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
|
{file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"},
|
||||||
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
|
{file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"},
|
||||||
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
|
{file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"},
|
||||||
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
|
{file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"},
|
||||||
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
|
{file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"},
|
||||||
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
|
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"},
|
||||||
]
|
]
|
||||||
bleach = [
|
bleach = [
|
||||||
{file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"},
|
{file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"},
|
||||||
@ -1299,6 +1357,70 @@ blurhash-python = [
|
|||||||
boussole = [
|
boussole = [
|
||||||
{file = "boussole-2.0.0.tar.gz", hash = "sha256:e4907180698339c778669d71b16a77b8d54c97d54e79d7813de1630a9d091a2f"},
|
{file = "boussole-2.0.0.tar.gz", hash = "sha256:e4907180698339c778669d71b16a77b8d54c97d54e79d7813de1630a9d091a2f"},
|
||||||
]
|
]
|
||||||
|
brotli = [
|
||||||
|
{file = "Brotli-1.0.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70"},
|
||||||
|
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b"},
|
||||||
|
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6"},
|
||||||
|
{file = "Brotli-1.0.9-cp27-cp27m-win32.whl", hash = "sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa"},
|
||||||
|
{file = "Brotli-1.0.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452"},
|
||||||
|
{file = "Brotli-1.0.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9744a863b489c79a73aba014df554b0e7a0fc44ef3f8a0ef2a52919c7d155031"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a72661af47119a80d82fa583b554095308d6a4c356b2a554fdc2799bc19f2a43"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ee83d3e3a024a9618e5be64648d6d11c37047ac48adff25f12fa4226cf23d1c"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19598ecddd8a212aedb1ffa15763dd52a388518c4550e615aed88dc3753c0f0c"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44bb8ff420c1d19d91d79d8c3574b8954288bdff0273bf788954064d260d7ab0"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e23281b9a08ec338469268f98f194658abfb13658ee98e2b7f85ee9dd06caa91"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3496fc835370da351d37cada4cf744039616a6db7d13c430035e901443a34daa"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83bb06a0192cccf1eb8d0a28672a1b79c74c3a8a5f2619625aeb6f28b3a82bb"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-win32.whl", hash = "sha256:26d168aac4aaec9a4394221240e8a5436b5634adc3cd1cdf637f6645cecbf181"},
|
||||||
|
{file = "Brotli-1.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:622a231b08899c864eb87e85f81c75e7b9ce05b001e59bbfbf43d4a71f5f32b2"},
|
||||||
|
{file = "Brotli-1.0.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4"},
|
||||||
|
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296"},
|
||||||
|
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430"},
|
||||||
|
{file = "Brotli-1.0.9-cp35-cp35m-win32.whl", hash = "sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1"},
|
||||||
|
{file = "Brotli-1.0.9-cp35-cp35m-win_amd64.whl", hash = "sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87fdccbb6bb589095f413b1e05734ba492c962b4a45a13ff3408fa44ffe6479b"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:6d847b14f7ea89f6ad3c9e3901d1bc4835f6b390a9c71df999b0162d9bb1e20f"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:495ba7e49c2db22b046a53b469bbecea802efce200dffb69b93dd47397edc9b6"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:4688c1e42968ba52e57d8670ad2306fe92e0169c6f3af0089be75bbac0c64a3b"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-win32.whl", hash = "sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14"},
|
||||||
|
{file = "Brotli-1.0.9-cp36-cp36m-win_amd64.whl", hash = "sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29d1d350178e5225397e28ea1b7aca3648fcbab546d20e7475805437bfb0a130"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7bbff90b63328013e1e8cb50650ae0b9bac54ffb4be6104378490193cd60f85a"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ec1947eabbaf8e0531e8e899fc1d9876c179fc518989461f5d24e2223395a9e3"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-win32.whl", hash = "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"},
|
||||||
|
{file = "Brotli-1.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e16eb9541f3dd1a3e92b89005e37b1257b157b7256df0e36bd7b33b50be73bcb"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a674ac10e0a87b683f4fa2b6fa41090edfd686a6524bd8dedbd6138b309175c"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e2d9e1cbc1b25e22000328702b014227737756f4b5bf5c485ac1d8091ada078b"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b336c5e9cf03c7be40c47b5fd694c43c9f1358a80ba384a21969e0b4e66a9b17"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:85f7912459c67eaab2fb854ed2bc1cc25772b300545fe7ed2dc03954da638649"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-win32.whl", hash = "sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429"},
|
||||||
|
{file = "Brotli-1.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2aad0e0baa04517741c9bb5b07586c642302e5fb3e75319cb62087bd0995ab19"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cb1e18167792d7d21e21365d7650b72d5081ed476123ff7b8cac7f45189c0c7"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bf919756d25e4114ace16a8ce91eb340eb57a08e2c6950c3cebcbe3dff2a5e7"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e4c4e92c14a57c9bd4cb4be678c25369bf7a092d55fd0866f759e425b9660806"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e48f4234f2469ed012a98f4b7874e7f7e173c167bed4934912a29e03167cf6b1"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ed4c92a0665002ff8ea852353aeb60d9141eb04109e88928026d3c8a9e5433c"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-win32.whl", hash = "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3"},
|
||||||
|
{file = "Brotli-1.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761"},
|
||||||
|
{file = "Brotli-1.0.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9749a124280a0ada4187a6cfd1ffd35c350fb3af79c706589d98e088c5044267"},
|
||||||
|
{file = "Brotli-1.0.9-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:76ffebb907bec09ff511bb3acc077695e2c32bc2142819491579a695f77ffd4d"},
|
||||||
|
{file = "Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438"},
|
||||||
|
]
|
||||||
bs4 = [
|
bs4 = [
|
||||||
{file = "bs4-0.0.1.tar.gz", hash = "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"},
|
{file = "bs4-0.0.1.tar.gz", hash = "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"},
|
||||||
]
|
]
|
||||||
@ -1389,8 +1511,8 @@ colorama = [
|
|||||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
||||||
]
|
]
|
||||||
colorlog = [
|
colorlog = [
|
||||||
{file = "colorlog-6.6.0-py2.py3-none-any.whl", hash = "sha256:351c51e866c86c3217f08e4b067a7974a678be78f07f85fc2d55b8babde6d94e"},
|
{file = "colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662"},
|
||||||
{file = "colorlog-6.6.0.tar.gz", hash = "sha256:344f73204009e4c83c5b6beb00b3c45dc70fcdae3c80db919e0a4171d006fde8"},
|
{file = "colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5"},
|
||||||
]
|
]
|
||||||
emoji = [
|
emoji = [
|
||||||
{file = "emoji-1.7.0.tar.gz", hash = "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1"},
|
{file = "emoji-1.7.0.tar.gz", hash = "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1"},
|
||||||
@ -1400,8 +1522,8 @@ factory-boy = [
|
|||||||
{file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
|
{file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
|
||||||
]
|
]
|
||||||
faker = [
|
faker = [
|
||||||
{file = "Faker-14.1.0-py3-none-any.whl", hash = "sha256:067a03f64e555261610e69277536072997b4576dbf84b113faef3c06d85b466b"},
|
{file = "Faker-14.2.0-py3-none-any.whl", hash = "sha256:e02c55a5b0586caaf913cc6c254b3de178e08b031c5922e590fd033ebbdbfd02"},
|
||||||
{file = "Faker-14.1.0.tar.gz", hash = "sha256:0e00bfa1eadf1493f15662edb181222fea4847764cf3f9ff3e66ee0f95c9a644"},
|
{file = "Faker-14.2.0.tar.gz", hash = "sha256:6db56e2c43a2b74250d1c332ef25fef7dc07dcb6c5fab5329dd7b4467b8ed7b9"},
|
||||||
]
|
]
|
||||||
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"},
|
||||||
@ -1416,61 +1538,60 @@ flake8 = [
|
|||||||
]
|
]
|
||||||
frozendict = []
|
frozendict = []
|
||||||
greenlet = [
|
greenlet = [
|
||||||
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
|
{file = "greenlet-1.1.3-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:8c287ae7ac921dfde88b1c125bd9590b7ec3c900c2d3db5197f1286e144e712b"},
|
||||||
{file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"},
|
{file = "greenlet-1.1.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:870a48007872d12e95a996fca3c03a64290d3ea2e61076aa35d3b253cf34cd32"},
|
||||||
{file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"},
|
{file = "greenlet-1.1.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7c5227963409551ae4a6938beb70d56bf1918c554a287d3da6853526212fbe0a"},
|
||||||
{file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"},
|
{file = "greenlet-1.1.3-cp27-cp27m-win32.whl", hash = "sha256:9fae214f6c43cd47f7bef98c56919b9222481e833be2915f6857a1e9e8a15318"},
|
||||||
{file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"},
|
{file = "greenlet-1.1.3-cp27-cp27m-win_amd64.whl", hash = "sha256:de431765bd5fe62119e0bc6bc6e7b17ac53017ae1782acf88fcf6b7eae475a49"},
|
||||||
{file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"},
|
{file = "greenlet-1.1.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:510c3b15587afce9800198b4b142202b323bf4b4b5f9d6c79cb9a35e5e3c30d2"},
|
||||||
{file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"},
|
{file = "greenlet-1.1.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:9951dcbd37850da32b2cb6e391f621c1ee456191c6ae5528af4a34afe357c30e"},
|
||||||
{file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"},
|
{file = "greenlet-1.1.3-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:07c58e169bbe1e87b8bbf15a5c1b779a7616df9fd3e61cadc9d691740015b4f8"},
|
||||||
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"},
|
{file = "greenlet-1.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df02fdec0c533301497acb0bc0f27f479a3a63dcdc3a099ae33a902857f07477"},
|
||||||
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"},
|
{file = "greenlet-1.1.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c88e134d51d5e82315a7c32b914a58751b7353eb5268dbd02eabf020b4c4700"},
|
||||||
{file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"},
|
{file = "greenlet-1.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b41d19c0cfe5c259fe6c539fd75051cd39a5d33d05482f885faf43f7f5e7d26"},
|
||||||
{file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"},
|
{file = "greenlet-1.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:6f5d4b2280ceea76c55c893827961ed0a6eadd5a584a7c4e6e6dd7bc10dfdd96"},
|
||||||
{file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"},
|
{file = "greenlet-1.1.3-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:184416e481295832350a4bf731ba619a92f5689bf5d0fa4341e98b98b1265bd7"},
|
||||||
{file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"},
|
{file = "greenlet-1.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd0404d154084a371e6d2bafc787201612a1359c2dee688ae334f9118aa0bf47"},
|
||||||
{file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"},
|
{file = "greenlet-1.1.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a43bbfa9b6cfdfaeefbd91038dde65ea2c421dc387ed171613df340650874f2"},
|
||||||
{file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"},
|
{file = "greenlet-1.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce5b64dfe8d0cca407d88b0ee619d80d4215a2612c1af8c98a92180e7109f4b5"},
|
||||||
{file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"},
|
{file = "greenlet-1.1.3-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:903fa5716b8fbb21019268b44f73f3748c41d1a30d71b4a49c84b642c2fed5fa"},
|
||||||
{file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"},
|
{file = "greenlet-1.1.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:0118817c9341ef2b0f75f5af79ac377e4da6ff637e5ee4ac91802c0e379dadb4"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"},
|
{file = "greenlet-1.1.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:466ce0928e33421ee84ae04c4ac6f253a3a3e6b8d600a79bd43fd4403e0a7a76"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"},
|
{file = "greenlet-1.1.3-cp35-cp35m-win32.whl", hash = "sha256:65ad1a7a463a2a6f863661329a944a5802c7129f7ad33583dcc11069c17e622c"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"},
|
{file = "greenlet-1.1.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7532a46505470be30cbf1dbadb20379fb481244f1ca54207d7df3bf0bbab6a20"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"},
|
{file = "greenlet-1.1.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:caff52cb5cd7626872d9696aee5b794abe172804beb7db52eed1fd5824b63910"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"},
|
{file = "greenlet-1.1.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:db41f3845eb579b544c962864cce2c2a0257fe30f0f1e18e51b1e8cbb4e0ac6d"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"},
|
{file = "greenlet-1.1.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:e8533f5111704d75de3139bf0b8136d3a6c1642c55c067866fa0a51c2155ee33"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"},
|
{file = "greenlet-1.1.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537e4baf0db67f382eb29255a03154fcd4984638303ff9baaa738b10371fa57"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"},
|
{file = "greenlet-1.1.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8bfd36f368efe0ab2a6aa3db7f14598aac454b06849fb633b762ddbede1db90"},
|
||||||
{file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"},
|
{file = "greenlet-1.1.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0877a9a2129a2c56a2eae2da016743db7d9d6a05d5e1c198f1b7808c602a30e"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"},
|
{file = "greenlet-1.1.3-cp36-cp36m-win32.whl", hash = "sha256:88b04e12c9b041a1e0bcb886fec709c488192638a9a7a3677513ac6ba81d8e79"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"},
|
{file = "greenlet-1.1.3-cp36-cp36m-win_amd64.whl", hash = "sha256:4f166b4aca8d7d489e82d74627a7069ab34211ef5ebb57c300ec4b9337b60fc0"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"},
|
{file = "greenlet-1.1.3-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:cd16a89efe3a003029c87ff19e9fba635864e064da646bc749fc1908a4af18f3"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"},
|
{file = "greenlet-1.1.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5b756e6730ea59b2745072e28ad27f4c837084688e6a6b3633c8b1e509e6ae0e"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"},
|
{file = "greenlet-1.1.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:9b2f7d0408ddeb8ea1fd43d3db79a8cefaccadd2a812f021333b338ed6b10aba"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"},
|
{file = "greenlet-1.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44b4817c34c9272c65550b788913620f1fdc80362b209bc9d7dd2f40d8793080"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"},
|
{file = "greenlet-1.1.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d58a5a71c4c37354f9e0c24c9c8321f0185f6945ef027460b809f4bb474bfe41"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"},
|
{file = "greenlet-1.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dd51d2650e70c6c4af37f454737bf4a11e568945b27f74b471e8e2a9fd21268"},
|
||||||
{file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"},
|
{file = "greenlet-1.1.3-cp37-cp37m-win32.whl", hash = "sha256:048d2bed76c2aa6de7af500ae0ea51dd2267aec0e0f2a436981159053d0bc7cc"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"},
|
{file = "greenlet-1.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:77e41db75f9958f2083e03e9dd39da12247b3430c92267df3af77c83d8ff9eed"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"},
|
{file = "greenlet-1.1.3-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:1626185d938d7381631e48e6f7713e8d4b964be246073e1a1d15c2f061ac9f08"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"},
|
{file = "greenlet-1.1.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:1ec2779774d8e42ed0440cf8bc55540175187e8e934f2be25199bf4ed948cd9e"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"},
|
{file = "greenlet-1.1.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f2f908239b7098799b8845e5936c2ccb91d8c2323be02e82f8dcb4a80dcf4a25"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"},
|
{file = "greenlet-1.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b181e9aa6cb2f5ec0cacc8cee6e5a3093416c841ba32c185c30c160487f0380"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"},
|
{file = "greenlet-1.1.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cf45e339cabea16c07586306a31cfcc5a3b5e1626d365714d283732afed6809"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"},
|
{file = "greenlet-1.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6200a11f003ec26815f7e3d2ded01b43a3810be3528dd760d2f1fa777490c3cd"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"},
|
{file = "greenlet-1.1.3-cp38-cp38-win32.whl", hash = "sha256:db5b25265010a1b3dca6a174a443a0ed4c4ab12d5e2883a11c97d6e6d59b12f9"},
|
||||||
{file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"},
|
{file = "greenlet-1.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:095a980288fe05adf3d002fbb180c99bdcf0f930e220aa66fcd56e7914a38202"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"},
|
{file = "greenlet-1.1.3-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:cbc1eb55342cbac8f7ec159088d54e2cfdd5ddf61c87b8bbe682d113789331b2"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"},
|
{file = "greenlet-1.1.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:694ffa7144fa5cc526c8f4512665003a39fa09ef00d19bbca5c8d3406db72fbe"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"},
|
{file = "greenlet-1.1.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:aa741c1a8a8cc25eb3a3a01a62bdb5095a773d8c6a86470bde7f607a447e7905"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"},
|
{file = "greenlet-1.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3a669f11289a8995d24fbfc0e63f8289dd03c9aaa0cc8f1eab31d18ca61a382"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"},
|
{file = "greenlet-1.1.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76a53bfa10b367ee734b95988bd82a9a5f0038a25030f9f23bbbc005010ca600"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"},
|
{file = "greenlet-1.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fb0aa7f6996879551fd67461d5d3ab0c3c0245da98be90c89fcb7a18d437403"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"},
|
{file = "greenlet-1.1.3-cp39-cp39-win32.whl", hash = "sha256:5fbe1ab72b998ca77ceabbae63a9b2e2dc2d963f4299b9b278252ddba142d3f1"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"},
|
{file = "greenlet-1.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:ffe73f9e7aea404722058405ff24041e59d31ca23d1da0895af48050a07b6932"},
|
||||||
{file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"},
|
{file = "greenlet-1.1.3.tar.gz", hash = "sha256:bcb6c6dd1d6be6d38d6db283747d07fda089ff8c559a835236560a4410340455"},
|
||||||
{file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"},
|
|
||||||
]
|
]
|
||||||
h11 = [
|
h11 = [
|
||||||
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
|
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
|
||||||
@ -1490,6 +1611,42 @@ httpcore = [
|
|||||||
{file = "httpcore-0.15.0-py3-none-any.whl", hash = "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6"},
|
{file = "httpcore-0.15.0-py3-none-any.whl", hash = "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6"},
|
||||||
{file = "httpcore-0.15.0.tar.gz", hash = "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b"},
|
{file = "httpcore-0.15.0.tar.gz", hash = "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b"},
|
||||||
]
|
]
|
||||||
|
httptools = [
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5"},
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23"},
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed"},
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683"},
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c"},
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919"},
|
||||||
|
{file = "httptools-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe"},
|
||||||
|
{file = "httptools-0.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd"},
|
||||||
|
{file = "httptools-0.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c"},
|
||||||
|
{file = "httptools-0.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e"},
|
||||||
|
{file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d"},
|
||||||
|
{file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae"},
|
||||||
|
{file = "httptools-0.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777"},
|
||||||
|
{file = "httptools-0.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111"},
|
||||||
|
{file = "httptools-0.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1"},
|
||||||
|
{file = "httptools-0.4.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0"},
|
||||||
|
{file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af"},
|
||||||
|
{file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4"},
|
||||||
|
{file = "httptools-0.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409"},
|
||||||
|
{file = "httptools-0.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d"},
|
||||||
|
{file = "httptools-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83"},
|
||||||
|
{file = "httptools-0.4.0.tar.gz", hash = "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff"},
|
||||||
|
]
|
||||||
httpx = [
|
httpx = [
|
||||||
{file = "httpx-0.23.0-py3-none-any.whl", hash = "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b"},
|
{file = "httpx-0.23.0-py3-none-any.whl", hash = "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b"},
|
||||||
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
|
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
|
||||||
@ -1612,8 +1769,8 @@ lxml = [
|
|||||||
{file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"},
|
{file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"},
|
||||||
]
|
]
|
||||||
mako = [
|
mako = [
|
||||||
{file = "Mako-1.2.1-py3-none-any.whl", hash = "sha256:df3921c3081b013c8a2d5ff03c18375651684921ae83fd12e64800b7da923257"},
|
{file = "Mako-1.2.2-py3-none-any.whl", hash = "sha256:8efcb8004681b5f71d09c983ad5a9e6f5c40601a6ec469148753292abc0da534"},
|
||||||
{file = "Mako-1.2.1.tar.gz", hash = "sha256:f054a5ff4743492f1aa9ecc47172cb33b42b9d993cffcc146c9de17e717b0307"},
|
{file = "Mako-1.2.2.tar.gz", hash = "sha256:3724869b363ba630a272a5f89f68c070352137b8fd1757650017b7e06fda163f"},
|
||||||
]
|
]
|
||||||
markdown = []
|
markdown = []
|
||||||
markupsafe = [
|
markupsafe = [
|
||||||
@ -1702,8 +1859,8 @@ packaging = [
|
|||||||
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
||||||
]
|
]
|
||||||
pathspec = [
|
pathspec = [
|
||||||
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
|
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
|
||||||
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
|
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
|
||||||
]
|
]
|
||||||
pillow = [
|
pillow = [
|
||||||
{file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"},
|
{file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"},
|
||||||
@ -1774,8 +1931,8 @@ pluggy = [
|
|||||||
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
||||||
]
|
]
|
||||||
prompt-toolkit = [
|
prompt-toolkit = [
|
||||||
{file = "prompt_toolkit-3.0.30-py3-none-any.whl", hash = "sha256:d8916d3f62a7b67ab353a952ce4ced6a1d2587dfe9ef8ebc30dd7c386751f289"},
|
{file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"},
|
||||||
{file = "prompt_toolkit-3.0.30.tar.gz", hash = "sha256:859b283c50bde45f5f97829f77a4674d1c1fcd88539364f1b28a37805cfd89c0"},
|
{file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"},
|
||||||
]
|
]
|
||||||
py = [
|
py = [
|
||||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
||||||
@ -1826,41 +1983,42 @@ pycryptodome = [
|
|||||||
{file = "pycryptodome-3.15.0.tar.gz", hash = "sha256:9135dddad504592bcc18b0d2d95ce86c3a5ea87ec6447ef25cfedea12d6018b8"},
|
{file = "pycryptodome-3.15.0.tar.gz", hash = "sha256:9135dddad504592bcc18b0d2d95ce86c3a5ea87ec6447ef25cfedea12d6018b8"},
|
||||||
]
|
]
|
||||||
pydantic = [
|
pydantic = [
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c9e04a6cdb7a363d7cb3ccf0efea51e0abb48e180c0d31dca8d247967d85c6e"},
|
{file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"},
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafe841be1103f340a24977f61dee76172e4ae5f647ab9e7fd1e1fca51524f08"},
|
{file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"},
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afacf6d2a41ed91fc631bade88b1d319c51ab5418870802cedb590b709c5ae3c"},
|
{file = "pydantic-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:352aedb1d71b8b0736c6d56ad2bd34c6982720644b0624462059ab29bd6e5912"},
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ee0d69b2a5b341fc7927e92cae7ddcfd95e624dfc4870b32a85568bd65e6131"},
|
{file = "pydantic-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19b3b9ccf97af2b7519c42032441a891a5e05c68368f40865a90eb88833c2559"},
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ff68fc85355532ea77559ede81f35fff79a6a5543477e168ab3a381887caea76"},
|
{file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9069e1b01525a96e6ff49e25876d90d5a563bc31c658289a8772ae186552236"},
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c0f5e142ef8217019e3eef6ae1b6b55f09a7a15972958d44fbd228214cede567"},
|
{file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:355639d9afc76bcb9b0c3000ddcd08472ae75318a6eb67a15866b87e2efa168c"},
|
||||||
{file = "pydantic-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:615661bfc37e82ac677543704437ff737418e4ea04bef9cf11c6d27346606044"},
|
{file = "pydantic-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae544c47bec47a86bc7d350f965d8b15540e27e5aa4f55170ac6a75e5f73b644"},
|
||||||
{file = "pydantic-1.9.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:328558c9f2eed77bd8fffad3cef39dbbe3edc7044517f4625a769d45d4cf7555"},
|
{file = "pydantic-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4c805731c33a8db4b6ace45ce440c4ef5336e712508b4d9e1aafa617dc9907f"},
|
||||||
{file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd446bdb7755c3a94e56d7bdfd3ee92396070efa8ef3a34fab9579fe6aa1d84"},
|
{file = "pydantic-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d49f3db871575e0426b12e2f32fdb25e579dea16486a26e5a0474af87cb1ab0a"},
|
||||||
{file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0b214e57623a535936005797567231a12d0da0c29711eb3514bc2b3cd008d0f"},
|
{file = "pydantic-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c90345ec7dd2f1bcef82ce49b6235b40f282b94d3eec47e801baf864d15525"},
|
||||||
{file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d8ce3fb0841763a89322ea0432f1f59a2d3feae07a63ea2c958b2315e1ae8adb"},
|
{file = "pydantic-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b5ba54d026c2bd2cb769d3468885f23f43710f651688e91f5fb1edcf0ee9283"},
|
||||||
{file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b34ba24f3e2d0b39b43f0ca62008f7ba962cff51efa56e64ee25c4af6eed987b"},
|
{file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05e00dbebbe810b33c7a7362f231893183bcc4251f3f2ff991c31d5c08240c42"},
|
||||||
{file = "pydantic-1.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:84d76ecc908d917f4684b354a39fd885d69dd0491be175f3465fe4b59811c001"},
|
{file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2d0567e60eb01bccda3a4df01df677adf6b437958d35c12a3ac3e0f078b0ee52"},
|
||||||
{file = "pydantic-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4de71c718c9756d679420c69f216776c2e977459f77e8f679a4a961dc7304a56"},
|
{file = "pydantic-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:c6f981882aea41e021f72779ce2a4e87267458cc4d39ea990729e21ef18f0f8c"},
|
||||||
{file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5803ad846cdd1ed0d97eb00292b870c29c1f03732a010e66908ff48a762f20e4"},
|
{file = "pydantic-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4aac8e7103bf598373208f6299fa9a5cfd1fc571f2d40bf1dd1955a63d6eeb5"},
|
||||||
{file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8c5360a0297a713b4123608a7909e6869e1b56d0e96eb0d792c27585d40757f"},
|
{file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a7b66c3f499108b448f3f004801fcd7d7165fb4200acb03f1c2402da73ce4c"},
|
||||||
{file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:cdb4272678db803ddf94caa4f94f8672e9a46bae4a44f167095e4d06fec12979"},
|
{file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bedf309630209e78582ffacda64a21f96f3ed2e51fbf3962d4d488e503420254"},
|
||||||
{file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19b5686387ea0d1ea52ecc4cffb71abb21702c5e5b2ac626fd4dbaa0834aa49d"},
|
{file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9300fcbebf85f6339a02c6994b2eb3ff1b9c8c14f502058b5bf349d42447dcf5"},
|
||||||
{file = "pydantic-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e0b4fb13ad4db4058a7c3c80e2569adbd810c25e6ca3bbd8b2a9cc2cc871d7"},
|
{file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:216f3bcbf19c726b1cc22b099dd409aa371f55c08800bcea4c44c8f74b73478d"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91089b2e281713f3893cd01d8e576771cd5bfdfbff5d0ed95969f47ef6d676c3"},
|
{file = "pydantic-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dd3f9a40c16daf323cf913593083698caee97df2804aa36c4b3175d5ac1b92a2"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e631c70c9280e3129f071635b81207cad85e6c08e253539467e4ead0e5b219aa"},
|
{file = "pydantic-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b97890e56a694486f772d36efd2ba31612739bc6f3caeee50e9e7e3ebd2fdd13"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b3946f87e5cef3ba2e7bd3a4eb5a20385fe36521d6cc1ebf3c08a6697c6cfb3"},
|
{file = "pydantic-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9cabf4a7f05a776e7793e72793cd92cc865ea0e83a819f9ae4ecccb1b8aa6116"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5565a49effe38d51882cb7bac18bda013cdb34d80ac336428e8908f0b72499b0"},
|
{file = "pydantic-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06094d18dd5e6f2bbf93efa54991c3240964bb663b87729ac340eb5014310624"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bd67cb2c2d9602ad159389c29e4ca964b86fa2f35c2faef54c3eb28b4efd36c8"},
|
{file = "pydantic-1.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc78cc83110d2f275ec1970e7a831f4e371ee92405332ebfe9860a715f8336e1"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4aafd4e55e8ad5bd1b19572ea2df546ccace7945853832bb99422a79c70ce9b8"},
|
{file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ee433e274268a4b0c8fde7ad9d58ecba12b069a033ecc4645bb6303c062d2e9"},
|
||||||
{file = "pydantic-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:d70916235d478404a3fa8c997b003b5f33aeac4686ac1baa767234a0f8ac2326"},
|
{file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c2abc4393dea97a4ccbb4ec7d8658d4e22c4765b7b9b9445588f16c71ad9965"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ca86b525264daa5f6b192f216a0d1e860b7383e3da1c65a1908f9c02f42801"},
|
{file = "pydantic-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b959f4d8211fc964772b595ebb25f7652da3f22322c007b6fed26846a40685e"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1061c6ee6204f4f5a27133126854948e3b3d51fcc16ead2e5d04378c199b2f44"},
|
{file = "pydantic-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c33602f93bfb67779f9c507e4d69451664524389546bacfe1bee13cae6dc7488"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78578f0c7481c850d1c969aca9a65405887003484d24f6110458fb02cca7747"},
|
{file = "pydantic-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5760e164b807a48a8f25f8aa1a6d857e6ce62e7ec83ea5d5c5a802eac81bad41"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da164119602212a3fe7e3bc08911a89db4710ae51444b4224c2382fd09ad453"},
|
{file = "pydantic-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eb843dcc411b6a2237a694f5e1d649fc66c6064d02b204a7e9d194dff81eb4b"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ead3cd020d526f75b4188e0a8d71c0dbbe1b4b6b5dc0ea775a93aca16256aeb"},
|
{file = "pydantic-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b8795290deaae348c4eba0cebb196e1c6b98bdbe7f50b2d0d9a4a99716342fe"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7d0f183b305629765910eaad707800d2f47c6ac5bcfb8c6397abdc30b69eeb15"},
|
{file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e0bedafe4bc165ad0a56ac0bd7695df25c50f76961da29c050712596cf092d6d"},
|
||||||
{file = "pydantic-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1a68f4f65a9ee64b6ccccb5bf7e17db07caebd2730109cb8a95863cfa9c4e55"},
|
{file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e05aed07fa02231dbf03d0adb1be1d79cabb09025dd45aa094aa8b4e7b9dcda"},
|
||||||
{file = "pydantic-1.9.2-py3-none-any.whl", hash = "sha256:78a4d6bdfd116a559aeec9a4cfe77dda62acc6233f8b56a716edad2651023e5e"},
|
{file = "pydantic-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c1ba1afb396148bbc70e9eaa8c06c1716fdddabaf86e7027c5988bae2a829ab6"},
|
||||||
{file = "pydantic-1.9.2.tar.gz", hash = "sha256:8cb0bc509bfb71305d7a59d00163d5f9fc4530f0881ea32c74ff4f74c85f3d3d"},
|
{file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"},
|
||||||
|
{file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"},
|
||||||
]
|
]
|
||||||
pyflakes = [
|
pyflakes = [
|
||||||
{file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
|
{file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
|
||||||
@ -1878,8 +2036,8 @@ pyparsing = [
|
|||||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
||||||
]
|
]
|
||||||
pytest = [
|
pytest = [
|
||||||
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
|
{file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"},
|
||||||
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
|
{file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"},
|
||||||
]
|
]
|
||||||
pytest-asyncio = [
|
pytest-asyncio = [
|
||||||
{file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"},
|
{file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"},
|
||||||
@ -1890,6 +2048,10 @@ python-dateutil = [
|
|||||||
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
|
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
|
||||||
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
|
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
|
||||||
]
|
]
|
||||||
|
python-dotenv = [
|
||||||
|
{file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"},
|
||||||
|
{file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"},
|
||||||
|
]
|
||||||
python-multipart = [
|
python-multipart = [
|
||||||
{file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
|
{file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
|
||||||
]
|
]
|
||||||
@ -1945,8 +2107,8 @@ six = [
|
|||||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||||
]
|
]
|
||||||
sniffio = [
|
sniffio = [
|
||||||
{file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
|
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
|
||||||
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
|
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
|
||||||
]
|
]
|
||||||
soupsieve = [
|
soupsieve = [
|
||||||
{file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
|
{file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
|
||||||
@ -1991,8 +2153,8 @@ sqlalchemy = [
|
|||||||
{file = "SQLAlchemy-1.4.40.tar.gz", hash = "sha256:44a660506080cc975e1dfa5776fe5f6315ddc626a77b50bf0eee18b0389ea265"},
|
{file = "SQLAlchemy-1.4.40.tar.gz", hash = "sha256:44a660506080cc975e1dfa5776fe5f6315ddc626a77b50bf0eee18b0389ea265"},
|
||||||
]
|
]
|
||||||
sqlalchemy2-stubs = [
|
sqlalchemy2-stubs = [
|
||||||
{file = "sqlalchemy2-stubs-0.0.2a25.tar.gz", hash = "sha256:2fbfddfee7fc6b45206dc52e9fe9d91a787efb6af13191debe84dda9b4798abd"},
|
{file = "sqlalchemy2-stubs-0.0.2a27.tar.gz", hash = "sha256:f79bce50b7837a2c2374ef4480b41e2b8a8226f313f347dc2a70526a4191db93"},
|
||||||
{file = "sqlalchemy2_stubs-0.0.2a25-py3-none-any.whl", hash = "sha256:9104894cee3159906079c4a31c2a66fbedfc72a381c51dfd1d8ebae8ffd7f2ca"},
|
{file = "sqlalchemy2_stubs-0.0.2a27-py3-none-any.whl", hash = "sha256:6cea12fec3c261f6e0e14a95d2cc4914e373095e68ec4fc2eb473183ac2b17a2"},
|
||||||
]
|
]
|
||||||
starlette = [
|
starlette = [
|
||||||
{file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"},
|
{file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"},
|
||||||
@ -2014,7 +2176,10 @@ types-cachetools = [
|
|||||||
{file = "types_cachetools-5.2.1-py3-none-any.whl", hash = "sha256:b496b7e364ba050c4eaadcc6582f2c9fbb04f8ee7141eb3b311a8589dbd4506a"},
|
{file = "types_cachetools-5.2.1-py3-none-any.whl", hash = "sha256:b496b7e364ba050c4eaadcc6582f2c9fbb04f8ee7141eb3b311a8589dbd4506a"},
|
||||||
]
|
]
|
||||||
types-emoji = []
|
types-emoji = []
|
||||||
types-markdown = []
|
types-markdown = [
|
||||||
|
{file = "types-Markdown-3.4.1.tar.gz", hash = "sha256:cda9bfd1fcb11e8133a037f8a184e3059ae7389a5f5cc0b53117bf2902aca10d"},
|
||||||
|
{file = "types_Markdown-3.4.1-py3-none-any.whl", hash = "sha256:2d1e5bfff192c78d6644bc3820fea9c7c7cb42dc87558020728ec5b728448ce2"},
|
||||||
|
]
|
||||||
types-pillow = [
|
types-pillow = [
|
||||||
{file = "types-Pillow-9.2.1.tar.gz", hash = "sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4"},
|
{file = "types-Pillow-9.2.1.tar.gz", hash = "sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4"},
|
||||||
{file = "types_Pillow-9.2.1-py3-none-any.whl", hash = "sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"},
|
{file = "types_Pillow-9.2.1-py3-none-any.whl", hash = "sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"},
|
||||||
@ -2033,10 +2198,31 @@ typing-extensions = [
|
|||||||
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
|
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
|
||||||
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
|
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
|
||||||
]
|
]
|
||||||
urllib3 = []
|
urllib3 = [
|
||||||
|
{file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},
|
||||||
|
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
|
||||||
|
]
|
||||||
uvicorn = [
|
uvicorn = [
|
||||||
{file = "uvicorn-0.17.6-py3-none-any.whl", hash = "sha256:19e2a0e96c9ac5581c01eb1a79a7d2f72bb479691acd2b8921fce48ed5b961a6"},
|
{file = "uvicorn-0.18.3-py3-none-any.whl", hash = "sha256:0abd429ebb41e604ed8d2be6c60530de3408f250e8d2d84967d85ba9e86fe3af"},
|
||||||
{file = "uvicorn-0.17.6.tar.gz", hash = "sha256:5180f9d059611747d841a4a4c4ab675edf54c8489e97f96d0583ee90ac3bfc23"},
|
{file = "uvicorn-0.18.3.tar.gz", hash = "sha256:9a66e7c42a2a95222f76ec24a4b754c158261c4696e683b9dadc72b590e0311b"},
|
||||||
|
]
|
||||||
|
uvloop = [
|
||||||
|
{file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"},
|
||||||
|
{file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"},
|
||||||
|
{file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"},
|
||||||
|
{file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"},
|
||||||
|
{file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"},
|
||||||
|
{file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"},
|
||||||
|
{file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"},
|
||||||
|
{file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"},
|
||||||
|
{file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"},
|
||||||
|
{file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"},
|
||||||
|
{file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"},
|
||||||
|
{file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"},
|
||||||
|
{file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"},
|
||||||
|
{file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"},
|
||||||
|
{file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"},
|
||||||
|
{file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"},
|
||||||
]
|
]
|
||||||
watchdog = [
|
watchdog = [
|
||||||
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},
|
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},
|
||||||
@ -2065,6 +2251,26 @@ watchdog = [
|
|||||||
{file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"},
|
{file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"},
|
||||||
{file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"},
|
{file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"},
|
||||||
]
|
]
|
||||||
|
watchfiles = [
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:1e41c8b4bf3e07c18aa51775b36b718830fa727929529a7d6e5b38cf845a06b4"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b2c7ad91a867dd688b9a12097dd6a4f89397b43fccee871152aa67197cc94398"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:75a4b9cec1b1c337ea77d4428b29861553d6bf8179923b1bc7e825e217460e2c"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a3debb19912072799d7ca53e99fc5f090f77948f5601392623b2a416b4c86be"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35f3e411822e14a35f2ef656535aad4e6e79670d6b6ef8e53db958e28916b1fe"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9a7a6dc63684ff5ba11f0be0e64f744112c3c7a0baf4ec8f6794f9a6257d21e"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e939a2693404ac11e055f9d1237db8ad7635e2185a6143bde00116e691ea2983"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd7d2fd9a8f28066edc8db5278f3632eb94d10596af760fa0601631f32b1a41e"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-win32.whl", hash = "sha256:f91035a273001390093a09e52274a34695b0d15ee8736183b640bbc3b8a432ab"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-win_amd64.whl", hash = "sha256:a8a1809bf910672aa0b7ed6e6045d4fc2cf1e0718b99bc443ef17faa5697b68a"},
|
||||||
|
{file = "watchfiles-0.16.1-cp37-abi3-win_arm64.whl", hash = "sha256:baa6d0c1c5140e1dcf6ff802dd7b09fcd95b358e50d42fabc83d83f719451c54"},
|
||||||
|
{file = "watchfiles-0.16.1-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:5741246ae399a03395aa5ee35480083a4f29d58ffd41dd3395594f8805f8cdbc"},
|
||||||
|
{file = "watchfiles-0.16.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44c6aff58b8a70a26431737e483a54e8e224279b21873388571ed184fe7c91a7"},
|
||||||
|
{file = "watchfiles-0.16.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d1b2d0cf060e5222a930a3e2f40f6577da1d18c085c32741b98a128dc1e72c"},
|
||||||
|
{file = "watchfiles-0.16.1-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:70159e759f52b65a50c498182dece80364bfd721e839c254c328cbc7a1716616"},
|
||||||
|
{file = "watchfiles-0.16.1-pp39-pypy39_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22af3b915f928ef59d427d7228668f87ac8054ed8200808c73fbcaa4f82d5572"},
|
||||||
|
{file = "watchfiles-0.16.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a6a1ac96edf5bc3f8e36f4462fc1daad0ec3769ff2adb920571e120e37c91c5"},
|
||||||
|
{file = "watchfiles-0.16.1.tar.gz", hash = "sha256:aed7575e24434c8fec2f2bbb0cecb1521ea1240234d9108db7915a3424d92394"},
|
||||||
|
]
|
||||||
wcwidth = [
|
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"},
|
||||||
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
|
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
|
||||||
@ -2073,6 +2279,56 @@ webencodings = [
|
|||||||
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
|
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
|
||||||
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
|
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
|
||||||
]
|
]
|
||||||
|
websockets = [
|
||||||
|
{file = "websockets-10.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-win32.whl", hash = "sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae"},
|
||||||
|
{file = "websockets-10.3-cp310-cp310-win_amd64.whl", hash = "sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-win32.whl", hash = "sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1"},
|
||||||
|
{file = "websockets-10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-win32.whl", hash = "sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559"},
|
||||||
|
{file = "websockets-10.3-cp38-cp38-win_amd64.whl", hash = "sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-win32.whl", hash = "sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582"},
|
||||||
|
{file = "websockets-10.3-cp39-cp39-win_amd64.whl", hash = "sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02"},
|
||||||
|
{file = "websockets-10.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7"},
|
||||||
|
{file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f"},
|
||||||
|
{file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4"},
|
||||||
|
{file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755"},
|
||||||
|
{file = "websockets-10.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55"},
|
||||||
|
{file = "websockets-10.3.tar.gz", hash = "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"},
|
||||||
|
]
|
||||||
win32-setctime = [
|
win32-setctime = [
|
||||||
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
|
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
|
||||||
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
|
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
|
||||||
|
@ -9,7 +9,6 @@ license = "AGPL-3.0"
|
|||||||
python = "^3.10"
|
python = "^3.10"
|
||||||
Jinja2 = "^3.1.2"
|
Jinja2 = "^3.1.2"
|
||||||
fastapi = "^0.78.0"
|
fastapi = "^0.78.0"
|
||||||
uvicorn = "^0.17.6"
|
|
||||||
pycryptodome = "^3.14.1"
|
pycryptodome = "^3.14.1"
|
||||||
bcrypt = "^3.2.2"
|
bcrypt = "^3.2.2"
|
||||||
itsdangerous = "^2.1.2"
|
itsdangerous = "^2.1.2"
|
||||||
@ -43,6 +42,8 @@ asgiref = "^3.5.2"
|
|||||||
supervisor = "^4.2.4"
|
supervisor = "^4.2.4"
|
||||||
invoke = "^1.7.1"
|
invoke = "^1.7.1"
|
||||||
boussole = "^2.0.0"
|
boussole = "^2.0.0"
|
||||||
|
uvicorn = {extras = ["standard"], version = "^0.18.3"}
|
||||||
|
Brotli = "^1.0.9"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^22.3.0"
|
black = "^22.3.0"
|
||||||
|
@ -6,7 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import tomli_w
|
import tomli_w
|
||||||
from markdown import markdown # type: ignore
|
|
||||||
from prompt_toolkit import prompt
|
from prompt_toolkit import prompt
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
|
||||||
@ -58,8 +57,7 @@ def main() -> None:
|
|||||||
prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt()
|
prompt("admin password: ", is_password=True).encode(), bcrypt.gensalt()
|
||||||
).decode()
|
).decode()
|
||||||
dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"])
|
dat["name"] = prompt("name (e.g. John Doe): ", default=dat["username"])
|
||||||
dat["summary"] = markdown(
|
dat["summary"] = prompt(
|
||||||
prompt(
|
|
||||||
(
|
(
|
||||||
"summary (short description, in markdown, "
|
"summary (short description, in markdown, "
|
||||||
"press [CTRL] + [SPACE] to submit):\n"
|
"press [CTRL] + [SPACE] to submit):\n"
|
||||||
@ -67,7 +65,6 @@ def main() -> None:
|
|||||||
key_bindings=_kb,
|
key_bindings=_kb,
|
||||||
multiline=True,
|
multiline=True,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
dat["https"] = True
|
dat["https"] = True
|
||||||
proto = "https"
|
proto = "https"
|
||||||
yn = ""
|
yn = ""
|
||||||
|
129
tasks.py
129
tasks.py
@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import subprocess
|
|
||||||
import tarfile
|
import tarfile
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -164,14 +163,12 @@ def stats(ctx):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def embed_version() -> Generator[None, None, None]:
|
def embed_version() -> Generator[None, None, None]:
|
||||||
|
from app.utils.version import get_version_commit
|
||||||
|
|
||||||
version_file = Path("app/_version.py")
|
version_file = Path("app/_version.py")
|
||||||
version_file.unlink(missing_ok=True)
|
version_file.unlink(missing_ok=True)
|
||||||
version = (
|
version_commit = get_version_commit()
|
||||||
subprocess.check_output(["git", "rev-parse", "--short=8", "v2"])
|
version_file.write_text(f'VERSION_COMMIT = "{version_commit}"')
|
||||||
.split()[0]
|
|
||||||
.decode()
|
|
||||||
)
|
|
||||||
version_file.write_text(f'VERSION_COMMIT = "{version}"')
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@ -193,6 +190,109 @@ def prune_old_data(ctx):
|
|||||||
asyncio.run(run_prune_old_data())
|
asyncio.run(run_prune_old_data())
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def webfinger(ctx, account):
|
||||||
|
# type: (Context, str) -> None
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.source import _MENTION_REGEX
|
||||||
|
from app.webfinger import get_actor_url
|
||||||
|
|
||||||
|
logger.disable("app")
|
||||||
|
if not account.startswith("@"):
|
||||||
|
account = f"@{account}"
|
||||||
|
if not _MENTION_REGEX.match(account):
|
||||||
|
print(f"Invalid acccount {account}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Resolving {account}")
|
||||||
|
try:
|
||||||
|
maybe_actor_url = asyncio.run(get_actor_url(account))
|
||||||
|
if maybe_actor_url:
|
||||||
|
print(f"SUCCESS: {maybe_actor_url}")
|
||||||
|
else:
|
||||||
|
print(f"ERROR: Failed to resolve {account}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: Failed to resolve {account}")
|
||||||
|
print("".join(traceback.format_exception(exc)))
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def move_to(ctx, moved_to):
|
||||||
|
# type: (Context, str) -> None
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.actor import LOCAL_ACTOR
|
||||||
|
from app.actor import fetch_actor
|
||||||
|
from app.boxes import send_move
|
||||||
|
from app.database import async_session
|
||||||
|
from app.source import _MENTION_REGEX
|
||||||
|
from app.webfinger import get_actor_url
|
||||||
|
|
||||||
|
logger.disable("app")
|
||||||
|
|
||||||
|
if not moved_to.startswith("@"):
|
||||||
|
moved_to = f"@{moved_to}"
|
||||||
|
if not _MENTION_REGEX.match(moved_to):
|
||||||
|
print(f"Invalid acccount {moved_to}")
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _send_move():
|
||||||
|
print(f"Initiating move to {moved_to}")
|
||||||
|
async with async_session() as db_session:
|
||||||
|
try:
|
||||||
|
moved_to_actor_id = await get_actor_url(moved_to)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: Failed to resolve {moved_to}")
|
||||||
|
print("".join(traceback.format_exception(exc)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not moved_to_actor_id:
|
||||||
|
print("ERROR: Failed to resolve {moved_to}")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_actor = await fetch_actor(db_session, moved_to_actor_id)
|
||||||
|
|
||||||
|
if LOCAL_ACTOR.ap_id not in new_actor.ap_actor.get("alsoKnownAs", []):
|
||||||
|
print(
|
||||||
|
f"{new_actor.handle}/{moved_to_actor_id} is missing "
|
||||||
|
f"{LOCAL_ACTOR.ap_id} in alsoKnownAs"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await send_move(db_session, new_actor.ap_id)
|
||||||
|
|
||||||
|
print("Done")
|
||||||
|
|
||||||
|
asyncio.run(_send_move())
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def self_destruct(ctx):
|
||||||
|
# type: (Context) -> None
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.boxes import send_self_destruct
|
||||||
|
from app.database import async_session
|
||||||
|
|
||||||
|
logger.disable("app")
|
||||||
|
|
||||||
|
async def _send_self_destruct():
|
||||||
|
if input("Initiating self destruct, type yes to confirm: ") != "yes":
|
||||||
|
print("Aborting")
|
||||||
|
|
||||||
|
async with async_session() as db_session:
|
||||||
|
await send_self_destruct(db_session)
|
||||||
|
|
||||||
|
print("Done")
|
||||||
|
|
||||||
|
asyncio.run(_send_self_destruct())
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def yunohost_config(
|
def yunohost_config(
|
||||||
ctx,
|
ctx,
|
||||||
@ -212,3 +312,18 @@ def yunohost_config(
|
|||||||
summary=summary,
|
summary=summary,
|
||||||
password=password,
|
password=password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def reset_password(ctx):
|
||||||
|
# type: (Context) -> None
|
||||||
|
import bcrypt
|
||||||
|
from prompt_toolkit import prompt
|
||||||
|
|
||||||
|
new_password = bcrypt.hashpw(
|
||||||
|
prompt("New admin password: ", is_password=True).encode(), bcrypt.gensalt()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Update data/profile.toml with:")
|
||||||
|
print(f'admin_password = "{new_password}"')
|
||||||
|
@ -84,12 +84,13 @@ def build_move_activity(
|
|||||||
|
|
||||||
|
|
||||||
def build_note_object(
|
def build_note_object(
|
||||||
from_remote_actor: actor.RemoteActor,
|
from_remote_actor: actor.RemoteActor | models.Actor,
|
||||||
outbox_public_id: str | None = None,
|
outbox_public_id: str | None = None,
|
||||||
content: str = "Hello",
|
content: str = "Hello",
|
||||||
to: list[str] = None,
|
to: list[str] = None,
|
||||||
cc: list[str] = None,
|
cc: list[str] = None,
|
||||||
tags: list[ap.RawObject] = None,
|
tags: list[ap.RawObject] = None,
|
||||||
|
in_reply_to: str | None = None,
|
||||||
) -> ap.RawObject:
|
) -> ap.RawObject:
|
||||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
|
context = from_remote_actor.ap_id + "/ctx/" + uuid4().hex
|
||||||
@ -108,8 +109,8 @@ def build_note_object(
|
|||||||
"url": from_remote_actor.ap_id + "/note/" + note_id,
|
"url": from_remote_actor.ap_id + "/note/" + note_id,
|
||||||
"tag": tags or [],
|
"tag": tags or [],
|
||||||
"summary": None,
|
"summary": None,
|
||||||
"inReplyTo": None,
|
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
|
"inReplyTo": in_reply_to,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,4 +52,4 @@ def test_sqlalchemy_factory(db: Session) -> None:
|
|||||||
ap_actor=ra.ap_actor,
|
ap_actor=ra.ap_actor,
|
||||||
ap_id=ra.ap_id,
|
ap_id=ra.ap_id,
|
||||||
)
|
)
|
||||||
assert actor_in_db.id == db.query(models.Actor).one().id
|
assert actor_in_db.id == db.execute(select(models.Actor)).scalar_one().id
|
||||||
|
@ -75,7 +75,7 @@ def test_inbox_incoming_follow_request(
|
|||||||
assert inbox_object.ap_object == follow_activity.ap_object
|
assert inbox_object.ap_object == follow_activity.ap_object
|
||||||
|
|
||||||
# And a follower was internally created
|
# And a follower was internally created
|
||||||
follower = db.query(models.Follower).one()
|
follower = db.execute(select(models.Follower)).scalar_one()
|
||||||
assert follower.ap_actor_id == ra.ap_id
|
assert follower.ap_actor_id == ra.ap_id
|
||||||
assert follower.actor_id == saved_actor.id
|
assert follower.actor_id == saved_actor.id
|
||||||
assert follower.inbox_object_id == inbox_object.id
|
assert follower.inbox_object_id == inbox_object.id
|
||||||
@ -414,3 +414,12 @@ def test_inbox__move_activity(
|
|||||||
)
|
)
|
||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# And a notification was created
|
||||||
|
notif = db.execute(
|
||||||
|
select(models.Notification).where(
|
||||||
|
models.Notification.notification_type == models.NotificationType.MOVE
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert notif.actor.ap_id == new_ra.ap_id
|
||||||
|
assert notif.inbox_object_id == inbox_activity.id
|
||||||
|
@ -2,13 +2,17 @@ from unittest import mock
|
|||||||
|
|
||||||
import respx
|
import respx
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
from app import models
|
from app import models
|
||||||
from app import webfinger
|
from app import webfinger
|
||||||
|
from app.actor import LOCAL_ACTOR
|
||||||
from app.config import generate_csrf_token
|
from app.config import generate_csrf_token
|
||||||
from tests.utils import generate_admin_session_cookies
|
from tests.utils import generate_admin_session_cookies
|
||||||
|
from tests.utils import setup_inbox_note
|
||||||
|
from tests.utils import setup_outbox_note
|
||||||
from tests.utils import setup_remote_actor
|
from tests.utils import setup_remote_actor
|
||||||
from tests.utils import setup_remote_actor_as_follower
|
from tests.utils import setup_remote_actor_as_follower
|
||||||
|
|
||||||
@ -49,16 +53,178 @@ def test_send_follow_request(
|
|||||||
assert response.headers.get("Location") == "http://testserver/"
|
assert response.headers.get("Location") == "http://testserver/"
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Follow"
|
assert outbox_object.ap_type == "Follow"
|
||||||
assert outbox_object.activity_object_ap_id == ra.ap_id
|
assert outbox_object.activity_object_ap_id == ra.ap_id
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.query(models.OutgoingActivity).one()
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == ra.inbox_url
|
assert outgoing_activity.recipient == ra.inbox_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_delete__reverts_side_effects(
|
||||||
|
db: Session,
|
||||||
|
client: TestClient,
|
||||||
|
respx_mock: respx.MockRouter,
|
||||||
|
) -> None:
|
||||||
|
# given a remote actor
|
||||||
|
ra = setup_remote_actor(respx_mock)
|
||||||
|
|
||||||
|
# who is a follower
|
||||||
|
follower = setup_remote_actor_as_follower(ra)
|
||||||
|
actor = follower.actor
|
||||||
|
|
||||||
|
# with a note that has existing replies
|
||||||
|
inbox_note = setup_inbox_note(actor)
|
||||||
|
inbox_note.replies_count = 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# and a local reply
|
||||||
|
outbox_note = setup_outbox_note(
|
||||||
|
to=[ap.AS_PUBLIC],
|
||||||
|
cc=[LOCAL_ACTOR.followers_collection_id], # type: ignore
|
||||||
|
in_reply_to=inbox_note.ap_id,
|
||||||
|
)
|
||||||
|
inbox_note.replies_count = inbox_note.replies_count + 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/actions/delete",
|
||||||
|
data={
|
||||||
|
"redirect_url": "http://testserver/",
|
||||||
|
"ap_object_id": outbox_note.ap_id,
|
||||||
|
"csrf_token": generate_csrf_token(),
|
||||||
|
},
|
||||||
|
cookies=generate_admin_session_cookies(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then the server returns a 302
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers.get("Location") == "http://testserver/"
|
||||||
|
|
||||||
|
# And the Delete activity was created in the outbox
|
||||||
|
outbox_object = db.execute(
|
||||||
|
select(models.OutboxObject).where(models.OutboxObject.ap_type == "Delete")
|
||||||
|
).scalar_one()
|
||||||
|
assert outbox_object.ap_type == "Delete"
|
||||||
|
assert outbox_object.activity_object_ap_id == outbox_note.ap_id
|
||||||
|
|
||||||
|
# And an outgoing activity was queued
|
||||||
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
|
assert outgoing_activity.recipient == ra.inbox_url
|
||||||
|
|
||||||
|
# And the replies count of the replied object was decremented
|
||||||
|
db.refresh(inbox_note)
|
||||||
|
assert inbox_note.replies_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_create_activity__no_content(
|
||||||
|
db: Session,
|
||||||
|
client: TestClient,
|
||||||
|
respx_mock: respx.MockRouter,
|
||||||
|
) -> None:
|
||||||
|
# given a remote actor
|
||||||
|
ra = setup_remote_actor(respx_mock)
|
||||||
|
|
||||||
|
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
|
||||||
|
response = client.post(
|
||||||
|
"/admin/actions/new",
|
||||||
|
data={
|
||||||
|
"redirect_url": "http://testserver/",
|
||||||
|
"visibility": ap.VisibilityEnum.PUBLIC.name,
|
||||||
|
"csrf_token": generate_csrf_token(),
|
||||||
|
},
|
||||||
|
cookies=generate_admin_session_cookies(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then the server returns a 422
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_create_activity__with_attachment(
|
||||||
|
db: Session,
|
||||||
|
client: TestClient,
|
||||||
|
respx_mock: respx.MockRouter,
|
||||||
|
) -> None:
|
||||||
|
# given a remote actor
|
||||||
|
ra = setup_remote_actor(respx_mock)
|
||||||
|
|
||||||
|
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
|
||||||
|
response = client.post(
|
||||||
|
"/admin/actions/new",
|
||||||
|
data={
|
||||||
|
"content": "hello",
|
||||||
|
"redirect_url": "http://testserver/",
|
||||||
|
"visibility": ap.VisibilityEnum.PUBLIC.name,
|
||||||
|
"csrf_token": generate_csrf_token(),
|
||||||
|
},
|
||||||
|
files=[
|
||||||
|
("files", ("attachment.txt", "hello")),
|
||||||
|
],
|
||||||
|
cookies=generate_admin_session_cookies(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then the server returns a 302
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# And the Follow activity was created in the outbox
|
||||||
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
|
assert outbox_object.ap_type == "Note"
|
||||||
|
assert outbox_object.summary is None
|
||||||
|
assert outbox_object.content == "<p>hello</p>"
|
||||||
|
assert len(outbox_object.attachments) == 1
|
||||||
|
attachment = outbox_object.attachments[0]
|
||||||
|
assert attachment.type == "Document"
|
||||||
|
|
||||||
|
attachment_response = client.get(attachment.url)
|
||||||
|
assert attachment_response.status_code == 200
|
||||||
|
assert attachment_response.content == b"hello"
|
||||||
|
|
||||||
|
upload = db.execute(select(models.Upload)).scalar_one()
|
||||||
|
assert upload.content_hash == (
|
||||||
|
"324dcf027dd4a30a932c441f365a25e86b173defa4b8e58948253471b81b72cf"
|
||||||
|
)
|
||||||
|
|
||||||
|
outbox_attachment = db.execute(select(models.OutboxObjectAttachment)).scalar_one()
|
||||||
|
assert outbox_attachment.upload_id == upload.id
|
||||||
|
assert outbox_attachment.outbox_object_id == outbox_object.id
|
||||||
|
assert outbox_attachment.filename == "attachment.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_create_activity__no_content_with_cw_and_attachments(
|
||||||
|
db: Session,
|
||||||
|
client: TestClient,
|
||||||
|
respx_mock: respx.MockRouter,
|
||||||
|
) -> None:
|
||||||
|
# given a remote actor
|
||||||
|
ra = setup_remote_actor(respx_mock)
|
||||||
|
|
||||||
|
with mock.patch.object(webfinger, "get_actor_url", return_value=ra.ap_id):
|
||||||
|
response = client.post(
|
||||||
|
"/admin/actions/new",
|
||||||
|
data={
|
||||||
|
"content_warning": "cw",
|
||||||
|
"redirect_url": "http://testserver/",
|
||||||
|
"visibility": ap.VisibilityEnum.PUBLIC.name,
|
||||||
|
"csrf_token": generate_csrf_token(),
|
||||||
|
},
|
||||||
|
files={"files": ("attachment.txt", "hello")},
|
||||||
|
cookies=generate_admin_session_cookies(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then the server returns a 302
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# And the Follow activity was created in the outbox
|
||||||
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
|
assert outbox_object.ap_type == "Note"
|
||||||
|
assert outbox_object.summary is None
|
||||||
|
assert outbox_object.content == "<p>cw</p>"
|
||||||
|
assert len(outbox_object.attachments) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_send_create_activity__no_followers_and_with_mention(
|
def test_send_create_activity__no_followers_and_with_mention(
|
||||||
db: Session,
|
db: Session,
|
||||||
client: TestClient,
|
client: TestClient,
|
||||||
@ -83,11 +249,11 @@ def test_send_create_activity__no_followers_and_with_mention(
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.query(models.OutgoingActivity).one()
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == ra.inbox_url
|
assert outgoing_activity.recipient == ra.inbox_url
|
||||||
|
|
||||||
@ -119,11 +285,11 @@ def test_send_create_activity__with_followers(
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.query(models.OutgoingActivity).one()
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
||||||
@ -159,7 +325,7 @@ def test_send_create_activity__question__one_of(
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Question"
|
assert outbox_object.ap_type == "Question"
|
||||||
assert outbox_object.is_one_of_poll is True
|
assert outbox_object.is_one_of_poll is True
|
||||||
assert len(outbox_object.poll_items) == 2
|
assert len(outbox_object.poll_items) == 2
|
||||||
@ -167,7 +333,7 @@ def test_send_create_activity__question__one_of(
|
|||||||
assert outbox_object.is_poll_ended is False
|
assert outbox_object.is_poll_ended is False
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.query(models.OutgoingActivity).one()
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
||||||
@ -205,7 +371,7 @@ def test_send_create_activity__question__any_of(
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Question"
|
assert outbox_object.ap_type == "Question"
|
||||||
assert outbox_object.is_one_of_poll is False
|
assert outbox_object.is_one_of_poll is False
|
||||||
assert len(outbox_object.poll_items) == 4
|
assert len(outbox_object.poll_items) == 4
|
||||||
@ -213,7 +379,7 @@ def test_send_create_activity__question__any_of(
|
|||||||
assert outbox_object.is_poll_ended is False
|
assert outbox_object.is_poll_ended is False
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.query(models.OutgoingActivity).one()
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
|
||||||
@ -246,11 +412,11 @@ def test_send_create_activity__article(
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Article"
|
assert outbox_object.ap_type == "Article"
|
||||||
assert outbox_object.ap_object["name"] == "Article"
|
assert outbox_object.ap_object["name"] == "Article"
|
||||||
|
|
||||||
# And an outgoing activity was queued
|
# And an outgoing activity was queued
|
||||||
outgoing_activity = db.query(models.OutgoingActivity).one()
|
outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
|
||||||
assert outgoing_activity.outbox_object_id == outbox_object.id
|
assert outgoing_activity.outbox_object_id == outbox_object.id
|
||||||
assert outgoing_activity.recipient == follower.actor.inbox_url
|
assert outgoing_activity.recipient == follower.actor.inbox_url
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@ -31,7 +33,19 @@ def test_followers__ap(client, db) -> None:
|
|||||||
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
|
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
||||||
assert response.json()["id"].endswith("/followers")
|
json_resp = response.json()
|
||||||
|
assert json_resp["id"].endswith("/followers")
|
||||||
|
assert "first" in json_resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_followers__ap_hides_followers(client, db) -> None:
|
||||||
|
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
|
||||||
|
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
||||||
|
json_resp = response.json()
|
||||||
|
assert json_resp["id"].endswith("/followers")
|
||||||
|
assert "first" not in json_resp
|
||||||
|
|
||||||
|
|
||||||
def test_followers__html(client, db) -> None:
|
def test_followers__html(client, db) -> None:
|
||||||
@ -40,14 +54,40 @@ def test_followers__html(client, db) -> None:
|
|||||||
assert response.headers["content-type"].startswith("text/html")
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
|
||||||
|
|
||||||
|
def test_followers__html_hides_followers(client, db) -> None:
|
||||||
|
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
|
||||||
|
response = client.get("/followers", headers={"Accept": "text/html"})
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
|
||||||
|
|
||||||
def test_following__ap(client, db) -> None:
|
def test_following__ap(client, db) -> None:
|
||||||
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
|
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
||||||
assert response.json()["id"].endswith("/following")
|
json_resp = response.json()
|
||||||
|
assert json_resp["id"].endswith("/following")
|
||||||
|
assert "first" in json_resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_following__ap_hides_following(client, db) -> None:
|
||||||
|
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
|
||||||
|
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
|
||||||
|
json_resp = response.json()
|
||||||
|
assert json_resp["id"].endswith("/following")
|
||||||
|
assert "first" not in json_resp
|
||||||
|
|
||||||
|
|
||||||
def test_following__html(client, db) -> None:
|
def test_following__html(client, db) -> None:
|
||||||
response = client.get("/following")
|
response = client.get("/following")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
|
||||||
|
|
||||||
|
def test_following__html_hides_following(client, db) -> None:
|
||||||
|
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
|
||||||
|
response = client.get("/following", headers={"Accept": "text/html"})
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import activitypub as ap
|
from app import activitypub as ap
|
||||||
@ -35,7 +36,7 @@ def test_tags__note_with_tag(db: Session, client: TestClient) -> None:
|
|||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
# And the Follow activity was created in the outbox
|
# And the Follow activity was created in the outbox
|
||||||
outbox_object = db.query(models.OutboxObject).one()
|
outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
|
||||||
assert outbox_object.ap_type == "Note"
|
assert outbox_object.ap_type == "Note"
|
||||||
assert len(outbox_object.tags) == 1
|
assert len(outbox_object.tags) == 1
|
||||||
emoji_tag = outbox_object.tags[0]
|
emoji_tag = outbox_object.tags[0]
|
||||||
|
@ -169,6 +169,53 @@ def setup_remote_actor_as_following_and_follower(
|
|||||||
return following, follower
|
return following, follower
|
||||||
|
|
||||||
|
|
||||||
|
def setup_outbox_note(
|
||||||
|
content: str = "Hello",
|
||||||
|
to: list[str] = None,
|
||||||
|
cc: list[str] = None,
|
||||||
|
tags: list[ap.RawObject] = None,
|
||||||
|
in_reply_to: str | None = None,
|
||||||
|
) -> models.OutboxObject:
|
||||||
|
note_id = uuid4().hex
|
||||||
|
note_from_outbox = RemoteObject(
|
||||||
|
factories.build_note_object(
|
||||||
|
from_remote_actor=LOCAL_ACTOR,
|
||||||
|
outbox_public_id=note_id,
|
||||||
|
content=content,
|
||||||
|
to=to,
|
||||||
|
cc=cc,
|
||||||
|
tags=tags,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
),
|
||||||
|
LOCAL_ACTOR,
|
||||||
|
)
|
||||||
|
return factories.OutboxObjectFactory.from_remote_object(note_id, note_from_outbox)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_inbox_note(
|
||||||
|
actor: models.Actor,
|
||||||
|
content: str = "Hello",
|
||||||
|
to: list[str] = None,
|
||||||
|
cc: list[str] = None,
|
||||||
|
tags: list[ap.RawObject] = None,
|
||||||
|
in_reply_to: str | None = None,
|
||||||
|
) -> models.OutboxObject:
|
||||||
|
note_id = uuid4().hex
|
||||||
|
note_from_outbox = RemoteObject(
|
||||||
|
factories.build_note_object(
|
||||||
|
from_remote_actor=actor,
|
||||||
|
outbox_public_id=note_id,
|
||||||
|
content=content,
|
||||||
|
to=to,
|
||||||
|
cc=cc,
|
||||||
|
tags=tags,
|
||||||
|
in_reply_to=in_reply_to,
|
||||||
|
),
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return factories.InboxObjectFactory.from_remote_object(note_from_outbox, actor)
|
||||||
|
|
||||||
|
|
||||||
def setup_inbox_delete(
|
def setup_inbox_delete(
|
||||||
actor: models.Actor, deleted_object_ap_id: str
|
actor: models.Actor, deleted_object_ap_id: str
|
||||||
) -> models.InboxObject:
|
) -> models.InboxObject:
|
||||||
|
Reference in New Issue
Block a user