]> git.mxchange.org Git - fba.git/blob - fba/models/instances.py
1294a01a48effa3a85b8fb3e3c9bd71dabebe7e2
[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     fields.append(domain)
130
131     logger.debug("sql_string='%s',fields()=%d", sql_string, len(fields))
132     sql_string = "UPDATE instances SET" + sql_string + " last_updated = ? WHERE domain = ? LIMIT 1"
133
134     logger.debug("Executing SQL: sql_string='%s',fields()=%d", sql_string, len(fields))
135     database.cursor.execute(sql_string, fields)
136
137     logger.debug("rowcount=%d", database.cursor.rowcount)
138     if database.cursor.rowcount == 0:
139         raise Exception(f"Did not update any rows: domain='{domain}',fields()={len(fields)}")
140
141     logger.debug("Invoking commit() ...")
142     database.connection.commit()
143
144     logger.debug("Deleting _pending for domain='%s'", domain)
145     for key in _pending:
146         logger.debug("domain='%s',key='%s'", domain, key)
147         if domain in _pending[key]:
148             logger.debug("Deleting key='%s',domain='%s' ...", key, domain)
149             del _pending[key][domain]
150
151     logger.debug("EXIT!")
152
153 def add(domain: str, origin: str, command: str, path: str = None, software: str = None):
154     logger.debug("domain='%s',origin='%s',command='%s',path='%s',software='%s' - CALLED!", domain, origin, command, path, software)
155     domain_helper.raise_on(domain)
156
157     if not isinstance(origin, str) and origin is not None:
158         raise ValueError(f"origin[]='{type(origin)}' is not of type 'str'")
159     elif origin == "":
160         raise ValueError("Parameter 'origin' is empty")
161     elif not isinstance(command, str):
162         raise ValueError(f"command[]='{type(command)}' is not of type 'str'")
163     elif command == "":
164         raise ValueError("Parameter 'command' is empty")
165     elif not isinstance(path, str) and path is not None:
166         raise ValueError(f"path[]='{type(path)}' is not of type 'str'")
167     elif path == "":
168         raise ValueError("Parameter 'path' is empty")
169     elif not isinstance(software, str) and software is not None:
170         raise ValueError(f"software[]='{type(software)}' is not of type 'str'")
171     elif software == "":
172         raise ValueError("Parameter 'software' is empty")
173     elif origin is not None and not validators.domain(origin.split("/")[0]):
174         raise ValueError(f"Bad origin name='{origin}'")
175     elif blacklist.is_blacklisted(domain):
176         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
177     elif domain.find("/profile/") > 0 or domain.find("/users/") > 0 or (is_registered(domain.split("/")[0]) and domain.find("/c/") > 0):
178         raise Exception(f"domain='{domain}' is a single user")
179     elif domain.find("/tag/") > 0:
180         raise Exception(f"domain='{domain}' is a tag")
181
182     if software is None:
183         try:
184             logger.debug("domain='%s',origin='%s',command='%s',path='%s'", domain, origin, command, path)
185             software = federation.determine_software(domain, path)
186         except network.exceptions as exception:
187             logger.warning("Exception '%s' during determining software type, domain='%s'", type(exception), domain)
188             set_last_error(domain, exception)
189
190     logger.debug("Determined software='%s'", software)
191     if software == "lemmy" and domain.find("/c/") > 0:
192         domain = domain.split("/c/")[0]
193         if is_registered(domain):
194             logger.warning("domain='%s' already registered after cutting off user part. - EXIT!", domain)
195             return
196
197     logger.info("Adding instance domain='%s',origin='%s',software='%s',command='%s'", domain, origin, software, command)
198     database.cursor.execute(
199         "INSERT INTO instances (domain, origin, command, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
200         (
201            domain,
202            origin,
203            command,
204            utils.get_hash(domain),
205            software,
206            time.time()
207         ),
208     )
209
210     logger.debug("Marking domain='%s' as registered.", domain)
211     cache.set_sub_key("is_registered", domain, True)
212
213     logger.debug("Checking if domain='%s' has pending updates ...", domain)
214     if has_pending(domain):
215         logger.debug("Flushing updates for domain='%s' ...", domain)
216         update_data(domain)
217
218     logger.debug("EXIT!")
219
220 def set_last_nodeinfo(domain: str):
221     logger.debug("domain='%s' - CALLED!", domain)
222     domain_helper.raise_on(domain)
223
224     logger.debug("Updating last_nodeinfo for domain='%s'", domain)
225     _set_data("last_nodeinfo", domain, time.time())
226
227     logger.debug("EXIT!")
228
229 def set_last_error(domain: str, error: dict):
230     logger.debug("domain='%s',error[]='%s' - CALLED!", domain, type(error))
231     domain_helper.raise_on(domain)
232
233     logger.debug("error[]='%s' - BEFORE!", type(error))
234     if isinstance(error, (BaseException, json.decoder.JSONDecodeError)):
235         error = f"error[{type(error)}]='{str(error)}'"
236     logger.debug("error[]='%s' - AFTER!", type(error))
237
238     if isinstance(error, str):
239         logger.debug("Setting last_error_details='%s' (str)", error)
240         _set_data("last_status_code"  , domain, 999)
241         _set_data("last_error_details", domain, error if error != "" else None)
242     elif isinstance(error, requests.models.Response):
243         logger.debug("Setting last_error_details='%s' (Response)", error.reason)
244         _set_data("last_status_code"  , domain, error.status_code)
245         _set_data("last_error_details", domain, error.reason if error.reason != "" else None)
246     elif not isinstance(error, dict):
247         raise KeyError(f"Cannot handle keys in error[{type(error)}]='{error}'")
248     elif "status_code" in error and "error_message" in error:
249         logger.debug("Setting last_error_details='%s' (error_message)", error['error_message'])
250         _set_data("last_status_code"  , domain, error["status_code"])
251         _set_data("last_error_details", domain, error["error_message"] if error["error_message"] != "" else None)
252     elif "json" in error and "error" in error["json"]:
253         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"])
254         _set_data("last_status_code"  , domain, error["status_code"])
255         _set_data("last_error_details", domain, error["json"]["error"] if error["json"]["error"] != "" else None)
256
257     logger.debug("Invoking error_log.add(domain='%s',error[]='%s'", domain, type(error))
258     error_log.add(domain, error)
259
260     logger.debug("EXIT!")
261
262 def set_success(domain: str):
263     logger.debug("domain='%s' - CALLED!", domain)
264     domain_helper.raise_on(domain)
265
266     # Set both to success
267     _set_data("last_status_code"  , domain, 200)
268     _set_data("last_error_details", domain, None)
269
270     logger.debug("EXIT!")
271
272 def is_registered(domain: str) -> bool:
273     logger.debug("domain='%s' - CALLED!", domain)
274     domain_helper.raise_on(domain)
275
276     logger.debug("domain='%s' - CALLED!", domain)
277     if not cache.key_exists("is_registered"):
278         logger.debug("Cache for 'is_registered' not initialized, fetching all rows ...")
279         database.cursor.execute("SELECT domain FROM instances")
280
281         # Check Set all
282         cache.set_all("is_registered", database.cursor.fetchall(), True)
283
284     # Is cache found?
285     registered = cache.sub_key_exists("is_registered", domain)
286
287     logger.debug("registered='%s' - EXIT!", registered)
288     return registered
289
290 def is_recent(domain: str, column: str = "last_instance_fetch") -> bool:
291     logger.debug("domain='%s',column='%s' - CALLED!", domain, column)
292     domain_helper.raise_on(domain)
293
294     if not isinstance(column, str):
295         raise ValueError(f"Parameter column[]='{type(column)}' is not of type 'str'")
296     elif column not in ["last_instance_fetch", "last_blocked"]:
297         raise ValueError(f"Parameter column='{column}' is not expected")
298     elif not is_registered(domain):
299         logger.debug("domain='%s' is not registered, returning False - EXIT!", domain)
300         return False
301
302     # Query database
303     database.cursor.execute(f"SELECT {column} FROM instances WHERE domain = ? LIMIT 1", [domain])
304
305     # Fetch row
306     fetched = database.cursor.fetchone()[column]
307
308     logger.debug("fetched[%s]='%s'", type(fetched), fetched)
309     recently = isinstance(fetched, float) and (time.time() - fetched) <= config.get("recheck_instance")
310
311     logger.debug("recently='%s' - EXIT!", recently)
312     return recently
313
314 def deobfuscate(char: str, domain: str, blocked_hash: str = None) -> tuple:
315     logger.debug("char='%s',domain='%s',blocked_hash='%s' - CALLED!", char, domain, blocked_hash)
316
317     if not isinstance(char, str):
318         raise ValueError(f"Parameter char[]='{type(char)}' is not of type 'str'")
319     elif char == "":
320         raise ValueError("Parameter 'char' is empty")
321     elif not char in domain:
322         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
323     elif not isinstance(domain, str):
324         raise ValueError(f"Parameter domain[]='%s'", type(domain))
325     elif domain == "":
326         raise ValueError("Parameter 'domain' is empty")
327     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
328         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not of type 'str'")
329
330     # Init row
331     row = None
332
333     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
334     if isinstance(blocked_hash, str):
335         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
336         database.cursor.execute(
337             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
338         )
339
340         row = database.cursor.fetchone()
341         logger.debug("row[]='%s'", type(row))
342
343         if row is None:
344             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
345             return deobfuscate(char, domain)
346     elif not domain.startswith("*."):
347         logger.debug("domain='%s' - BEFORE!", domain)
348         domain = tidyup.domain(domain)
349         logger.debug("domain='%s' - AFTER!", domain)
350
351         if domain == "":
352             debug.warning("domain is empty after tidyup - EXIT!")
353             return None
354
355         search = domain.replace(char, "_")
356
357         logger.debug("Looking up domain='%s',search='%s' ...", domain, search)
358         database.cursor.execute(
359             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? OR 'https://' || domain LIKE ? ORDER BY rowid LIMIT 1", [search, search]
360         )
361
362         row = database.cursor.fetchone()
363         logger.debug("row[]='%s'", type(row))
364
365     logger.debug("row[]='%s' - EXIT!", type(row))
366     return row
367
368 def set_last_blocked(domain: str):
369     logger.debug("domain='%s' - CALLED!", domain)
370     domain_helper.raise_on(domain)
371
372     # Set timestamp
373     _set_data("last_blocked", domain, time.time())
374     logger.debug("EXIT!")
375
376 def set_last_instance_fetch(domain: str):
377     logger.debug("domain='%s' - CALLED!", domain)
378     domain_helper.raise_on(domain)
379
380     # Set timestamp
381     _set_data("last_instance_fetch", domain, time.time())
382     logger.debug("EXIT!")
383
384 def set_total_peers(domain: str, peers: list):
385     logger.debug("domain='%s',peers()=%d - CALLED!", domain, len(peers))
386     domain_helper.raise_on(domain)
387
388     if not isinstance(peers, list):
389         raise ValueError(f"Parameter peers[]='{type(peers)}' is not of type 'list'")
390
391     # Set timestamp
392     _set_data("total_peers", domain, len(peers))
393     logger.debug("EXIT!")
394
395 def set_total_blocks(domain: str, blocks: list):
396     logger.debug("domain='%s',blocks()=%d - CALLED!", domain, len(blocks))
397     domain_helper.raise_on(domain)
398
399     if not isinstance(blocks, list):
400         raise ValueError(f"Parameter blocks[]='{type(blocks)}' is not of type 'list'")
401
402     # Set timestamp
403     _set_data("total_blocks", domain, len(blocks))
404     logger.debug("EXIT!")
405
406 def set_nodeinfo_url(domain: str, url: str):
407     logger.debug("domain='%s',url='%s' - CALLED!", domain, url)
408     domain_helper.raise_on(domain)
409
410     if not isinstance(url, str) and url is not None:
411         raise ValueError(f"Parameter url[]='{type(url)}' is not of type 'str'")
412     elif url == "":
413         raise ValueError("Parameter 'url' is empty")
414
415     # Set timestamp
416     _set_data("nodeinfo_url", domain, url)
417     logger.debug("EXIT!")
418
419 def set_detection_mode(domain: str, mode: str):
420     logger.debug("domain='%s',mode='%s' - CALLED!", domain, mode)
421     domain_helper.raise_on(domain)
422
423     if not isinstance(mode, str) and mode is not None:
424         raise ValueError(f"Parameter mode[]='{type(mode)}' is not of type 'str'")
425     elif mode == "":
426         raise ValueError("Parameter 'mode' is empty")
427
428     # Set timestamp
429     _set_data("detection_mode", domain, mode)
430     logger.debug("EXIT!")
431
432 def set_has_obfuscation(domain: str, status: bool):
433     logger.debug("domain(%d)='%s',status='%s' - CALLED!", len(domain), domain, status)
434     domain_helper.raise_on(domain)
435
436     if not isinstance(status, bool):
437         raise ValueError(f"Parameter status[]='{type(status)}' is not of type 'bool'")
438
439     # Set timestamp
440     _set_data("has_obfuscation", domain, status)
441     logger.debug("EXIT!")
442
443 def set_software(domain: str, software: str):
444     logger.debug("domain='%s',software='%s' - CALLED!", domain, software)
445     domain_helper.raise_on(domain)
446
447     if not isinstance(software, str) and software is not None:
448         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
449     elif software == "":
450         raise ValueError("Parameter 'software' is empty")
451
452     # Set timestamp
453     _set_data("software", domain, software)
454     logger.debug("EXIT!")