Coverage for slidge/db/avatar.py: 89%

225 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-10 12:26 +0000

1import asyncio 

2import hashlib 

3import io 

4import logging 

5from collections.abc import AsyncIterator 

6from concurrent.futures import ThreadPoolExecutor 

7from contextlib import asynccontextmanager 

8from http import HTTPStatus 

9from pathlib import Path 

10from typing import Literal 

11 

12import aiohttp 

13from multidict import CIMultiDictProxy 

14from PIL.Image import Image 

15from PIL.Image import open as open_image 

16from sqlalchemy import select 

17 

18from ..core import config 

19from ..core.attachment_upload import AttachmentUploader 

20from ..util.lock import NamedLockMixin 

21from ..util.types import AnySession, AvatarMetadata 

22from ..util.types import Avatar as AvatarType 

23from .models import Avatar 

24from .store import AvatarStore 

25 

26_AVATAR_FETCH_CONCURRENCY = 8 

27_AVATAR_DOWNLOAD_TIMEOUT = 30 

28 

29 

30class CachedAvatar: 

31 def __init__(self, stored: Avatar, root_dir: Path) -> None: 

32 self._stored = stored 

33 self._root = root_dir 

34 

35 @property 

36 def pk(self) -> int | None: 

37 return self._stored.id 

38 

39 @property 

40 def hash(self) -> str | None: 

41 """ 

42 SHA1 of avatar in PNG format, stored in self._root, meant to be 

43 served in-band. `None` if the avatar is only available through HTTP. 

44 """ 

45 return self._stored.hash 

46 

47 @property 

48 def height(self) -> int: 

49 return self._stored.height 

50 

51 @property 

52 def width(self) -> int: 

53 return self._stored.width 

54 

55 @property 

56 def etag(self) -> str | None: 

57 return self._stored.etag 

58 

59 @property 

60 def last_modified(self) -> str | None: 

61 return self._stored.last_modified 

62 

63 @property 

64 def data(self) -> bytes: 

65 return self.path.read_bytes() 

66 

67 @property 

68 def path(self) -> Path: 

69 assert self.hash 

70 return (self._root / self.hash).with_suffix(".png") 

71 

72 

73class NotModified(Exception): 

74 pass 

75 

76 

77class AvatarDownloadError(Exception): 

78 def __init__(self, url: str, status: int | None = None, message: str = "") -> None: 

79 self.url = url 

80 self.status = status 

81 self.message = message 

82 if status is None: 

83 super().__init__(f"{message} ({url})" if message else url) 

84 else: 

85 super().__init__(f"{status} {message} ({url})") 

86 

87 

88class AvatarCache(NamedLockMixin): 

89 dir: Path 

90 http: aiohttp.ClientSession 

91 store: AvatarStore 

92 

93 def __init__(self) -> None: 

94 self._thread_pool = ThreadPoolExecutor(config.AVATAR_RESAMPLING_THREADS) 

95 self._download_semaphore = asyncio.BoundedSemaphore(_AVATAR_FETCH_CONCURRENCY) 

96 super().__init__() 

97 

98 def from_stored(self, stored: Avatar) -> CachedAvatar: 

99 return CachedAvatar(stored, self.dir) 

100 

101 def set_dir(self, path: Path) -> None: 

102 self.dir = path 

103 self.dir.mkdir(exist_ok=True) 

104 for f in path.glob("*"): 

105 if f.suffix != ".png": 

106 # FIXME: remove this before 1.0.0! 

107 # slidge v0.5.0 wrote useless non-PNG files in here, this 

108 # cleans them up 

109 f.unlink() 

110 log.debug("Checking avatar files") 

111 with self.store.session(expire_on_commit=False) as orm: 

112 for stored in orm.query(Avatar).all(): 

113 avatar = CachedAvatar(stored, path) 

114 if avatar.hash is None or (avatar.hash and avatar.path.exists()): 

115 continue 

116 log.warning( 

117 "Removing avatar %s from store because %s does not exist", 

118 avatar.hash, 

119 avatar.path, 

120 ) 

121 orm.delete(stored) 

122 orm.commit() 

123 

124 def close(self) -> None: 

125 self._thread_pool.shutdown(cancel_futures=True) 

126 

127 def __get_http_headers( 

128 self, cached: CachedAvatar | Avatar | None = None 

129 ) -> dict[str, str]: 

130 headers = {} 

131 if ( 

132 cached 

133 and cached.hash 

134 and (self.dir / cached.hash).with_suffix(".png").exists() 

135 ): 

136 if last_modified := cached.last_modified: 

137 headers["If-Modified-Since"] = last_modified 

