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