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