138 if etag := cached.etag: 

139 headers["If-None-Match"] = etag 

140 return headers 

141 

142 @asynccontextmanager 

143 async def _fetch( 

144 self, 

145 url: str, 

146 headers: dict[str, str], 

147 method: Literal["GET", "HEAD"], 

148 ) -> AsyncIterator[aiohttp.ClientResponse]: 

149 """Common bits of logic for HTTP requests fetching avatar (meta)data.""" 

150 async with self._download_semaphore: 

151 try: 

152 async with self.http.request( 

153 method, 

154 url, 

155 headers=headers, 

156 timeout=aiohttp.ClientTimeout(total=_AVATAR_DOWNLOAD_TIMEOUT), 

157 ) as response: 

158 yield response 

159 except aiohttp.ClientResponseError as e: 

160 raise AvatarDownloadError(url, e.status, e.message) from e 

161 except (aiohttp.ClientError, TimeoutError) as e: 

162 raise AvatarDownloadError(url, message=str(e)) from e 

163 

164 async def __download_if_modified( 

165 self, 

166 url: str, 

167 headers: dict[str, str], 

168 ) -> tuple[CIMultiDictProxy[str], bytes]: 

169 """ 

170 Download avatar only if it has been modified compared to what we have 

171 in cache. 

172 

173 :return: HTTP response headers, data 

174 :raise: NotModified if fetching was not necessary 

175 """ 

176 async with self._fetch(url, headers, "GET") as response: 

177 if response.status == HTTPStatus.NOT_MODIFIED: 

178 log.debug("Using avatar cache for %s", url) 

179 raise NotModified 

180 response.raise_for_status() 

181 data = await response.read() 

182 return response.headers, data 

183 

184 async def __is_modified(self, url: str, headers: dict[str, str]) -> bool: 

185 async with self._fetch(url, headers, "HEAD") as response: 

186 response.raise_for_status() 

187 if response.status == HTTPStatus.NOT_MODIFIED: 

188 return False 

189 cached_last_modified = headers.get("If-Modified-Since") 

190 if not cached_last_modified: 

191 return True 

192 response_last_modified = response.headers.get("last-modified") 

193 if not response_last_modified: 

194 return True 

195 return cached_last_modified != response_last_modified 

196 

197 async def url_modified(self, url: str) -> bool: 

198 with self.store.session() as orm: 

199 cached = orm.query(Avatar).filter_by(url=url).one_or_none() 

200 if cached is None: 

201 return True 

202 headers = self.__get_http_headers(cached) 

203 return await self.__is_modified(url, headers) 

204 

205 @staticmethod 

206 async def __open_image(avatar: AvatarType) -> Image: 

207 if avatar.data is not None: 

208 return open_image(io.BytesIO(avatar.data)) 

209 elif avatar.path is not None: 

210 return open_image(avatar.path) 

211 raise TypeError("Avatar must be bytes or a Path", avatar) 

212 

213 async def get( 

214 self, avatar: AvatarType, session: AnySession | None = None 

215 ) -> CachedAvatar: 

216 if avatar.unique_id is not None: 

217 with self.store.session() as orm: 

218 stored = ( 

219 orm.query(Avatar) 

220 .filter_by(legacy_id=str(avatar.unique_id)) 

221 .one_or_none() 

222 ) 

223 if stored is not None: 

224 return self.from_stored(stored) 

225 

226 if avatar.url is not None: 

227 return await self.__fetch_url_if_not_cached(avatar, session=session) 

228 

229 return await self.__process( 

230 avatar, await self.__open_image(avatar), session=session 

231 ) 

232 

233 async def __fetch_url_if_not_cached( 

234 self, 

235 avatar: AvatarType, 

236 session: AnySession | None = None, 

237 convert: bool = True, 

238 ) -> CachedAvatar: 

239 assert avatar.url is not None 

240 async with self.lock(avatar.unique_id or avatar.url): 

241 with self.store.session() as orm: 

242 if avatar.unique_id is None: 

243 stored = orm.query(Avatar).filter_by(url=avatar.url).one_or_none() 

244 else: 

245 stored = ( 

246 orm.query(Avatar) 

247 .filter_by(legacy_id=str(avatar.unique_id)) 

248 .one_or_none() 

249 ) 

250 if stored is not None: 

251 return self.from_stored(stored) 

252 

253 try: 

254 response_headers, data = await self.__download_if_modified( 

255 avatar.url, self.__get_http_headers(stored) 

256 ) 

257 except NotModified: 

258 assert stored is not None 

259 return self.from_stored(stored) 

260 

