Skip to content

Commit 2c5a24e

Browse files
committed
misc fixes in apps
1 parent fa392d8 commit 2c5a24e

14 files changed

Lines changed: 288 additions & 178 deletions

File tree

apps/_dashboard/__init__.py

Lines changed: 78 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def logout():
135135

136136
@action("tickets/search")
137137
@action.uses(Logged(session), "dbadmin.html")
138-
def dbadmin():
138+
def tickets_search():
139139
db = error_logger.database_logger.db
140140

141141
def make_grid():
@@ -164,7 +164,13 @@ def make_grid():
164164
def dbadmin(app_name, db_name, table_name):
165165
themes = get_available_themes()
166166
module = Reloader.MODULES.get(app_name)
167-
db = getattr(module, db_name)
167+
if module is None:
168+
raise HTTP(404)
169+
db = getattr(module, db_name, None)
170+
# Reject any module attribute that is not a DAL instance to avoid
171+
# leaking arbitrary objects exposed by the app.
172+
if not isinstance(db, DAL) or table_name not in db.tables:
173+
raise HTTP(404)
168174

169175
def make_grid():
170176
make_safe(db)
@@ -205,14 +211,13 @@ def make_grid():
205211
@catch_errors
206212
def info():
207213
vars = [{"name": "python", "version": sys.version}]
208-
for module in sorted(sys.modules):
209-
if not "." in module:
210-
try:
211-
m = __import__(module)
212-
if "__version__" in dir(m):
213-
vars.append({"name": module, "version": m.__version__})
214-
except ImportError:
215-
pass
214+
for name in sorted(sys.modules):
215+
if "." in name:
216+
continue
217+
module = sys.modules.get(name)
218+
version = getattr(module, "__version__", None)
219+
if version is not None:
220+
vars.append({"name": name, "version": str(version)})
216221
return {"status": "success", "payload": vars}
217222

218223
@action("routes")
@@ -250,8 +255,13 @@ def apps():
250255
def delete_app(name):
251256
"""delete the app"""
252257
path = os.path.join(FOLDER, name)
253-
timestamp = datetime.datetime.now().strftime("%Y-%m-%d")
254-
archive = os.path.join(FOLDER, "%s.%s.zip" % (name, timestamp))
258+
# Keep backups out of the apps folder so they don't show up in
259+
# listings or get re-imported by py4web.
260+
backups_dir = os.path.join(FOLDER, "_backups")
261+
if not os.path.exists(backups_dir):
262+
os.makedirs(backups_dir)
263+
timestamp = datetime.datetime.now().strftime("%Y-%m-%dT%H%M%S")
264+
archive = os.path.join(backups_dir, "%s.%s" % (name, timestamp))
255265
if os.path.exists(path) and os.path.isdir(path):
256266
# zip the folder, just in case
257267
shutil.make_archive(archive, "zip", path)
@@ -264,12 +274,13 @@ def delete_app(name):
264274
@session_secured
265275
def new_file(name, file_name):
266276
"""creates a new file"""
267-
path = os.path.join(FOLDER, name)
268-
form = request.json
269-
if not os.path.exists(path):
277+
app_path = os.path.join(FOLDER, name)
278+
if not os.path.exists(app_path):
270279
return {"status": "success", "payload": "App does not exist"}
271-
full_path = os.path.join(path, file_name)
272-
if not full_path.startswith(path + os.sep):
280+
# safe_join normalises ``..`` segments and refuses anything that
281+
# escapes the app directory.
282+
full_path = safe_join(app_path, file_name)
283+
if not full_path:
273284
return {"status": "success", "payload": "Invalid path"}
274285
if os.path.exists(full_path):
275286
return {"status": "success", "payload": "File already exists"}
@@ -288,7 +299,9 @@ def new_file(name, file_name):
288299
@catch_errors
289300
def walk(path):
290301
"""Returns a nested folder structure as a tree"""
291-
top = os.path.join(FOLDER, path)
302+
top = safe_join(FOLDER, path)
303+
if not top or not os.path.isdir(top):
304+
return {"status": "error", "message": "folder does not exist"}
292305
filter = None
293306
filter_file = os.path.join(top, PY4WEB_IGNORE)
294307
if os.path.exists(filter_file):
@@ -311,10 +324,10 @@ def visible(root, name, filter=filter):
311324
or os.path.basename(root) == "uploads"
312325
)
313326

