From ce1ed60dd2b70f1b11e72080333ed40e0c85349a Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 27 Feb 2021 00:10:03 -0600 Subject: [PATCH 01/13] allow for configuration to override the favicon Closes #5 --- incorporealcms/config.py | 2 ++ incorporealcms/templates/base.html | 2 +- tests/test_factory.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/incorporealcms/config.py b/incorporealcms/config.py index bc8c3ba..7e8ee0a 100644 --- a/incorporealcms/config.py +++ b/incorporealcms/config.py @@ -54,3 +54,5 @@ class Config(object): DEFAULT_PAGE_STYLE = 'light' TITLE_SUFFIX = 'incorporeal.org' CONTACT_EMAIL = 'bss@incorporeal.org' + + # specify FAVICON in your instance config.py to override the suou icon diff --git a/incorporealcms/templates/base.html b/incorporealcms/templates/base.html index e15878f..8d5d67a 100644 --- a/incorporealcms/templates/base.html +++ b/incorporealcms/templates/base.html @@ -9,7 +9,7 @@ - +
{% block header %} diff --git a/tests/test_factory.py b/tests/test_factory.py index b0a2a3f..1a99425 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -58,3 +58,13 @@ def test_media_file_access(client): response = client.get('/media/favicon.png') assert response.status_code == 200 assert response.headers['content-type'] == 'image/png' + + +def test_favicon_override(): + """Test that a configuration with a specific favicon overrides the default.""" + instance_path = os.path.join(HERE, 'instance') + app = create_app(instance_path=instance_path, test_config={'FAVICON': '/media/foo.png'}) + client = app.test_client() + response = client.get('/no-title') + assert response.status_code == 200 + assert b'' in response.data From d89fd151ca432d200879571d0409a2fa5f37a7b6 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 27 Feb 2021 00:30:32 -0600 Subject: [PATCH 02/13] use just the page part of the path in breadcrumbs rather than showing the full path (e.g. /foo/bar/baz) in breadcrumbs when the page doesn't have a Title, show just the leaf (baz) Closes #4 --- incorporealcms/pages.py | 10 +++++++++- tests/test_pages.py | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/incorporealcms/pages.py b/incorporealcms/pages.py index 7e7da0d..911df60 100644 --- a/incorporealcms/pages.py +++ b/incorporealcms/pages.py @@ -130,5 +130,13 @@ def generate_parent_navs(path): with app.open_instance_resource(path, 'r') as entry_file: entry = entry_file.read() _ = Markup(md.convert(entry)) - page_name = " ".join(md.Meta.get('title')) if md.Meta.get('title') else request_path + page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') + else request_path_to_breadcrumb_display(request_path)) return generate_parent_navs(parent_resource_path) + [(page_name, request_path)] + + +def request_path_to_breadcrumb_display(path): + """Given a request path, e.g. "/foo/bar/baz/", turn it into breadcrumby text "baz".""" + undired = path.rstrip('/') + leaf = undired[undired.rfind('/'):] + return leaf.strip('/') diff --git a/tests/test_pages.py b/tests/test_pages.py index ce41165..a61c535 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -3,7 +3,7 @@ import pytest from werkzeug.http import dump_cookie from incorporealcms.pages import (generate_parent_navs, instance_resource_path_to_request_path, render, - request_path_to_instance_resource_path) + request_path_to_breadcrumb_display, request_path_to_instance_resource_path) def test_generate_page_navs_index(app): @@ -15,13 +15,13 @@ def test_generate_page_navs_index(app): def test_generate_page_navs_subdir_index(app): """Test that dir pages have navs to the root and themselves.""" with app.app_context(): - assert generate_parent_navs('pages/subdir/index.md') == [('incorporeal.org', '/'), ('/subdir/', '/subdir/')] + assert generate_parent_navs('pages/subdir/index.md') == [('incorporeal.org', '/'), ('subdir', '/subdir/')] def test_generate_page_navs_subdir_real_page(app): """Test that real pages have navs to the root, their parent, and themselves.""" with app.app_context(): - assert generate_parent_navs('pages/subdir/page.md') == [('incorporeal.org', '/'), ('/subdir/', '/subdir/'), + assert generate_parent_navs('pages/subdir/page.md') == [('incorporeal.org', '/'), ('subdir', '/subdir/'), ('Page', '/subdir/page')] @@ -31,7 +31,7 @@ def test_generate_page_navs_subdir_with_title_parsing_real_page(app): assert generate_parent_navs('pages/subdir-with-title/page.md') == [ ('incorporeal.org', '/'), ('SUB!', '/subdir-with-title/'), - ('/subdir-with-title/page', '/subdir-with-title/page') + ('page', '/subdir-with-title/page') ] @@ -162,3 +162,12 @@ def test_request_resource_request_page(app): """Test that a request can resolve to a resource and back to a request.""" with app.test_request_context(): instance_resource_path_to_request_path(request_path_to_instance_resource_path('no-title')) == 'no-title' + + +def test_request_path_to_breadcrumb_display_patterns(): + """Test various conversions from request path to leaf nodes for display in the breadcrumbs.""" + assert request_path_to_breadcrumb_display('/foo') == 'foo' + assert request_path_to_breadcrumb_display('/foo/') == 'foo' + assert request_path_to_breadcrumb_display('/foo/bar') == 'bar' + assert request_path_to_breadcrumb_display('/foo/bar/') == 'bar' + assert request_path_to_breadcrumb_display('/') == '' From 06d948a70994650f99ff4f148786d7199ecd7e92 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sun, 7 Mar 2021 23:09:58 -0600 Subject: [PATCH 03/13] have specific styles @import the base styles this clarifies the value of what was formerly "style.css" a bit, and also opens the door for potential styles that don't inherit the base styling at all --- incorporealcms/static/css/{style.css => base.css} | 0 incorporealcms/static/css/dark.css | 2 ++ incorporealcms/static/css/light.css | 2 ++ incorporealcms/templates/base.html | 1 - 4 files changed, 4 insertions(+), 1 deletion(-) rename incorporealcms/static/css/{style.css => base.css} (100%) diff --git a/incorporealcms/static/css/style.css b/incorporealcms/static/css/base.css similarity index 100% rename from incorporealcms/static/css/style.css rename to incorporealcms/static/css/base.css diff --git a/incorporealcms/static/css/dark.css b/incorporealcms/static/css/dark.css index 9c038e0..854d854 100644 --- a/incorporealcms/static/css/dark.css +++ b/incorporealcms/static/css/dark.css @@ -1,3 +1,5 @@ +@import '/static/css/base.css'; + html { color: #CCC; } diff --git a/incorporealcms/static/css/light.css b/incorporealcms/static/css/light.css index 8b268e4..e9b83f3 100644 --- a/incorporealcms/static/css/light.css +++ b/incorporealcms/static/css/light.css @@ -1,3 +1,5 @@ +@import '/static/css/base.css'; + html { color: #222; } diff --git a/incorporealcms/templates/base.html b/incorporealcms/templates/base.html index 8d5d67a..3146cb9 100644 --- a/incorporealcms/templates/base.html +++ b/incorporealcms/templates/base.html @@ -7,7 +7,6 @@ - From 757b067e16b1b1d20c763419f8fc18aebb019053 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Tue, 9 Mar 2021 09:10:33 -0600 Subject: [PATCH 04/13] create a "plain" style with next to no CSS --- incorporealcms/lib.py | 1 + incorporealcms/static/css/plain.css | 8 ++++++++ incorporealcms/templates/base.html | 1 + 3 files changed, 10 insertions(+) create mode 100644 incorporealcms/static/css/plain.css diff --git a/incorporealcms/lib.py b/incorporealcms/lib.py index 711c042..5973e1f 100644 --- a/incorporealcms/lib.py +++ b/incorporealcms/lib.py @@ -34,6 +34,7 @@ def render(template_name_or_list, **context): PAGE_STYLES = { 'dark': 'css/dark.css', 'light': 'css/light.css', + 'plain': 'css/plain.css', } selected_style = request.args.get('style', None) diff --git a/incorporealcms/static/css/plain.css b/incorporealcms/static/css/plain.css new file mode 100644 index 0000000..264e44a --- /dev/null +++ b/incorporealcms/static/css/plain.css @@ -0,0 +1,8 @@ + +.img-25 { + max-width: 25% !important; +} + +.img-50 { + max-width: 50% !important; +} diff --git a/incorporealcms/templates/base.html b/incorporealcms/templates/base.html index 3146cb9..0d71a44 100644 --- a/incorporealcms/templates/base.html +++ b/incorporealcms/templates/base.html @@ -22,6 +22,7 @@
{% endblock %} From ced67bec8bbb2f8deb4992f3432da7241c90edac Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 14 Apr 2021 20:45:50 -0500 Subject: [PATCH 05/13] allow for serving files directly inside pages/ --- incorporealcms/pages.py | 17 ++++++++----- tests/functional_tests.py | 7 ++++++ tests/instance/pages/bss-square-no-bg.png | Bin 0 -> 49940 bytes tests/instance/pages/foo.txt | 1 + tests/test_pages.py | 28 +++++++++++++++------- 5 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 tests/instance/pages/bss-square-no-bg.png create mode 100644 tests/instance/pages/foo.txt diff --git a/incorporealcms/pages.py b/incorporealcms/pages.py index 911df60..6191852 100644 --- a/incorporealcms/pages.py +++ b/incorporealcms/pages.py @@ -6,7 +6,7 @@ import re from flask import Blueprint, Markup, abort from flask import current_app as app -from flask import redirect, request +from flask import redirect, request, send_from_directory from tzlocal import get_localzone from incorporealcms.lib import get_meta_str, init_md, render @@ -21,7 +21,7 @@ bp = Blueprint('pages', __name__, url_prefix='/') def display_page(path): """Get the file contents of the requested path and render the file.""" try: - resolved_path = request_path_to_instance_resource_path(path) + resolved_path, render_md = request_path_to_instance_resource_path(path) logger.debug("received request for path '%s', resolved to '%s'", path, resolved_path) except PermissionError: abort(400) @@ -30,6 +30,9 @@ def display_page(path): except FileNotFoundError: abort(404) + if not render_md: + return send_from_directory(app.instance_path, resolved_path) + try: with app.open_instance_resource(resolved_path, 'r') as entry_file: mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone()) @@ -76,21 +79,23 @@ def request_path_to_instance_resource_path(path): logger.info("client requested a path '%s' that is actually a directory", path) raise IsADirectoryError - # derive the proper markdown file depending on if this is a dir or file + # derive the proper markdown or actual file depending on if this is a dir or file if os.path.isdir(resolved_path): absolute_resource = os.path.join(resolved_path, 'index.md') + elif os.path.exists(resolved_path): + logger.info("final DIRECT path = '%s' for request '%s'", resolved_path, path) + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), False else: absolute_resource = f'{resolved_path}.md' - logger.info("final path = '%s' for request '%s'", absolute_resource, path) - # does the final file actually exist? if not os.path.exists(absolute_resource): logger.warning("requested final path '%s' does not exist!", absolute_resource) raise FileNotFoundError + logger.info("final path = '%s' for request '%s'", absolute_resource, path) # we checked that the file exists via absolute path, but now we need to give the path relative to instance dir - return absolute_resource.replace(f'{app.instance_path}{os.path.sep}', '') + return absolute_resource.replace(f'{app.instance_path}{os.path.sep}', ''), True def instance_resource_path_to_request_path(path): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index bd3a892..e83ca73 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -9,6 +9,13 @@ def test_page_that_exists(client): assert b'

