Skip to content

Commit 90ec4eb

Browse files
tejksatasvetlov
authored andcommitted
#921 show directory index (from PR #1033) (#1094)
* Add `show_index` flag to StaticRoute Related: #921 * Accessing static dir, should return 403 by default If we want to access a static file dir, it should return `Forbidden (403)` by default. Related: #921 * WIP: if allowed - return directory's index, otherwise - 403 XXX: need for a response to return proper html Related: #921 * Return directory index list if we allow to show it Also I now return in place, instead of creating variable and returning later, I am not a fan of returning somewehere inside a method, though if we tried to return `ret` at the end as before, but I guess it's the most clean pattern to do this. This is because we have to conditional blocks, either of which can return from the method. If first condtitonal creates `ret` variable, later conditional may just raise `HTTPNotFound` which we do not want. Though, I do not want to check that `ret` is populated either. Thus return in place. Related: #921 * Test if correct statuses are returned if we allow acces index or not Related: #921 * Prettier directory output - Better method name - More human readable output (newlines) - Title is shown as relative path to static directory and not as posix path - Show directories with slash at the end of the name * Remove unnecessary comment * Update docs And fix a minor typo * Check of html content is added for the response page with a directory index Related: #921 * Test of accessing non-existing static resource added Related: #921 * 403 Forbidden returned if the permission error occurred on listing requested folder Related: #921 * show_index parameter usage of app.router.add_static method documented Related: #921 * Import order fixed Related: #921 * Make directory index sorted so stable for tests Related: #921 * Improve tests coverage Related: #921 * Improve tests coverage Related: #921
1 parent 9f8d5e2 commit 90ec4eb

File tree

4 files changed

+199
-16
lines changed

4 files changed

+199
-16
lines changed

aiohttp/web_urldispatcher.py

+61-10
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
from .abc import AbstractMatchInfo, AbstractRouter, AbstractView
1717
from .file_sender import FileSender
1818
from .protocol import HttpVersion11
19-
from .web_exceptions import (HTTPExpectationFailed, HTTPMethodNotAllowed,
20-
HTTPNotFound)
21-
from .web_reqrep import StreamResponse
19+
from .web_exceptions import (HTTPExpectationFailed, HTTPForbidden,
20+
HTTPMethodNotAllowed, HTTPNotFound)
21+
from .web_reqrep import Response, StreamResponse
22+
2223

