flask-Humanify 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flask_humanify/__init__.py +3 -2
- flask_humanify/datasets/keys.pkl +0 -0
- flask_humanify/features/error_handler.py +177 -0
- flask_humanify/humanify.py +183 -21
- flask_humanify/memory_server.py +5 -3
- flask_humanify/templates/audio_challenge.html +3 -1
- flask_humanify/templates/exception.html +66 -0
- flask_humanify/utils.py +86 -20
- {flask_humanify-0.2.0.dist-info → flask_humanify-0.2.2.dist-info}/METADATA +112 -2
- flask_humanify-0.2.2.dist-info/RECORD +22 -0
- flask_humanify/secret_key.bin +0 -0
- flask_humanify-0.2.0.dist-info/RECORD +0 -20
- {flask_humanify-0.2.0.dist-info → flask_humanify-0.2.2.dist-info}/WHEEL +0 -0
- {flask_humanify-0.2.0.dist-info → flask_humanify-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {flask_humanify-0.2.0.dist-info → flask_humanify-0.2.2.dist-info}/top_level.txt +0 -0
flask_humanify/__init__.py
CHANGED
@@ -4,11 +4,12 @@ Flask-Humanify
|
|
4
4
|
A Flask extension that protects against bots and DDoS attacks.
|
5
5
|
"""
|
6
6
|
|
7
|
-
__version__ = "0.2.
|
7
|
+
__version__ = "0.2.2"
|
8
8
|
|
9
9
|
from . import utils
|
10
10
|
from .humanify import Humanify
|
11
11
|
from .features.rate_limiter import RateLimiter
|
12
|
+
from .features.error_handler import ErrorHandler
|
12
13
|
|
13
14
|
|
14
|
-
__all__ = ["Humanify", "RateLimiter", "utils"]
|
15
|
+
__all__ = ["Humanify", "RateLimiter", "ErrorHandler", "utils"]
|
Binary file
|
@@ -0,0 +1,177 @@
|
|
1
|
+
from typing import Final, Optional
|
2
|
+
from flask import Flask, render_template
|
3
|
+
|
4
|
+
|
5
|
+
ERROR_CODES: Final[dict] = {
|
6
|
+
400: {
|
7
|
+
"title": "Bad Request",
|
8
|
+
"description": "The server could not understand your request due to invalid syntax.",
|
9
|
+
},
|
10
|
+
401: {
|
11
|
+
"title": "Unauthorized",
|
12
|
+
"description": "You must authenticate yourself to get the requested response.",
|
13
|
+
},
|
14
|
+
403: {
|
15
|
+
"title": "Forbidden",
|
16
|
+
"description": "You do not have access rights to the content.",
|
17
|
+
},
|
18
|
+
404: {
|
19
|
+
"title": "Not Found",
|
20
|
+
"description": "The server cannot find the requested resource.",
|
21
|
+
},
|
22
|
+
405: {
|
23
|
+
"title": "Method Not Allowed",
|
24
|
+
"description": "The request method is known by the server but is not supported by the target resource.",
|
25
|
+
},
|
26
|
+
406: {
|
27
|
+
"title": "Not Acceptable",
|
28
|
+
"description": (
|
29
|
+
"The server cannot produce a response matching the list of acceptable values "
|
30
|
+
"defined in your request's proactive content negotiation headers."
|
31
|
+
),
|
32
|
+
},
|
33
|
+
408: {
|
34
|
+
"title": "Request Timeout",
|
35
|
+
"description": (
|
36
|
+
"The server did not receive a complete request message from you within the time that "
|
37
|
+
"it was prepared to wait."
|
38
|
+
),
|
39
|
+
},
|
40
|
+
409: {
|
41
|
+
"title": "Conflict",
|
42
|
+
"description": (
|
43
|
+
"The request could not be completed due to a conflict with the current state of "
|
44
|
+
"the target resource."
|
45
|
+
),
|
46
|
+
},
|
47
|
+
410: {
|
48
|
+
"title": "Gone",
|
49
|
+
"description": "The requested resource is no longer available and will not be available again.",
|
50
|
+
},
|
51
|
+
411: {
|
52
|
+
"title": "Length Required",
|
53
|
+
"description": "The server refuses to accept the request without a defined Content-Length header.",
|
54
|
+
},
|
55
|
+
412: {
|
56
|
+
"title": "Precondition Failed",
|
57
|
+
"description": (
|
58
|
+
"The server does not meet one of the preconditions that you put on "
|
59
|
+
"the request header fields."
|
60
|
+
),
|
61
|
+
},
|
62
|
+
413: {
|
63
|
+
"title": "Payload Too Large",
|
64
|
+
"description": "The request entity is larger than limits defined by the server.",
|
65
|
+
},
|
66
|
+
414: {
|
67
|
+
"title": "URI Too Long",
|
68
|
+
"description": "The URI requested by you is longer than the server is willing to interpret.",
|
69
|
+
},
|
70
|
+
415: {
|
71
|
+
"title": "Unsupported Media Type",
|
72
|
+
"description": "The media format of the requested data is not supported by the server.",
|
73
|
+
},
|
74
|
+
416: {
|
75
|
+
"title": "Range Not Satisfiable",
|
76
|
+
"description": "The range specified by the Range header field in your request can't be fulfilled.",
|
77
|
+
},
|
78
|
+
417: {
|
79
|
+
"title": "Expectation Failed",
|
80
|
+
"description": (
|
81
|
+
"The expectation given in your request's Expect header field could not be met by at "
|
82
|
+
"least one of the inbound servers."
|
83
|
+
),
|
84
|
+
},
|
85
|
+
418: {
|
86
|
+
"title": "I'm a teapot",
|
87
|
+
"description": "The web server rejects the attempt to make coffee with a teapot.",
|
88
|
+
},
|
89
|
+
422: {
|
90
|
+
"title": "Unprocessable Entity",
|
91
|
+
"description": "The request was well-formed but was unable to be followed due to semantic errors.",
|
92
|
+
},
|
93
|
+
423: {
|
94
|
+
"title": "Locked",
|
95
|
+
"description": "The resource that is being accessed is locked.",
|
96
|
+
},
|
97
|
+
424: {
|
98
|
+
"title": "Failed Dependency",
|
99
|
+
"description": "The request failed due to failure of a previous request.",
|
100
|
+
},
|
101
|
+
428: {
|
102
|
+
"title": "Precondition Required",
|
103
|
+
"description": "The origin server requires your request to be conditional.",
|
104
|
+
},
|
105
|
+
429: {
|
106
|
+
"title": "Too Many Requests",
|
107
|
+
"description": "You have sent too many requests in a given amount of time.",
|
108
|
+
},
|
109
|
+
431: {
|
110
|
+
"title": "Request Header Fields Too Large",
|
111
|
+
"description": (
|
112
|
+
"The server is unwilling to process your request because its header "
|
113
|
+
"fields are too large."
|
114
|
+
),
|
115
|
+
},
|
116
|
+
451: {
|
117
|
+
"title": "Unavailable For Legal Reasons",
|
118
|
+
"description": "The server is denying access to the resource as a consequence of a legal demand.",
|
119
|
+
},
|
120
|
+
500: {
|
121
|
+
"title": "Internal Server Error",
|
122
|
+
"description": "The server has encountered a situation it doesn't know how to handle.",
|
123
|
+
},
|
124
|
+
501: {
|
125
|
+
"title": "Not Implemented",
|
126
|
+
"description": "The request method is not supported by the server and cannot be handled.",
|
127
|
+
},
|
128
|
+
502: {
|
129
|
+
"title": "Bad Gateway",
|
130
|
+
"description": (
|
131
|
+
"The server, while acting as a gateway or proxy, received an invalid response from "
|
132
|
+
"the upstream server."
|
133
|
+
),
|
134
|
+
},
|
135
|
+
503: {
|
136
|
+
"title": "Service Unavailable",
|
137
|
+
"description": "The server is not ready to handle the request.",
|
138
|
+
},
|
139
|
+
504: {
|
140
|
+
"title": "Gateway Timeout",
|
141
|
+
"description": (
|
142
|
+
"The server is acting as a gateway or proxy and did not receive a timely response "
|
143
|
+
"from the upstream server."
|
144
|
+
),
|
145
|
+
},
|
146
|
+
505: {
|
147
|
+
"title": "HTTP Version Not Supported",
|
148
|
+
"description": "The HTTP version used in your request is not supported by the server.",
|
149
|
+
},
|
150
|
+
}
|
151
|
+
|
152
|
+
|
153
|
+
class ErrorHandler:
|
154
|
+
def __init__(self, app: Flask, errors: Optional[list[int]] = None):
|
155
|
+
self.app = app
|
156
|
+
|
157
|
+
for error_code in errors or ERROR_CODES:
|
158
|
+
self.app.register_error_handler(error_code, self.handle_error)
|
159
|
+
|
160
|
+
def handle_error(self, error: Exception) -> tuple:
|
161
|
+
"""Render exception page with appropriate error information."""
|
162
|
+
code = getattr(error, "code", type(error).__name__)
|
163
|
+
info = ERROR_CODES.get(code, {})
|
164
|
+
title = f"{code} | {info.get('title', 'Error')}"
|
165
|
+
message = (
|
166
|
+
info.get("description")
|
167
|
+
or str(error).split(" ", 1)[-1].strip()
|
168
|
+
or "An error occurred"
|
169
|
+
)
|
170
|
+
|
171
|
+
return render_template("exception.html").replace(
|
172
|
+
"EXCEPTION_TITLE", title
|
173
|
+
).replace("EXCEPTION_CODE", str(code)).replace(
|
174
|
+
"EXCEPTION_MESSAGE", message
|
175
|
+
), getattr(
|
176
|
+
error, "code", 500
|
177
|
+
)
|
flask_humanify/humanify.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
from dataclasses import dataclass
|
2
2
|
import logging
|
3
3
|
import random
|
4
|
-
from typing import List, Optional
|
4
|
+
from typing import List, Optional, Union, Dict, Any, Pattern
|
5
|
+
import re
|
6
|
+
import fnmatch
|
5
7
|
|
6
8
|
from werkzeug.wrappers import Response
|
7
9
|
from flask import (
|
@@ -46,13 +48,13 @@ IMAGE_CAPTCHA_MAPPING = {
|
|
46
48
|
"num_correct": (2, 3),
|
47
49
|
"num_images": 9,
|
48
50
|
"preview_image": False,
|
49
|
-
"hardness_range": (1,
|
51
|
+
"hardness_range": (1, 3),
|
50
52
|
},
|
51
53
|
"one_click": {
|
52
54
|
"num_correct": 1,
|
53
55
|
"num_images": 6,
|
54
56
|
"preview_image": True,
|
55
|
-
"hardness_range": (1,
|
57
|
+
"hardness_range": (1, 3),
|
56
58
|
},
|
57
59
|
}
|
58
60
|
|
@@ -127,11 +129,16 @@ class Humanify:
|
|
127
129
|
"""
|
128
130
|
|
129
131
|
def __init__(
|
130
|
-
self,
|
132
|
+
self,
|
133
|
+
app=None,
|
134
|
+
challenge_type: str = "one_click",
|
135
|
+
image_dataset: Optional[str] = "ai_dogs",
|
136
|
+
audio_dataset: Optional[str] = None,
|
131
137
|
):
|
132
138
|
self.app = app
|
133
139
|
self.challenge_type = challenge_type
|
134
|
-
self.
|
140
|
+
self.image_dataset = image_dataset
|
141
|
+
self.audio_dataset = audio_dataset
|
135
142
|
if app is not None:
|
136
143
|
self.init_app(app)
|
137
144
|
|
@@ -142,7 +149,8 @@ class Humanify:
|
|
142
149
|
self.app = app
|
143
150
|
|
144
151
|
ensure_server_running(
|
145
|
-
image_dataset=self.
|
152
|
+
image_dataset=self.image_dataset,
|
153
|
+
audio_dataset=self.audio_dataset,
|
146
154
|
)
|
147
155
|
self.memory_client = MemoryClient()
|
148
156
|
self.memory_client.connect()
|
@@ -162,6 +170,9 @@ class Humanify:
|
|
162
170
|
"""
|
163
171
|
Challenge route.
|
164
172
|
"""
|
173
|
+
if self.image_dataset is None:
|
174
|
+
return self._render_challenge(is_audio=True)
|
175
|
+
|
165
176
|
return self._render_challenge()
|
166
177
|
|
167
178
|
@self.blueprint.route("/humanify/audio_challenge", methods=["GET"])
|
@@ -169,6 +180,11 @@ class Humanify:
|
|
169
180
|
"""
|
170
181
|
Audio challenge route.
|
171
182
|
"""
|
183
|
+
if self.audio_dataset is None:
|
184
|
+
return redirect(
|
185
|
+
url_for("humanify.challenge", return_url=request.full_path)
|
186
|
+
)
|
187
|
+
|
172
188
|
return self._render_challenge(is_audio=True)
|
173
189
|
|
174
190
|
@self.blueprint.route("/humanify/verify", methods=["POST"])
|
@@ -176,6 +192,9 @@ class Humanify:
|
|
176
192
|
"""
|
177
193
|
Verify route.
|
178
194
|
"""
|
195
|
+
if self.image_dataset is None:
|
196
|
+
abort(404)
|
197
|
+
|
179
198
|
return self._verify_captcha()
|
180
199
|
|
181
200
|
@self.blueprint.route("/humanify/verify_audio", methods=["POST"])
|
@@ -183,6 +202,9 @@ class Humanify:
|
|
183
202
|
"""
|
184
203
|
Verify audio route.
|
185
204
|
"""
|
205
|
+
if self.audio_dataset is None:
|
206
|
+
abort(404)
|
207
|
+
|
186
208
|
return self._verify_audio_captcha()
|
187
209
|
|
188
210
|
@self.blueprint.route("/humanify/access_denied", methods=["GET"])
|
@@ -198,27 +220,168 @@ class Humanify:
|
|
198
220
|
{"Cache-Control": "public, max-age=15552000"},
|
199
221
|
)
|
200
222
|
|
201
|
-
def register_middleware(
|
223
|
+
def register_middleware(
|
224
|
+
self,
|
225
|
+
action: str = "challenge",
|
226
|
+
endpoint_patterns: Union[str, List[str], None] = None,
|
227
|
+
url_patterns: Union[str, List[str], None] = None,
|
228
|
+
exclude_patterns: Union[str, List[str], None] = None,
|
229
|
+
request_filters: Optional[Dict[str, Any]] = None,
|
230
|
+
):
|
202
231
|
"""
|
203
|
-
Register the middleware.
|
232
|
+
Register the middleware with advanced filtering options.
|
233
|
+
|
234
|
+
Args:
|
235
|
+
action: The action to take when a bot is detected ('challenge' or 'deny_access')
|
236
|
+
endpoint_patterns: Endpoint patterns to match (regex or glob patterns)
|
237
|
+
url_patterns: URL patterns to match (regex or glob patterns)
|
238
|
+
exclude_patterns: Patterns to exclude from protection (regex or glob patterns)
|
239
|
+
request_filters: Dict of request attributes and values to filter by
|
204
240
|
"""
|
205
|
-
|
206
241
|
self.app = self.app or current_app
|
207
242
|
|
243
|
+
if isinstance(endpoint_patterns, str):
|
244
|
+
endpoint_patterns = [endpoint_patterns]
|
245
|
+
if isinstance(url_patterns, str):
|
246
|
+
url_patterns = [url_patterns]
|
247
|
+
if isinstance(exclude_patterns, str):
|
248
|
+
exclude_patterns = [exclude_patterns]
|
249
|
+
|
250
|
+
compiled_endpoint_patterns = (
|
251
|
+
self._compile_patterns(endpoint_patterns) if endpoint_patterns else None
|
252
|
+
)
|
253
|
+
compiled_url_patterns = (
|
254
|
+
self._compile_patterns(url_patterns) if url_patterns else None
|
255
|
+
)
|
256
|
+
compiled_exclude_patterns = (
|
257
|
+
self._compile_patterns(exclude_patterns) if exclude_patterns else None
|
258
|
+
)
|
259
|
+
|
208
260
|
@self.app.before_request
|
209
261
|
def before_request():
|
210
262
|
"""
|
211
|
-
Before request hook.
|
263
|
+
Before request hook with advanced filtering.
|
212
264
|
"""
|
213
265
|
if request.endpoint and request.endpoint.startswith("humanify."):
|
214
266
|
return
|
215
267
|
|
216
|
-
|
268
|
+
current_endpoint = request.endpoint or ""
|
269
|
+
current_path = request.path
|
270
|
+
|
271
|
+
if compiled_exclude_patterns and self._matches_any_pattern(
|
272
|
+
current_endpoint, current_path, compiled_exclude_patterns
|
273
|
+
):
|
274
|
+
return
|
275
|
+
|
276
|
+
patterns_specified = (
|
277
|
+
compiled_endpoint_patterns is not None
|
278
|
+
or compiled_url_patterns is not None
|
279
|
+
)
|
280
|
+
|
281
|
+
matches_endpoint = not patterns_specified or (
|
282
|
+
compiled_endpoint_patterns
|
283
|
+
and self._matches_any_pattern(
|
284
|
+
current_endpoint, None, compiled_endpoint_patterns
|
285
|
+
)
|
286
|
+
)
|
287
|
+
|
288
|
+
matches_url = not patterns_specified or (
|
289
|
+
compiled_url_patterns
|
290
|
+
and self._matches_any_pattern(None, current_path, compiled_url_patterns)
|
291
|
+
)
|
292
|
+
|
293
|
+
matches_request_filters = (
|
294
|
+
not request_filters or self._matches_request_filters(request_filters)
|
295
|
+
)
|
296
|
+
|
297
|
+
if (
|
298
|
+
(matches_endpoint or matches_url)
|
299
|
+
and matches_request_filters
|
300
|
+
and self.is_bot
|
301
|
+
):
|
217
302
|
if action == "challenge":
|
218
303
|
return self.challenge()
|
219
304
|
if action == "deny_access":
|
220
305
|
return self.deny_access()
|
221
306
|
|
307
|
+
def _compile_patterns(self, patterns):
|
308
|
+
"""
|
309
|
+
Compile a list of patterns into regex patterns.
|
310
|
+
Handles glob patterns like * and ? by converting them to regex.
|
311
|
+
"""
|
312
|
+
compiled = []
|
313
|
+
for pattern in patterns:
|
314
|
+
if pattern is None:
|
315
|
+
continue
|
316
|
+
|
317
|
+
if "*" in pattern or "?" in pattern:
|
318
|
+
regex_pattern = fnmatch.translate(pattern)
|
319
|
+
compiled.append(re.compile(regex_pattern))
|
320
|
+
else:
|
321
|
+
try:
|
322
|
+
compiled.append(re.compile(pattern))
|
323
|
+
except re.error:
|
324
|
+
compiled.append(re.compile(re.escape(pattern)))
|
325
|
+
|
326
|
+
return compiled
|
327
|
+
|
328
|
+
def _matches_any_pattern(
|
329
|
+
self,
|
330
|
+
endpoint: Optional[str],
|
331
|
+
path: Optional[str],
|
332
|
+
compiled_patterns: List[Pattern],
|
333
|
+
):
|
334
|
+
"""
|
335
|
+
Check if the current endpoint or path matches any of the compiled patterns.
|
336
|
+
"""
|
337
|
+
for pattern in compiled_patterns:
|
338
|
+
if endpoint is not None and pattern.search(endpoint):
|
339
|
+
return True
|
340
|
+
if path is not None and pattern.search(path):
|
341
|
+
return True
|
342
|
+
return False
|
343
|
+
|
344
|
+
def _matches_request_filters(self, request_filters: Dict[str, Any]) -> bool:
|
345
|
+
"""
|
346
|
+
Check if the current request matches all the specified filters.
|
347
|
+
Filters can target any attribute of the request object or its nested properties.
|
348
|
+
"""
|
349
|
+
for key, value in request_filters.items():
|
350
|
+
parts = key.split(".")
|
351
|
+
obj = request
|
352
|
+
|
353
|
+
for part in parts[:-1]:
|
354
|
+
if hasattr(obj, part):
|
355
|
+
obj = getattr(obj, part)
|
356
|
+
elif isinstance(obj, dict) and part in obj:
|
357
|
+
obj = obj[part]
|
358
|
+
else:
|
359
|
+
return False
|
360
|
+
|
361
|
+
final_attr = parts[-1]
|
362
|
+
|
363
|
+
if hasattr(obj, final_attr):
|
364
|
+
attr_value = getattr(obj, final_attr)
|
365
|
+
elif isinstance(obj, dict) and final_attr in obj:
|
366
|
+
attr_value = obj[final_attr]
|
367
|
+
else:
|
368
|
+
return False
|
369
|
+
|
370
|
+
if isinstance(value, str) and value.startswith("regex:"):
|
371
|
+
regex_pattern = value[6:]
|
372
|
+
try:
|
373
|
+
if not re.search(regex_pattern, str(attr_value)):
|
374
|
+
return False
|
375
|
+
except (re.error, TypeError):
|
376
|
+
return False
|
377
|
+
elif isinstance(value, list):
|
378
|
+
if attr_value not in value:
|
379
|
+
return False
|
380
|
+
elif attr_value != value:
|
381
|
+
return False
|
382
|
+
|
383
|
+
return True
|
384
|
+
|
222
385
|
@property
|
223
386
|
def client_ip(self) -> Optional[str]:
|
224
387
|
"""Get the client IP address."""
|
@@ -310,7 +473,7 @@ class Humanify:
|
|
310
473
|
num_correct=captcha_config["num_correct"],
|
311
474
|
num_images=captcha_config["num_images"],
|
312
475
|
preview_image=use_preview_image,
|
313
|
-
dataset_name=self.
|
476
|
+
dataset_name=self.image_dataset,
|
314
477
|
)
|
315
478
|
|
316
479
|
if not images_bytes:
|
@@ -319,16 +482,15 @@ class Humanify:
|
|
319
482
|
processed_images = []
|
320
483
|
for i, img_bytes in enumerate(images_bytes):
|
321
484
|
try:
|
322
|
-
|
323
|
-
captcha_config["hardness_range"][0],
|
324
|
-
captcha_config["hardness_range"][1],
|
325
|
-
)
|
326
|
-
distorted = manipulate_image_bytes(
|
485
|
+
distorted_img_bytes = manipulate_image_bytes(
|
327
486
|
img_bytes,
|
328
487
|
is_small=not (i == 0 and use_preview_image),
|
329
|
-
hardness=
|
488
|
+
hardness=random.randint(
|
489
|
+
captcha_config["hardness_range"][0],
|
490
|
+
captcha_config["hardness_range"][1],
|
491
|
+
),
|
330
492
|
)
|
331
|
-
processed_images.append(image_bytes_to_data_url(
|
493
|
+
processed_images.append(image_bytes_to_data_url(distorted_img_bytes))
|
332
494
|
except Exception as e:
|
333
495
|
current_app.logger.error(f"Error processing image: {e}")
|
334
496
|
processed_images.append(
|
@@ -359,7 +521,7 @@ class Humanify:
|
|
359
521
|
captcha_data=captcha_data,
|
360
522
|
return_url=return_url or "/",
|
361
523
|
error=error,
|
362
|
-
audio_challenge_available=
|
524
|
+
audio_challenge_available=self.audio_dataset is not None,
|
363
525
|
),
|
364
526
|
mimetype="text/html",
|
365
527
|
)
|
@@ -400,7 +562,7 @@ class Humanify:
|
|
400
562
|
captcha_data=captcha_data,
|
401
563
|
return_url=return_url or "/",
|
402
564
|
error=error,
|
403
|
-
image_challenge_available=
|
565
|
+
image_challenge_available=self.image_dataset is not None,
|
404
566
|
),
|
405
567
|
mimetype="text/html",
|
406
568
|
)
|
flask_humanify/memory_server.py
CHANGED
@@ -204,7 +204,9 @@ class MemoryServer:
|
|
204
204
|
return False
|
205
205
|
|
206
206
|
def load_captcha_datasets(
|
207
|
-
self,
|
207
|
+
self,
|
208
|
+
image_dataset: Optional[str] = None,
|
209
|
+
audio_dataset: Optional[str] = None,
|
208
210
|
) -> bool:
|
209
211
|
"""Load captcha datasets into memory."""
|
210
212
|
try:
|
@@ -819,8 +821,8 @@ class MemoryClient:
|
|
819
821
|
def ensure_server_running(
|
820
822
|
port: int = 9876,
|
821
823
|
data_path: Optional[str] = None,
|
822
|
-
image_dataset: str =
|
823
|
-
audio_dataset: str =
|
824
|
+
image_dataset: Optional[str] = None,
|
825
|
+
audio_dataset: Optional[str] = None,
|
824
826
|
) -> None:
|
825
827
|
"""Ensure that the memory server is running."""
|
826
828
|
if data_path is None:
|
@@ -198,7 +198,9 @@
|
|
198
198
|
href="{{ url_for('humanify.challenge', return_url=return_url) }}"
|
199
199
|
>
|
200
200
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
201
|
-
<path
|
201
|
+
<path
|
202
|
+
d="M96 416q-14 0-23-9t-9-23V128q0-14 9-23t23-9h320q14 0 23 9t9 23v256q0 14-9 23t-23 9zm88-176q20 0 34-14t14-34-14-34-34-14-34 14-14 34 14 34 34 14m216 128v-64l-64-64-96 96-56-57-88 89z"
|
203
|
+
/>
|
202
204
|
</svg>
|
203
205
|
Image challenge
|
204
206
|
</a>
|
@@ -0,0 +1,66 @@
|
|
1
|
+
<!doctype html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8" />
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6
|
+
<title>EXCEPTION_TITLE</title>
|
7
|
+
<style>
|
8
|
+
body {
|
9
|
+
font-family: system-ui, sans-serif;
|
10
|
+
background: #f2f2f2;
|
11
|
+
color: #181818;
|
12
|
+
margin: 0;
|
13
|
+
line-height: 1.5;
|
14
|
+
text-align: center;
|
15
|
+
display: grid;
|
16
|
+
place-items: center;
|
17
|
+
height: 100vh;
|
18
|
+
padding: 0 20px;
|
19
|
+
}
|
20
|
+
|
21
|
+
@media (prefers-color-scheme: dark) {
|
22
|
+
body {
|
23
|
+
background: #121212;
|
24
|
+
color: #f2f2f2;
|
25
|
+
}
|
26
|
+
|
27
|
+
.btn {
|
28
|
+
background: #f2f2f2;
|
29
|
+
color: #121212;
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
.content {
|
34
|
+
max-width: 600px;
|
35
|
+
}
|
36
|
+
|
37
|
+
h1 {
|
38
|
+
font-size: 64px;
|
39
|
+
margin: 15px 0;
|
40
|
+
}
|
41
|
+
|
42
|
+
p {
|
43
|
+
font-size: 20px;
|
44
|
+
margin: 15px 0;
|
45
|
+
opacity: 0.8;
|
46
|
+
}
|
47
|
+
|
48
|
+
.btn {
|
49
|
+
display: inline-block;
|
50
|
+
padding: 12px 24px;
|
51
|
+
background: #181818;
|
52
|
+
color: #f2f2f2;
|
53
|
+
border-radius: 6px;
|
54
|
+
text-decoration: none;
|
55
|
+
margin-top: 20px;
|
56
|
+
}
|
57
|
+
</style>
|
58
|
+
</head>
|
59
|
+
<body>
|
60
|
+
<div class="content">
|
61
|
+
<h1>EXCEPTION_CODE</h1>
|
62
|
+
<p>EXCEPTION_MESSAGE</p>
|
63
|
+
<a href="/" class="btn">Back to home</a>
|
64
|
+
</div>
|
65
|
+
</body>
|
66
|
+
</html>
|
flask_humanify/utils.py
CHANGED
@@ -251,6 +251,8 @@ def manipulate_image_bytes(
|
|
251
251
|
"""Manipulates an image represented by bytes to create a distorted version."""
|
252
252
|
# pylint: disable=no-member
|
253
253
|
|
254
|
+
hardness = min(max(1, hardness), 4)
|
255
|
+
|
254
256
|
img = cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_COLOR)
|
255
257
|
if img is None:
|
256
258
|
logger.error("Image data could not be decoded by OpenCV")
|
@@ -259,25 +261,85 @@ def manipulate_image_bytes(
|
|
259
261
|
size = 100 if is_small else 200
|
260
262
|
img = cv2.resize(img, (size, size), interpolation=cv2.INTER_LINEAR)
|
261
263
|
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
264
|
+
mask_pattern = np.zeros((size, size, 3), dtype=np.uint8)
|
265
|
+
|
266
|
+
grid_size = max(8, 16 - hardness * 2)
|
267
|
+
for i in range(0, size, grid_size):
|
268
|
+
thickness = 1
|
269
|
+
cv2.line(mask_pattern, (i, 0), (i, size), (2, 2, 2), thickness)
|
270
|
+
cv2.line(mask_pattern, (0, i), (size, i), (2, 2, 2), thickness)
|
266
271
|
|
267
|
-
|
268
|
-
|
272
|
+
mask_opacity = min(0.06 + hardness * 0.03, 0.18)
|
273
|
+
img = cv2.addWeighted(img, 1 - mask_opacity, mask_pattern, mask_opacity, 0)
|
269
274
|
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
275
|
+
noise_max = max(1, 1 + hardness // 2)
|
276
|
+
noise_pattern = np.random.randint(
|
277
|
+
0, noise_max, size=(size, size, 3), dtype=np.uint8
|
278
|
+
)
|
279
|
+
img = cv2.add(img, noise_pattern)
|
280
|
+
|
281
|
+
num_dots = np.random.randint(5 + 5 * hardness, 10 + 10 * hardness + 1)
|
282
|
+
dot_coords = np.random.randint(0, [size, size], size=(num_dots, 2))
|
283
|
+
|
284
|
+
dot_intensity = 0.05 + hardness * 0.05
|
285
|
+
rand_max = max(1, 10 * hardness)
|
286
|
+
colors = np.random.randint(0, rand_max, size=(num_dots, 3)) + np.array(
|
287
|
+
[img[coord[1], coord[0]] for coord in dot_coords]
|
288
|
+
) * (1 - dot_intensity)
|
289
|
+
colors = np.clip(colors, 0, 255).astype(np.uint8)
|
290
|
+
|
291
|
+
for (x, y), color in zip(dot_coords, colors):
|
292
|
+
img[y, x] = color
|
293
|
+
|
294
|
+
num_lines = np.random.randint(2 * hardness, 5 * hardness + 1)
|
295
|
+
start_coords = np.random.randint(0, [size, size], size=(num_lines, 2))
|
296
|
+
end_coords = np.random.randint(0, [size, size], size=(num_lines, 2))
|
297
|
+
|
298
|
+
line_intensity = max(4, 3 * hardness)
|
299
|
+
colors = np.random.randint(3, line_intensity, size=(num_lines, 3))
|
300
|
+
|
301
|
+
for (start, end), color in zip(zip(start_coords, end_coords), colors):
|
302
|
+
cv2.line(img, tuple(start), tuple(end), color.tolist(), 1)
|
303
|
+
|
304
|
+
for _ in range(hardness):
|
305
|
+
x = np.random.randint(0, size)
|
306
|
+
y = np.random.randint(0, size)
|
307
|
+
length = np.random.randint(5 + 3 * hardness, 10 + 5 * hardness + 1)
|
308
|
+
angle = np.random.randint(0, 360)
|
309
|
+
text_max = max(3, 2 + hardness)
|
310
|
+
text_color = np.random.randint(1, text_max, 3).tolist()
|
311
|
+
|
312
|
+
end_x = int(x + length * np.cos(np.radians(angle)))
|
313
|
+
end_y = int(y + length * np.sin(np.radians(angle)))
|
314
|
+
cv2.line(img, (x, y), (end_x, end_y), text_color, 1)
|
315
|
+
|
316
|
+
for _ in range(1 + hardness // 2):
|
317
|
+
patch_size = np.random.randint(4 + hardness, 6 + 3 * hardness + 1)
|
318
|
+
x = np.random.randint(0, size - patch_size)
|
319
|
+
y = np.random.randint(0, size - patch_size)
|
320
|
+
|
321
|
+
patch = np.zeros((patch_size, patch_size, 3), dtype=np.uint8)
|
322
|
+
for i in range(0, patch_size, 2):
|
323
|
+
for j in range(0, patch_size, 2):
|
324
|
+
if (i + j) % 4 == 0:
|
325
|
+
patch_color_max = max(2, 1 + hardness)
|
326
|
+
patch[i : i + 2, j : j + 2] = [
|
327
|
+
np.random.randint(1, patch_color_max)
|
328
|
+
] * 3
|
329
|
+
|
330
|
+
patch_opacity = 0.03 + 0.02 * hardness
|
331
|
+
roi = img[y : y + patch_size, x : x + patch_size]
|
332
|
+
img[y : y + patch_size, x : x + patch_size] = cv2.addWeighted(
|
333
|
+
roi, 1 - patch_opacity, patch, patch_opacity, 0
|
334
|
+
)
|
274
335
|
|
275
|
-
|
276
|
-
|
336
|
+
max_shift = hardness
|
337
|
+
x_shifts = np.random.randint(-max_shift, max_shift + 1, size=(size, size))
|
338
|
+
y_shifts = np.random.randint(-max_shift, max_shift + 1, size=(size, size))
|
277
339
|
|
278
|
-
|
279
|
-
|
280
|
-
|
340
|
+
saturation_factor = 1 + hardness * 0.05
|
341
|
+
value_factor = 1 - hardness * 0.03
|
342
|
+
blur_factor = hardness * 0.05
|
281
343
|
|
282
344
|
map_x, map_y = np.meshgrid(np.arange(size), np.arange(size))
|
283
345
|
map_x = (map_x + x_shifts) % size
|
@@ -289,14 +351,18 @@ def manipulate_image_bytes(
|
|
289
351
|
shifted_img_hsv = cv2.cvtColor(shifted_img, cv2.COLOR_BGR2HSV)
|
290
352
|
|
291
353
|
shifted_img_hsv[..., 1] = np.clip(
|
292
|
-
shifted_img_hsv[..., 1] *
|
293
|
-
)
|
294
|
-
shifted_img_hsv[..., 2] = np.clip(
|
295
|
-
shifted_img_hsv[..., 2] * (1 - hardness * 0.09), 0, 255
|
354
|
+
shifted_img_hsv[..., 1] * saturation_factor, 0, 255
|
296
355
|
)
|
356
|
+
shifted_img_hsv[..., 2] = np.clip(shifted_img_hsv[..., 2] * value_factor, 0, 255)
|
297
357
|
|
298
358
|
shifted_img = cv2.cvtColor(shifted_img_hsv, cv2.COLOR_HSV2BGR)
|
299
|
-
shifted_img = cv2.GaussianBlur(shifted_img, (5, 5),
|
359
|
+
shifted_img = cv2.GaussianBlur(shifted_img, (5, 5), blur_factor)
|
360
|
+
|
361
|
+
noise_high = max(1, 1 + hardness // 3)
|
362
|
+
high_freq_noise = np.random.randint(
|
363
|
+
0, noise_high, size=shifted_img.shape, dtype=np.uint8
|
364
|
+
)
|
365
|
+
shifted_img = cv2.add(shifted_img, high_freq_noise)
|
300
366
|
|
301
367
|
_, output_bytes = cv2.imencode(".png", shifted_img)
|
302
368
|
if not _:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: flask-Humanify
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.2
|
4
4
|
Summary: Protect against bots and DDoS attacks
|
5
5
|
Author-email: TN3W <tn3w@protonmail.com>
|
6
6
|
License-Expression: Apache-2.0
|
@@ -45,7 +45,7 @@ from flask import Flask
|
|
45
45
|
from flask_Humanify import Humanify
|
46
46
|
|
47
47
|
app = Flask(__name__)
|
48
|
-
humanify = Humanify(app, challenge_type="one_click",
|
48
|
+
humanify = Humanify(app, challenge_type="one_click", image_dataset="ai_dogs")
|
49
49
|
|
50
50
|
# Register the middleware to deny access to bots
|
51
51
|
humanify.register_middleware(action="challenge")
|
@@ -61,6 +61,39 @@ if __name__ == "__main__":
|
|
61
61
|
app.run()
|
62
62
|
```
|
63
63
|
|
64
|
+
### Advanced Protection Rules
|
65
|
+
|
66
|
+
You can customize bot protection with advanced filtering rules:
|
67
|
+
|
68
|
+
```python
|
69
|
+
# Protect specific endpoints with regex patterns
|
70
|
+
humanify.register_middleware(
|
71
|
+
action="challenge",
|
72
|
+
endpoint_patterns=["api.*", "admin.*"] # Protect all API and admin endpoints
|
73
|
+
)
|
74
|
+
|
75
|
+
# Protect specific URL paths
|
76
|
+
humanify.register_middleware(
|
77
|
+
action="deny_access",
|
78
|
+
url_patterns=["/sensitive/*", "/admin/*"] # Deny bot access to sensitive areas
|
79
|
+
)
|
80
|
+
|
81
|
+
# Exclude certain patterns from protection
|
82
|
+
humanify.register_middleware(
|
83
|
+
endpoint_patterns=["api.*"],
|
84
|
+
exclude_patterns=["api.public.*"] # Don't protect public API endpoints
|
85
|
+
)
|
86
|
+
|
87
|
+
# Filter by request parameters
|
88
|
+
humanify.register_middleware(
|
89
|
+
request_filters={
|
90
|
+
"method": ["POST", "PUT", "DELETE"], # Only protect write operations
|
91
|
+
"args.admin": "true", # Only when admin=true query parameter exists
|
92
|
+
"headers.content-type": "regex:application/json.*" # Match content type with regex
|
93
|
+
}
|
94
|
+
)
|
95
|
+
```
|
96
|
+
|
64
97
|
Not using the middleware:
|
65
98
|
|
66
99
|
```python
|
@@ -96,3 +129,80 @@ Add the extension to your Flask app:
|
|
96
129
|
app = Flask(__name__)
|
97
130
|
humanify = Humanify(app)
|
98
131
|
```
|
132
|
+
|
133
|
+
## Additional Features
|
134
|
+
|
135
|
+
### Rate Limiting
|
136
|
+
|
137
|
+
Flask-Humanify includes a rate limiting feature to protect your application from excessive requests:
|
138
|
+
|
139
|
+
```python
|
140
|
+
from flask import Flask
|
141
|
+
from flask_humanify import Humanify, RateLimiter
|
142
|
+
|
143
|
+
app = Flask(__name__)
|
144
|
+
humanify = Humanify(app)
|
145
|
+
# Default: 10 requests per 10 seconds
|
146
|
+
rate_limiter = RateLimiter(app)
|
147
|
+
|
148
|
+
# Or customize rate limits
|
149
|
+
rate_limiter = RateLimiter(app, max_requests=20, time_window=30)
|
150
|
+
```
|
151
|
+
|
152
|
+
The rate limiter will automatically:
|
153
|
+
|
154
|
+
- Track requests by IP address
|
155
|
+
- Hash IPs for privacy
|
156
|
+
- Redirect to a rate-limited page when limits are exceeded
|
157
|
+
- Ignore rate limits for special pages like the rate-limited and access-denied pages
|
158
|
+
|
159
|
+
### Error Handling
|
160
|
+
|
161
|
+
Flask-Humanify provides a clean error handling system:
|
162
|
+
|
163
|
+
```python
|
164
|
+
from flask import Flask
|
165
|
+
from flask_humanify import Humanify, ErrorHandler
|
166
|
+
|
167
|
+
app = Flask(__name__)
|
168
|
+
humanify = Humanify(app)
|
169
|
+
# Handle all standard HTTP errors
|
170
|
+
error_handler = ErrorHandler(app)
|
171
|
+
|
172
|
+
# Or handle only specific error codes
|
173
|
+
error_handler = ErrorHandler(app, errors=[404, 429, 500])
|
174
|
+
```
|
175
|
+
|
176
|
+
The error handler:
|
177
|
+
|
178
|
+
- Renders user-friendly error pages
|
179
|
+
- Uses the custom exception.html template
|
180
|
+
- Provides appropriate error messages and descriptions
|
181
|
+
- Includes HTTP status codes and titles
|
182
|
+
|
183
|
+
### Complete Example
|
184
|
+
|
185
|
+
Here's a complete example combining all features:
|
186
|
+
|
187
|
+
```python
|
188
|
+
from flask import Flask
|
189
|
+
from flask_humanify import Humanify, RateLimiter, ErrorHandler
|
190
|
+
|
191
|
+
app = Flask(__name__)
|
192
|
+
# Setup core protection
|
193
|
+
humanify = Humanify(app, challenge_type="one_click", image_dataset="animals")
|
194
|
+
humanify.register_middleware(action="challenge")
|
195
|
+
|
196
|
+
# Add rate limiting
|
197
|
+
rate_limiter = RateLimiter(app, max_requests=15, time_window=60)
|
198
|
+
|
199
|
+
# Add error handling
|
200
|
+
error_handler = ErrorHandler(app)
|
201
|
+
|
202
|
+
@app.route("/")
|
203
|
+
def index():
|
204
|
+
return "Hello, Human!"
|
205
|
+
|
206
|
+
if __name__ == "__main__":
|
207
|
+
app.run(debug=True)
|
208
|
+
```
|
@@ -0,0 +1,22 @@
|
|
1
|
+
flask_humanify/__init__.py,sha256=rvTDStRFUl6sFNVgm7NKEA3fkH2gP7osDl3IuUy-SNU,334
|
2
|
+
flask_humanify/humanify.py,sha256=C-HhqWfvENMyOjU2LJ8aUW0npLm2LavuiTcvAf_LTCM,22179
|
3
|
+
flask_humanify/memory_server.py,sha256=NlGYrHuH6S7Ei7I5gc78aYvZvEq6bUz-lgLhuiCXTEM,30339
|
4
|
+
flask_humanify/utils.py,sha256=mG8oZL6U52yDWOlE3umqf5NNuwKB-LnhMjr_xdpQcAo,18581
|
5
|
+
flask_humanify/datasets/ai_dogs.pkl,sha256=ODznMPafM3d4ZNM2MPqEdX21nh53CZhsLornWc1IZPc,24645910
|
6
|
+
flask_humanify/datasets/animals.pkl,sha256=zhOY-J3h18ZMBE5D9q_ujQc7ZW1WRNcuYfM4J2P1huM,10323365
|
7
|
+
flask_humanify/datasets/characters.pkl,sha256=tEbGnbi5S9Y2Us9arVghdV43luqOAiGTQMLiU2bka6U,12756114
|
8
|
+
flask_humanify/datasets/ipset.json,sha256=YNPqwI109lYkfvZeOPsoDH_dKJxOCs0G2nvx_s2mvqU,30601191
|
9
|
+
flask_humanify/datasets/keys.pkl,sha256=sYzgJ5SP5SluWwyatFnWc-hj8rzwaOEOMVLJOw4VZik,32763786
|
10
|
+
flask_humanify/features/error_handler.py,sha256=bLOyaS6jtkLyxf7RviXD5fVrJ7fBAd9I8Zt2Q1m54n8,6123
|
11
|
+
flask_humanify/features/rate_limiter.py,sha256=QMIwfEllTDup6jxckEbE83PlJZeLw-0SvyxPqzXJYzU,2204
|
12
|
+
flask_humanify/templates/access_denied.html,sha256=p8ea_9gvv83aYFHaVKKedmQr6M8Z7NyHJK_OT3jdTOs,3169
|
13
|
+
flask_humanify/templates/audio_challenge.html,sha256=Tvs7mTah59n4I-w5N8ABcW8AMPN8zAAcFYZUXBm6NH0,6442
|
14
|
+
flask_humanify/templates/exception.html,sha256=OcAuyoBQ5wLMQvFAVi2LS7gcDsLmCpkuHCBQoh_eGTg,1711
|
15
|
+
flask_humanify/templates/grid_challenge.html,sha256=cToubxQvI7bJ-BKS9wceIvsAmp6h2EGYLhgyRr6pFxU,7179
|
16
|
+
flask_humanify/templates/one_click_challenge.html,sha256=21WOkYWrW48GXpVPBMPrEJ7Iq5JawkrXKUyUUfxWmTc,5996
|
17
|
+
flask_humanify/templates/rate_limited.html,sha256=Bv98MoetuSJGpWkDaQfhl7JwcWJiGaG2gwqxvSphaTM,3114
|
18
|
+
flask_humanify-0.2.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
19
|
+
flask_humanify-0.2.2.dist-info/METADATA,sha256=5Mrphy_GSkMiJVc7ZEdkiO2_-eVILQ1plCWlxI7bO0E,6092
|
20
|
+
flask_humanify-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
21
|
+
flask_humanify-0.2.2.dist-info/top_level.txt,sha256=9-c6uhxwCpPE3BJYge1Y9Z_bYmWitI0fY5RgqMiFWr0,15
|
22
|
+
flask_humanify-0.2.2.dist-info/RECORD,,
|
flask_humanify/secret_key.bin
DELETED
Binary file
|
@@ -1,20 +0,0 @@
|
|
1
|
-
flask_humanify/__init__.py,sha256=m00B4jvNt8wDtNtodyHv1GKDxW3CdblFpVE1Bea0-7g,269
|
2
|
-
flask_humanify/humanify.py,sha256=YZFndaMwC24VtddrH5_SX7Lx995QKZGehv49kDK181w,16252
|
3
|
-
flask_humanify/memory_server.py,sha256=pmW1MjFYaLVizjzL_9YuDTc2T-3XkkdKLK9QPfmQ-4U,30310
|
4
|
-
flask_humanify/secret_key.bin,sha256=5e9HXg-A5HtFRVYv4zA1rkSQAP03RcnwdCpnA_98_8Q,32
|
5
|
-
flask_humanify/utils.py,sha256=jWAQDZmH356VBRnfSgPloQbqkNj6bbRjIOXNRUAPUyk,16016
|
6
|
-
flask_humanify/datasets/ai_dogs.pkl,sha256=ODznMPafM3d4ZNM2MPqEdX21nh53CZhsLornWc1IZPc,24645910
|
7
|
-
flask_humanify/datasets/animals.pkl,sha256=zhOY-J3h18ZMBE5D9q_ujQc7ZW1WRNcuYfM4J2P1huM,10323365
|
8
|
-
flask_humanify/datasets/characters.pkl,sha256=tEbGnbi5S9Y2Us9arVghdV43luqOAiGTQMLiU2bka6U,12756114
|
9
|
-
flask_humanify/datasets/ipset.json,sha256=YNPqwI109lYkfvZeOPsoDH_dKJxOCs0G2nvx_s2mvqU,30601191
|
10
|
-
flask_humanify/features/rate_limiter.py,sha256=QMIwfEllTDup6jxckEbE83PlJZeLw-0SvyxPqzXJYzU,2204
|
11
|
-
flask_humanify/templates/access_denied.html,sha256=p8ea_9gvv83aYFHaVKKedmQr6M8Z7NyHJK_OT3jdTOs,3169
|
12
|
-
flask_humanify/templates/audio_challenge.html,sha256=1QR-EQs3CJleT64jWxAreJG-RIUHuQox-jXswOCemXg,6397
|
13
|
-
flask_humanify/templates/grid_challenge.html,sha256=cToubxQvI7bJ-BKS9wceIvsAmp6h2EGYLhgyRr6pFxU,7179
|
14
|
-
flask_humanify/templates/one_click_challenge.html,sha256=21WOkYWrW48GXpVPBMPrEJ7Iq5JawkrXKUyUUfxWmTc,5996
|
15
|
-
flask_humanify/templates/rate_limited.html,sha256=Bv98MoetuSJGpWkDaQfhl7JwcWJiGaG2gwqxvSphaTM,3114
|
16
|
-
flask_humanify-0.2.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
17
|
-
flask_humanify-0.2.0.dist-info/METADATA,sha256=ah_0kX60dPqLsJF6LreSX85_YgbdxjTL7_gGNHRupPI,3233
|
18
|
-
flask_humanify-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
19
|
-
flask_humanify-0.2.0.dist-info/top_level.txt,sha256=9-c6uhxwCpPE3BJYge1Y9Z_bYmWitI0fY5RgqMiFWr0,15
|
20
|
-
flask_humanify-0.2.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|