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}}'?
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