slidge 0.1.2__py3-none-any.whl → 0.2.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.
Files changed (63) hide show
  1. slidge/__init__.py +3 -5
  2. slidge/__main__.py +2 -196
  3. slidge/__version__.py +5 -0
  4. slidge/command/adhoc.py +8 -1
  5. slidge/command/admin.py +5 -6
  6. slidge/command/base.py +1 -2
  7. slidge/command/register.py +32 -16
  8. slidge/command/user.py +85 -5
  9. slidge/contact/contact.py +93 -31
  10. slidge/contact/roster.py +54 -39
  11. slidge/core/config.py +13 -7
  12. slidge/core/gateway/base.py +139 -34
  13. slidge/core/gateway/disco.py +2 -4
  14. slidge/core/gateway/mam.py +1 -4
  15. slidge/core/gateway/ping.py +2 -3
  16. slidge/core/gateway/presence.py +1 -1
  17. slidge/core/gateway/registration.py +32 -21
  18. slidge/core/gateway/search.py +3 -5
  19. slidge/core/gateway/session_dispatcher.py +109 -51
  20. slidge/core/gateway/vcard_temp.py +6 -4
  21. slidge/core/mixins/__init__.py +11 -1
  22. slidge/core/mixins/attachment.py +15 -10
  23. slidge/core/mixins/avatar.py +66 -18
  24. slidge/core/mixins/base.py +8 -2
  25. slidge/core/mixins/message.py +11 -7
  26. slidge/core/mixins/message_maker.py +17 -9
  27. slidge/core/mixins/presence.py +14 -4
  28. slidge/core/pubsub.py +54 -212
  29. slidge/core/session.py +65 -33
  30. slidge/db/__init__.py +4 -0
  31. slidge/db/alembic/env.py +64 -0
  32. slidge/db/alembic/script.py.mako +26 -0
  33. slidge/db/alembic/versions/09f27f098baa_add_missing_attributes_in_room.py +36 -0
  34. slidge/db/alembic/versions/29f5280c61aa_store_subject_setter_in_room.py +37 -0
  35. slidge/db/alembic/versions/8d2ced764698_rely_on_db_to_store_contacts_rooms_and_.py +133 -0
  36. slidge/db/alembic/versions/aa9d82a7f6ef_db_creation.py +76 -0
  37. slidge/db/alembic/versions/b33993e87db3_move_everything_to_persistent_db.py +214 -0
  38. slidge/db/alembic/versions/e91195719c2c_store_users_avatars_persistently.py +26 -0
  39. slidge/db/avatar.py +224 -0
  40. slidge/db/meta.py +65 -0
  41. slidge/db/models.py +365 -0
  42. slidge/db/store.py +976 -0
  43. slidge/group/archive.py +13 -14
  44. slidge/group/bookmarks.py +59 -56
  45. slidge/group/participant.py +81 -29
  46. slidge/group/room.py +242 -142
  47. slidge/main.py +201 -0
  48. slidge/migration.py +30 -0
  49. slidge/slixfix/__init__.py +35 -2
  50. slidge/slixfix/roster.py +11 -4
  51. slidge/slixfix/xep_0292/vcard4.py +1 -0
  52. slidge/util/db.py +1 -47
  53. slidge/util/test.py +21 -4
  54. slidge/util/types.py +24 -4
  55. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/METADATA +3 -1
  56. slidge-0.2.0a0.dist-info/RECORD +108 -0
  57. slidge/core/cache.py +0 -183
  58. slidge/util/schema.sql +0 -126
  59. slidge/util/sql.py +0 -508
  60. slidge-0.1.2.dist-info/RECORD +0 -96
  61. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/LICENSE +0 -0
  62. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/WHEEL +0 -0
  63. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,214 @@
