slidge 0.2.12__py3-none-any.whl → 0.3.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slidge/__init__.py +5 -2
- slidge/command/adhoc.py +9 -3
- slidge/command/admin.py +16 -12
- slidge/command/base.py +16 -12
- slidge/command/chat_command.py +25 -16
- slidge/command/user.py +7 -8
- slidge/contact/contact.py +119 -209
- slidge/contact/roster.py +106 -105
- slidge/core/config.py +2 -43
- slidge/core/dispatcher/caps.py +9 -2
- slidge/core/dispatcher/disco.py +13 -3
- slidge/core/dispatcher/message/__init__.py +1 -1
- slidge/core/dispatcher/message/chat_state.py +17 -8
- slidge/core/dispatcher/message/marker.py +7 -5
- slidge/core/dispatcher/message/message.py +117 -92
- slidge/core/dispatcher/muc/__init__.py +1 -1
- slidge/core/dispatcher/muc/admin.py +4 -4
- slidge/core/dispatcher/muc/mam.py +10 -6
- slidge/core/dispatcher/muc/misc.py +4 -2
- slidge/core/dispatcher/muc/owner.py +5 -3
- slidge/core/dispatcher/muc/ping.py +3 -1
- slidge/core/dispatcher/presence.py +21 -15
- slidge/core/dispatcher/registration.py +20 -12
- slidge/core/dispatcher/search.py +7 -3
- slidge/core/dispatcher/session_dispatcher.py +13 -5
- slidge/core/dispatcher/util.py +37 -27
- slidge/core/dispatcher/vcard.py +7 -4
- slidge/core/gateway.py +168 -84
- slidge/core/mixins/__init__.py +1 -11
- slidge/core/mixins/attachment.py +163 -148
- slidge/core/mixins/avatar.py +100 -177
- slidge/core/mixins/db.py +50 -2
- slidge/core/mixins/message.py +19 -17
- slidge/core/mixins/message_maker.py +29 -15
- slidge/core/mixins/message_text.py +38 -30
- slidge/core/mixins/presence.py +91 -35
- slidge/core/pubsub.py +42 -47
- slidge/core/session.py +88 -57
- slidge/db/alembic/versions/0337c90c0b96_unify_legacy_xmpp_id_mappings.py +183 -0
- slidge/db/alembic/versions/4dbd23a3f868_new_avatar_store.py +56 -0
- slidge/db/alembic/versions/54ce3cde350c_use_hash_for_avatar_filenames.py +50 -0
- slidge/db/alembic/versions/58b98dacf819_refactor.py +118 -0
- slidge/db/alembic/versions/75a62b74b239_ditch_hats_table.py +74 -0
- slidge/db/avatar.py +150 -119
- slidge/db/meta.py +33 -22
- slidge/db/models.py +68 -117
- slidge/db/store.py +412 -1094
- slidge/group/archive.py +61 -54
- slidge/group/bookmarks.py +74 -55
- slidge/group/participant.py +135 -142
- slidge/group/room.py +315 -312
- slidge/main.py +28 -18
- slidge/migration.py +2 -12
- slidge/slixfix/__init__.py +20 -4
- slidge/slixfix/delivery_receipt.py +6 -4
- slidge/slixfix/link_preview/link_preview.py +1 -1
- slidge/slixfix/link_preview/stanza.py +1 -1
- slidge/slixfix/roster.py +5 -7
- slidge/slixfix/xep_0077/register.py +8 -8
- slidge/slixfix/xep_0077/stanza.py +7 -7
- slidge/slixfix/xep_0100/gateway.py +12 -13
- slidge/slixfix/xep_0153/vcard_avatar.py +1 -1
- slidge/slixfix/xep_0292/vcard4.py +1 -1
- slidge/util/archive_msg.py +11 -5
- slidge/util/conf.py +23 -20
- slidge/util/jid_escaping.py +1 -1
- slidge/{core/mixins → util}/lock.py +6 -6
- slidge/util/test.py +30 -29
- slidge/util/types.py +22 -18
- slidge/util/util.py +19 -22
- {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/METADATA +1 -1
- slidge-0.3.0a0.dist-info/RECORD +117 -0
- {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/WHEEL +1 -1
- slidge-0.2.12.dist-info/RECORD +0 -112
- {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/entry_points.txt +0 -0
- {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/licenses/LICENSE +0 -0
- {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,118 @@
|
|
1
|
+
"""Add unique constraints that we should have added before
|
2
|
+
|
3
|
+
Revision ID: 58b98dacf819
|
4
|
+
Revises: 54ce3cde350c
|
5
|
+
Create Date: 2025-04-23 22:55:11.731158
|
6
|
+
|
7
|
+
"""
|
8
|
+
|
9
|
+
from typing import Sequence, Union
|
10
|
+
|
11
|
+
import sqlalchemy as sa
|
12
|
+
from alembic import op
|
13
|
+
|
14
|
+
# revision identifiers, used by Alembic.
|
15
|
+
revision: str = "58b98dacf819"
|
16
|
+
down_revision: Union[str, None] = "54ce3cde350c"
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
19
|
+
|
20
|
+
|
21
|
+
def upgrade() -> None:
|
22
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
23
|
+
with op.batch_alter_table("avatar", schema=None) as batch_op:
|
24
|
+
batch_op.drop_constraint("avatar_unique_legacy_id", type_="unique")
|
25
|
+
batch_op.create_unique_constraint(batch_op.f("uq_avatar_hash"), ["hash"])
|
26
|
+
batch_op.create_unique_constraint(
|
27
|
+
batch_op.f("uq_avatar_legacy_id"), ["legacy_id"]
|
28
|
+
)
|
29
|
+
|
30
|
+
with op.batch_alter_table("bob", schema=None) as batch_op:
|
31
|
+
batch_op.create_unique_constraint(batch_op.f("uq_bob_sha_1"), ["sha_1"])
|
32
|
+
batch_op.create_unique_constraint(batch_op.f("uq_bob_sha_256"), ["sha_256"])
|
33
|
+
batch_op.create_unique_constraint(batch_op.f("uq_bob_sha_512"), ["sha_512"])
|
34
|
+
|
35
|
+
with op.batch_alter_table("contact", schema=None) as batch_op:
|
36
|
+
batch_op.create_unique_constraint(
|
37
|
+
batch_op.f("uq_contact_user_account_id"), ["user_account_id", "legacy_id"]
|
38
|
+
)
|
39
|
+
|
40
|
+
with op.batch_alter_table("contact_sent", schema=None) as batch_op:
|
41
|
+
batch_op.create_unique_constraint(
|
42
|
+
batch_op.f("uq_contact_sent_contact_id"), ["contact_id", "msg_id"]
|
43
|
+
)
|
44
|
+
|
45
|
+
with op.batch_alter_table("hat", schema=None) as batch_op:
|
46
|
+
batch_op.create_unique_constraint(batch_op.f("uq_hat_title"), ["title", "uri"])
|
47
|
+
|
48
|
+
with op.batch_alter_table("mam", schema=None) as batch_op:
|
49
|
+
batch_op.create_unique_constraint(
|
50
|
+
batch_op.f("uq_mam_room_id"), ["room_id", "stanza_id"]
|
51
|
+
)
|
52
|
+
|
53
|
+
with op.batch_alter_table("participant", schema=None) as batch_op:
|
54
|
+
batch_op.alter_column("resource", existing_type=sa.VARCHAR(), nullable=False)
|
55
|
+
batch_op.alter_column("nickname", existing_type=sa.VARCHAR(), nullable=False)
|
56
|
+
batch_op.alter_column(
|
57
|
+
"nickname_no_illegal", existing_type=sa.VARCHAR(), nullable=False
|
58
|
+
)
|
59
|
+
batch_op.create_unique_constraint(
|
60
|
+
batch_op.f("uq_participant_room_id"), ["room_id", "resource"]
|
61
|
+
)
|
62
|
+
|
63
|
+
with op.batch_alter_table("room", schema=None) as batch_op:
|
64
|
+
batch_op.alter_column(
|
65
|
+
"muc_type", existing_type=sa.VARCHAR(length=21), nullable=False
|
66
|
+
)
|
67
|
+
|
68
|
+
with op.batch_alter_table("user_account", schema=None) as batch_op:
|
69
|
+
batch_op.create_unique_constraint(batch_op.f("uq_user_account_jid"), ["jid"])
|
70
|
+
|
71
|
+
# ### end Alembic commands ###
|
72
|
+
|
73
|
+
|
74
|
+
def downgrade() -> None:
|
75
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
76
|
+
with op.batch_alter_table("user_account", schema=None) as batch_op:
|
77
|
+
batch_op.drop_constraint(batch_op.f("uq_user_account_jid"), type_="unique")
|
78
|
+
|
79
|
+
with op.batch_alter_table("room", schema=None) as batch_op:
|
80
|
+
batch_op.alter_column(
|
81
|
+
"muc_type", existing_type=sa.VARCHAR(length=21), nullable=True
|
82
|
+
)
|
83
|
+
|
84
|
+
with op.batch_alter_table("participant", schema=None) as batch_op:
|
85
|
+
batch_op.drop_constraint(batch_op.f("uq_participant_room_id"), type_="unique")
|
86
|
+
batch_op.alter_column(
|
87
|
+
"nickname_no_illegal", existing_type=sa.VARCHAR(), nullable=True
|
88
|
+
)
|
89
|
+
batch_op.alter_column("nickname", existing_type=sa.VARCHAR(), nullable=True)
|
90
|
+
batch_op.alter_column("resource", existing_type=sa.VARCHAR(), nullable=True)
|
91
|
+
|
92
|
+
with op.batch_alter_table("mam", schema=None) as batch_op:
|
93
|
+
batch_op.drop_constraint(batch_op.f("uq_mam_room_id"), type_="unique")
|
94
|
+
|
95
|
+
with op.batch_alter_table("hat", schema=None) as batch_op:
|
96
|
+
batch_op.drop_constraint(batch_op.f("uq_hat_title"), type_="unique")
|
97
|
+
|
98
|
+
with op.batch_alter_table("contact_sent", schema=None) as batch_op:
|
99
|
+
batch_op.drop_constraint(
|
100
|
+
batch_op.f("uq_contact_sent_contact_id"), type_="unique"
|
101
|
+
)
|
102
|
+
|
103
|
+
with op.batch_alter_table("contact", schema=None) as batch_op:
|
104
|
+
batch_op.drop_constraint(
|
105
|
+
batch_op.f("uq_contact_user_account_id"), type_="unique"
|
106
|
+
)
|
107
|
+
|
108
|
+
with op.batch_alter_table("bob", schema=None) as batch_op:
|
109
|
+
batch_op.drop_constraint(batch_op.f("uq_bob_sha_512"), type_="unique")
|
110
|
+
batch_op.drop_constraint(batch_op.f("uq_bob_sha_256"), type_="unique")
|
111
|
+
batch_op.drop_constraint(batch_op.f("uq_bob_sha_1"), type_="unique")
|
112
|
+
|
113
|
+
with op.batch_alter_table("avatar", schema=None) as batch_op:
|
114
|
+
batch_op.drop_constraint(batch_op.f("uq_avatar_legacy_id"), type_="unique")
|
115
|
+
batch_op.drop_constraint(batch_op.f("uq_avatar_hash"), type_="unique")
|
116
|
+
batch_op.create_unique_constraint("avatar_unique_legacy_id", ["legacy_id"])
|
117
|
+
|
118
|
+
# ### end Alembic commands ###
|
@@ -0,0 +1,74 @@
|
|
1
|
+
"""Ditch hats table
|
2
|
+
|
3
|
+
Revision ID: 75a62b74b239
|
4
|
+
Revises: 0337c90c0b96
|
5
|
+
Create Date: 2025-05-02 14:24:26.141034
|
6
|
+
|
7
|
+
"""
|
8
|
+
|
9
|
+
from typing import Sequence, Union
|
10
|
+
|
11
|
+
import sqlalchemy as sa
|
12
|
+
from alembic import op
|
13
|
+
|
14
|
+
# revision identifiers, used by Alembic.
|
15
|
+
revision: str = "75a62b74b239"
|
16
|
+
down_revision: Union[str, None] = "0337c90c0b96"
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
19
|
+
|
20
|
+
|
21
|
+
def upgrade() -> None:
|
22
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
23
|
+
op.drop_table("participant_hats")
|
24
|
+
op.drop_table("hat")
|
25
|
+
with op.batch_alter_table("contact", schema=None) as batch_op:
|
26
|
+
batch_op.drop_constraint("uq_contact_user_account_id", type_="unique")
|
27
|
+
batch_op.create_unique_constraint(
|
28
|
+
batch_op.f("uq_contact_user_account_id"), ["user_account_id", "jid"]
|
29
|
+
)
|
30
|
+
|
31
|
+
with op.batch_alter_table("participant", schema=None) as batch_op:
|
32
|
+
batch_op.add_column(
|
33
|
+
sa.Column("hats", sa.JSON(), nullable=False, server_default="[]")
|
34
|
+
)
|
35
|
+
|
36
|
+
# ### end Alembic commands ###
|
37
|
+
|
38
|
+
|
39
|
+
def downgrade() -> None:
|
40
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
41
|
+
with op.batch_alter_table("participant", schema=None) as batch_op:
|
42
|
+
batch_op.drop_column("hats")
|
43
|
+
|
44
|
+
with op.batch_alter_table("contact", schema=None) as batch_op:
|
45
|
+
batch_op.drop_constraint(
|
46
|
+
batch_op.f("uq_contact_user_account_id"), type_="unique"
|
47
|
+
)
|
48
|
+
batch_op.create_unique_constraint(
|
49
|
+
"uq_contact_user_account_id", ["user_account_id", "legacy_id"]
|
50
|
+
)
|
51
|
+
|
52
|
+
op.create_table(
|
53
|
+
"hat",
|
54
|
+
sa.Column("id", sa.INTEGER(), nullable=False),
|
55
|
+
sa.Column("title", sa.VARCHAR(), nullable=False),
|
56
|
+
sa.Column("uri", sa.VARCHAR(), nullable=False),
|
57
|
+
sa.PrimaryKeyConstraint("id"),
|
58
|
+
sa.UniqueConstraint("title", "uri", name="uq_hat_title"),
|
59
|
+
)
|
60
|
+
op.create_table(
|
61
|
+
"participant_hats",
|
62
|
+
sa.Column("participant_id", sa.INTEGER(), nullable=False),
|
63
|
+
sa.Column("hat_id", sa.INTEGER(), nullable=False),
|
64
|
+
sa.ForeignKeyConstraint(
|
65
|
+
["hat_id"],
|
66
|
+
["hat.id"],
|
67
|
+
),
|
68
|
+
sa.ForeignKeyConstraint(
|
69
|
+
["participant_id"],
|
70
|
+
["participant.id"],
|
71
|
+
),
|
72
|
+
sa.PrimaryKeyConstraint("participant_id", "hat_id"),
|
73
|
+
)
|
74
|
+
# ### end Alembic commands ###
|
slidge/db/avatar.py
CHANGED
@@ -2,9 +2,7 @@ import asyncio
|
|
2
2
|
import hashlib
|
3
3
|
import io
|
4
4
|
import logging
|
5
|
-
import uuid
|
6
5
|
from concurrent.futures import ThreadPoolExecutor
|
7
|
-
from dataclasses import dataclass
|
8
6
|
from http import HTTPStatus
|
9
7
|
from pathlib import Path
|
10
8
|
from typing import Optional
|
@@ -14,24 +12,42 @@ from multidict import CIMultiDictProxy
|
|
14
12
|
from PIL.Image import Image
|
15
13
|
from PIL.Image import open as open_image
|
16
14
|
from sqlalchemy import select
|
17
|
-
from sqlalchemy.orm.exc import DetachedInstanceError
|
18
15
|
|
19
|
-
from
|
20
|
-
from
|
21
|
-
from
|
22
|
-
from
|
16
|
+
from ..core import config
|
17
|
+
from ..util.lock import NamedLockMixin
|
18
|
+
from ..util.types import Avatar as AvatarType
|
19
|
+
from .models import Avatar
|
20
|
+
from .store import AvatarStore
|
23
21
|
|
24
22
|
|
25
|
-
@dataclass
|
26
23
|
class CachedAvatar:
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
24
|
+
def __init__(self, stored: Avatar, root_dir: Path) -> None:
|
25
|
+
self.stored = stored
|
26
|
+
self._root = root_dir
|
27
|
+
|
28
|
+
@property
|
29
|
+
def pk(self) -> int | None:
|
30
|
+
return self.stored.id
|
31
|
+
|
32
|
+
@property
|
33
|
+
def hash(self) -> str:
|
34
|
+
return self.stored.hash
|
35
|
+
|
36
|
+
@property
|
37
|
+
def height(self) -> int:
|
38
|
+
return self.stored.height
|
39
|
+
|
40
|
+
@property
|
41
|
+
def width(self) -> int:
|
42
|
+
return self.stored.width
|
43
|
+
|
44
|
+
@property
|
45
|
+
def etag(self) -> str | None:
|
46
|
+
return self.stored.etag
|
47
|
+
|
48
|
+
@property
|
49
|
+
def last_modified(self) -> str | None:
|
50
|
+
return self.stored.last_modified
|
35
51
|
|
36
52
|
@property
|
37
53
|
def data(self):
|
@@ -39,40 +55,32 @@ class CachedAvatar:
|
|
39
55
|
|
40
56
|
@property
|
41
57
|
def path(self):
|
42
|
-
return self.
|
43
|
-
|
44
|
-
@staticmethod
|
45
|
-
def from_store(stored: Avatar, root_dir: Path):
|
46
|
-
return CachedAvatar(
|
47
|
-
pk=stored.id,
|
48
|
-
filename=stored.filename,
|
49
|
-
hash=stored.hash,
|
50
|
-
height=stored.height,
|
51
|
-
width=stored.width,
|
52
|
-
etag=stored.etag,
|
53
|
-
root=root_dir,
|
54
|
-
last_modified=stored.last_modified,
|
55
|
-
)
|
58
|
+
return (self._root / self.hash).with_suffix(".png")
|
56
59
|
|
57
60
|
|
58
61
|
class NotModified(Exception):
|
59
62
|
pass
|
60
63
|
|
61
64
|
|
62
|
-
class AvatarCache:
|
65
|
+
class AvatarCache(NamedLockMixin):
|
63
66
|
dir: Path
|
64
67
|
http: aiohttp.ClientSession
|
65
68
|
store: AvatarStore
|
66
69
|
|
67
|
-
def __init__(self):
|
70
|
+
def __init__(self) -> None:
|
68
71
|
self._thread_pool = ThreadPoolExecutor(config.AVATAR_RESAMPLING_THREADS)
|
72
|
+
super().__init__()
|
73
|
+
|
74
|
+
def get(self, stored: Avatar) -> CachedAvatar:
|
75
|
+
return CachedAvatar(stored, self.dir)
|
69
76
|
|
70
|
-
def set_dir(self, path: Path):
|
77
|
+
def set_dir(self, path: Path) -> None:
|
71
78
|
self.dir = path
|
72
79
|
self.dir.mkdir(exist_ok=True)
|
73
|
-
|
74
|
-
|
75
|
-
|
80
|
+
log.debug("Checking avatar files")
|
81
|
+
with self.store.session(expire_on_commit=False) as orm:
|
82
|
+
for stored in orm.query(Avatar).all():
|
83
|
+
avatar = CachedAvatar(stored, path)
|
76
84
|
if avatar.path.exists():
|
77
85
|
continue
|
78
86
|
log.warning(
|
@@ -80,14 +88,15 @@ class AvatarCache:
|
|
80
88
|
avatar.hash,
|
81
89
|
avatar.path,
|
82
90
|
)
|
83
|
-
|
91
|
+
orm.delete(stored)
|
92
|
+
orm.commit()
|
84
93
|
|
85
|
-
def close(self):
|
94
|
+
def close(self) -> None:
|
86
95
|
self._thread_pool.shutdown(cancel_futures=True)
|
87
96
|
|
88
|
-
def __get_http_headers(self, cached: Optional[CachedAvatar | Avatar]):
|
97
|
+
def __get_http_headers(self, cached: Optional[CachedAvatar | Avatar] = None):
|
89
98
|
headers = {}
|
90
|
-
if cached and (self.dir / cached.
|
99
|
+
if cached and (self.dir / cached.hash).with_suffix(".png").exists():
|
91
100
|
if last_modified := cached.last_modified:
|
92
101
|
headers["If-Modified-Since"] = last_modified
|
93
102
|
if etag := cached.etag:
|
@@ -113,105 +122,127 @@ class AvatarCache:
|
|
113
122
|
async with self.http.head(url, headers=headers) as response:
|
114
123
|
return response.status != HTTPStatus.NOT_MODIFIED
|
115
124
|
|
116
|
-
async def url_modified(self, url:
|
117
|
-
|
125
|
+
async def url_modified(self, url: str) -> bool:
|
126
|
+
with self.store.session() as orm:
|
127
|
+
cached = orm.query(Avatar).filter_by(url=url).one_or_none()
|
118
128
|
if cached is None:
|
119
129
|
return True
|
120
130
|
headers = self.__get_http_headers(cached)
|
121
131
|
return await self.__is_modified(url, headers)
|
122
132
|
|
123
|
-
def get_by_pk(self, pk: int) -> CachedAvatar:
|
124
|
-
stored = self.store.get_by_pk(pk)
|
125
|
-
assert stored is not None
|
126
|
-
return CachedAvatar.from_store(stored, self.dir)
|
127
|
-
|
128
133
|
@staticmethod
|
129
134
|
async def _get_image(avatar: AvatarType) -> Image:
|
130
|
-
if
|
131
|
-
return open_image(io.BytesIO(avatar))
|
132
|
-
elif
|
133
|
-
return open_image(avatar)
|
135
|
+
if avatar.data is not None:
|
136
|
+
return open_image(io.BytesIO(avatar.data))
|
137
|
+
elif avatar.path is not None:
|
138
|
+
return open_image(avatar.path)
|
134
139
|
raise TypeError("Avatar must be bytes or a Path", avatar)
|
135
140
|
|
136
141
|
async def convert_or_get(self, avatar: AvatarType) -> CachedAvatar:
|
137
|
-
if
|
138
|
-
with self.store.session():
|
139
|
-
stored =
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
)
|
144
|
-
except NotModified:
|
145
|
-
assert stored is not None
|
146
|
-
try:
|
147
|
-
return CachedAvatar.from_store(stored, self.dir)
|
148
|
-
except DetachedInstanceError:
|
149
|
-
# This is an awful hack to prevent errors on startup under certain conditions,
|
150
|
-
# because we basically misused SQLAlchemy pretty bad in slidge.db.store.EngineMixin.session().
|
151
|
-
# cf https://codeberg.org/slidge/slidge/issues/36
|
152
|
-
# and https://docs.sqlalchemy.org/en/20/orm/session_basics.html#session-faq-threadsafe
|
153
|
-
# It may be related to threads as we only have reports of this for slidge-whatsapp and skidge
|
154
|
-
# which are the only implementations that uses threads.
|
155
|
-
# In any case, a proper fix implies a major refactoring in which we spawn and close SQLAlchemy
|
156
|
-
# "ORM Session"s with a reasonable, well-thought lifetime, instead of how we do it now, where we
|
157
|
-
# basically just brute-forced our way into having something usable but with poor performance.
|
158
|
-
# Databases, asyncio, and concurrency in general are hard… :(
|
159
|
-
# Oh, and getting rid of the convoluted mess that this giant method is would also probably
|
160
|
-
# be a good idea.
|
161
|
-
stored = self.store.get_by_url(avatar)
|
162
|
-
assert stored is not None
|
163
|
-
return CachedAvatar.from_store(stored, self.dir)
|
164
|
-
else:
|
165
|
-
img = await self._get_image(avatar)
|
166
|
-
response_headers = None
|
167
|
-
with self.store.session() as orm:
|
168
|
-
resize = (size := config.AVATAR_SIZE) and any(x > size for x in img.size)
|
169
|
-
if resize:
|
170
|
-
await asyncio.get_event_loop().run_in_executor(
|
171
|
-
self._thread_pool, img.thumbnail, (size, size)
|
142
|
+
if avatar.unique_id is not None:
|
143
|
+
with self.store.session() as orm:
|
144
|
+
stored = (
|
145
|
+
orm.query(Avatar)
|
146
|
+
.filter_by(legacy_id=str(avatar.unique_id))
|
147
|
+
.one_or_none()
|
172
148
|
)
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
):
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
149
|
+
if stored is not None:
|
150
|
+
return self.get(stored)
|
151
|
+
|
152
|
+
if avatar.url is not None:
|
153
|
+
return await self.__convert_url(avatar)
|
154
|
+
|
155
|
+
return await self.convert(avatar, await self._get_image(avatar))
|
156
|
+
|
157
|
+
async def __convert_url(self, avatar: AvatarType) -> CachedAvatar:
|
158
|
+
assert avatar.url is not None
|
159
|
+
async with self.lock(avatar.unique_id or avatar.url):
|
160
|
+
with self.store.session() as orm:
|
161
|
+
if avatar.unique_id is None:
|
162
|
+
stored = orm.query(Avatar).filter_by(url=avatar.url).one_or_none()
|
163
|
+
else:
|
164
|
+
stored = (
|
165
|
+
orm.query(Avatar)
|
166
|
+
.filter_by(legacy_id=str(avatar.unique_id))
|
167
|
+
.one_or_none()
|
168
|
+
)
|
169
|
+
if stored is not None:
|
170
|
+
return self.get(stored)
|
190
171
|
|
191
|
-
|
192
|
-
|
172
|
+
try:
|
173
|
+
img, response_headers = await self.__download(
|
174
|
+
avatar.url, self.__get_http_headers(stored)
|
175
|
+
)
|
176
|
+
except NotModified:
|
177
|
+
assert stored is not None
|
178
|
+
return self.get(stored)
|
193
179
|
|
194
|
-
|
180
|
+
return await self.convert(avatar, img, response_headers)
|
195
181
|
|
182
|
+
async def convert(
|
183
|
+
self,
|
184
|
+
avatar: AvatarType,
|
185
|
+
img: Image,
|
186
|
+
response_headers: CIMultiDictProxy[str] | None = None,
|
187
|
+
) -> CachedAvatar:
|
188
|
+
resize = (size := config.AVATAR_SIZE) and any(x > size for x in img.size)
|
189
|
+
if resize:
|
190
|
+
await asyncio.get_event_loop().run_in_executor(
|
191
|
+
self._thread_pool, img.thumbnail, (size, size)
|
192
|
+
)
|
193
|
+
log.debug("Resampled image to %s", img.size)
|
194
|
+
|
195
|
+
if (
|
196
|
+
not resize
|
197
|
+
and img.format == "PNG"
|
198
|
+
and avatar.path is not None
|
199
|
+
and avatar.path.exists()
|
200
|
+
):
|
201
|
+
img_bytes = avatar.path.read_bytes()
|
202
|
+
else:
|
203
|
+
with io.BytesIO() as f:
|
204
|
+
img.save(f, format="PNG")
|
205
|
+
img_bytes = f.getvalue()
|
206
|
+
|
207
|
+
hash_ = hashlib.sha1(img_bytes).hexdigest()
|
208
|
+
file_path = (self.dir / hash_).with_suffix(".png")
|
209
|
+
if file_path.exists():
|
210
|
+
log.warning("Overwriting %s", file_path)
|
211
|
+
with file_path.open("wb") as file:
|
212
|
+
file.write(img_bytes)
|
213
|
+
|
214
|
+
with self.store.session(expire_on_commit=False) as orm:
|
196
215
|
stored = orm.execute(select(Avatar).where(Avatar.hash == hash_)).scalar()
|
197
216
|
|
198
217
|
if stored is not None:
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
218
|
+
if avatar.unique_id is not None:
|
219
|
+
if str(avatar.unique_id) != stored.legacy_id:
|
220
|
+
log.warning(
|
221
|
+
"Updating the 'unique' ID of an avatar, was '%s', is now '%s'",
|
222
|
+
stored.legacy_id,
|
223
|
+
avatar.unique_id,
|
224
|
+
)
|
225
|
+
stored.legacy_id = str(avatar.unique_id)
|
226
|
+
orm.add(stored)
|
227
|
+
orm.commit()
|
228
|
+
|
229
|
+
return self.get(stored)
|
230
|
+
|
231
|
+
stored = Avatar(
|
232
|
+
hash=hash_,
|
233
|
+
height=img.height,
|
234
|
+
width=img.width,
|
235
|
+
url=avatar.url,
|
236
|
+
legacy_id=avatar.unique_id,
|
237
|
+
)
|
238
|
+
if response_headers:
|
239
|
+
stored.etag = response_headers.get("etag")
|
240
|
+
stored.last_modified = response_headers.get("last-modified")
|
211
241
|
|
242
|
+
with self.store.session(expire_on_commit=False) as orm:
|
212
243
|
orm.add(stored)
|
213
244
|
orm.commit()
|
214
|
-
return
|
245
|
+
return self.get(stored)
|
215
246
|
|
216
247
|
|
217
248
|
avatar_cache = AvatarCache()
|
slidge/db/meta.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import json
|
4
|
-
from typing import Union
|
4
|
+
from typing import Any, Union
|
5
5
|
|
6
6
|
import sqlalchemy as sa
|
7
7
|
from slixmpp import JID
|
8
|
+
from sqlalchemy import Dialect
|
8
9
|
|
9
10
|
|
10
11
|
class JIDType(sa.TypeDecorator[JID]):
|
@@ -28,7 +29,11 @@ class JIDType(sa.TypeDecorator[JID]):
|
|
28
29
|
return JID(value)
|
29
30
|
|
30
31
|
|
31
|
-
|
32
|
+
JSONSerializableTypes = Union[str, float, None, "JSONSerializable"]
|
33
|
+
JSONSerializable = dict[str, JSONSerializableTypes]
|
34
|
+
|
35
|
+
|
36
|
+
class JSONEncodedDict(sa.TypeDecorator[JSONSerializable]):
|
32
37
|
"""
|
33
38
|
Custom SQLAlchemy type for dictionaries stored as JSON
|
34
39
|
|
@@ -40,33 +45,39 @@ class JSONEncodedDict(sa.TypeDecorator):
|
|
40
45
|
|
41
46
|
cache_ok = True
|
42
47
|
|
43
|
-
def process_bind_param(
|
44
|
-
|
45
|
-
|
48
|
+
def process_bind_param(
|
49
|
+
self, value: JSONSerializable | None, dialect: Dialect
|
50
|
+
) -> str | None:
|
51
|
+
if value is None:
|
52
|
+
return None
|
53
|
+
return json.dumps(value)
|
46
54
|
|
47
|
-
|
55
|
+
def process_result_value(
|
56
|
+
self, value: Any | None, dialect: Dialect
|
57
|
+
) -> JSONSerializable | None:
|
58
|
+
if value is None:
|
59
|
+
return None
|
60
|
+
return json.loads(value) # type:ignore
|
48
61
|
|
49
|
-
def process_result_value(self, value, dialect):
|
50
|
-
if value is not None:
|
51
|
-
value = json.loads(value)
|
52
|
-
return value
|
53
62
|
|
63
|
+
class Base(sa.orm.DeclarativeBase):
|
64
|
+
type_annotation_map = {JSONSerializable: JSONEncodedDict, JID: JIDType}
|
54
65
|
|
55
|
-
JSONSerializableTypes = Union[str, float, None, "JSONSerializable"]
|
56
|
-
JSONSerializable = dict[str, JSONSerializableTypes]
|
57
66
|
|
67
|
+
Base.metadata.naming_convention = {
|
68
|
+
"ix": "ix_%(column_0_label)s",
|
69
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
70
|
+
"ck": "ck_%(table_name)s_`%(constraint_name)s`",
|
71
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
72
|
+
"pk": "pk_%(table_name)s",
|
73
|
+
}
|
58
74
|
|
59
|
-
class Base(sa.orm.DeclarativeBase):
|
60
|
-
type_annotation_map = {JSONSerializable: JSONEncodedDict, JID: JIDType}
|
61
|
-
naming_convention = {
|
62
|
-
"ix": "ix_%(column_0_label)s",
|
63
|
-
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
64
|
-
"ck": "ck_%(table_name)s_`%(constraint_name)s`",
|
65
|
-
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
66
|
-
"pk": "pk_%(table_name)s",
|
67
|
-
}
|
68
75
|
|
76
|
+
def get_engine(path: str, echo: bool = False) -> sa.Engine:
|
77
|
+
from sqlalchemy import log as sqlalchemy_log
|
69
78
|
|
70
|
-
def get_engine(path: str) -> sa.Engine:
|
71
79
|
engine = sa.create_engine(path)
|
80
|
+
if echo:
|
81
|
+
sqlalchemy_log._add_default_handler = lambda x: None # type:ignore
|
82
|
+
engine.echo = True
|
72
83
|
return engine
|