├── patchserver ├── routes │ ├── __init__.py │ ├── web_ui.py │ ├── auth.py │ ├── validator │ │ ├── __init__.py │ │ ├── schema_version.json │ │ └── schema_full_definition.json │ ├── webhooks.py │ ├── error_handlers.py │ ├── jamf_pro.py │ ├── api_operations.py │ └── api.py ├── database.py ├── static │ ├── css │ │ ├── custom.css │ │ └── jquery.dataTables.min.css │ ├── images │ │ ├── sort_asc.png │ │ ├── icon │ │ │ ├── 16x16.png │ │ │ ├── 32x32.png │ │ │ ├── 36x36.png │ │ │ ├── 48x48.png │ │ │ ├── 57x57.png │ │ │ ├── 60x60.png │ │ │ ├── 70x70.png │ │ │ ├── 72x72.png │ │ │ ├── 76x76.png │ │ │ ├── 96x96.png │ │ │ ├── 114x114.png │ │ │ ├── 120x120.png │ │ │ ├── 144x144.png │ │ │ ├── 150x150.png │ │ │ ├── 152x152.png │ │ │ ├── 180x180.png │ │ │ ├── 192x192.png │ │ │ ├── 310x310.png │ │ │ ├── favicon.ico │ │ │ ├── apple-icon.png │ │ │ ├── apple-icon-precomposed.png │ │ │ ├── browserconfig.xml │ │ │ └── manifest.json │ │ ├── sort_both.png │ │ └── sort_desc.png │ └── js │ │ ├── fontawesome │ │ └── attribution.js │ │ ├── custom.js │ │ └── bootstrap-filestyle.min.js ├── __init__.py ├── exc.py ├── config.py ├── utilities │ └── __init__.py ├── factory.py ├── templates │ ├── index.html │ ├── base.html │ └── modals.html └── models.py ├── .gitignore ├── docker ├── wsgi.py ├── config.py └── Dockerfile ├── docs ├── images │ ├── gui_01_index.png │ ├── gui_08_actions.png │ ├── gui_09_backups.png │ ├── jamf_setup_01.png │ ├── jamf_setup_02.png │ ├── jamf_setup_03.png │ ├── jamf_setup_04.png │ ├── jamf_setup_05.png │ ├── gui_02_new_title.png │ ├── gui_03_title_created.png │ ├── gui_05_title_error.png │ ├── gui_06_title_update.png │ ├── gui_07_title_updated.png │ └── gui_04_title_conflict.png ├── apis │ ├── jamf_pro.rst │ ├── ps_api.rst │ └── auth.rst ├── managing │ ├── troubleshooting.md │ ├── patch_starter.md │ └── user_interface.md ├── setup │ ├── local_testing.md │ ├── in_jamf_pro.md │ └── docker.md └── change_history.md ├── run.py ├── Pipfile ├── LICENSE ├── README.md └── Pipfile.lock /patchserver/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | patchserver/patch_server.db 3 | patchserver/reset_api_token 4 | -------------------------------------------------------------------------------- /docker/wsgi.py: -------------------------------------------------------------------------------- 1 | from patchserver.factory import create_app 2 | 3 | application = create_app() 4 | -------------------------------------------------------------------------------- /patchserver/database.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() 4 | -------------------------------------------------------------------------------- /docs/images/gui_01_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_01_index.png -------------------------------------------------------------------------------- /docs/images/gui_08_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_08_actions.png -------------------------------------------------------------------------------- /docs/images/gui_09_backups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_09_backups.png -------------------------------------------------------------------------------- /docs/images/jamf_setup_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/jamf_setup_01.png -------------------------------------------------------------------------------- /docs/images/jamf_setup_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/jamf_setup_02.png -------------------------------------------------------------------------------- /docs/images/jamf_setup_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/jamf_setup_03.png -------------------------------------------------------------------------------- /docs/images/jamf_setup_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/jamf_setup_04.png -------------------------------------------------------------------------------- /docs/images/jamf_setup_05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/jamf_setup_05.png -------------------------------------------------------------------------------- /docs/images/gui_02_new_title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_02_new_title.png -------------------------------------------------------------------------------- /docs/images/gui_03_title_created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_03_title_created.png -------------------------------------------------------------------------------- /docs/images/gui_05_title_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_05_title_error.png -------------------------------------------------------------------------------- /docs/images/gui_06_title_update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_06_title_update.png -------------------------------------------------------------------------------- /docs/images/gui_07_title_updated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_07_title_updated.png -------------------------------------------------------------------------------- /docs/images/gui_04_title_conflict.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/docs/images/gui_04_title_conflict.png -------------------------------------------------------------------------------- /patchserver/static/css/custom.css: -------------------------------------------------------------------------------- 1 | .btn { 2 | vertical-align: middle; 3 | } 4 | 5 | .table { 6 | width: 100% !important; 7 | } 8 | -------------------------------------------------------------------------------- /patchserver/static/images/sort_asc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/sort_asc.png -------------------------------------------------------------------------------- /docker/config.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import cpu_count 2 | 3 | bind = "0.0.0.0:5000" 4 | workers = (2 * cpu_count()) + 1 5 | # threads = 2 6 | -------------------------------------------------------------------------------- /patchserver/static/images/icon/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/16x16.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/32x32.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/36x36.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/48x48.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/57x57.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/60x60.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/70x70.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/72x72.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/76x76.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/96x96.png -------------------------------------------------------------------------------- /patchserver/static/images/sort_both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/sort_both.png -------------------------------------------------------------------------------- /patchserver/static/images/sort_desc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/sort_desc.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/114x114.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/120x120.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/144x144.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/150x150.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/152x152.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/180x180.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/192x192.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/310x310.png -------------------------------------------------------------------------------- /patchserver/static/images/icon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/favicon.ico -------------------------------------------------------------------------------- /patchserver/static/images/icon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/apple-icon.png -------------------------------------------------------------------------------- /patchserver/__init__.py: -------------------------------------------------------------------------------- 1 | """Patch Server for Jamf Pro""" 2 | __title__ = "Patch Server" 3 | __version__ = "2020.10.02" 4 | __author__ = "Bryson Tyrrell" 5 | -------------------------------------------------------------------------------- /patchserver/static/images/icon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brysontyrrell/PatchServer/HEAD/patchserver/static/images/icon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from patchserver import factory 2 | 3 | application = factory.create_app() 4 | 5 | if __name__ == "__main__": 6 | application.run(host="0.0.0.0", debug=True, threaded=True) 7 | -------------------------------------------------------------------------------- /patchserver/static/js/fontawesome/attribution.js: -------------------------------------------------------------------------------- 1 | console.log(`Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com 2 | License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 3 | `) -------------------------------------------------------------------------------- /patchserver/routes/web_ui.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints, render_template, request, url_for 2 | 3 | blueprint = blueprints.Blueprint("web_ui", __name__) 4 | 5 | 6 | @blueprint.route("/") 7 | def index(): 8 | return render_template("index.html"), 200 9 | -------------------------------------------------------------------------------- /patchserver/static/images/icon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /docs/apis/jamf_pro.rst: -------------------------------------------------------------------------------- 1 | Jamf Pro Patch API 2 | ================== 3 | 4 | Endpoints 5 | --------- 6 | 7 | .. qrefflask:: patchserver.factory:create_docs_app() 8 | :blueprints: jamf_pro 9 | :undoc-static: 10 | 11 | Reference 12 | --------- 13 | 14 | .. autoflask:: patchserver.factory:create_docs_app() 15 | :blueprints: jamf_pro 16 | :undoc-static: 17 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | sphinx = "*" 8 | sphinx-rtd-theme = "*" 9 | sphinxcontrib-httpdomain = "*" 10 | 11 | [packages] 12 | flask = "*" 13 | flask-sqlalchemy = "*" 14 | python-dateutil = "*" 15 | jsonschema = "*" 16 | requests = "*" 17 | 18 | [requires] 19 | python_version = "3.8" 20 | -------------------------------------------------------------------------------- /patchserver/exc.py: -------------------------------------------------------------------------------- 1 | class PatchServerException(Exception): 2 | pass 3 | 4 | 5 | class Unauthorized(PatchServerException): 6 | pass 7 | 8 | 9 | class InvalidPatchDefinitionError(PatchServerException): 10 | pass 11 | 12 | 13 | class SoftwareTitleNotFound(PatchServerException): 14 | pass 15 | 16 | 17 | class InvalidWebhook(PatchServerException): 18 | pass 19 | 20 | 21 | class PatchArchiveRestoreFailure(PatchServerException): 22 | pass 23 | -------------------------------------------------------------------------------- /docs/apis/ps_api.rst: -------------------------------------------------------------------------------- 1 | Patch Server API 2 | ================ 3 | 4 | .. note:: 5 | 6 | To retrieve the JSON of a patch definition on the server, refer to the 7 | :doc:`Jamf Pro Patch API documentation <./jamf_pro>`. 8 | 9 | Endpoints 10 | --------- 11 | 12 | .. qrefflask:: patchserver.factory:create_docs_app() 13 | :blueprints: api 14 | :undoc-static: 15 | 16 | Reference 17 | --------- 18 | 19 | .. autoflask:: patchserver.factory:create_docs_app() 20 | :blueprints: api 21 | :undoc-static: 22 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim-buster 2 | 3 | RUN export DEBIAN_FRONTEND=noninteractive && \ 4 | apt-get update && \ 5 | apt-get -y upgrade 6 | 7 | COPY /Pipfile* /opt/ps/ 8 | COPY /patchserver /opt/ps/patchserver 9 | COPY /docker/wsgi.py /opt/ps/ 10 | COPY /docker/config.py /opt/ps/ 11 | 12 | RUN pip install pipenv gunicorn && \ 13 | cd /opt/ps && \ 14 | pipenv install --deploy --system 15 | 16 | ENV DATABASE_DIR=/var/lib/patchserver 17 | 18 | WORKDIR /opt/ps 19 | 20 | EXPOSE 5000 21 | 22 | CMD ["gunicorn", "--config", "/opt/ps/config.py", "wsgi"] 23 | -------------------------------------------------------------------------------- /patchserver/config.py: -------------------------------------------------------------------------------- 1 | from distutils.util import strtobool 2 | import os 3 | 4 | SECRET_KEY = os.urandom(32) 5 | 6 | APP_DIR = os.path.dirname(os.path.realpath(__file__)) 7 | 8 | DEBUG = True 9 | SQL_LOGGING = False 10 | 11 | ENABLE_PROXY_SUPPORT = strtobool(os.getenv("ENABLE_PROXY_SUPPORT", "False")) 12 | 13 | DATABASE_PATH = os.path.join(os.environ.get("DATABASE_DIR", APP_DIR), "patch_server.db") 14 | 15 | SQLALCHEMY_DATABASE_URI = "sqlite:////{}".format(DATABASE_PATH) 16 | SQLALCHEMY_TRACK_MODIFICATIONS = False 17 | 18 | RESET_API_TOKEN = os.path.exists(os.path.join(APP_DIR, "reset_api_token")) 19 | -------------------------------------------------------------------------------- /docs/managing/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | If you encounter issues between Jamf Pro and your Patch Server you can investigate using the resources detailed here. 4 | 5 | ## Jamf Pro Connection Test 6 | 7 | Jamf Pro has a test option to verify it can contact the Patch Server and read software titles. In Jamf Pro, go to **Settings > Computer Management > Patch Management > ** in the management console. 8 | 9 | Click the **Test** button at the bottom of the page. 10 | 11 | ![Jamf Pro Patch Source Test](../images/jamf_setup_03.png) 12 | 13 | ## Jamf Pro Logs 14 | 15 | If you enable ``DEBUG`` mode for Jamf Pro logging, you can search for entries with ``SoftwareTitleMonitor``. The debug statements will show when the sync occurs, what titles are being requested, and the error encountered. 16 | -------------------------------------------------------------------------------- /patchserver/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import current_app 4 | 5 | from patchserver.database import db 6 | from patchserver.models import ApiToken 7 | 8 | 9 | def reset_api_token(): 10 | """Deletes the API token in the Patch Server database. 11 | 12 | This function is invoked on startup if the ``reset_api_token`` file has been 13 | written to the application directory. 14 | 15 | The file is removed when this function is invoked. 16 | """ 17 | token = ApiToken.query.first() 18 | if token: 19 | current_app.logger.info("Resetting API Token") 20 | db.session.delete(token) 21 | db.session.commit() 22 | 23 | current_app.config.pop("RESET_API_TOKEN") 24 | current_app.logger.info("Removing 'reset_api_token' stub") 25 | os.remove(os.path.join(current_app.config["APP_DIR"], "reset_api_token")) 26 | -------------------------------------------------------------------------------- /patchserver/static/images/icon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /docs/setup/local_testing.md: -------------------------------------------------------------------------------- 1 | # Local Testing 2 | 3 | > :warning: Though you can run the application locally using `run.py` it is **strongly** recommended you perform testing with Docker (use the same image you would deploy in production). See the [Docker setup documentation](docker.md) to learn more. 4 | 5 | Clone the project repository to the system that will run the application. 6 | 7 | Change into the directory for the project, create a Python virtual environment, and install the project dependencies using `pipenv`. Then use `run.py` to start the app. 8 | 9 | ```shell script 10 | % cd /path/to/PatchServer 11 | % pipenv install --deploy 12 | % pipenv run python run.py 13 | ``` 14 | 15 | You will be able to access the application using `localhost` or your computer's IP address at port `5000`. 16 | 17 | The Patch Server database file will default to `patchserver/patch_server.db` in the repository. You can change the location of this file by setting the `DATABASE_DIR` environment variable. 18 | -------------------------------------------------------------------------------- /patchserver/routes/auth.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from flask import current_app, request 4 | 5 | from patchserver.exc import Unauthorized 6 | from patchserver.models import ApiToken 7 | 8 | 9 | def api_auth(func): 10 | """A decorator for routes that requires token authentication on a request if 11 | the API token exists in the database. 12 | """ 13 | 14 | @functools.wraps(func) 15 | def wrapped(*args, **kwargs): 16 | api_token = ApiToken.query.first() 17 | if api_token: 18 | auth_header = request.headers.get("Authorization") 19 | if not auth_header: 20 | raise Unauthorized("Authentication required") 21 | 22 | schema, auth_token = auth_header.split() 23 | current_app.logger.debug(schema) 24 | current_app.logger.debug(auth_token) 25 | 26 | if schema != "Bearer" or api_token.token != auth_token: 27 | raise Unauthorized("Invalid token provided") 28 | 29 | current_app.logger.debug("Auth successful") 30 | return func(*args, **kwargs) 31 | 32 | return wrapped 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Bryson Tyrrell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ***** Jamf Title Editor ***** 2 | 3 | Jamf Pro customers running version 10.31+ have access to [Title Editor](https://www.jamf.com/blog/what-is-title-editor/), a free service to create and manage patch definitions using both a GUI and an API. It is a far more feature rich implementation of an external patch source, and users of this project should consider migrating. 4 | 5 | The Patch Server project is unlikely to recieve any new feature or maintenance updates going forward. 6 | 7 | # Patch Server for Jamf Pro 8 | 9 | An open source implementation of an external patch source for Jamf Pro. The Patch Server provides an interface for Jamf Pro subscribe to your custom software title definitions and a API for managing those definitions. 10 | 11 | ## Documentation 12 | 13 | - [User Interface](docs/managing/user_interface.md) 14 | - [Using Patch Starter Script](docs/managing/patch_starter.md) 15 | - [Troubleshooting](docs/managing/troubleshooting.md) 16 | - [Change History](docs/change_history.md) 17 | 18 | ### Setup Instructions 19 | 20 | - [Local Testing](docs/setup/local_testing.md) 21 | - [Docker Usage](docs/setup/docker.md) 22 | - [Setup Patch Server in Jamf Pro](docs/setup/in_jamf_pro.md) 23 | 24 | ### API 25 | 26 | - Coming Soon 27 | -------------------------------------------------------------------------------- /docs/managing/patch_starter.md: -------------------------------------------------------------------------------- 1 | # Using Patch Starter Script 2 | 3 | Patch Starter Script is a tool to enable admins to create patch title definitions and version data to use with the Patch Server API. 4 | 5 | The `patchstarter.py` script is available on [GitHub](https://github.com/brysontyrrell/Patch-Starter-Script). Refer to the `README` on the project's homepage for more information on usage and options. 6 | 7 | ## Create a New Title 8 | 9 | Here is a basic example of using `patchstarter.py` to generate a definition and then sending it to the patch server: 10 | 11 | ```shell script 12 | curl http://localhost:5000/api/v1/title \ 13 | -X POST \ 14 | -d "$(python patchstarter.py /Applications/GitHub\ Desktop.app -p "GitHub" )" \ 15 | -H 'Content-Type: application/json' 16 | ``` 17 | 18 | ## Update an Existing Title's Version 19 | 20 | Here is a basic example of using `patchstarter.py` to generate version data for an application and then add it to an existing title on the patch server: 21 | 22 | ```shell script 23 | curl http://localhost:5000/api/v1/title/GitHubDesktop/version \ 24 | -X POST \ 25 | -d "$(python patchstarter.py /Applications/GitHub\ Desktop.app -p "GitHub" --patch-only)" \ 26 | -H 'Content-Type: application/json' 27 | ``` 28 | -------------------------------------------------------------------------------- /patchserver/routes/validator/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from jsonschema import validate, ValidationError 5 | 6 | from patchserver.exc import InvalidPatchDefinitionError 7 | 8 | dir_path = os.path.dirname(os.path.realpath(__file__)) 9 | 10 | with open(os.path.join(dir_path, "schema_full_definition.json"), "r") as f_obj: 11 | patch_schema = json.load(f_obj) 12 | 13 | with open(os.path.join(dir_path, "schema_version.json"), "r") as f_obj: 14 | version_schema = json.load(f_obj) 15 | 16 | 17 | def validate_json(data, schema=None): 18 | """Takes a dictionary object and validates it against a JSON schema. 19 | 20 | :param dict data: The JSON to validate as a dictionary object. 21 | :param str schema: Which schema to validate against. Valid options are: 22 | patch or version. 23 | 24 | :raises: InvalidPatchDefinitionError 25 | """ 26 | if schema not in ("patch", "version"): 27 | raise ValueError("Argument 'schema' must be 'patch' or 'version'") 28 | 29 | if schema == "patch": 30 | use_schema = patch_schema 31 | else: 32 | use_schema = version_schema 33 | 34 | try: 35 | validate(data, use_schema) 36 | except ValidationError as error: 37 | message = ( 38 | "Validation error encountered with submitted JSON: {} for " 39 | "item: /{}".format( 40 | str(error.message), "/".join([str(i) for i in error.path]) 41 | ) 42 | ) 43 | raise InvalidPatchDefinitionError(message) 44 | -------------------------------------------------------------------------------- /docs/setup/in_jamf_pro.md: -------------------------------------------------------------------------------- 1 | # Setup Patch Server in Jamf Pro 2 | 3 | Configure the Patch Server as an External Patch Source in Jamf Pro. 4 | 5 | > :information_source: External Patch Sources is a feature of Jamf Pro v10.2+. 6 | 7 | To add your Patch Server as a **Patch External Source** in Jamf Pro, go to **Settings > Computer Management > Patch Management** in the management console. 8 | 9 | ![](../images/jamf_setup_01.png) 10 | 11 | Click the **+ New** button next to **Patch External Source**. On the next screen assign a name to your Patch Server. In the **SERVER** field enter the URL without with the schema or port and append `/jamf/v1`: 12 | 13 | ```text 14 | /jamf/v1 15 | ``` 16 | 17 | In the **PORT** field enter `5000` (or alternatively, the the port you configured during setup). 18 | 19 | > :information_source: Only check the **Use SSL** box if you have configured a TLS certificate and are serving traffic over HTTPS from your Patch Server. If you are using HTTP leave this box unchecked. 20 | 21 | ![](../images/jamf_setup_02.png) 22 | 23 | After saving your settings, a **Test** button will be available on the Patch Server's page. Click it to verify Jamf Pro can connect to your Patch Server and data is being received. 24 | 25 | ![](../images/jamf_setup_03.png) 26 | 27 | Your Patch Server will now be displayed on the **Patch Management** settings 28 | page. 29 | 30 | ![](../images/jamf_setup_04.png) 31 | 32 | You will now be able to add your software titles on your Patch Server from the **Computers > Patch Management > Software Titles** list. 33 | 34 | ![](../images/jamf_setup_05.png) 35 | -------------------------------------------------------------------------------- /docs/apis/auth.rst: -------------------------------------------------------------------------------- 1 | API Authentication 2 | ================== 3 | 4 | You may optionally generate an API token that will be required for all requests 5 | made to the following ``/api/v1/title*`` endpoints to prevent unauthenticated 6 | requests to create, update, or delete software titles. 7 | 8 | .. note:: 9 | 10 | The ``/jamf/v1`` and ``/api/v1/backup`` endpoints remain open and will not 11 | use the API token for authentication. 12 | 13 | .. warning:: 14 | 15 | THe UI does not yet support API authentication. You will receive a 16 | **"Unauthorized: Authentication required"** message if you attempt to use 17 | the **New Title +** or **X (delete)** options. 18 | 19 | See the :doc:`Patch Server API documentation <./ps_api>` for how to create an 20 | API token. 21 | 22 | Authenticating Requests 23 | ----------------------- 24 | 25 | If you have created an API token, you must include it with your requests in the 26 | ``Authorization`` header and the ``Bearer`` type:: 27 | 28 | Authorization: Bearer 94631ec5c65e4dd19fb81479abdd2929 29 | 30 | Requests without this header will be rejected with a ``401`` status. 31 | 32 | Retrieve/Reset the API Token 33 | ---------------------------- 34 | 35 | In the event you lose your API token, you can use a command line utillity such 36 | as ``sqlite3`` to retrieve the existing token: 37 | 38 | .. code-block:: bash 39 | 40 | $ sqlite3 patch_server.db "SELECT * FROM api_token;" 41 | 42 | If you wish to reset the token, write a stub file into the ``patchserver`` 43 | application directory named ``reset_api_token`` and restart the server. The API 44 | token will be deleted from the database and the stub file cleared. You will then 45 | be allowed to create a new API token using ``/api/v1/token``. 46 | -------------------------------------------------------------------------------- /patchserver/factory.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import logging 3 | from werkzeug.middleware.proxy_fix import ProxyFix 4 | 5 | from patchserver import config 6 | from patchserver.database import db 7 | from patchserver.routes import api, error_handlers, jamf_pro, web_ui 8 | from patchserver.utilities import reset_api_token 9 | 10 | 11 | def register_blueprints(app): 12 | """Registers blueprints with the passed ``app`` object. 13 | 14 | :param flask.Flask app: Instantiated Flask app 15 | """ 16 | app.register_blueprint(error_handlers.blueprint) 17 | app.register_blueprint(web_ui.blueprint) 18 | app.register_blueprint(api.blueprint) 19 | app.register_blueprint(jamf_pro.blueprint) 20 | 21 | 22 | def create_app(): 23 | app = Flask(__name__) 24 | app.config.from_object(config) 25 | 26 | if app.config["ENABLE_PROXY_SUPPORT"]: 27 | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_host=1, x_proto=1, x_port=1) 28 | 29 | db.init_app(app) 30 | 31 | # if not os.path.exists(config.DATABASE_PATH): 32 | with app.app_context(): 33 | db.create_all() 34 | 35 | if app.config.get("RESET_API_TOKEN"): 36 | reset_api_token() 37 | 38 | if app.config.get("SQL_LOGGING"): 39 | sql_logger = logging.getLogger("sqlalchemy.engine") 40 | 41 | for handler in app.logger.handlers: 42 | sql_logger.addHandler(handler) 43 | 44 | if app.config.get("DEBUG"): 45 | sql_logger.setLevel(logging.DEBUG) 46 | 47 | register_blueprints(app) 48 | return app 49 | 50 | 51 | def create_docs_app(): 52 | """Instantiates the Flask application object for creating documentation.""" 53 | app = Flask(__name__) 54 | register_blueprints(app) 55 | return app 56 | -------------------------------------------------------------------------------- /patchserver/routes/webhooks.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import time 3 | 4 | from flask import current_app, g, url_for 5 | import requests 6 | 7 | from patchserver.models import WebhookUrls 8 | 9 | 10 | def webhook_event(func): 11 | """A decorator that will send a webhook event notification to a all URLs 12 | saved in the Patch Server database. 13 | """ 14 | 15 | @functools.wraps(func) 16 | def wrapped(*args, **kwargs): 17 | response = func(*args, **kwargs) 18 | current_app.logger.debug("Webhook event invoked") 19 | 20 | for webhook_url in WebhookUrls.query.all(): 21 | if webhook_url.enabled: 22 | current_app.logger.debug("Sending event to: {}".format(webhook_url.url)) 23 | event = { 24 | "event": g.event_type, 25 | "timestamp": int(time.time()), 26 | "software_title": g.event["id"], 27 | "version:": g.event["currentVersion"], 28 | "url": url_for("jamf_pro.patch_by_name_id", name_id=g.event["id"]), 29 | } 30 | if webhook_url.send_definition: 31 | event["patch_definition"] = g.event 32 | 33 | try: 34 | requests.post( 35 | webhook_url.url, 36 | json=event, 37 | timeout=5, 38 | verify=webhook_url.verify_ssl, 39 | ) 40 | except requests.RequestException as err: 41 | current_app.logger.exception("The webhook failed!") 42 | 43 | else: 44 | current_app.logger.info( 45 | "The webhook is not enabled for: " "{}".format(webhook_url.url) 46 | ) 47 | 48 | current_app.logger.debug("Webhook event complete") 49 | 50 | return response 51 | 52 | return wrapped 53 | -------------------------------------------------------------------------------- /docs/managing/user_interface.md: -------------------------------------------------------------------------------- 1 | # User Interface 2 | 3 | In a browser, the root of the patch server will take you to the main page where you can view and manage the available software titles. 4 | 5 | ![](../images/gui_01_index.png) 6 | 7 | ## Upload a New Software Title 8 | 9 | Click the **New Title** button to bring up the file selector. Browse for the JSON file of the patch definition and then click **Upload**. 10 | 11 | ![](../images/gui_02_new_title.png) 12 | 13 | You will recieve a confirmation of a successful upload. 14 | 15 | ![](../images/gui_03_title_created.png) 16 | 17 | ### Upload Errors 18 | 19 | If the patch server rejects your upload of a patch definition, it will provide a notification with the reason so you can correct the cause and retry. 20 | 21 | ![](../images/gui_04_title_conflict.png) 22 | 23 | There is a conflict with an existing software title. 24 | 25 | ![](../images/gui_05_title_error.png) 26 | 27 | The patch definition failed validation, but the cause is displayed. 28 | 29 | ## Update a Title's Version 30 | 31 | Click the green **Up** icon for a title to display a file prompt. Select the JSON file containing the new version data and submit it. 32 | 33 | ![](../images/gui_06_title_update.png) 34 | 35 | You will receive a confirmation of a successful upload. 36 | 37 | ![](../images/gui_07_title_updated.png) 38 | 39 | You will also receive similar feedback for errors as with creating new titles. 40 | 41 | ## Other Title Actions 42 | 43 | There are additional actions available for each software title. 44 | 45 | - The blue **View** icon will take you to the URL of the patch definition JSON. 46 | - The red **X** icon will delete the title from the server. 47 | 48 | ![](../images/gui_08_actions.png) 49 | 50 | > :warning: The delete action cannot be undone. 51 | 52 | ## Backup Patch Definitions 53 | 54 | Click the **Backup** button and you will download a zipped archive of all patch 55 | definitions for all your software titles. 56 | 57 | > :information_source: This is a feature of the API that you can use with automation for scheduled backups of the server. 58 | 59 | ![](../images/gui_09_backups.png) 60 | -------------------------------------------------------------------------------- /docs/setup/docker.md: -------------------------------------------------------------------------------- 1 | # Docker Usage 2 | 3 | ## Build Image 4 | 5 | In the `docker` directory of the project repository is a `Dockerfile` that can be used to launch the patch server as a container. 6 | 7 | Clone the project repository to your computer. Create the Docker image with: 8 | 9 | ```shell script 10 | % cd /path/to/PatchServer 11 | % docker build --tag patchserver:latest -f docker/Dockerfile . 12 | ``` 13 | 14 | ## Run Container 15 | 16 | Run a container with the following command: 17 | 18 | ```shell script 19 | % docker run -v /:/var/lib/patchserver -p 5000:5000 patchserver 20 | ``` 21 | 22 | > :information_source: Use the `-d` option to run the container in the background. 23 | 24 | > :information_source: The `-v /:/var/lib/patchserver` option is to mount a local directory to the path in the running container where the persistent data for the patch server is stored (i.e. the database). 25 | 26 | > :warning: If you do not attach a volume to `/var/lib/patchserver` the database will be erased when the container is stopped and removed. 27 | 28 | You will be able to access the application using the IP address of the host (your computer's IP address when running Docker locally) at port `5000`. 29 | 30 | ## Configuration 31 | 32 | ### Enable Proxy Support 33 | 34 | When running Patch Server behind a reverse proxy for TLS (e.g. Nginx, Apache) redirects may send a client from `https` to `http`. If your proxy is configured to pass the `X-Forwarded-For` and `X-Forwarded-Proto` headers you can enable proxy on Patch Server via environment variable. 35 | 36 | ```shell script 37 | ENABLE_PROXY_SUPPORT=True 38 | ``` 39 | 40 | > :information_source: Use the `-e` option to pass env vars to the `docker run` command. 41 | 42 | > :information_source: See [Proxy Setups](https://flask.palletsprojects.com/en/1.1.x/deploying/wsgi-standalone/#deploying-proxy-setups) in the Deploying Flask documentation for more details. 43 | 44 | ## Performance 45 | 46 | The application, by default, runs 2 worker per available CPU plus 1 (a 2 CPU host will produce 5 workers) with 1 thread per worker. 47 | 48 | ## Advanced Usage 49 | 50 | Coming soon. 51 | -------------------------------------------------------------------------------- /patchserver/routes/error_handlers.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints, current_app, flash, jsonify, redirect, request, url_for 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from patchserver.exc import ( 5 | InvalidPatchDefinitionError, 6 | InvalidWebhook, 7 | PatchArchiveRestoreFailure, 8 | SoftwareTitleNotFound, 9 | Unauthorized, 10 | ) 11 | 12 | blueprint = blueprints.Blueprint("error_handlers", __name__) 13 | 14 | 15 | @blueprint.app_errorhandler(Unauthorized) 16 | def unauthorized(err): 17 | current_app.logger.error(err.message) 18 | 19 | if request.user_agent.browser: 20 | flash({"title": "Unauthorized", "message": err.message}, "warning") 21 | return redirect(url_for("web_ui.index")) 22 | else: 23 | return jsonify({"unauthorized": err.message}), 401 24 | 25 | 26 | @blueprint.app_errorhandler(InvalidPatchDefinitionError) 27 | def error_invalid_patch_definition(err): 28 | current_app.logger.error(err.message) 29 | 30 | if request.user_agent.browser: 31 | flash( 32 | {"title": "Invalid Patch Definition JSON", "message": err.message}, 33 | "warning", 34 | ) 35 | return redirect(url_for("web_ui.index")) 36 | else: 37 | return jsonify({"invalid_json": err.message}), 400 38 | 39 | 40 | @blueprint.app_errorhandler(InvalidWebhook) 41 | def error_invalid_webhook(err): 42 | current_app.logger.error(err.message) 43 | 44 | if request.user_agent.browser: 45 | flash({"title": "Invalid Webhook", "message": err.message}, "warning") 46 | return redirect(url_for("web_ui.index")) 47 | else: 48 | return jsonify({"invalid_json": err.message}), 400 49 | 50 | 51 | @blueprint.app_errorhandler(SoftwareTitleNotFound) 52 | def error_title_not_found(err): 53 | current_app.logger.error(err) 54 | if request.user_agent.browser: 55 | flash({"title": "Software title not found", "message": err.message}, "warning") 56 | return redirect(url_for("web_ui.index")) 57 | else: 58 | return jsonify({"title_not_found": err.message}), 404 59 | 60 | 61 | @blueprint.app_errorhandler(IntegrityError) 62 | def database_integrity_error(err): 63 | current_app.logger.exception(str(err.__dict__)) 64 | message = ( 65 | "Unable to write title to the database. A title of the given name may already." 66 | ) 67 | 68 | if request.user_agent.browser: 69 | flash({"title": "There was a conflict", "message": message}, "danger") 70 | return redirect(url_for("web_ui.index")) 71 | else: 72 | return jsonify({"message": message}), 409 73 | 74 | 75 | @blueprint.app_errorhandler(PatchArchiveRestoreFailure) 76 | def archive_restore_failure(err): 77 | current_app.logger.error(err.message) 78 | 79 | if request.user_agent.browser: 80 | flash( 81 | {"title": "Unable to Restore Patch Archive", "message": err.message}, 82 | "warning", 83 | ) 84 | return redirect(url_for("web_ui.index")) 85 | else: 86 | return jsonify({"restore_failure": err.message}), 400 87 | -------------------------------------------------------------------------------- /patchserver/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block body_content %} 3 | 4 | {% include 'modals.html' %} 5 | 6 |
7 |
8 |

