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.
@@ -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
@@ -0,0 +1,8 @@
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
+