txhttputil 1.4.9__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.
- txhttputil/__init__.py +9 -0
- txhttputil/downloader/HttpFileDownloader.py +102 -0
- txhttputil/downloader/HttpResourceProxy.py +151 -0
- txhttputil/downloader/__init__.py +8 -0
- txhttputil/login_page/LoginElement.py +45 -0
- txhttputil/login_page/LoginTemplate.xml +63 -0
- txhttputil/login_page/__init__.py +8 -0
- txhttputil/site/AuthCredentials.py +38 -0
- txhttputil/site/AuthResource.py +58 -0
- txhttputil/site/AuthSessionWrapper.py +224 -0
- txhttputil/site/AuthUserDetails.py +51 -0
- txhttputil/site/BasicResource.py +155 -0
- txhttputil/site/BasicResourceTest.py +112 -0
- txhttputil/site/FileUnderlayResource.py +188 -0
- txhttputil/site/FileUnderlayResourceTest.py +108 -0
- txhttputil/site/FileUploadRequest.py +250 -0
- txhttputil/site/RedirectToHttpsResource.py +43 -0
- txhttputil/site/RedirectionRule.py +24 -0
- txhttputil/site/ResourceCreator.py +12 -0
- txhttputil/site/RootResource.py +29 -0
- txhttputil/site/RootSiteTest.py +8 -0
- txhttputil/site/SiteUtil.py +157 -0
- txhttputil/site/StaticFileResource.py +163 -0
- txhttputil/site/__init__.py +8 -0
- txhttputil/util/DeferUtil.py +54 -0
- txhttputil/util/ModuleUtil.py +23 -0
- txhttputil/util/PemUtil.py +750 -0
- txhttputil/util/SslUtil.py +396 -0
- txhttputil/util/__init__.py +8 -0
- txhttputil-1.4.9.dist-info/LICENSE +22 -0
- txhttputil-1.4.9.dist-info/METADATA +82 -0
- txhttputil-1.4.9.dist-info/RECORD +34 -0
- txhttputil-1.4.9.dist-info/WHEEL +5 -0
- txhttputil-1.4.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Optional, Union
|
|
12
|
+
|
|
13
|
+
from twisted.web.resource import NoResource
|
|
14
|
+
from twisted.web.util import Redirect
|
|
15
|
+
from txhttputil.site.BasicResource import BasicResource
|
|
16
|
+
from txhttputil.site.RedirectionRule import RedirectionRule
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
import mimetypes
|
|
21
|
+
|
|
22
|
+
mimetypes.init()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_extensions_for_type(general_type):
|
|
26
|
+
for ext in mimetypes.types_map:
|
|
27
|
+
if mimetypes.types_map[ext].split("/")[0] == general_type:
|
|
28
|
+
yield ext
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
IMAGE_EXTENSIONS = list(get_extensions_for_type("image"))
|
|
32
|
+
FONT_EXTENSIONS = list(get_extensions_for_type("font"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SinglePageAppConfig:
|
|
36
|
+
def __init__(self, **kwargs):
|
|
37
|
+
self.indexHtmlPath = None
|
|
38
|
+
|
|
39
|
+
for kw, arg in kwargs.items():
|
|
40
|
+
if not hasattr(self, kw):
|
|
41
|
+
raise KeyError(
|
|
42
|
+
"%s is not an argument for %s" % (kw, self.__class__)
|
|
43
|
+
)
|
|
44
|
+
setattr(self, kw, arg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FileUnderlayResource(BasicResource):
|
|
48
|
+
"""
|
|
49
|
+
This class resolves URLs into either a static file or a C{BasicResource}
|
|
50
|
+
|
|
51
|
+
This is a multi level search :
|
|
52
|
+
1) getChild, looking for resource in the resource tree
|
|
53
|
+
2) The staticFileUnderlay is searched.
|
|
54
|
+
3) Request fails with NoResource()
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
acceptedExtensions = [".js", ".css", ".html", ".xml"]
|
|
59
|
+
acceptedExtensions += FONT_EXTENSIONS
|
|
60
|
+
acceptedExtensions += IMAGE_EXTENSIONS
|
|
61
|
+
|
|
62
|
+
# TODO Implement accepted extensions
|
|
63
|
+
|
|
64
|
+
def __init__(self, allowedIpsList=None):
|
|
65
|
+
BasicResource.__init__(self, allowedIpsList)
|
|
66
|
+
|
|
67
|
+
self._fileSystemRoots = []
|
|
68
|
+
self._singlePageAppConfig = None
|
|
69
|
+
self._redirections = {}
|
|
70
|
+
|
|
71
|
+
def addRedirectionRule(self, redirection: Optional[RedirectionRule] = None):
|
|
72
|
+
if redirection is not None:
|
|
73
|
+
self._redirections[redirection.origin] = redirection.destination
|
|
74
|
+
|
|
75
|
+
def getFileResource(self, postPath):
|
|
76
|
+
from txhttputil.site.StaticFileResource import StaticFileResource
|
|
77
|
+
|
|
78
|
+
resourcePath = os.path.join(*postPath).decode() if postPath else None
|
|
79
|
+
filePath = self.getRealFilePath(resourcePath) if resourcePath else None
|
|
80
|
+
|
|
81
|
+
if filePath:
|
|
82
|
+
return self._gzipIfRequired(StaticFileResource(filePath))
|
|
83
|
+
|
|
84
|
+
if not resourcePath or "." not in os.path.basename(resourcePath):
|
|
85
|
+
singlePageResource = self._getSinglePageAppResource()
|
|
86
|
+
if singlePageResource:
|
|
87
|
+
return singlePageResource
|
|
88
|
+
|
|
89
|
+
return NoResource()
|
|
90
|
+
|
|
91
|
+
def addFileSystemRoot(self, fileSystemRoot: str):
|
|
92
|
+
if not os.path.isdir(fileSystemRoot):
|
|
93
|
+
raise NotADirectoryError("%s is not a directory" % fileSystemRoot)
|
|
94
|
+
|
|
95
|
+
self._fileSystemRoots.append(fileSystemRoot)
|
|
96
|
+
|
|
97
|
+
def enableSinglePageApplication(self, indexHtmlPath="index.html"):
|
|
98
|
+
self._singlePageAppConfig = SinglePageAppConfig(
|
|
99
|
+
indexHtmlPath=indexHtmlPath
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _resolveFilePathUnderRoot(
|
|
103
|
+
self, rootDir: str, resourcePath: str
|
|
104
|
+
) -> Union[str, None]:
|
|
105
|
+
if os.path.isabs(resourcePath):
|
|
106
|
+
logger.debug(
|
|
107
|
+
"Resource path %s is absolute and not allowed",
|
|
108
|
+
resourcePath,
|
|
109
|
+
)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
rootRealPath = os.path.realpath(rootDir)
|
|
113
|
+
candidatePath = os.path.realpath(os.path.join(rootDir, resourcePath))
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
withinRoot = (
|
|
117
|
+
os.path.commonpath([rootRealPath, candidatePath])
|
|
118
|
+
== rootRealPath
|
|
119
|
+
)
|
|
120
|
+
except ValueError:
|
|
121
|
+
withinRoot = False
|
|
122
|
+
|
|
123
|
+
if not withinRoot:
|
|
124
|
+
logger.debug(
|
|
125
|
+
"Resource path %s escapes root %s",
|
|
126
|
+
resourcePath,
|
|
127
|
+
rootDir,
|
|
128
|
+
)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
return candidatePath
|
|
132
|
+
|
|
133
|
+
def getRealFilePath(self, resourcePath: str) -> Union[str, None]:
|
|
134
|
+
if not resourcePath:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
for rootDir in self._fileSystemRoots[::-1]:
|
|
138
|
+
realFilePath = self._resolveFilePathUnderRoot(rootDir, resourcePath)
|
|
139
|
+
if realFilePath is None:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if os.path.isdir(realFilePath):
|
|
143
|
+
logger.debug(
|
|
144
|
+
"Resource path %s is a directory %s",
|
|
145
|
+
resourcePath,
|
|
146
|
+
realFilePath,
|
|
147
|
+
)
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if os.path.isfile(realFilePath):
|
|
151
|
+
return realFilePath
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def _getSinglePageAppResource(self):
|
|
156
|
+
if not self._singlePageAppConfig:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# Try to serve a single page app
|
|
160
|
+
indexHtmlPath = self._singlePageAppConfig.indexHtmlPath
|
|
161
|
+
assert indexHtmlPath is not None, "indexHtmlPath must be set for SPA"
|
|
162
|
+
filePath = self.getRealFilePath(indexHtmlPath)
|
|
163
|
+
if filePath:
|
|
164
|
+
from txhttputil.site.StaticFileResource import StaticFileResource
|
|
165
|
+
|
|
166
|
+
return self._gzipIfRequired(StaticFileResource(filePath))
|
|
167
|
+
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def render_GET(self, request):
|
|
171
|
+
"""Render Get
|
|
172
|
+
|
|
173
|
+
If we're being redered then that means we've exactly matched the resurce path.
|
|
174
|
+
IE, The path is looking for a directory, not a file.
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
singlePageAppResource = self._getSinglePageAppResource()
|
|
179
|
+
if singlePageAppResource:
|
|
180
|
+
return singlePageAppResource.render_GET(request) # pyright: ignore[reportAttributeAccessIssue]
|
|
181
|
+
|
|
182
|
+
# Else, render no resource
|
|
183
|
+
return NoResource().render(request)
|
|
184
|
+
|
|
185
|
+
def getChild(self, path, request):
|
|
186
|
+
if request.uri in self._redirections.keys():
|
|
187
|
+
return Redirect(self._redirections[request.uri])
|
|
188
|
+
return super().getChild(path, request)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from twisted.internet import reactor
|
|
13
|
+
from twisted.trial import unittest
|
|
14
|
+
from twisted.web import server
|
|
15
|
+
from twisted.web.resource import ErrorPage
|
|
16
|
+
from txhttputil.downloader.HttpFileDownloader import HttpFileDownloader
|
|
17
|
+
from txhttputil.site.FileUnderlayResource import FileUnderlayResource
|
|
18
|
+
|
|
19
|
+
import txhttputil
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s:%(message)s"
|
|
25
|
+
DATE_FORMAT = "%d-%b-%Y %H:%M:%S"
|
|
26
|
+
logging.basicConfig(format=LOG_FORMAT, datefmt=DATE_FORMAT, level=logging.DEBUG)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileUnderlayResourceTest(unittest.TestCase):
|
|
30
|
+
PORT = 7999
|
|
31
|
+
|
|
32
|
+
def setUp(self):
|
|
33
|
+
rootResource = FileUnderlayResource()
|
|
34
|
+
|
|
35
|
+
self.fsRoot = os.path.dirname(txhttputil.__file__)
|
|
36
|
+
rootResource.addFileSystemRoot(self.fsRoot)
|
|
37
|
+
|
|
38
|
+
logger.info("Test file system root = %s", self.fsRoot)
|
|
39
|
+
|
|
40
|
+
rootResource.putChild(
|
|
41
|
+
b"test", ErrorPage(200, "This path worked, /test", "")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.site = reactor.listenTCP(self.PORT, server.Site(rootResource))
|
|
45
|
+
|
|
46
|
+
self.sitePath = "http://%s:%s" % ("127.0.0.1", self.site.port)
|
|
47
|
+
|
|
48
|
+
def tearDown(self):
|
|
49
|
+
d = self.site.stopListening()
|
|
50
|
+
return d
|
|
51
|
+
|
|
52
|
+
def _checkDownloadedResource(
|
|
53
|
+
self, spooledNamedTempFile, fileName=None, testContents=None
|
|
54
|
+
):
|
|
55
|
+
contents = spooledNamedTempFile.read()
|
|
56
|
+
logger.debug(contents)
|
|
57
|
+
|
|
58
|
+
if fileName:
|
|
59
|
+
with open(fileName, "rb") as f:
|
|
60
|
+
self.assertEqual(contents, f.read())
|
|
61
|
+
|
|
62
|
+
if testContents:
|
|
63
|
+
self.assertEqual(contents, testContents)
|
|
64
|
+
|
|
65
|
+
def testDowloadUnderlayRootFile(self):
|
|
66
|
+
testContents = (
|
|
67
|
+
b"\n<html>\n <head><title>200 - This path worked, /test</title><"
|
|
68
|
+
b"/head>\n <body>\n <h1>This path worked, /test</h1>\n "
|
|
69
|
+
b"<p></p>\n </body>\n</html>\n"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
d = HttpFileDownloader(self.sitePath + "/test").run()
|
|
73
|
+
d.addCallback(self._checkDownloadedResource, testContents=testContents)
|
|
74
|
+
return d
|
|
75
|
+
|
|
76
|
+
def testDowloadUnderlayFile2(self):
|
|
77
|
+
fileName = "/login_page/LoginElement.py"
|
|
78
|
+
realPath = self.fsRoot + fileName
|
|
79
|
+
|
|
80
|
+
d = HttpFileDownloader(self.sitePath + fileName).run()
|
|
81
|
+
d.addCallback(self._checkDownloadedResource, fileName=realPath)
|
|
82
|
+
return d
|
|
83
|
+
|
|
84
|
+
def testDirectoryTraversalBlocked(self):
|
|
85
|
+
traversalPath = "/".join([".."] * 10) + "/etc/passwd"
|
|
86
|
+
d = HttpFileDownloader(self.sitePath + "/" + traversalPath).run()
|
|
87
|
+
|
|
88
|
+
def assertNotSuccessful(_result):
|
|
89
|
+
self.fail("Directory traversal request must not succeed")
|
|
90
|
+
|
|
91
|
+
def assertNotFound(failure):
|
|
92
|
+
self.assertIn("404", str(failure.value))
|
|
93
|
+
|
|
94
|
+
d.addCallbacks(
|
|
95
|
+
callback=assertNotSuccessful,
|
|
96
|
+
errback=assertNotFound,
|
|
97
|
+
)
|
|
98
|
+
return d
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
from twisted.trial import runner, reporter
|
|
103
|
+
import sys
|
|
104
|
+
|
|
105
|
+
trialRunner = runner.TrialRunner(reporter.VerboseTextReporter)
|
|
106
|
+
suite = runner.TestLoader().loadClass(FileUnderlayResourceTest)
|
|
107
|
+
result = trialRunner.run(suite)
|
|
108
|
+
sys.exit(not result.wasSuccessful())
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from urllib.parse import parse_qs
|
|
12
|
+
|
|
13
|
+
from pytmpdir.spooled_named_temporary_file import (
|
|
14
|
+
SpooledNamedTemporaryFile,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(name=__name__)
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
import cgi
|
|
21
|
+
from cgi import valid_boundary, maxlen
|
|
22
|
+
|
|
23
|
+
from twisted.web import server
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FileUploadRequest(server.Request):
|
|
27
|
+
"""File Upload Request
|
|
28
|
+
|
|
29
|
+
This request handlers supports the upload of large files.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# max amount of memory to allow any ~single~ request argument [ie: POSTed file]
|
|
34
|
+
# to take up before being flushed into a temporary file.
|
|
35
|
+
# eg: 50 users uploading 4 large files could use up to [and in excess of]
|
|
36
|
+
# 200 times value specified below.
|
|
37
|
+
# note: this value seems to be taken with a grain of salt, memory usage may spike
|
|
38
|
+
# FAR above this value in some cases.
|
|
39
|
+
# eg: set the memory limit to 5 MB, write 2 blocks of 4MB, mem usage will
|
|
40
|
+
# have spiked to 8MB before the data is rolled to disk after the
|
|
41
|
+
# second write completes.
|
|
42
|
+
memorylimit = 1024 * 1024 * 1
|
|
43
|
+
|
|
44
|
+
# enable/disable debug logging
|
|
45
|
+
do_log = False
|
|
46
|
+
|
|
47
|
+
# re-defined only for debug/logging purposes
|
|
48
|
+
def gotLength(self, length):
|
|
49
|
+
if self.do_log:
|
|
50
|
+
logger.debug(
|
|
51
|
+
"%f Headers received, Content-Length: %d", time.time(), length
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return server.Request.gotLength(self, length)
|
|
55
|
+
|
|
56
|
+
# re-definition of twisted.web.server.Request.requestrecieved, the only difference
|
|
57
|
+
# is that self.parse_multipart() is used rather than cgi.parse_multipart()
|
|
58
|
+
def requestReceived(self, command, path, version):
|
|
59
|
+
"""
|
|
60
|
+
Called by channel when all data has been received.
|
|
61
|
+
|
|
62
|
+
This method is not intended for users.
|
|
63
|
+
|
|
64
|
+
@type command: C{bytes}
|
|
65
|
+
@param command: The HTTP verb of this request. This has the case
|
|
66
|
+
supplied by the client (eg, it maybe "get" rather than "GET").
|
|
67
|
+
|
|
68
|
+
@type path: C{bytes}
|
|
69
|
+
@param path: The URI of this request.
|
|
70
|
+
|
|
71
|
+
@type version: C{bytes}
|
|
72
|
+
@param version: The HTTP version of this request.
|
|
73
|
+
"""
|
|
74
|
+
clength = self.content.tell()
|
|
75
|
+
self.content.seek(0, 0)
|
|
76
|
+
self.args = {}
|
|
77
|
+
|
|
78
|
+
self.method, self.uri = command, path
|
|
79
|
+
self.clientproto = version
|
|
80
|
+
x = self.uri.split(b"?", 1)
|
|
81
|
+
|
|
82
|
+
if len(x) == 1:
|
|
83
|
+
self.path = self.uri
|
|
84
|
+
else:
|
|
85
|
+
self.path, argstring = x
|
|
86
|
+
self.args = parse_qs(argstring, 1)
|
|
87
|
+
|
|
88
|
+
# Argument processing
|
|
89
|
+
args = self.args
|
|
90
|
+
ctype = self.requestHeaders.getRawHeaders(b"content-type")
|
|
91
|
+
if ctype is not None:
|
|
92
|
+
ctype = ctype[0]
|
|
93
|
+
|
|
94
|
+
if self.method == b"POST" and ctype and clength:
|
|
95
|
+
mfd = b"multipart/form-data"
|
|
96
|
+
key, pdict = self._parseHeader(ctype)
|
|
97
|
+
# This weird CONTENT-LENGTH param is required by
|
|
98
|
+
# cgi.parse_multipart() in some versions of Python 3.7+, see
|
|
99
|
+
# bpo-29979. It looks like this will be relaxed and backported, see
|
|
100
|
+
# https://github.com/python/cpython/pull/8530.
|
|
101
|
+
pdict["CONTENT-LENGTH"] = clength
|
|
102
|
+
if key == b"application/x-www-form-urlencoded":
|
|
103
|
+
args.update(parse_qs(self.content.read(), 1))
|
|
104
|
+
elif key == mfd:
|
|
105
|
+
try:
|
|
106
|
+
if b"useLargeRequest" in args:
|
|
107
|
+
self.content.seek(0, 0)
|
|
108
|
+
cgiArgs = self.parse_multipart(self.content, pdict)
|
|
109
|
+
|
|
110
|
+
else:
|
|
111
|
+
cgiArgs = cgi.parse_multipart(self.content, pdict)
|
|
112
|
+
|
|
113
|
+
self.args.update(
|
|
114
|
+
{x.encode("iso-8859-1"): y for x, y in cgiArgs.items()}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
# It was a bad request, or we got a signal.
|
|
119
|
+
self.channel._respondToBadRequestAndDisconnect()
|
|
120
|
+
if isinstance(e, (TypeError, ValueError, KeyError)):
|
|
121
|
+
return
|
|
122
|
+
# If it's not a userspace error from CGI, reraise
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
self.content.seek(0, 0)
|
|
126
|
+
|
|
127
|
+
self.process()
|
|
128
|
+
|
|
129
|
+
# re-definition of cgi.parse_multipart that uses a single temporary file to store
|
|
130
|
+
# data rather than storing 2 to 3 copies in various lists.
|
|
131
|
+
def parse_multipart(self, fp, pdict):
|
|
132
|
+
"""Parse multipart input.
|
|
133
|
+
|
|
134
|
+
Arguments:
|
|
135
|
+
fp : input file
|
|
136
|
+
pdict: dictionary containing other parameters of content-type header
|
|
137
|
+
|
|
138
|
+
Returns a dictionary just like parse_qs(): keys are the field names, each
|
|
139
|
+
value is a list of values for that field. This is easy to use but not
|
|
140
|
+
much good if you are expecting megabytes to be uploaded -- in that case,
|
|
141
|
+
use the FieldStorage class instead which is much more flexible. Note
|
|
142
|
+
that content-type is the raw, unparsed contents of the content-type
|
|
143
|
+
header.
|
|
144
|
+
|
|
145
|
+
XXX This does not parse nested multipart parts -- use FieldStorage for
|
|
146
|
+
that.
|
|
147
|
+
|
|
148
|
+
XXX This should really be subsumed by FieldStorage altogether -- no
|
|
149
|
+
point in having two implementations of the same parsing algorithm.
|
|
150
|
+
Also, FieldStorage protects itself better against certain DoS attacks
|
|
151
|
+
by limiting the size of the data read in one chunk. The API here
|
|
152
|
+
does not support that kind of protection. This also affects parse()
|
|
153
|
+
since it can call parse_multipart().
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
import http.client
|
|
157
|
+
|
|
158
|
+
boundary = b""
|
|
159
|
+
if "boundary" in pdict:
|
|
160
|
+
boundary = pdict["boundary"]
|
|
161
|
+
if not valid_boundary(boundary):
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Invalid boundary in multipart form: %r" % (boundary,)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
nextpart = b"--" + boundary
|
|
167
|
+
lastpart = b"--" + boundary + b"--"
|
|
168
|
+
partdict = {}
|
|
169
|
+
terminator = b""
|
|
170
|
+
|
|
171
|
+
while terminator != lastpart:
|
|
172
|
+
bytes = -1
|
|
173
|
+
data = SpooledNamedTemporaryFile(max_size=self.memorylimit)
|
|
174
|
+
if terminator:
|
|
175
|
+
# At start of next part. Read headers first.
|
|
176
|
+
headers = http.client.parse_headers(fp)
|
|
177
|
+
clength = headers.get("content-length")
|
|
178
|
+
if clength:
|
|
179
|
+
try:
|
|
180
|
+
bytes = int(clength)
|
|
181
|
+
except ValueError:
|
|
182
|
+
pass
|
|
183
|
+
if bytes > 0:
|
|
184
|
+
if maxlen and bytes > maxlen:
|
|
185
|
+
raise ValueError("Maximum content length exceeded")
|
|
186
|
+
data.write(fp.read(bytes))
|
|
187
|
+
# Read lines until end of part.
|
|
188
|
+
while 1:
|
|
189
|
+
line = fp.readline()
|
|
190
|
+
if not line:
|
|
191
|
+
terminator = lastpart # End outer loop
|
|
192
|
+
break
|
|
193
|
+
if line.startswith(b"--"):
|
|
194
|
+
terminator = line.rstrip()
|
|
195
|
+
if terminator in (nextpart, lastpart):
|
|
196
|
+
break
|
|
197
|
+
data.write(line)
|
|
198
|
+
# Done with part.
|
|
199
|
+
if data.tell() == 0:
|
|
200
|
+
continue
|
|
201
|
+
if bytes < 0:
|
|
202
|
+
# if a Content-Length header was not supplied with the MIME part
|
|
203
|
+
# then the trailing line break must be removed.
|
|
204
|
+
# we have data, read the last 2 bytes
|
|
205
|
+
rewind = min(2, data.tell())
|
|
206
|
+
data.seek(-rewind, os.SEEK_END)
|
|
207
|
+
line = data.read(2)
|
|
208
|
+
if line[-2:] == b"\r\n":
|
|
209
|
+
data.seek(-2, os.SEEK_END)
|
|
210
|
+
data.truncate()
|
|
211
|
+
elif line[-1:] == b"\n":
|
|
212
|
+
data.seek(-1, os.SEEK_END)
|
|
213
|
+
data.truncate()
|
|
214
|
+
|
|
215
|
+
line = headers["content-disposition"]
|
|
216
|
+
if not line:
|
|
217
|
+
continue
|
|
218
|
+
key, params = cgi.parse_header(line)
|
|
219
|
+
if key != "form-data":
|
|
220
|
+
continue
|
|
221
|
+
if "name" in params:
|
|
222
|
+
name = params["name"]
|
|
223
|
+
# kludge in the filename
|
|
224
|
+
if "filename" in params:
|
|
225
|
+
fname_index = name + "_filename"
|
|
226
|
+
if fname_index in partdict:
|
|
227
|
+
partdict[fname_index].append(params["filename"])
|
|
228
|
+
else:
|
|
229
|
+
partdict[fname_index] = [params["filename"]]
|
|
230
|
+
else:
|
|
231
|
+
continue
|
|
232
|
+
if name in partdict:
|
|
233
|
+
data.seek(0, 0)
|
|
234
|
+
partdict[name].append(data)
|
|
235
|
+
else:
|
|
236
|
+
data.seek(0, 0)
|
|
237
|
+
partdict[name] = [data]
|
|
238
|
+
|
|
239
|
+
fp.seek(rewind) # restore cursor
|
|
240
|
+
return partdict
|
|
241
|
+
|
|
242
|
+
def _parseHeader(self, line):
|
|
243
|
+
# cgi.parse_header requires a str
|
|
244
|
+
key, pdict = cgi.parse_header(line.decode("charmap"))
|
|
245
|
+
|
|
246
|
+
# We want the key as bytes, and cgi.parse_multipart (which consumes
|
|
247
|
+
# pdict) expects a dict of str keys but bytes values
|
|
248
|
+
key = key.encode("charmap")
|
|
249
|
+
pdict = {x: y.encode("charmap") for x, y in pdict.items()}
|
|
250
|
+
return (key, pdict)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
from twisted.web import resource
|
|
10
|
+
from twisted.web.server import NOT_DONE_YET
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RedirectToHttpsResource(resource.Resource):
|
|
14
|
+
"""
|
|
15
|
+
I redirect to the same path at a given URL scheme
|
|
16
|
+
@param newScheme: scheme to redirect to (e.g. https)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
isLeaf = 0
|
|
20
|
+
newScheme = b'https'
|
|
21
|
+
|
|
22
|
+
def __init__(self, newPort):
|
|
23
|
+
resource.Resource.__init__(self)
|
|
24
|
+
self.newPort = newPort
|
|
25
|
+
|
|
26
|
+
def render(self, request):
|
|
27
|
+
newURLPath = request.URLPath()
|
|
28
|
+
|
|
29
|
+
# TODO Double check that == gives the correct behaviour here
|
|
30
|
+
if newURLPath.scheme == self.newScheme:
|
|
31
|
+
raise ValueError("Redirect loop: we're trying to redirect"
|
|
32
|
+
" to the same URL scheme in the request")
|
|
33
|
+
|
|
34
|
+
newURLPath.scheme = self.newScheme
|
|
35
|
+
newURLPath.netloc = b'%s:%s' % (newURLPath.netloc.split(b':')[0],
|
|
36
|
+
str(self.newPort).encode())
|
|
37
|
+
|
|
38
|
+
request.redirect(str(newURLPath).encode())
|
|
39
|
+
request.finish()
|
|
40
|
+
return NOT_DONE_YET
|
|
41
|
+
|
|
42
|
+
def getChild(self, name, request):
|
|
43
|
+
return self
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
class RedirectionRule:
|
|
10
|
+
def __init__(self, origin: str = "", destination: str = ""):
|
|
11
|
+
"""A definition of Resource redirection rule
|
|
12
|
+
:param str from: a source uri as in twisted.web.http.Request
|
|
13
|
+
:param str to: a destination url.
|
|
14
|
+
"""
|
|
15
|
+
self._origin: str = origin
|
|
16
|
+
self._destination: str = destination
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def origin(self) -> bytes:
|
|
20
|
+
return self._origin.encode()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def destination(self) -> bytes:
|
|
24
|
+
return self._destination.encode()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
#
|
|
3
|
+
# This software is open source; the MIT license applies.
|
|
4
|
+
# See LICENSE for the full license text.
|
|
5
|
+
#
|
|
6
|
+
# Website : https://flyingion.com
|
|
7
|
+
# Support : contact@flyingion.com
|
|
8
|
+
|
|
9
|
+
from txhttputil.site.AuthUserDetails import AuthUserDetails
|
|
10
|
+
from txhttputil.site.ResourceUtil import RESOURCES
|
|
11
|
+
from txhttputil.site.StaticFileMultiPath import StaticFileMultiPath
|
|
12
|
+
from txhttputil.site.StaticFileResource import StaticFileResource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RootResource(StaticFileResource):
|
|
16
|
+
"""Root Resource
|
|
17
|
+
|
|
18
|
+
This resource is the root or subroot of a resource tree.
|
|
19
|
+
It first looks for resources created with "createRoot
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def createRootResource(
|
|
24
|
+
userAccess: AuthUserDetails, staticFileRoot: StaticFileMultiPath
|
|
25
|
+
):
|
|
26
|
+
rootResource = RootResource(userAccess, staticFileRoot=staticFileRoot)
|
|
27
|
+
callResourceCreators(RESOURCES, rootResource, userAccess)
|
|
28
|
+
|
|
29
|
+
return rootResource
|