test index

' in response.data +def test_direct_file_that_exists(client): + """Test that the app can serve a basic file at the index.""" + response = client.get('/foo.txt') + assert response.status_code == 200 + assert b'test file' in response.data + + def test_page_that_doesnt_exist(client): """Test that the app returns 404 for nonsense requests and they use my error page.""" response = client.get('/ohuesthaoeusth') diff --git a/tests/instance/pages/bss-square-no-bg.png b/tests/instance/pages/bss-square-no-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..c592d258e2b8393c4435b4a83c2ddbd0020c3931 GIT binary patch literal 49940 zcmeHQ2~ZSQ8txtj7(k&x6cvSrAmSPYb%`JxGIsF(_V$W7ZYIn#EnOM2QSKSpyzJ5Ol)1kHfL2r(aTAwW;;^shX;;?%_?p z_x|tu|Nr~n>rR!02X(dVZAlPB*N|Xo1VNZFZ6AplI^)u1b1p%Uu?qqNWeeuaAqbE3 zKc<%r6o1^a*#CUWR9~4}vhVx8vb~y;5s#xYdPK*4``KsTeUzVn{pzAm^Wx*aiKiSc zTDO=SFWnZgX{$!?V)!}zs7Lw$ebc=cd86XU?e#~dJXzbiIrC^`H5Fs$baQcWHz$YiqpJ$; zkE^u4p!kQS*2=~wo~g7tFi(^?X_t-eqZ7ljcWwB*`tbKjasPIBWSZ+y?%8M1&gkZc zBdgtQ|GG0f{m!6#^DMWx+4JXm*LnBx z%PsuQU+dp^H?xR1mp89Eqb@59&49(y;NLGJ2asjrY-xriNRe1V!2t{cSOTB` zPyi?Z6aWeU1%LuT0iXa-04M+y015yFfC4}Ppa4+t+M+;L`~5(%NZc}0{cwx+;!jA@ zRgb51y85ctI{!*p;(I)uSJq9JTQD~TJE1Z=iV3~;2b?r4-^~*L$}f=O^y95!vBK_T zFPyf#r$5cs9UX)fp!<>Je(~asxV!toLPC#P7>2_m6T%P!0UM%i6kK-4jyEFu zZ$sB%){P7o3?8s ze>uD<+P30qTT(UPhT_O9Sk-VE5g>^#Wl}J86x%b1`iCg&`Sr#A9vHP4D)R3tUOH%eg;NVX{33mo(Bxu`vv zdvWF$*BM;q{PZdrL#(E*=W{Dm!S;te+KN#7mvh$4C#F9hp)!5f|pG{E+s( zviy?Lpj|G{JE6jf)8)DWxHLrF7pe1pPt{X|GiuZ|8&o~8ncZN|ZI#=j8f#RY=)1CJ zAonC!x{;fsOvrr5+D+JTN@j`L&3?M>*lw;zH@SZM=nrw@R2Yaxy0~wXCAOOZ=q3Zk z=D2cijN7AoE;L*Cz&g>Cdy?Cunmy4W3337eLpz#J$V%%KB|U=HnAXrwk&MnU;J1+b!X1z|b$V?G97LUDHNl)h94*_`i z%Tz>sN)?_3yOJg)j-!IHMBK-+Cu2Qi6rO-wqKMV|%m;88gG)8?K=cDH2l1|8xVQ@) zTSsH*XQ@XN;d{9b&%zs7Qj_MiAL|ILO}o&^+jrv$kn&;+|GC~C%fWv^KR{i_yCO$4 zN5c~oSa0ZK@5T0+oq+cOfhwZ*cQz4NM+8OyMj!$s03!e+puq^h2*3zPFaj_FFaipU z0E_^P0LhDx9YA3Ct{6d`qkeB>32n)w~sN`WuuF zh$41Kt+8t4PL|w%YkMJ9>qAoASXXo0WoDd;h*&r_5?5@ALSm_Vx(V*`9``8Xu(!LX z{%&~(UP!6TC?R=g^%EiM=EdIVososBCT&L}3)3!S_HvkZK)W$)RDgCNw6yULkUWsQ zfENVc4!+$uwt|ojLOKClKqL>5ys-~~1P&57V_ZPqj*G-Wt^v8mm_t5Dj;u% zyj{R9Aa57QPuj-`Ge6ay7Fec?@i02R2`t?)W0*xB#pT7b^15{j?_eAZ#Y zAk>0TD-Z~QP-`qmb({ghf87WlNL~!doB)SGG6(DGa4ZhV93*r498P}#z8!qK04`t^ zt?>e%jx(@|7S_xQRHA`u1-SF zi@VF%2L{OGRx(ZE%(FM;k?|G|8A+02m;67YXxs}uSTaO|-6^ zXs+gmD|(`y(T!q`FTVN$zey9l+*sv6K#HZ5_%W>C9@^V*hHw@gpFM7Gh z=?D|-vt&gnj@*$k*s~dr-deKcfEo5dj+4TV|M+q(fS4MNWj76Cr}EIQQiB`gjD&<< zg1bbOSV-uz+j^%M+!$vhD5e~DiB=$1jUVVu;bdx>J%hUhlg+mZnBZ47`c|SDCKx6d zD7w?RzmfhJ5DW+g1OtNkp_>GvXimn^K05!elnE#rC>kglC>khQ{<^AKK+! Date: Wed, 14 Apr 2021 21:35:14 -0500 Subject: [PATCH 06/13] don't error on breadcrumbs if a dir doesn't have index.md fixes #8 --- incorporealcms/pages.py | 15 +++++++++------ tests/instance/pages/no-index-dir/page.md | 1 + tests/test_pages.py | 10 ++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 tests/instance/pages/no-index-dir/page.md diff --git a/incorporealcms/pages.py b/incorporealcms/pages.py index 6191852..c952eaf 100644 --- a/incorporealcms/pages.py +++ b/incorporealcms/pages.py @@ -132,12 +132,15 @@ def generate_parent_navs(path): md = init_md() # read the resource - with app.open_instance_resource(path, 'r') as entry_file: - entry = entry_file.read() - _ = Markup(md.convert(entry)) - page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') - else request_path_to_breadcrumb_display(request_path)) - return generate_parent_navs(parent_resource_path) + [(page_name, request_path)] + try: + with app.open_instance_resource(path, 'r') as entry_file: + entry = entry_file.read() + _ = Markup(md.convert(entry)) + page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') + else request_path_to_breadcrumb_display(request_path)) + return generate_parent_navs(parent_resource_path) + [(page_name, request_path)] + except FileNotFoundError: + return generate_parent_navs(parent_resource_path) + [(request_path, request_path)] def request_path_to_breadcrumb_display(path): diff --git a/tests/instance/pages/no-index-dir/page.md b/tests/instance/pages/no-index-dir/page.md new file mode 100644 index 0000000..2bcf858 --- /dev/null +++ b/tests/instance/pages/no-index-dir/page.md @@ -0,0 +1 @@ +this is a test page diff --git a/tests/test_pages.py b/tests/test_pages.py index f438540..83c3886 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -35,6 +35,16 @@ def test_generate_page_navs_subdir_with_title_parsing_real_page(app): ] +def test_generate_page_navs_subdir_with_no_index(app): + """Test that breadcrumbs still generate even if a subdir doesn't have an index.md.""" + with app.app_context(): + assert generate_parent_navs('pages/no-index-dir/page.md') == [ + ('incorporeal.org', '/'), + ('/no-index-dir/', '/no-index-dir/'), + ('page', '/no-index-dir/page') + ] + + def test_render_with_user_dark_theme(app): """Test that a request with the dark theme selected renders the dark theme.""" cookie = dump_cookie("user-style", 'dark') From 71ead20f3fa585efa6a405d90881d2b011d62e78 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Thu, 15 Apr 2021 20:36:30 -0500 Subject: [PATCH 07/13] have file handler return render type rather than bool for when we have further types to render --- incorporealcms/pages.py | 53 ++++++++++++++++++++++------------------- tests/test_pages.py | 16 ++++++------- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/incorporealcms/pages.py b/incorporealcms/pages.py index c952eaf..2714e7a 100644 --- a/incorporealcms/pages.py +++ b/incorporealcms/pages.py @@ -21,7 +21,7 @@ bp = Blueprint('pages', __name__, url_prefix='/') def display_page(path): """Get the file contents of the requested path and render the file.""" try: - resolved_path, render_md = request_path_to_instance_resource_path(path) + resolved_path, render_type = request_path_to_instance_resource_path(path) logger.debug("received request for path '%s', resolved to '%s'", path, resolved_path) except PermissionError: abort(400) @@ -30,31 +30,34 @@ def display_page(path): except FileNotFoundError: abort(404) - if not render_md: + if render_type == 'file': return send_from_directory(app.instance_path, resolved_path) + elif render_type == 'markdown': + try: + with app.open_instance_resource(resolved_path, 'r') as entry_file: + mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone()) + entry = entry_file.read() + except OSError: + logger.exception("resolved path '%s' could not be opened!", resolved_path) + abort(500) + else: + md = init_md() + content = Markup(md.convert(entry)) + logger.debug("file metadata: %s", md.Meta) - try: - with app.open_instance_resource(resolved_path, 'r') as entry_file: - mtime = datetime.datetime.fromtimestamp(os.path.getmtime(entry_file.name), get_localzone()) - entry = entry_file.read() - except OSError: - logger.exception("resolved path '%s' could not be opened!", resolved_path) - abort(500) + parent_navs = generate_parent_navs(resolved_path) + + page_name = (get_meta_str(md, 'title') if md.Meta.get('title') else + f'/{instance_resource_path_to_request_path(resolved_path)}') + page_title = f'{page_name} - {app.config["TITLE_SUFFIX"]}' if page_name else app.config['TITLE_SUFFIX'] + logger.debug("title (potentially derived): %s", page_title) + + return render('base.html', title=page_title, description=get_meta_str(md, 'description'), + image=get_meta_str(md, 'image'), base_url=request.base_url, content=content, + navs=parent_navs, mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z')) else: - md = init_md() - content = Markup(md.convert(entry)) - logger.debug("file metadata: %s", md.Meta) - - parent_navs = generate_parent_navs(resolved_path) - - page_name = (get_meta_str(md, 'title') if md.Meta.get('title') else - f'/{instance_resource_path_to_request_path(resolved_path)}') - page_title = f'{page_name} - {app.config["TITLE_SUFFIX"]}' if page_name else app.config['TITLE_SUFFIX'] - logger.debug("title (potentially derived): %s", page_title) - - return render('base.html', title=page_title, description=get_meta_str(md, 'description'), - image=get_meta_str(md, 'image'), base_url=request.base_url, content=content, navs=parent_navs, - mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z')) + logger.exception("unsupported render_type '%s'!?", render_type) + abort(500) def request_path_to_instance_resource_path(path): @@ -84,7 +87,7 @@ def request_path_to_instance_resource_path(path): absolute_resource = os.path.join(resolved_path, 'index.md') elif os.path.exists(resolved_path): logger.info("final DIRECT path = '%s' for request '%s'", resolved_path, path) - return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), False + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'file' else: absolute_resource = f'{resolved_path}.md' @@ -95,7 +98,7 @@ def request_path_to_instance_resource_path(path): logger.info("final path = '%s' for request '%s'", absolute_resource, path) # we checked that the file exists via absolute path, but now we need to give the path relative to instance dir - return absolute_resource.replace(f'{app.instance_path}{os.path.sep}', ''), True + return absolute_resource.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown' def instance_resource_path_to_request_path(path): diff --git a/tests/test_pages.py b/tests/test_pages.py index 83c3886..b1fb102 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -70,45 +70,45 @@ def test_render_with_no_user_theme(app): def test_request_path_to_instance_resource_path(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): - assert request_path_to_instance_resource_path('index') == ('pages/index.md', True) + assert request_path_to_instance_resource_path('index') == ('pages/index.md', 'markdown') def test_request_path_to_instance_resource_path_direct_file(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): - assert request_path_to_instance_resource_path('no-title') == ('pages/no-title.md', True) + assert request_path_to_instance_resource_path('no-title') == ('pages/no-title.md', 'markdown') def test_request_path_to_instance_resource_path_in_subdir(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): - assert request_path_to_instance_resource_path('subdir/page') == ('pages/subdir/page.md', True) + assert request_path_to_instance_resource_path('subdir/page') == ('pages/subdir/page.md', 'markdown') def test_request_path_to_instance_resource_path_subdir_index(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): - assert request_path_to_instance_resource_path('subdir/') == ('pages/subdir/index.md', True) + assert request_path_to_instance_resource_path('subdir/') == ('pages/subdir/index.md', 'markdown') def test_request_path_to_instance_resource_path_relatives_walked(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): assert (request_path_to_instance_resource_path('subdir/more-subdir/../../more-metadata') == - ('pages/more-metadata.md', True)) + ('pages/more-metadata.md', 'markdown')) def test_request_path_to_instance_resource_path_relatives_walked_indexes_work_too(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): - assert request_path_to_instance_resource_path('subdir/more-subdir/../../') == ('pages/index.md', True) + assert request_path_to_instance_resource_path('subdir/more-subdir/../../') == ('pages/index.md', 'markdown') def test_request_path_to_instance_resource_path_relatives_walked_into_subdirs_also_fine(app): """Test a normal URL request is transformed into the file path.""" with app.test_request_context(): assert (request_path_to_instance_resource_path('subdir/more-subdir/../../subdir/page') == - ('pages/subdir/page.md', True)) + ('pages/subdir/page.md', 'markdown')) def test_request_path_to_instance_resource_path_permission_error_on_ref_above_pages(app): @@ -129,7 +129,7 @@ def test_request_path_to_instance_resource_path_actual_file(app): """Test that a request for e.g. '/foo.png' when foo.png is a real file works.""" with app.test_request_context(): assert (request_path_to_instance_resource_path('bss-square-no-bg.png') == - ('pages/bss-square-no-bg.png', False)) + ('pages/bss-square-no-bg.png', 'file')) def test_request_path_to_instance_resource_path_nonexistant_file_errors(app): From c90f0a3a4208eccef6e2581971d2afc8dc6c4526 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Thu, 15 Apr 2021 21:43:29 -0500 Subject: [PATCH 08/13] treat symlinks as redirects closes #7 --- incorporealcms/pages.py | 14 +++++++++++++- tests/functional_tests.py | 15 +++++++++++++++ tests/instance/pages/symlink-to-foo.txt | 1 + tests/instance/pages/symlink-to-no-title.md | 1 + tests/test_pages.py | 14 ++++++++++++++ 5 files changed, 44 insertions(+), 1 deletion(-) create mode 120000 tests/instance/pages/symlink-to-foo.txt create mode 120000 tests/instance/pages/symlink-to-no-title.md diff --git a/incorporealcms/pages.py b/incorporealcms/pages.py index 2714e7a..7b41627 100644 --- a/incorporealcms/pages.py +++ b/incorporealcms/pages.py @@ -22,7 +22,8 @@ def display_page(path): """Get the file contents of the requested path and render the file.""" try: resolved_path, render_type = request_path_to_instance_resource_path(path) - logger.debug("received request for path '%s', resolved to '%s'", path, resolved_path) + logger.debug("received request for path '%s', resolved to '%s', type '%s'", + path, resolved_path, render_type) except PermissionError: abort(400) except IsADirectoryError: @@ -32,6 +33,8 @@ def display_page(path): if render_type == 'file': return send_from_directory(app.instance_path, resolved_path) + elif render_type == 'symlink': + return redirect(instance_resource_path_to_request_path(resolved_path), code=301) elif render_type == 'markdown': try: with app.open_instance_resource(resolved_path, 'r') as entry_file: @@ -77,6 +80,15 @@ def request_path_to_instance_resource_path(path): logger.warning("client tried to request a path '%s' outside of the base_dir!", path) raise PermissionError + # if this is a (valid) symlink, find what it's pointed to and redirect the user + if os.path.islink(os.path.join(base_dir, path)): + logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path) + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink' + elif os.path.islink(os.path.join(base_dir, f'{path}.md')): + resolved_path = os.path.realpath(os.path.join(base_dir, f'{path}.md')) + logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path) + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink' + # if this is a file-like requset but actually a directory, redirect the user if os.path.isdir(resolved_path) and not path.endswith('/'): logger.info("client requested a path '%s' that is actually a directory", path) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index e83ca73..e76fdb3 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -108,6 +108,21 @@ def test_that_page_request_redirects_to_directory(client): """ response = client.get('/subdir') assert response.status_code == 301 + assert response.location == 'http://localhost/subdir/' + + +def test_that_request_to_symlink_redirects_markdown(client): + """Test that a request to /foo redirects to /what-foo-points-at.""" + response = client.get('/symlink-to-no-title') + assert response.status_code == 301 + assert response.location == 'http://localhost/no-title' + + +def test_that_request_to_symlink_redirects_file(client): + """Test that a request to /foo.txt redirects to /what-foo-points-at.txt.""" + response = client.get('/symlink-to-foo.txt') + assert response.status_code == 301 + assert response.location == 'http://localhost/foo.txt' def test_that_dir_request_does_not_redirect(client): diff --git a/tests/instance/pages/symlink-to-foo.txt b/tests/instance/pages/symlink-to-foo.txt new file mode 120000 index 0000000..996f178 --- /dev/null +++ b/tests/instance/pages/symlink-to-foo.txt @@ -0,0 +1 @@ +foo.txt \ No newline at end of file diff --git a/tests/instance/pages/symlink-to-no-title.md b/tests/instance/pages/symlink-to-no-title.md new file mode 120000 index 0000000..e4c6749 --- /dev/null +++ b/tests/instance/pages/symlink-to-no-title.md @@ -0,0 +1 @@ +no-title.md \ No newline at end of file diff --git a/tests/test_pages.py b/tests/test_pages.py index b1fb102..c7c0172 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -132,6 +132,20 @@ def test_request_path_to_instance_resource_path_actual_file(app): ('pages/bss-square-no-bg.png', 'file')) +def test_request_path_to_instance_resource_path_markdown_symlink(app): + """Test that a request for e.g. '/foo' when foo.md is a symlink to another .md file redirects.""" + with app.test_request_context(): + assert (request_path_to_instance_resource_path('symlink-to-no-title') == + ('pages/no-title.md', 'symlink')) + + +def test_request_path_to_instance_resource_path_file_symlink(app): + """Test that a request for e.g. '/foo' when foo.txt is a symlink to another .txt file redirects.""" + with app.test_request_context(): + assert (request_path_to_instance_resource_path('symlink-to-foo.txt') == + ('pages/foo.txt', 'symlink')) + + def test_request_path_to_instance_resource_path_nonexistant_file_errors(app): """Test that a request for something not on disk errors.""" with app.test_request_context(): From 60715a3a5c5301ee44aaeedd151248171a002dd1 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 17 Apr 2021 10:30:01 -0500 Subject: [PATCH 09/13] make request -> instance conversion support symlink dirs I think this also clarifies the code, a bit --- incorporealcms/pages.py | 60 +++++++++++++++----------- tests/functional_tests.py | 14 ++++++ tests/instance/pages/symlink-to-subdir | 1 + tests/test_pages.py | 7 +++ 4 files changed, 57 insertions(+), 25 deletions(-) create mode 120000 tests/instance/pages/symlink-to-subdir diff --git a/incorporealcms/pages.py b/incorporealcms/pages.py index 7b41627..7ea9dd2 100644 --- a/incorporealcms/pages.py +++ b/incorporealcms/pages.py @@ -34,7 +34,10 @@ def display_page(path): if render_type == 'file': return send_from_directory(app.instance_path, resolved_path) elif render_type == 'symlink': - return redirect(instance_resource_path_to_request_path(resolved_path), code=301) + logger.debug("attempting to redirect path '%s' to reverse of resource '%s'", path, resolved_path) + redirect_path = f'/{instance_resource_path_to_request_path(resolved_path)}' + logger.debug("redirect path: '%s'", redirect_path) + return redirect(redirect_path, code=301) elif render_type == 'markdown': try: with app.open_instance_resource(resolved_path, 'r') as entry_file: @@ -72,45 +75,52 @@ def request_path_to_instance_resource_path(path): """ # check if the path is allowed base_dir = os.path.realpath(f'{app.instance_path}/pages/') - resolved_path = os.path.realpath(os.path.join(base_dir, path)) - logger.debug("base_dir: %s, constructed resolved_path: %s", base_dir, resolved_path) + verbatim_path = os.path.abspath(os.path.join(base_dir, path)) + resolved_path = os.path.realpath(verbatim_path) + logger.debug("base_dir '%s', constructed resolved_path '%s' for path '%s'", base_dir, resolved_path, path) # bail if the requested real path isn't inside the base directory if base_dir != os.path.commonpath((base_dir, resolved_path)): logger.warning("client tried to request a path '%s' outside of the base_dir!", path) raise PermissionError - # if this is a (valid) symlink, find what it's pointed to and redirect the user - if os.path.islink(os.path.join(base_dir, path)): - logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path) - return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink' - elif os.path.islink(os.path.join(base_dir, f'{path}.md')): - resolved_path = os.path.realpath(os.path.join(base_dir, f'{path}.md')) - logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path) - return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink' + # see if we have a real file or if we should infer markdown rendering + if os.path.exists(resolved_path): + # if this is a file-like request but actually a directory, redirect the user + if os.path.isdir(resolved_path) and not path.endswith('/'): + logger.info("client requested a path '%s' that is actually a directory", path) + raise IsADirectoryError - # if this is a file-like requset but actually a directory, redirect the user - if os.path.isdir(resolved_path) and not path.endswith('/'): - logger.info("client requested a path '%s' that is actually a directory", path) - raise IsADirectoryError + # if the requested path contains a symlink, redirect the user + if verbatim_path != resolved_path: + logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path) + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink' + + # derive the proper markdown or actual file depending on if this is a dir or file + if os.path.isdir(resolved_path): + resolved_path = os.path.join(resolved_path, 'index.md') + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown' - # derive the proper markdown or actual file depending on if this is a dir or file - if os.path.isdir(resolved_path): - absolute_resource = os.path.join(resolved_path, 'index.md') - elif os.path.exists(resolved_path): logger.info("final DIRECT path = '%s' for request '%s'", resolved_path, path) return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'file' - else: - absolute_resource = f'{resolved_path}.md' + + # if we're here, this isn't direct file access, so try markdown inference + verbatim_path = os.path.abspath(os.path.join(base_dir, f'{path}.md')) + resolved_path = os.path.realpath(verbatim_path) # does the final file actually exist? - if not os.path.exists(absolute_resource): - logger.warning("requested final path '%s' does not exist!", absolute_resource) + if not os.path.exists(resolved_path): + logger.warning("requested final path '%s' does not exist!", resolved_path) raise FileNotFoundError - logger.info("final path = '%s' for request '%s'", absolute_resource, path) + # check for symlinks + if verbatim_path != resolved_path: + logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path) + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink' + + logger.info("final path = '%s' for request '%s'", resolved_path, path) # we checked that the file exists via absolute path, but now we need to give the path relative to instance dir - return absolute_resource.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown' + return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown' def instance_resource_path_to_request_path(path): diff --git a/tests/functional_tests.py b/tests/functional_tests.py index e76fdb3..a47916f 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -125,6 +125,20 @@ def test_that_request_to_symlink_redirects_file(client): assert response.location == 'http://localhost/foo.txt' +def test_that_request_to_symlink_redirects_directory(client): + """Test that a request to /foo/ redirects to /what-foo-points-at/.""" + response = client.get('/symlink-to-subdir/') + assert response.status_code == 301 + assert response.location == 'http://localhost/subdir' + # sadly, this location also redirects + response = client.get('/subdir') + assert response.status_code == 301 + assert response.location == 'http://localhost/subdir/' + # but we do get there + response = client.get('/subdir/') + assert response.status_code == 200 + + def test_that_dir_request_does_not_redirect(client): """Test that a request to /foo/ serves the index page, if foo is a directory.""" response = client.get('/subdir/') diff --git a/tests/instance/pages/symlink-to-subdir b/tests/instance/pages/symlink-to-subdir new file mode 120000 index 0000000..8bbe8a5 --- /dev/null +++ b/tests/instance/pages/symlink-to-subdir @@ -0,0 +1 @@ +subdir \ No newline at end of file diff --git a/tests/test_pages.py b/tests/test_pages.py index c7c0172..a1d6f55 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -146,6 +146,13 @@ def test_request_path_to_instance_resource_path_file_symlink(app): ('pages/foo.txt', 'symlink')) +def test_request_path_to_instance_resource_path_dir_symlink(app): + """Test that a request for e.g. '/foo' when /foo is a symlink to /bar redirects.""" + with app.test_request_context(): + assert (request_path_to_instance_resource_path('symlink-to-subdir/') == + ('pages/subdir', 'symlink')) + + def test_request_path_to_instance_resource_path_nonexistant_file_errors(app): """Test that a request for something not on disk errors.""" with app.test_request_context(): From 30b79e9dc18a2dc84c25a2dd03426d3c5cecfafc Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 17 Apr 2021 10:39:05 -0500 Subject: [PATCH 10/13] add tests for subdir symlinks this is automagically supported by the previous rewrite --- tests/functional_tests.py | 9 +++++++++ tests/test_pages.py | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index a47916f..6bdafd3 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -139,6 +139,15 @@ def test_that_request_to_symlink_redirects_directory(client): assert response.status_code == 200 +def test_that_request_to_symlink_redirects_subdirectory(client): + """Test that a request to /foo/bar redirects to /what-foo-points-at/bar.""" + response = client.get('/symlink-to-subdir/page-no-title') + assert response.status_code == 301 + assert response.location == 'http://localhost/subdir/page-no-title' + response = client.get('/subdir/page-no-title') + assert response.status_code == 200 + + def test_that_dir_request_does_not_redirect(client): """Test that a request to /foo/ serves the index page, if foo is a directory.""" response = client.get('/subdir/') diff --git a/tests/test_pages.py b/tests/test_pages.py index a1d6f55..839ffe3 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -153,6 +153,13 @@ def test_request_path_to_instance_resource_path_dir_symlink(app): ('pages/subdir', 'symlink')) +def test_request_path_to_instance_resource_path_subdir_symlink(app): + """Test that a request for e.g. '/foo/baz' when /foo is a symlink to /bar redirects.""" + with app.test_request_context(): + assert (request_path_to_instance_resource_path('symlink-to-subdir/page-no-title') == + ('pages/subdir/page-no-title.md', 'symlink')) + + def test_request_path_to_instance_resource_path_nonexistant_file_errors(app): """Test that a request for something not on disk errors.""" with app.test_request_context(): From 6705fa38eb546b1fc6ce8d2eeee277705d8a3952 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 17 Apr 2021 10:58:06 -0500 Subject: [PATCH 11/13] requirements bumps --- requirements/requirements-dev.txt | 33 +++++++++++++++++-------------- requirements/requirements.txt | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index d1a09e2..9cced20 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -16,7 +16,7 @@ click==7.1.2 # via # flask # pip-tools -coverage==5.4 +coverage==5.5 # via pytest-cov distlib==0.3.1 # via virtualenv @@ -30,7 +30,7 @@ flake8-blind-except==0.2.0 # via -r requirements/requirements-dev.in flake8-builtins==1.5.3 # via -r requirements/requirements-dev.in -flake8-docstrings==1.5.0 +flake8-docstrings==1.6.0 # via -r requirements/requirements-dev.in flake8-executable==2.1.1 # via -r requirements/requirements-dev.in @@ -42,7 +42,7 @@ flake8-logging-format==0.6.0 # via -r requirements/requirements-dev.in flake8-mutable==1.2.0 # via -r requirements/requirements-dev.in -flake8==3.8.4 +flake8==3.9.1 # via # -r requirements/requirements-dev.in # dlint @@ -53,19 +53,19 @@ flake8==3.8.4 # flake8-mutable flask==1.1.2 # via -r requirements/requirements.in -gitdb==4.0.5 +gitdb==4.0.7 # via gitpython -gitpython==3.1.13 +gitpython==3.1.14 # via bandit iniconfig==1.1.1 # via pytest -isort==5.7.0 +isort==5.8.0 # via flake8-isort itsdangerous==1.1.0 # via flask jinja2==2.11.3 # via flask -markdown==3.3.3 +markdown==3.3.4 # via # -r requirements/requirements.in # mdx-linkify @@ -82,7 +82,9 @@ packaging==20.9 # tox pbr==5.5.1 # via stevedore -pip-tools==5.5.0 +pep517==0.10.0 + # via pip-tools +pip-tools==6.1.0 # via -r requirements/requirements-dev.in pluggy==0.13.1 # via @@ -92,17 +94,17 @@ py==1.10.0 # via # pytest # tox -pycodestyle==2.6.0 +pycodestyle==2.7.0 # via flake8 -pydocstyle==5.1.1 +pydocstyle==6.0.0 # via flake8-docstrings -pyflakes==2.2.0 +pyflakes==2.3.1 # via flake8 pyparsing==2.4.7 # via packaging pytest-cov==2.11.1 # via -r requirements/requirements-dev.in -pytest==6.2.2 +pytest==6.2.3 # via # -r requirements/requirements-dev.in # pytest-cov @@ -116,7 +118,7 @@ six==1.15.0 # bleach # tox # virtualenv -smmap==3.0.5 +smmap==4.0.0 # via gitdb snowballstemmer==2.1.0 # via pydocstyle @@ -126,11 +128,12 @@ testfixtures==6.17.1 # via flake8-isort toml==0.10.2 # via + # pep517 # pytest # tox tox-wheel==0.6.0 # via -r requirements/requirements-dev.in -tox==3.22.0 +tox==3.23.0 # via # -r requirements/requirements-dev.in # tox-wheel @@ -138,7 +141,7 @@ tzlocal==2.1 # via -r requirements/requirements.in versioneer==0.19 # via -r requirements/requirements-dev.in -virtualenv==20.4.2 +virtualenv==20.4.3 # via tox webencodings==0.5.1 # via bleach diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 94afa16..55d31f8 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -14,7 +14,7 @@ itsdangerous==1.1.0 # via flask jinja2==2.11.3 # via flask -markdown==3.3.3 +markdown==3.3.4 # via # -r requirements/requirements.in # mdx-linkify From 1ac13f3b9c03b86bac2176b223935ce493a38a2f Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 17 Apr 2021 11:08:01 -0500 Subject: [PATCH 12/13] add some 500 tests for test coverage --- tests/functional_tests.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 6bdafd3..714a5c4 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -1,5 +1,6 @@ """Test page requests.""" import re +from unittest.mock import patch def test_page_that_exists(client): @@ -41,6 +42,24 @@ def test_internal_server_error_serves_error_page(client): assert b'bss@incorporeal.org' in response.data +def test_oserror_is_500(client, app): + """Test that an OSError raises as a 500.""" + with app.test_request_context(): + with patch('flask.current_app.open_instance_resource', side_effect=OSError): + response = client.get('/') + assert response.status_code == 500 + assert b'INTERNAL SERVER ERROR' in response.data + + +def test_unsupported_file_type_is_500(client, app): + """Test a coding condition mishap raises as a 500.""" + with app.test_request_context(): + with patch('incorporealcms.pages.request_path_to_instance_resource_path', return_value=('foo', 'bar')): + response = client.get('/') + assert response.status_code == 500 + assert b'INTERNAL SERVER ERROR' in response.data + + def test_weird_paths_do_not_get_served(client): """Test that we clean up requests as desired.""" response = client.get('/../../') From cde82ab9184564738f6d889c07f82bccee84deb1 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Sat, 17 Apr 2021 11:16:34 -0500 Subject: [PATCH 13/13] don't route /media separately anymore --- incorporealcms/__init__.py | 7 +------ tests/instance/{ => pages}/media/favicon.png | Bin 2 files changed, 1 insertion(+), 6 deletions(-) rename tests/instance/{ => pages}/media/favicon.png (100%) diff --git a/incorporealcms/__init__.py b/incorporealcms/__init__.py index 726eaa3..6d87421 100644 --- a/incorporealcms/__init__.py +++ b/incorporealcms/__init__.py @@ -3,7 +3,7 @@ import logging import os from logging.config import dictConfig -from flask import Flask, request, send_from_directory +from flask import Flask, request from ._version import get_versions @@ -40,11 +40,6 @@ def create_app(instance_path=None, test_config=None): logger.info("RESPONSE: %s %s: %s", request.method, request.path, response.status) return response - @app.route(f'/{app.config["MEDIA_DIR"]}/') - def media_files(filename): - return send_from_directory(os.path.join(app.instance_path, app.config['MEDIA_DIR']), - filename) - from . import error_pages, pages app.register_blueprint(pages.bp) app.register_error_handler(400, error_pages.bad_request) diff --git a/tests/instance/media/favicon.png b/tests/instance/pages/media/favicon.png similarity index 100% rename from tests/instance/media/favicon.png rename to tests/instance/pages/media/favicon.png