diff --git a/.gitignore b/.gitignore index 327bee3..64a862c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ oldref/* examples/*.db examples/uploads/* *.db +uploads/* diff --git a/.travis.yml b/.travis.yml index 7035e24..b8c03b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,11 @@ language: python +dist: bionic python: - - "3.8" - - "3.8-dev" # 3.8 development branch + - "3.7-dev" + - "3.8-dev" - "3.9" - - "3.9-dev" # 3.9 development branch - - "3.10" - - "3.10-dev" # 3.10 + - "3.9-dev" + - "3.10" # 3.10 #- "nightly" # nightly build # command to install dependencies install: @@ -15,4 +15,4 @@ install: - pip install -e . # command to run tests script: - - bash runtest.sh + - bash test_run.sh diff --git a/examples/run.sh b/dev_run.sh similarity index 87% rename from examples/run.sh rename to dev_run.sh index b01227b..50be992 100755 --- a/examples/run.sh +++ b/dev_run.sh @@ -1,6 +1,8 @@ #!/bin/sh -. ../bin/activate +. bin/activate + +cd examples #export FLASK_APP=auth_basic #export FLASK_APP=auth_database diff --git a/doomer.jpg b/doomer.jpg new file mode 100644 index 0000000..1008d26 Binary files /dev/null and b/doomer.jpg differ diff --git a/examples/auth_withrole_test.py b/examples/auth_withrole_test.py index c9c66c9..4d185c5 100644 --- a/examples/auth_withrole_test.py +++ b/examples/auth_withrole_test.py @@ -29,6 +29,7 @@ def test_admin_feature(client): assert resp.status_code == 200 assert b'welcome jason, your role is admin' in resp.data + resp = client.post('/user/add', data={'username': 'newuser', 'password':'newpass', 'password_confirm': 'newpass', 'role': 'no role'}, follow_redirects=True) assert len(resp.history) == 1 assert resp.status_code == 200 @@ -38,6 +39,13 @@ def test_admin_feature(client): assert b'newuser (no role)' in resp.data assert b'green">useradd successful' in resp.data + resp = client.get('/user/mod') + assert resp.status_code == 400 + + resp = client.get('/user/mod?id=newuser') + assert resp.status_code == 200 + assert b'no role' in resp.data + resp = client.post('/user/mod?id=newuser', data={'role': 'admin'}, follow_redirects=True) assert len(resp.history) == 1 assert resp.status_code == 200 @@ -47,6 +55,13 @@ def test_admin_feature(client): assert b'newuser (admin)' in resp.data assert b'green">usermod successful' in resp.data + resp = client.get('/user/del') + assert resp.status_code == 400 + + resp = client.get('/user/del?id=newuser') + assert resp.status_code == 200 + assert b'remove user newuser?' in resp.data + resp = client.post('/user/del?id=newuser', follow_redirects=True) assert len(resp.history) == 1 assert resp.status_code == 200 diff --git a/examples/doomer.jpg b/examples/doomer.jpg new file mode 100644 index 0000000..1008d26 Binary files /dev/null and b/examples/doomer.jpg differ diff --git a/examples/starter_1/__init__.py b/examples/starter_1/__init__.py index bc34d7d..4428f78 100644 --- a/examples/starter_1/__init__.py +++ b/examples/starter_1/__init__.py @@ -13,6 +13,8 @@ from .models import MyRole, MyUser, Project, my_declarative_base from .auth import MyAuth +from sqlalchemy.ext.declarative import declarative_base + def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) app.secret_key = 'v3rypowerfuls3cret, or not. CHANGE THIS!@' diff --git a/examples/starter_1/models.py b/examples/starter_1/models.py index c7da285..c27de6c 100644 --- a/examples/starter_1/models.py +++ b/examples/starter_1/models.py @@ -1,10 +1,11 @@ +import os import jwt import json from flask import current_app from werkzeug.datastructures import FileStorage from flask_arch.cms import SQLContent, DEFAULT -from flask_arch.cms import file_storage, SIZE_MB +from flask_arch.cms import file_storage, SIZE_MB, SIZE_KB from flask_arch.user import SQLUserWithRole, SQLRole from flask_arch.exceptions import UserError from flask_arch.utils import parse_boolean @@ -15,10 +16,17 @@ my_declarative_base = declarative_base() +# TODO: remove this in production, only used for unit-testing +upload_d = 'uploads' +if os.environ.get('UPLOAD_DIR'): + upload_d = os.environ['UPLOAD_DIR'] + print(upload_d) + class MyRole(SQLRole, my_declarative_base): def set_json_privileges(self, jsonstr): po = json.loads(jsonstr) + self.privileges = '{}' for k, v in po.items(): self.set_privilege(k, v) @@ -34,7 +42,7 @@ def modify(self, rp, actor): # using file_storage for storing profile picture (EXAMPLE OF SINGULAR FILE MANAGEMENT) -@file_storage(max_size=5*SIZE_MB, regex_whitelist=['jpe?g$', 'png$']) +@file_storage(upload_dir=upload_d, max_size=80*SIZE_KB, regex_whitelist=['jpe?g$', 'png$']) class MyUser(SQLUserWithRole): userid = 'email' # indicate the user will login with 'email' as its identifier @@ -97,7 +105,7 @@ def profile_img(cls): # only allow files ending in jpg, jpeg and png # store the files separately for each content, with subdirectory created using the 'name' attribute # EXAMPLE OF MULTIPLE FILE HANDLING -@file_storage(max_size=5*SIZE_MB, regex_whitelist=['jpe?g$', 'png$'], subdir_key='name') +@file_storage(upload_dir=upload_d, max_size=5*SIZE_MB, regex_whitelist=['jpe?g$', 'png$'], subdir_key='name') class Project(SQLContent, my_declarative_base): __tablename__ = "project" diff --git a/examples/starter_1/templates/users/del.html b/examples/starter_1/templates/users/del.html index c5bbc4b..9a1a640 100644 --- a/examples/starter_1/templates/users/del.html +++ b/examples/starter_1/templates/users/del.html @@ -7,6 +7,7 @@

are you sure you want to remove user '{{target.name}}'?

+ {% include "includes/csrf_token.html" %}
diff --git a/examples/starter_1_test.py b/examples/starter_1_test.py new file mode 100644 index 0000000..b90b0a0 --- /dev/null +++ b/examples/starter_1_test.py @@ -0,0 +1,220 @@ +import pytest +import os +import re +import tempfile +import shutil + +# hacky way to use a different upload_dir when testing +tmpd = tempfile.mkdtemp() +os.environ['UPLOAD_DIR'] = tmpd + +from werkzeug.datastructures import FileStorage +from starter_1 import create_app + +@pytest.fixture() +def app(): + db_fd, db_file = tempfile.mkstemp() + db_uri = 'sqlite:///%s' % db_file + app = create_app({ + 'TESTING': True, + 'DBURI': db_uri, + }) + yield app + os.close(db_fd) + os.unlink(db_file) + +@pytest.fixture() +def client(app): + return app.test_client() + +def parse_csrf(resp): + mt = re.search(b'"csrf_token" value="(\\S+)"', resp.data) + assert mt + csrf = mt.group(1).decode() + return csrf + +def login(client, email, passw): + resp = client.get('/auth/login') + csrf = parse_csrf(resp) + + resp = client.post('/auth/login', data={'email':email, 'password':passw, 'csrf_token':csrf}, follow_redirects=True) + assert resp.status_code == 200 + +def logout(client): + resp = client.get('/auth/logout', follow_redirects=True) + assert resp.status_code == 200 + +#TODO: allow multi-testing by fixing 'is already defined for this MetaData instance' +def test_all(client): + login(client, 'admin@test.d', 'hunter2') + + # test obtain profile picture + resp = client.get('/auth/profile') + mt = re.search(b'src="/auth/file\\?filename=(\\S+\\.jpg)"', resp.data) + assert mt + profimg_name = mt.group(1).decode() + resp = client.get(f'/auth/file?filename={profimg_name}') + assert resp.status_code == 200 + + test_file = FileStorage(stream=open('doomer.jpg', "rb"),) + resp = client.get('/auth/renew') + csrf = parse_csrf(resp) + resp = client.post('/auth/renew', data={'profile_img':test_file, 'csrf_token':csrf}, content_type='multipart/form-data', follow_redirects=True) + assert resp.status_code == 400 + assert b'invalid file size' in resp.data + + resp = client.get(f'/auth/file?filename={profimg_name}') + assert resp.status_code == 200 + + test_file = FileStorage(stream=open('wojak.jpg', "rb"),) + resp = client.get('/auth/renew') + csrf = parse_csrf(resp) + resp = client.post('/auth/renew', data={'profile_img':test_file, 'csrf_token':csrf}, content_type='multipart/form-data', follow_redirects=True) + assert resp.status_code == 200 + mt = re.search(b'src="/auth/file\\?filename=(\\S+\\.jpg)"', resp.data) + assert mt + new_profimg_name = mt.group(1).decode() + assert new_profimg_name != profimg_name + + resp = client.get(f'/auth/file?filename={profimg_name}') + assert resp.status_code == 404 + assert b'Not Found' in resp.data + + resp = client.get(f'/auth/file?filename={new_profimg_name}') + assert resp.status_code == 200 + + resp = client.get('/role/list') + assert b'"project.view": 1' in resp.data + + resp = client.get('/role/insert') + csrf = parse_csrf(resp) + + resp = client.post('/role/insert', data={'name':'NEW_ROLE_TEST', 'privileges':'{"NEW_ROLE_PRIV": 1}', 'csrf_token':csrf}, follow_redirects=True) + assert b'NEW_ROLE_TEST' in resp.data and b'NEW_ROLE_PRIV' in resp.data + + assert resp.status_code == 200 + + resp = client.get('/role/update') + assert resp.status_code == 400 + + resp = client.get('/role/delete') + assert resp.status_code == 400 + + resp = client.get('/project/list') + assert resp.status_code == 200 + + resp = client.get('/role/update?id=1') + csrf = parse_csrf(resp) + + resp = client.post('/role/update?id=1', data={'privileges':'{"user.view": 1, "user.add": 1, "user.mod": 1, "user.del": 1, "role.view": 1, "role.insert": 1, "role.update": 1, "role.delete": 1, "project.view": 0, "project.insert": 1, "project.update": 1, "project.delete": 1}', 'csrf_token': csrf}, follow_redirects=True) + assert resp.status_code == 200 + + resp = client.get('/role/update?id=2') + csrf = parse_csrf(resp) + resp = client.post('/role/update?id=2', data={'privileges':'{}', 'csrf_token': csrf}, follow_redirects=True) + assert resp.status_code == 200 + assert b'"project.view"' not in resp.data + csrf = parse_csrf(resp) + + resp = client.get('/role/update?id=2') + csrf = parse_csrf(resp) + resp = client.post('/role/update?id=2', data={'privileges':'{"project.view":1, "project.insert":1, "project.update":1}', 'csrf_token': csrf}, follow_redirects=True) + assert resp.status_code == 200 + + resp = client.get('/project/list') + assert resp.status_code == 403 + + resp = client.post('/role/delete?id=3', data={'csrf_token': csrf}, follow_redirects=True) + assert resp.status_code == 200 + assert b'NEW_ROLE_TEST' not in resp.data and b'NEW_ROLE_PRIV' not in resp.data + + resp = client.post('/role/update?id=1', data={'privileges':'{"user.view": 1, "user.add": 1, "user.mod": 1, "user.del": 1, "role.view": 1, "role.insert": 1, "role.update": 1, "role.delete": 1, "project.view": 1, "project.insert": 1, "project.update": 1, "project.delete": 1}', 'csrf_token': csrf}, follow_redirects=True) + assert resp.status_code == 200 + + # MULTI-FILE UPLOAD + resp = client.get('/project/insert') + csrf = parse_csrf(resp) + + invalid_file = FileStorage(stream=open('dev_run.sh', "rb"),) + resp = client.post('/project/insert', data={'name':'UPLOAD_TEST', 'csrf_token': csrf, 'project_files':[invalid_file]}, content_type='multipart/form-data', follow_redirects=True) + assert resp.status_code == 400 + assert b'invalid file name' in resp.data + + invalid_file = FileStorage(stream=open('dev_run.sh', "rb"),) + + resp = client.get('/project/insert') + csrf = parse_csrf(resp) + + test_file = FileStorage(stream=open('doomer.jpg', "rb"),) + resp = client.post('/project/insert', data={'name':'UPLOAD_TEST', 'csrf_token': csrf, 'project_files':[test_file]}, content_type='multipart/form-data', follow_redirects=True) + assert b'UPLOAD_TEST' in resp.data + + resp = client.get('/project/view?id=1') + mt = re.search(b'\\.jpg">(\\S+\\.jpg)', resp.data) + assert mt + filename = mt.group(1).decode() + + resp = client.get(f'/project/file?id=1&filename={filename}') + assert resp.status_code == 200 + + resp = client.get('/project/update?id=1') + csrf = parse_csrf(resp) + + test_file = FileStorage(stream=open('wojak.jpg', "rb"),) + resp = client.post('/project/update?id=1', data={'project_files':[test_file], 'csrf_token':csrf}, follow_redirects=True, content_type='multipart/form-data') + assert resp.status_code == 200 + + resp = client.get('/project/view?id=1') + mt = re.findall(b'\\.jpg">(\\S+\\.jpg)', resp.data) + assert len(mt) == 2 + + resp = client.get('/project/update?id=1') + csrf = parse_csrf(resp) + + resp = client.post('/project/update?id=1', data={filename:'delete', 'csrf_token':csrf}, follow_redirects=True, content_type='multipart/form-data') + assert resp.status_code == 200 + + resp = client.get('/project/view?id=1') + mt = re.findall(b'\\.jpg">(\\S+\\.jpg)', resp.data) + assert len(mt) == 1 + assert filename not in mt + left_over = mt[0] + + logout(client) + + login(client, 'user@test.d', 'asdasd') + + resp = client.get('/role/list') + assert resp.status_code == 403 + assert b'403 Forbidden' in resp.data + + resp = client.get('/user/list') + assert resp.status_code == 403 + assert b'403 Forbidden' in resp.data + + resp = client.get('/project/list') + csrf = parse_csrf(resp) + assert resp.status_code == 200 + + resp = client.get('/project/view?id=1', follow_redirects=True) + assert b'cannot view, no ownership' in resp.data + csrf = parse_csrf(resp) + + resp = client.get('/project/update?id=1', follow_redirects=True) + assert b'cannot view, no ownership' in resp.data + + resp = client.post('/project/update?id=1', data={left_over:'delete', 'csrf_token':csrf}, content_type='multipart/form-data', follow_redirects=True) + assert resp.status_code == 400 + assert b'cannot modify, no ownership' + + resp = client.get('/project/delete?id=1', follow_redirects=True) + assert b'403 Forbidden' in resp.data + + resp = client.post('/project/delete?id=1', data={'csrf_token':csrf}, follow_redirects=True) + assert resp.status_code == 403 + assert b'cannot delete, no ownership' + + logout(client) + + # dont with testing, remove temporary upload directory + shutil.rmtree(tmpd) diff --git a/flask_arch/blocks.py b/flask_arch/blocks.py index cfb7ca2..5eeca48 100644 --- a/flask_arch/blocks.py +++ b/flask_arch/blocks.py @@ -2,7 +2,7 @@ from .utils import ensure_type, ensure_callable import traceback -from jinja2.exceptions import TemplateNotFound +from jinja2.exceptions import TemplateNotFound, UndefinedError from flask import redirect, url_for, flash, render_template, request, abort, current_app, make_response # late-binding vs. early binding @@ -150,7 +150,13 @@ def _handle_user_error(self, e): if e.reroute: return self.reroute() else: - return self.render(), e.code + try: + return self.render(), e.code + except UndefinedError: + # template variable undefined, + # likely something bad has happened + # (i.e., user posting to a route they're not supposed to) + self.abort(400) def client_error(self, e): if current_app.debug: @@ -160,6 +166,9 @@ def client_error(self, e): if isinstance(e, exceptions.UserError): # is a user error return self._handle_user_error(e) + elif isinstance(e, FileNotFoundError): + # file not found, 404 + self.abort(404) self.abort(400) # response 4xx diff --git a/flask_arch/cms/blocks.py b/flask_arch/cms/blocks.py index 1b96d69..c07441f 100644 --- a/flask_arch/cms/blocks.py +++ b/flask_arch/cms/blocks.py @@ -59,7 +59,6 @@ def route(self): except Exception as e: return self.client_error(e) - class PrepExecBlock(ManageBlock): def __init__(self, keyword, content_manager, **kwargs): @@ -145,7 +144,8 @@ class DelBlock(PrepExecBlock): def initial(self): rp = RequestParser(request) c = self.content_manager.query(rp) - return self.render(target=c) + cv = c.view(rp, current_user) + return self.render(target=cv) def prepare(self, rp): c = self.content_manager.query(rp) diff --git a/flask_arch/cms/files.py b/flask_arch/cms/files.py index 6db7c7d..dccc3a7 100644 --- a/flask_arch/cms/files.py +++ b/flask_arch/cms/files.py @@ -54,9 +54,8 @@ def get_file_path(self, filename): store_dir = self.get_store_dir() filename = secure_filename(filename) path = os.path.join(store_dir, filename) - print(path) if not os.path.isfile(path): - raise INVALID_FNAME + raise FileNotFoundError('file not found') return path cls.get_file_path = get_file_path diff --git a/flask_arch/user/base.py b/flask_arch/user/base.py index 977b649..097629d 100644 --- a/flask_arch/user/base.py +++ b/flask_arch/user/base.py @@ -18,7 +18,8 @@ def set_privilege(self, privilege, set_1=True): if set_1: pd[privilege] = 1 else: - pd.pop(privilege) + if privilege in pd: + pd.pop(privilege) self.privileges = json.dumps(pd) def has_privilege(self, privilege): diff --git a/requirements.txt b/requirements.txt index 9e2b3c7..7d1a1bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,6 @@ -attrs==21.4.0 -blinker==1.4 -click==8.0.4 -Flask==2.0.3 --e git+ssh://git@github.com/ToraNova/flask-arch.git@fbcfed2a9575e4fadd6d47fc809ec951727a498f#egg=flask_arch -Flask-Login==0.5.0 +Flask>=1.1.4 Flask-WTF==1.0.0 -greenlet==1.1.2 -iniconfig==1.1.1 -itsdangerous==2.1.0 -Jinja2==3.0.3 -MarkupSafe==2.1.0 -packaging==21.3 -pluggy==1.0.0 -py==1.11.0 +Flask-Login==0.5.0 PyJWT==2.3.0 -pyparsing==3.0.7 -pytest==7.0.1 SQLAlchemy==1.4.31 -tomli==2.0.1 -Werkzeug==2.0.3 -WTForms==3.0.1 +pytest==7.0.1 diff --git a/setup.py b/setup.py index 87079e7..b5fa8c3 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -_version = '0.0.7' +_version = '0.1.0' setup( name='flask-arch', diff --git a/runtest.sh b/test_run.sh similarity index 100% rename from runtest.sh rename to test_run.sh diff --git a/wojak.jpg b/wojak.jpg new file mode 100644 index 0000000..9c7a64a Binary files /dev/null and b/wojak.jpg differ