314-
if not os.path.exists(top) or not os.path.isdir(top):
315-
return {"status": "error", "message": "folder does not exist"}
316327
store = {}
317-
for root, dirs, files in os.walk(top, topdown=False, followlinks=True):
328+
# followlinks=False avoids cycles when an app contains symlinks
329+
# back into its own tree.
330+
for root, dirs, files in os.walk(top, topdown=False, followlinks=False):
318331
if visible(*os.path.split(root)):
319332
store[root] = {
320333
"dirs": list(
@@ -350,23 +363,32 @@ def load_bytes(path):
350363
@action("packed/<path:path>")
351364
@session_secured
352365
def packed(path):
353-
"""Packs an app"""
354-
appname = path.split(".")[-2]
355-
# some security
366+
"""Pack an app into a downloadable zip.
367+
368+
``path`` is expected to be ``<appname>.zip`` — robustly take the
369+
portion before the final extension as the app name.
370+
"""
371+
if "/" in path:
372+
raise HTTP(400)
373+
appname = os.path.splitext(os.path.basename(path))[0]
356374
app_dir = os.path.join(FOLDER, appname)
357-
if "/" in path or appname.startswith(".") or not os.path.exists(app_dir):
375+
if appname.startswith(".") or not os.path.isdir(app_dir):
358376
raise HTTP(400)
377+
skip_dir_names = {"__pycache__", ".git", ".hg", ".svn", "node_modules"}
359378
store = io.BytesIO()
360379
zip = zipfile.ZipFile(store, mode="w", compression=zipfile.ZIP_DEFLATED)
361-
for root, dirs, files in os.walk(app_dir, topdown=False):
362-
if not root.startswith("."):
363-
for name in files:
364-
if not (
365-
name.endswith("~") or name.endswith(".pyc") or name[:1] in "#."
366-
):
367-
filename = os.path.join(root, name)
368-
short = filename[len(app_dir + os.path.sep) :]
369-
zip.write(filename, short)
380+
for root, dirs, files in os.walk(app_dir, topdown=True):
381+
# mutate `dirs` in place so os.walk skips them entirely
382+
dirs[:] = [
383+
d for d in dirs
384+
if d not in skip_dir_names and not d.startswith(".")
385+
]
386+
for name in files:
387+
if name.endswith("~") or name.endswith(".pyc") or name[:1] in "#.":
388+
continue
389+
filename = os.path.join(root, name)
390+
short = filename[len(app_dir + os.path.sep):]
391+
zip.write(filename, short)
370392
zip.close()
371393
data = store.getvalue()
372394
response.headers["Content-Type"] = "application/zip"
@@ -486,8 +508,10 @@ def save(path, reload_app=True):
486508
"""Saves a file"""
487509
app_name = path.split("/")[0]
488510
path = safe_join(FOLDER, path) or abort()
511+
body = json.load(request.body)
512+
if not isinstance(body, str):
513+
raise HTTP(400, body="save expects a JSON string body")
489514
with open(path, "wb") as myfile:
490-
body = json.load(request.body)
491515
myfile.write(body.encode("utf8"))
492516
if reload_app:
493517
Reloader.import_app(app_name)
@@ -577,14 +601,22 @@ def new_app():
577601
# Below here work in progress
578602
#
579603

604+
def _project_repo(project):
605+
"""Return the absolute path to a project's git repo, or 400."""
606+
repo = safe_join(FOLDER, project)
607+
if not repo or not is_git_repo(repo):
608+
raise HTTP(400)
609+
return repo
610+
580611
@action("gitlog/<project>")
581612
@action.uses(Logged(session), "gitlog.html")
582613
@catch_errors
583614
def gitlog(project):
584-
if not is_git_repo(os.path.join(FOLDER, project)):
615+
repo = safe_join(FOLDER, project)
616+
if not repo or not is_git_repo(repo):
585617
return "Project is not a GIT repo"
586-
branches = get_branches(cwd=os.path.join(FOLDER, project))
587-
commits = get_commits(cwd=os.path.join(FOLDER, project))
618+
branches = get_branches(cwd=repo)
619+
commits = get_commits(cwd=repo)
588620
return dict(
589621
status="success",
590622
commits=commits,
@@ -595,35 +627,26 @@ def gitlog(project):
595627

596628
@authenticated.callback()
597629
def checkout(project, commit):
598-
if not is_git_repo(project):
599-
raise HTTP(400)
600-
run("git stash", cwd=os.path.join(FOLDER, project))
601-
run("git checkout " + commit, cwd=os.path.join(FOLDER, project))
630+
repo = _project_repo(project)
631+
run("git stash", cwd=repo)
632+
run("git checkout " + commit, cwd=repo)
602633
Reloader.import_app(project)
603634

604635
@action("swapbranch/<project>", method="POST")
605636
@action.uses(Logged(session))
606637
def swapbranch(project):
607-
if not is_git_repo(project):
608-
raise HTTP(400)
609-
610-
branch = (
611-
request.forms.get("branches") if request.forms.get("branches") else "master"
612-
)
638+
_project_repo(project)
639+
branch = request.forms.get("branches") or "master"
613640
# swap branches then go back to gitlog so new commits load
614641
checkout(project, branch)
615642
redirect(URL("gitlog", project))
616643

617644
@action("gitshow/<project>/<commit>")
618645
@action.uses(Logged(session), "gitshow.html")
619646
def gitshow(project, commit):
620-
if not is_git_repo(project):
621-
raise HTTP(400)
622-
flag = request.params.get("showfull")
623-
opt = ""
624-
if flag == "true":
625-
opt = " -U9999"
626-
patch = run("git show " + commit + opt, cwd=os.path.join(FOLDER, project))
647+
repo = _project_repo(project)
648+
opt = " -U9999" if request.params.get("showfull") == "true" else ""
649+
patch = run("git show " + commit + opt, cwd=repo)
627650
return diff2kryten(patch)
628651

629652

apps/_dashboard/utils.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
---------------
1010
"""
1111

12-
import glob
1312
import gzip
14-
import logging
1513
import os
1614
import re
1715
import shutil
@@ -155,6 +153,9 @@ def create_app(path, model="scaffold.w3p"):
155153

156154

157155
def make_safe(db):
156+
"""Wrap callable Field.default / Field.update so the dashboard can render
157+
rows even if the app's own callbacks raise."""
158+
158159
def make_safe_field(func):
159160
def wrapper():
160161
try:
@@ -164,6 +165,8 @@ def wrapper():
164165
print("Warning: _dashboard trying to access a forbidden method of app")
165166
return None
166167

168+
return wrapper
169+
167170
for table in db:
168171
for field in table:
169172
if callable(field.default):
@@ -177,14 +180,18 @@ def run(command, cwd="."):
177180
return subprocess.check_output(command.split(), cwd=cwd).decode(errors="ignore")
178181

179182

180-
def get_commits(project, cwd="."):
181-
"""List of git commits for the project"""
183+
def get_commits(cwd="."):
184+
"""List of git commits for the repository at ``cwd``."""
182185
output = run("git log", cwd=cwd)
183186
commits = []
187+
commit = None
184188
for line in output.split("\n"):
185189
if line.startswith("commit "):
186190
commit = {"code": line[7:], "message": "", "author": "", "date": ""}
187191
commits.append(commit)
192+
elif commit is None:
193+
# Skip stray lines that appear before the first 'commit ' header.
194+
continue
188195
elif line.startswith("Author: "):
189196
commit["author"] = line[8:]
190197
elif line.startswith("Date: "):

apps/_scaffold/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# check compatibility
22
import py4web
33

4-
assert py4web.check_compatible("1.20190709.1")
4+
# Use a real check (not assert) so the guard survives ``python -O``.
5+
if not py4web.check_compatible("1.20190709.1"):
6+
raise RuntimeError("py4web 1.20190709.1+ required")
57

68
# by importing controllers you expose the actions defined in it
79
from . import controllers

apps/_scaffold/common.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@
8484

8585
session = Session(secret=settings.SESSION_SECRET_KEY, storage=DBStore(db))
8686

87+
else:
88+
raise ValueError(
89+
"Unknown SESSION_TYPE %r in settings.py "
90+
"(expected one of: cookies, redis, memcache, database)"
91+
% (settings.SESSION_TYPE,)
92+
)
93+
8794
# #######################################################
8895
# Instantiate the object and actions that handle auth
8996
# #######################################################
@@ -116,10 +123,11 @@
116123
)
117124

118125
# #######################################################
119-
# Create a table to tag users as group members
126+
# Create a table to tag users as group members. ``groups`` is always
127+
# defined (possibly None) so optional plugins (LDAP, etc.) can reference
128+
# it without risking a NameError if auth has no database.
120129
# #######################################################
121-
if auth.db:
122-
groups = Tags(db.auth_user, "groups")
130+
groups = Tags(db.auth_user, "groups") if auth.db else None
123131

124132
# #######################################################
125133
# Enable optional auth plugin

apps/_scaffold/controllers.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,19 @@
2525
Warning: Fixtures MUST be declared with @action.uses({fixtures}) else your app will result in undefined behavior
2626
"""
2727

28-
from yatl.helpers import A
28+
from py4web import action, redirect, HTTP, URL
2929

30-
from py4web import URL, abort, action, redirect, request
31-
32-
from .common import (
33-
T,
34-
auth,
35-
authenticated,
36-
cache,
37-
db,
38-
flash,
39-
logger,
40-
session,
41-
unauthenticated,
42-
)
30+
from .common import T, auth, cache, db, session, Field
4331

4432

4533
@action("index")
4634
@action.uses("index.html", auth, T)
4735
def index():
4836
user = auth.get_user()
49-
message = T("Hello {first_name}").format(**user) if user else T("Hello")
37+
if user:
38+
message = T("Hello {first_name}").format(
39+
first_name=user.get("first_name", "")
40+
)
41+
else:
42+
message = T("Hello")
5043
return dict(message=message)

apps/_scaffold/scheduler.py

Whitespace-only changes.

apps/_scaffold/tasks.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def my_task(**inputs):
1212
try:
1313
# do something here
1414
db.commit()
15-
except:
15+
except Exception:
1616
# rollback on failure
1717
db.rollback()
1818
return {}
@@ -45,21 +45,19 @@ def my_task(**inputs):
4545

4646
from celery import Celery
4747

48-
# to use "from .common import scheduler" and then use it according
49-
# to celery docs, examples in tasks.py
5048
celery_scheduler = Celery(
5149
"apps.%s.tasks" % settings.APP_NAME, broker=settings.CELERY_BROKER
5250
)
5351

5452
# register your tasks
55-
@scheduler.task
53+
@celery_scheduler.task
5654
def my_task():
5755
# reconnect to database
5856
db._adapter.reconnect()
5957
try:
6058
# do something here
6159
db.commit()
62-
except:
60+
except Exception:
6361
# rollback on failure
6462
db.rollback()
6563

0 commit comments

Comments
 (0)