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