]> git.mxchange.org Git - fba.git/blob - fba/models/instances.py
3ba4bd760bec1a0cf58135e0697c6a2a4be327aa
[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     elif domain.find("/tag/") > 0:
175         raise Exception(f"domain='{domain}' is a tag")
176
177     if software is None:
178         try:
179             logger.debug("domain='%s',origin='%s',command='%s',path='%s'", domain, origin, command, path)
180             software = federation.determine_software(domain, path)
181         except network.exceptions as exception:
182             logger.warning("Exception '%s' during determining software type, domain='%s'", type(exception), domain)
183             set_last_error(domain, exception)
184
185     logger.debug("Determined software='%s'", software)
186     if software == "lemmy" and domain.find("/c/") > 0:
187         domain = domain.split("/c/")[0]
188         if is_registered(domain):
189             logger.warning("domain='%s' already registered after cutting off user part. - EXIT!", domain)
190             return
191
192     logger.info("Adding instance domain='%s',origin='%s',software='%s',command='%s'", domain, origin, software, command)
193     database.cursor.execute(
194         "INSERT INTO instances (domain, origin, command, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
195         (
196            domain,
197            origin,
198            command,
199            utils.get_hash(domain),
200            software,
201            time.time()
202         ),
203     )
204
205     logger.debug("Marking domain='%s' as registered.", domain)
206     cache.set_sub_key("is_registered", domain, True)
207
208     logger.debug("Checking if domain='%s' has pending updates ...", domain)
209     if has_pending(domain):
210         logger.debug("Flushing updates for domain='%s' ...", domain)
211         update_data(domain)
212
213     logger.debug("EXIT!")
214
215 def set_last_nodeinfo(domain: str):
216     logger.debug("domain='%s' - CALLED!", domain)
217     domain_helper.raise_on(domain)
218
219     logger.debug("Updating last_nodeinfo for domain='%s'", domain)
220     _set_data("last_nodeinfo", domain, time.time())
221
222     logger.debug("EXIT!")
223
224 def set_last_error(domain: str, error: dict):
225     logger.debug("domain='%s',error[]='%s' - CALLED!", domain, type(error))
226     domain_helper.raise_on(domain)
227
228     logger.debug("error[]='%s' - BEFORE!", type(error))
229     if isinstance(error, (BaseException, json.decoder.JSONDecodeError)):
230         error = f"error[{type(error)}]='{str(error)}'"
231     logger.debug("error[]='%s' - AFTER!", type(error))
232
233     if isinstance(error, str):
234         logger.debug("Setting last_error_details='%s' (str)", error)
235         _set_data("last_status_code"  , domain, 999)
236         _set_data("last_error_details", domain, error if error != "" else None)
237     elif isinstance(error, requests.models.Response):
238         logger.debug("Setting last_error_details='%s' (Response)", error.reason)
239         _set_data("last_status_code"  , domain, error.status_code)
240         _set_data("last_error_details", domain, error.reason if error.reason != "" else None)
241     elif not isinstance(error, dict):
242         raise KeyError(f"Cannot handle keys in error[{type(error)}]='{error}'")
243     elif "status_code" in error and "error_message" in error:
244         logger.debug("Setting last_error_details='%s' (error_message)", error['error_message'])
245         _set_data("last_status_code"  , domain, error["status_code"])
246         _set_data("last_error_details", domain, error["error_message"] if error["error_message"] != "" else None)
247     elif "json" in error and "error" in error["json"]:
248         logger.debug("Setting last_error_details='%s' (json,error)", error["json"]["error"])
249         _set_data("last_status_code"  , domain, error["status_code"])
250         _set_data("last_error_details", domain, error["json"]["error"] if error["json"]["error"] != "" else None)
251
252     logger.debug("Invoking error_log.add(domain='%s',error[]='%s'", domain, type(error))
253     error_log.add(domain, error)
254
255     logger.debug("EXIT!")
256
257 def set_success(domain: str):
258     logger.debug("domain='%s' - CALLED!", domain)
259     domain_helper.raise_on(domain)
260
261     # Set both to success
262     _set_data("last_status_code"  , domain, 200)
263     _set_data("last_error_details", domain, None)
264
265     logger.debug("EXIT!")
266
267 def is_registered(domain: str) -> bool:
268     logger.debug("domain='%s' - CALLED!", domain)
269     domain_helper.raise_on(domain)
270
271     logger.debug("domain='%s' - CALLED!", domain)
272     if not cache.key_exists("is_registered"):
273         logger.debug("Cache for 'is_registered' not initialized, fetching all rows ...")
274         database.cursor.execute("SELECT domain FROM instances")
275
276         # Check Set all
277         cache.set_all("is_registered", database.cursor.fetchall(), True)
278
279     # Is cache found?
280     registered = cache.sub_key_exists("is_registered", domain)
281
282     logger.debug("registered='%s' - EXIT!", registered)
283     return registered
284
285 def is_recent(domain: str, column: str = "last_instance_fetch") -> bool:
286     logger.debug("domain='%s',column='%s' - CALLED!", domain)
287     domain_helper.raise_on(domain)
288
289     if not isinstance(column, str):
290         raise ValueError(f"Parameter column[]='{type(column)}' is not 'str'")
291     elif column not in ["last_instance_fetch", "last_blocks"]:
292         raise ValueError(f"Parameter column='{column}' is not expected")
293     elif not is_registered(domain):
294         logger.debug("domain='%s' is not registered, returning False - EXIT!", domain)
295         return False
296
297     # Query database
298     database.cursor.execute(f"SELECT {column} FROM instances WHERE domain = ? LIMIT 1", [domain])
299
300     # Fetch row
301     fetched = database.cursor.fetchone()[0]
302
303     logger.debug("fetched[%s]='%s'", type(fetched), fetched)
304     recently = isinstance(fetched, float) and time.time() - fetched <= config.get("recheck_instance")
305
306     logger.debug("recently='%s' - EXIT!", recently)
307     return recently
308
309 def deobfuscate(char: str, domain: str, blocked_hash: str = None) -> tuple:
310     logger.debug("char='%s',domain='%s',blocked_hash='%s' - CALLED!", char, domain, blocked_hash)
311
312     if not isinstance(domain, str):
313         raise ValueError(f"Parameter domain[]='{type(domain)}' is not 'str'")
314     elif domain == "":
315         raise ValueError("Parameter 'domain' is empty")
316     elif domain.lower() != domain:
317         raise ValueError(f"Parameter domain='{domain}' must be all lower-case")
318     elif domain.endswith(".arpa"):
319         raise ValueError(f"domain='{domain}' is a domain for reversed IP addresses, please don't crawl them!")
320     elif domain.endswith(".tld"):
321         raise ValueError(f"domain='{domain}' is a fake domain, please don't crawl them!")
322     elif not isinstance(char, str):
323         raise ValueError(f"Parameter char[]='{type(char)}' is not 'str'")
324     elif char == "":
325         raise ValueError("Parameter 'char' is empty")
326     elif not char in domain:
327         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
328     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
329         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not 'str'")
330
331     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
332     if isinstance(blocked_hash, str):
333         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
334         database.cursor.execute(
335             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
336         )
337
338         row = database.cursor.fetchone()
339         logger.debug("row[]='%s'", type(row))
340
341         if row is None:
342             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
343             return deobfuscate(char, domain)
344     else:
345         logger.debug("Looking up domain='%s' ...", domain)
346         database.cursor.execute(
347             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? ORDER BY rowid LIMIT 1", [domain.replace(char, "_")]
348         )
349
350         row = database.cursor.fetchone()
351         logger.debug("row[]='%s'", type(row))
352
353     logger.debug("row[]='%s' - EXIT!", type(row))
354     return row
355
356 def set_last_blocked(domain: str):
357     logger.debug("domain='%s' - CALLED!", domain)
358     domain_helper.raise_on(domain)
359
360     # Set timestamp
361     _set_data("last_blocked", domain, time.time())
362     logger.debug("EXIT!")
363
364 def set_last_instance_fetch(domain: str):
365     logger.debug("domain='%s' - CALLED!", domain)
366     domain_helper.raise_on(domain)
367
368     # Set timestamp
369     _set_data("last_instance_fetch", domain, time.time())
370     logger.debug("EXIT!")
371
372 def set_total_peers(domain: str, peers: list):
373     logger.debug("domain='%s',peers()=%d - CALLED!", domain, len(peers))
374     domain_helper.raise_on(domain)
375
376     if not isinstance(peers, list):
377         raise ValueError(f"Parameter peers[]='{type(peers)}' is not 'list': '%s'")
378
379     # Set timestamp
380     _set_data("total_peers", domain, len(peers))
381     logger.debug("EXIT!")
382
383 def set_nodeinfo_url(domain: str, url: str):
384     logger.debug("domain='%s',url='%s' - CALLED!", domain, url)
385     domain_helper.raise_on(domain)
386
387     if not isinstance(url, str):
388         raise ValueError("Parameter url[]='{type(url)}' is not 'list'")
389     elif url == "":
390         raise ValueError("Parameter 'url' is empty")
391
392     # Set timestamp
393     _set_data("nodeinfo_url", domain, url)
394     logger.debug("EXIT!")
395
396 def set_detection_mode(domain: str, mode: str):
397     logger.debug("domain='%s',mode='%s' - CALLED!", domain, mode)
398     domain_helper.raise_on(domain)
399
400     if not isinstance(mode, str):
401         raise ValueError("Parameter mode[]='{type(mode)}' is not 'list'")
402     elif mode == "":
403         raise ValueError("Parameter 'mode' is empty")
404
405     # Set timestamp
406     _set_data("detection_mode", domain, mode)
407     logger.debug("EXIT!")
408
409 def set_has_obfuscation(domain: str, status: bool):
410     logger.debug("domain(%d)='%s',status='%s' - CALLED!", len(domain), domain, status)
411     domain_helper.raise_on(domain)
412
413     if not isinstance(status, bool):
414         raise ValueError(f"Parameter status[]='{type(status)}' is not 'bool'")
415
416     # Set timestamp
417     _set_data("has_obfuscation", domain, status)
418     logger.debug("EXIT!")