]> git.mxchange.org Git - fba.git/blob - fba/models/instances.py
8a4103ef518d45bf01221270541c6c76a507a5bc
[fba.git] / fba / models / instances.py
1 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
2 # Copyright (C) 2023 Free Software Foundation
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published
6 # by the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17 import logging
18 import json
19 import time
20
21 import requests
22 import validators
23
24 from fba import database
25 from fba import utils
26
27 from fba.helpers import blacklist
28 from fba.helpers import cache
29 from fba.helpers import config
30 from fba.helpers import domain as domain_helper
31 from fba.helpers import tidyup
32
33 from fba.http import federation
34 from fba.http import network
35
36 from fba.models import error_log
37
38 logging.basicConfig(level=logging.INFO)
39 logger = logging.getLogger(__name__)
40 #logger.setLevel(logging.DEBUG)
41
42 # Found info from node, such as nodeinfo URL, detection mode that needs to be
43 # written to database. Both arrays must be filled at the same time or else
44 # update() will fail
45 _pending = {
46     # Detection mode
47     # NULL means all detection methods have failed (maybe still reachable instance)
48     "detection_mode"     : {},
49     # Found nodeinfo URL
50     "nodeinfo_url"       : {},
51     # Found total peers
52     "total_peers"        : {},
53     # Found total blocks
54     "total_blocks"       : {},
55     # Obfuscated domains
56     "obfuscated_blocks"  : {},
57     # Last fetched instances
58     "last_instance_fetch": {},
59     # Last updated
60     "last_updated"       : {},
61     # Last blocked
62     "last_blocked"       : {},
63     # Last nodeinfo (fetched)
64     "last_nodeinfo"      : {},
65     # Last response time
66     "last_response_time" : {},
67     # Last status code
68     "last_status_code"   : {},
69     # Last error details
70     "last_error_details" : {},
71     # Wether obfuscation has been used
72     "has_obfuscation"    : {},
73     # Original software
74     "original_software"  : {},
75     # Aliased software
76     "software"           : {},
77 }
78
79 def _set_data(key: str, domain: str, value: any):
80     logger.debug("key='%s',domain='%s',value[]='%s' - CALLED!", key, domain, type(value))
81     domain_helper.raise_on(domain)
82
83     if not isinstance(key, str):
84         raise ValueError(f"Parameter key[]='{type(key)}' is not of type 'str'")
85     elif key == "":
86         raise ValueError("Parameter 'key' is empty")
87     elif not key in _pending:
88         raise ValueError(f"key='{key}' not found in _pending")
89     elif blacklist.is_blacklisted(domain):
90         raise Exception(f"domain='{domain}' is blacklisted but function has been invoked")
91     elif not utils.is_primitive(value):
92         raise ValueError(f"value[]='{type(value)}' is not a primitive type")
93
94     # Set it
95     _pending[key][domain] = value
96
97     logger.debug("EXIT!")
98
99 def has_pending(domain: str) -> bool:
100     logger.debug("domain='%s' - CALLED!", domain)
101     domain_helper.raise_on(domain)
102
103     if not is_registered(domain):
104         raise ValueError(f"domain='{domain}' is not registered but function was invoked.")
105     elif blacklist.is_blacklisted(domain):
106         raise Exception(f"domain='{domain}' is blacklisted but function has been invoked")
107
108     has = False
109     logger.debug("Checking %d _pending array elements ...", len(_pending))
110     for key in _pending:
111         logger.debug("domain='%s',_pending[%s]()=%d", domain, key, len(_pending[key]))
112         if domain in _pending[key]:
113             logger.debug("domain='%s' at key='%s' has pending data ...", domain, key)
114             has = True
115             break
116
117     logger.debug("has='%s' - EXIT!", has)
118     return has
119
120 def update(domain: str):
121     logger.debug("domain='%s' - CALLED!", domain)
122     domain_helper.raise_on(domain)
123
124     if not is_registered(domain):
125         raise Exception(f"domain='{domain}' cannot be updated while not being registered")
126     elif not has_pending(domain):
127         raise Exception(f"domain='{domain}' has no pending instance data, but function invoked")
128     elif blacklist.is_blacklisted(domain):
129         raise Exception(f"domain='{domain}' is blacklisted but function has been invoked")
130
131     sql_string = ""
132     fields = list()
133
134     logger.debug("Checking %d _pending array elements ...", len(_pending))
135     for key in _pending:
136         logger.debug("Checking key='%s',domain='%s'", key, domain)
137         if domain in _pending[key]:
138             logger.debug("Adding '%s' for key='%s' ...", _pending[key][domain], key)
139             fields.append(_pending[key][domain])
140             sql_string += f" {key} = ?,"
141
142     logger.debug("sql_string(%d)='%s'", len(sql_string), sql_string)
143     if sql_string == "":
144         raise ValueError(f"No fields have been set, but function invoked, domain='{domain}'")
145
146     # Set last_updated to current timestamp
147     fields.append(time.time())
148
149     # For WHERE statement
150     logger.debug("Setting domain='%s' for WHERE statement ...", domain)
151     fields.append(domain)
152
153     logger.debug("sql_string='%s',fields()=%d", sql_string, len(fields))
154     sql_string = "UPDATE instances SET" + sql_string + " last_updated = ? WHERE domain = ? LIMIT 1"
155
156     logger.debug("Executing SQL: sql_string='%s',fields()=%d", sql_string, len(fields))
157     database.cursor.execute(sql_string, fields)
158
159     logger.debug("rowcount=%d", database.cursor.rowcount)
160     if database.cursor.rowcount == 0:
161         raise Exception(f"Did not update any rows: domain='{domain}',fields()={len(fields)}")
162
163     logger.debug("Invoking commit() ...")
164     database.connection.commit()
165
166     logger.debug("Deleting _pending for domain='%s'", domain)
167     for key in _pending:
168         logger.debug("domain='%s',key='%s'", domain, key)
169         if domain in _pending[key]:
170             logger.debug("Deleting key='%s',domain='%s' ...", key, domain)
171             del _pending[key][domain]
172
173     logger.debug("EXIT!")
174
175 def add(domain: str, origin: str, command: str, path: str = None, software: str = None):
176     logger.debug("domain='%s',origin='%s',command='%s',path='%s',software='%s' - CALLED!", domain, origin, command, path, software)
177     domain_helper.raise_on(domain)
178
179     if not isinstance(origin, str) and origin is not None:
180         raise ValueError(f"origin[]='{type(origin)}' is not of type 'str'")
181     elif origin == "":
182         raise ValueError("Parameter 'origin' is empty")
183     elif not isinstance(command, str):
184         raise ValueError(f"command[]='{type(command)}' is not of type 'str'")
185     elif command == "":
186         raise ValueError("Parameter 'command' is empty")
187     elif not isinstance(path, str) and path is not None:
188         raise ValueError(f"path[]='{type(path)}' is not of type 'str'")
189     elif path == "":
190         raise ValueError("Parameter 'path' is empty")
191     elif path is not None and not path.startswith("/"):
192         raise ValueError(f"path='{path}' does not start with / but should")
193     elif not isinstance(software, str) and software is not None:
194         raise ValueError(f"software[]='{type(software)}' is not of type 'str'")
195     elif software == "":
196         raise ValueError("Parameter 'software' is empty")
197     elif origin is not None and not validators.domain(origin.split("/")[0]):
198         raise ValueError(f"Bad origin name='{origin}'")
199     elif blacklist.is_blacklisted(domain):
200         raise Exception(f"domain='{domain}' is blacklisted, but function invoked")
201     elif domain.find("/profile/") > 0 or domain.find("/users/") > 0 or (is_registered(domain.split("/")[0]) and domain.find("/c/") > 0):
202         raise Exception(f"domain='{domain}' is a single user")
203     elif domain.find("/tag/") > 0:
204         raise Exception(f"domain='{domain}' is a tag")
205
206     if software is None:
207         try:
208             logger.debug("domain='%s',origin='%s',command='%s',path='%s'", domain, origin, command, path)
209             software = federation.determine_software(domain, path)
210         except network.exceptions as exception:
211             logger.warning("Exception '%s' during determining software type, domain='%s'", type(exception), domain)
212             set_last_error(domain, exception)
213
214     logger.debug("Determined software='%s'", software)
215     if software == "lemmy" and domain.find("/c/") > 0:
216         domain = domain.split("/c/")[0]
217
218         logger.debug("domain='%s' - LEMMY /c/ !", domain)
219         if is_registered(domain):
220             logger.warning("domain='%s' already registered after cutting off user part. - EXIT!", domain)
221             return
222
223     logger.info("Adding instance domain='%s',origin='%s',software='%s',command='%s' ...", domain, origin, software, command)
224     database.cursor.execute(
225         "INSERT INTO instances (domain, origin, command, hash, software, original_software, first_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
226         (
227            domain,
228            origin,
229            command,
230            utils.get_hash(domain),
231            software,
232            software,
233            time.time()
234         ),
235     )
236
237     logger.debug("Marking domain='%s' as registered.", domain)
238     cache.set_sub_key("is_registered", domain, True)
239
240     logger.debug("Checking if domain='%s' has pending updates ...", domain)
241     if has_pending(domain):
242         logger.debug("Flushing updates for domain='%s' ...", domain)
243         update(domain)
244
245     logger.debug("EXIT!")
246
247 def set_last_nodeinfo(domain: str):
248     logger.debug("domain='%s' - CALLED!", domain)
249     domain_helper.raise_on(domain)
250
251     logger.debug("Updating last_nodeinfo for domain='%s'", domain)
252     _set_data("last_nodeinfo", domain, time.time())
253
254     logger.debug("EXIT!")
255
256 def set_last_error(domain: str, error: dict):
257     logger.debug("domain='%s',error[]='%s' - CALLED!", domain, type(error))
258     domain_helper.raise_on(domain)
259
260     logger.debug("error[]='%s' - BEFORE!", type(error))
261     if isinstance(error, (BaseException, json.decoder.JSONDecodeError)):
262         error = f"error[{type(error)}]='{str(error)}'"
263     logger.debug("error[]='%s' - AFTER!", type(error))
264
265     if isinstance(error, str):
266         logger.debug("Setting last_error_details='%s' (str)", error)
267         _set_data("last_status_code"  , domain, 999)
268         _set_data("last_error_details", domain, error if error != "" else None)
269     elif isinstance(error, requests.models.Response):
270         logger.debug("Setting last_error_details='%s' (Response)", error.reason)
271         _set_data("last_status_code"  , domain, error.status_code)
272         _set_data("last_error_details", domain, error.reason if error.reason != "" else None)
273     elif not isinstance(error, dict):
274         raise KeyError(f"Cannot handle keys in error[{type(error)}]='{error}'")
275     elif "status_code" in error and "error_message" in error:
276         logger.debug("Setting last_error_details='%s' (error_message)", error['error_message'])
277         _set_data("last_status_code"  , domain, error["status_code"])
278         _set_data("last_error_details", domain, error["error_message"] if error["error_message"] != "" else None)
279     elif "json" in error and "error" in error["json"] and "msg" in error["json"]:
280         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["msg"])
281         _set_data("last_status_code"  , domain, error["status_code"])
282         _set_data("last_error_details", domain, error["json"]["msg"] if error["json"]["msg"] != "" else None)
283     elif "json" in error and "error" in error["json"] and "message" in error["json"]["error"]:
284         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"]["message"])
285         _set_data("last_status_code"  , domain, error["status_code"])
286         _set_data("last_error_details", domain, error["json"]["error"]["message"] if error["json"]["error"]["message"] != "" else None)
287     elif "json" in error and "error" in error["json"]:
288         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"])
289         _set_data("last_status_code"  , domain, error["status_code"])
290         _set_data("last_error_details", domain, error["json"]["error"] if error["json"]["error"] != "" else None)
291
292     logger.debug("Invoking error_log.add(domain='%s',error[]='%s'", domain, type(error))
293     error_log.add(domain, error)
294
295     logger.debug("EXIT!")
296
297 def set_success(domain: str):
298     logger.debug("domain='%s' - CALLED!", domain)
299     domain_helper.raise_on(domain)
300
301     # Set both to success
302     _set_data("last_status_code"  , domain, 200)
303     _set_data("last_error_details", domain, None)
304
305     logger.debug("EXIT!")
306
307 def is_registered(domain: str, skip_raise = False) -> bool:
308     logger.debug("domain='%s',skip_raise='%s' - CALLED!", domain, skip_raise)
309     domain_helper.raise_on(domain)
310
311     if not isinstance(skip_raise, bool):
312         raise ValueError(f"skip_raise[]='{type(skip_raise)}' is not type of 'bool'")
313
314     if not skip_raise:
315         domain_helper.raise_on(domain)
316
317     logger.debug("domain='%s' - CALLED!", domain)
318     if not cache.key_exists("is_registered"):
319         logger.debug("Cache for 'is_registered' not initialized, fetching all rows ...")
320         database.cursor.execute("SELECT domain FROM instances")
321
322         # Check Set all
323         cache.set_all("is_registered", database.cursor.fetchall(), True)
324
325     # Is cache found?
326     registered = cache.sub_key_exists("is_registered", domain)
327
328     logger.debug("registered='%s' - EXIT!", registered)
329     return registered
330
331 def is_recent(domain: str, column: str = "last_instance_fetch") -> bool:
332     logger.debug("domain='%s',column='%s' - CALLED!", domain, column)
333     domain_helper.raise_on(domain)
334
335     if not isinstance(column, str):
336         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
337     elif not column.startswith("last_"):
338         raise ValueError(f"Parameter column='{column}' is not expected")
339     elif not is_registered(domain):
340         logger.debug("domain='%s' is not registered, returning False - EXIT!", domain)
341         return False
342
343     key = "recheck_instance"
344     if column == "last_blocked":
345         key = "recheck_block"
346
347     # Query database
348     database.cursor.execute(f"SELECT {column} FROM instances WHERE domain = ? LIMIT 1", [domain])
349
350     # Fetch row
351     row = database.cursor.fetchone()
352
353     fetched = float(row[column]) if row[column] is not None else 0.0
354
355     diff = (time.time() - fetched)
356
357     logger.debug("fetched[%s]='%s',key='%s',diff=%f", type(fetched), fetched, key, diff)
358     recently = bool(diff < config.get(key))
359
360     logger.debug("recently='%s' - EXIT!", recently)
361     return recently
362
363 def deobfuscate(char: str, domain: str, blocked_hash: str = None) -> tuple:
364     logger.debug("char='%s',domain='%s',blocked_hash='%s' - CALLED!", char, domain, blocked_hash)
365
366     if not isinstance(char, str):
367         raise ValueError(f"Parameter char[]='{type(char)}' is not of type 'str'")
368     elif char == "":
369         raise ValueError("Parameter 'char' is empty")
370     elif not char in domain:
371         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
372     elif not isinstance(domain, str):
373         raise ValueError(f"Parameter domain[]='{type(domain)}'")
374     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
375         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not of type 'str'")
376
377     # Init row
378     row = None
379
380     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
381     if isinstance(blocked_hash, str):
382         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
383         database.cursor.execute(
384             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
385         )
386
387         row = database.cursor.fetchone()
388         logger.debug("row[]='%s'", type(row))
389
390         if row is None:
391             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
392             return deobfuscate(char, domain)
393     elif not domain.startswith("*."):
394         logger.debug("domain='%s' - BEFORE!", domain)
395         domain = tidyup.domain(domain)
396         logger.debug("domain='%s' - AFTER!", domain)
397
398         if domain == "":
399             logger.warning("domain is empty after tidyup - EXIT!")
400             return None
401
402         search = domain.replace(char, "_")
403
404         logger.debug("Looking up domain='%s',search='%s' ...", domain, search)
405         database.cursor.execute(
406             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? OR 'https://' || domain LIKE ? ORDER BY rowid LIMIT 1", [search, search]
407         )
408
409         row = database.cursor.fetchone()
410         logger.debug("row[]='%s'", type(row))
411
412     logger.debug("row[]='%s' - EXIT!", type(row))
413     return row
414
415 def set_last_blocked(domain: str):
416     logger.debug("domain='%s' - CALLED!", domain)
417     domain_helper.raise_on(domain)
418
419     # Set timestamp
420     _set_data("last_blocked", domain, time.time())
421     logger.debug("EXIT!")
422
423 def set_last_instance_fetch(domain: str):
424     logger.debug("domain='%s' - CALLED!", domain)
425     domain_helper.raise_on(domain)
426
427     # Set timestamp
428     _set_data("last_instance_fetch", domain, time.time())
429     logger.debug("EXIT!")
430
431 def set_last_response_time(domain: str, response_time: float):
432     logger.debug("domain='%s',response_time=%d - CALLED!", domain, response_time)
433     domain_helper.raise_on(domain)
434
435     if not isinstance(response_time, float):
436         raise ValueError(f"response_time[]='{type(response_time)}' is not of type 'float'")
437     elif response_time < 0:
438         raise ValueError(f"response_time={response_time} is below zero")
439
440     # Set timestamp
441     _set_data("last_response_time", domain, response_time)
442     logger.debug("EXIT!")
443
444 def set_total_peers(domain: str, peers: list):
445     logger.debug("domain='%s',peers()=%d - CALLED!", domain, len(peers))
446     domain_helper.raise_on(domain)
447
448     if not isinstance(peers, list):
449         raise ValueError(f"Parameter peers[]='{type(peers)}' is not of type 'list'")
450
451     # Set timestamp
452     _set_data("total_peers", domain, len(peers))
453     logger.debug("EXIT!")
454
455 def set_total_blocks(domain: str, blocks: list):
456     logger.debug("domain='%s',blocks()=%d - CALLED!", domain, len(blocks))
457     domain_helper.raise_on(domain)
458
459     if not isinstance(blocks, list):
460         raise ValueError(f"Parameter blocks[]='{type(blocks)}' is not of type 'list'")
461
462     # Set timestamp
463     _set_data("total_blocks", domain, len(blocks))
464     logger.debug("EXIT!")
465
466 def set_obfuscated_blocks(domain: str, obfuscated: int):
467     logger.debug("domain='%s',obfuscated=%d - CALLED!", domain, obfuscated)
468     domain_helper.raise_on(domain)
469
470     if not isinstance(obfuscated, int):
471         raise ValueError(f"Parameter obfuscated[]='{type(obfuscated)}' is not of type 'int'")
472     elif obfuscated < 0:
473         raise ValueError(f"Parameter obfuscated={obfuscated} is not valid")
474
475     # Set timestamp
476     _set_data("obfuscated_blocks", domain, obfuscated)
477     logger.debug("EXIT!")
478
479 def set_nodeinfo_url(domain: str, url: str):
480     logger.debug("domain='%s',url='%s' - CALLED!", domain, url)
481     domain_helper.raise_on(domain)
482
483     if not isinstance(url, str) and url is not None:
484         raise ValueError(f"Parameter url[]='{type(url)}' is not of type 'str'")
485     elif url == "":
486         raise ValueError("Parameter 'url' is empty")
487     elif url is not None and not validators.url(url):
488         raise ValueError(f"Parameter url='{url}' is not a valid URL")
489
490     # Set timestamp
491     _set_data("nodeinfo_url", domain, url)
492     logger.debug("EXIT!")
493
494 def set_detection_mode(domain: str, mode: str):
495     logger.debug("domain='%s',mode='%s' - CALLED!", domain, mode)
496     domain_helper.raise_on(domain)
497
498     if not isinstance(mode, str) and mode is not None:
499         raise ValueError(f"Parameter mode[]='{type(mode)}' is not of type 'str'")
500     elif mode == "":
501         raise ValueError("Parameter 'mode' is empty")
502
503     # Set timestamp
504     _set_data("detection_mode", domain, mode)
505     logger.debug("EXIT!")
506
507 def set_has_obfuscation(domain: str, status: bool):
508     logger.debug("domain='%s',status='%s' - CALLED!", domain, status)
509     domain_helper.raise_on(domain)
510
511     if not isinstance(status, bool):
512         raise ValueError(f"Parameter status[]='{type(status)}' is not of type 'bool'")
513
514     # Set timestamp
515     _set_data("has_obfuscation", domain, status)
516     logger.debug("EXIT!")
517
518 def set_original_software(domain: str, software: str):
519     logger.debug("domain='%s',software='%s' - CALLED!", domain, software)
520     domain_helper.raise_on(domain)
521
522     if not isinstance(software, str) and software is not None:
523         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
524     elif software == "":
525         raise ValueError("Parameter 'software' is empty")
526
527     # Set original software
528     _set_data("original_software", domain, software)
529     logger.debug("EXIT!")
530
531
532 def set_software(domain: str, software: str):
533     logger.debug("domain='%s',software='%s' - CALLED!", domain, software)
534     domain_helper.raise_on(domain)
535
536     if not isinstance(software, str) and software is not None:
537         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
538     elif software == "":
539         raise ValueError("Parameter 'software' is empty")
540
541     # Set software (maybe aliased to generic name)
542     _set_data("software", domain, software)
543     logger.debug("EXIT!")
544
545 def valid(value: str, column: str) -> bool:
546     logger.debug("value='%s' - CALLED!", value)
547     if not isinstance(value, str):
548         raise ValueError(f"Parameter value[]='{type(value)}' is not of type 'str'")
549     elif value == "":
550         raise ValueError("Parameter 'value' is empty")
551     elif not isinstance(column, str):
552         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
553     elif column == "":
554         raise ValueError("Parameter 'column' is empty")
555
556     # Query database
557     database.cursor.execute(
558         f"SELECT {column} FROM instances WHERE {column} = ? LIMIT 1", [value]
559     )
560
561     is_valid = database.cursor.fetchone() is not None
562
563     logger.debug("is_valid='%s' - EXIT!", is_valid)
564     return is_valid
565
566 def translate_idnas(rows: list, column: str):
567     logger.debug("rows[]='%s' - CALLED!", type(rows))
568
569     if not isinstance(rows, list):
570         raise ValueError("rows[]='{type(rows)}' is not of type 'list'")
571     elif len(rows) == 0:
572         raise ValueError("Parameter 'rows' is an empty list")
573     elif not isinstance(column, str):
574         raise ValueError(f"column='{type(column)}' is not of type 'str'")
575     elif column == "":
576         raise ValueError("Parameter 'column' is empty")
577     elif column not in ["domain", "origin"]:
578         raise ValueError(f"column='{column}' is not supported")
579
580     logger.info("Checking/converting %d domain names ...", len(rows))
581     for row in rows:
582         logger.debug("row[]='%s'", type(row))
583
584         translated = row[column].encode("idna").decode("utf-8")
585         logger.debug("translated='%s',row[%s]='%s'", translated, column, row[column])
586
587         if translated != row[column]:
588             logger.info("Translated row[%s]='%s' to '%s'", column, row[column], translated)
589             if is_registered(translated, True):
590                 logger.warning("Deleting row[%s]='%s' as translated='%s' already exist", column, row[column], translated)
591                 database.cursor.execute(f"DELETE FROM instances WHERE {column} = ? LIMIT 1", [row[column]])
592             else:
593                 logger.debug("Updating row[%s]='%s' to translated='%s' ...", column, row[column], translated)
594                 database.cursor.execute(f"UPDATE instances SET {column} = ? WHERE {column} = ? LIMIT 1", [translated, row[column]])
595
596             logger.debug("Invoking commit() ...")
597             database.connection.commit()
598
599     logger.debug("EXIT!")