261 return await self.__process( 

262 avatar, 

263 open_image(io.BytesIO(data)), 

264 response_headers, 

265 session, 

266 img_bytes=data, 

267 ) 

268 

269 async def __process( 

270 self, 

271 avatar: AvatarType, 

272 img: Image, 

273 response_headers: CIMultiDictProxy[str] | None = None, 

274 session: AnySession | None = None, 

275 img_bytes: bytes | None = None, 

276 ) -> CachedAvatar: 

277 too_big = (size := config.AVATAR_SIZE) and any(x > size for x in img.size) 

278 if too_big: 

279 await asyncio.get_event_loop().run_in_executor( 

280 self._thread_pool, img.thumbnail, (size, size) 

281 ) 

282 img_bytes = _get_png_bytes(img) 

283 log.debug("Resampled image to %s", img.size) 

284 else: 

285 img_bytes = ( 

286 _get_any_bytes(img_bytes, avatar) 

287 if img.format == "PNG" 

288 else _get_png_bytes(img) 

289 ) 

290 

291 hash_ = hashlib.sha1(img_bytes).hexdigest() 

292 http_metadata = await self.__upload(session, avatar, img, hash_, len(img_bytes)) 

293 file_path = (self.dir / hash_).with_suffix(".png") 

294 if file_path.exists(): 

295 log.warning("Overwriting %s", file_path) 

296 with file_path.open("wb") as file: 

297 file.write(img_bytes) 

298 with self.store.session(expire_on_commit=False) as orm: 

299 stored = orm.execute(select(Avatar).where(Avatar.hash == hash_)).scalar() 

300 

301 if stored is not None: 

302 if ( 

303 avatar.unique_id is not None 

304 and str(avatar.unique_id) != stored.legacy_id 

305 ): 

306 log.warning( 

307 "Updating the 'unique' hash of an avatar, was '%s', is now '%s'", 

308 stored.legacy_id, 

309 avatar.unique_id, 

310 ) 

311 stored.legacy_id = str(avatar.unique_id) 

312 stored.set_http_metadata(http_metadata) 

313 orm.add(stored) 

314 orm.commit() 

315 

316 return self.from_stored(stored) 

317 

318 stored = Avatar( 

319 hash=hash_, 

320 height=img.height, 

321 width=img.width, 

322 url=avatar.url, 

323 legacy_id=avatar.unique_id, 

324 ) 

325 stored.set_http_metadata(http_metadata) 

326 

327 if response_headers: 

328 stored.etag = response_headers.get("etag") 

329 stored.last_modified = response_headers.get("last-modified") 

330 

331 with self.store.session(expire_on_commit=False) as orm: 

332 if avatar.url is not None: 

333 existing = orm.execute( 

334 select(Avatar).filter_by(url=avatar.url) 

335 ).scalar_one_or_none() 

336 if existing is not None: 

337 orm.delete(existing) 

338 orm.commit() 

339 orm.add(stored) 

340 orm.commit() 

341 return self.from_stored(stored) 

342 

343 async def __upload( 

344 self, 

345 session: AnySession | None, 

346 avatar: AvatarType, 

347 img: Image, 

348 hash_: str, 

349 size: int, 

350 ) -> AvatarMetadata | None: 

351 if not session: 

352 return None 

353 if config.USE_ATTACHMENT_ORIGINAL_URLS and avatar.url is not None: 

354 assert img.format 

355 return AvatarMetadata( 

356 url=avatar.url, 

357 width=img.width, 

358 height=img.height, 

359 bytes=size, 

360 type=img.format.lower(), 

361 id=hash_, 

362 ) 

363 

364 return await AttachmentUploader(session.xmpp, session).upload_avatar( 

365 avatar, img, hash_ 

366 ) 

367 

368 

369def _get_png_bytes(img: Image) -> bytes: 

370 if img.mode == "CMYK": 

371 # PNG does not support CMYK. 

372 img = img.convert("RGB") 

373 with io.BytesIO() as f: 

374 img.save(f, format="PNG") 

375 return f.getvalue() 

376 

377 

378def _get_any_bytes(img_bytes: bytes | None, avatar: AvatarType) -> bytes: 

379 r = img_bytes or avatar.data 

380 if r is None: 

381 if avatar.path is None: 

382 raise RuntimeError("NEVER") 

383 r = avatar.path.read_bytes() 

384 return r 

385 

386 

387avatar_cache = AvatarCache() 

388log = logging.getLogger(__name__) 

389_download_lock = asyncio.Lock() 

390 

391__all__ = ("AvatarType", "CachedAvatar", "avatar_cache")