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