diff --git a/vasl_templates/webapp/files.py b/vasl_templates/webapp/files.py index e285e5a..7b606de 100644 --- a/vasl_templates/webapp/files.py +++ b/vasl_templates/webapp/files.py @@ -2,8 +2,11 @@ import os import io +import urllib.request +import urllib.parse +import mimetypes -from flask import send_file, jsonify, redirect, url_for, abort +from flask import send_file, send_from_directory, jsonify, redirect, url_for, abort from vasl_templates.webapp import app from vasl_templates.webapp.file_server.vasl_mod import VaslMod @@ -19,20 +22,45 @@ class FileServer: """Serve static files.""" def __init__( self, base_dir ): - self.base_dir = os.path.abspath( base_dir ) + if FileServer.is_remote_path( base_dir ): + self.base_dir = base_dir + else: + self.base_dir = os.path.abspath( base_dir ) - def get_file( self, fname ): + def serve_file( self, path ): """Serve a file.""" - if not fname: - return None - fname = os.path.join( self.base_dir, fname ) - fname = os.path.abspath( fname ) - if not os.path.isfile( fname ): - return None - prefix = os.path.commonpath( [ self.base_dir, fname ] ) - if prefix != self.base_dir: - return None # nb: files must be sub-ordinate to the configured directory - return fname + if FileServer.is_remote_path( self.base_dir ): + url = "{}/{}".format( self.base_dir, path ) + # NOTE: We download the target file and serve it ourself (instead of just redirecting) + # since VASSAL can't handle SSL :-/ + resp = urllib.request.urlopen( url ) + buf = io.BytesIO() + buf.write( resp.read() ) + buf.seek( 0 ) + mime_type = mimetypes.guess_type( url )[0] + if not mime_type: + # FUDGE! send_file() requires a MIME type, so we take a guess and hope the browser + # can figure it out if we're wrong :-/ + mime_type = "image/png" + return send_file( buf, mimetype=mime_type ) + else: + path = path.replace( "\\", "/" ) # nb: for Windows :-/ + return send_from_directory( self.base_dir, path ) + + @staticmethod + def is_remote_path( path ): + """Check if a path is referring to a remote server.""" + return path.startswith( ("http://","https://") ) + +# --------------------------------------------------------------------- + +@app.route( "/user/" ) +def get_user_file( path ): + """Get a static file.""" + dname = app.config.get( "USER_FILES_DIR" ) + if not dname: + abort( 404 ) + return FileServer( dname ).serve_file( path ) # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index 23d2edb..c12f656 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -283,6 +283,9 @@ function make_snippet( $btn, params, extra_params, show_date_warnings ) return "[error: can't process template'" ; } + // fixup any user file URL's + snippet = snippet.replace( "{{USER_FILES}}", APP_URL_BASE + "/user" ) ; + return snippet ; } diff --git a/vasl_templates/webapp/tests/fixtures/user-files/amp=& ; plus=+.txt b/vasl_templates/webapp/tests/fixtures/user-files/amp=& ; plus=+.txt new file mode 100644 index 0000000..15ce1ca --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/user-files/amp=& ; plus=+.txt @@ -0,0 +1 @@ +special chars diff --git a/vasl_templates/webapp/tests/fixtures/user-files/hello.txt b/vasl_templates/webapp/tests/fixtures/user-files/hello.txt new file mode 100644 index 0000000..2bd9a94 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/user-files/hello.txt @@ -0,0 +1 @@ +Yo, wassup! diff --git a/vasl_templates/webapp/tests/fixtures/user-files/subdir/placeholder.png b/vasl_templates/webapp/tests/fixtures/user-files/subdir/placeholder.png new file mode 100644 index 0000000..62b2678 Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/user-files/subdir/placeholder.png differ diff --git a/vasl_templates/webapp/tests/remote.py b/vasl_templates/webapp/tests/remote.py index e71a26c..83718e3 100644 --- a/vasl_templates/webapp/tests/remote.py +++ b/vasl_templates/webapp/tests/remote.py @@ -185,3 +185,16 @@ class ControlTests: webapp_vo_notes._cached_vo_notes = None #pylint: disable=protected-access webapp_vo_notes._vo_notes_file_server = None #pylint: disable=protected-access return self + + def _set_user_files_dir( self, dtype=None ): + """Set the user files directory.""" + if dtype == "test": + dname = os.path.join( os.path.split(__file__)[0], "fixtures/user-files" ) + elif dtype and dtype.startswith( ("http://","https://") ): + dname = dtype + else: + assert dtype is None + dname = None + _logger.info( "Setting user files: %s", dname ) + app.config["USER_FILES_DIR"] = dname + return self diff --git a/vasl_templates/webapp/tests/test_files.py b/vasl_templates/webapp/tests/test_files.py index c31d4d9..6253ea5 100644 --- a/vasl_templates/webapp/tests/test_files.py +++ b/vasl_templates/webapp/tests/test_files.py @@ -1,26 +1,200 @@ """ Test serving files. """ import os +import re +import urllib.request + +import pytest +import werkzeug.exceptions from vasl_templates.webapp.files import FileServer +from vasl_templates.webapp.tests.utils import init_webapp, find_child, wait_for_clipboard # --------------------------------------------------------------------- -def test_file_server(): - """Test serving files.""" +def test_local_file_server( webapp ): + """Test serving files from the local file system.""" # initialize base_dir = os.path.normpath( os.path.join( os.path.split(__file__)[0], "fixtures/file-server" ) ) file_server = FileServer( base_dir ) # do the tests - assert file_server.get_file( "1.txt" ) == os.path.join( base_dir, "1.txt" ) - assert file_server.get_file( "/1.txt" ) is None - assert file_server.get_file( "unknown.txt" ) is None - assert file_server.get_file( "subdir/2.txt" ) == os.path.normpath( os.path.join( base_dir, "subdir/2.txt" ) ) - assert file_server.get_file( "/subdir/2.txt" ) is None - - # try access a file outside the configured directory - fname = "../new-default-scenario.json" - assert os.path.isfile( os.path.join( base_dir, fname ) ) - assert file_server.get_file( fname ) is None + with webapp.test_request_context(): + + assert _get_response_data( file_server.serve_file( "1.txt" ) ).strip() == b"file 1" + with pytest.raises( werkzeug.exceptions.NotFound ): + _get_response_data( file_server.serve_file( "/1.txt" ) ) + + with pytest.raises( werkzeug.exceptions.NotFound ): + _get_response_data( file_server.serve_file( "unknown.txt" ) ) + + assert _get_response_data( file_server.serve_file( "subdir/2.txt" ) ).strip() == b"file 2" + with pytest.raises( werkzeug.exceptions.NotFound ): + _get_response_data( file_server.serve_file( "/subdir/2.txt" ) ) + + # try to get a file outside the configured directory + fname = "../new-default-scenario.json" + assert os.path.isfile( os.path.join( base_dir, fname ) ) + with pytest.raises( werkzeug.exceptions.NotFound ): + _get_response_data( file_server.serve_file( fname) ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def test_remote_file_server( webapp ): + """Test serving files from a remote file system.""" + + # initialize + base_url = "{}/static/images".format( _get_base_url( webapp ) ) + file_server = FileServer( base_url ) + base_dir = os.path.join( os.path.split(__file__)[0], "../static/images" ) + + def do_test( fname ): + """Get the specified user file from the remote server and check the response.""" + buf = _get_response_data( file_server.serve_file( fname ) ) + with open( os.path.join( base_dir, fname ), "rb" ) as fp: + assert buf == fp.read() + + # do the tests + with webapp.test_request_context(): + do_test( "hint.gif" ) + do_test( "flags/german.png" ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def _get_base_url( webapp ): + """Get the webapp base URL.""" + url = webapp.url_for( "get_user_file", path="unused" ) + mo = re.search( r"^http://.+?:\d+", url ) + return mo.group() + +def _get_response_data( resp ): + """Get the data from a Flask response.""" + resp.direct_passthrough = False + return resp.get_data() + +# --------------------------------------------------------------------- + +def test_local_user_files( webapp, webdriver ): + """Test serving user files from the local file system.""" + + def do_test( enable_user_files ): #pylint: disable=missing-docstring + + # initialize + init_webapp( webapp, webdriver, + reset = lambda ct: + ct.set_user_files_dir( dtype = "test" if enable_user_files else None ) + ) + + # try getting a user file + try: + url = webapp.url_for( "get_user_file", path="hello.txt" ) + resp = urllib.request.urlopen( url ) + assert enable_user_files # nb: we should only get here if user files are enabled + assert resp.code == 200 + assert resp.read().strip() == b"Yo, wassup!" + assert resp.headers[ "Content-Type" ].startswith( "text/plain" ) + except urllib.error.HTTPError as ex: + assert not enable_user_files # nb: we should only get here if user files are disabled + assert ex.code == 404 + + # try getting a non-existent file (nb: should always fail, whether user files are enabled/disabled) + with pytest.raises( urllib.error.HTTPError ) as exc_info: + url = webapp.url_for( "get_user_file", path="unknown" ) + resp = urllib.request.urlopen( url ) + assert exc_info.value.code == 404 + + # try getting a file in a sub-directory + try: + url = webapp.url_for( "get_user_file", path="subdir/placeholder.png" ) + resp = urllib.request.urlopen( url ) + assert enable_user_files # nb: we should only get here if user files are enabled + assert resp.code == 200 + assert resp.read().startswith( b"\x89PNG\r\n" ) + assert resp.headers[ "Content-Type" ] == "image/png" + except urllib.error.HTTPError as ex: + assert not enable_user_files # nb: we should only get here if user files are disabled + assert ex.code == 404 + + # try getting a file outside the configured directory (nb: should always fail) + fname = os.path.join( os.path.split(__file__)[0], "fixtures/vasl-pieces.txt" ) + assert os.path.isfile( fname ) + with pytest.raises( urllib.error.HTTPError ) as exc_info: + url = webapp.url_for( "get_user_file", path="../vasl-pieces.txt" ) + resp = urllib.request.urlopen( url ) + assert exc_info.value.code == 404 + + # try getting a file with special characters in its name + try: + url = webapp.url_for( "get_user_file", path="amp=& ; plus=+.txt" ) + resp = urllib.request.urlopen( url ) + assert enable_user_files # nb: we should only get here if user files are enabled + assert resp.code == 200 + assert resp.read().strip() == b"special chars" + assert resp.headers[ "Content-Type" ].startswith( "text/plain" ) + except urllib.error.HTTPError as ex: + assert not enable_user_files # nb: we should only get here if user files are disabled + assert ex.code == 404 + + # do the tests with user files enabled/disabled + do_test( True ) + do_test( False ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def test_remote_user_files( webapp, webdriver ): + """Test serving user files from a remote server.""" + + def do_test( enable_user_files ): #pylint: disable=missing-docstring + + # initialize + base_url = "{}/static/images".format( _get_base_url( webapp ) ) + init_webapp( webapp, webdriver, + reset = lambda ct: + ct.set_user_files_dir( dtype = base_url if enable_user_files else None ) + ) + + # try getting a user file + try: + url = webapp.url_for( "get_user_file", path="menu.png" ) + resp = urllib.request.urlopen( url ) + assert enable_user_files # nb: we should only get here if user files are enabled + assert resp.code == 200 + assert resp.read().startswith( b"\x89PNG\r\n" ) + assert resp.headers[ "Content-Type" ] == "image/png" + except urllib.error.HTTPError as ex: + assert not enable_user_files # nb: we should only get here if user files are disabled + assert ex.code == 404 + + # do the tests with user files enabled/disabled + do_test( True ) + do_test( False ) + +# --------------------------------------------------------------------- + +def test_user_file_snippets( webapp, webdriver ): + """Test user files in snippets.""" + + def do_test( enable_user_files ): #pylint: disable=missing-docstring + + # initialize + init_webapp( webapp, webdriver, + reset = lambda ct: ct.set_user_files_dir( dtype = "test" if enable_user_files else None ) + ) + + # set the victory conditions + elem = find_child( "textarea[name='VICTORY_CONDITIONS']" ) + elem.send_keys( "my image: {{USER_FILES}}/subdir/placeholder.png" ) + btn = find_child( "button.generate[data-id='victory_conditions']" ) + btn.click() + def get_user_file_url( clipboard ): #pylint: disable=missing-docstring + # nb: the test template wraps {{VICTORY_CONDITIONS}} in square brackets :-/ + mo = re.search( r"http://.+?/([^]]+)", clipboard ) + return "/" + mo.group(1) + wait_for_clipboard( 2, "/user/subdir/placeholder.png", transform=get_user_file_url ) + + # do the tests with user files enabled/disabled + # NOTE: The user file URL will be inserted into the snippet even if user files are disabled, + # but the URL will 404 when somebody tries to resolve it. + do_test( True ) + do_test( False ) diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index fe2b1ac..a0d62cf 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -57,7 +57,8 @@ def init_webapp( webapp, webdriver, **options ): .set_default_template_pack( dname=None ) \ .set_vasl_mod( vmod=None ) \ .set_vassal_engine( vengine=None ) \ - .set_vo_notes_dir( dtype=None ) + .set_vo_notes_dir( dtype=None ) \ + .set_user_files_dir( dtype=None ) if "reset" in options: options.pop( "reset" )( control_tests ) diff --git a/vasl_templates/webapp/vo_notes.py b/vasl_templates/webapp/vo_notes.py index f3cdf87..929bbc4 100644 --- a/vasl_templates/webapp/vo_notes.py +++ b/vasl_templates/webapp/vo_notes.py @@ -124,28 +124,29 @@ def get_vo_note( vo_type, nat, key ): abort( 404 ) vo_notes = _do_get_vo_notes( vo_type ) fname = vo_notes.get( nat, {} ).get( key ) - fname = _vo_notes_file_server.get_file( fname ) - if not fname: - abort( 404 ) + resp = _vo_notes_file_server.serve_file( fname ) # check if we should resize the file scaling = request.args.get( "scaling" ) # nb: allow individual notes to set their scaling if not scaling: scaling = app.config.get( "CHAPTER_H_NOTE_SCALING", 100 ) - if scaling != 100: + if scaling == 100: + # nope - just return the file as it is + return resp + else: # yup - make it so - with open( fname, "rb" ) as fp: - img = Image.open( fp ) - width = int( img.size[0] * float(scaling) / 100 ) - height = int( img.size[1] * float(scaling) / 100 ) - img = img.resize( (width,height), Image.ANTIALIAS ) + buf = io.BytesIO() + resp.direct_passthrough = False + buf.write( resp.get_data() ) + buf.seek( 0 ) + img = Image.open( buf ) + width = int( img.size[0] * float(scaling) / 100 ) + height = int( img.size[1] * float(scaling) / 100 ) + img = img.resize( (width,height), Image.ANTIALIAS ) buf = io.BytesIO() img.save( buf, format="PNG" ) buf.seek( 0 ) return send_file( buf, mimetype="image/png" ) - else: - # nope - just return the file as it is - return send_file( fname ) # ---------------------------------------------------------------------