]> git.mxchange.org Git - fba.git/blob - fba/http/network.py
Continued:
[fba.git] / fba / http / network.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 time
19
20 import reqto
21 import requests
22 import urllib3
23
24 from fba import utils
25
26 from fba.helpers import blacklist
27 from fba.helpers import config
28 from fba.helpers import cookies
29 from fba.helpers import domain as domain_helper
30 from fba.helpers import json as json_helper
31
32 from fba.models import instances
33
34 logging.basicConfig(level=logging.INFO)
35 logger = logging.getLogger(__name__)
36
37 # HTTP headers for non-API requests
38 web_headers = {
39     "User-Agent": config.get("useragent"),
40 }
41
42 # HTTP headers for API requests
43 api_headers = {
44     "User-Agent"  : config.get("useragent"),
45     "Content-Type": "application/json",
46 }
47
48 # Exceptions to always catch
49 exceptions = (
50     requests.exceptions.ChunkedEncodingError,
51     requests.exceptions.ConnectionError,
52     requests.exceptions.ContentDecodingError,
53     requests.exceptions.InvalidSchema,
54     requests.exceptions.InvalidURL,
55     requests.exceptions.Timeout,
56     requests.exceptions.TooManyRedirects,
57     UnicodeEncodeError,
58     urllib3.exceptions.LocationParseError
59 )
60
61 def post_json_api(domain: str, path: str, data: str = "", headers: dict = dict()) -> dict:
62     logger.debug("domain='%s',path='%s',data='%s',headers()=%d - CALLED!", domain, path, data, len(headers))
63     domain_helper.raise_on(domain)
64
65     if blacklist.is_blacklisted(domain):
66         raise ValueError(f"domain='{domain}' is blacklisted but function was invoked")
67     elif not isinstance(path, str):
68         raise ValueError(f"path[]='{type(path)}' is not of type 'str'")
69     elif path == "":
70         raise ValueError("Parameter 'path' is empty")
71     elif not path.startswith("/"):
72         raise ValueError(f"path='{path}' does not start with / but should")
73     elif not isinstance(data, str):
74         raise ValueError(f"data[]='{type(data)}' is not of type 'str'")
75     elif not isinstance(headers, dict):
76         raise ValueError(f"headers[]='{type(headers)}' is not of type 'list'")
77
78     json_reply = {
79         "status_code": 200,
80     }
81
82     try:
83         logger.debug("Sending POST to domain='%s',path='%s',data='%s',headers(%d)='%s'", domain, path, data, len(headers), headers)
84         start = time.perf_counter()
85         response = reqto.post(
86             f"https://{domain}{path}",
87             data=data,
88             headers={**api_headers, **headers},
89             timeout=(config.get("connection_timeout"), config.get("read_timeout")),
90             cookies=cookies.get_all(domain),
91             allow_redirects=False
92         )
93         response_time = time.perf_counter() - start
94         logger.debug("response_time=%s", response_time)
95
96         instances.set_last_response_time(domain, response_time)
97
98         logger.debug("response.ok='%s',response.status_code=%d,response.reason='%s',response_time=%s", response.ok, response.status_code, response.reason, response_time)
99         if response.ok and response.status_code == 200:
100             logger.debug("Parsing JSON response from domain='%s',path='%s' ...", domain, path)
101             json_reply["json"] = json_helper.from_response(response)
102
103         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
104         if not response.ok or response.status_code > 200 or len(response.text.strip()) == 0:
105             logger.debug("Cannot query JSON API: domain='%s',path='%s',data()=%d,response.status_code=%d,response.text()=%d", domain, path, len(data), response.status_code, len(response.text))
106             json_reply["status_code"]   = response.status_code
107             json_reply["error_message"] = response.reason
108             instances.set_last_error(domain, response)
109
110     except exceptions as exception:
111         logger.debug("Fetching path='%s' from domain='%s' failed. exception[%s]='%s'", path, domain, type(exception), str(exception))
112         json_reply["status_code"]   = 999
113         json_reply["error_message"] = f"exception['{type(exception)}']='{str(exception)}'"
114         json_reply["exception"]     = exception
115         instances.set_last_error(domain, exception)
116         raise exception
117
118     logger.debug("Returning json_reply(%d)[]='%s' - EXIT!", len(json_reply), type(json_reply))
119     return json_reply
120
121 def fetch_api_url(url: str, timeout: tuple) -> dict:
122     logger.debug("url='%s',timeout()=%d - CALLED!", url, len(timeout))
123
124     if not isinstance(url, str):
125         raise ValueError(f"Parameter url[]='{type(url)}' is not of type 'str'")
126     elif url == "":
127         raise ValueError("Parameter 'url' is empty")
128     elif not validators.url(url):
129         raise ValueError(f"Parameter url='{url}' is not a valid URL")
130     elif not isinstance(timeout, tuple):
131         raise ValueError(f"timeout[]='{type(timeout)}' is not of type 'tuple'")
132
133     json_reply = {
134        "status_code": 200,
135     }
136
137     try:
138         logger.debug("Fetching url='%s' ...", url)
139         response = utils.fetch_url(url, api_headers, timeout)
140
141         logger.debug("response.ok='%s',response.status_code=%d,response.reason='%s'", response.ok, response.status_code, response.reason)
142         if response.ok and response.status_code == 200:
143             logger.debug("Parsing JSON response from url='%s' ...", url)
144             json_reply["json"] = json_helper.from_response(response)
145
146         logger.debug("response.ok='%s',response.status_code='%s',response.text()=%d", response.ok, response.status_code, len(response.text))
147         if not response.ok or response.status_code > 200 or len(response.text) == 0:
148             logger.warning("Cannot query JSON API: url='%s',response.status_code=%d,response.text()=%d", url, response.status_code, len(response.text))
149             json_reply["status_code"]   = response.status_code
150             json_reply["error_message"] = response.reason
151
152     except exceptions as exception:
153         logger.debug("Fetching url='%s' failed. exception[%s]='%s'", url, type(exception), str(exception))
154         json_reply["status_code"]   = 999
155         json_reply["error_message"] = f"exception['{type(exception)}']='{str(exception)}'"
156         json_reply["exception"]     = exception
157         raise exception
158
159     logger.debug("Returning json_reply(%d)[]='%s' - EXIT!", len(json_reply), type(json_reply))
160     return json_reply
161
162 def get_json_api(domain: str, path: str, headers: dict, timeout: tuple) -> dict:
163     logger.debug("domain='%s',path='%s',timeout()=%d - CALLED!", domain, path, len(timeout))
164     domain_helper.raise_on(domain)
165
166     if blacklist.is_blacklisted(domain):
167         raise ValueError(f"domain='{domain}' is blacklisted but function was invoked")
168     elif not isinstance(path, str):
169         raise ValueError(f"path[]='{type(path)}' is not of type 'str'")
170     elif path == "":
171         raise ValueError("Parameter 'path' is empty")
172     elif not path.startswith("/"):
173         raise ValueError(f"path='{path}' does not start with / but should")
174     elif not isinstance(headers, dict):
175         raise ValueError(f"headers[]='{type(headers)}' is not of type 'list'")
176     elif not isinstance(timeout, tuple):
177         raise ValueError(f"timeout[]='{type(timeout)}' is not of type 'tuple'")
178
179     json_reply = {
180         "status_code": 200,
181     }
182
183     try:
184         logger.debug("Sending GET to domain='%s',path='%s',timeout(%d)='%s'", domain, path, len(timeout), timeout)
185         response = fetch_response(domain, path, {**api_headers, **headers}, timeout)
186     except exceptions as exception:
187         logger.debug("Fetching path='%s' from domain='%s' failed. exception[%s]='%s'", path, domain, type(exception), str(exception))
188         json_reply["status_code"]   = 999
189         json_reply["error_message"] = f"exception['{type(exception)}']='{str(exception)}'"
190         json_reply["exception"]     = exception
191         instances.set_last_error(domain, exception)
192         raise exception
193
194     logger.debug("response.ok='%s',response.status_code=%d,response.reason='%s'", response.ok, response.status_code, response.reason)
195     if response.ok and response.status_code == 200:
196         logger.debug("Parsing JSON response from domain='%s',path='%s' ...", domain, path)
197         json_reply["json"] = json_helper.from_response(response)
198         logger.debug("json_reply[json][]='%s'", type(json_reply["json"]))
199
200     logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
201     if not response.ok or response.status_code > 200 or len(response.text) == 0:
202         logger.debug("Cannot query JSON API: domain='%s',path='%s',response.status_code=%d,response.text()=%d", domain, path, response.status_code, len(response.text))
203         json_reply["status_code"]   = response.status_code
204         json_reply["error_message"] = response.reason
205         instances.set_last_error(domain, response)
206
207     logger.debug("Returning json_reply(%d)[]='%s' - EXIT!", len(json_reply), type(json_reply))
208     return json_reply
209
210 def send_bot_post(domain: str, blocklist: list):
211     logger.debug("domain='%s',blocklist()=%d - CALLED!", domain, len(blocklist))
212     domain_helper.raise_on(domain)
213
214     if blacklist.is_blacklisted(domain):
215         raise ValueError(f"domain='{domain}' is blacklisted but function was invoked")
216     elif not isinstance(blocklist, list):
217         raise ValueError(f"Parameter blocklist[]='{type(blocklist)}' is not of type 'list'")
218     elif len(blocklist) == 0:
219         raise ValueError("Parameter 'blocklist' is empty")
220     elif config.get("bot_token") == "":
221         raise ValueError("config[bot_token] is not set")
222
223     message = f"{domain} has blocked the following instances:\n\n"
224     truncated = False
225
226     if len(blocklist) > 20:
227         logger.warning("blocklist()=%d for domain='%s' has more than 20 records, truncating to 20 ...", len(blocklist), domain)
228         truncated = True
229         blocklist = blocklist[0 : 19]
230
231     logger.debug("blocklist()=%d", len(blocklist))
232     for block in blocklist:
233         logger.debug("block[%s]='%s'", type(block), block)
234         if block["reason"] in [None, ""]:
235             logger.debug("block[blocked]='%s' is being blocked with no reason specified", block["blocked"])
236             message = message + block["blocked"] + " with unspecified reason\n"
237         else:
238             logger.debug("block[reason]()=%d", len(block["reason"]))
239             if len(block["reason"]) > 420:
240                 block["reason"] = block["reason"][0:419] + "[…]"
241
242             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
243
244     if truncated:
245         message = message + "(the list has been truncated to the first 20 entries)"
246
247     response = reqto.post(
248         f"{config.get('bot_instance')}/api/v1/statuses",
249         data={
250             "status"      : message,
251             "visibility"  : config.get("bot_visibility"),
252             "content_type": "text/plain"
253         },
254         headers={**api_headers, **{"Authorization": "Bearer " + config.get("bot_token")}},
255         timeout=(config.get("connection_timeout"), config.get("read_timeout")),
256         allow_redirects=False
257     )
258
259     logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
260     return response.ok and response.status_code == 200 and response.text.strip() != ""
261
262 def fetch_response(domain: str, path: str, headers: dict, timeout: tuple, allow_redirects: bool = False) -> requests.models.Response:
263     logger.debug("domain='%s',path='%s',headers()=%d,timeout='%s',allow_redirects='%s' - CALLED!", domain, path, len(headers), timeout, allow_redirects)
264     domain_helper.raise_on(domain)
265
266     if blacklist.is_blacklisted(domain):
267         raise ValueError(f"domain='{domain}' is blacklisted but function was invoked")
268     elif not isinstance(path, str):
269         raise ValueError(f"Parameter path[]='{type(path)}' is not of type 'str'")
270     elif path == "":
271         raise ValueError("Parameter 'path' is empty")
272     elif not path.startswith("/"):
273         raise ValueError(f"path='{path}' does not start with / but should")
274     elif not isinstance(headers, dict):
275         raise ValueError(f"headers[]='{type(headers)}' is not of type 'dict'")
276     elif not isinstance(timeout, tuple):
277         raise ValueError(f"timeout[]='{type(timeout)}' is not of type 'tuple'")
278
279     start = 0
280     try:
281         logger.debug("Sending GET request to 'https://%s%s' ...", domain, path)
282         start = time.perf_counter()
283         response = reqto.get(
284             f"https://{domain}{path}",
285             headers=headers,
286             timeout=timeout,
287             cookies=cookies.get_all(domain),
288             allow_redirects=allow_redirects
289         )
290         response_time = time.perf_counter() - start
291         logger.debug("Setting response_time=%s for domain='%s' ...", response_time, domain)
292         instances.set_last_response_time(domain, response_time)
293
294         logger.debug("response.ok='%s',response.status_code=%d,response.reason='%s',response_time=%s", response.ok, response.status_code, response.reason, response_time)
295     except exceptions as exception:
296         logger.debug("Fetching path='%s' from domain='%s' failed. exception[%s]='%s'", path, domain, type(exception), str(exception))
297         instances.set_last_error(domain, exception)
298
299         response_time = time.perf_counter() - start
300         logger.debug("Setting response_time=%s for domain='%s' ...", response_time, domain)
301         instances.set_last_response_time(domain, response_time)
302
303         raise exception
304
305     logger.debug("response[]='%s' - EXIT!", type(response))
306     return response