]> git.mxchange.org Git - fba.git/blob - fba/models/instances.py
17c2018ef0354655583e1c55b9419d66c6ebc730
[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: 'AUTO_DISCOVERY', 'STATIC_CHECKS' or 'GENERATOR'
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 }
64
65 def _set_data(key: str, domain: str, value: any):
66     logger.debug("key='%s',domain='%s',value[]='%s' - CALLED!", key, domain, type(value))
67     domain_helper.raise_on(domain)
68     if not isinstance(key, str):
69         raise ValueError("Parameter key[]='{type(key)}' is not 'str'")
70     elif key == "":
71         raise ValueError("Parameter 'key' is empty")
72     elif not key in _pending:
73         raise ValueError(f"key='{key}' not found in _pending")
74     elif not utils.is_primitive(value):
75         raise ValueError(f"value[]='{type(value)}' is not a primitive type")
76
77     # Set it
78     _pending[key][domain] = value
79
80     logger.debug("EXIT!")
81
82 def has_pending(domain: str) -> bool:
83     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
84     domain_helper.raise_on(domain)
85
86     has = False
87     for key in _pending:
88         logger.debug("key='%s',domain='%s',_pending[key]()=%d", key, domain, len(_pending[key]))
89         if domain in _pending[key]:
90             has = True
91             break
92
93     logger.debug("has='%s' - EXIT!", has)
94     return has
95
96 def update_data(domain: str):
97     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
98     domain_helper.raise_on(domain)
99     if not has_pending(domain):
100         raise Exception(f"domain='{domain}' has no pending instance data, but function invoked")
101     elif not is_registered(domain):
102         raise Exception(f"domain='{domain}' cannot be updated while not being registered")
103
104     logger.debug("Updating instance data for domain='%s' ...", domain)
105     sql_string = ""
106     fields = list()
107     for key in _pending:
108         logger.debug("Checking key='%s',domain='%s'", key, domain)
109         if domain in _pending[key]:
110             logger.debug("Adding '%s' for key='%s' ...", _pending[key][domain], key)
111             fields.append(_pending[key][domain])
112             sql_string += f" {key} = ?,"
113
114     logger.debug("sql_string()=%d", len(sql_string))
115     if sql_string == "":
116         raise ValueError(f"No fields have been set, but method invoked, domain='{domain}'")
117
118     # Set last_updated to current timestamp
119     fields.append(time.time())
120
121     # For WHERE statement
122     fields.append(domain)
123
124     logger.debug("sql_string='%s',fields()=%d", sql_string, len(fields))
125     sql_string = "UPDATE instances SET" + sql_string + " last_updated = ? WHERE domain = ? LIMIT 1"
126
127     logger.debug("Executing SQL: '%s'", sql_string)
128     database.cursor.execute(sql_string, fields)
129
130     logger.debug("rowcount=%d", database.cursor.rowcount)
131     if database.cursor.rowcount == 0:
132         raise Exception(f"Did not update any rows: domain='{domain}',fields()={len(fields)}")
133
134     logger.debug("Invoking commit() ...")
135     database.connection.commit()
136
137     logger.debug("Deleting _pending for domain='%s'", domain)
138     for key in _pending:
139         logger.debug("domain='%s',key='%s'", domain, key)
140         if domain in _pending[key]:
141             logger.debug("Deleting key='%s',domain='%s' ...", key, domain)
142             del _pending[key][domain]
143
144     logger.debug("EXIT!")
145
146 def add(domain: str, origin: str, command: str, path: str = None, software: str = None):
147     logger.debug("domain='%s',origin='%s',command='%s',path='%s',software='%s' - CALLED!", domain, origin, command, path, software)
148     domain_helper.raise_on(domain)
149     if not isinstance(origin, str) and origin is not None:
150         raise ValueError(f"origin[]='{type(origin)}' is not 'str'")
151     elif origin == "":
152         raise ValueError("Parameter 'origin' is empty")
153     elif not isinstance(command, str):
154         raise ValueError(f"command[]='{type(command)}' is not 'str'")
155     elif command == "":
156         raise ValueError("Parameter 'command' is empty")
157     elif not isinstance(path, str) and path is not None:
158         raise ValueError(f"path[]='{type(path)}' is not 'str'")
159     elif path == "":
160         raise ValueError("Parameter 'path' is empty")
161     elif not isinstance(software, str) and software is not None:
162         raise ValueError(f"software[]='{type(software)}' is not 'str'")
163     elif software == "":
164         raise ValueError("Parameter 'software' is empty")
165     elif origin is not None and not validators.domain(origin.split("/")[0]):
166         raise ValueError(f"Bad origin name='{origin}'")
167     elif blacklist.is_blacklisted(domain):
168         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
169     elif domain.find("/profile/") > 0 or domain.find("/users/") > 0 or (software == "lemmy" and domain.find("/c/") > 0):
170         raise Exception(f"domain='{domain}' is a single user")
171
172     if software is None:
173         try:
174             logger.debug("domain='%s',origin='%s',command='%s',path='%s'", domain, origin, command, path)
175             software = federation.determine_software(domain, path)
176         except network.exceptions as exception:
177             logger.warning("Exception '%s' during determining software type, domain='%s'", type(exception), domain)
178             set_last_error(domain, exception)
179
180     logger.debug("Determined software='%s'", software)
181     if software == "lemmy" and domain.find("/c/") > 0:
182         domain = domain.split("/c/")[0]
183         if is_registered(domain):
184             logger.warning("domain='%s' already registered after cutting off user part. - EXIT!", domain)
185             return
186
187     logger.info("Adding instance domain='%s',origin='%s',software='%s',command='%s'", domain, origin, software, command)
188     database.cursor.execute(
189         "INSERT INTO instances (domain, origin, command, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
190         (
191            domain,
192            origin,
193            command,
194            utils.get_hash(domain),
195            software,
196            time.time()
197         ),
198     )
199
200     logger.debug("Marking domain='%s' as registered.", domain)
201     cache.set_sub_key("is_registered", domain, True)
202
203     if has_pending(domain):
204         logger.debug("domain='%s' has pending nodeinfo being updated ...", domain)
205         update_data(domain)
206
207     logger.debug("EXIT!")
208
209 def set_last_nodeinfo(domain: str):
210     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
211     domain_helper.raise_on(domain)
212
213     logger.debug("Updating last_nodeinfo for domain:", domain)
214     _set_data("last_nodeinfo", domain, time.time())
215
216     # Running pending updated
217     logger.debug("Invoking update_data(%s) ...", domain)
218     update_data(domain)
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 is_registered(domain: str) -> bool:
256     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
257     domain_helper.raise_on(domain)
258
259     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
260     if not cache.key_exists("is_registered"):
261         logger.debug("Cache for 'is_registered' not initialized, fetching all rows ...")
262         database.cursor.execute("SELECT domain FROM instances")
263
264         # Check Set all
265         cache.set_all("is_registered", database.cursor.fetchall(), True)
266
267     # Is cache found?
268     registered = cache.sub_key_exists("is_registered", domain)
269
270     logger.debug("registered='%s' - EXIT!", registered)
271     return registered
272
273 def is_recent(domain: str) -> bool:
274     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
275     domain_helper.raise_on(domain)
276     if not is_registered(domain):
277         logger.debug(f"domain='{domain}' is not registered, returning False - EXIT!")
278         return False
279
280     # Query database
281     database.cursor.execute("SELECT last_instance_fetch FROM instances WHERE domain = ? LIMIT 1", [domain])
282
283     # Fetch row
284     fetched = database.cursor.fetchone()[0]
285
286     logger.debug("fetched[%s]='%s'", type(fetched), fetched)
287     recently = isinstance(fetched, float) and time.time() - fetched <= config.get("recheck_instance")
288
289     logger.debug("recently='%s' - EXIT!", recently)
290     return recently
291
292 def deobscure(char: str, domain: str, blocked_hash: str = None) -> tuple:
293     logger.debug("char='%s',domain='%s',blocked_hash='%s' - CALLED!", char, domain, blocked_hash)
294
295     if not isinstance(domain, str):
296         raise ValueError(f"Parameter domain[]='{type(domain)}' is not 'str'")
297     elif domain == "":
298         raise ValueError("Parameter 'domain' is empty")
299     elif domain.lower() != domain:
300         raise ValueError(f"Parameter domain='{domain}' must be all lower-case")
301     elif domain.endswith(".arpa"):
302         raise ValueError(f"domain='{domain}' is a domain for reversed IP addresses, please don't crawl them!")
303     elif domain.endswith(".tld"):
304         raise ValueError(f"domain='{domain}' is a fake domain, please don't crawl them!")
305     elif not isinstance(char, str):
306         raise ValueError(f"Parameter char[]='{type(char)}' is not 'str'")
307     elif char == "":
308         raise ValueError("Parameter 'char' is empty")
309     elif not char in domain:
310         raise ValueError(f"char='{char}' not found in domain='{domain}' but function invoked")
311     elif not isinstance(blocked_hash, str) and blocked_hash is not None:
312         raise ValueError(f"Parameter blocked_hash[]='{type(blocked_hash)}' is not 'str'")
313
314     logger.debug("blocked_hash[]='%s'", type(blocked_hash))
315     if isinstance(blocked_hash, str):
316         logger.debug("Looking up blocked_hash='%s',domain='%s' ...", blocked_hash, domain)
317         database.cursor.execute(
318             "SELECT domain, origin, nodeinfo_url FROM instances WHERE hash = ? OR domain LIKE ? LIMIT 1", [blocked_hash, domain.replace(char, "_")]
319         )
320
321         row = database.cursor.fetchone()
322         logger.debug("row[]='%s'", type(row))
323
324         if row is None:
325             logger.debug("blocked_hash='%s' not found, trying domain='%s' ...", blocked_hash, domain)
326             return deobscure(char, domain)
327     else:
328         logger.debug("Looking up domain='%s' ...", domain)
329         database.cursor.execute(
330             "SELECT domain, origin, nodeinfo_url FROM instances WHERE domain LIKE ? ORDER BY rowid LIMIT 1", [domain.replace(char, "_")]
331         )
332
333         row = database.cursor.fetchone()
334         logger.debug("row[]='%s'", type(row))
335
336     logger.debug("row[]='%s' - EXIT!", type(row))
337     return row
338
339 def set_last_blocked(domain: str):
340     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
341     domain_helper.raise_on(domain)
342
343     # Set timestamp
344     _set_data("last_blocked", domain, time.time())
345     logger.debug("EXIT!")
346
347 def set_last_instance_fetch(domain: str):
348     logger.debug("domain(%d)='%s' - CALLED!", len(domain), domain)
349     domain_helper.raise_on(domain)
350
351     # Set timestamp
352     _set_data("last_instance_fetch", domain, time.time())
353     logger.debug("EXIT!")
354
355 def set_total_peers(domain: str, peers: list):
356     logger.debug(f"domain='{domain}',peers()={len(peers)} - CALLED!")
357     domain_helper.raise_on(domain)
358     if not isinstance(peers, list):
359         raise ValueError(f"Parameter peers[]='{type(peers)}' is not 'list'")
360
361     # Set timestamp
362     _set_data("total_peers", domain, len(peers))
363     logger.debug("EXIT!")
364
365 def set_nodeinfo_url(domain: str, url: str):
366     logger.debug(f"domain='{domain}',url='{url}' - CALLED!")
367     domain_helper.raise_on(domain)
368     if not isinstance(url, str):
369         raise ValueError("Parameter url[]='{type(url)}' is not 'list'")
370     elif url == "":
371         raise ValueError("Parameter 'url' is empty")
372
373     # Set timestamp
374     _set_data("nodeinfo_url", domain, url)
375     logger.debug("EXIT!")
376
377 def set_detection_mode(domain: str, mode: str):
378     logger.debug(f"domain='{domain}',mode='{mode}' - CALLED!")
379     domain_helper.raise_on(domain)
380     if not isinstance(mode, str):
381         raise ValueError("Parameter mode[]='{type(mode)}' is not 'list'")
382     elif mode == "":
383         raise ValueError("Parameter 'mode' is empty")
384
385     # Set timestamp
386     _set_data("detection_mode", domain, mode)
387     logger.debug("EXIT!")