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