]> 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
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", len(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     domain_helper.raise_on(domain)
317
318     if not isinstance(char, str):
319         raise ValueError(f"Parameter char[]='{type(char)}' is not of type 'str'")
320     elif char == "":
321         raise ValueError("Parameter 'char' is empty")
322     elif not char in domain:
323         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
324     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
325         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not of type 'str'")
326
327     # Init row
328     row = None
329
330     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
331     if isinstance(blocked_hash, str):
332         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
333         database.cursor.execute(
334             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
335         )
336
337         row = database.cursor.fetchone()
338         logger.debug("row[]='%s'", type(row))
339
340         if row is None:
341             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
342             return deobfuscate(char, domain)
343     elif not domain.startswith("*."):
344         logger.debug("domain='%s' - BEFORE!", domain)
345         domain = tidyup.domain(domain)
346         logger.debug("domain='%s' - AFTER!", domain)
347
348         if domain == "":
349             debug.warning("domain is empty after tidyup - EXIT!")
350             return None
351
352         search = domain.replace(char, "_")
353
354         logger.debug("Looking up domain='%s',search='%s' ...", domain, search)
355         database.cursor.execute(
356             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? OR 'https://' || domain LIKE ? ORDER BY rowid LIMIT 1", [search, search]
357         )
358
359         row = database.cursor.fetchone()
360         logger.debug("row[]='%s'", type(row))
361
362     logger.debug("row[]='%s' - EXIT!", type(row))
363     return row
364
365 def set_last_blocked(domain: str):
366     logger.debug("domain='%s' - CALLED!", domain)
367     domain_helper.raise_on(domain)
368
369     # Set timestamp
370     _set_data("last_blocked", domain, time.time())
371     logger.debug("EXIT!")
372
373 def set_last_instance_fetch(domain: str):
374     logger.debug("domain='%s' - CALLED!", domain)
375     domain_helper.raise_on(domain)
376
377     # Set timestamp
378     _set_data("last_instance_fetch", domain, time.time())
379     logger.debug("EXIT!")
380
381 def set_total_peers(domain: str, peers: list):
382     logger.debug("domain='%s',peers()=%d - CALLED!", domain, len(peers))
383     domain_helper.raise_on(domain)
384
385     if not isinstance(peers, list):
386         raise ValueError(f"Parameter peers[]='{type(peers)}' is not of type 'list'")
387
388     # Set timestamp
389     _set_data("total_peers", domain, len(peers))
390     logger.debug("EXIT!")
391
392 def set_total_blocks(domain: str, blocks: list):
393     logger.debug("domain='%s',blocks()=%d - CALLED!", domain, len(blocks))
394     domain_helper.raise_on(domain)
395
396     if not isinstance(blocks, list):
397         raise ValueError(f"Parameter blocks[]='{type(blocks)}' is not of type 'list'")
398
399     # Set timestamp
400     _set_data("total_blocks", domain, len(blocks))
401     logger.debug("EXIT!")
402
403 def set_nodeinfo_url(domain: str, url: str):
404     logger.debug("domain='%s',url='%s' - CALLED!", domain, url)
405     domain_helper.raise_on(domain)
406
407     if not isinstance(url, str) and url is not None:
408         raise ValueError(f"Parameter url[]='{type(url)}' is not of type 'str'")
409     elif url == "":
410         raise ValueError("Parameter 'url' is empty")
411
412     # Set timestamp
413     _set_data("nodeinfo_url", domain, url)
414     logger.debug("EXIT!")
415
416 def set_detection_mode(domain: str, mode: str):
417     logger.debug("domain='%s',mode='%s' - CALLED!", domain, mode)
418     domain_helper.raise_on(domain)
419
420     if not isinstance(mode, str) and mode is not None:
421         raise ValueError(f"Parameter mode[]='{type(mode)}' is not of type 'str'")
422     elif mode == "":
423         raise ValueError("Parameter 'mode' is empty")
424
425     # Set timestamp
426     _set_data("detection_mode", domain, mode)
427     logger.debug("EXIT!")
428
429 def set_has_obfuscation(domain: str, status: bool):
430     logger.debug("domain(%d)='%s',status='%s' - CALLED!", len(domain), domain, status)
431     domain_helper.raise_on(domain)
432
433     if not isinstance(status, bool):
434         raise ValueError(f"Parameter status[]='{type(status)}' is not of type 'bool'")
435
436     # Set timestamp
437     _set_data("has_obfuscation", domain, status)
438     logger.debug("EXIT!")
439
440 def set_software(domain: str, software: str):
441     logger.debug("domain='%s',software='%s' - CALLED!", domain, software)
442     domain_helper.raise_on(domain)
443
444     if not isinstance(software, str) and software is not None:
445         raise ValueError(f"Parameter software[]='{type(software)}' is not of type 'str'")
446     elif software == "":
447         raise ValueError("Parameter 'software' is empty")
448
449     # Set timestamp
450     _set_data("software", domain, software)
451     logger.debug("EXIT!")