diff --git a/.gitignore b/.gitignore index c9341231..b5de73ec 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,6 @@ node_modules coverage .aws-sam layer +[#]* +.[#]* +sam-installation diff --git a/Makefile b/Makefile index de11c850..b4730143 100644 --- a/Makefile +++ b/Makefile @@ -10,20 +10,18 @@ JS_FILES := $(TS_FILES:.ts=.js) ################################################################ # Create the virtual enviornment for testing and CI/CD -ACTIVATE = . venv/bin/activate REQ = venv/pyvenv.cfg -PY=python3.11 -PYTHON=$(ACTIVATE) ; $(PY) -PIP_INSTALL=$(PYTHON) -m pip install --no-warn-script-location -ETC=etc -APP_ETC=deploy/app/etc -DBMAINT=-m deploy.app.dbmaint +PYTHON=venv/bin/python +PIP_INSTALL=venv/bin/pip install --no-warn-script-location +ROOT_ETC=etc +DEPLOY_ETC=deploy/etc +DBMAINT=dbutil.py # Note: PLANTTRACER_CREDENTIALS must be set venv: @echo install venv for the development environment - $(PY) -m venv venv + python3 -m venv venv $(PYTHON) -m pip install --upgrade pip if [ -r requirements.txt ]; then $(PIP_INSTALL) -r requirements.txt ; fi if [ -r deploy/requirements.txt ]; then $(PIP_INSTALL) -r deploy/requirements.txt ; fi @@ -66,10 +64,11 @@ check: PYLINT_OPTS:=--output-format=parseable --rcfile .pylintrc --fail-under=$(PYLINT_THRESHOLD) --verbose pylint: $(REQ) - $(ACTIVATE) ; $(PY) -m pylint $(PYLINT_OPTS) deploy + $(PYTHON) -m pylint $(PYLINT_OPTS) deploy + $(PYTHON) -m pylint $(PYLINT_OPTS) *.py pylint-tests: $(REQ) - $(ACTIVATE) ; $(PY) -m pylint $(PYLINT_OPTS) --init-hook="import sys;sys.path.append('tests');import conftest" tests + $(PYTHON) -m pylint $(PYLINT_OPTS) --init-hook="import sys;sys.path.append('tests');import conftest" tests mypy: mypy --show-error-codes --pretty --ignore-missing-imports --strict . @@ -138,10 +137,10 @@ pytest-quiet: $(PYTHON) -m pytest --log-cli-level=ERROR test-schema-upgrade: - $(PYTHON) $(DBMAINT) --rootconfig $(ETC)/mysql-root-localhost.ini --dropdb test_db1 || echo database does not exist - $(PYTHON) $(DBMAINT) --rootconfig $(ETC)/mysql-root-localhost.ini --createdb test_db1 --schema $(ETC)/schema_0.sql - $(PYTHON) $(DBMAINT) --rootconfig $(ETC)/mysql-root-localhost.ini --upgradedb test_db1 - $(PYTHON) $(DBMAINT) --rootconfig $(ETC)/mysql-root-localhost.ini --dropdb test_db1 + $(PYTHON) $(DBMAINT) --rootconfig $(ROOT_ETC)/mysql-root-localhost.ini --dropdb test_db1 || echo database does not exist + $(PYTHON) $(DBMAINT) --rootconfig $(ROOT_ETC)/mysql-root-localhost.ini --createdb test_db1 --schema $(ROOT_ETC)/schema_0.sql + $(PYTHON) $(DBMAINT) --rootconfig $(ROOT_ETC)/mysql-root-localhost.ini --upgradedb test_db1 + $(PYTHON) $(DBMAINT) --rootconfig $(ROOT_ETC)/mysql-root-localhost.ini --dropdb test_db1 pytest-coverage: $(REQ) $(PIP_INSTALL) codecov pytest pytest_cov @@ -164,25 +163,25 @@ debug: debug-local: @echo run bottle locally in debug mode, storing new data in database - PLANTTRACER_CREDENTIALS=$(ETC)/credentials-localhost.ini $(DEBUG) --storelocal + PLANTTRACER_CREDENTIALS=$(APP_ETC)/credentials-localhost.ini $(DEBUG) --storelocal debug-single: @echo run bottle locally in debug mode single-threaded - PLANTTRACER_CREDENTIALS=$(ETC)/credentials-localhost.ini $(DEBUG) + PLANTTRACER_CREDENTIALS=$(APP_ETC)/credentials-localhost.ini $(DEBUG) debug-multi: @echo run bottle locally in debug mode multi-threaded - PLANTTRACER_CREDENTIALS=$(ETC)/credentials-localhost.ini $(DEBUG) --multi + PLANTTRACER_CREDENTIALS=$(APP_ETC)/credentials-localhost.ini $(DEBUG) --multi debug-dev: @echo run bottle locally in debug mode, storing new data in S3, with the dev.planttracer.com database @echo for debugging Python and Javascript with remote database - PLANTTRACER_CREDENTIALS=$(ETC)/credentials-aws-dev.ini $(DEBUG) + PLANTTRACER_CREDENTIALS=$(APP_ETC)/credentials-aws-dev.ini $(DEBUG) debug-dev-api: @echo Debug local JavaScript with remote server. @echo run bottle locally in debug mode, storing new data in S3, with the dev.planttracer.com database and API calls - PLANTTRACER_CREDENTIALS=$(ETC)/credentials-aws-dev.ini PLANTTRACER_API_BASE=https://dev.planttracer.com/ $(DEBUG) + PLANTTRACER_CREDENTIALS=$(APP_ETC)/credentials-aws-dev.ini PLANTTRACER_API_BASE=https://dev.planttracer.com/ $(DEBUG) tracker-debug: /bin/rm -f outfile.mp4 @@ -204,27 +203,29 @@ jscoverage: ################################################################ -# Installations are used by the CI pipeline: -# Use actions_test unless a local db is already defined +# Installations are used by the CI pipeline and by developers +# $(REQ) gets made by the virtual environment installer, but you need to have python installed first. PLANTTRACER_LOCALDB_NAME ?= actions_test create_localdb: - @echo Creating local database, exercise the upgrade code and write credentials to $(PLANTTRACER_CREDENTIALS) using $(ETC)/github_actions_mysql_rootconfig.ini + @echo Creating local database, exercise the upgrade code and write credentials + @echo to $(PLANTTRACER_CREDENTIALS) using $(ROOT_ETC)/github_actions_mysql_rootconfig.ini @echo $(PLANTTRACER_CREDENTIALS) will be used automatically by other tests - mkdir -p $(ETC) - ls -l $(ETC) - $(PYTHON) $(DBMAINT) --create_client=$$MYSQL_ROOT_PASSWORD --writeconfig $(ETC)/github_actions_mysql_rootconfig.ini - ls -l $(ETC) - $(PYTHON) $(DBMAINT) --rootconfig $(ETC)/github_actions_mysql_rootconfig.ini \ + pwd + mkdir -p $(ROOT_ETC) + ls -l $(ROOT_ETC) + $(PYTHON) $(DBMAINT) --create_client=$$MYSQL_ROOT_PASSWORD --writeconfig $(ROOT_ETC)/github_actions_mysql_rootconfig.ini + ls -l $(ROOT_ETC) + $(PYTHON) $(DBMAINT) --rootconfig $(ROOT_ETC)/github_actions_mysql_rootconfig.ini \ --createdb $(PLANTTRACER_LOCALDB_NAME) \ - --schema $(APP_ETC)/schema_0.sql \ + --schema $(DEPLOY_ETC)/schema_0.sql \ --writeconfig $(PLANTTRACER_CREDENTIALS) $(PYTHON) $(DBMAINT) --upgradedb --loglevel DEBUG $(PYTHON) -m pytest -x --log-cli-level=DEBUG tests/dbreader_test.py remove_localdb: - @echo Removing local database using $(ETC)/github_actions_mysql_rootconfig.ini - $(PYTHON) $(DBMAINT) --rootconfig $(ETC)/github_actions_mysql_rootconfig.ini --dropdb $(PLANTTRACER_LOCALDB_NAME) + @echo Removing local database using $(ROOT_ETC)/github_actions_mysql_rootconfig.ini + $(PYTHON) $(DBMAINT) --rootconfig $(ROOT_ETC)/github_actions_mysql_rootconfig.ini --dropdb $(PLANTTRACER_LOCALDB_NAME) install-chromium-browser-ubuntu: $(REQ) @@ -237,14 +238,31 @@ install-chromium-browser-macos: $(REQ) # Includes ubuntu dependencies install-ubuntu: $(REQ) echo on GitHub, we use this action instead: https://github.com/marketplace/actions/setup-ffmpeg - make venv which ffmpeg || sudo apt install ffmpeg which node || sudo apt-get install nodejs which npm || sudo apt-get install npm npm ci - make venv if [ -r requirements-ubuntu.txt ]; then $(PIP_INSTALL) -r requirements-ubuntu.txt ; fi +# Install for AWS Linux for running SAM +# Start with: +# sudo dfn install git && git clone --recursive https://github.com/Plant-Tracer/webapp && (cd webapp; make aws-install) +install-aws: + echo install for AWS Linux, for making the lambda. + echo note does not install ffmpeg currently + (cd $HOME; \ + wget https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip; \ + unzip aws-sam-cli-linux-x86_64.zip -d sam-installation; \ + sudo ./sam-installation/install ) + sudo dnf install -y docker + sudo systemctl enable docker + sudo systemctl start docker + sudo dnf install -y python3.11 + sudo dnf install -y nodejs npm + npm ci + make $(REQ) + if [ -r requirements-aws.txt ]; then $(PIP_INSTALL) -r requirements-ubuntu.txt ; fi + # Includes MacOS dependencies managed through Brew install-macos: brew update @@ -255,12 +273,11 @@ install-macos: brew install npm npm ci npm install -g typescript webpack webpack-cli - make venv + make $(REQ) if [ -r requirements-macos.txt ]; then $(PIP_INSTALL) -r requirements-macos.txt ; fi # Includes Windows dependencies -install-windows: - make venv +install-windows: $(REQ) if [ -r requirements-windows.txt ]; then $(PIP_INSTALL) -r requirements-windows.txt ; fi ################################################################ diff --git a/README.md b/README.md index 9e332fa1..36936769 100644 --- a/README.md +++ b/README.md @@ -70,3 +70,7 @@ Keep your Lambda functions optimized for performance and cost. # Lambda and S3 Lamda limits returns to 6MB and uploads to around 256K. So large uploads are done with presigned POST to S3 and large downloads by putting the data into S3 and having it pulled with a presigned URL. + + +colima start +docker ps -a diff --git a/dbutil.py b/dbutil.py new file mode 100644 index 00000000..faa6cbf9 --- /dev/null +++ b/dbutil.py @@ -0,0 +1,240 @@ +""" +dbutil.py - CLI for dbmaint module. +""" + +import sys +import os +import configparser +import uuid + +from deploy.app import clogging +from deploy.app import dbmaint +from deploy.app import db +from deploy.app import dbfile +from deploy.app import paths +from deploy.app.constants import C + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Database Maintenance Program. The database to act upon is specified in the ini file specified by the PLANTTRACER_CREDENTIALS environment variable, in the sections for [dbreader] and [dbwriter]", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + required = parser.add_argument_group('required arguments') + required.add_argument( + "--rootconfig", + help='Specify config file with MySQL database root credentials in [client] section. ' + 'Format is the same as the mysql --defaults-extra-file= argument') + parser.add_argument("--sendlink", help="Send link to the given email address, registering it if necessary.") + parser.add_argument('--planttracer_endpoint',help='https:// endpoint where planttracer app can be found') + parser.add_argument("--createdb", + help='Create a new database and a dbreader and dbwriter user. Database must not exist. ' + 'Requires that the variables MYSQL_DATABASE and MYSQL_HOST are set, and that MYSQL_PASSWORD and MYSQL_USER ' + 'are set with a MySQL username that can issue the "CREATE DATABASE" command.' + 'Outputs setenv for DBREADER and DBWRITER') + parser.add_argument("--upgradedb", help='Upgrade a database schema',action='store_true') + parser.add_argument("--dropdb", help='Drop an existing database.') + parser.add_argument("--readconfig", help="Specify the config.ini file to read") + parser.add_argument("--writeconfig", help="Specify the config.ini file to write.") + parser.add_argument('--purge_test_data', help='Remove the test data from the database', action='store_true') + parser.add_argument('--purge_all_movies', help='Remove all of the movies from the database', action='store_true') + parser.add_argument("--purge_movie",help="Remove the movie and all of its associated data from the database",type=int) + parser.add_argument("--create_client",help="Create a [client] section with a root username and the specified password") + parser.add_argument("--create_course",help="Create a course and register --admin_email --admin_name as the administrator") + parser.add_argument('--demo_email',help='If create_course is specified, also create a demo user with this email and upload demo movies ') + parser.add_argument("--admin_email",help="Specify the email address of the course administrator") + parser.add_argument("--admin_name",help="Specify the name of the course administrator") + parser.add_argument("--max_enrollment",help="Max enrollment for course",type=int,default=20) + parser.add_argument("--report",help="Print a report of the database",action='store_true') + parser.add_argument("--freshen",help="Non-destructive cleans up the movie metadata for all movies.",action='store_true') + parser.add_argument("--clean",help="Destructive cleans up the movie metadata for all movies.",action='store_true') + parser.add_argument("--schema", help="Specify schema file to use", default=paths.SCHEMA_FILE) + parser.add_argument("--dump", help="Backup all objects as JSON files and movie files to new directory called DUMP. ") + parser.add_argument("--add_admin", help="Add --admin_email user as a course admin to the course specified by --course_id, --course_name, or --course_name", action='store_true') + parser.add_argument("--course_id", help="integer course id", type=int) + parser.add_argument("--course_key", help="integer course id") + parser.add_argument("--course_name", help="integer course id") + parser.add_argument("--remove_admin", help="Remove the --admin_email user as a course admin from the course specified by --course_id, --course_name, or --course_name", action='store_true') + + clogging.add_argument(parser, loglevel_default='WARNING') + args = parser.parse_args() + clogging.setup(level=args.loglevel) + + config = configparser.ConfigParser() + + if args.rootconfig: + config.read(args.rootconfig) + os.environ[C.PLANTTRACER_CREDENTIALS] = args.rootconfig + + if args.readconfig: + paths.CREDENTIALS_FILE = paths.AWS_CREDENTIALS_FILE = args.readconfig + + if args.sendlink: + if not args.planttracer_endpoint: + raise RuntimeError("Please specify --planttracer_endpoint") + new_api_key = db.make_new_api_key(email=args.sendlink) + db.send_links(email=args.sendlink, planttracer_endpoint = args.planttracer_endpoint, + new_api_key=new_api_key) + sys.exit(0) + + ################################################################ + ## Startup stuff + + if args.createdb or args.dropdb: + cp = configparser.ConfigParser() + if args.rootconfig is None: + print("Please specify --rootconfig for --createdb or --dropdb",file=sys.stderr) + sys.exit(1) + + ath = dbfile.DBMySQLAuth.FromConfigFile(args.rootconfig, 'client') + with dbfile.DBMySQL( ath ) as droot: + if args.createdb: + dbmaint.createdb(droot=droot, createdb_name = args.createdb, + write_config_fname=args.writeconfig, schema=args.schema) + sys.exit(0) + + if args.dropdb: + # Delete the database and the users created for the database + dbreader_user = 'dbreader_' + args.dropdb + dbwriter_user = 'dbwriter_' + args.dropdb + c = droot.cursor() + for ipaddr in dbmaint.hostnames(): + c.execute(f'DROP USER IF EXISTS `{dbreader_user}`@`{ipaddr}`') + c.execute(f'DROP USER IF EXISTS `{dbwriter_user}`@`{ipaddr}`') + c.execute(f'DROP DATABASE IF EXISTS {args.dropdb}') + sys.exit(0) + + # These all use existing databases + cp = configparser.ConfigParser() + if args.create_client: + print(f"creating root with password '{args.create_client}'") + if 'client' not in cp: + cp.add_section('client') + cp['client']['user']='root' + cp['client']['password']=args.create_client + cp['client']['host'] = 'localhost' + cp['client']['database'] = 'sys' + + if args.readconfig: + cp.read(args.readconfig) + print("config read from",args.readconfig) + if cp['dbreader']['mysql_database'] != cp['dbwriter']['mysql_database']: + raise RuntimeError("dbreader and dbwriter do not address the same database") + + if args.writeconfig: + with open(args.writeconfig, 'w') as fp: + cp.write(fp) + print(args.writeconfig,"is written") + + if args.create_course: + print("creating course...") + if not args.admin_email: + print("Must provide --admin_email",file=sys.stderr) + if not args.admin_name: + print("Must provide --admin_name",file=sys.stderr) + if not args.admin_email or not args.admin_name: + sys.exit(1) + course_key = str(uuid.uuid4())[9:18] + dbmaint.create_course(course_key = course_key, + course_name = args.create_course, + admin_email = args.admin_email, + admin_name = args.admin_name, + max_enrollment = args.max_enrollment, + demo_email = args.demo_email + ) + print(f"course_key: {course_key}") + sys.exit(0) + + if args.upgradedb: + # the upgrade can be done with dbwriter, as long as dbwriter can update the schema. + # In our current versions, it can. + ath = dbfile.DBMySQLAuth.FromConfigFile(os.environ[C.PLANTTRACER_CREDENTIALS], 'dbwriter') + dbmaint.schema_upgrade(ath) + sys.exit(0) + + if args.add_admin: + print("adding admin to course...") + if not args.admin_email: + print("Must provide --admin_email",file=sys.stderr) + sys.exit(1) + user = db.lookup_user(email=args.admin_email) + if not user.get('id'): + print(f"User {args.admin_email} does not exist") + sys.exit(1) + if not args.course_key and not args.course_id and not args.course_name: + print("Must provide one of --course_key, --course_id, or --course_name",file=sys.stderr) + sys.exit(1) + if args.course_id: + course = db.lookup_course_by_id(course_id=args.course_id) + if course.get('id'): + dbmaint.add_admin_to_course(admin_email = args.admin_email, course_id = args.course_id) + sys.exit(0) + else: + print(f"Course with id {args.course_id} does not exist.",file=sys.stderr) + sys.exit(1) + elif args.course_key: + course = db.lookup_course_by_key(course_key=args.course_key) + if course.get('course_key'): + dbmaint.add_admin_to_course(admin_email = args.admin_email, course_key = course['course_key']) + sys.exit(0) + else: + print(f"Course with key {args.course_key} does not exist.",file=sys.stderr) + sys.exit(1) + elif args.course_name: + course = db.lookup_course_by_name(course_name = args.course_name) + if course.get('id'): + dbmaint.add_admin_to_course(admin_email=args.admin_email, course_id=course['id']) + sys.exit(0) + else: + print(f'Course with name {args.course_name} does not exist.',file=sys.stderr) + sys.exit(1) + + if args.remove_admin: + print("removing admin from course...") + if not args.admin_email: + print("Must provide --admin_email",file=sys.stderr) + sys.exit(1) + user = db.lookup_user(email=args.admin_email) + if not user.get('id'): + print(f"User {args.admin_email} does not exist") + sys.exit(1) + if not args.course_key and not args.course_id and not args.course_name: + print("Must provide one of --course_key, --course_id, or --course_name",file=sys.stderr) + sys.exit(1) + dbmaint.remove_admin_from_course( + admin_email = args.admin_email, + course_id = args.course_id, + course_key = args.course_key, + course_name = args.course_name + ) + sys.exit(0) + + ################################################################ + ## Cleanup + + if args.purge_test_data: + dbmaint.purge_test_data() + + if args.purge_all_movies: + dbmaint.purge_all_movies() + + if args.purge_movie: + db.purge_movie(movie_id=args.purge_movie) + + ################################################################ + ## Maintenance + + if args.report: + dbmaint.report() + sys.exit(0) + + if args.freshen: + dbmaint.freshen(False) + sys.exit(0) + + if args.clean: + dbmaint.freshen(True) + sys.exit(0) + + if args.dump: + dbmaint.dump(config,args.dump) + sys.exit() diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 6fe27423..b97f1f1a 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -3,16 +3,19 @@ # # Note that AWS Lambda deploys the application in /var/task # +# https://docs.aws.amazon.com/lambda/latest/dg/python-image.html FROM --platform=linux/amd64 public.ecr.aws/lambda/python:3.11 -COPY demo.py requirements.txt ./ + +COPY demo.py requirements.txt ./ RUN python3 -m pip install -r requirements.txt -t . --no-warn-script-location # Copy the the app -COPY ./ ./ +COPY *.py ${LAMBDA_TASK_ROOT}/app/ +COPY etc/ ${LAMBDA_TASK_ROOT}/app/etc/ +COPY static/ ${LAMBDA_TASK_ROOT}/app/static/ +COPY templates/ ${LAMBDA_TASK_ROOT}/app/templates/ -# CMD is used for local deployment: -#CMD ["demo.lambda_handler"] -CMD ["lambda_handler.lambda_app"] +CMD ["app.lambda_handler.handler"] diff --git a/deploy/app/__init__.py b/deploy/app/__init__.py index e69de29b..20679c7b 100644 --- a/deploy/app/__init__.py +++ b/deploy/app/__init__.py @@ -0,0 +1,4 @@ +""" +stuff +""" +__version__='0.9.0' diff --git a/deploy/app/apikey.py b/deploy/app/apikey.py index 93b17c14..02bec5ee 100644 --- a/deploy/app/apikey.py +++ b/deploy/app/apikey.py @@ -2,6 +2,7 @@ apikey.py Implements the user_dict and APIKEY functions - the high-level authentication system. +All done through get_user_dict() below, which is kind of gross. """ @@ -10,10 +11,14 @@ import functools import subprocess import json +from functools import lru_cache +import base64 +from os.path import join from flask import request from . import db +from .paths import ETC_DIR from .auth import get_dbreader,AuthError from .constants import C,__version__ @@ -76,6 +81,14 @@ def cookie_name(): return C.API_KEY_COOKIE_BASE + "-" + get_dbreader().database +def add_cookie(response): + """Add the cookie if the apikey was in the get value""" + api_key = request.values.get('api_key', None) + if api_key: + response.set_cookie(cookie_name(), api_key, + max_age = C.API_KEY_COOKIE_MAX_AGE) + + def get_user_api_key(): """Gets the user APIkey from either the URL or the cookie or the form, but does not validate it. If we are running in an @@ -88,10 +101,9 @@ def get_user_api_key(): a string of the demo mode's API key if no user is logged in and demo mode is available. None if user is not logged in and no demo mode """ - # check the query string + # check the query string. api_key = request.values.get('api_key', None) # must be 'api_key', because may be in URL if api_key is not None: - logging.debug("api_key set in request.values=%s",api_key) return api_key # Return the api_key if it is in a cookie. @@ -115,10 +127,16 @@ def get_user_dict(): userdict = db.validate_api_key(api_key) if not userdict: - logging.info("api_key %s is invalid ipaddr=%s request.url=%s", api_key,request.remote_addr,request.url) + logging.info("api_key %s is invalid ipaddr=%s request.url=%s", + api_key,request.remote_addr,request.url) raise AuthError(f"api_key '{api_key}' is invalid") return userdict +@lru_cache(maxsize=1) +def favicon_base64(): + with open( join( ETC_DIR, C.FAVICON), 'rb') as f: + return base64.b64encode(f.read()).decode('utf-8') + def page_dict(title='', *, require_auth=False, lookup=True, logout=False,debug=False): """Returns a dictionary that can be used by post of the templates. :param: title - the title we should give the page @@ -169,6 +187,7 @@ def page_dict(title='', *, require_auth=False, lookup=True, logout=False,debug=F ret= fix_types({ C.API_BASE: api_base, C.STATIC_BASE: static_base, + 'favicon_base64':favicon_base64(), 'api_key': api_key, # the API key that is currently active 'user_id': user_id, # the user_id that is active 'user_name': user_name, # the user's name diff --git a/deploy/app/auth.py b/deploy/app/auth.py index 215bff83..fb6ee772 100644 --- a/deploy/app/auth.py +++ b/deploy/app/auth.py @@ -15,7 +15,6 @@ from .constants import C from .paths import DEFAULT_CREDENTIALS_FILE -COOKIE_MAXAGE = 60*60*24*180 SMTP_ATTRIBS = ['SMTP_USERNAME', 'SMTP_PASSWORD', 'SMTP_PORT', 'SMTP_HOST'] ################################################################ @@ -24,7 +23,7 @@ def credentials_file(): - name = os.environ.get(C.PLANTTRACER_CREDENTIALS,DEFAULT_CREDENTIALS_FILE) + name = os.environ.get(C.PLANTTRACER_CREDENTIALS, DEFAULT_CREDENTIALS_FILE) if not os.path.exists(name): logging.error("Cannot find %s (PLANTTRACER_CREDENTIALS=%s)",os.path.abspath(name),name) raise FileNotFoundError(name) diff --git a/deploy/app/bottle_api.py b/deploy/app/bottle_api.py index 08bae298..f4c8e0c7 100644 --- a/deploy/app/bottle_api.py +++ b/deploy/app/bottle_api.py @@ -14,7 +14,7 @@ from collections import defaultdict from zipfile import ZipFile -from flask import Blueprint, request, make_response, redirect +from flask import Blueprint, request, make_response, redirect, current_app from validate_email_address import validate_email from . import db @@ -267,6 +267,8 @@ def api_upload_movie(): :param: key - where the file gets uploaded -from api_new_movie() :param: request.files['file'] - the file! """ + logging.info("info") + logging.error("error") scheme = get('scheme') key = get('key') movie_data_sha256 = get('sha256') # claimed SHA256 @@ -594,7 +596,7 @@ def api_ver(): """Report the python version. Allows us to validate we are using Python3. Run the dictionary below through the VERSION_TEAMPLTE with jinja2. """ - logging.debug("api_ver") + current_app.logger.error("api_ver") return {'__version__': __version__, 'sys_version': sys.version} ################################################################ diff --git a/deploy/app/bottle_app.py b/deploy/app/bottle_app.py index a99eb0ce..e0aaa3b3 100644 --- a/deploy/app/bottle_app.py +++ b/deploy/app/bottle_app.py @@ -8,6 +8,7 @@ import sys import os import logging +from logging.config import dictConfig from flask import Flask, request, render_template, jsonify, make_response @@ -17,6 +18,7 @@ from . import db_object from . import dbmaint from . import clogging +from . import apikey from .bottle_api import api_bp from .constants import __version__,GET,GET_POST,C @@ -43,24 +45,45 @@ def lambda_startup(): clogging.setup(level=os.environ.get('PLANTTRACER_LOG_LEVEL',logging.INFO)) fix_boto_log_level() - if C.PLANTTRACER_S3_BUCKET in os.environ: + logging.info("p1") + if os.environ.get(C.PLANTTRACER_S3_BUCKET,None): db_object.S3_BUCKET = os.environ[C.PLANTTRACER_S3_BUCKET] + logging.info("p2a %s",db_object.S3_BUCKET) else: config = auth.config() try: db_object.S3_BUCKET = config['s3']['s3_bucket'] + logging.info("p2b %s",db_object.S3_BUCKET) except KeyError as e: logging.info("s3_bucket not defined in config file. using db object store instead. %s",e) + logging.info("p3 %s",db_object.S3_BUCKET) ################################################################ ## API SUPPORT +dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] [%(process)d] %(levelname)s %(filename)s:%(lineno)d %(message)s', + }}, + 'root': { + 'level': 'DEBUG', + } +}) + +fix_boto_log_level() +lambda_startup() app = Flask(__name__) app.register_blueprint(api_bp, url_prefix='/api') +app.logger.info("new Flask(__name__=%s)",__name__) +app.logger.info("PLANTTRACER_CREDENTIALS=%s",os.environ.get(C.PLANTTRACER_CREDENTIALS,None)) +app.logger.info("db_object.S3_BUCKET=%s",db_object.S3_BUCKET) +logging.info("regular logging works too") -# Note - Flask automatically serves /static -## Error handling +################################################################ +### Error Handling +################################################################ @app.errorhandler(AuthError) def handle_auth_error(ex): @@ -76,6 +99,9 @@ def handle_auth_error(ex): # HTML Pages served with template system ################################################################ +################ +## These mostly do forms or static content + @app.route('/', methods=GET) def func_root(): """/ - serve the home page""" @@ -93,19 +119,10 @@ def func_error(): def func_audit(): return render_template('audit.html', **page_dict("Audit", require_auth=True)) -@app.route('/list', methods=GET) -def func_list(): - return render_template('list.html', **page_dict('List Movies', require_auth=True)) - @app.route('/analyze', methods=GET) def func_analyze(): return render_template('analyze.html', **page_dict('Analyze Movie', require_auth=True)) -## debug page -@app.route('/debug', methods=GET) -def app_debug(): - return render_template('debug.html', routes=app.url_map) - ## ## Login page includes the api keys of all the demo users. ## @@ -134,7 +151,6 @@ def func_register(): hostname=request.host, register=True) - @app.route('/resend', methods=GET) def func_resend(): """/resend sends the register.html template which loads register.js with register variable set to False""" @@ -147,20 +163,39 @@ def func_resend(): def func_tos(): return render_template('tos.html', **page_dict('Terms of Service')) -@app.route('/upload', methods=GET) -def func_upload(): - """/upload - Upload a new file""" - logging.debug("/upload require_auth=True") - return render_template('upload.html', **page_dict('Upload a Movie', require_auth=True)) - @app.route('/users', methods=GET) def func_users(): """/users - provide a users list""" return render_template('users.html', **page_dict('List Users', require_auth=True)) +################ +# These are the two links that might have an ?apikey=; if we got that, set the cookie +@app.route('/list', methods=GET) +def func_list(): + response = make_response(render_template('list.html', + **page_dict('List Movies', + require_auth=True))) + # if api_key was in the query string, set the cookie + apikey.add_cookie(response) + return response + +@app.route('/upload', methods=GET) +def func_upload(): + """/upload - Upload a new file. Can also set cookie.""" + logging.debug("/upload require_auth=True") + response = make_response(render_template('upload.html', + **page_dict('Upload a Movie', + require_auth=True))) + apikey.add_cookie(response) + return response + ################################################################ ## debug/demo +@app.route('/debug', methods=GET) +def app_debug(): + return render_template('debug.html', routes=app.url_map) + @app.route('/demo_tracer1.html', methods=GET) def demo_tracer1(): return render_template('demo_tracer1.html', **page_dict('demo_tracer1',require_auth=False)) @@ -178,6 +213,7 @@ def func_ver(): """Demo for reporting python version. Allows us to validate we are using Python3. Run the dictionary below through the VERSION_TEAMPLTE with jinja2. """ + logging.info("/ver") response = make_response(render_template('version.txt', __version__=__version__, sys_version= sys.version)) diff --git a/deploy/app/constants.py b/deploy/app/constants.py index 9d8a3985..0e8717cc 100644 --- a/deploy/app/constants.py +++ b/deploy/app/constants.py @@ -23,10 +23,11 @@ class C: FFMPEG_PATH = 'FFMPEG_PATH' # Other + FAVICON = 'icon.png' API_BASE='API_BASE' STATIC_BASE='STATIC_BASE' TRACKING_COMPLETED='TRACKING COMPLETED' # keep case; it's used as a flag - MAX_FILE_UPLOAD = 1024*1024*64 + MAX_FILE_UPLOAD = 1024*1024*256 MAX_FRAMES = 1e6 # max possible frames in a movie NOTIFY_UPDATE_INTERVAL = 5.0 TRACK_DELAY = 'TRACK_DELAY' @@ -43,6 +44,7 @@ class C: SCHEME_DB_MAX_OBJECT_LEN = 16_000_000 REDIRECT_FOUND = 302 API_KEY_COOKIE_BASE = 'api_key' + API_KEY_COOKIE_MAX_AGE = 60*60*24*180 SMTPCONFIG_ARN = 'SMTPCONFIG_ARN' class MIME: diff --git a/deploy/app/db_object.py b/deploy/app/db_object.py index 88205a17..82a78e8b 100644 --- a/deploy/app/db_object.py +++ b/deploy/app/db_object.py @@ -95,6 +95,7 @@ def make_urn(*, object_name, scheme = None ): if scheme == C.SCHEME_S3 and S3_BUCKET is None: scheme = C.SCHEME_DB if scheme == C.SCHEME_S3: + assert len(S3_BUCKET)>0 netloc = S3_BUCKET elif scheme == C.SCHEME_DB: netloc = DB_TABLE @@ -133,7 +134,7 @@ def read_signed_url(*,urn,sig): logging.error("URL signature does not match. urn=%s sig=%s computed_sig=%s",urn,sig,computed_sig) raise AuthError("signature does not verify") -def make_presigned_post(*, urn, maxsize=10_000_000, mime_type='video/mp4',expires=3600, sha256=None): +def make_presigned_post(*, urn, maxsize=C.MAX_FILE_UPLOAD, mime_type='video/mp4',expires=3600, sha256=None): """Returns a dictionary with 'url' and 'fields'""" o = urllib.parse.urlparse(urn) if o.scheme==C.SCHEME_S3: diff --git a/deploy/app/dbmaint.py b/deploy/app/dbmaint.py index ea17c058..69d1c740 100755 --- a/deploy/app/dbmaint.py +++ b/deploy/app/dbmaint.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Database Management Tool for webapp +Database Management Support """ import sys @@ -18,14 +18,11 @@ from tabulate import tabulate from botocore.exceptions import ClientError,ParamValidationError -from . import paths from . import db from . import tracker from . import auth -from . import clogging from . import dbfile -from .constants import C -from .paths import TEMPLATE_DIR, SCHEMA_FILE, TEST_DATA_DIR, SCHEMA_TEMPLATE +from .paths import TEMPLATE_DIR, TEST_DATA_DIR, SCHEMA_TEMPLATE from .dbfile import MYSQL_HOST,MYSQL_USER,MYSQL_PASSWORD,MYSQL_DATABASE,DBMySQL assert os.path.exists(TEMPLATE_DIR) @@ -361,227 +358,3 @@ def dump(config,dumpdir): json.dump(movie, f, default=str) with open(os.path.join(dumpdir,f"movie_{movie_id}.mp4"),"wb") as f: f.write(movie_data) - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description="Database Maintenance Program. The database to act upon is specified in the ini file specified by the PLANTTRACER_CREDENTIALS environment variable, in the sections for [dbreader] and [dbwriter]", - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - required = parser.add_argument_group('required arguments') - required.add_argument( - "--rootconfig", - help='Specify config file with MySQL database root credentials in [client] section. ' - 'Format is the same as the mysql --defaults-extra-file= argument') - parser.add_argument("--sendlink", help="Send link to the given email address, registering it if necessary.") - parser.add_argument('--planttracer_endpoint',help='https:// endpoint where planttracer app can be found') - parser.add_argument("--createdb", - help='Create a new database and a dbreader and dbwriter user. Database must not exist. ' - 'Requires that the variables MYSQL_DATABASE and MYSQL_HOST are set, and that MYSQL_PASSWORD and MYSQL_USER ' - 'are set with a MySQL username that can issue the "CREATE DATABASE" command.' - 'Outputs setenv for DBREADER and DBWRITER') - parser.add_argument("--upgradedb", help='Upgrade a database schema',action='store_true') - parser.add_argument("--dropdb", help='Drop an existing database.') - parser.add_argument("--readconfig", help="Specify the config.ini file to read") - parser.add_argument("--writeconfig", help="Specify the config.ini file to write.") - parser.add_argument('--purge_test_data', help='Remove the test data from the database', action='store_true') - parser.add_argument('--purge_all_movies', help='Remove all of the movies from the database', action='store_true') - parser.add_argument("--purge_movie",help="Remove the movie and all of its associated data from the database",type=int) - parser.add_argument("--create_client",help="Create a [client] section with a root username and the specified password") - parser.add_argument("--create_course",help="Create a course and register --admin_email --admin_name as the administrator") - parser.add_argument('--demo_email',help='If create_course is specified, also create a demo user with this email and upload demo movies ') - parser.add_argument("--admin_email",help="Specify the email address of the course administrator") - parser.add_argument("--admin_name",help="Specify the name of the course administrator") - parser.add_argument("--max_enrollment",help="Max enrollment for course",type=int,default=20) - parser.add_argument("--report",help="Print a report of the database",action='store_true') - parser.add_argument("--freshen",help="Non-destructive cleans up the movie metadata for all movies.",action='store_true') - parser.add_argument("--clean",help="Destructive cleans up the movie metadata for all movies.",action='store_true') - parser.add_argument("--schema", help="Specify schema file to use", default=SCHEMA_FILE) - parser.add_argument("--dump", help="Backup all objects as JSON files and movie files to new directory called DUMP. ") - parser.add_argument("--add_admin", help="Add --admin_email user as a course admin to the course specified by --course_id, --course_name, or --course_name", action='store_true') - parser.add_argument("--course_id", help="integer course id", type=int) - parser.add_argument("--course_key", help="integer course id") - parser.add_argument("--course_name", help="integer course id") - parser.add_argument("--remove_admin", help="Remove the --admin_email user as a course admin from the course specified by --course_id, --course_name, or --course_name", action='store_true') - - clogging.add_argument(parser, loglevel_default='WARNING') - args = parser.parse_args() - clogging.setup(level=args.loglevel) - - config = configparser.ConfigParser() - - if args.rootconfig: - config.read(args.rootconfig) - os.environ[C.PLANTTRACER_CREDENTIALS] = args.rootconfig - - if args.readconfig: - paths.CREDENTIALS_FILE = paths.AWS_CREDENTIALS_FILE = args.readconfig - - if args.sendlink: - if not args.planttracer_endpoint: - raise RuntimeError("Please specify --planttracer_endpoint") - new_api_key = db.make_new_api_key(email=args.sendlink) - db.send_links(email=args.sendlink, planttracer_endpoint = args.planttracer_endpoint, - new_api_key=new_api_key) - sys.exit(0) - - ################################################################ - ## Startup stuff - - if args.createdb or args.dropdb: - cp = configparser.ConfigParser() - if args.rootconfig is None: - print("Please specify --rootconfig for --createdb or --dropdb",file=sys.stderr) - sys.exit(1) - - ath = dbfile.DBMySQLAuth.FromConfigFile(args.rootconfig, 'client') - with dbfile.DBMySQL( ath ) as droot: - if args.createdb: - createdb(droot=droot, createdb_name = args.createdb, - write_config_fname=args.writeconfig, schema=args.schema) - sys.exit(0) - - if args.dropdb: - # Delete the database and the users created for the database - dbreader_user = 'dbreader_' + args.dropdb - dbwriter_user = 'dbwriter_' + args.dropdb - c = droot.cursor() - for ipaddr in hostnames(): - c.execute(f'DROP USER IF EXISTS `{dbreader_user}`@`{ipaddr}`') - c.execute(f'DROP USER IF EXISTS `{dbwriter_user}`@`{ipaddr}`') - c.execute(f'DROP DATABASE IF EXISTS {args.dropdb}') - sys.exit(0) - - # These all use existing databases - cp = configparser.ConfigParser() - if args.create_client: - print(f"creating root with password '{args.create_client}'") - if 'client' not in cp: - cp.add_section('client') - cp['client']['user']='root' - cp['client']['password']=args.create_client - cp['client']['host'] = 'localhost' - cp['client']['database'] = 'sys' - - if args.readconfig: - cp.read(args.readconfig) - print("config read from",args.readconfig) - if cp['dbreader']['mysql_database'] != cp['dbwriter']['mysql_database']: - raise RuntimeError("dbreader and dbwriter do not address the same database") - - if args.writeconfig: - with open(args.writeconfig, 'w') as fp: - cp.write(fp) - print(args.writeconfig,"is written") - - if args.create_course: - print("creating course...") - if not args.admin_email: - print("Must provide --admin_email",file=sys.stderr) - if not args.admin_name: - print("Must provide --admin_name",file=sys.stderr) - if not args.admin_email or not args.admin_name: - sys.exit(1) - course_key = str(uuid.uuid4())[9:18] - create_course(course_key = course_key, - course_name = args.create_course, - admin_email = args.admin_email, - admin_name = args.admin_name, - max_enrollment = args.max_enrollment, - demo_email = args.demo_email - ) - print(f"course_key: {course_key}") - sys.exit(0) - - if args.upgradedb: - # the upgrade can be done with dbwriter, as long as dbwriter can update the schema. - # In our current versions, it can. - ath = dbfile.DBMySQLAuth.FromConfigFile(os.environ[C.PLANTTRACER_CREDENTIALS], 'dbwriter') - schema_upgrade(ath) - sys.exit(0) - - if args.add_admin: - print("adding admin to course...") - if not args.admin_email: - print("Must provide --admin_email",file=sys.stderr) - sys.exit(1) - user = db.lookup_user(email=args.admin_email) - if not user.get('id'): - print(f"User {args.admin_email} does not exist") - sys.exit(1) - if not args.course_key and not args.course_id and not args.course_name: - print("Must provide one of --course_key, --course_id, or --course_name",file=sys.stderr) - sys.exit(1) - if args.course_id: - course = db.lookup_course_by_id(course_id=args.course_id) - if course.get('id'): - add_admin_to_course(admin_email = args.admin_email, course_id = args.course_id) - sys.exit(0) - else: - print(f"Course with id {args.course_id} does not exist.",file=sys.stderr) - sys.exit(1) - elif args.course_key: - course = db.lookup_course_by_key(course_key=args.course_key) - if course.get('course_key'): - add_admin_to_course(admin_email = args.admin_email, course_key = course['course_key']) - sys.exit(0) - else: - print(f"Course with key {args.course_key} does not exist.",file=sys.stderr) - sys.exit(1) - elif args.course_name: - course = db.lookup_course_by_name(course_name = args.course_name) - if course.get('id'): - add_admin_to_course(admin_email=args.admin_email, course_id=course['id']) - sys.exit(0) - else: - print(f'Course with name {args.course_name} does not exist.',file=sys.stderr) - sys.exit(1) - - if args.remove_admin: - print("removing admin from course...") - if not args.admin_email: - print("Must provide --admin_email",file=sys.stderr) - sys.exit(1) - user = db.lookup_user(email=args.admin_email) - if not user.get('id'): - print(f"User {args.admin_email} does not exist") - sys.exit(1) - if not args.course_key and not args.course_id and not args.course_name: - print("Must provide one of --course_key, --course_id, or --course_name",file=sys.stderr) - sys.exit(1) - remove_admin_from_course( - admin_email = args.admin_email, - course_id = args.course_id, - course_key = args.course_key, - course_name = args.course_name - ) - sys.exit(0) - - ################################################################ - ## Cleanup - - if args.purge_test_data: - purge_test_data() - - if args.purge_all_movies: - purge_all_movies() - - if args.purge_movie: - db.purge_movie(movie_id=args.purge_movie) - - ################################################################ - ## Maintenance - - if args.report: - report() - sys.exit(0) - - if args.freshen: - freshen(False) - sys.exit(0) - - if args.clean: - freshen(True) - sys.exit(0) - - if args.dump: - dump(config,args.dump) - sys.exit() diff --git a/deploy/app/paths.py b/deploy/app/paths.py index faa137dd..24ffcfee 100644 --- a/deploy/app/paths.py +++ b/deploy/app/paths.py @@ -25,7 +25,7 @@ SCHEMA_FILE = join(ETC_DIR, 'schema.sql') SCHEMA_TEMPLATE = join(ETC_DIR, 'schema_{schema}.sql') -DEFAULT_CREDENTIALS_FILE = join(ETC_DIR,'credentials.ini') +DEFAULT_CREDENTIALS_FILE = join(ETC_DIR, 'credentials.ini') SCHEMA0_FILE = SCHEMA_TEMPLATE.format(schema=0) SCHEMA1_FILE = SCHEMA_TEMPLATE.format(schema=1) @@ -39,7 +39,12 @@ def ffmpeg_path(): if C.FFMPEG_PATH in os.environ: - return os.environ[C.FFMPEG_PATH] + pth = os.environ[C.FFMPEG_PATH] + if os.path.exists(pth): + return pth + pth = shutil.which('ffmpeg') + if pth: + return pth if os.path.exists(AWS_LAMBDA_LINUX_STATIC_FFMPEG): return AWS_LAMBDA_LINUX_STATIC_FFMPEG - return shutil.which('ffmpeg') + raise FileNotFoundError("ffmpeg") diff --git a/deploy/app/templates/base.html b/deploy/app/templates/base.html index fb0e1d2c..226ef5dd 100644 --- a/deploy/app/templates/base.html +++ b/deploy/app/templates/base.html @@ -1,12 +1,12 @@
-