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