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