9 | Patch Server 10 | 11 | Docs   12 |

13 |
14 | 15 |
16 |
17 |

18 | Software Titles 19 |
20 | 23 | 26 |
27 |

28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
Patch IDNamePublisherCurrent VersionLast Modified
46 | 47 |
48 | 49 |
50 | 51 |
52 | 53 |
54 |

55 | Webhooks 56 |
57 | 60 |
61 |

62 |
63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
URLVerify SSLSend DefinitionEnabled
76 | 77 |
78 |
79 | 80 | 84 | 85 | {% endblock %} 86 | -------------------------------------------------------------------------------- /patchserver/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Patch Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% with messages = get_flashed_messages(with_categories=true) %} 49 | {% if messages %} 50 | {% for category, message in messages %} 51 | 57 | {% endfor %} 58 | {% endif %} 59 | {% endwith %} 60 | 61 | {% block body_content %} 62 | {% endblock %} 63 | 64 | 65 | -------------------------------------------------------------------------------- /patchserver/templates/modals.html: -------------------------------------------------------------------------------- 1 | 21 | 22 | 42 | 43 | -------------------------------------------------------------------------------- /docs/change_history.md: -------------------------------------------------------------------------------- 1 | # Change History 2 | 3 | ## 2020.10.05 4 | 5 | Proxy support added. Can be toggled using the `ENABLE_PROXY_SUPPORT` environment variable. See [Docker Usage](setup/docker.md) for more details. 6 | 7 | ## 2020.10.02 8 | 9 | Project updated for Python 3.8 with updated dependencies. 10 | 11 | Removed RSS feed `/rss` (no longer included in latest version of Werkzeug). 12 | 13 | Dropped support for installing on macOS, Red Hat, and Ubuntu. 14 | 15 | Updated Dockerfile to build slim Python 3.8 image (the new image is ~198MB in size). Docker will be the only supported method of running Patch Server going forward. Patch Server remains a Flask application so manual installs can still be performed. 16 | 17 | ## 0.8.3 (2018-07-18) 18 | 19 | Implemented restoring a patch archive backup via a new `/api/v1/restore` endpoint. 20 | 21 | Switched from relative to absolute imports in code. 22 | 23 | ## 0.8.2 (2018-06-05) 24 | 25 | Fixed an issue where a mismatched or non-existing software title ID passed to `/jamf/v1/software/,<Title>` would result in a 404 error response. 26 | 27 | The expected behavior is the invalid title ID to be omitted from the results (this is the behavior of both the official Jamf patch feed and CommunityPatch). 28 | 29 | Added documentation to describe resources for troubleshooting issues between Jamf Pro and a Patch Server. 30 | 31 | ## 0.8.1 (2018-05-21) 32 | 33 | macOS quick install script. Updated daemon (removed `KeepAlive`). Updated documentation on managing the server using the Apache scripts. 34 | 35 | ## 0.8.0 (2018-05-11) 36 | 37 | Update a title's version from the UI. 38 | 39 | **This updated contains a breaking API change!** Previously, the `/api/v1/title/<title>/version` endpoint required JSON provided in the following format: 40 | 41 | ```json 42 | { 43 | "Items": [ 44 | { 45 | "version": "", 46 | "releaseDate": "", 47 | "standalone": true, 48 | "minimumOperatingSystem": "", 49 | "reboot": false, 50 | "killApps": [], 51 | "components": [], 52 | "capabilities": [], 53 | "dependencies": [] 54 | } 55 | ] 56 | } 57 | ``` 58 | 59 | The intent was to allow multiple versions to be posted at once (the underlying 60 | `create_patch_objects` function takes a list of patches), but for easier use 61 | and consistency with how the CommunityPatch API operates this has been changed. 62 | 63 | The endpoint will now expect only the JSON of the version itself: 64 | 65 | ```json 66 | { 67 | "version": "", 68 | "releaseDate": "", 69 | "standalone": true, 70 | "minimumOperatingSystem": "", 71 | "reboot": false, 72 | "killApps": [], 73 | "components": [], 74 | "capabilities": [], 75 | "dependencies": [] 76 | } 77 | ``` 78 | 79 | ## 0.7.1 (2018-05-10) 80 | 81 | Updated documentation including: 82 | 83 | - Installation instructions and resources for Ubuntu Server 16.04 84 | - A new "Managing Your Patch Server" section to help document workflows. 85 | 86 | ## 0.7.0 (2018-04-16) 87 | 88 | New webhook feature to allow the patch server to notify remote servers of changes to software titles via HTTP POSTs. 89 | 90 | ## 0.6.0 (2018-02-14) 91 | 92 | You can secure the API with token authentication (if you really want to). 93 | 94 | ## 0.5.3 (2018-02-13) 95 | 96 | Because accurate and easy to read instructions are important. 97 | 98 | ## 0.5.2 (2018-02-08) 99 | 100 | A minor renaming. 101 | 102 | ## 0.5.1 (2018-02-03) 103 | 104 | Make the GUI great(ish) again. The **New Title** button has been updated. It now prompts you to select a JSON file (the patch definition) and performs the upload. Validation is still performed on the uploaded file as with the API. 105 | 106 | The new `/api/v1/backup` feature is available in the GUI. Click the **Backup** button to trigger. 107 | 108 | The **View** button for a software title has been moved to the right and will take the user to the `/jamf/v1/patch/{title}` endpoint to view the JSON. 109 | 110 | All GUI actions now provide feeback on success or error. 111 | 112 | ## 0.5.0 (2018-02-02) 113 | 114 | Organized code. JSON validation for API. Really big documentation update (now hosted on Read the Docs). Installation instructions for macOS and Docker. 115 | 116 | Added `GET /api/v1/backup`. Download a zipped archive of all patch definitions on the server. Version history notes. 117 | 118 | > :information_source: Removed most of the UI and some API endpoints no longer required without the associated UI views. 119 | 120 | ## 0.4.3 (2018-02-01) 121 | 122 | The non-existent requirements file now exists. 123 | 124 | ## 0.4.2 (2018-01-09) 125 | 126 | Patch eligibility criteria added to software title view. 127 | 128 | ## 0.4.1 (2018-01-08) 129 | 130 | Fixed UI redirects. 131 | 132 | ## 0.4.0 (2018-01-07) 133 | 134 | Switched to Pipenv for development. 135 | 136 | ## 0.3.3 (2018-01-05) 137 | 138 | Typos and such. 139 | 140 | ## 0.3.2 (2017-10-25) 141 | 142 | Editing software title in the UI view. 143 | 144 | ## 0.3.1 (2017-10-20) 145 | 146 | Moved javascript out of the HTML and into static. Database moved to application directory. Patch title deletion. Bug fixes. 147 | 148 | ## 0.3.0 (2017-10-19) 149 | 150 | UI view for individual software titles. 151 | 152 | 153 | ## 0.2.1 (2017-10-12) 154 | 155 | Bug fix for software title creation. 156 | 157 | ## 0.2.0 (2017-08-23) 158 | 159 | Added RSS feed. 160 | 161 | ## 0.1.2 (2017-08-11) 162 | 163 | Database improvements. Proper deletion of all objects linked to a patch. 164 | 165 | ## 0.1.1 (2017-08-10) 166 | 167 | Initial GUI. Deduplication of criteria entries. Extension attribute objects. 168 | 169 | ## 0.1.0 (2017-08-09) 170 | 171 | Initial commit. 172 | -------------------------------------------------------------------------------- /patchserver/routes/jamf_pro.py: -------------------------------------------------------------------------------- 1 | from flask import blueprints, jsonify 2 | import sqlalchemy 3 | 4 | from patchserver.models import SoftwareTitle 5 | from patchserver.routes.api_operations import lookup_software_title 6 | 7 | blueprint = blueprints.Blueprint("jamf_pro", __name__, url_prefix="/jamf/v1") 8 | 9 | 10 | @blueprint.route("/software", methods=["GET"]) 11 | def software_titles(): 12 | """Returns all available software titles on server. 13 | 14 | .. :quickref: Software Title; List all software titles. 15 | 16 | **Example Request:** 17 | 18 | .. sourcecode:: http 19 | 20 | GET /jamf/v1/software HTTP/1.1 21 | Accept: application/json 22 | 23 | **Example Response:** 24 | 25 | A successful response will return a ``200`` status and an array of software 26 | title summaries. 27 | 28 | .. sourcecode:: http 29 | 30 | HTTP/1.1 200 OK 31 | Content-Type: application/json 32 | 33 | [ 34 | { 35 | "currentVersion": "10.1.1", 36 | "id": "Composer", 37 | "lastModified": "2018-02-02T17:39:58Z", 38 | "name": "Composer", 39 | "publisher": "Jamf" 40 | }, 41 | { 42 | "currentVersion": "10.1.1", 43 | "id": "JamfAdmin", 44 | "lastModified": "2018-02-02T17:39:51Z", 45 | "name": "Jamf Admin", 46 | "publisher": "Jamf" 47 | }, 48 | { 49 | "currentVersion": "10.1.1", 50 | "id": "JamfImaging", 51 | "lastModified": "2018-02-02T17:39:53Z", 52 | "name": "Jamf Imaging", 53 | "publisher": "Jamf" 54 | }, 55 | { 56 | "currentVersion": "10.1.1", 57 | "id": "JamfRemote", 58 | "lastModified": "2018-02-02T17:39:56Z", 59 | "name": "Jamf Remote", 60 | "publisher": "Jamf" 61 | } 62 | ] 63 | 64 | """ 65 | titles = SoftwareTitle.query.all() 66 | return jsonify([title.serialize_short for title in titles]), 200 67 | 68 | 69 | @blueprint.route("/software/<name_ids>") 70 | def software_titles_select(name_ids): 71 | """Returns a selection of software titles on server. The software title IDs 72 | must be passed as a comma separated string. 73 | 74 | .. :quickref: Software Title; List selected software titles. 75 | 76 | **Example Request:** 77 | 78 | .. sourcecode:: http 79 | 80 | GET /jamf/v1/software/Composer,JamfImaging HTTP/1.1 81 | Accept: application/json 82 | 83 | **Example Response:** 84 | 85 | A successful response will return a ``200`` status and an array of software 86 | title summaries. 87 | 88 | .. sourcecode:: http 89 | 90 | HTTP/1.1 200 OK 91 | Content-Type: application/json 92 | 93 | [ 94 | { 95 | "currentVersion": "10.1.1", 96 | "id": "Composer", 97 | "lastModified": "2018-02-02T17:39:58Z", 98 | "name": "Composer", 99 | "publisher": "Jamf" 100 | }, 101 | { 102 | "currentVersion": "10.1.1", 103 | "id": "JamfImaging", 104 | "lastModified": "2018-02-02T17:39:53Z", 105 | "name": "Jamf Imaging", 106 | "publisher": "Jamf" 107 | } 108 | ] 109 | 110 | **Error Responses** 111 | 112 | .. sourcecode:: http 113 | 114 | GET /jamf/v1/software/Composers,JamfImager HTTP/1.1 115 | Accept: application/json 116 | 117 | A ``404`` status is returned if any of the specified software titles do not 118 | exist. 119 | 120 | .. sourcecode:: http 121 | 122 | HTTP/1.1 404 Not Found 123 | Content-Type: application/json 124 | 125 | { 126 | "title_not_found": [ 127 | "Composers", 128 | "JamfImager" 129 | ] 130 | } 131 | 132 | """ 133 | # Comma separated list of name IDs 134 | name_id_list = name_ids.split(",") 135 | title_list = SoftwareTitle.query.filter( 136 | sqlalchemy.or_(SoftwareTitle.id_name.in_(name_id_list)) 137 | ).all() 138 | 139 | return jsonify([title.serialize_short for title in title_list]), 200 140 | 141 | 142 | @blueprint.route("/patch/<name_id>") 143 | def patch_by_name_id(name_id): 144 | """Returns a selection of software titles on server. The software title IDs 145 | must be passed as a comma separated string. 146 | 147 | .. :quickref: Software Title; Return a patch definition. 148 | 149 | **Example Request:** 150 | 151 | .. sourcecode:: http 152 | 153 | GET /jamf/v1/patch/Composer HTTP/1.1 154 | Accept: application/json 155 | 156 | **Example Response:** 157 | 158 | A successful response will return a ``200`` status and the full patch 159 | definition for the specified software title. 160 | 161 | .. sourcecode:: http 162 | 163 | HTTP/1.1 200 OK 164 | Content-Type: application/json 165 | 166 | { 167 | "id": "Composer", 168 | "name": "Composer", 169 | "publisher": "Jamf", 170 | "appName": "Composer.app", 171 | "bundleId": "com.jamfsoftware.Composer", 172 | "requirements": ["requirementObjects"], 173 | "patches": ["versionObjects"], 174 | "extensionAttributes": ["extensionAttributeObjects"] 175 | } 176 | 177 | **Error Responses** 178 | 179 | .. sourcecode:: http 180 | 181 | GET /jamf/v1/software/Composers HTTP/1.1 182 | Accept: application/json 183 | 184 | A ``404`` status is returned if any of the specified software title does not 185 | exist. 186 | 187 | .. sourcecode:: http 188 | 189 | HTTP/1.1 404 Not Found 190 | Content-Type: application/json 191 | 192 | { 193 | "title_not_found": "Composers" 194 | } 195 | 196 | """ 197 | return jsonify(lookup_software_title(name_id).serialize), 200 198 | -------------------------------------------------------------------------------- /patchserver/routes/validator/schema_version.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "http://example.com/example.json", 3 | "type": "object", 4 | "definitions": {}, 5 | "$schema": "http://json-schema.org/draft-06/schema#", 6 | "properties": { 7 | "version": { 8 | "$id": "/properties/version", 9 | "type": "string", 10 | "examples": [ 11 | "10.1.1" 12 | ] 13 | }, 14 | "releaseDate": { 15 | "$id": "/properties/releaseDate", 16 | "type": "string", 17 | "examples": [ 18 | "2017-12-20T10:08:38.270Z" 19 | ] 20 | }, 21 | "standalone": { 22 | "$id": "/properties/standalone", 23 | "type": "boolean", 24 | "examples": [ 25 | true 26 | ] 27 | }, 28 | "minimumOperatingSystem": { 29 | "$id": "/properties/minimumOperatingSystem", 30 | "type": "string", 31 | "examples": [ 32 | "10.9" 33 | ] 34 | }, 35 | "reboot": { 36 | "$id": "/properties/reboot", 37 | "type": "boolean", 38 | "examples": [ 39 | false 40 | ] 41 | }, 42 | "killApps": { 43 | "$id": "/properties/killApps", 44 | "type": "array", 45 | "items": { 46 | "$id": "/properties/killApps/items", 47 | "type": "object", 48 | "properties": { 49 | "bundleId": { 50 | "$id": "/properties/killApps/items/properties/bundleId", 51 | "type": "string", 52 | "examples": [ 53 | "com.jamfsoftware.Composer" 54 | ] 55 | }, 56 | "appName": { 57 | "$id": "/properties/killApps/items/properties/appName", 58 | "type": "string", 59 | "examples": [ 60 | "Composer.app" 61 | ] 62 | } 63 | }, 64 | "required": [ 65 | "bundleId", 66 | "appName" 67 | ] 68 | } 69 | }, 70 | "components": { 71 | "$id": "/properties/components", 72 | "type": "array", 73 | "items": { 74 | "$id": "/properties/components/items", 75 | "type": "object", 76 | "properties": { 77 | "name": { 78 | "$id": "/properties/components/items/properties/name", 79 | "type": "string", 80 | "examples": [ 81 | "Composer" 82 | ] 83 | }, 84 | "version": { 85 | "$id": "/properties/components/items/properties/version", 86 | "type": "string", 87 | "examples": [ 88 | "10.1.1" 89 | ] 90 | }, 91 | "criteria": { 92 | "$id": "/properties/components/items/properties/criteria", 93 | "type": "array", 94 | "items": { 95 | "$id": "/properties/components/items/properties/criteria/items", 96 | "type": "object", 97 | "properties": { 98 | "name": { 99 | "$id": "/properties/components/items/properties/criteria/items/properties/name", 100 | "type": "string", 101 | "examples": [ 102 | "Application Bundle ID" 103 | ] 104 | }, 105 | "operator": { 106 | "$id": "/properties/components/items/properties/criteria/items/properties/operator", 107 | "type": "string", 108 | "examples": [ 109 | "is" 110 | ] 111 | }, 112 | "value": { 113 | "$id": "/properties/components/items/properties/criteria/items/properties/value", 114 | "type": "string", 115 | "examples": [ 116 | "com.jamfsoftware.Composer" 117 | ] 118 | }, 119 | "type": { 120 | "$id": "/properties/components/items/properties/criteria/items/properties/type", 121 | "type": "string", 122 | "examples": [ 123 | "recon" 124 | ] 125 | }, 126 | "and": { 127 | "$id": "/properties/components/items/properties/criteria/items/properties/and", 128 | "type": "boolean", 129 | "examples": [ 130 | true 131 | ] 132 | } 133 | }, 134 | "required": [ 135 | "name", 136 | "operator", 137 | "value", 138 | "type" 139 | ] 140 | } 141 | } 142 | }, 143 | "required": [ 144 | "name", 145 | "version", 146 | "criteria" 147 | ] 148 | } 149 | }, 150 | "capabilities": { 151 | "$id": "/properties/capabilities", 152 | "type": "array", 153 | "items": { 154 | "$id": "/properties/capabilities/items", 155 | "type": "object", 156 | "properties": { 157 | "name": { 158 | "$id": "/properties/capabilities/items/properties/name", 159 | "type": "string", 160 | "examples": [ 161 | "Operating System Version" 162 | ] 163 | }, 164 | "operator": { 165 | "$id": "/properties/capabilities/items/properties/operator", 166 | "type": "string", 167 | "examples": [ 168 | "greater than or equal" 169 | ] 170 | }, 171 | "value": { 172 | "$id": "/properties/capabilities/items/properties/value", 173 | "type": "string", 174 | "examples": [ 175 | "10.9" 176 | ] 177 | }, 178 | "type": { 179 | "$id": "/properties/capabilities/items/properties/type", 180 | "type": "string", 181 | "examples": [ 182 | "recon" 183 | ] 184 | } 185 | }, 186 | "required": [ 187 | "name", 188 | "operator", 189 | "value", 190 | "type" 191 | ] 192 | } 193 | }, 194 | "dependencies": { 195 | "$id": "/properties/dependencies", 196 | "type": "array" 197 | } 198 | }, 199 | "required": [ 200 | "version", 201 | "releaseDate", 202 | "standalone", 203 | "minimumOperatingSystem", 204 | "reboot", 205 | "killApps", 206 | "components", 207 | "capabilities" 208 | ] 209 | } 210 | -------------------------------------------------------------------------------- /patchserver/static/js/custom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by brysontyrrell on 8/1/17. 3 | */ 4 | 5 | function compare(a,b) { 6 | if (a.id < b.id) 7 | return -1; 8 | if (a.id > b.id) 9 | return 1; 10 | return 0; 11 | } 12 | 13 | 14 | function ConvertFormToJSON(form){ 15 | var array = jQuery(form).serializeArray(); 16 | var json = {}; 17 | 18 | jQuery.each(array, function() { 19 | json[this.name] = this.value || ''; 20 | }); 21 | 22 | return json; 23 | } 24 | 25 | 26 | // https://gist.github.com/kmaida/6045266 27 | function ConvertTimestamp(timestamp) { 28 | var d = new Date(timestamp * 1000), // Convert the passed timestamp to milliseconds 29 | yyyy = d.getFullYear(), 30 | mm = ('0' + (d.getMonth() + 1)).slice(-2), // Months are zero based. Add leading 0. 31 | dd = ('0' + d.getDate()).slice(-2), // Add leading 0. 32 | hh = d.getHours(), 33 | h = hh, 34 | min = ('0' + d.getMinutes()).slice(-2), // Add leading 0. 35 | ampm = 'AM', 36 | time; 37 | 38 | if (hh > 12) { 39 | h = hh - 12; 40 | ampm = 'PM'; 41 | } else if (hh === 12) { 42 | h = 12; 43 | ampm = 'PM'; 44 | } else if (hh === 0) { 45 | h = 12; 46 | } 47 | 48 | // ie: 2013-02-18, 8:35 AM 49 | time = yyyy + '-' + mm + '-' + dd + ' ' + h + ':' + min + ' ' + ampm; 50 | 51 | return time; 52 | } 53 | 54 | 55 | /** 56 | * Functions for index.html 57 | */ 58 | function listSoftwareTitles() { 59 | 60 | $('#patch-list').DataTable({ 61 | "paging": false, 62 | "searching": false, 63 | "bInfo": false, 64 | "language": { 65 | "zeroRecords": "No Patch Definitions Found" 66 | }, 67 | "ajax": { 68 | "url": "jamf/v1/software", 69 | "dataSrc": "" 70 | }, 71 | "columnDefs": [ 72 | { "targets": 0, "data": "id" }, 73 | { 74 | "targets": 1, 75 | "orderable": false, 76 | "data": "id", 77 | "render": function ( value, type, row, meta ) { 78 | return '<button class="btn btn-info btn-sm" onclick="window.location.href=\'../jamf/v1/patch/' + value + '\'">' + 79 | '<i class="fas fa-eye"></i></button>'; 80 | } 81 | }, 82 | { "targets": 2, "orderable": false, "data": "name" }, 83 | { "targets": 3, "data": "publisher" }, 84 | { "targets": 4, "data": "currentVersion" }, 85 | { 86 | "targets": 5, 87 | "orderable": false, 88 | "data": "id", 89 | "render": function ( value, type, row, meta ) { 90 | return '<button class="btn btn-success btn-sm" data-toggle="modal" data-target="#titleVersionModal"' + 91 | 'onclick="document.getElementById(\'titleVersionModalName\').innerHTML=\'' + value + '\'; document.getElementById(\'titleVersionModalForm\').action=\'/api/v1/title/' + value + '/version\'">' + 92 | '<i class="fas fa-chevron-up"></i></button>'; 93 | } 94 | }, 95 | { "targets": 6, "data": "lastModified" }, 96 | { 97 | "targets": 7, 98 | "orderable": false, 99 | "data": "id", 100 | "render": function ( value, type, row, meta ) { 101 | return '<button id="' + value + '" class="btn btn-danger btn-sm" onclick="indexDeletePatch(this.id)">' + 102 | '<i class="fas fa-times"></i></button>'; 103 | } 104 | } 105 | ] 106 | }); 107 | 108 | } 109 | 110 | function listWebhooks() { 111 | 112 | $('#webhook-list').DataTable({ 113 | "paging": false, 114 | "searching": false, 115 | "bInfo": false, 116 | "language": { 117 | "zeroRecords": "No Webhooks Have Been Configured" 118 | }, 119 | "ajax": { 120 | "url": "api/v1/webhooks", 121 | "dataSrc": "" 122 | }, 123 | "columnDefs": [ 124 | { "targets": 0, "data": "url" }, 125 | { "targets": 1, "orderable": false, "data": "verify_ssl" }, 126 | { "targets": 2, "orderable": false, "data": "send_definition" }, 127 | { "targets": 3, "data": "enabled" }, 128 | { 129 | "targets": 4, 130 | "orderable": false, 131 | "data": "id", 132 | "render": function ( value, type, row, meta ) { 133 | return '<button id="' + value + '" class="btn btn-danger btn-xs" onclick="indexDeleteWebhook(this.id)">' + 134 | '<span class="glyphicon glyphicon-remove"></button>'; 135 | } 136 | } 137 | ] 138 | }); 139 | 140 | } 141 | 142 | 143 | //function indexAddPatch() { 144 | // var registerForm = $('#addPatchForm'); 145 | // 146 | // registerForm.on('submit', function (event) { 147 | // //stop submit the form, we will post it manually. 148 | // event.preventDefault(); 149 | // var jsonData = ConvertFormToJSON(registerForm); 150 | // $("#addPatchFormSubmit").prop("disabled", true); 151 | // 152 | // $.ajax({ 153 | // type: "POST", 154 | // url: "../api/v1/title", 155 | // dataType: 'json', 156 | // contentType: "application/json", 157 | // data: JSON.stringify(jsonData), 158 | // cache: false, 159 | // success: function (data) { 160 | // console.log("SUCCESS: ", data); 161 | // window.location.href = '../'; 162 | // }, 163 | // error: function (e) { 164 | // console.log("ERROR: ", e); 165 | // console.log("ERROR MSG: ", e.responseText); 166 | // window.location.href = '../'; 167 | // } 168 | // }); 169 | // }); 170 | //} 171 | 172 | 173 | function indexDeletePatch(name_id) { 174 | $.ajax({ 175 | type: 'DELETE', 176 | url: "../api/v1/title/" + name_id, 177 | cache: false, 178 | success: function (data) { 179 | location.reload(); 180 | }, 181 | error: function (e) { 182 | console.log("ERROR: ", e); 183 | console.log("ERROR MSG: ", e.responseText); 184 | location.reload(); 185 | } 186 | }); 187 | } 188 | 189 | function indexDeleteWebhook(id) { 190 | $.ajax({ 191 | type: 'DELETE', 192 | url: "../api/v1/webhooks/" + id, 193 | cache: false, 194 | success: function (data) { 195 | console.log('SUCCESS'); 196 | location.reload(); 197 | }, 198 | error: function (e) { 199 | console.log("ERROR: ", e); 200 | console.log("ERROR MSG: ", e.responseText); 201 | location.reload(); 202 | } 203 | }); 204 | } -------------------------------------------------------------------------------- /patchserver/static/js/bootstrap-filestyle.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * bootstrap-filestyle 3 | * doc: http://markusslima.github.io/bootstrap-filestyle/ 4 | * github: https://github.com/markusslima/bootstrap-filestyle 5 | * 6 | * Copyright (c) 2017 Markus Vinicius da Silva Lima 7 | * Version 2.1.0 8 | * Licensed under the MIT license. 9 | */ 10 | !function(e){"use strict";var t=0,i=function(t,i){this.options=i,this.$elementFilestyle=[],this.$element=e(t)};i.prototype={clear:function(){this.$element.val(""),this.$elementFilestyle.find(":text").val(""),this.$elementFilestyle.find(".badge").remove()},destroy:function(){this.$element.removeAttr("style").removeData("filestyle"),this.$elementFilestyle.remove()},disabled:function(e){return e!==!0&&e!==!1?this.options.disabled:(this.options.disabled=e,this.$element.prop("disabled",this.options.disabled),this.$elementFilestyle.find("label").prop("disabled",this.options.disabled),this.options.disabled?this.$elementFilestyle.find("label").css("opacity","0.65"):this.$elementFilestyle.find("label").css("opacity","1"),void 0)},dragdrop:function(e){return e!==!0&&e!==!1?this.options.dragdrop:void(this.options.dragdrop=e)},buttonBefore:function(e){if(e===!0)this.options.buttonBefore||(this.options.buttonBefore=e,this.options.input&&(this.$elementFilestyle.remove(),this.constructor(),this.pushNameFiles()));else{if(e!==!1)return this.options.buttonBefore;this.options.buttonBefore&&(this.options.buttonBefore=e,this.options.input&&(this.$elementFilestyle.remove(),this.constructor(),this.pushNameFiles()))}},input:function(e){if(e===!0)this.options.input||(this.options.input=e,this.options.buttonBefore?this.$elementFilestyle.append(this.htmlInput()):this.$elementFilestyle.prepend(this.htmlInput()),this.pushNameFiles(),this.$elementFilestyle.find(".group-span-filestyle").addClass("input-group-btn"));else{if(e!==!1)return this.options.input;this.options.input&&(this.options.input=e,this.$elementFilestyle.find(":text").remove(),this.$elementFilestyle.find(".group-span-filestyle").removeClass("input-group-btn"))}},size:function(e){if(void 0===e)return this.options.size;this.options.size=e;var t=this.$elementFilestyle.find("label"),i=this.$elementFilestyle.find("input");t.removeClass("btn-lg btn-sm"),i.removeClass("form-control-lg form-control-sm"),"nr"!=this.options.size&&(t.addClass("btn-"+this.options.size),i.addClass("form-control-"+this.options.size))},placeholder:function(e){return void 0===e?this.options.placeholder:(this.options.placeholder=e,void this.$elementFilestyle.find("input").attr("placeholder",e))},text:function(e){return void 0===e?this.options.text:(this.options.text=e,void this.$elementFilestyle.find("label .text").html(this.options.text))},btnClass:function(e){return void 0===e?this.options.btnClass:(this.options.btnClass=e,void this.$elementFilestyle.find("label").attr({"class":"btn "+this.options.btnClass+" btn-"+this.options.size}))},badge:function(e){if(e===!0){this.options.badge=e;var t=this.pushNameFiles();this.$elementFilestyle.find("label").append(' <span class="badge '+this.options.badgeName+'">'+t.length+"</span>")}else{if(e!==!1)return this.options.badge;this.options.badge=e,this.$elementFilestyle.find(".badge").remove()}},badgeName:function(e){return void 0===e?this.options.badgeName:(this.options.badgeName=e,void this.$elementFilestyle.find(".badge").attr({"class":"badge "+this.options.badgeName}))},htmlIcon:function(e){return void 0!==e&&(this.options.htmlIcon=e),this.options.htmlIcon},htmlInput:function(){return this.options.input?'<input type="text" class="form-control '+("nr"==this.options.size?"":"form-control-"+this.options.size)+'" placeholder="'+this.options.placeholder+'" disabled> ':""},pushNameFiles:function(){var e="",t=[];void 0===this.$element[0].files?t[0]={name:this.$element[0]&&this.$element[0].value}:t=this.$element[0].files;for(var i=0;i<t.length;i++)e+=t[i].name.split("\\").pop()+", ";return""!==e?this.$elementFilestyle.find(":text").val(e.replace(/\, $/g,"")):this.$elementFilestyle.find(":text").val(""),t},constructor:function(){var i=this,n="",s=i.$element.attr("id"),l="";""!==s&&s||(s="filestyle-"+t,i.$element.attr({id:s}),t++),l='<span class="group-span-filestyle '+(i.options.input?"input-group-btn":"")+'"><label for="'+s+'" style="margin-bottom: 0;" class="btn '+i.options.btnClass+" "+("nr"==i.options.size?"":"btn-"+i.options.size)+'" '+(i.options.disabled||i.$element.attr("disabled")?' disabled="true"':"")+">"+i.htmlIcon()+'<span class="buttonText">'+i.options.text+"</span></label></span>",n=i.options.buttonBefore?l+i.htmlInput():i.htmlInput()+l,i.$elementFilestyle=e('<div class="bootstrap-filestyle input-group"><div name="filedrag"></div>'+n+"</div>"),i.$elementFilestyle.find(".group-span-filestyle").attr("tabindex","0").keypress(function(e){return 13===e.keyCode||32===e.charCode?(i.$elementFilestyle.find("label").click(),!1):void 0}),i.$element.css({position:"absolute",clip:"rect(0px 0px 0px 0px)"}).attr("tabindex","-1").after(i.$elementFilestyle),i.$elementFilestyle.find(i.options.buttonBefore?"label":":input").css({"border-top-left-radius":".25rem","border-bottom-left-radius":".25rem"}),i.$elementFilestyle.find('[name="filedrag"]').css({position:"absolute",width:"100%",height:i.$elementFilestyle.height()+"px","z-index":-1}),(i.options.disabled||i.$element.attr("disabled"))&&(i.$element.attr("disabled","true"),i.options.disabled?i.$elementFilestyle.find("label").css("opacity","0.65"):i.$elementFilestyle.find("label").css("opacity","1")),i.$element.change(function(){var e=i.pushNameFiles();i.options.badge?0==i.$elementFilestyle.find(".badge").length?i.$elementFilestyle.find("label").append(' <span class="badge '+i.options.badgeName+'">'+e.length+"</span>"):0==e.length?i.$elementFilestyle.find(".badge").remove():i.$elementFilestyle.find(".badge").html(e.length):i.$elementFilestyle.find(".badge").remove(),i.options.onChange(e)}),window.navigator.userAgent.search(/firefox/i)>-1&&i.$elementFilestyle.find("label").click(function(){return i.$element.click(),!1}),e(document).on("dragover",function(t){t.preventDefault(),t.stopPropagation(),i.options.dragdrop&&e('[name="filedrag"]').css("z-index","9")}).on("drop",function(t){t.preventDefault(),t.stopPropagation(),i.options.dragdrop&&e('[name="filedrag"]').css("z-index","-1")}),i.$elementFilestyle.find('[name="filedrag"]').on("dragover",function(e){e.preventDefault(),e.stopPropagation()}).on("dragenter",function(e){e.preventDefault(),e.stopPropagation()}).on("drop",function(t){if(t.originalEvent.dataTransfer&&!i.options.disabled&&i.options.dragdrop&&t.originalEvent.dataTransfer.files.length){t.preventDefault(),t.stopPropagation(),i.$element[0].files=t.originalEvent.dataTransfer.files;var n=i.pushNameFiles();i.options.badge?0==i.$elementFilestyle.find(".badge").length?i.$elementFilestyle.find("label").append(' <span class="badge '+i.options.badgeName+'">'+n.length+"</span>"):0==n.length?i.$elementFilestyle.find(".badge").remove():i.$elementFilestyle.find(".badge").html(n.length):i.$elementFilestyle.find(".badge").remove(),e('[name="filedrag"]').css("z-index","-1")}})}};var n=e.fn.filestyle;e.fn.filestyle=function(t,n){var s="",l=this.each(function(){if("file"===e(this).attr("type")){var l=e(this),o=l.data("filestyle"),a=e.extend({},e.fn.filestyle.defaults,t,"object"==typeof t&&t);o||(l.data("filestyle",o=new i(this,a)),o.constructor()),"string"==typeof t&&(s=o[t](n))}});return void 0!==typeof s?s:l},e.fn.filestyle.defaults={text:"Choose file",htmlIcon:"",btnClass:"btn-secondary",size:"nr",input:!0,badge:!1,badgeName:"badge-light",buttonBefore:!1,dragdrop:!0,disabled:!1,placeholder:"",onChange:function(){}},e.fn.filestyle.noConflict=function(){return e.fn.filestyle=n,this},e(function(){e(".filestyle").each(function(){var t=e(this),i={input:"false"!==t.attr("data-input"),htmlIcon:t.attr("data-icon"),buttonBefore:"true"===t.attr("data-buttonBefore"),disabled:"true"===t.attr("data-disabled"),size:t.attr("data-size"),text:t.attr("data-text"),btnClass:t.attr("data-btnClass"),badge:"true"===t.attr("data-badge"),dragdrop:"false"!==t.attr("data-dragdrop"),badgeName:t.attr("data-badgeName"),placeholder:t.attr("data-placeholder")};t.filestyle(i)})})}(window.jQuery); -------------------------------------------------------------------------------- /patchserver/routes/api_operations.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import dateutil.parser 3 | import hashlib 4 | import json 5 | import os 6 | import tempfile 7 | import shutil 8 | import zipfile 9 | 10 | from patchserver.database import db 11 | from patchserver.models import ( 12 | Criteria, 13 | ExtensionAttribute, 14 | Patch, 15 | PatchComponent, 16 | PatchCompontentCriteria, 17 | PatchCriteria, 18 | PatchKillApps, 19 | SoftwareTitle, 20 | SoftwareTitleCriteria 21 | ) 22 | from patchserver.exc import ( 23 | InvalidPatchDefinitionError, 24 | PatchArchiveRestoreFailure, 25 | SoftwareTitleNotFound 26 | ) 27 | from patchserver.routes.validator import validate_json 28 | 29 | 30 | def lookup_software_title(name_id): 31 | title = SoftwareTitle.query.filter_by(id_name=name_id).first() 32 | if not title: 33 | raise SoftwareTitleNotFound(name_id) 34 | else: 35 | return title 36 | 37 | 38 | def get_last_index_value(model, model_attribute, filter_arg): 39 | result = model.query.with_entities( 40 | model.index).filter( 41 | getattr(model, model_attribute) == filter_arg).order_by( 42 | model.index.desc()).first() 43 | 44 | return result[0] if result else 0 45 | 46 | 47 | def create_title(data): 48 | new_title = SoftwareTitle( 49 | id_name=data['id'], 50 | name=data['name'], 51 | publisher=data['publisher'], 52 | app_name=data['appName'], 53 | bundle_id=data['bundleId'] 54 | ) 55 | db.session.add(new_title) 56 | 57 | if data.get('requirements'): 58 | create_criteria_objects( 59 | data['requirements'], software_title=new_title) 60 | 61 | if data.get('patches'): 62 | create_patch_objects( 63 | list(reversed(data['patches'])), software_title=new_title) 64 | 65 | if data.get('extensionAttributes'): 66 | create_extension_attributes( 67 | data['extensionAttributes'], new_title) 68 | 69 | db.session.commit() 70 | return new_title 71 | 72 | 73 | def create_criteria_objects(criteria_list, software_title=None, 74 | patch_object=None, patch_component=None): 75 | """ 76 | [ 77 | <criteria_object_1>, 78 | <criteria_object_2>, 79 | <criteria_object_3> 80 | ] 81 | """ 82 | def eval_bool(value): 83 | try: 84 | return ast.literal_eval(value) 85 | except ValueError: 86 | return bool(value) 87 | 88 | for criterion in criteria_list: 89 | print(criterion) 90 | criteria_hash = hashlib.sha1( 91 | (criterion['name'] + 92 | criterion['operator'] + 93 | criterion['value'] + 94 | criterion['type'] + 95 | str(eval_bool(criterion.get('and', True)))).encode('utf-8') 96 | ).hexdigest() 97 | 98 | criteria = Criteria.query.filter_by(hash=criteria_hash).first() 99 | if not criteria: 100 | criteria = Criteria( 101 | name=criterion['name'], 102 | operator=criterion['operator'], 103 | value=criterion['value'], 104 | type_=criterion['type'], 105 | and_=eval_bool(criterion.get('and', True)) 106 | ) 107 | 108 | if software_title: 109 | last_index = get_last_index_value( 110 | SoftwareTitleCriteria, 111 | 'software_title', 112 | software_title) 113 | 114 | db.session.add( 115 | SoftwareTitleCriteria( 116 | software_title=software_title, 117 | criteria=criteria, 118 | index=last_index + 1 119 | ) 120 | ) 121 | elif patch_object: 122 | last_index = get_last_index_value( 123 | PatchCriteria, 124 | 'patch', 125 | patch_object) 126 | 127 | db.session.add( 128 | PatchCriteria( 129 | patch=patch_object, 130 | criteria=criteria, 131 | index=last_index + 1 132 | ) 133 | ) 134 | elif patch_component: 135 | last_index = get_last_index_value( 136 | PatchCompontentCriteria, 137 | 'patch_component', 138 | patch_component) 139 | 140 | db.session.add( 141 | PatchCompontentCriteria( 142 | patch_component=patch_component, 143 | criteria=criteria, 144 | index=last_index + 1 145 | ) 146 | ) 147 | 148 | db.session.add(criteria) 149 | 150 | 151 | def create_extension_attributes(ext_att_list, software_title): 152 | for ext_att in ext_att_list: 153 | db.session.add( 154 | ExtensionAttribute( 155 | key=ext_att['key'], 156 | value=ext_att['value'], 157 | display_name=ext_att['displayName'], 158 | software_title=software_title 159 | ) 160 | ) 161 | 162 | 163 | def create_patch_objects(patch_list, software_title): 164 | """""" 165 | for patch in patch_list: 166 | new_patch = Patch( 167 | version=patch['version'], 168 | release_date=dateutil.parser.parse(patch['releaseDate']), 169 | standalone=patch['standalone'], 170 | minimum_operating_system=patch['minimumOperatingSystem'], 171 | reboot=patch['reboot'], 172 | software_title=software_title 173 | ) 174 | db.session.add(new_patch) 175 | 176 | if patch.get('capabilities'): 177 | create_criteria_objects( 178 | patch['capabilities'], patch_object=new_patch) 179 | 180 | if patch.get('components'): 181 | create_patch_object_components( 182 | patch['components'], patch_object=new_patch) 183 | 184 | if patch.get('killApps'): 185 | create_patch_object_kill_apps( 186 | patch['killApps'], patch_object=new_patch) 187 | 188 | 189 | def create_patch_object_components(component_list, patch_object): 190 | for component in component_list: 191 | new_component = PatchComponent( 192 | name=component['name'], 193 | version=component['version'], 194 | patch=patch_object 195 | ) 196 | db.session.add(new_component) 197 | 198 | if component.get('criteria'): 199 | create_criteria_objects( 200 | component['criteria'], patch_component=new_component) 201 | 202 | 203 | def create_patch_object_kill_apps(kill_apps_list, patch_object): 204 | for kill_app in kill_apps_list: 205 | new_kill_app = PatchKillApps( 206 | bundleId=kill_app['bundleId'], 207 | appName=kill_app['appName'], 208 | patch=patch_object 209 | ) 210 | db.session.add(new_kill_app) 211 | 212 | 213 | def create_backup_archive(): 214 | titles = SoftwareTitle.query.all() 215 | tempdir = tempfile.mkdtemp(prefix='patch-dump-') 216 | 217 | for title in titles: 218 | filename = '{}.json'.format(title.id_name) 219 | with open(os.path.join(tempdir, filename), 'w') as f_obj: 220 | json.dump(title.serialize, f_obj) 221 | 222 | archive_list = os.listdir(tempdir) 223 | archive_path = tempfile.mkstemp(prefix='patch-archive-')[1] 224 | 225 | with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zip: 226 | for filename in archive_list: 227 | zip.write( 228 | os.path.join(tempdir, filename), 229 | os.path.join('patch_archive', filename) 230 | ) 231 | 232 | shutil.rmtree(tempdir) 233 | return archive_path 234 | 235 | 236 | def restore_backup_archive(uploaded_archive): 237 | if os.path.splitext(uploaded_archive.filename)[-1] != '.zip': 238 | raise PatchArchiveRestoreFailure( 239 | 'The submitted file is not a .zip archive') 240 | 241 | if len(SoftwareTitle.query.all()) != 0: 242 | raise PatchArchiveRestoreFailure( 243 | 'Definitions already exist on this server') 244 | 245 | definitions_to_restore = list() 246 | 247 | with zipfile.ZipFile(uploaded_archive, 'r') as zip_file: 248 | for file_ in zip_file.namelist(): 249 | data = json.loads(zip_file.read(file_)) 250 | 251 | try: 252 | validate_json(data, 'patch') 253 | except InvalidPatchDefinitionError: 254 | raise PatchArchiveRestoreFailure( 255 | 'A definition in the archive failed validation') 256 | 257 | definitions_to_restore.append(data) 258 | 259 | saved_definitions = list() 260 | 261 | for definition in definitions_to_restore: 262 | saved_def = create_title(definition) 263 | saved_definitions.append( 264 | { 265 | "database_id": saved_def.id, 266 | "id": saved_def.id_name 267 | } 268 | ) 269 | 270 | return saved_definitions 271 | -------------------------------------------------------------------------------- /patchserver/routes/validator/schema_full_definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "http://example.com/example.json", 3 | "type": "object", 4 | "definitions": {}, 5 | "$schema": "http://json-schema.org/draft-06/schema#", 6 | "properties": { 7 | "name": { 8 | "$id": "/properties/name", 9 | "type": "string", 10 | "examples": [ 11 | "Composer" 12 | ] 13 | }, 14 | "publisher": { 15 | "$id": "/properties/publisher", 16 | "type": "string", 17 | "examples": [ 18 | "Jamf" 19 | ] 20 | }, 21 | "appName": { 22 | "$id": "/properties/appName", 23 | "type": ["string", "null"], 24 | "examples": [ 25 | "Composer.app" 26 | ] 27 | }, 28 | "bundleId": { 29 | "$id": "/properties/bundleId", 30 | "type": ["string", "null"], 31 | "examples": [ 32 | "com.jamfsoftware.Composer" 33 | ] 34 | }, 35 | "lastModified": { 36 | "$id": "/properties/lastModified", 37 | "type": "string", 38 | "examples": [ 39 | "2017-12-20T16:11:01Z" 40 | ] 41 | }, 42 | "currentVersion": { 43 | "$id": "/properties/currentVersion", 44 | "type": "string", 45 | "examples": [ 46 | "10.1.1" 47 | ] 48 | }, 49 | "requirements": { 50 | "$id": "/properties/requirements", 51 | "type": "array", 52 | "items": { 53 | "$id": "/properties/requirements/items", 54 | "type": "object", 55 | "properties": { 56 | "name": { 57 | "$id": "/properties/requirements/items/properties/name", 58 | "type": "string", 59 | "examples": [ 60 | "Application Bundle ID" 61 | ] 62 | }, 63 | "operator": { 64 | "$id": "/properties/requirements/items/properties/operator", 65 | "type": "string", 66 | "examples": [ 67 | "is" 68 | ] 69 | }, 70 | "value": { 71 | "$id": "/properties/requirements/items/properties/value", 72 | "type": "string", 73 | "examples": [ 74 | "com.jamfsoftware.Composer" 75 | ] 76 | }, 77 | "type": { 78 | "$id": "/properties/requirements/items/properties/type", 79 | "type": "string", 80 | "examples": [ 81 | "recon" 82 | ] 83 | }, 84 | "and": { 85 | "$id": "/properties/requirements/items/properties/and", 86 | "type": "boolean", 87 | "examples": [ 88 | true 89 | ] 90 | } 91 | }, 92 | "required": [ 93 | "name", 94 | "operator", 95 | "value", 96 | "type", 97 | "and" 98 | ] 99 | } 100 | }, 101 | "patches": { 102 | "$id": "/properties/patches", 103 | "type": "array", 104 | "items": { 105 | "$id": "/properties/patches/items", 106 | "type": "object", 107 | "properties": { 108 | "version": { 109 | "$id": "/properties/patches/items/properties/version", 110 | "type": "string", 111 | "examples": [ 112 | "10.1.1" 113 | ] 114 | }, 115 | "releaseDate": { 116 | "$id": "/properties/patches/items/properties/releaseDate", 117 | "type": "string", 118 | "examples": [ 119 | "2017-12-20T10:08:38.270Z" 120 | ] 121 | }, 122 | "standalone": { 123 | "$id": "/properties/patches/items/properties/standalone", 124 | "type": "boolean", 125 | "examples": [ 126 | true 127 | ] 128 | }, 129 | "minimumOperatingSystem": { 130 | "$id": "/properties/patches/items/properties/minimumOperatingSystem", 131 | "type": "string", 132 | "examples": [ 133 | "10.9" 134 | ] 135 | }, 136 | "reboot": { 137 | "$id": "/properties/patches/items/properties/reboot", 138 | "type": "boolean", 139 | "examples": [ 140 | false 141 | ] 142 | }, 143 | "killApps": { 144 | "$id": "/properties/patches/items/properties/killApps", 145 | "type": "array", 146 | "items": { 147 | "$id": "/properties/patches/items/properties/killApps/items", 148 | "type": "object", 149 | "properties": { 150 | "bundleId": { 151 | "$id": "/properties/patches/items/properties/killApps/items/properties/bundleId", 152 | "type": "string", 153 | "examples": [ 154 | "com.jamfsoftware.Composer" 155 | ] 156 | }, 157 | "appName": { 158 | "$id": "/properties/patches/items/properties/killApps/items/properties/appName", 159 | "type": "string", 160 | "examples": [ 161 | "Composer.app" 162 | ] 163 | } 164 | }, 165 | "required": [ 166 | "bundleId", 167 | "appName" 168 | ] 169 | } 170 | }, 171 | "components": { 172 | "$id": "/properties/patches/items/properties/components", 173 | "type": "array", 174 | "items": { 175 | "$id": "/properties/patches/items/properties/components/items", 176 | "type": "object", 177 | "properties": { 178 | "name": { 179 | "$id": "/properties/patches/items/properties/components/items/properties/name", 180 | "type": "string", 181 | "examples": [ 182 | "Composer" 183 | ] 184 | }, 185 | "version": { 186 | "$id": "/properties/patches/items/properties/components/items/properties/version", 187 | "type": "string", 188 | "examples": [ 189 | "10.1.1" 190 | ] 191 | }, 192 | "criteria": { 193 | "$id": "/properties/patches/items/properties/components/items/properties/criteria", 194 | "type": "array", 195 | "items": { 196 | "$id": "/properties/patches/items/properties/components/items/properties/criteria/items", 197 | "type": "object", 198 | "properties": { 199 | "name": { 200 | "$id": "/properties/patches/items/properties/components/items/properties/criteria/items/properties/name", 201 | "type": "string", 202 | "examples": [ 203 | "Application Bundle ID" 204 | ] 205 | }, 206 | "operator": { 207 | "$id": "/properties/patches/items/properties/components/items/properties/criteria/items/properties/operator", 208 | "type": "string", 209 | "examples": [ 210 | "is" 211 | ] 212 | }, 213 | "value": { 214 | "$id": "/properties/patches/items/properties/components/items/properties/criteria/items/properties/value", 215 | "type": "string", 216 | "examples": [ 217 | "com.jamfsoftware.Composer" 218 | ] 219 | }, 220 | "type": { 221 | "$id": "/properties/patches/items/properties/components/items/properties/criteria/items/properties/type", 222 | "type": "string", 223 | "examples": [ 224 | "recon" 225 | ] 226 | }, 227 | "and": { 228 | "$id": "/properties/patches/items/properties/components/items/properties/criteria/items/properties/and", 229 | "type": "boolean", 230 | "examples": [ 231 | true 232 | ] 233 | } 234 | }, 235 | "required": [ 236 | "name", 237 | "operator", 238 | "value", 239 | "type" 240 | ] 241 | } 242 | } 243 | }, 244 | "required": [ 245 | "name", 246 | "version", 247 | "criteria" 248 | ] 249 | } 250 | }, 251 | "capabilities": { 252 | "$id": "/properties/patches/items/properties/capabilities", 253 | "type": "array", 254 | "items": { 255 | "$id": "/properties/patches/items/properties/capabilities/items", 256 | "type": "object", 257 | "properties": { 258 | "name": { 259 | "$id": "/properties/patches/items/properties/capabilities/items/properties/name", 260 | "type": "string", 261 | "examples": [ 262 | "Operating System Version" 263 | ] 264 | }, 265 | "operator": { 266 | "$id": "/properties/patches/items/properties/capabilities/items/properties/operator", 267 | "type": "string", 268 | "examples": [ 269 | "greater than or equal" 270 | ] 271 | }, 272 | "value": { 273 | "$id": "/properties/patches/items/properties/capabilities/items/properties/value", 274 | "type": "string", 275 | "examples": [ 276 | "10.9" 277 | ] 278 | }, 279 | "type": { 280 | "$id": "/properties/patches/items/properties/capabilities/items/properties/type", 281 | "type": "string", 282 | "examples": [ 283 | "recon" 284 | ] 285 | } 286 | }, 287 | "required": [ 288 | "name", 289 | "operator", 290 | "value", 291 | "type" 292 | ] 293 | } 294 | }, 295 | "dependencies": { 296 | "$id": "/properties/patches/items/properties/dependencies", 297 | "type": "array" 298 | } 299 | }, 300 | "required": [ 301 | "version", 302 | "releaseDate", 303 | "standalone", 304 | "minimumOperatingSystem", 305 | "reboot", 306 | "killApps", 307 | "components", 308 | "capabilities" 309 | ] 310 | } 311 | }, 312 | "extensionAttributes": { 313 | "$id": "/properties/extensionAttributes", 314 | "type": "array", 315 | "items": { 316 | "$id": "/properties/extensionAttributes/items", 317 | "type": "object", 318 | "properties": { 319 | "key": { 320 | "$id": "/properties/extensionAttributes/items/properties/key", 321 | "type": "string", 322 | "examples": [ 323 | "composer-ea" 324 | ] 325 | }, 326 | "value": { 327 | "$id": "/properties/extensionAttributes/items/properties/value", 328 | "type": "string", 329 | "examples": [ 330 | "<Base 64 encoded string>" 331 | ] 332 | }, 333 | "displayName": { 334 | "$id": "/properties/extensionAttributes/items/properties/displayName", 335 | "type": "string", 336 | "examples": [ 337 | "Composer" 338 | ] 339 | } 340 | }, 341 | "required": [ 342 | "key", 343 | "value", 344 | "displayName" 345 | ] 346 | } 347 | }, 348 | "id": { 349 | "$id": "/properties/id", 350 | "type": "string", 351 | "examples": [ 352 | "Composer" 353 | ] 354 | } 355 | }, 356 | "required": [ 357 | "name", 358 | "publisher", 359 | "appName", 360 | "bundleId", 361 | "lastModified", 362 | "currentVersion", 363 | "requirements", 364 | "patches", 365 | "extensionAttributes", 366 | "id" 367 | ] 368 | } 369 | -------------------------------------------------------------------------------- /patchserver/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import hashlib 3 | from operator import itemgetter 4 | import uuid 5 | 6 | from sqlalchemy import event 7 | from sqlalchemy.engine import Engine 8 | from sqlalchemy.orm import Session 9 | 10 | from patchserver.database import db 11 | 12 | 13 | @event.listens_for(Engine, "connect") 14 | def set_sqlite_pragma(dbapi_connection, connection_record): 15 | cursor = dbapi_connection.cursor() 16 | cursor.execute("PRAGMA foreign_keys=ON") 17 | cursor.close() 18 | 19 | 20 | def datetime_to_iso(date): 21 | """Returns an ISO 8601 format 22 | 2017-08-08T21:06:49Z 23 | 24 | :param datetime date: Datetime object 25 | """ 26 | return date.strftime("%Y-%m-%dT%H:%M:%SZ") 27 | 28 | 29 | def generate_token(): 30 | """Created a 32 character token from a UUID""" 31 | return uuid.uuid4().hex 32 | 33 | 34 | class ApiToken(db.Model): 35 | __tablename__ = "api_token" 36 | 37 | id = db.Column(db.Integer, primary_key=True) 38 | 39 | token = db.Column(db.String(32), default=generate_token) 40 | created_at = db.Column( 41 | db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow 42 | ) 43 | 44 | 45 | class WebhookUrls(db.Model): 46 | __tablename__ = "webhook_urls" 47 | 48 | id = db.Column(db.Integer, primary_key=True) 49 | 50 | url = db.Column(db.String(255), unique=True) 51 | enabled = db.Column(db.Boolean, default=True) 52 | verify_ssl = db.Column(db.Boolean, default=True) 53 | send_definition = db.Column(db.Boolean, default=False) 54 | 55 | @property 56 | def serialize(self): 57 | return { 58 | "id": self.id, 59 | "url": self.url, 60 | "enabled": self.enabled, 61 | "verify_ssl": self.verify_ssl, 62 | "send_definition": self.send_definition, 63 | } 64 | 65 | 66 | def sorted_criteria(criteria_list): 67 | sorted_list = sorted(criteria_list, key=itemgetter("index")) 68 | for item in sorted_list: 69 | del item["index"] 70 | 71 | return sorted_list 72 | 73 | 74 | class SoftwareTitle(db.Model): 75 | __tablename__ = "software_titles" 76 | 77 | id = db.Column(db.Integer, primary_key=True) 78 | 79 | id_name = db.Column(db.String, unique=True) 80 | 81 | name = db.Column(db.String) 82 | publisher = db.Column(db.String) 83 | app_name = db.Column(db.String) 84 | bundle_id = db.Column(db.String) 85 | 86 | last_modified = db.Column( 87 | db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow 88 | ) 89 | 90 | requirements = db.relationship( 91 | "SoftwareTitleCriteria", 92 | back_populates="software_title", 93 | cascade="all, delete, delete-orphan", 94 | ) 95 | 96 | patches = db.relationship( 97 | "Patch", 98 | back_populates="software_title", 99 | order_by="desc(Patch.id)", 100 | cascade="all, delete", 101 | ) 102 | 103 | extension_attributes = db.relationship( 104 | "ExtensionAttribute", back_populates="software_title", cascade="all, delete" 105 | ) 106 | 107 | @property 108 | def current_version(self): 109 | if not self.patches: 110 | return None 111 | else: 112 | return self.patches[0].version 113 | 114 | @property 115 | def serialize_short(self): 116 | return { 117 | "name": self.name, 118 | "publisher": self.publisher, 119 | "lastModified": datetime_to_iso(self.last_modified), 120 | "currentVersion": self.current_version, 121 | "id": self.id_name, 122 | } 123 | 124 | @property 125 | def serialize(self): 126 | requirements = [criteria.serialize for criteria in self.requirements] 127 | return { 128 | "name": self.name, 129 | "publisher": self.publisher, 130 | "appName": self.app_name, 131 | "bundleId": self.bundle_id, 132 | "lastModified": datetime_to_iso(self.last_modified), 133 | "currentVersion": self.current_version, 134 | "requirements": sorted_criteria(requirements), 135 | "patches": [patch.serialize for patch in self.patches], 136 | "extensionAttributes": [ 137 | ext_att.serialize for ext_att in self.extension_attributes 138 | ], 139 | "id": self.id_name, 140 | } 141 | 142 | 143 | class ExtensionAttribute(db.Model): 144 | __tablename__ = "extension_attributes" 145 | 146 | id = db.Column(db.Integer, primary_key=True) 147 | 148 | key = db.Column(db.String) 149 | value = db.Column(db.Text) 150 | display_name = db.Column(db.String) 151 | 152 | software_title_id = db.Column(db.Integer, db.ForeignKey("software_titles.id")) 153 | 154 | software_title = db.relationship( 155 | "SoftwareTitle", back_populates="extension_attributes" 156 | ) 157 | 158 | @property 159 | def serialize(self): 160 | return {"key": self.key, "value": self.value, "displayName": self.display_name} 161 | 162 | 163 | class Patch(db.Model): 164 | __tablename__ = "patches" 165 | 166 | id = db.Column(db.Integer, primary_key=True) 167 | 168 | version = db.Column(db.String) 169 | standalone = db.Column(db.Boolean, default=True) 170 | minimum_operating_system = db.Column(db.String) 171 | reboot = db.Column(db.Boolean, default=False) 172 | 173 | release_date = db.Column(db.DateTime) 174 | 175 | software_title_id = db.Column(db.Integer, db.ForeignKey("software_titles.id")) 176 | 177 | software_title = db.relationship("SoftwareTitle", back_populates="patches") 178 | 179 | kill_apps = db.relationship( 180 | "PatchKillApps", back_populates="patch", cascade="all, delete" 181 | ) 182 | 183 | components = db.relationship( 184 | "PatchComponent", back_populates="patch", cascade="all, delete" 185 | ) 186 | 187 | capabilities = db.relationship( 188 | "PatchCriteria", back_populates="patch", cascade="all, delete, delete-orphan" 189 | ) 190 | 191 | dependencies = None # Not used 192 | 193 | @property 194 | def serialize(self): 195 | return { 196 | "version": self.version, 197 | "releaseDate": datetime_to_iso(self.release_date), 198 | "standalone": self.standalone, 199 | "minimumOperatingSystem": self.minimum_operating_system, 200 | "reboot": self.reboot, 201 | "killApps": [killApp.serialize for killApp in self.kill_apps], 202 | "components": [component.serialize for component in self.components], 203 | "capabilities": [criteria.serialize for criteria in self.capabilities] 204 | # 'dependencies': [] 205 | } 206 | 207 | 208 | class PatchKillApps(db.Model): 209 | __tablename__ = "patch_kill_apps" 210 | 211 | id = db.Column(db.Integer, primary_key=True) 212 | 213 | bundleId = db.Column(db.String) 214 | appName = db.Column(db.String) 215 | 216 | patch_id = db.Column(db.Integer, db.ForeignKey("patches.id")) 217 | 218 | patch = db.relationship("Patch", back_populates="kill_apps") 219 | 220 | @property 221 | def serialize(self): 222 | return {"bundleId": self.bundleId, "appName": self.appName} 223 | 224 | 225 | class PatchComponent(db.Model): 226 | __tablename__ = "patch_components" 227 | 228 | id = db.Column(db.Integer, primary_key=True) 229 | 230 | name = db.Column(db.String) 231 | version = db.Column(db.String) 232 | 233 | patch_id = db.Column(db.Integer, db.ForeignKey("patches.id")) 234 | patch = db.relationship("Patch", back_populates="components") 235 | 236 | criteria = db.relationship( 237 | "PatchCompontentCriteria", 238 | back_populates="patch_component", 239 | cascade="all, delete, delete-orphan", 240 | ) 241 | 242 | @property 243 | def serialize(self): 244 | return { 245 | "name": self.name, 246 | "version": self.version, 247 | "criteria": [criteria.serialize for criteria in self.criteria], 248 | } 249 | 250 | 251 | class Criteria(db.Model): 252 | 253 | __tablename__ = "criteria" 254 | 255 | id = db.Column(db.Integer, primary_key=True) 256 | 257 | name = db.Column(db.String) 258 | operator = db.Column(db.String) 259 | value = db.Column(db.String) 260 | type_ = db.Column(db.String) 261 | and_ = db.Column(db.Boolean, default=True) 262 | 263 | hash = db.Column(db.String, unique=True) 264 | 265 | software_title = db.relationship("SoftwareTitleCriteria", back_populates="criteria") 266 | 267 | patch = db.relationship("PatchCriteria", back_populates="criteria") 268 | 269 | patch_component = db.relationship( 270 | "PatchCompontentCriteria", back_populates="criteria" 271 | ) 272 | 273 | def __init__(self, **kwargs): 274 | super(Criteria, self).__init__(**kwargs) 275 | 276 | self.hash = hashlib.sha1( 277 | ( 278 | self.name + self.operator + self.value + self.type_ + str(self.and_) 279 | ).encode("utf-8") 280 | ).hexdigest() 281 | 282 | @property 283 | def orphaned(self): 284 | if ( 285 | len(self.software_title) + len(self.patch) + len(self.patch_component) 286 | ) == 0: 287 | return True 288 | else: 289 | return False 290 | 291 | @property 292 | def serialize(self): 293 | return { 294 | "name": self.name, 295 | "operator": self.operator, 296 | "value": self.value, 297 | "type": self.type_, 298 | "and": self.and_, 299 | } 300 | 301 | 302 | class SoftwareTitleCriteria(db.Model): 303 | """Association table for linking sets of criteria to a software title.""" 304 | 305 | __tablename__ = "software_title_criteria" 306 | 307 | title_id = db.Column( 308 | db.Integer, db.ForeignKey("software_titles.id"), primary_key=True 309 | ) 310 | criteria_id = db.Column(db.Integer, db.ForeignKey("criteria.id"), primary_key=True) 311 | 312 | index = db.Column(db.Integer) 313 | 314 | software_title = db.relationship("SoftwareTitle", back_populates="requirements") 315 | 316 | criteria = db.relationship("Criteria", back_populates="software_title") 317 | 318 | @property 319 | def serialize(self): 320 | data = self.criteria.serialize 321 | data["index"] = self.index 322 | return data 323 | 324 | 325 | class PatchCriteria(db.Model): 326 | """Association table for linking sets of criteria to a patch.""" 327 | 328 | __tablename__ = "patch_criteria" 329 | 330 | patch_id = db.Column(db.Integer, db.ForeignKey("patches.id"), primary_key=True) 331 | criteria_id = db.Column(db.Integer, db.ForeignKey("criteria.id"), primary_key=True) 332 | 333 | index = db.Column(db.Integer) 334 | 335 | patch = db.relationship("Patch", back_populates="capabilities") 336 | 337 | criteria = db.relationship("Criteria", back_populates="patch") 338 | 339 | @property 340 | def serialize(self): 341 | return self.criteria.serialize 342 | 343 | 344 | class PatchCompontentCriteria(db.Model): 345 | """Association table for linking sets of criteria to a patch.""" 346 | 347 | __tablename__ = "patch_component_criteria" 348 | 349 | component_id = db.Column( 350 | db.Integer, db.ForeignKey("patch_components.id"), primary_key=True 351 | ) 352 | criteria_id = db.Column(db.Integer, db.ForeignKey("criteria.id"), primary_key=True) 353 | 354 | index = db.Column(db.Integer) 355 | 356 | patch_component = db.relationship("PatchComponent", back_populates="criteria") 357 | 358 | criteria = db.relationship("Criteria", back_populates="patch_component") 359 | 360 | @property 361 | def serialize(self): 362 | return self.criteria.serialize 363 | 364 | 365 | @event.listens_for(SoftwareTitle.requirements, "append") 366 | @event.listens_for(SoftwareTitle.requirements, "remove") 367 | @event.listens_for(SoftwareTitle.patches, "append") 368 | @event.listens_for(SoftwareTitle.patches, "remove") 369 | @event.listens_for(SoftwareTitle.requirements, "append") 370 | @event.listens_for(SoftwareTitle.requirements, "remove") 371 | def software_title_child_update(target, value, initiator): 372 | target.last_modified = datetime.utcnow() 373 | 374 | 375 | @event.listens_for(Session, "after_flush") 376 | def delete_ophaned_criteria(session, ctx): 377 | check = False 378 | 379 | for instance in session.deleted: 380 | if isinstance(instance, (SoftwareTitle, Patch, PatchComponent)): 381 | check = True 382 | 383 | if check: 384 | session.query(Criteria).filter( 385 | ~Criteria.software_title.any(), 386 | ~Criteria.patch.any(), 387 | ~Criteria.patch_component.any(), 388 | ).delete(synchronize_session=False) 389 | -------------------------------------------------------------------------------- /patchserver/static/css/jquery.dataTables.min.css: -------------------------------------------------------------------------------- 1 | table.dataTable{width:100%;margin:0 auto;clear:both;border-collapse:separate;border-spacing:0}table.dataTable thead th,table.dataTable tfoot th{font-weight:bold}table.dataTable thead th,table.dataTable thead td{padding:10px 18px;border-bottom:1px solid #111}table.dataTable thead th:active,table.dataTable thead td:active{outline:none}table.dataTable tfoot th,table.dataTable tfoot td{padding:10px 18px 6px 18px;border-top:1px solid #111}table.dataTable thead .sorting,table.dataTable thead .sorting_asc,table.dataTable thead .sorting_desc,table.dataTable thead .sorting_asc_disabled,table.dataTable thead .sorting_desc_disabled{cursor:pointer;*cursor:hand;background-repeat:no-repeat;background-position:center right}table.dataTable thead .sorting{background-image:url("../images/sort_both.png")}table.dataTable thead .sorting_asc{background-image:url("../images/sort_asc.png")}table.dataTable thead .sorting_desc{background-image:url("../images/sort_desc.png")}table.dataTable thead .sorting_asc_disabled{background-image:url("../images/sort_asc_disabled.png")}table.dataTable thead .sorting_desc_disabled{background-image:url("../images/sort_desc_disabled.png")}table.dataTable tbody tr{background-color:#ffffff}table.dataTable tbody tr.selected{background-color:#B0BED9}table.dataTable tbody th,table.dataTable tbody td{padding:8px 10px}table.dataTable.row-border tbody th,table.dataTable.row-border tbody td,table.dataTable.display tbody th,table.dataTable.display tbody td{border-top:1px solid #ddd}table.dataTable.row-border tbody tr:first-child th,table.dataTable.row-border tbody tr:first-child td,table.dataTable.display tbody tr:first-child th,table.dataTable.display tbody tr:first-child td{border-top:none}table.dataTable.cell-border tbody th,table.dataTable.cell-border tbody td{border-top:1px solid #ddd;border-right:1px solid #ddd}table.dataTable.cell-border tbody tr th:first-child,table.dataTable.cell-border tbody tr td:first-child{border-left:1px solid #ddd}table.dataTable.cell-border tbody tr:first-child th,table.dataTable.cell-border tbody tr:first-child td{border-top:none}table.dataTable.stripe tbody tr.odd,table.dataTable.display tbody tr.odd{background-color:#f9f9f9}table.dataTable.stripe tbody tr.odd.selected,table.dataTable.display tbody tr.odd.selected{background-color:#acbad4}table.dataTable.hover tbody tr:hover,table.dataTable.display tbody tr:hover{background-color:#f6f6f6}table.dataTable.hover tbody tr:hover.selected,table.dataTable.display tbody tr:hover.selected{background-color:#aab7d1}table.dataTable.order-column tbody tr>.sorting_1,table.dataTable.order-column tbody tr>.sorting_2,table.dataTable.order-column tbody tr>.sorting_3,table.dataTable.display tbody tr>.sorting_1,table.dataTable.display tbody tr>.sorting_2,table.dataTable.display tbody tr>.sorting_3{background-color:#fafafa}table.dataTable.order-column tbody tr.selected>.sorting_1,table.dataTable.order-column tbody tr.selected>.sorting_2,table.dataTable.order-column tbody tr.selected>.sorting_3,table.dataTable.display tbody tr.selected>.sorting_1,table.dataTable.display tbody tr.selected>.sorting_2,table.dataTable.display tbody tr.selected>.sorting_3{background-color:#acbad5}table.dataTable.display tbody tr.odd>.sorting_1,table.dataTable.order-column.stripe tbody tr.odd>.sorting_1{background-color:#f1f1f1}table.dataTable.display tbody tr.odd>.sorting_2,table.dataTable.order-column.stripe tbody tr.odd>.sorting_2{background-color:#f3f3f3}table.dataTable.display tbody tr.odd>.sorting_3,table.dataTable.order-column.stripe tbody tr.odd>.sorting_3{background-color:whitesmoke}table.dataTable.display tbody tr.odd.selected>.sorting_1,table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_1{background-color:#a6b4cd}table.dataTable.display tbody tr.odd.selected>.sorting_2,table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_2{background-color:#a8b5cf}table.dataTable.display tbody tr.odd.selected>.sorting_3,table.dataTable.order-column.stripe tbody tr.odd.selected>.sorting_3{background-color:#a9b7d1}table.dataTable.display tbody tr.even>.sorting_1,table.dataTable.order-column.stripe tbody tr.even>.sorting_1{background-color:#fafafa}table.dataTable.display tbody tr.even>.sorting_2,table.dataTable.order-column.stripe tbody tr.even>.sorting_2{background-color:#fcfcfc}table.dataTable.display tbody tr.even>.sorting_3,table.dataTable.order-column.stripe tbody tr.even>.sorting_3{background-color:#fefefe}table.dataTable.display tbody tr.even.selected>.sorting_1,table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_1{background-color:#acbad5}table.dataTable.display tbody tr.even.selected>.sorting_2,table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_2{background-color:#aebcd6}table.dataTable.display tbody tr.even.selected>.sorting_3,table.dataTable.order-column.stripe tbody tr.even.selected>.sorting_3{background-color:#afbdd8}table.dataTable.display tbody tr:hover>.sorting_1,table.dataTable.order-column.hover tbody tr:hover>.sorting_1{background-color:#eaeaea}table.dataTable.display tbody tr:hover>.sorting_2,table.dataTable.order-column.hover tbody tr:hover>.sorting_2{background-color:#ececec}table.dataTable.display tbody tr:hover>.sorting_3,table.dataTable.order-column.hover tbody tr:hover>.sorting_3{background-color:#efefef}table.dataTable.display tbody tr:hover.selected>.sorting_1,table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_1{background-color:#a2aec7}table.dataTable.display tbody tr:hover.selected>.sorting_2,table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_2{background-color:#a3b0c9}table.dataTable.display tbody tr:hover.selected>.sorting_3,table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_3{background-color:#a5b2cb}table.dataTable.no-footer{border-bottom:1px solid #111}table.dataTable.nowrap th,table.dataTable.nowrap td{white-space:nowrap}table.dataTable.compact thead th,table.dataTable.compact thead td{padding:4px 17px}table.dataTable.compact tfoot th,table.dataTable.compact tfoot td{padding:4px}table.dataTable.compact tbody th,table.dataTable.compact tbody td{padding:4px}table.dataTable th.dt-left,table.dataTable td.dt-left{text-align:left}table.dataTable th.dt-center,table.dataTable td.dt-center,table.dataTable td.dataTables_empty{text-align:center}table.dataTable th.dt-right,table.dataTable td.dt-right{text-align:right}table.dataTable th.dt-justify,table.dataTable td.dt-justify{text-align:justify}table.dataTable th.dt-nowrap,table.dataTable td.dt-nowrap{white-space:nowrap}table.dataTable thead th.dt-head-left,table.dataTable thead td.dt-head-left,table.dataTable tfoot th.dt-head-left,table.dataTable tfoot td.dt-head-left{text-align:left}table.dataTable thead th.dt-head-center,table.dataTable thead td.dt-head-center,table.dataTable tfoot th.dt-head-center,table.dataTable tfoot td.dt-head-center{text-align:center}table.dataTable thead th.dt-head-right,table.dataTable thead td.dt-head-right,table.dataTable tfoot th.dt-head-right,table.dataTable tfoot td.dt-head-right{text-align:right}table.dataTable thead th.dt-head-justify,table.dataTable thead td.dt-head-justify,table.dataTable tfoot th.dt-head-justify,table.dataTable tfoot td.dt-head-justify{text-align:justify}table.dataTable thead th.dt-head-nowrap,table.dataTable thead td.dt-head-nowrap,table.dataTable tfoot th.dt-head-nowrap,table.dataTable tfoot td.dt-head-nowrap{white-space:nowrap}table.dataTable tbody th.dt-body-left,table.dataTable tbody td.dt-body-left{text-align:left}table.dataTable tbody th.dt-body-center,table.dataTable tbody td.dt-body-center{text-align:center}table.dataTable tbody th.dt-body-right,table.dataTable tbody td.dt-body-right{text-align:right}table.dataTable tbody th.dt-body-justify,table.dataTable tbody td.dt-body-justify{text-align:justify}table.dataTable tbody th.dt-body-nowrap,table.dataTable tbody td.dt-body-nowrap{white-space:nowrap}table.dataTable,table.dataTable th,table.dataTable td{box-sizing:content-box}.dataTables_wrapper{position:relative;clear:both;*zoom:1;zoom:1}.dataTables_wrapper .dataTables_length{float:left}.dataTables_wrapper .dataTables_length select{border:1px solid #aaa;border-radius:3px;padding:5px;background-color:transparent;padding:4px}.dataTables_wrapper .dataTables_filter{float:right;text-align:right}.dataTables_wrapper .dataTables_filter input{border:1px solid #aaa;border-radius:3px;padding:5px;background-color:transparent;margin-left:3px}.dataTables_wrapper .dataTables_info{clear:both;float:left;padding-top:0.755em}.dataTables_wrapper .dataTables_paginate{float:right;text-align:right;padding-top:0.25em}.dataTables_wrapper .dataTables_paginate .paginate_button{box-sizing:border-box;display:inline-block;min-width:1.5em;padding:0.5em 1em;margin-left:2px;text-align:center;text-decoration:none !important;cursor:pointer;*cursor:hand;color:#333 !important;border:1px solid transparent;border-radius:2px}.dataTables_wrapper .dataTables_paginate .paginate_button.current,.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover{color:#333 !important;border:1px solid #979797;background-color:white;background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #fff), color-stop(100%, #dcdcdc));background:-webkit-linear-gradient(top, #fff 0%, #dcdcdc 100%);background:-moz-linear-gradient(top, #fff 0%, #dcdcdc 100%);background:-ms-linear-gradient(top, #fff 0%, #dcdcdc 100%);background:-o-linear-gradient(top, #fff 0%, #dcdcdc 100%);background:linear-gradient(to bottom, #fff 0%, #dcdcdc 100%)}.dataTables_wrapper .dataTables_paginate .paginate_button.disabled,.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover,.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active{cursor:default;color:#666 !important;border:1px solid transparent;background:transparent;box-shadow:none}.dataTables_wrapper .dataTables_paginate .paginate_button:hover{color:white !important;border:1px solid #111;background-color:#585858;background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #585858), color-stop(100%, #111));background:-webkit-linear-gradient(top, #585858 0%, #111 100%);background:-moz-linear-gradient(top, #585858 0%, #111 100%);background:-ms-linear-gradient(top, #585858 0%, #111 100%);background:-o-linear-gradient(top, #585858 0%, #111 100%);background:linear-gradient(to bottom, #585858 0%, #111 100%)}.dataTables_wrapper .dataTables_paginate .paginate_button:active{outline:none;background-color:#2b2b2b;background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, #2b2b2b), color-stop(100%, #0c0c0c));background:-webkit-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%);background:-moz-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%);background:-ms-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%);background:-o-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%);background:linear-gradient(to bottom, #2b2b2b 0%, #0c0c0c 100%);box-shadow:inset 0 0 3px #111}.dataTables_wrapper .dataTables_paginate .ellipsis{padding:0 1em}.dataTables_wrapper .dataTables_processing{position:absolute;top:50%;left:50%;width:100%;height:40px;margin-left:-50%;margin-top:-25px;padding-top:20px;text-align:center;font-size:1.2em;background-color:white;background:-webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255,255,255,0)), color-stop(25%, rgba(255,255,255,0.9)), color-stop(75%, rgba(255,255,255,0.9)), color-stop(100%, rgba(255,255,255,0)));background:-webkit-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);background:-moz-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);background:-ms-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);background:-o-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);background:linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%)}.dataTables_wrapper .dataTables_length,.dataTables_wrapper .dataTables_filter,.dataTables_wrapper .dataTables_info,.dataTables_wrapper .dataTables_processing,.dataTables_wrapper .dataTables_paginate{color:#333}.dataTables_wrapper .dataTables_scroll{clear:both}.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody{*margin-top:-1px;-webkit-overflow-scrolling:touch}.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th,.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td,.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th,.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td{vertical-align:middle}.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th>div.dataTables_sizing,.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td>div.dataTables_sizing,.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th>div.dataTables_sizing,.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td>div.dataTables_sizing{height:0;overflow:hidden;margin:0 !important;padding:0 !important}.dataTables_wrapper.no-footer .dataTables_scrollBody{border-bottom:1px solid #111}.dataTables_wrapper.no-footer div.dataTables_scrollHead table.dataTable,.dataTables_wrapper.no-footer div.dataTables_scrollBody>table{border-bottom:none}.dataTables_wrapper:after{visibility:hidden;display:block;content:"";clear:both;height:0}@media screen and (max-width: 767px){.dataTables_wrapper .dataTables_info,.dataTables_wrapper .dataTables_paginate{float:none;text-align:center}.dataTables_wrapper .dataTables_paginate{margin-top:0.5em}}@media screen and (max-width: 640px){.dataTables_wrapper .dataTables_length,.dataTables_wrapper .dataTables_filter{float:none;text-align:center}.dataTables_wrapper .dataTables_filter{margin-top:0.5em}} 2 | -------------------------------------------------------------------------------- /patchserver/routes/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlparse 3 | 4 | from flask import blueprints, flash, g, jsonify, redirect, request, send_file, url_for 5 | 6 | from patchserver.database import db 7 | from patchserver.exc import InvalidPatchDefinitionError, InvalidWebhook 8 | from patchserver.models import ApiToken, SoftwareTitle, WebhookUrls 9 | from patchserver.routes.api_operations import ( 10 | create_criteria_objects, 11 | create_extension_attributes, 12 | create_patch_objects, 13 | lookup_software_title, 14 | create_backup_archive, 15 | restore_backup_archive, 16 | ) 17 | from patchserver.routes.auth import api_auth 18 | from patchserver.routes.validator import validate_json 19 | from patchserver.routes.webhooks import webhook_event 20 | 21 | blueprint = blueprints.Blueprint("api", __name__, url_prefix="/api/v1") 22 | 23 | 24 | @blueprint.route("/token", methods=["POST"]) 25 | def token_create(): 26 | """Create an API token for the server. 27 | 28 | .. :quickref: Token; Create the API token. 29 | 30 | **Example Request:** 31 | 32 | .. sourcecode:: http 33 | 34 | POST /api/v1/token HTTP/1.1 35 | 36 | **Example Response:** 37 | 38 | A successful response will return a ``201`` status with the API token. 39 | 40 | .. sourcecode:: http 41 | 42 | HTTP/1.1 201 Created 43 | Content-Type: application/json 44 | 45 | { 46 | "token_created": "94631ec5c65e4dd19fb81479abdd2929" 47 | } 48 | 49 | **Error Responses** 50 | 51 | A ``403`` status is returned if an API token already exists. 52 | 53 | .. sourcecode:: http 54 | 55 | HTTP/1.1 403 Forbidden 56 | Content-Type: application/json 57 | 58 | { 59 | "forbidden": "A token already exists for this server" 60 | } 61 | 62 | :return: 63 | """ 64 | if ApiToken.query.first(): 65 | return jsonify({"forbidden": "A token already exists for this server"}), 403 66 | 67 | new_token = ApiToken() 68 | db.session.add(new_token) 69 | db.session.commit() 70 | 71 | return jsonify({"token_created": new_token.token}), 201 72 | 73 | 74 | @blueprint.route("/title", methods=["POST"]) 75 | @api_auth 76 | @webhook_event 77 | def title_create(): 78 | """Create a new patch definition on the server. 79 | 80 | .. :quickref: Software Title; Create a patch definition. 81 | 82 | **Example Request:** 83 | 84 | .. sourcecode:: http 85 | 86 | POST /api/v1/title HTTP/1.1 87 | Content-Type: application/json 88 | 89 | { 90 | "id": "Composer", 91 | "name": "Composer", 92 | "publisher": "Jamf", 93 | "appName": "Composer.app", 94 | "bundleId": "com.jamfsoftware.Composer", 95 | "requirements": ["requirementObjects"], 96 | "patches": ["versionObjects"], 97 | "extensionAttributes": ["extensionAttributeObjects"] 98 | } 99 | 100 | .. note:: 101 | 102 | The JSON schema for a patch definition can be found in the project 103 | repository at: 104 | ``patchserver/routes/validator/schema_full_definition.json`` 105 | 106 | **Example Response:** 107 | 108 | A successful response will return a ``201`` status with the numerical 109 | database ID as well as the definition's ID. 110 | 111 | .. sourcecode:: http 112 | 113 | HTTP/1.1 201 Created 114 | Content-Type: application/json 115 | 116 | { 117 | "database_id": 1, 118 | "id": "Composer" 119 | } 120 | 121 | **Error Responses** 122 | 123 | A ``409`` status is returned if you attempt to create a patch definition 124 | using an ID that already exists in the database. 125 | 126 | .. sourcecode:: http 127 | 128 | HTTP/1.1 409 Conflict 129 | Content-Type: application/json 130 | 131 | { 132 | "database_conflict": "A software title of the provided name already exists." 133 | } 134 | 135 | A ``400`` status can be returned if your patch definition fails a 136 | validation check against the JSON schema. If this occurs, a reason will 137 | be provided in the JSON response. 138 | 139 | .. sourcecode:: http 140 | 141 | HTTP/1.1 400 Bad Request 142 | Content-Type: application/json 143 | 144 | { 145 | "invalid_json": "Validation error encountered with submitted JSON for item: /u'true' is not of type u'boolean'" 146 | } 147 | 148 | A ``400`` status can be returned if your patch definition fails a 149 | validation check against the JSON schema. If this occurs, a reason will 150 | be provided in the JSON response. 151 | 152 | .. sourcecode:: http 153 | 154 | HTTP/1.1 400 Bad Request 155 | Content-Type: application/json 156 | 157 | { 158 | "invalid_json": "Validation error encountered with submitted JSON: u'true' is not of type u'boolean' for item: /patches/0/components/0/criteria/0/and" 159 | } 160 | 161 | """ 162 | data = request.get_json() 163 | if not data: 164 | try: 165 | data = json.load(request.files["file"]) 166 | except ValueError: 167 | raise InvalidPatchDefinitionError("No JSON data could be found.") 168 | 169 | validate_json(data, "patch") 170 | 171 | new_title = SoftwareTitle( 172 | id_name=data["id"], 173 | name=data["name"], 174 | publisher=data["publisher"], 175 | app_name=data["appName"], 176 | bundle_id=data["bundleId"], 177 | ) 178 | db.session.add(new_title) 179 | 180 | if data.get("requirements"): 181 | create_criteria_objects(data["requirements"], software_title=new_title) 182 | 183 | if data.get("patches"): 184 | create_patch_objects(list(reversed(data["patches"])), software_title=new_title) 185 | 186 | if data.get("extensionAttributes"): 187 | create_extension_attributes(data["extensionAttributes"], new_title) 188 | 189 | db.session.commit() 190 | 191 | g.event_type = "new_title" 192 | g.event = new_title.serialize 193 | 194 | if request.user_agent.browser: 195 | flash( 196 | { 197 | "title": "Software title created", 198 | "message": 'View at <a href="{0}">{0}</a>'.format( 199 | url_for("jamf_pro.patch_by_name_id", name_id=new_title.id_name) 200 | ), 201 | }, 202 | "success", 203 | ) 204 | return redirect(url_for("web_ui.index")) 205 | else: 206 | return jsonify({"id": new_title.id_name, "database_id": new_title.id}), 201 207 | 208 | 209 | @blueprint.route("/title/<name_id>", methods=["DELETE"]) 210 | @api_auth 211 | @webhook_event 212 | def title_delete(name_id): 213 | """Delete a patch definition on the server. 214 | 215 | .. :quickref: Software Title; Delete a patch definition. 216 | 217 | **Example Request:** 218 | 219 | .. sourcecode:: http 220 | 221 | DELETE /api/v1/title/Composer HTTP/1.1 222 | 223 | **Example Response:** 224 | 225 | A successful response will return a ``204`` status. 226 | 227 | .. sourcecode:: http 228 | 229 | HTTP/1.1 204 No Content 230 | 231 | **Error Responses** 232 | 233 | A ``404`` status is returned if the specified patch definition does 234 | not exist. 235 | 236 | .. sourcecode:: http 237 | 238 | HTTP/1.1 404 Not Found 239 | Content-Type: application/json 240 | 241 | { 242 | "title_not_found": "Composer" 243 | } 244 | 245 | """ 246 | title = lookup_software_title(name_id) 247 | 248 | g.event_type = "title_deleted" 249 | g.event = title.serialize 250 | 251 | db.session.delete(title) 252 | db.session.commit() 253 | 254 | if request.user_agent.browser: 255 | flash({"title": "Software title deleted", "message": name_id}, "success") 256 | 257 | return jsonify({}), 204 258 | 259 | 260 | @blueprint.route("/title/<name_id>/version", methods=["POST"]) 261 | @api_auth 262 | def title_versions(name_id): 263 | """Create a new patch version for an existing patch definition. 264 | 265 | .. :quickref: Software Title; Create a patch version. 266 | 267 | **Example Request:** 268 | 269 | .. sourcecode:: http 270 | 271 | POST /api/v1/title/Composer/version HTTP/1.1 272 | Content-Type: application/json 273 | 274 | { 275 | "version": "10.1.1", 276 | "releaseDate": "2017-12-20T10:08:38.270Z", 277 | "standalone": true, 278 | "minimumOperatingSystem": "10.9", 279 | "reboot": false, 280 | "killApps": [ 281 | { 282 | "bundleId": "com.jamfsoftware.Composer", 283 | "appName": "Composer.app" 284 | } 285 | ], 286 | "components": [ 287 | { 288 | "name": "Composer", 289 | "version": "10.1.1", 290 | "criteria": ["requirementsObjects"] 291 | } 292 | ], 293 | "capabilities": ["requirementsObjects"], 294 | "dependencies": [] 295 | } 296 | 297 | .. note:: 298 | 299 | The JSON schema for a patch definition can be found in the project 300 | repository at: 301 | ``patchserver/routes/validator/schema_version.json`` 302 | 303 | **Example Response:** 304 | 305 | A successful response will return a ``201`` status. 306 | 307 | .. sourcecode:: http 308 | 309 | HTTP/1.1 201 Created 310 | Content-Type: application/json 311 | 312 | {} 313 | 314 | **Error Responses** 315 | 316 | A ``400`` status can be returned if your patch version fails a validation 317 | check against the JSON schema. If this occurs, a reason will be provided in 318 | the JSON response. 319 | 320 | .. sourcecode:: http 321 | 322 | HTTP/1.1 400 Bad Request 323 | Content-Type: application/json 324 | 325 | { 326 | "invalid_json": "Validation error encountered with submitted JSON: u'true' is not of type u'boolean' for item: /patches/0/components/0/criteria/0/and" 327 | } 328 | 329 | """ 330 | data = request.get_json() 331 | if not data: 332 | try: 333 | data = json.load(request.files["file"]) 334 | except ValueError: 335 | raise InvalidPatchDefinitionError("No JSON data could be found.") 336 | 337 | validate_json(data, "version") 338 | 339 | title = lookup_software_title(name_id) 340 | if data["version"] in [patch.version for patch in title.patches]: 341 | return ( 342 | jsonify( 343 | { 344 | "database_conflict": "The provided version already exists " 345 | "for this software title." 346 | } 347 | ), 348 | 409, 349 | ) 350 | 351 | create_patch_objects([data], software_title=title) 352 | db.session.commit() 353 | 354 | if request.user_agent.browser: 355 | flash( 356 | { 357 | "title": "Software title version updated", 358 | "message": 'View at <a href="{0}">{0}</a>'.format( 359 | url_for("jamf_pro.patch_by_name_id", name_id=name_id) 360 | ), 361 | }, 362 | "success", 363 | ) 364 | return redirect(url_for("web_ui.index")) 365 | else: 366 | return jsonify({}), 201 367 | 368 | 369 | @blueprint.route("/backup") 370 | def backup_titles(): 371 | """Download a zipped archive of all patch definitions. 372 | 373 | .. :quickref: Backup; Downloadable archive of all software titles. 374 | 375 | **Example Request:** 376 | 377 | .. sourcecode:: http 378 | 379 | GET /api/v1/backup HTTP/1.1 380 | 381 | **Example Response:** 382 | 383 | A successful response will return a ``200`` status and a zipped archive 384 | containing the patch definitions. 385 | 386 | .. sourcecode:: http 387 | 388 | HTTP/1.1 200 OK 389 | Content-Type: application/zip 390 | 391 | <patch_archive.zip> 392 | 393 | """ 394 | archive = create_backup_archive() 395 | return ( 396 | send_file(archive, as_attachment=True, attachment_filename="patch_archive.zip"), 397 | 200, 398 | ) 399 | 400 | 401 | @blueprint.route("/restore", methods=["POST"]) 402 | def restore_titles(): 403 | """Restore a zipped archive of definitions to the server. This endpoint 404 | may only be used when no definitions exist. If definitions have been created 405 | the restore request will be rejected. 406 | 407 | .. :quickref: Backup; Restore definitions from a zipped archive of software titles. 408 | 409 | **Example Request:** 410 | 411 | .. sourcecode:: http 412 | 413 | POST /api/v1/restore HTTP/1.1 414 | Content-Type: application/zip 415 | 416 | <patch_archive.zip> 417 | 418 | **Example Response:** 419 | 420 | A successful response will return a ``201`` status and a JSON object 421 | containing the software title IDs and their database IDs. 422 | 423 | .. sourcecode:: http 424 | 425 | HTTP/1.1 201 Created 426 | Content-Type: application/json 427 | 428 | [ 429 | { 430 | "database_id": 1, 431 | "id": "Composer" 432 | }, 433 | { 434 | "database_id": 2, 435 | "id": "JamfImaging" 436 | } 437 | ] 438 | 439 | **Error Responses** 440 | 441 | A ``400`` status can be returned if a file other than a zip archive is 442 | submitted, if a validation error occurs when processing the unzipped 443 | definitions, or if the request was made after definitions have been already 444 | created. 445 | 446 | .. sourcecode:: http 447 | 448 | HTTP/1.1 400 Bad Request 449 | Content-Type: application/json 450 | 451 | { 452 | "restore_failure": "The submitted file is not a .zip archive" 453 | } 454 | 455 | .. sourcecode:: http 456 | 457 | HTTP/1.1 400 Bad Request 458 | Content-Type: application/json 459 | 460 | { 461 | "restore_failure": "A definition in the archive failed validation" 462 | } 463 | 464 | .. sourcecode:: http 465 | 466 | HTTP/1.1 400 Bad Request 467 | Content-Type: application/json 468 | 469 | { 470 | "restore_failure": "Definitions already exist on this server" 471 | } 472 | 473 | """ 474 | uploaded_file = request.files["file"] 475 | restored_definitions = restore_backup_archive(uploaded_file) 476 | return jsonify(restored_definitions), 201 477 | 478 | 479 | @blueprint.route("/webhooks", methods=["GET", "POST"]) 480 | @api_auth 481 | def webhooks(): 482 | if request.method == "GET": 483 | results = list() 484 | for webhook in WebhookUrls.query.all(): 485 | results.append(webhook.serialize) 486 | 487 | return jsonify(results), 200 488 | 489 | elif request.method == "POST": 490 | data = request.get_json() 491 | if not data: 492 | data = { 493 | "url": request.form.get("url", ""), 494 | "enabled": bool(request.form.get("enabled")), 495 | "send_definition": bool(request.form.get("send_definition")), 496 | } 497 | 498 | def validate_url(url): 499 | parsed = urlparse(url) 500 | if parsed.scheme and parsed.netloc: 501 | return True 502 | else: 503 | return False 504 | 505 | if not validate_url(data["url"]): 506 | raise InvalidWebhook("The provided URL is invalid") 507 | 508 | new_webhook = WebhookUrls( 509 | url=data["url"], 510 | enabled=data["enabled"], 511 | send_definition=data["send_definition"], 512 | ) 513 | db.session.add(new_webhook) 514 | db.session.commit() 515 | 516 | if request.user_agent.browser: 517 | flash( 518 | { 519 | "title": "Webhook saved", 520 | "message": "The new webhook has been saved.", 521 | }, 522 | "success", 523 | ) 524 | return redirect(url_for("web_ui.index")) 525 | else: 526 | return jsonify({"id": new_webhook.id}), 201 527 | 528 | 529 | @blueprint.route("/webhooks/<webhook_id>", methods=["DELETE"]) 530 | @api_auth 531 | def webhooks_delete(webhook_id): 532 | """Delete a configured webhook from the server by ID. 533 | 534 | .. :quickref: Webhooks; Delete a webhook. 535 | 536 | **Example Request:** 537 | 538 | .. sourcecode:: http 539 | 540 | DELETE /api/v1/webhooks/1 HTTP/1.1 541 | 542 | **Example Response:** 543 | 544 | A successful response will return a ``204`` status. 545 | 546 | .. sourcecode:: http 547 | 548 | HTTP/1.1 204 No Content 549 | 550 | **Error Responses** 551 | 552 | A ``404`` status is returned if the specified webhook does not exist. 553 | 554 | .. sourcecode:: http 555 | 556 | HTTP/1.1 404 Not Found 557 | Content-Type: application/json 558 | 559 | { 560 | "webhook_id_not_found": 1 561 | } 562 | 563 | """ 564 | webhook = WebhookUrls.query.filter_by(id=webhook_id).first() 565 | if not webhook: 566 | flash({"title": "Not found!", "message": ""}, "error") 567 | return redirect(url_for("web_ui.index")) 568 | 569 | webhook_url = webhook.url 570 | 571 | db.session.delete(webhook) 572 | db.session.commit() 573 | 574 | if request.user_agent.browser: 575 | flash({"title": "Webhook deleted", "message": webhook_url}, "success") 576 | 577 | return jsonify({}), 204 578 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "82b9e793afc78cf7a3424fd40709f80d7436befa5ca6849f5100e17e45353889" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "attrs": { 20 | "hashes": [ 21 | "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", 22 | "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" 23 | ], 24 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 25 | "version": "==20.2.0" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", 30 | "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" 31 | ], 32 | "version": "==2020.6.20" 33 | }, 34 | "chardet": { 35 | "hashes": [ 36 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 37 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 38 | ], 39 | "version": "==3.0.4" 40 | }, 41 | "click": { 42 | "hashes": [ 43 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 44 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 45 | ], 46 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 47 | "version": "==7.1.2" 48 | }, 49 | "flask": { 50 | "hashes": [ 51 | "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", 52 | "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" 53 | ], 54 | "index": "pypi", 55 | "version": "==1.1.2" 56 | }, 57 | "flask-sqlalchemy": { 58 | "hashes": [ 59 | "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e", 60 | "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5" 61 | ], 62 | "index": "pypi", 63 | "version": "==2.4.4" 64 | }, 65 | "idna": { 66 | "hashes": [ 67 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 68 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 69 | ], 70 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 71 | "version": "==2.10" 72 | }, 73 | "itsdangerous": { 74 | "hashes": [ 75 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 76 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 77 | ], 78 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 79 | "version": "==1.1.0" 80 | }, 81 | "jinja2": { 82 | "hashes": [ 83 | "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", 84 | "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" 85 | ], 86 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 87 | "version": "==2.11.2" 88 | }, 89 | "jsonschema": { 90 | "hashes": [ 91 | "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", 92 | "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" 93 | ], 94 | "index": "pypi", 95 | "version": "==3.2.0" 96 | }, 97 | "markupsafe": { 98 | "hashes": [ 99 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 100 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 101 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 102 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 103 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 104 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 105 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 106 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 107 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 108 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 109 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 110 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 111 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 112 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 113 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 114 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 115 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 116 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 117 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 118 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 119 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 120 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 121 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 122 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 123 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 124 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 125 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 126 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 127 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 128 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 129 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 130 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 131 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" 132 | ], 133 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 134 | "version": "==1.1.1" 135 | }, 136 | "pyrsistent": { 137 | "hashes": [ 138 | "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" 139 | ], 140 | "markers": "python_version >= '3.5'", 141 | "version": "==0.17.3" 142 | }, 143 | "python-dateutil": { 144 | "hashes": [ 145 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 146 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 147 | ], 148 | "index": "pypi", 149 | "version": "==2.8.1" 150 | }, 151 | "requests": { 152 | "hashes": [ 153 | "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", 154 | "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" 155 | ], 156 | "index": "pypi", 157 | "version": "==2.24.0" 158 | }, 159 | "six": { 160 | "hashes": [ 161 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 162 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 163 | ], 164 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 165 | "version": "==1.15.0" 166 | }, 167 | "sqlalchemy": { 168 | "hashes": [ 169 | "sha256:072766c3bd09294d716b2d114d46ffc5ccf8ea0b714a4e1c48253014b771c6bb", 170 | "sha256:107d4af989831d7b091e382d192955679ec07a9209996bf8090f1f539ffc5804", 171 | "sha256:15c0bcd3c14f4086701c33a9e87e2c7ceb3bcb4a246cd88ec54a49cf2a5bd1a6", 172 | "sha256:26c5ca9d09f0e21b8671a32f7d83caad5be1f6ff45eef5ec2f6fd0db85fc5dc0", 173 | "sha256:276936d41111a501cf4a1a0543e25449108d87e9f8c94714f7660eaea89ae5fe", 174 | "sha256:3292a28344922415f939ee7f4fc0c186f3d5a0bf02192ceabd4f1129d71b08de", 175 | "sha256:33d29ae8f1dc7c75b191bb6833f55a19c932514b9b5ce8c3ab9bc3047da5db36", 176 | "sha256:3bba2e9fbedb0511769780fe1d63007081008c5c2d7d715e91858c94dbaa260e", 177 | "sha256:465c999ef30b1c7525f81330184121521418a67189053bcf585824d833c05b66", 178 | "sha256:51064ee7938526bab92acd049d41a1dc797422256086b39c08bafeffb9d304c6", 179 | "sha256:5a49e8473b1ab1228302ed27365ea0fadd4bf44bc0f9e73fe38e10fdd3d6b4fc", 180 | "sha256:618db68745682f64cedc96ca93707805d1f3a031747b5a0d8e150cfd5055ae4d", 181 | "sha256:6547b27698b5b3bbfc5210233bd9523de849b2bb8a0329cd754c9308fc8a05ce", 182 | "sha256:6557af9e0d23f46b8cd56f8af08eaac72d2e3c632ac8d5cf4e20215a8dca7cea", 183 | "sha256:73a40d4fcd35fdedce07b5885905753d5d4edf413fbe53544dd871f27d48bd4f", 184 | "sha256:8280f9dae4adb5889ce0bb3ec6a541bf05434db5f9ab7673078c00713d148365", 185 | "sha256:83469ad15262402b0e0974e612546bc0b05f379b5aa9072ebf66d0f8fef16bea", 186 | "sha256:860d0fe234922fd5552b7f807fbb039e3e7ca58c18c8d38aa0d0a95ddf4f6c23", 187 | "sha256:883c9fb62cebd1e7126dd683222b3b919657590c3e2db33bdc50ebbad53e0338", 188 | "sha256:8afcb6f4064d234a43fea108859942d9795c4060ed0fbd9082b0f280181a15c1", 189 | "sha256:96f51489ac187f4bab588cf51f9ff2d40b6d170ac9a4270ffaed535c8404256b", 190 | "sha256:9e865835e36dfbb1873b65e722ea627c096c11b05f796831e3a9b542926e979e", 191 | "sha256:aa0554495fe06172b550098909be8db79b5accdf6ffb59611900bea345df5eba", 192 | "sha256:b595e71c51657f9ee3235db8b53d0b57c09eee74dfb5b77edff0e46d2218dc02", 193 | "sha256:b6ff91356354b7ff3bd208adcf875056d3d886ed7cef90c571aef2ab8a554b12", 194 | "sha256:b70bad2f1a5bd3460746c3fb3ab69e4e0eb5f59d977a23f9b66e5bdc74d97b86", 195 | "sha256:c7adb1f69a80573698c2def5ead584138ca00fff4ad9785a4b0b2bf927ba308d", 196 | "sha256:c898b3ebcc9eae7b36bd0b4bbbafce2d8076680f6868bcbacee2d39a7a9726a7", 197 | "sha256:e49947d583fe4d29af528677e4f0aa21f5e535ca2ae69c48270ebebd0d8843c0", 198 | "sha256:eb1d71643e4154398b02e88a42fc8b29db8c44ce4134cf0f4474bfc5cb5d4dac", 199 | "sha256:f2e8a9c0c8813a468aa659a01af6592f71cd30237ec27c4cc0683f089f90dcfc", 200 | "sha256:fe7fe11019fc3e6600819775a7d55abc5446dda07e9795f5954fdbf8a49e1c37" 201 | ], 202 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 203 | "version": "==1.3.19" 204 | }, 205 | "urllib3": { 206 | "hashes": [ 207 | "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", 208 | "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" 209 | ], 210 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 211 | "version": "==1.25.10" 212 | }, 213 | "werkzeug": { 214 | "hashes": [ 215 | "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", 216 | "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" 217 | ], 218 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 219 | "version": "==1.0.1" 220 | } 221 | }, 222 | "develop": { 223 | "alabaster": { 224 | "hashes": [ 225 | "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", 226 | "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" 227 | ], 228 | "version": "==0.7.12" 229 | }, 230 | "babel": { 231 | "hashes": [ 232 | "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", 233 | "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" 234 | ], 235 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 236 | "version": "==2.8.0" 237 | }, 238 | "certifi": { 239 | "hashes": [ 240 | "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", 241 | "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" 242 | ], 243 | "version": "==2020.6.20" 244 | }, 245 | "chardet": { 246 | "hashes": [ 247 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 248 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 249 | ], 250 | "version": "==3.0.4" 251 | }, 252 | "docutils": { 253 | "hashes": [ 254 | "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", 255 | "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" 256 | ], 257 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 258 | "version": "==0.16" 259 | }, 260 | "idna": { 261 | "hashes": [ 262 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 263 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 264 | ], 265 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 266 | "version": "==2.10" 267 | }, 268 | "imagesize": { 269 | "hashes": [ 270 | "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", 271 | "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" 272 | ], 273 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 274 | "version": "==1.2.0" 275 | }, 276 | "jinja2": { 277 | "hashes": [ 278 | "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", 279 | "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" 280 | ], 281 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 282 | "version": "==2.11.2" 283 | }, 284 | "markupsafe": { 285 | "hashes": [ 286 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 287 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 288 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 289 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 290 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 291 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 292 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 293 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 294 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 295 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 296 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 297 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 298 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 299 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 300 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 301 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 302 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 303 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 304 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 305 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 306 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 307 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 308 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 309 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 310 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 311 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 312 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 313 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 314 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 315 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 316 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 317 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 318 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" 319 | ], 320 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 321 | "version": "==1.1.1" 322 | }, 323 | "packaging": { 324 | "hashes": [ 325 | "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", 326 | "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" 327 | ], 328 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 329 | "version": "==20.4" 330 | }, 331 | "pygments": { 332 | "hashes": [ 333 | "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", 334 | "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" 335 | ], 336 | "markers": "python_version >= '3.5'", 337 | "version": "==2.7.1" 338 | }, 339 | "pyparsing": { 340 | "hashes": [ 341 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 342 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 343 | ], 344 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 345 | "version": "==2.4.7" 346 | }, 347 | "pytz": { 348 | "hashes": [ 349 | "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", 350 | "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" 351 | ], 352 | "version": "==2020.1" 353 | }, 354 | "requests": { 355 | "hashes": [ 356 | "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", 357 | "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" 358 | ], 359 | "index": "pypi", 360 | "version": "==2.24.0" 361 | }, 362 | "six": { 363 | "hashes": [ 364 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 365 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 366 | ], 367 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 368 | "version": "==1.15.0" 369 | }, 370 | "snowballstemmer": { 371 | "hashes": [ 372 | "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", 373 | "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" 374 | ], 375 | "version": "==2.0.0" 376 | }, 377 | "sphinx": { 378 | "hashes": [ 379 | "sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8", 380 | "sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0" 381 | ], 382 | "index": "pypi", 383 | "version": "==3.2.1" 384 | }, 385 | "sphinx-rtd-theme": { 386 | "hashes": [ 387 | "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d", 388 | "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82" 389 | ], 390 | "index": "pypi", 391 | "version": "==0.5.0" 392 | }, 393 | "sphinxcontrib-applehelp": { 394 | "hashes": [ 395 | "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", 396 | "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" 397 | ], 398 | "markers": "python_version >= '3.5'", 399 | "version": "==1.0.2" 400 | }, 401 | "sphinxcontrib-devhelp": { 402 | "hashes": [ 403 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 404 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 405 | ], 406 | "markers": "python_version >= '3.5'", 407 | "version": "==1.0.2" 408 | }, 409 | "sphinxcontrib-htmlhelp": { 410 | "hashes": [ 411 | "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", 412 | "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" 413 | ], 414 | "markers": "python_version >= '3.5'", 415 | "version": "==1.0.3" 416 | }, 417 | "sphinxcontrib-httpdomain": { 418 | "hashes": [ 419 | "sha256:1fb5375007d70bf180cdd1c79e741082be7aa2d37ba99efe561e1c2e3f38191e", 420 | "sha256:ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335" 421 | ], 422 | "index": "pypi", 423 | "version": "==1.7.0" 424 | }, 425 | "sphinxcontrib-jsmath": { 426 | "hashes": [ 427 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 428 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 429 | ], 430 | "markers": "python_version >= '3.5'", 431 | "version": "==1.0.1" 432 | }, 433 | "sphinxcontrib-qthelp": { 434 | "hashes": [ 435 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 436 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 437 | ], 438 | "markers": "python_version >= '3.5'", 439 | "version": "==1.0.3" 440 | }, 441 | "sphinxcontrib-serializinghtml": { 442 | "hashes": [ 443 | "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", 444 | "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" 445 | ], 446 | "markers": "python_version >= '3.5'", 447 | "version": "==1.1.4" 448 | }, 449 | "urllib3": { 450 | "hashes": [ 451 | "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", 452 | "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" 453 | ], 454 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 455 | "version": "==1.25.10" 456 | } 457 | } 458 | } 459 | --------------------------------------------------------------------------------