2324
__all__ = ('UrlDispatcher', 'UrlMappingMatchInfo',
2425
'AbstractResource', 'Resource', 'PlainResource', 'DynamicResource',
@@ -437,7 +438,8 @@ class StaticRoute(Route):
437438

438439
def __init__(self, name, prefix, directory, *,
439440
expect_handler=None, chunk_size=256*1024,
440-
response_factory=StreamResponse):
441+
response_factory=StreamResponse,
442+
show_index=False):
441443
assert prefix.startswith('/'), prefix
442444
assert prefix.endswith('/'), prefix
443445
super().__init__(
@@ -457,6 +459,7 @@ def __init__(self, name, prefix, directory, *,
457459
self._directory = directory
458460
self._file_sender = FileSender(resp_factory=response_factory,
459461
chunk_size=chunk_size)
462+
self._show_index = show_index
460463

461464
def match(self, path):
462465
if not path.startswith(self._prefix):
@@ -489,13 +492,59 @@ def handle(self, request):
489492
request.app.logger.exception(error)
490493
raise HTTPNotFound() from error
491494

492-
# Make sure that filepath is a file
493-
if not filepath.is_file():
494-
raise HTTPNotFound()
495+
# on opening a dir, load it's contents if allowed
496+
if filepath.is_dir():
497+
if self._show_index:
498+
try:
499+
ret = Response(text=self._directory_as_html(filepath))
500+
except PermissionError:
501+
raise HTTPForbidden()
502+
else:
503+
raise HTTPForbidden()
504+
elif filepath.is_file():
505+
ret = yield from self._file_sender.send(request, filepath)
506+
else:
507+
raise HTTPNotFound
495508

496-
ret = yield from self._file_sender.send(request, filepath)
497509
return ret
498510

511+
def _directory_as_html(self, filepath):
512+
"returns directory's index as html"
513+
# sanity check
514+
assert filepath.is_dir()
515+
516+
posix_dir_len = len(self._directory.as_posix())
517+
518+
# remove the beginning of posix path, so it would be relative
519+
# to our added static path
520+
relative_path_to_dir = filepath.as_posix()[posix_dir_len:]
521+
index_of = "Index of /{}".format(relative_path_to_dir)
522+
head = "<head>\n<title>{}</title>\n</head>".format(index_of)
523+
h1 = "<h1>{}</h1>".format(index_of)
524+
525+
index_list = []
526+
dir_index = filepath.iterdir()
527+
for _file in sorted(dir_index):
528+
# show file url as relative to static path
529+
file_url = _file.as_posix()[posix_dir_len:]
530+
531+
# if file is a directory, add '/' to the end of the name
532+
if _file.is_dir():
533+
file_name = "{}/".format(_file.name)
534+
else:
535+
file_name = _file.name
536+
537+
index_list.append(
538+
'<li><a href="{url}">{name}</a></li>'.format(url=file_url,
539+
name=file_name)
540+
)
541+
ul = "<ul>\n{}\n</ul>".format('\n'.join(index_list))
542+
body = "<body>\n{}\n{}\n</body>".format(h1, ul)
543+
544+
html = "<html>\n{}\n{}\n</html>".format(head, body)
545+
546+
return html
547+
499548
def __repr__(self):
500549
name = "'" + self.name + "' " if self.name is not None else ""
501550
return "<StaticRoute {name}[{method}] {path} -> {directory!r}".format(
@@ -720,7 +769,8 @@ def add_route(self, method, path, handler,
720769
expect_handler=expect_handler)
721770

722771
def add_static(self, prefix, path, *, name=None, expect_handler=None,
723-
chunk_size=256*1024, response_factory=StreamResponse):
772+
chunk_size=256*1024, response_factory=StreamResponse,
773+
show_index=False):
724774
"""
725775
Adds static files view
726776
:param prefix - url prefix
@@ -732,7 +782,8 @@ def add_static(self, prefix, path, *, name=None, expect_handler=None,
732782
route = StaticRoute(name, prefix, path,
733783
expect_handler=expect_handler,
734784
chunk_size=chunk_size,
735-
response_factory=response_factory)
785+
response_factory=response_factory,
786+
show_index=show_index)
736787
self.register_route(route)
737788
return route
738789

docs/web.rst

+6
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,12 @@ To do it just register a new static route by
341341

342342
app.router.add_static('/prefix', path_to_static_folder)
343343

344+
When a directory is accessed within a static route then the server responses
345+
to client with ``HTTP/403 Forbidden`` by default. Displaying folder index
346+
instead could be enabled with ``show_index`` parameter set to ``True``::
347+
348+
app.router.add_static('/prefix', path_to_static_folder, show_index=True)
349+
344350

345351
Template Rendering
346352
------------------

docs/web_reference.rst

+6-1
Original file line numberDiff line numberDiff line change
@@ -1289,7 +1289,8 @@ Router is any object that implements :class:`AbstractRouter` interface.
12891289
.. versionadded:: 1.0
12901290

12911291
.. method:: add_static(prefix, path, *, name=None, expect_handler=None, \
1292-
chunk_size=256*1024, response_factory=StreamResponse)
1292+
chunk_size=256*1024, response_factory=StreamResponse \
1293+
show_index=False)
12931294

12941295
Adds a router and a handler for returning static files.
12951296

@@ -1341,6 +1342,10 @@ Router is any object that implements :class:`AbstractRouter` interface.
13411342

13421343
.. versionadded:: 0.17
13431344

1345+
:param bool show_index: flag for allowing to show indexes of a directory,
1346+
by default it's not allowed and HTTP/403 will
1347+
be returned on directory access.
1348+
13441349
:returns: new :class:`StaticRoute` instance.
13451350

13461351
.. coroutinemethod:: resolve(request)

tests/test_web_urldispatcher.py

+126-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import shutil
55
import tempfile
66

7+
from unittest import mock
8+
from unittest.mock import MagicMock
9+
710
import pytest
811

912
import aiohttp.web
@@ -29,28 +32,146 @@ def teardown():
2932
return tmp_dir
3033

3134

35+
@pytest.mark.parametrize("show_index,status,data",
36+
[(False, 403, None),
37+
(True, 200,
38+
b'<html>\n<head>\n<title>Index of /</title>\n'
39+
b'</head>\n<body>\n<h1>Index of /</h1>\n<ul>\n'
40+
b'<li><a href="/my_dir">my_dir/</a></li>\n'
41+
b'<li><a href="/my_file">my_file</a></li>\n'
42+
b'</ul>\n</body>\n</html>')])
3243
@pytest.mark.run_loop
33-
def test_access_root_of_static_handler(tmp_dir_path, create_app_and_client):
44+
def test_access_root_of_static_handler(tmp_dir_path, create_app_and_client,
45+
show_index, status, data):
3446
"""
3547
Tests the operation of static file server.
3648
Try to access the root of static file server, and make
37-
sure that a proper not found error is returned.
49+
sure that correct HTTP statuses are returned depending if we directory
50+
index should be shown or not.
3851
"""
3952
# Put a file inside tmp_dir_path:
4053
my_file_path = os.path.join(tmp_dir_path, 'my_file')
4154
with open(my_file_path, 'w') as fw:
4255
fw.write('hello')
4356

57+
my_dir_path = os.path.join(tmp_dir_path, 'my_dir')
58+
os.mkdir(my_dir_path)
59+
60+
my_file_path = os.path.join(my_dir_path, 'my_file_in_dir')
61+
with open(my_file_path, 'w') as fw:
62+
fw.write('world')
63+
4464
app, client = yield from create_app_and_client()
4565

4666
# Register global static route:
47-
app.router.add_static('/', tmp_dir_path)
67+
app.router.add_static('/', tmp_dir_path, show_index=show_index)
4868

4969
# Request the root of the static directory.
50-
# Expect an 404 error page.
5170
r = yield from client.get('/')
71+
assert r.status == status
72+
73+
if data:
74+
read_ = (yield from r.read())
75+
assert read_ == data
76+
yield from r.release()
77+
78+
79+
@pytest.mark.run_loop
80+
def test_access_non_existing_resource(tmp_dir_path, create_app_and_client):
81+
"""
82+
Tests accessing non-existing resource
83+
Try to access a non-exiting resource and make sure that 404 HTTP status
84+
returned.
85+
"""
86+
app, client = yield from create_app_and_client()
87+
88+
# Register global static route:
89+
app.router.add_static('/', tmp_dir_path, show_index=True)
90+
91+
# Request the root of the static directory.
92+
r = yield from client.get('/non_existing_resource')
5293
assert r.status == 404
53-
# data = (yield from r.read())
94+
yield from r.release()
95+
96+
97+
@pytest.mark.run_loop
98+
def test_unauthorized_folder_access(tmp_dir_path, create_app_and_client):
99+
"""
100+
Tests the unauthorized access to a folder of static file server.
101+
Try to list a folder content of static file server when server does not
102+
have permissions to do so for the folder.
103+
"""
104+
my_dir_path = os.path.join(tmp_dir_path, 'my_dir')
105+
os.mkdir(my_dir_path)
106+
107+
app, client = yield from create_app_and_client()
108+
109+
with mock.patch('pathlib.Path.__new__') as path_constructor:
110+
path = MagicMock()
111+
path.joinpath.return_value = path
112+
path.resolve.return_value = path
113+
path.iterdir.return_value.__iter__.side_effect = PermissionError()
114+
path_constructor.return_value = path
115+
116+
# Register global static route:
117+
app.router.add_static('/', tmp_dir_path, show_index=True)
118+
119+
# Request the root of the static directory.
120+
r = yield from client.get('/my_dir')
121+
assert r.status == 403
122+
123+
yield from r.release()
124+
125+
126+
@pytest.mark.run_loop
127+
def test_access_symlink_loop(tmp_dir_path, create_app_and_client):
128+
"""
129+
Tests the access to a looped symlink, which could not be resolved.
130+
"""
131+
my_dir_path = os.path.join(tmp_dir_path, 'my_symlink')
132+
os.symlink(my_dir_path, my_dir_path)
133+
134+
app, client = yield from create_app_and_client()
135+
136+
# Register global static route:
137+
app.router.add_static('/', tmp_dir_path, show_index=True)
138+
139+
# Request the root of the static directory.
140+
r = yield from client.get('/my_symlink')
141+
assert r.status == 404
142+
143+
yield from r.release()
144+
145+
146+
@pytest.mark.run_loop
147+
def test_access_special_resource(tmp_dir_path, create_app_and_client):
148+
"""
149+
Tests the access to a resource that is neither a file nor a directory.
150+
Checks that if a special resource is accessed (f.e. named pipe or UNIX
151+
domain socket) then 404 HTTP status returned.
152+
"""
153+
app, client = yield from create_app_and_client()
154+
155+
with mock.patch('pathlib.Path.__new__') as path_constructor:
156+
special = MagicMock()
157+
special.is_dir.return_value = False
158+
special.is_file.return_value = False
159+
160+
path = MagicMock()
161+
path.joinpath.side_effect = lambda p: (special if p == 'special'
162+
else path)
163+
path.resolve.return_value = path
164+
special.resolve.return_value = special
165+
166+
path_constructor.return_value = path
167+
168+
# Register global static route:
169+
app.router.add_static('/', tmp_dir_path, show_index=True)
170+
171+
# Request the root of the static directory.
172+
r = yield from client.get('/special')
173+
assert r.status == 404
174+
54175
yield from r.release()
55176

56177

0 commit comments

Comments
 (0)