casefold_db.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env python
  2. # Copyright 2021 The Matrix.org Foundation C.I.C.
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import argparse
  16. import json
  17. import os
  18. import sqlite3
  19. import sys
  20. from typing import Any, Dict, List, Tuple
  21. import signedjson.sign
  22. from sydent.config import SydentConfig
  23. from sydent.sydent import Sydent, parse_config_file
  24. from sydent.util import json_decoder
  25. from sydent.util.emailutils import sendEmail
  26. from sydent.util.hash import sha256_and_url_safe_base64
  27. from tests.utils import ResolvingMemoryReactorClock
  28. def calculate_lookup_hash(sydent, address):
  29. cur = sydent.db.cursor()
  30. pepper_result = cur.execute("SELECT lookup_pepper from hashing_metadata")
  31. pepper = pepper_result.fetchone()[0]
  32. combo = "%s %s %s" % (address, "email", pepper)
  33. lookup_hash = sha256_and_url_safe_base64(combo)
  34. return lookup_hash
  35. def update_local_associations(
  36. sydent: Sydent, db: sqlite3.Connection, send_email: bool, dry_run: bool
  37. ):
  38. """Update the DB table local_threepid_associations so that all stored
  39. emails are casefolded, and any duplicate mxid's associated with the
  40. given email are deleted.
  41. :return: None
  42. """
  43. cur = db.cursor()
  44. res = cur.execute(
  45. "SELECT address, mxid FROM local_threepid_associations WHERE medium = 'email'"
  46. "ORDER BY ts DESC"
  47. )
  48. # a dict that associates an email address with correspoinding mxids and lookup hashes
  49. associations: Dict[str, List[Tuple[str, str, str]]] = {}
  50. # iterate through selected associations, casefold email, rehash it, and add to
  51. # associations dict
  52. for address, mxid in res.fetchall():
  53. casefold_address = address.casefold()
  54. # rehash email since hashes are case-sensitive
  55. lookup_hash = calculate_lookup_hash(sydent, casefold_address)
  56. if casefold_address in associations:
  57. associations[casefold_address].append((address, mxid, lookup_hash))
  58. else:
  59. associations[casefold_address] = [(address, mxid, lookup_hash)]
  60. # list of arguments to update db with
  61. db_update_args: List[Tuple[str, str, str, str]] = []
  62. # list of mxids to delete
  63. to_delete: List[Tuple[str]] = []
  64. # list of mxids to send emails to letting them know the mxid has been deleted
  65. mxids: List[Tuple[str, str]] = []
  66. for casefold_address, assoc_tuples in associations.items():
  67. db_update_args.append(
  68. (
  69. casefold_address,
  70. assoc_tuples[0][2],
  71. assoc_tuples[0][0],
  72. assoc_tuples[0][1],
  73. )
  74. )
  75. if len(assoc_tuples) > 1:
  76. # Iterate over all associations except for the first one, since we've already
  77. # processed it.
  78. for address, mxid, _ in assoc_tuples[1:]:
  79. to_delete.append((address,))
  80. mxids.append((mxid, address))
  81. # iterate through the mxids and send email, let's only send one email per mxid
  82. if send_email and not dry_run:
  83. for mxid, address in mxids:
  84. processed_mxids = []
  85. if mxid in processed_mxids:
  86. continue
  87. else:
  88. if sydent.config.email.template is None:
  89. templateFile = sydent.get_branded_template(
  90. None,
  91. "migration_template.eml",
  92. ("email", "email.template"),
  93. )
  94. else:
  95. templateFile = sydent.config.email.template
  96. sendEmail(
  97. sydent,
  98. templateFile,
  99. address,
  100. {"mxid": "mxid", "subject_header_value": "MatrixID Update"},
  101. )
  102. processed_mxids.append(mxid)
  103. print(
  104. f"{len(to_delete)} rows to delete, {len(db_update_args)} rows to update in local_threepid_associations"
  105. )
  106. if not dry_run:
  107. if len(to_delete) > 0:
  108. cur.executemany(
  109. "DELETE FROM local_threepid_associations WHERE address = ?", to_delete
  110. )
  111. if len(db_update_args) > 0:
  112. cur.executemany(
  113. "UPDATE local_threepid_associations SET address = ?, lookup_hash = ? WHERE address = ? AND mxid = ?",
  114. db_update_args,
  115. )
  116. # We've finished updating the database, committing the transaction.
  117. db.commit()
  118. def update_global_associations(
  119. sydent: Sydent, db: sqlite3.Connection, send_email: bool, dry_run: bool
  120. ):
  121. """Update the DB table global_threepid_associations so that all stored
  122. emails are casefolded, the signed association is re-signed and any duplicate
  123. mxid's associated with the given email are deleted.
  124. :return: None
  125. """
  126. # get every row where the local server is origin server and medium is email
  127. origin_server = sydent.config.general.server_name
  128. medium = "email"
  129. cur = db.cursor()
  130. res = cur.execute(
  131. "SELECT address, mxid, sgAssoc FROM global_threepid_associations WHERE medium = ?"
  132. "AND originServer = ? ORDER BY ts DESC",
  133. (medium, origin_server),
  134. )
  135. # dict that stores email address with mxid, email address, lookup hash, and
  136. # signed association
  137. associations: Dict[str, List[Tuple[str, str, str, str]]] = {}
  138. # iterate through selected associations, casefold email, rehash it, re-sign the
  139. # associations and add to associations dict
  140. for address, mxid, sg_assoc in res.fetchall():
  141. casefold_address = address.casefold()
  142. # rehash the email since hash functions are case-sensitive
  143. lookup_hash = calculate_lookup_hash(sydent, casefold_address)
  144. # update signed associations with new casefolded address and re-sign
  145. sg_assoc = json_decoder.decode(sg_assoc)
  146. sg_assoc["address"] = address.casefold()
  147. sg_assoc = json.dumps(
  148. signedjson.sign.sign_json(
  149. sg_assoc, sydent.config.general.server_name, sydent.keyring.ed25519
  150. )
  151. )
  152. if casefold_address in associations:
  153. associations[casefold_address].append(
  154. (address, mxid, lookup_hash, sg_assoc)
  155. )
  156. else:
  157. associations[casefold_address] = [(address, mxid, lookup_hash, sg_assoc)]
  158. # list of arguments to update db with
  159. db_update_args: List[Tuple[Any, str, str, str, str]] = []
  160. # list of mxids to delete
  161. to_delete: List[Tuple[str]] = []
  162. for casefold_address, assoc_tuples in associations.items():
  163. db_update_args.append(
  164. (
  165. casefold_address,
  166. assoc_tuples[0][2],
  167. assoc_tuples[0][3],
  168. assoc_tuples[0][0],
  169. assoc_tuples[0][1],
  170. )
  171. )
  172. if len(assoc_tuples) > 1:
  173. # Iterate over all associations except for the first one, since we've already
  174. # processed it.
  175. for address, mxid, _, _ in assoc_tuples[1:]:
  176. to_delete.append((address,))
  177. print(
  178. f"{len(to_delete)} rows to delete, {len(db_update_args)} rows to update in global_threepid_associations"
  179. )
  180. if not dry_run:
  181. if len(to_delete) > 0:
  182. cur.executemany(
  183. "DELETE FROM global_threepid_associations WHERE address = ?", to_delete
  184. )
  185. if len(db_update_args) > 0:
  186. cur.executemany(
  187. "UPDATE global_threepid_associations SET address = ?, lookup_hash = ?, sgAssoc = ? WHERE address = ? AND mxid = ?",
  188. db_update_args,
  189. )
  190. db.commit()
  191. if __name__ == "__main__":
  192. parser = argparse.ArgumentParser(description="Casefold email addresses in database")
  193. parser.add_argument(
  194. "--no-email", action="store_true", help="run script but do not send emails"
  195. )
  196. parser.add_argument(
  197. "--dry-run",
  198. action="store_true",
  199. help="run script but do not send emails or alter database",
  200. )
  201. parser.add_argument("config_path", help="path to the sydent configuration file")
  202. args = parser.parse_args()
  203. # if the path the user gives us doesn't work, find it for them
  204. if not os.path.exists(args.config_path):
  205. print(f"The config file '{args.config_path}' does not exist.")
  206. sys.exit(1)
  207. config = parse_config_file(args.config_path)
  208. sydent_config = SydentConfig()
  209. sydent_config.parse_from_config_parser(config)
  210. reactor = ResolvingMemoryReactorClock()
  211. sydent = Sydent(config, sydent_config, reactor, False)
  212. update_global_associations(sydent, sydent.db, not args.no_email, args.dry_run)
  213. update_local_associations(sydent, sydent.db, not args.no_email, args.dry_run)