]> 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 "message" in error["json"]["error"]:
277         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"]["message"])
278         _set_data("last_status_code"  , domain, error["status_code"])
279         _set_data("last_error_details", domain, error["json"]["error"]["message"] if error["json"]["error"]["message"] != "" else None)
280     elif "json" in error and "error" in error["json"]:
281         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"])
282         _set_data("last_status_code"  , domain, error["status_code"])
283         _set_data("last_error_details", domain, error["json"]["error"] if error["json"]["error"] != "" else None)
284
285     logger.debug("Invoking error_log.add(domain='%s',error[]='%s'", domain, type(error))
286     error_log.add(domain, error)
287
288     logger.debug("EXIT!")
289
290 def set_success(domain: str):
291     logger.debug("domain='%s' - CALLED!", domain)
292     domain_helper.raise_on(domain)
293
294     # Set both to success
295     _set_data("last_status_code"  , domain, 200)
296     _set_data("last_error_details", domain, None)
297
298     logger.debug("EXIT!")
299
300 def is_registered(domain: str, skip_raise = False) -> bool:
301     logger.debug("domain='%s',skip_raise='%s' - CALLED!", domain, skip_raise)
302     domain_helper.raise_on(domain)
303
304     if not isinstance(skip_raise, bool):
305         raise ValueError(f"skip_raise[]='{type(skip_raise)}' is not type of 'bool'")
306
307     if not skip_raise:
308         domain_helper.raise_on(domain)
309
310     logger.debug("domain='%s' - CALLED!", domain)
311     if not cache.key_exists("is_registered"):
312         logger.debug("Cache for 'is_registered' not initialized, fetching all rows ...")
313         database.cursor.execute("SELECT domain FROM instances")
314
315         # Check Set all
316         cache.set_all("is_registered", database.cursor.fetchall(), True)
317
318     # Is cache found?
319     registered = cache.sub_key_exists("is_registered", domain)
320
321     logger.debug("registered='%s' - EXIT!", registered)
322     return registered
323
324 def is_recent(domain: str, column: str = "last_instance_fetch") -> bool:
325     logger.debug("domain='%s',column='%s' - CALLED!", domain, column)
326     domain_helper.raise_on(domain)
327
328     if not isinstance(column, str):
329         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
330     elif not column.startswith("last_"):
331         raise ValueError(f"Parameter column='{column}' is not expected")
332     elif not is_registered(domain):
333         logger.debug("domain='%s' is not registered, returning False - EXIT!", domain)
334         return False
335
336     key = "recheck_instance"
337     if column == "last_blocked":
338         key = "recheck_block"
339
340     # Query database
341     database.cursor.execute(f"SELECT {column} FROM instances WHERE domain = ? LIMIT 1", [domain])
342
343     # Fetch row
344     row = database.cursor.fetchone()
345
346     fetched = float(row[column]) if row[column] is not None else 0.0
347
348     diff = (time.time() - fetched)
349
350     logger.debug("fetched[%s]='%s',key='%s',diff=%f", type(fetched), fetched, key, diff)
351     recently = bool(diff < config.get(key))
352
353     logger.debug("recently='%s' - EXIT!", recently)
354     return recently
355
356 def deobfuscate(char: str, domain: str, blocked_hash: str = None) -> tuple:
357     logger.debug("char='%s',domain='%s',blocked_hash='%s' - CALLED!", char, domain, blocked_hash)
358
359     if not isinstance(char, str):
360         raise ValueError(f"Parameter char[]='{type(char)}' is not of type 'str'")
361     elif char == "":
362         raise ValueError("Parameter 'char' is empty")
363     elif not char in domain:
364         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
365     elif not isinstance(domain, str):
366         raise ValueError(f"Parameter domain[]='{type(domain)}'")
367     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
368         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not of type 'str'")
369
370     # Init row
371     row = None
372
373     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
374     if isinstance(blocked_hash, str):
375         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
376         database.cursor.execute(
377             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
378         )
379
380         row = database.cursor.fetchone()
381         logger.debug("row[]='%s'", type(row))
382
383         if row is None:
384             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
385             return deobfuscate(char, domain)
386     elif not domain.startswith("*."):
387         logger.debug("domain='%s' - BEFORE!", domain)
388         domain = tidyup.domain(domain)
389         logger.debug("domain='%s' - AFTER!", domain)
390
391         if domain == "":
392             logger.warning("domain is empty after tidyup - EXIT!")
393             return None
394
395         search = domain.replace(char, "_")
396
397         logger.debug("Looking up domain='%s',search='%s' ...", domain, search)
398         database.cursor.execute(
399             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? OR 'https://' || domain LIKE ? ORDER BY rowid LIMIT 1", [search, search]
400         )
401
402         row = database.cursor.fetchone()
403         logger.debug("row[]='%s'", type(row))
404
405     logger.debug("row[]='%s' - EXIT!", type(row))
406     return row
407
408 def set_last_blocked(domain: str):
409     logger.debug("domain='%s' - CALLED!", domain)
410     domain_helper.raise_on(domain)
411
412     # Set timestamp
413     _set_data("last_blocked", domain, time.time())
414     logger.debug("EXIT!")
415
416 def set_last_instance_fetch(domain: str):
417     logger.debug("domain='%s' - CALLED!", domain)
418     domain_helper.raise_on(domain)
419
420     # Set timestamp
421     _set_data("last_instance_fetch", domain, time.time())
422     logger.debug("EXIT!")
423
424 def set_last_response_time(domain: str, response_time: float):
425     logger.debug("domain='%s',response_time=%d - CALLED!", domain, response_time)
426     domain_helper.raise_on(domain)
427
428     if not isinstance(response_time, float):
429         raise ValueError(f"response_time[]='{type(response_time)}' is not of type 'float'")
430     elif response_time < 0:
431         raise ValueError(f"response_time={response_time} is below zero")
432
433     # Set timestamp
434     _set_data("last_response_time", domain, response_time)
435     logger.debug("EXIT!")
436
437 def set_total_peers(domain: str, peers: list):
438     logger.debug("domain='%s',peers()=%d - CALLED!", domain, len(peers))
439     domain_helper.raise_on(domain)
440
441     if not isinstance(peers, list):
442         raise ValueError(f"Parameter peers[]='{type(peers)}' is not of type 'list'")
443
444     # Set timestamp
445     _set_data("total_peers", domain, len(peers))
446     logger.debug("EXIT!")
447
448 def set_total_blocks(domain: str, blocks: list):
449     logger.debug("domain='%s',blocks()=%d - CALLED!", domain, len(blocks))
450     domain_helper.raise_on(domain)
451
452     if not isinstance(blocks, list):
453         raise ValueError(f"Parameter blocks[]='{type(blocks)}' is not of type 'list'")
454
455     # Set timestamp
456     _set_data("total_blocks", domain, len(blocks))
457     logger.debug("EXIT!")
458
459 def set_obfuscated_blocks(domain: str, obfuscated: int):
460     logger.debug("domain='%s',obfuscated=%d - CALLED!", domain, obfuscated)
461     domain_helper.raise_on(domain)
462
463     if not isinstance(obfuscated, int):
464         raise ValueError(f"Parameter obfuscated[]='{type(obfuscated)}' is not of type 'int'")
465     elif obfuscated < 0:
466         raise ValueError(f"Parameter obfuscated={obfuscated} is not valid")
467
468     # Set timestamp
469     _set_data("obfuscated_blocks", domain, obfuscated)
470     logger.debug("EXIT!")
471
472 def set_nodeinfo_url(domain: str, url: str):
473     logger.debug("domain='%s',url='%s' - CALLED!", domain, url)
474     domain_helper.raise_on(domain)
475
476     if not isinstance(url, str) and url is not None:
477         raise ValueError(f"Parameter url[]='{type(url)}' is not of type 'str'")
478     elif url == "":
479         raise ValueError("Parameter 'url' is empty")
480
481     # Set timestamp
482     _set_data("nodeinfo_url", domain, url)
483     logger.debug("EXIT!")
484
485 def set_detection_mode(domain: str, mode: str):
486     logger.debug("domain='%s',mode='%s' - CALLED!", domain, mode)
487     domain_helper.raise_on(domain)
488
489     if not isinstance(mode, str) and mode is not None:
490         raise ValueError(f"Parameter mode[]='{type(mode)}' is not of type 'str'")
491     elif mode == "":
492         raise ValueError("Parameter 'mode' is empty")
493
494     # Set timestamp
495     _set_data("detection_mode", domain, mode)
496     logger.debug("EXIT!")
497
498 def set_has_obfuscation(domain: str, status: bool):
499     logger.debug("domain='%s',status='%s' - CALLED!", domain, status)
500     domain_helper.raise_on(domain)
501
502     if not isinstance(status, bool):
503         raise ValueError(f"Parameter status[]='{type(status)}' is not of type 'bool'")
504
505     # Set timestamp
506     _set_data("has_obfuscation", domain, status)
507     logger.debug("EXIT!")
508
509 def set_software(domain: str, software: str):
510     logger.debug("domain='%s',software='%s' - CALLED!", domain, software)
511     domain_helper.raise_on(domain)
512
513     if not isinstance(software, str) and software is not None:
514         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
515     elif software == "":
516         raise ValueError("Parameter 'software' is empty")
517
518     # Set timestamp
519     _set_data("software", domain, software)
520     logger.debug("EXIT!")
521
522 def valid(value: str, column: str) -> bool:
523     logger.debug("value='%s' - CALLED!", value)
524     if not isinstance(value, str):
525         raise ValueError(f"Parameter value[]='{type(value)}' is not of type 'str'")
526     elif value == "":
527         raise ValueError("Parameter 'value' is empty")
528     elif not isinstance(column, str):
529         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
530     elif column == "":
531         raise ValueError("Parameter 'column' is empty")
532
533     # Query database
534     database.cursor.execute(
535         f"SELECT {column} FROM instances WHERE {column} = ? LIMIT 1", [value]
536     )
537
538     is_valid = database.cursor.fetchone() is not None
539
540     logger.debug("is_valid='%s' - EXIT!", is_valid)
541     return is_valid
542
543 def translate_idnas(rows: list, column: str):
544     logger.debug("rows[]='%s' - CALLED!", type(rows))
545
546     if not isinstance(rows, list):
547         raise ValueError("rows[]='{type(rows)}' is not of type 'list'")
548     elif len(rows) == 0:
549         raise ValueError("Parameter 'rows' is an empty list")
550     elif not isinstance(column, str):
551         raise ValueError(f"column='{type(column)}' is not of type 'str'")
552     elif column == "":
553         raise ValueError("Parameter 'column' is empty")
554     elif column not in ["domain", "origin"]:
555         raise ValueError(f"column='{column}' is not supported")
556
557     logger.info("Checking/converting %d domain names ...", len(rows))
558     for row in rows:
559         logger.debug("row[]='%s'", type(row))
560
561         translated = row[column].encode("idna").decode("utf-8")
562         logger.debug("translated='%s',row[%s]='%s'", translated, column, row[column])
563
564         if translated != row[column]:
565             logger.info("Translated row[%s]='%s' to '%s'", column, row[column], translated)
566             if is_registered(translated, True):
567                 logger.warning("Deleting row[%s]='%s' as translated='%s' already exist", column, row[column], translated)
568                 database.cursor.execute(f"DELETE FROM instances WHERE {column} = ? LIMIT 1", [row[column]])
569             else:
570                 logger.debug("Updating row[%s]='%s' to translated='%s' ...", column, row[column], translated)
571                 database.cursor.execute(f"UPDATE instances SET {column} = ? WHERE {column} = ? LIMIT 1", [translated, row[column]])
572
573             logger.debug("Invoking commit() ...")
574             database.connection.commit()
575
576     logger.debug("EXIT!")