]> git.mxchange.org Git - fba.git/blob - fba/models/instances.py
Continued:
[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 status code
66     "last_status_code"   : {},
67     # Last error details
68     "last_error_details" : {},
69     # Wether obfuscation has been used
70     "has_obfuscation"    : {},
71     # Determined software
72     "software"           : {},
73 }
74
75 def _set_data(key: str, domain: str, value: any):
76     logger.debug("key='%s',domain='%s',value[]='%s' - CALLED!", key, domain, type(value))
77     domain_helper.raise_on(domain)
78     if not isinstance(key, str):
79         raise ValueError(f"Parameter key[]='{type(key)}' is not of type 'str'")
80     elif key == "":
81         raise ValueError("Parameter 'key' is empty")
82     elif not key in _pending:
83         raise ValueError(f"key='{key}' not found in _pending")
84     elif not utils.is_primitive(value):
85         raise ValueError(f"value[]='{type(value)}' is not a primitive type")
86
87     # Set it
88     _pending[key][domain] = value
89
90     logger.debug("EXIT!")
91
92 def has_pending(domain: str) -> bool:
93     logger.debug("domain='%s' - CALLED!", domain)
94     domain_helper.raise_on(domain)
95
96     has = False
97     for key in _pending:
98         logger.debug("key='%s',domain='%s',_pending[key]()=%d", key, domain, len(_pending[key]))
99         if domain in _pending[key]:
100             has = True
101             break
102
103     logger.debug("has='%s' - EXIT!", has)
104     return has
105
106 def update(domain: str):
107     logger.debug("domain='%s' - CALLED!", domain)
108     domain_helper.raise_on(domain)
109     if not has_pending(domain):
110         raise Exception(f"domain='{domain}' has no pending instance data, but function invoked")
111     elif not is_registered(domain):
112         raise Exception(f"domain='{domain}' cannot be updated while not being registered")
113
114     logger.debug("Updating instance data for domain='%s' ...", domain)
115     sql_string = ""
116     fields = list()
117     for key in _pending:
118         logger.debug("Checking key='%s',domain='%s'", key, domain)
119         if domain in _pending[key]:
120             logger.debug("Adding '%s' for key='%s' ...", _pending[key][domain], key)
121             fields.append(_pending[key][domain])
122             sql_string += f" {key} = ?,"
123
124     logger.debug("sql_string(%d)='%s'", len(sql_string), sql_string)
125     if sql_string == "":
126         raise ValueError(f"No fields have been set, but function invoked, domain='{domain}'")
127
128     # Set last_updated to current timestamp
129     fields.append(time.time())
130
131     # For WHERE statement
132     logger.debug("Setting domain='%s' for WHERE statement ...", domain)
133     fields.append(domain)
134
135     logger.debug("sql_string='%s',fields()=%d", sql_string, len(fields))
136     sql_string = "UPDATE instances SET" + sql_string + " last_updated = ? WHERE domain = ? LIMIT 1"
137
138     logger.debug("Executing SQL: sql_string='%s',fields()=%d", sql_string, len(fields))
139     database.cursor.execute(sql_string, fields)
140
141     logger.debug("rowcount=%d", database.cursor.rowcount)
142     if database.cursor.rowcount == 0:
143         raise Exception(f"Did not update any rows: domain='{domain}',fields()={len(fields)}")
144
145     logger.debug("Invoking commit() ...")
146     database.connection.commit()
147
148     logger.debug("Deleting _pending for domain='%s'", domain)
149     for key in _pending:
150         logger.debug("domain='%s',key='%s'", domain, key)
151         if domain in _pending[key]:
152             logger.debug("Deleting key='%s',domain='%s' ...", key, domain)
153             del _pending[key][domain]
154
155     logger.debug("EXIT!")
156
157 def add(domain: str, origin: str, command: str, path: str = None, software: str = None):
158     logger.debug("domain='%s',origin='%s',command='%s',path='%s',software='%s' - CALLED!", domain, origin, command, path, software)
159     domain_helper.raise_on(domain)
160
161     if not isinstance(origin, str) and origin is not None:
162         raise ValueError(f"origin[]='{type(origin)}' is not of type 'str'")
163     elif origin == "":
164         raise ValueError("Parameter 'origin' is empty")
165     elif not isinstance(command, str):
166         raise ValueError(f"command[]='{type(command)}' is not of type 'str'")
167     elif command == "":
168         raise ValueError("Parameter 'command' is empty")
169     elif not isinstance(path, str) and path is not None:
170         raise ValueError(f"path[]='{type(path)}' is not of type 'str'")
171     elif path == "":
172         raise ValueError("Parameter 'path' is empty")
173     elif not isinstance(software, str) and software is not None:
174         raise ValueError(f"software[]='{type(software)}' is not of type 'str'")
175     elif software == "":
176         raise ValueError("Parameter 'software' is empty")
177     elif origin is not None and not validators.domain(origin.split("/")[0]):
178         raise ValueError(f"Bad origin name='{origin}'")
179     elif blacklist.is_blacklisted(domain):
180         raise Exception(f"domain='{domain}' is blacklisted, but function invoked")
181     elif domain.find("/profile/") > 0 or domain.find("/users/") > 0 or (is_registered(domain.split("/")[0]) and domain.find("/c/") > 0):
182         raise Exception(f"domain='{domain}' is a single user")
183     elif domain.find("/tag/") > 0:
184         raise Exception(f"domain='{domain}' is a tag")
185
186     if software is None:
187         try:
188             logger.debug("domain='%s',origin='%s',command='%s',path='%s'", domain, origin, command, path)
189             software = federation.determine_software(domain, path)
190         except network.exceptions as exception:
191             logger.warning("Exception '%s' during determining software type, domain='%s'", type(exception), domain)
192             set_last_error(domain, exception)
193
194     logger.debug("Determined software='%s'", software)
195     if software == "lemmy" and domain.find("/c/") > 0:
196         domain = domain.split("/c/")[0]
197         if is_registered(domain):
198             logger.warning("domain='%s' already registered after cutting off user part. - EXIT!", domain)
199             return
200
201     logger.info("Adding instance domain='%s',origin='%s',software='%s',command='%s'", domain, origin, software, command)
202     database.cursor.execute(
203         "INSERT INTO instances (domain, origin, command, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
204         (
205            domain,
206            origin,
207            command,
208            utils.get_hash(domain),
209            software,
210            time.time()
211         ),
212     )
213
214     logger.debug("Marking domain='%s' as registered.", domain)
215     cache.set_sub_key("is_registered", domain, True)
216
217     logger.debug("Checking if domain='%s' has pending updates ...", domain)
218     if has_pending(domain):
219         logger.debug("Flushing updates for domain='%s' ...", domain)
220         update(domain)
221
222     logger.debug("EXIT!")
223
224 def set_last_nodeinfo(domain: str):
225     logger.debug("domain='%s' - CALLED!", domain)
226     domain_helper.raise_on(domain)
227
228     logger.debug("Updating last_nodeinfo for domain='%s'", domain)
229     _set_data("last_nodeinfo", domain, time.time())
230
231     logger.debug("EXIT!")
232
233 def set_last_error(domain: str, error: dict):
234     logger.debug("domain='%s',error[]='%s' - CALLED!", domain, type(error))
235     domain_helper.raise_on(domain)
236
237     logger.debug("error[]='%s' - BEFORE!", type(error))
238     if isinstance(error, (BaseException, json.decoder.JSONDecodeError)):
239         error = f"error[{type(error)}]='{str(error)}'"
240     logger.debug("error[]='%s' - AFTER!", type(error))
241
242     if isinstance(error, str):
243         logger.debug("Setting last_error_details='%s' (str)", error)
244         _set_data("last_status_code"  , domain, 999)
245         _set_data("last_error_details", domain, error if error != "" else None)
246     elif isinstance(error, requests.models.Response):
247         logger.debug("Setting last_error_details='%s' (Response)", error.reason)
248         _set_data("last_status_code"  , domain, error.status_code)
249         _set_data("last_error_details", domain, error.reason if error.reason != "" else None)
250     elif not isinstance(error, dict):
251         raise KeyError(f"Cannot handle keys in error[{type(error)}]='{error}'")
252     elif "status_code" in error and "error_message" in error:
253         logger.debug("Setting last_error_details='%s' (error_message)", error['error_message'])
254         _set_data("last_status_code"  , domain, error["status_code"])
255         _set_data("last_error_details", domain, error["error_message"] if error["error_message"] != "" else None)
256     elif "json" in error and "error" in error["json"]:
257         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"])
258         _set_data("last_status_code"  , domain, error["status_code"])
259         _set_data("last_error_details", domain, error["json"]["error"] if error["json"]["error"] != "" else None)
260
261     logger.debug("Invoking error_log.add(domain='%s',error[]='%s'", domain, type(error))
262     error_log.add(domain, error)
263
264     logger.debug("EXIT!")
265
266 def set_success(domain: str):
267     logger.debug("domain='%s' - CALLED!", domain)
268     domain_helper.raise_on(domain)
269
270     # Set both to success
271     _set_data("last_status_code"  , domain, 200)
272     _set_data("last_error_details", domain, None)
273
274     logger.debug("EXIT!")
275
276 def is_registered(domain: str, skip_raise = False) -> bool:
277     logger.debug("domain='%s',skip_raise='%s' - CALLED!", domain, skip_raise)
278     if not isinstance(skip_raise, bool):
279         raise ValueError(f"skip_raise[]='{type(skip_raise)}' is not type of 'bool'")
280
281     if not skip_raise:
282         domain_helper.raise_on(domain)
283
284     logger.debug("domain='%s' - CALLED!", domain)
285     if not cache.key_exists("is_registered"):
286         logger.debug("Cache for 'is_registered' not initialized, fetching all rows ...")
287         database.cursor.execute("SELECT domain FROM instances")
288
289         # Check Set all
290         cache.set_all("is_registered", database.cursor.fetchall(), True)
291
292     # Is cache found?
293     registered = cache.sub_key_exists("is_registered", domain)
294
295     logger.debug("registered='%s' - EXIT!", registered)
296     return registered
297
298 def is_recent(domain: str, column: str = "last_instance_fetch") -> bool:
299     logger.debug("domain='%s',column='%s' - CALLED!", domain, column)
300     domain_helper.raise_on(domain)
301
302     if not isinstance(column, str):
303         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
304     elif not column.startswith("last_"):
305         raise ValueError(f"Parameter column='{column}' is not expected")
306     elif not is_registered(domain):
307         logger.debug("domain='%s' is not registered, returning False - EXIT!", domain)
308         return False
309
310     key = "recheck_instance"
311     if column == "last_blocked":
312         key = "recheck_block"
313
314     # Query database
315     database.cursor.execute(f"SELECT {column} FROM instances WHERE domain = ? LIMIT 1", [domain])
316
317     # Fetch row
318     row = database.cursor.fetchone()
319
320     fetched = float(row[column]) if row[column] is not None else 0.0
321
322     diff = (time.time() - fetched)
323
324     logger.debug("fetched[%s]='%s',key='%s',diff=%f", type(fetched), fetched, key, diff)
325     recently = bool(diff < config.get(key))
326
327     logger.debug("recently='%s' - EXIT!", recently)
328     return recently
329
330 def deobfuscate(char: str, domain: str, blocked_hash: str = None) -> tuple:
331     logger.debug("char='%s',domain='%s',blocked_hash='%s' - CALLED!", char, domain, blocked_hash)
332
333     if not isinstance(char, str):
334         raise ValueError(f"Parameter char[]='{type(char)}' is not of type 'str'")
335     elif char == "":
336         raise ValueError("Parameter 'char' is empty")
337     elif not char in domain:
338         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
339     elif not isinstance(domain, str):
340         raise ValueError(f"Parameter domain[]='{type(domain)}'")
341     elif domain == "":
342         raise ValueError("Parameter 'domain' is empty")
343     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
344         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not of type 'str'")
345
346     # Init row
347     row = None
348
349     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
350     if isinstance(blocked_hash, str):
351         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
352         database.cursor.execute(
353             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
354         )
355
356         row = database.cursor.fetchone()
357         logger.debug("row[]='%s'", type(row))
358
359         if row is None:
360             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
361             return deobfuscate(char, domain)
362     elif not domain.startswith("*."):
363         logger.debug("domain='%s' - BEFORE!", domain)
364         domain = tidyup.domain(domain)
365         logger.debug("domain='%s' - AFTER!", domain)
366
367         if domain == "":
368             logger.warning("domain is empty after tidyup - EXIT!")
369             return None
370
371         search = domain.replace(char, "_")
372
373         logger.debug("Looking up domain='%s',search='%s' ...", domain, search)
374         database.cursor.execute(
375             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? OR 'https://' || domain LIKE ? ORDER BY rowid LIMIT 1", [search, search]
376         )
377
378         row = database.cursor.fetchone()
379         logger.debug("row[]='%s'", type(row))
380
381     logger.debug("row[]='%s' - EXIT!", type(row))
382     return row
383
384 def set_last_blocked(domain: str):
385     logger.debug("domain='%s' - CALLED!", domain)
386     domain_helper.raise_on(domain)
387
388     # Set timestamp
389     _set_data("last_blocked", domain, time.time())
390     logger.debug("EXIT!")
391
392 def set_last_instance_fetch(domain: str):
393     logger.debug("domain='%s' - CALLED!", domain)
394     domain_helper.raise_on(domain)
395
396     # Set timestamp
397     _set_data("last_instance_fetch", domain, time.time())
398     logger.debug("EXIT!")
399
400 def set_total_peers(domain: str, peers: list):
401     logger.debug("domain='%s',peers()=%d - CALLED!", domain, len(peers))
402     domain_helper.raise_on(domain)
403
404     if not isinstance(peers, list):
405         raise ValueError(f"Parameter peers[]='{type(peers)}' is not of type 'list'")
406
407     # Set timestamp
408     _set_data("total_peers", domain, len(peers))
409     logger.debug("EXIT!")
410
411 def set_total_blocks(domain: str, blocks: list):
412     logger.debug("domain='%s',blocks()=%d - CALLED!", domain, len(blocks))
413     domain_helper.raise_on(domain)
414
415     if not isinstance(blocks, list):
416         raise ValueError(f"Parameter blocks[]='{type(blocks)}' is not of type 'list'")
417
418     # Set timestamp
419     _set_data("total_blocks", domain, len(blocks))
420     logger.debug("EXIT!")
421
422 def set_obfuscated_blocks(domain: str, obfuscated: int):
423     logger.debug("domain='%s',obfuscated=%d - CALLED!", domain, obfuscated)
424     domain_helper.raise_on(domain)
425
426     if not isinstance(obfuscated, int):
427         raise ValueError(f"Parameter obfuscated[]='{type(obfuscated)}' is not of type 'int'")
428     elif obfuscated < 0:
429         raise ValueError(f"Parameter obfuscated={obfuscated} is not valid")
430
431     # Set timestamp
432     _set_data("obfuscated_blocks", domain, obfuscated)
433     logger.debug("EXIT!")
434
435 def set_nodeinfo_url(domain: str, url: str):
436     logger.debug("domain='%s',url='%s' - CALLED!", domain, url)
437     domain_helper.raise_on(domain)
438
439     if not isinstance(url, str) and url is not None:
440         raise ValueError(f"Parameter url[]='{type(url)}' is not of type 'str'")
441     elif url == "":
442         raise ValueError("Parameter 'url' is empty")
443
444     # Set timestamp
445     _set_data("nodeinfo_url", domain, url)
446     logger.debug("EXIT!")
447
448 def set_detection_mode(domain: str, mode: str):
449     logger.debug("domain='%s',mode='%s' - CALLED!", domain, mode)
450     domain_helper.raise_on(domain)
451
452     if not isinstance(mode, str) and mode is not None:
453         raise ValueError(f"Parameter mode[]='{type(mode)}' is not of type 'str'")
454     elif mode == "":
455         raise ValueError("Parameter 'mode' is empty")
456
457     # Set timestamp
458     _set_data("detection_mode", domain, mode)
459     logger.debug("EXIT!")
460
461 def set_has_obfuscation(domain: str, status: bool):
462     logger.debug("domain='%s',status='%s' - CALLED!", domain, status)
463     domain_helper.raise_on(domain)
464
465     if not isinstance(status, bool):
466         raise ValueError(f"Parameter status[]='{type(status)}' is not of type 'bool'")
467
468     # Set timestamp
469     _set_data("has_obfuscation", domain, status)
470     logger.debug("EXIT!")
471
472 def set_software(domain: str, software: str):
473     logger.debug("domain='%s',software='%s' - CALLED!", domain, software)
474     domain_helper.raise_on(domain)
475
476     if not isinstance(software, str) and software is not None:
477         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
478     elif software == "":
479         raise ValueError("Parameter 'software' is empty")
480
481     # Set timestamp
482     _set_data("software", domain, software)
483     logger.debug("EXIT!")
484
485 def valid(value: str, column: str) -> bool:
486     logger.debug("value='%s' - CALLED!", value)
487     if not isinstance(value, str):
488         raise ValueError(f"Parameter value[]='{type(value)}' is not of type 'str'")
489     elif value == "":
490         raise ValueError("Parameter 'value' is empty")
491     elif not isinstance(column, str):
492         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
493     elif column == "":
494         raise ValueError("Parameter 'column' is empty")
495
496     # Query database
497     database.cursor.execute(
498         f"SELECT {column} FROM instances WHERE {column} = ? LIMIT 1", [value]
499     )
500
501     is_valid = database.cursor.fetchone() is not None
502
503     logger.debug("is_valid='%s' - EXIT!", is_valid)
504     return is_valid
505
506 def translate_idnas(rows: list, column: str):
507     logger.debug("rows[]='%s' - CALLED!", type(rows))
508     if not isinstance(rows, list):
509         raise ValueError("rows[]='{type(rows)}' is not of type 'list'")
510     elif len(rows) == 0:
511         raise ValueError("Parameter 'rows' is an empty list")
512     elif not isinstance(column, str):
513         raise ValueError(f"column='{type(column)}' is not of type 'str'")
514     elif column == "":
515         raise ValueError("Parameter 'column' is empty")
516     elif column not in ["domain", "origin"]:
517         raise ValueError(f"column='{column}' is not supported")
518
519     logger.info("Checking/converting %d domain names ...", len(rows))
520     for row in rows:
521         logger.debug("row[]='%s'", type(row))
522
523         translated = row[column].encode("idna").decode("utf-8")
524         logger.debug("translated='%s',row[%s]='%s'", translated, column, row[column])
525
526         if translated != row[column]:
527             logger.info("Translated row[%s]='%s' to '%s'", column, row[column], translated)
528             if is_registered(translated, True):
529                 logger.warning("Deleting row[%s]='%s' as translated='%s' already exist", column, row[column], translated)
530                 database.cursor.execute(f"DELETE FROM instances WHERE {column} = ? LIMIT 1", [row[column]])
531             else:
532                 database.cursor.execute(f"UPDATE instances SET {column} = ? WHERE {column} = ? LIMIT 1", [translated, row[column]])
533
534             logger.debug("Invoking commit() ...")
535             database.connection.commit()
536
537     logger.debug("EXIT!")