1
+ """Move everything to persistent DB
2
+
3
+ Revision ID: b33993e87db3
4
+ Revises: e91195719c2c
5
+ Create Date: 2024-06-25 16:09:36.663953
6
+
7
+ """
8
+
9
+ import shutil
10
+ from typing import Sequence, Union
11
+
12
+ import sqlalchemy as sa
13
+ from alembic import op
14
+
15
+ import slidge.db.meta
16
+ from slidge import global_config
17
+
18
+ # revision identifiers, used by Alembic.
19
+ revision: str = "b33993e87db3"
20
+ down_revision: Union[str, None] = "e91195719c2c"
21
+ branch_labels: Union[str, Sequence[str], None] = None
22
+ depends_on: Union[str, Sequence[str], None] = None
23
+
24
+
25
+ def upgrade() -> None:
26
+ # ### commands auto generated by Alembic - please adjust! ###
27
+ op.create_table(
28
+ "avatar",
29
+ sa.Column("id", sa.Integer(), nullable=False),
30
+ sa.Column("filename", sa.String(), nullable=False),
31
+ sa.Column("hash", sa.String(), nullable=False),
32
+ sa.Column("height", sa.Integer(), nullable=False),
33
+ sa.Column("width", sa.Integer(), nullable=False),
34
+ sa.Column("legacy_id", sa.String(), nullable=True),
35
+ sa.Column("url", sa.String(), nullable=True),
36
+ sa.Column("etag", sa.String(), nullable=True),
37
+ sa.Column("last_modified", sa.String(), nullable=True),
38
+ sa.PrimaryKeyConstraint("id"),
39
+ sa.UniqueConstraint("filename"),
40
+ sa.UniqueConstraint("hash"),
41
+ sa.UniqueConstraint("legacy_id"),
42
+ )
43
+ op.create_table(
44
+ "attachment",
45
+ sa.Column("id", sa.Integer(), nullable=False),
46
+ sa.Column("user_account_id", sa.Integer(), nullable=False),
47
+ sa.Column("legacy_file_id", sa.String(), nullable=True),
48
+ sa.Column("url", sa.String(), nullable=False),
49
+ sa.Column("sims", sa.String(), nullable=True),
50
+ sa.Column("sfs", sa.String(), nullable=True),
51
+ sa.ForeignKeyConstraint(
52
+ ["user_account_id"],
53
+ ["user_account.id"],
54
+ ),
55
+ sa.PrimaryKeyConstraint("id"),
56
+ )
57
+ op.create_index(
58
+ op.f("ix_attachment_legacy_file_id"),
59
+ "attachment",
60
+ ["legacy_file_id"],
61
+ unique=False,
62
+ )
63
+ op.create_index(op.f("ix_attachment_url"), "attachment", ["url"], unique=False)
64
+ op.create_table(
65
+ "contact",
66
+ sa.Column("id", sa.Integer(), nullable=False),
67
+ sa.Column("user_account_id", sa.Integer(), nullable=False),
68
+ sa.Column("legacy_id", sa.String(), nullable=False),
69
+ sa.Column("jid", slidge.db.meta.JIDType(), nullable=False),
70
+ sa.Column("avatar_id", sa.Integer(), nullable=True),
71
+ sa.Column("nick", sa.String(), nullable=True),
72
+ sa.Column("cached_presence", sa.Boolean(), nullable=False),
73
+ sa.Column("last_seen", sa.DateTime(), nullable=True),
74
+ sa.Column("ptype", sa.String(), nullable=True),
75
+ sa.Column("pstatus", sa.String(), nullable=True),
76
+ sa.Column("pshow", sa.String(), nullable=True),
77
+ sa.ForeignKeyConstraint(
78
+ ["avatar_id"],
79
+ ["avatar.id"],
80
+ ),
81
+ sa.ForeignKeyConstraint(
82
+ ["user_account_id"],
83
+ ["user_account.id"],
84
+ ),
85
+ sa.PrimaryKeyConstraint("id"),
86
+ sa.UniqueConstraint("user_account_id", "jid"),
87
+ sa.UniqueConstraint("user_account_id", "legacy_id"),
88
+ )
89
+ op.create_table(
90
+ "legacy_ids_multi",
91
+ sa.Column("id", sa.Integer(), nullable=False),
92
+ sa.Column("user_account_id", sa.Integer(), nullable=False),
93
+ sa.Column("legacy_id", sa.String(), nullable=False),
94
+ sa.ForeignKeyConstraint(
95
+ ["user_account_id"],
96
+ ["user_account.id"],
97
+ ),
98
+ sa.PrimaryKeyConstraint("id"),
99
+ )
100
+ op.create_index(
101
+ "legacy_ids_multi_user_account_id_legacy_id",
102
+ "legacy_ids_multi",
103
+ ["user_account_id", "legacy_id"],
104
+ unique=True,
105
+ )
106
+ op.create_table(
107
+ "room",
108
+ sa.Column("id", sa.Integer(), nullable=False),
109
+ sa.Column("user_account_id", sa.Integer(), nullable=False),
110
+ sa.Column("legacy_id", sa.String(), nullable=False),
111
+ sa.Column("jid", slidge.db.meta.JIDType(), nullable=False),
112
+ sa.Column("avatar_id", sa.Integer(), nullable=True),
113
+ sa.Column("name", sa.String(), nullable=True),
114
+ sa.ForeignKeyConstraint(
115
+ ["avatar_id"],
116
+ ["avatar.id"],
117
+ ),
118
+ sa.ForeignKeyConstraint(
119
+ ["user_account_id"],
120
+ ["user_account.id"],
121
+ ),
122
+ sa.PrimaryKeyConstraint("id"),
123
+ sa.UniqueConstraint("jid"),
124
+ sa.UniqueConstraint("legacy_id"),
125
+ )
126
+ op.create_table(
127
+ "xmpp_to_legacy_ids",
128
+ sa.Column("id", sa.Integer(), nullable=False),
129
+ sa.Column("user_account_id", sa.Integer(), nullable=False),
130
+ sa.Column("xmpp_id", sa.String(), nullable=False),
131
+ sa.Column("legacy_id", sa.String(), nullable=False),
132
+ sa.Column(
133
+ "type",
134
+ sa.Enum("DM", "GROUP_CHAT", "THREAD", name="xmpptolegacyenum"),
135
+ nullable=False,
136
+ ),
137
+ sa.ForeignKeyConstraint(
138
+ ["user_account_id"],
139
+ ["user_account.id"],
140
+ ),
141
+ sa.PrimaryKeyConstraint("id"),
142
+ )
143
+ op.create_index(
144
+ "xmpp_legacy",
145
+ "xmpp_to_legacy_ids",
146
+ ["user_account_id", "xmpp_id", "legacy_id"],
147
+ unique=True,
148
+ )
149
+ op.create_table(
150
+ "mam",
151
+ sa.Column("id", sa.Integer(), nullable=False),
152
+ sa.Column("room_id", sa.Integer(), nullable=False),
153
+ sa.Column("stanza_id", sa.String(), nullable=False),
154
+ sa.Column("timestamp", sa.DateTime(), nullable=False),
155
+ sa.Column("author_jid", slidge.db.meta.JIDType(), nullable=False),
156
+ sa.Column("stanza", sa.String(), nullable=False),
157
+ sa.ForeignKeyConstraint(
158
+ ["room_id"],
159
+ ["room.id"],
160
+ ),
161
+ sa.PrimaryKeyConstraint("id"),
162
+ sa.UniqueConstraint("room_id", "stanza_id"),
163
+ )
164
+ op.create_table(
165
+ "xmpp_ids_multi",
166
+ sa.Column("id", sa.Integer(), nullable=False),
167
+ sa.Column("user_account_id", sa.Integer(), nullable=False),
168
+ sa.Column("xmpp_id", sa.String(), nullable=False),
169
+ sa.Column("legacy_ids_multi_id", sa.Integer(), nullable=False),
170
+ sa.ForeignKeyConstraint(
171
+ ["legacy_ids_multi_id"],
172
+ ["legacy_ids_multi.id"],
173
+ ),
174
+ sa.ForeignKeyConstraint(
175
+ ["user_account_id"],
176
+ ["user_account.id"],
177
+ ),
178
+ sa.PrimaryKeyConstraint("id"),
179
+ )
180
+ op.create_index(
181
+ "legacy_ids_multi_user_account_id_xmpp_id",
182
+ "xmpp_ids_multi",
183
+ ["user_account_id", "xmpp_id"],
184
+ unique=True,
185
+ )
186
+
187
+ try:
188
+ shutil.rmtree(global_config.HOME_DIR / "slidge_avatars_v2")
189
+ except (FileNotFoundError, AttributeError):
190
+ pass
191
+
192
+ # ### end Alembic commands ###
193
+
194
+
195
+ def downgrade() -> None:
196
+ # ### commands auto generated by Alembic - please adjust! ###
197
+ op.drop_index(
198
+ "legacy_ids_multi_user_account_id_xmpp_id", table_name="xmpp_ids_multi"
199
+ )
200
+ op.drop_table("xmpp_ids_multi")
201
+ op.drop_table("mam")
202
+ op.drop_index("xmpp_legacy", table_name="xmpp_to_legacy_ids")
203
+ op.drop_table("xmpp_to_legacy_ids")
204
+ op.drop_table("room")
205
+ op.drop_index(
206
+ "legacy_ids_multi_user_account_id_legacy_id", table_name="legacy_ids_multi"
207
+ )
208
+ op.drop_table("legacy_ids_multi")
209
+ op.drop_table("contact")
210
+ op.drop_index(op.f("ix_attachment_url"), table_name="attachment")
211
+ op.drop_index(op.f("ix_attachment_legacy_file_id"), table_name="attachment")
212
+ op.drop_table("attachment")
213
+ op.drop_table("avatar")
214
+ # ### end Alembic commands ###
@@ -0,0 +1,26 @@
1
+ """Store users' avatars' hashes persistently
2
+
3
+ Revision ID: e91195719c2c
4
+ Revises: aa9d82a7f6ef
5
+ Create Date: 2024-06-01 14:14:51.984943
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 = "e91195719c2c"
16
+ down_revision: Union[str, None] = "aa9d82a7f6ef"
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
+ op.add_column("user_account", sa.Column("avatar_hash", sa.String(), nullable=True))
23
+
24
+
25
+ def downgrade() -> None:
26
+ op.drop_column("user_account", "avatar_hash")
slidge/db/avatar.py ADDED
@@ -0,0 +1,224 @@
1
+ import asyncio
2
+ import hashlib
3
+ import io
4
+ import logging
5
+ import uuid
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from http import HTTPStatus
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Optional
11
+
12
+ import aiohttp
13
+ from multidict import CIMultiDictProxy
14
+ from PIL.Image import Image
15
+ from PIL.Image import open as open_image
16
+ from sqlalchemy import select
17
+
18
+ from slidge.core import config
19
+ from slidge.db.models import Avatar
20
+ from slidge.db.store import AvatarStore
21
+ from slidge.util.types import URL, AvatarType, LegacyFileIdType
22
+
23
+
24
+ @dataclass
25
+ class CachedAvatar:
26
+ pk: int
27
+ filename: str
28
+ hash: str
29
+ height: int
30
+ width: int
31
+ root: Path
32
+ etag: Optional[str] = None
33
+ last_modified: Optional[str] = None
34
+
35
+ @property
36
+ def data(self):
37
+ return self.path.read_bytes()
38
+
39
+ @property
40
+ def path(self):
41
+ return self.root / self.filename
42
+
43
+ @staticmethod
44
+ def from_store(stored: Avatar, root_dir: Path):
45
+ return CachedAvatar(
46
+ pk=stored.id,
47
+ filename=stored.filename,
48
+ hash=stored.hash,
49
+ height=stored.height,
50
+ width=stored.width,
51
+ etag=stored.etag,
52
+ root=root_dir,
53
+ last_modified=stored.last_modified,
54
+ )
55
+
56
+
57
+ class NotModified(Exception):
58
+ pass
59
+
60
+
61
+ class AvatarCache:
62
+ dir: Path
63
+ http: aiohttp.ClientSession
64
+ store: AvatarStore
65
+ legacy_avatar_type: Callable[[str], Any] = str
66
+
67
+ def __init__(self):
68
+ self._thread_pool = ThreadPoolExecutor(config.AVATAR_RESAMPLING_THREADS)
69
+
70
+ def set_dir(self, path: Path):
71
+ self.dir = path
72
+ self.dir.mkdir(exist_ok=True)
73
+
74
+ def close(self):
75
+ self._thread_pool.shutdown(cancel_futures=True)
76
+
77
+ def __get_http_headers(self, cached: Optional[CachedAvatar | Avatar]):
78
+ headers = {}
79
+ if cached and (self.dir / cached.filename).exists():
80
+ if last_modified := cached.last_modified:
81
+ headers["If-Modified-Since"] = last_modified
82
+ if etag := cached.etag:
83
+ headers["If-None-Match"] = etag
84
+ return headers
85
+
86
+ async def __download(
87
+ self,
88
+ url: str,
89
+ headers: dict[str, str],
90
+ ) -> tuple[Image, CIMultiDictProxy[str]]:
91
+ async with self.http.get(url, headers=headers) as response:
92
+ if response.status == HTTPStatus.NOT_MODIFIED:
93
+ log.debug("Using avatar cache for %s", url)
94
+ raise NotModified
95
+ return (
96
+ open_image(io.BytesIO(await response.read())),
97
+ response.headers,
98
+ )
99
+
100
+ async def __is_modified(self, url, headers) -> bool:
101
+ async with self.http.head(url, headers=headers) as response:
102
+ return response.status != HTTPStatus.NOT_MODIFIED
103
+
104
+ async def url_modified(self, url: URL) -> bool:
105
+ cached = self.store.get_by_url(url)
106
+ if cached is None:
107
+ return True
108
+ headers = self.__get_http_headers(cached)
109
+ return await self.__is_modified(url, headers)
110
+
111
+ def get(self, unique_id: LegacyFileIdType | URL) -> Optional[CachedAvatar]:
112
+ if isinstance(unique_id, URL):
113
+ stored = self.store.get_by_url(unique_id)
114
+ else:
115
+ stored = self.store.get_by_legacy_id(str(unique_id))
116
+ if stored is None:
117
+ return None
118
+ return CachedAvatar.from_store(stored, self.dir)
119
+
120
+ def get_by_pk(self, pk: int) -> CachedAvatar:
121
+ stored = self.store.get_by_pk(pk)
122
+ assert stored is not None
123
+ return CachedAvatar.from_store(stored, self.dir)
124
+
125
+ @staticmethod
126
+ async def _get_image(avatar: AvatarType) -> Image:
127
+ if isinstance(avatar, bytes):
128
+ return open_image(io.BytesIO(avatar))
129
+ elif isinstance(avatar, Path):
130
+ return open_image(avatar)
131
+ raise TypeError("Avatar must be bytes or a Path", avatar)
132
+
133
+ async def convert_or_get(
134
+ self,
135
+ avatar: AvatarType,
136
+ unique_id: Optional[LegacyFileIdType],
137
+ ) -> CachedAvatar:
138
+ if unique_id is not None:
139
+ cached = self.get(str(unique_id))
140
+ if cached is not None:
141
+ return cached
142
+
143
+ if isinstance(avatar, (URL, str)):
144
+ if unique_id is None:
145
+ stored = self.store.get_by_url(avatar)
146
+ try:
147
+ img, response_headers = await self.__download(
148
+ avatar, self.__get_http_headers(stored)
149
+ )
150
+ except NotModified:
151
+ assert stored is not None
152
+ return CachedAvatar.from_store(stored, self.dir)
153
+
154
+ else:
155
+ img, _ = await self.__download(avatar, {})
156
+ response_headers = None
157
+ else:
158
+ img = await self._get_image(avatar)
159
+ response_headers = None
160
+ with self.store.session() as orm:
161
+ stored = orm.execute(
162
+ select(Avatar).where(Avatar.legacy_id == str(unique_id))
163
+ ).scalar()
164
+ if stored is not None and stored.url is None:
165
+ return CachedAvatar.from_store(stored, self.dir)
166
+
167
+ resize = (size := config.AVATAR_SIZE) and any(x > size for x in img.size)
168
+ if resize:
169
+ await asyncio.get_event_loop().run_in_executor(
170
+ self._thread_pool, img.thumbnail, (size, size)
171
+ )
172
+ log.debug("Resampled image to %s", img.size)
173
+
174
+ filename = str(uuid.uuid1()) + ".png"
175
+ file_path = self.dir / filename
176
+
177
+ if (
178
+ not resize
179
+ and img.format == "PNG"
180
+ and isinstance(unique_id, str)
181
+ and (path := Path(unique_id))
182
+ and path.exists()
183
+ ):
184
+ img_bytes = path.read_bytes()
185
+ else:
186
+ with io.BytesIO() as f:
187
+ img.save(f, format="PNG")
188
+ img_bytes = f.getvalue()
189
+
190
+ with file_path.open("wb") as file:
191
+ file.write(img_bytes)
192
+
193
+ hash_ = hashlib.sha1(img_bytes).hexdigest()
194
+
195
+ stored = orm.execute(select(Avatar).where(Avatar.hash == hash_)).scalar()
196
+
197
+ if stored is not None:
198
+ return CachedAvatar.from_store(stored, self.dir)
199
+
200
+ stored = Avatar(
201
+ filename=filename,
202
+ hash=hash_,
203
+ height=img.height,
204
+ width=img.width,
205
+ legacy_id=None if unique_id is None else str(unique_id),
206
+ url=avatar if isinstance(avatar, (URL, str)) else None,
207
+ )
208
+ if response_headers:
209
+ stored.etag = response_headers.get("etag")
210
+ stored.last_modified = response_headers.get("last-modified")
211
+
212
+ orm.add(stored)
213
+ orm.commit()
214
+ return CachedAvatar.from_store(stored, self.dir)
215
+
216
+
217
+ avatar_cache = AvatarCache()
218
+ log = logging.getLogger(__name__)
219
+ _download_lock = asyncio.Lock()
220
+
221
+ __all__ = (
222
+ "CachedAvatar",
223
+ "avatar_cache",
224
+ )
slidge/db/meta.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Union
5
+
6
+ import sqlalchemy as sa
7
+ from slixmpp import JID
8
+
9
+
10
+ class JIDType(sa.TypeDecorator[JID]):
11
+ """
12
+ Custom SQLAlchemy type for JIDs
13
+ """
14
+
15
+ impl = sa.types.TEXT
16
+ cache_ok = True
17
+
18
+ def process_bind_param(self, value: JID | None, dialect: sa.Dialect) -> str | None:
19
+ if value is None:
20
+ return value
21
+ return str(value)
22
+
23
+ def process_result_value(
24
+ self, value: str | None, dialect: sa.Dialect
25
+ ) -> JID | None:
26
+ if value is None:
27
+ return value
28
+ return JID(value)
29
+
30
+
31
+ class JSONEncodedDict(sa.TypeDecorator):
32
+ """
33
+ Custom SQLAlchemy type for dictionaries stored as JSON
34
+
35
+ Note that mutations of the dictionary are not detected by SQLAlchemy,
36
+ which is why use ``attributes.flag_modified()`` in ``UserStore.update()``
37
+ """
38
+
39
+ impl = sa.VARCHAR
40
+
41
+ cache_ok = True
42
+
43
+ def process_bind_param(self, value, dialect):
44
+ if value is not None:
45
+ value = json.dumps(value)
46
+
47
+ return value
48
+
49
+ def process_result_value(self, value, dialect):
50
+ if value is not None:
51
+ value = json.loads(value)
52
+ return value
53
+
54
+
55
+ JSONSerializableTypes = Union[str, float, None, "JSONSerializable"]
56
+ JSONSerializable = dict[str, JSONSerializableTypes]
57
+
58
+
59
+ class Base(sa.orm.DeclarativeBase):
60
+ type_annotation_map = {JSONSerializable: JSONEncodedDict, JID: JIDType}
61
+
62
+
63
+ def get_engine(path: str) -> sa.Engine:
64
+ engine = sa.create_engine(path)
65
+ return engine