template_service.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import hashlib
  2. import io
  3. import logging
  4. import os
  5. import shutil
  6. import time
  7. import zipfile
  8. from concurrent.futures import ThreadPoolExecutor
  9. from typing import Optional
  10. import httpx
  11. from app.config import settings
  12. logger = logging.getLogger(__name__)
  13. class _TemplateFileInfo:
  14. __slots__ = ("size", "etag", "last_modified")
  15. def __init__(self, size: int, etag: str | None, last_modified: str | None):
  16. self.size = size
  17. self.etag = etag
  18. self.last_modified = last_modified
  19. class TemplateService:
  20. def __init__(self):
  21. self._cache: dict[str, str] = {}
  22. def get_template_path(self, template_url: str) -> str:
  23. if not template_url or not template_url.strip():
  24. raise ValueError("template_url is empty")
  25. if settings.template_cache_enabled and template_url in self._cache:
  26. cached = self._cache[template_url]
  27. if os.path.isdir(cached):
  28. logger.info("Using cached template from: %s", cached)
  29. return cached
  30. self._cache.pop(template_url, None)
  31. existing = self._find_in_filesystem(template_url)
  32. if existing:
  33. logger.info("Found existing template in file system: %s", existing)
  34. if settings.template_cache_enabled:
  35. self._cache[template_url] = existing
  36. return existing
  37. path = self._download_and_extract(template_url)
  38. if settings.template_cache_enabled:
  39. self._cache[template_url] = path
  40. return path
  41. def _download_and_extract(self, template_url: str) -> str:
  42. logger.info("Processing template from: %s", template_url)
  43. cache_dir = settings.template_cache_dir
  44. os.makedirs(cache_dir, exist_ok=True)
  45. file_info = self._get_file_info(template_url)
  46. url_hash = str(abs(hash(template_url)))
  47. if file_info:
  48. existing = self._find_by_file_info(cache_dir, url_hash, file_info)
  49. if existing:
  50. logger.info("Template with same content already exists: %s", existing)
  51. return existing
  52. zip_path = self._download_zip(template_url, cache_dir)
  53. try:
  54. md5 = self._calc_md5(zip_path)
  55. template_dir = os.path.join(cache_dir, f"template_{url_hash}_{md5}")
  56. self._cleanup_old_versions(cache_dir, url_hash)
  57. self._extract_zip(zip_path, template_dir)
  58. if file_info:
  59. self._save_metadata(template_dir, file_info)
  60. logger.info("Template extracted to: %s", template_dir)
  61. return template_dir
  62. finally:
  63. if os.path.isfile(zip_path):
  64. os.remove(zip_path)
  65. def _download_zip(self, url: str, cache_dir: str) -> str:
  66. zip_path = os.path.join(cache_dir, f"temp_{int(time.time() * 1000)}.zip")
  67. timeout_sec = settings.template_download_timeout / 1000
  68. max_size = settings.template_download_max_size
  69. with httpx.Client(timeout=timeout_sec, follow_redirects=True) as client:
  70. with client.stream("GET", url) as resp:
  71. resp.raise_for_status()
  72. total = 0
  73. with open(zip_path, "wb") as f:
  74. for chunk in resp.iter_bytes(8192):
  75. f.write(chunk)
  76. total += len(chunk)
  77. if total > max_size:
  78. raise ValueError("Downloaded size exceeds maximum allowed")
  79. logger.info("Downloaded zip file: %d bytes", os.path.getsize(zip_path))
  80. return zip_path
  81. @staticmethod
  82. def _extract_zip(zip_path: str, target_dir: str):
  83. os.makedirs(target_dir, exist_ok=True)
  84. with zipfile.ZipFile(zip_path, "r") as zf:
  85. zf.extractall(target_dir)
  86. logger.info("Extracted zip to: %s", target_dir)
  87. @staticmethod
  88. def _calc_md5(filepath: str) -> str:
  89. md5 = hashlib.md5()
  90. with open(filepath, "rb") as f:
  91. for chunk in iter(lambda: f.read(8192), b""):
  92. md5.update(chunk)
  93. return md5.hexdigest()
  94. @staticmethod
  95. def _cleanup_old_versions(cache_dir: str, url_hash: str):
  96. prefix = f"template_{url_hash}_"
  97. try:
  98. for name in os.listdir(cache_dir):
  99. full = os.path.join(cache_dir, name)
  100. if os.path.isdir(full) and name.startswith(prefix):
  101. shutil.rmtree(full, ignore_errors=True)
  102. logger.info("Cleaned up old template version: %s", full)
  103. except Exception as e:
  104. logger.warning("Error during cleanup: %s", e)
  105. def _find_in_filesystem(self, template_url: str) -> str | None:
  106. cache_dir = settings.template_cache_dir
  107. if not os.path.isdir(cache_dir):
  108. return None
  109. prefix = f"template_{hash(template_url)}_"
  110. try:
  111. for name in os.listdir(cache_dir):
  112. if os.path.isdir(os.path.join(cache_dir, name)) and name.startswith(prefix):
  113. return os.path.join(cache_dir, name)
  114. except Exception as e:
  115. logger.warning("Error finding template: %s", e)
  116. return None
  117. def _get_file_info(self, url: str) -> _TemplateFileInfo | None:
  118. timeout_sec = settings.template_download_timeout / 1000
  119. try:
  120. with httpx.Client(timeout=timeout_sec) as client:
  121. resp = client.head(url, follow_redirects=True)
  122. size = int(resp.headers.get("content-length", -1))
  123. etag = resp.headers.get("etag")
  124. last_modified = resp.headers.get("last-modified")
  125. return _TemplateFileInfo(size, etag, last_modified)
  126. except Exception as e:
  127. logger.warning("Failed to get file info: %s", e)
  128. return None
  129. def _find_by_file_info(self, cache_dir: str, url_hash: str, info: _TemplateFileInfo) -> str | None:
  130. prefix = f"template_{url_hash}_"
  131. try:
  132. for name in os.listdir(cache_dir):
  133. full = os.path.join(cache_dir, name)
  134. if not os.path.isdir(full) or not name.startswith(prefix):
  135. continue
  136. meta_file = os.path.join(full, ".metadata")
  137. if os.path.isfile(meta_file) and self._compare_metadata(meta_file, info):
  138. return full
  139. except Exception as e:
  140. logger.warning("Error finding by file info: %s", e)
  141. return None
  142. @staticmethod
  143. def _compare_metadata(meta_path: str, info: _TemplateFileInfo) -> bool:
  144. try:
  145. with open(meta_path, "r") as f:
  146. for line in f:
  147. if line.startswith("ETag:") and info.etag:
  148. if line[5:].strip() == info.etag:
  149. return True
  150. elif line.startswith("Size:") and info.size > 0:
  151. if int(line[5:].strip()) == info.size:
  152. return True
  153. except Exception:
  154. pass
  155. return False
  156. @staticmethod
  157. def _save_metadata(template_dir: str, info: _TemplateFileInfo):
  158. try:
  159. meta = os.path.join(template_dir, ".metadata")
  160. with open(meta, "w") as f:
  161. f.write(f"ETag: {info.etag or 'unknown'}\n")
  162. f.write(f"Size: {info.size}\n")
  163. f.write(f"Last-Modified: {info.last_modified or 'unknown'}\n")
  164. f.write(f"Created: {time.strftime('%Y-%m-%dT%H:%M:%SZ')}\n")
  165. except Exception as e:
  166. logger.warning("Failed to save metadata: %s", e)
  167. def cleanup_expired_templates(self, max_age_hours: int):
  168. if not settings.template_cache_enabled:
  169. return
  170. cache_dir = settings.template_cache_dir
  171. if not os.path.isdir(cache_dir):
  172. return
  173. cutoff = time.time() - max_age_hours * 3600
  174. try:
  175. for name in os.listdir(cache_dir):
  176. full = os.path.join(cache_dir, name)
  177. if os.path.isdir(full) and os.path.getmtime(full) < cutoff:
  178. shutil.rmtree(full, ignore_errors=True)
  179. logger.info("Cleaned up expired template: %s", full)
  180. except Exception as e:
  181. logger.error("Error during template cleanup: %s", e)
  182. def get_cache_stats(self) -> dict:
  183. stats: dict = {
  184. "cacheEnabled": settings.template_cache_enabled,
  185. "cacheDir": settings.template_cache_dir,
  186. "cachedTemplates": len(self._cache),
  187. }
  188. cache_dir = settings.template_cache_dir
  189. if os.path.isdir(cache_dir):
  190. total_size = 0
  191. dir_count = 0
  192. for root, dirs, files in os.walk(cache_dir):
  193. for f in files:
  194. total_size += os.path.getsize(os.path.join(root, f))
  195. for name in os.listdir(cache_dir):
  196. if os.path.isdir(os.path.join(cache_dir, name)) and name.startswith("template_"):
  197. dir_count += 1
  198. stats["cacheSizeBytes"] = total_size
  199. stats["cacheSizeMB"] = total_size // (1024 * 1024)
  200. stats["templateDirectories"] = dir_count
  201. return stats