Repository: google/ctfscoreboard Branch: main Commit: 28a8f6c30e40 Files: 135 Total size: 2.9 MB Directory structure: gitextract_m6halfw6/ ├── .codecov.yml ├── .coveragerc ├── .editorconfig ├── .gcloudignore ├── .gitignore ├── .hooks/ │ └── pre-commit.sh ├── .travis.yml ├── AUTHORS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app.yaml ├── config.example.py ├── doc/ │ ├── developing/ │ │ ├── README.md │ │ └── requirements.txt │ ├── docker/ │ │ ├── supervisord.conf │ │ └── uwsgi.ini │ ├── nginx.conf │ └── uwsgi.ini ├── main.py ├── requirements.txt ├── scoreboard/ │ ├── __init__.py │ ├── attachments/ │ │ ├── __init__.py │ │ ├── file.py │ │ ├── gcs.py │ │ └── testing.py │ ├── auth/ │ │ ├── __init__.py │ │ └── local.py │ ├── cache.py │ ├── config_defaults.py │ ├── context.py │ ├── controllers.py │ ├── csrfutil.py │ ├── errors.py │ ├── logger.py │ ├── mail.py │ ├── main.py │ ├── models.py │ ├── rest.py │ ├── tests/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── cache_test.py │ │ ├── controllers_test.py │ │ ├── csrfutil_test.py │ │ ├── data.py │ │ ├── models_test.py │ │ ├── rest_test.py │ │ ├── utils_test.py │ │ └── validators_test.py │ ├── utils.py │ ├── validators/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── nonce.py │ │ ├── per_team.py │ │ ├── regex.py │ │ └── static_pbkdf2.py │ ├── views.py │ └── wsgi.py ├── static/ │ ├── css/ │ │ └── .keep │ ├── js/ │ │ ├── Chart.Step.js │ │ ├── app.js │ │ ├── controllers/ │ │ │ ├── admin/ │ │ │ │ ├── challenges.js │ │ │ │ ├── news.js │ │ │ │ ├── page.js │ │ │ │ ├── teams.js │ │ │ │ └── tools.js │ │ │ ├── challenges.js │ │ │ ├── global.js │ │ │ ├── page.js │ │ │ ├── registration.js │ │ │ ├── scoreboard.js │ │ │ └── teams.js │ │ ├── directives.js │ │ ├── filters.js │ │ └── services/ │ │ ├── admin.js │ │ ├── challenges.js │ │ ├── global.js │ │ ├── page.js │ │ ├── session.js │ │ ├── teams.js │ │ ├── upload.js │ │ └── users.js │ ├── partials/ │ │ ├── admin/ │ │ │ ├── attachments.html │ │ │ ├── challenge.html │ │ │ ├── challenges.html │ │ │ ├── news.html │ │ │ ├── page.html │ │ │ ├── pages.html │ │ │ ├── restore.html │ │ │ ├── tags.html │ │ │ ├── teams.html │ │ │ ├── tools.html │ │ │ └── users.html │ │ ├── challenge_grid.html │ │ ├── components/ │ │ │ ├── challenge.html │ │ │ └── countdown.html │ │ ├── login.html │ │ ├── page.html │ │ ├── profile.html │ │ ├── pwreset.html │ │ ├── register.html │ │ ├── scoreboard.html │ │ └── team.html │ ├── scss/ │ │ ├── scoreboard-colors.scss │ │ ├── scoreboard-mobile.scss │ │ └── scoreboard.scss │ └── third_party/ │ ├── angular/ │ │ ├── LICENSE │ │ ├── angular-csp.css │ │ ├── angular-resource.js │ │ ├── angular-route.js │ │ ├── angular-sanitize.js │ │ └── angular.js │ ├── bootstrap/ │ │ ├── LICENSE │ │ ├── bootstrap.css │ │ └── bootstrap.js │ ├── bootstrap-theme/ │ │ └── bootstrap-theme.css │ ├── chart/ │ │ ├── Chart.Scatter.js │ │ ├── Chart.js │ │ ├── LICENSE.md │ │ └── Scatter.LICENSE.md │ ├── jquery/ │ │ ├── LICENSE.txt │ │ └── jquery.js │ ├── moment/ │ │ ├── LICENSE │ │ └── moment.js │ └── pagedown/ │ ├── LICENSE.txt │ ├── Markdown.Converter.js │ ├── Markdown.Editor.js │ └── Markdown.Sanitizer.js ├── templates/ │ ├── base.html │ ├── error.html │ ├── index.html │ └── pwreset.eml └── tests.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codecov.yml ================================================ codecov: notify: require_ci_to_pass: true comment: behavior: default layout: header, diff require_changes: false coverage: precision: 2 range: - 50.0 - 90.0 round: down status: changes: false patch: true project: true parsers: gcov: branch_detection: conditional: true loop: true macro: false method: false javascript: enable_partials: false ================================================ FILE: .coveragerc ================================================ [run] source = scoreboard omit = scoreboard/tests/* main.py setup.py branch = True ================================================ FILE: .editorconfig ================================================ # EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true [*] end_of_line = lf indent_style = space indent_size = 4 charset = utf-8 [*.py] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .gcloudignore ================================================ .gcloudignore .git scoreboard/tests *.md *.pyc htmlcov/ doc/ .hooks/ LICENSE AUTHORS Makefile Dockerfile config.example.py .coverage .coveragerc .travis.yml .editorconfig .gitignore .codecov.yml ================================================ FILE: .gitignore ================================================ # Compiled, swap files *.pyc *.swp .virtualenv __pycache__ # Runtime data /config.py *.bak *.db /attachments /attachments/** # Generated files /static/js/app.min.js /static/css/*.css # Python coverage tool2 .coverage htmlcov ================================================ FILE: .hooks/pre-commit.sh ================================================ #!/bin/bash if [ "${SKIP_TESTS}" != "" ] ; then exit 0 fi # stash code not to be committed git stash -q --keep-index >/dev/null 2>&1 RESULT=0 if git status --porcelain | awk '{print $2}' | grep -q '^scoreboard/' ; then # Run tests and flake8 if any files in scoreboard/... changed. python3 tests.py && flake8 scoreboard main.py RESULT=$? fi # restore stash # git has a bad bug with 2.24 and --quiet where it deletes files if git --version | grep -q '^git version 2.24' ; then git stash pop >/dev/null 2>&1 else git stash pop -q >/dev/null 2>&1 fi exit $RESULT ================================================ FILE: .travis.yml ================================================ language: python sudo: false python: - "2.7" - "3.6" - "3.7" install: - pip install -r requirements.txt - pip install -r doc/developing/requirements.txt - pip install codecov script: - coverage run tests.py - flake8 . after_success: - codecov ================================================ FILE: AUTHORS ================================================ Current maintainer: David Tomaschik Core Team: Andrew Griffiths David Tomaschik Niru Ragupathy Zachary Wade ================================================ FILE: CONTRIBUTING.md ================================================ Want to contribute? Great! First, read this page (including the small print at the end). ### Before you contribute Before we can use your code, you must sign the [Google Individual Contributor License Agreement] (https://cla.developers.google.com/about/google-individual) (CLA), which you can do online. The CLA is necessary mainly because you own the copyright to your changes, even after your contribution becomes part of our codebase, so we need your permission to use and distribute your code. We also need to be sure of various other things—for instance that you'll tell us if you know that your code infringes on other people's patents. You don't have to sign the CLA until after you've submitted your code for review and a member has approved it, but you must do it before we can put your code into our codebase. Before you start working on a larger contribution, you should get in touch with us first through the issue tracker with your idea so that we can help out and possibly guide you. Coordinating up front makes it much easier to avoid frustration later on. ### Code reviews All submissions, including submissions by project members, require review. We use Github pull requests for this purpose. ### The small print Contributions made by corporations are covered by a different agreement than the one above, the [Software Grant and Corporate Contributor License Agreement] (https://cla.developers.google.com/about/google-corporate). ================================================ FILE: Dockerfile ================================================ FROM debian:buster RUN apt-get update && apt-get install -y \ nginx \ python3 \ python3-dev \ python3-pip \ supervisor \ uwsgi \ uwsgi-plugin-python3 \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt RUN echo "daemon off;" >> /etc/nginx/nginx.conf COPY doc/nginx.conf /etc/nginx/sites-enabled/default COPY doc/docker/supervisord.conf /etc/supervisor/conf.d/ COPY . /opt/scoreboard # Suggest you mount a config at /opt/scoreboard/config.py instead COPY config.example.py /opt/scoreboard/config.py WORKDIR /opt/scoreboard RUN make # TODO: migrate this to run at runtime RUN python3 main.py createdb RUN chmod 666 /tmp/scoreboard* CMD ["/usr/bin/supervisord"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ # Makefile to minimize JS using UglifyJS (https://github.com/mishoo/UglifyJS2) # or 'cat' to just assemble into one file. MIN_JS:=static/js/app.min.js JS_SRC:=$(shell find static/js \! -name '*.min.*' -name '*.js') MINIFY:=$(shell which uglifyjs >/dev/null && echo `which uglifyjs` || echo cat) # Declarations for SCSS SCSS_SRC:=$(shell find static/scss -name '*.scss') PYSCSS:=$(shell which pyscss >/dev/null && echo `which pyscss` ) all: $(MIN_JS) scss $(MIN_JS): $(JS_SRC) $(MINIFY) $^ > $@ dev: scss python main.py scss: @if [ "$(PYSCSS)" = "" ]; then\ echo "pyscss not found, exiting";\ exit -1;\ fi;\ for i in $$(ls static/scss/); do\ echo "Making $${i%.scss}.css";\ $(PYSCSS) -o static/css/$${i%.scss}.css static/scss/$$i;\ done tests: python tests.py coverage: coverage run main.py runtests coverage html xdg-open htmlcov/index.html ================================================ FILE: README.md ================================================ ## CTF Scoreboard ## This is a basic CTF Scoreboard, with support for teams or individual competitors, and a handful of other features. Copyright 2020 Google LLC. This is not an official Google product. Author: Please see the AUTHORS file. This is a version 2.x branch. We've eliminated categories, in favor of tagging challenges. This simplifies the codebase significantly, and is a better fit since so many challenges border on more than one category. However, this branch is not compatible with databases from 1.x. If you need that, check out the 1.x branch, which will only be getting security & bug fixes. ### Installation ### 1. Install Python with PIP and setuptools. If you'd like to use a virtualenv, set one up and activate it now. Please note that only Python 3.6+ is officially supported at the present time, but it should still work on Python 2.7. 2. Install the dependencies: pip install -r requirements.txt 3. Install a database library. For MySQL, consider PyMySQL. For Postgres, use psycopg2. (Others may work; untested.) 4. Write a config.py for your relevant installation. An example is provided in config.example.py. SQLALCHEMY_DATABASE_URI = 'mysql://username:password@server/db' #SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://username:password@server/db' SECRET_KEY = 'Some Random Value For Session Keys' TEAM_SECRET_KEY = 'Another Random Value For Team Invite Codes' TITLE = 'FakeCTF' TEAMS = True ATTACHMENT_DIR = 'attachments' LOGIN_METHOD = 'local' # or appengine If you are using plaintext HTTP to run your scoreboard, you will need to add the following to your config.py, so that cookies will work: SESSION_COOKIE_SECURE = False If you are developing the scoreboard, the following settings may be useful for debugging purposes. Not useful for production usage, however. COUNT_QUERIES = True SQLALCHEMY_ECHO = True 5. Create the database: python main.py createdb 6. Set up your favorite python application server, optionally behind a webserver. You'll want to use main.app as your WSGI handler. Tested with uwsgi + nginx. Not tested with anything else, let me know if you have success. Sample configs are in doc/. 7. Register a user. The first user registed is automatically made an admin. You probably want to register your user before your players get access. 8. Have fun! Maybe set up some challenges. Players might like that more. ### Installation using Docker ### 1. Navigate to the folder where the Dockerfile is located. 2. Run the command below to build a docker image for the scoreboard and tag it as "scoreboard". docker build -t "scoreboard" . 3. Run the command below to create the docker container. docker create -p 80:80 scoreboard 4. Find the name of the container you created for the scoreboard. docker container ls -a 5. Run the command below to start the docker container for the scoreboard. docker start "container_name" ### Options ### **SCORING**: Set to 'progressive' to enable a scoring system where the total points for each challenge are divided amongst all the teams that solve that challenge. This rewards teams that solve infrequently solved (hard or obscure) challenges. **TITLE**: Scoreboard page titles. **TEAMS**: True if teams should be used, False for each player on their own team. **SQLALCHEMY_DATABASE_URI**: A SQLAlchemy database URI string. **LOGIN_METHOD**: Supports 'local' ### Development ### [![Build Status](https://travis-ci.org/google/ctfscoreboard.svg?branch=master)](https://travis-ci.org/google/ctfscoreboard) [![codecov](https://codecov.io/gh/google/ctfscoreboard/branch/master/graph/badge.svg)](https://codecov.io/gh/google/ctfscoreboard) **Use hooks** ln -s ../../.hooks/pre-commit.sh .git/hooks/pre-commit **Test Cases** - Setup database - Create user, verify admin - Create challenge - With, without attachment - Edit challenges - Add attachment - Delete attachment - Download backup - Restore backup - Create 2nd user, verify not admin - Solve challenge - Download attachment ### Thanks ### This project stands on the shoulders of giants. A big thanks to the following projects used to build this: - [Flask](http://flask.pocoo.org/) - [Flask-SQLAlchemy](https://pythonhosted.org/Flask-SQLAlchemy/) - [Flask-RESTful](https://flask-restful.readthedocs.io/en/latest/) - [SQLAlchemy](http://www.sqlalchemy.org/) - [AngularJS](https://angularjs.org/) - [jQuery](https://jquery.com/) - [PageDown](https://jquery.com/) - [Bootstrap](http://getbootstrap.com/) And many more indirect dependencies. ================================================ FILE: app.yaml ================================================ runtime: python37 instance_class: F4 automatic_scaling: max_instances: 50 max_idle_instances: 10 handlers: - url: /css static_dir: static/css secure: always - url: /js static_dir: static/js secure: always - url: /partials static_dir: static/partials secure: always - url: /fonts static_dir: static/fonts secure: always - url: /third_party static_dir: static/third_party secure: always - url: /createdb secure: always script: auto - url: /.* secure: always script: auto ================================================ FILE: config.example.py ================================================ # Copyright 2016 Google Inc. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Demo config.py, please configure your own SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/scoreboard.db' SQLALCHEMY_TRACK_MODIFICATIONS = True SECRET_KEY = 'CHANGEME CHANGEME CHANGEME' # Set TEAM_SECRET_KEY to a unique value so that you can rotate session # secrets (SECRET_KEY) without affecting team invite codes. TEAM_SECRET_KEY = SECRET_KEY TITLE = 'CTF Scoreboard Dev' TEAMS = True ATTACHMENT_BACKEND = 'file:///tmp/attachments' LOGIN_METHOD = 'local' SESSION_COOKIE_SECURE = False PROOF_OF_WORK_BITS = 12 ================================================ FILE: doc/developing/README.md ================================================ # Development Setup You'll want to have `python3` and `pip` installed. I also recommend `virtualenv` and `virtualenvwrapper` (if you don't have these, skip the virtualenv steps). On Debian or Ubuntu Linux, run: `apt install python3 python3-pip virtualenvwrapper` 1. `mkvirtualenv -p $(which python3) scoreboard` to create the virtualenv. 2. `git clone https://github.com/google/ctfscoreboard && cd ctfscoreboard` to clone the source. 3. `pip install -r requirements.txt` to install runtime dependencies. 4. `pip install -r doc/developing/requirements.txt` to install development dependencies. 5. `ln -s .hooks/pre-commit.sh .git/hooks/pre-commit` to install the development pre-commit hook. # Configuration & Initial Setup Copy the file `config.example.py` to `config.py` to make a configuration. This is suitable for basic development work, and will use a sqlite3 database in `/tmp/scoreboard.db` for storage. You'll want to run `python main.py createdb` to create the initial database. Optionally, you can run `python main.py createdata` to create some test data. These are just dummy challenges, teams, and users used for testing. # Running/Iterating You can either run `make dev` or `python main.py` to run the development server. By default, it runs on port 9999, but you can change this in your `config.py`. **Note** that if you make changes to models, affecting the database schema, you must either manually update your database, or delete it and recreate from scratch. Because this is a CTF scoreboard, used for short-lived events, there's no migration code. Run `make scss` to compile SCSS to CSS. You'll want to do this at least once, and any time you change the SCSS. Note that the CSS is not tracked in the git repository, so all style changes *must* be to SCSS. # Making Changes Please do all development work on a feature branch. Run the tests before you commit (if you have the git hook, it should run the the tests before committing). We try to mostly follow PEP-8, and `flake8` helps catch those mistakes. ================================================ FILE: doc/developing/requirements.txt ================================================ # Additional requirements for development coverage flake8 flask-testing mock ================================================ FILE: doc/docker/supervisord.conf ================================================ [supervisord] nodaemon=true [program:uwsgi] command=/usr/bin/uwsgi --ini /opt/scoreboard/doc/docker/uwsgi.ini stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:nginx] command=/usr/sbin/nginx stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ================================================ FILE: doc/docker/uwsgi.ini ================================================ # Sample uWSGI config file [uwsgi] chdir = /opt/scoreboard socket = 127.0.0.1:9000 processes = 4 threads = 2 master = true module = scoreboard.wsgi callable = app uid = nobody gid = nogroup daemonize = /var/log/uwsgi/app/uwsgi.log plugins = python3 ================================================ FILE: doc/nginx.conf ================================================ server { listen 80 default_server; root /opt/scoreboard/static; # Make sure code is not in document root! location @backend { include uwsgi_params; uwsgi_pass 127.0.0.1:9000; } location / { try_files $uri @backend; } } ================================================ FILE: doc/uwsgi.ini ================================================ # Sample uWSGI config file [uwsgi] chdir = /opt/scoreboard socket = 127.0.0.1:9000 processes = 4 threads = 2 master = true module = scoreboard.wsgi callable = app virtualenv = /opt/virtualenv uid = nobody gid = nogroup daemonize = /var/log/uwsgi/app/uwsgi.log plugins = python ================================================ FILE: main.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import sys from scoreboard import wsgi from scoreboard import models # For use in gunicorn from scoreboard.wsgi import app # noqa: F401 def main(argv): if 'createdb' in argv: models.db.create_all() elif 'createdata' in argv: from scoreboard.tests import data models.db.create_all() data.create_all() elif 'shell' in argv: try: import IPython run_shell = IPython.embed except ImportError: import readline # noqa: F401 import code run_shell = code.InteractiveConsole().interact run_shell() else: wsgi.app.run( host='0.0.0.0', debug=True, port=wsgi.app.config.get('PORT', 9999)) if __name__ == '__main__': main(sys.argv) ================================================ FILE: requirements.txt ================================================ Flask Flask-RESTful Flask-SQLAlchemy Flask-Scss Jinja2 MarkupSafe PyMySQL SQLAlchemy<1.4.0 Werkzeug<1.0.0 aniso8601 argparse itsdangerous pbkdf2 pylibmc python-dateutil pytz six google-cloud-logging google-cloud-storage mailjet_rest requests ================================================ FILE: scoreboard/__init__.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: scoreboard/attachments/__init__.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Handle attachments via the appropriate backend. Required API: send(models.Attachment): returns a flask response to download attachment upload(werkzeug.datastructures.FileStorage): returns attachment ID and path delete(models.Attachment): deletes the attachment specified """ try: import urlparse except ImportError: from urllib import parse as urlparse from scoreboard import main app = main.get_app() backend = None def get_backend_path(): """Get backend path for attachments.""" return app.config.get('ATTACHMENT_BACKEND') def get_backend_type(): """Determine type of backend.""" backend = get_backend_path() return urlparse.urlparse(backend).scheme def get_backend(_backend_type): backend = None if _backend_type == "file": from . import file as backend elif _backend_type == "gcs": from . import gcs as backend elif _backend_type == "test": from . import testing as backend else: raise ImportError('Unhandled attachment backend %s' % _backend_type) return backend def patch(_backend_type): globals()['backend'] = get_backend(_backend_type) backend = get_backend(get_backend_type()) ================================================ FILE: scoreboard/attachments/file.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Local filesystem backend for attachments. """ import hashlib import os import os.path try: import urlparse except ImportError: from urllib import parse as urlparse import flask from scoreboard import main app = main.get_app() def attachment_dir(create=False): """Return path and optionally create attachment directory.""" components = urlparse.urlparse(app.config.get('ATTACHMENT_BACKEND')) app.config_dir = components.path or components.netloc if app.config.get('CWD'): target_dir = os.path.normpath(os.path.join(app.config.get('CWD'), app.config_dir)) else: target_dir = os.path.abspath(app.config_dir) if not os.path.isdir(target_dir): if create: os.mkdir(target_dir) else: app.logger.error('Missing or invalid ATTACHMENT_DIR: %s', target_dir) flask.abort(500) return target_dir def send(attachment): """Send the attachment to the client.""" return flask.send_from_directory( attachment_dir(), attachment.aid, mimetype=attachment.content_type, attachment_filename=attachment.filename, as_attachment=True) def delete(attachment): """Delete the attachment from disk.""" path = os.path.join(attachment_dir(), attachment.aid) os.unlink(path) def upload(fp): """Upload the file attachment to the storage medium.""" md = hashlib.sha256() while True: blk = fp.read(2**16) if not blk: break md.update(blk) aid = md.hexdigest() fp.seek(0, os.SEEK_SET) dest_name = os.path.join(attachment_dir(create=True), aid) fp.save(dest_name, buffer_size=2**16) # TODO: add file:// prefix return aid, dest_name ================================================ FILE: scoreboard/attachments/gcs.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Attachments on Google Cloud Storage. """ import hashlib import os try: import urlparse except ImportError: import urllib.parse as urlparse try: from io import BytesIO except ImportError: try: import cStringIO.StringIO as BytesIO except ImportError: import StringIO.StringIO as BytesIO import flask from google.cloud import storage from google.cloud import exceptions from scoreboard import main app = main.get_app() def get_bucket(path=None): path = path or app.config.get('ATTACHMENT_BACKEND') url = urlparse.urlparse(path) return url.netloc def send(attachment): """Send to download URI.""" try: client = storage.Client() bucket = client.bucket(get_bucket()) buf = BytesIO() blob = bucket.get_blob(attachment.aid) if not blob: return flask.abort(404) blob.download_to_file(buf) buf.seek(0) return flask.send_file( buf, mimetype=attachment.content_type, attachment_filename=attachment.filename, add_etags=False, as_attachment=True) except exceptions.NotFound: return flask.abort(404) def delete(attachment): """Delete from GCS Bucket.""" try: client = storage.Client() bucket = client.bucket(get_bucket()) bucket.delete_blob(attachment.aid) except exceptions.NotFound: return flask.abort(404) def upload(fp): """Upload the attachment.""" md = hashlib.sha256() while True: blk = fp.read(2**16) if not blk: break md.update(blk) fp.seek(0, os.SEEK_SET) aid = md.hexdigest() client = storage.Client() bucket = client.bucket(get_bucket()) blob = bucket.blob(aid) blob.upload_from_file(fp) path = 'gcs://{}/{}'.format(get_bucket(), aid) return aid, path ================================================ FILE: scoreboard/attachments/testing.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Volatile filesystem backend for attachments. """ import hashlib import io import flask from scoreboard import main app = main.get_app() files = {} def send(attachment): """Send the attachment to the client.""" return flask.send_file(files[attachment.aid], attachment_filename="testing.txt", as_attachment=True) def delete(attachment): """Delete the attachment from disk.""" del files[attachment.aid] def upload(fp): """Upload the file attachment to the storage medium.""" md = hashlib.sha256() ret = io.BytesIO() while True: blk = fp.read(2**16) if not blk: break md.update(blk) ret.write(blk) aid = md.hexdigest() ret.seek(0) files[aid] = ret return aid, ('test://%s' % aid) ================================================ FILE: scoreboard/auth/__init__.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ This supports multiple auth systems as configured by the LOGIN_METHOD setting. The required API includes: login_user(flask_request): returns User or None get_login_uri(): returns URI for login get_register_uri(): returns URI for registration logout(): returns None register(flask_request): register a new user """ from scoreboard import main _login_method = main.get_app().config.get('LOGIN_METHOD') if _login_method == 'local': from scoreboard.auth.local import * # noqa: F401,F403 else: raise ImportError('Unhandled LOGIN_METHOD %s' % _login_method) ================================================ FILE: scoreboard/auth/local.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Local login support.""" from scoreboard import controllers from scoreboard import errors from scoreboard import models def login_user(flask_request): """Get the user for this request.""" data = flask_request.get_json() email = data['email'].lower() user = models.User.login_user(email, data['password']) if not user: raise errors.LoginError('Invalid username/password.') return user def get_login_uri(): return '/login' def get_register_uri(): return '/register' def logout(): pass def register(flask_request): data = flask_request.get_json() user = controllers.register_user( data['email'].lower(), data['nick'], data['password'], data.get('team_id'), data.get('team_name'), data.get('team_code')) return user ================================================ FILE: scoreboard/cache.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import functools import json import flask from werkzeug.contrib import cache from scoreboard import main app = main.get_app() class CacheWrapper(object): def __init__(self, app): cache_type = app.config.get('CACHE_TYPE') if cache_type == 'memcached': host = app.config.get('MEMCACHE_HOST') self._cache = cache.MemcachedCache([host]) elif cache_type == 'local': self._cache = cache.SimpleCache() else: self._cache = cache.NullCache() def __getattr__(self, name): return getattr(self._cache, name) global_cache = CacheWrapper(app) def rest_cache(f_or_key): """Mark a function for global caching.""" override_cache_key = None def wrap_func(f): @functools.wraps(f) def wrapped(*args, **kwargs): if override_cache_key: cache_key = override_cache_key else: try: cache_key = '%s/%s' % ( f.im_class.__name__, f.__name__) except AttributeError: cache_key = f.__name__ return _rest_cache_caller(f, cache_key, *args, **kwargs) return wrapped if isinstance(f_or_key, str): override_cache_key = f_or_key return wrap_func return wrap_func(f_or_key) def rest_cache_path(f): """Cache a result based on the path received.""" @functools.wraps(f) def wrapped(*args, **kwargs): cache_key = flask.request.path.encode('utf-8') return _rest_cache_caller(f, cache_key, *args, **kwargs) return wrapped def rest_team_cache(f_or_key): """Mark a function for per-team caching.""" override_cache_key = None def wrap_func(f): @functools.wraps(f) def wrapped(*args, **kwargs): if flask.g.tid: if override_cache_key: cache_key = override_cache_key % (flask.g.tid) else: try: cache_key = '%s/%s/%s' % ( f.im_class.__name__, f.__name__, flask.g.tid) except AttributeError: cache_key = '%s/%s' % ( f.__name__, flask.g.tid) return _rest_cache_caller(f, cache_key, *args, **kwargs) return f(*args, **kwargs) return wrapped if isinstance(f_or_key, str): override_cache_key = f_or_key if '%d' not in override_cache_key: raise ValueError('No way to override the key per team!') return wrap_func return wrap_func(f_or_key) def delete(key): """Delete cache entry.""" global_cache.delete(key) def clear(): """Flush global cache.""" global_cache.clear() def delete_team(base_key): """Delete team-based cache entry.""" if not flask.g.tid: return global_cache.delete(base_key % flask.g.tid) def _rest_cache_caller(f, cache_key, *args, **kwargs): value = global_cache.get(cache_key) if value: try: return _rest_add_cache_header(json.loads(value), True) except ValueError: pass value = f(*args, **kwargs) try: # TODO: only cache on success global_cache.set(cache_key, json.dumps(value)) except TypeError: pass return _rest_add_cache_header(value) def _rest_add_cache_header(rv, hit=False): # TODO: check status codes? headers = {'X-Cache-Hit': str(hit)} if isinstance(rv, str): return (rv, 200, headers) if isinstance(rv, tuple): if len(rv) == 1: return (rv[0], 200, headers) if len(rv) == 2: return (rv[0], rv[1], headers) if len(rv) == 3: if rv[2] is None: return (rv[0], rv[1], headers) if isinstance(rv[2], dict): rv[2].update(headers) return rv if isinstance(rv, (list, dict)): return rv, 200, headers # TODO: might need to support Response objects return rv ================================================ FILE: scoreboard/config_defaults.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os class Defaults(object): ATTACHMENT_BACKEND = 'file://attachments' COUNT_QUERIES = False CSP_POLICY = None CWD = os.path.dirname(os.path.realpath(__file__)) DEBUG = False EXTEND_CSP_POLICY = None ERROR_404_HELP = False FIRST_BLOOD = 0 FIRST_BLOOD_MIN = 0 GAME_TIME = (None, None) INVITE_KEY = None LOGIN_METHOD = 'local' MAIL_FROM = None MAIL_FROM_NAME = None MAIL_HOST = 'localhost' NEWS_POLL_INTERVAL = 60000 PROOF_OF_WORK_BITS = 0 RULES = '/rules' SCOREBOARD_ZEROS = True SCORING = 'plain' SECRET_KEY = None TEAM_SECRET_KEY = None SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = True SQLALCHEMY_TRACK_MODIFICATIONS = True SESSION_EXPIRATION_SECONDS = 60 * 60 SYSTEM_NAME = 'root' TEAMS = True TEASE_HIDDEN = True TITLE = 'Scoreboard' SUBMIT_AFTER_END = True ================================================ FILE: scoreboard/context.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import collections import time import flask from sqlalchemy import event from scoreboard import main from scoreboard import models from scoreboard import utils app = main.get_app() DEFAULT_CSP_POLICY = { 'default-src': ["'self'"], 'script-src': [ "'self'", "'unsafe-eval'", # Needed for Charts.js ], 'img-src': [ "'self'", 'data:', ], 'object-src': ["'none'"], 'font-src': [ "'self'", 'fonts.gstatic.com', ], 'style-src': [ "'self'", 'fonts.googleapis.com', "'unsafe-inline'", # Needed for Charts.js ], } _CSP_POLICY_STRING = None def get_csp_policy(): global _CSP_POLICY_STRING if _CSP_POLICY_STRING is not None: return _CSP_POLICY_STRING if app.config.get('CSP_POLICY'): policy = app.config.get('CSP_POLICY') elif app.config.get('EXTEND_CSP_POLICY'): policy = collections.defaultdict(list) for k, v in DEFAULT_CSP_POLICY.items(): policy[k] = v for k, v in app.config.get('EXTEND_CSP_POLICY').items(): policy[k].extend(v) else: policy = DEFAULT_CSP_POLICY components = [] for k, v in policy.items(): sources = ' '.join(v) components.append(k + ' ' + sources) _CSP_POLICY_STRING = '; '.join(components) return _CSP_POLICY_STRING # Setup flask.g @app.before_request def load_globals(): """Prepopulate flask.g.* with properties.""" try: del flask.g.user except AttributeError: pass try: del flask.g.team except AttributeError: pass if load_apikey(): return if (app.config.get('SESSION_EXPIRATION_SECONDS') and flask.session.get('expires') and flask.session.get('expires') < time.time()): flask.session.clear() flask.g.uid = flask.session.get('user') flask.g.tid = flask.session.get('team') flask.g.admin = flask.session.get('admin') or False def load_apikey(): """Load flask.g.user, flask.g.uid from an API key.""" try: key = flask.request.headers.get('X-SCOREBOARD-API-KEY') if not key or len(key) != 32: return user = models.User.get_by_api_key(key) if not user: return flask.g.user = user flask.g.uid = user.uid flask.g.admin = user.admin flask.g.tid = None return True except Exception: # Don't want any API key problems to block requests pass # Add headers to responses @app.after_request def add_headers(response): """Add security-related headers to all outgoing responses.""" h = response.headers h.setdefault('Content-Security-Policy', get_csp_policy()) h.setdefault('X-Frame-Options', 'DENY') h.add('X-XSS-Protection', '1', mode='block') return response @app.context_processor def util_contexts(): return dict(gametime=utils.GameTime) _query_count = 0 if app.config.get('COUNT_QUERIES'): @event.listens_for(models.db.engine, 'before_cursor_execute') def receive_before_cursor_execute( conn, cursor, statement, parameters, context, executemany): global _query_count _query_count += 1 @app.after_request def count_queries(response): global _query_count if _query_count > 0: app.logger.info('Request issued %d queries.', _query_count) _query_count = 0 return response def ensure_setup(): if not app: raise RuntimeError('Invalid app setup.') ================================================ FILE: scoreboard/controllers.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import datetime import flask import re from sqlalchemy import exc import urllib from scoreboard import errors from scoreboard import mail from scoreboard import main from scoreboard import models from scoreboard import utils from scoreboard import validators # Need to handle Python 2 and 3. try: urllib_quote = urllib.quote except AttributeError: import urllib.parse urllib_quote = urllib.parse.quote app = main.get_app() def register_user(email, nick, password, team_id=None, team_name=None, team_code=None): """Registers a player. Arguments: email: User's email nick: User's nick password: Player's password team_id: Id# of team, or None to create new team. team_name: Name of new team. team_code: Validation code to join team. """ if not re.match(r'[-0-9a-zA-Z.+_]+@[-0-9a-zA-Z.+_]+\.[a-zA-Z]+$', email): raise errors.ValidationError('Invalid email address.') # TODO: Sanitize other fields first = models.User.query.count() == 0 if not first and app.config.get('TEAMS'): if team_id == 'new': try: app.logger.info('Creating new team %s for user %s', team_name, nick) team = models.Team.create(team_name) except exc.IntegrityError: models.db.session.rollback() raise errors.ValidationError('Team already exists!') else: team = models.Team.query.get(int(team_id)) if not team or team_code.lower() != team.code.lower(): raise errors.ValidationError( 'Invalid team selection or team code.') else: team = None try: if not team and not first: team = models.Team.create(nick) user = models.User.create(email, nick, password, team=team) models.commit() except exc.IntegrityError: models.db.session.rollback() if models.User.get_by_email(email): raise errors.ValidationError('Duplicate email address.') if models.User.get_by_nick(nick): raise errors.ValidationError('Duplicate nick.') if team_name and models.Team.get_by_name(team_name): raise errors.ValidationError('Duplicate team name.') raise errors.ValidationError('Unknown integrity error.') if not user.admin: models.ScoreHistory.add_entry(team) models.commit() app.logger.info('User %s <%s> registered from IP %s.', nick, email, flask.request.remote_addr) return user @utils.require_not_started def change_user_team(uid, team_tid, code): """Provide an interface for changing a user's team""" team = models.Team.query.get_or_404(team_tid) user = models.User.query.get_or_404(uid) old_team = user.team if code.lower() != team.code.lower(): raise errors.ValidationError('Invalid team selection or team code') if team.tid == user.team_tid: raise errors.ValidationError('Changing to same team') app.logger.info('User %s switched to team %s from %s' % (user.nick, team.name, old_team.name)) user.team = team user.team_tid = team_tid flask.session['team'] = team_tid if old_team.players.count() == 0 and len(old_team.answers) == 0: app.logger.info('Removing team %s due to lack of players' % old_team.name) models.db.session.delete(old_team) models.commit() @utils.require_submittable def submit_answer(cid, answer, token): """Submits an answer. Args: cid: The ID of the challenge. answer: The answer to check. token: Provided proof of work token. Returns: Number of points awarded for answer. """ correct = 'WRONG' nbits = app.config.get('PROOF_OF_WORK_BITS', 0) if nbits and not utils.validate_proof_of_work(answer, token, nbits): raise errors.InvalidAnswerError('Bad proof of work token!') team = models.Team.current() if not team: raise errors.AccessDeniedError('No team!') try: challenge = models.Challenge.query.get(cid) if not challenge.unlocked_for_team(team): raise errors.AccessDeniedError('Challenge is locked!') validator = validators.GetValidatorForChallenge(challenge) if validator.validate_answer(answer, team): points = save_team_answer(challenge, team, answer) if utils.GameTime.over(): correct = 'CORRECT (Game Over)' else: correct = 'CORRECT' return points else: raise errors.InvalidAnswerError('Really? Haha no....') except (errors.IntegrityError, errors.FlushError) as exc: app.logger.exception('Error saving flag: %s', exc) models.db.session.rollback() raise finally: user = models.User.current() app.challenge_log.info( 'Player %s <%s>(%d)/Team %s(%d) submitted ' '"%s" for Challenge %s<%d>: %s', user.nick, user.email, user.uid, team.name, team.tid, answer, challenge.name, challenge.cid, correct) @utils.require_submittable def save_team_answer(challenge, team, answer): """Create the answer entry and update the scores.""" ans = models.Answer.create(challenge, team, answer) models.commit() team.last_solve = datetime.datetime.utcnow() challenge.update_answers(exclude_team=team) if utils.GameTime.over(): return 0 team.score += ans.current_points models.ScoreHistory.add_entry(team) models.commit() return ans.current_points def test_answer(cid, answer): """Tests an answer, returns Truthiness of answer.""" try: challenge = models.Challenge.query.get(cid) validator = validators.GetValidatorForChallenge(challenge) return validator.validate_answer(answer, None) except errors.IntegrityError: return False def offer_password_reset(user): token = user.get_token().decode('utf-8') token_url = utils.absolute_url('/pwreset/%s/%s' % (urllib_quote(user.email), token)) message = flask.render_template( 'pwreset.eml', token_url=token_url, user=user, ip=flask.request.remote_addr, config=app.config) subject = '%s Password Reset' % app.config.get('TITLE') try: mail.send(message, subject, user.email, to_name=user.nick) except mail.MailFailure as ex: raise errors.ServerError('Could not send mail: ' + str(ex)) ================================================ FILE: scoreboard/csrfutil.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import binascii import flask import functools import hashlib import hmac import jinja2 import struct import time from scoreboard import main from scoreboard import utils app = main.get_app() b64_vals = utils.to_bytes('_-') def _get_csrf_token(user=None, expires=None): user = user or flask.session.get('user', flask.request.remote_addr) expires = expires or int(time.time()) + 60 * 60 * 24 expires_bytes = struct.pack('') return field % token @app.before_request def csrf_protection_request(): """Add CSRF Protection to all non-GET/non-HEAD requests.""" if flask.request.method in ('GET', 'HEAD'): return if app.config.get('TESTING'): return header = flask.request.headers.get('X-XSRF-TOKEN') token = header or flask.request.values.get('csrftoken') if not token or not verify_csrf_token(token): app.logger.warning('CSRF Validation Failed') flask.abort(403) @app.after_request def add_csrf_protection(resp): """Add the XSRF-TOKEN cookie to all outgoing requests.""" resp.set_cookie('XSRF-TOKEN', get_csrf_token()) return resp @app.context_processor def csrf_context_processor(): """Add CSRF token and field to all rendering contexts.""" return { 'csrftoken': get_csrf_token, 'csrffield': get_csrf_field, } ================================================ FILE: scoreboard/errors.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Custom error classes plus access to SQLAlchemy exceptions from werkzeug import exceptions from sqlalchemy.exc import * # noqa: F401,F403 from sqlalchemy.orm.exc import * # noqa: F401,F403 class _MessageException(exceptions.HTTPException): """Message with JSON exception.""" default_message = 'Error' def __init__(self, msg=None): msg = msg or self.default_message super(_MessageException, self).__init__() self.data = {'message': msg} class AccessDeniedError(_MessageException): """No access to the resource.""" code = 403 class ValidationError(_MessageException): """Error during input validation.""" code = 400 class InvalidAnswerError(AccessDeniedError): """Submitting the wrong answer.""" default_message = 'Ha ha ha... No.' class LoginError(AccessDeniedError): """Failing to login.""" default_message = 'Invalid username/password.' class ServerError(_MessageException): code = 500 ================================================ FILE: scoreboard/logger.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging import flask class Formatter(logging.Formatter): """Custom formatter to handle application logging. This formatter adds a "client" attribute that will log the user and client information. """ def format(self, record): if flask.request: user = (('UID<%d>' % flask.g.uid) if 'uid' in flask.g and flask.g.uid else '-') record.client = "[{}/{}]".format(flask.request.remote_addr, user) else: record.client = "" return super(Formatter, self).format(record) ================================================ FILE: scoreboard/mail.py ================================================ # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from email.mime import text import email.utils import smtplib import socket import mailjet_rest from scoreboard import main app = main.get_app() class MailFailure(Exception): """Inability to send mail.""" pass def send(message, subject, to, to_name=None, sender=None, sender_name=None): """Send an email.""" sender = sender or app.config.get('MAIL_FROM') sender_name = sender_name or app.config.get('MAIL_FROM_NAME') or '' mail_provider = app.config.get('MAIL_PROVIDER') if mail_provider is None: app.logger.error('No MAIL_PROVIDER configured!') raise MailFailure('No MAIL_PROVIDER configured!') elif mail_provider == 'smtp': _send_smtp(message, subject, to, to_name, sender, sender_name) elif mail_provider == 'mailjet': _send_mailjet(message, subject, to, to_name, sender, sender_name) else: app.logger.error('Invalid MAIL_PROVIDER configured!') raise MailFailure('Invalid MAIL_PROVIDER configured!') def _send_smtp(message, subject, to, to_name, sender, sender_name): """SMTP implementation of sending email.""" host = app.config.get('MAIL_HOST') if not host: raise MailFailure('SMTP Server Not Configured') try: server = smtplib.SMTP(host) except (smtplib.SMTPConnectError, socket.error) as ex: app.logger.error('Unable to send mail: %s', str(ex)) raise MailFailure('Error connecting to SMTP server.') msg = text.MIMEText(message) msg['Subject'] = subject msg['To'] = email.utils.formataddr((to_name, to)) msg['From'] = email.utils.formataddr((sender_name, sender)) try: if app.debug: server.set_debuglevel(True) server.sendmail(sender, [to], msg.as_string()) except (smtplib.SMTPException, socket.error) as ex: app.logger.error('Unable to send mail: %s', str(ex)) raise MailFailure('Error sending mail to SMTP server.') finally: try: server.quit() except smtplib.SMTPException: pass def _send_mailjet(message, subject, to, to_name, sender, sender_name): """Mailjet implementation of sending email.""" api_key = app.config.get('MJ_APIKEY_PUBLIC') api_secret = app.config.get('MJ_APIKEY_PRIVATE') if not api_key or not api_secret: app.logger.error('Missing MJ_APIKEY_PUBLIC/MJ_APIKEY_PRIVATE!') return # Note the data structures we use are api v3.1 client = mailjet_rest.Client( auth=(api_key, api_secret), api_url='https://api.mailjet.com/', version='v3.1') from_obj = { "Email": sender, } if sender_name: from_obj["Name"] = sender_name to_obj = [{ "Email": to, }] if to_name: to_obj[0]["Name"] = to_name message = { "From": from_obj, "To": to_obj, "Subject": subject, "TextPart": message, } result = client.send.create(data={'Messages': [message]}) if result.status_code != 200: app.logger.error( 'Error sending via mailjet: (%d) %r', result.status_code, result.text) raise MailFailure('Error sending via mailjet!') try: j = result.json() except Exception: app.logger.error('Error sending via mailjet: %r', result.text) raise MailFailure('Error sending via mailjet!') if j['Messages'][0]['Status'] != 'success': app.logger.error('Error sending via mailjet: %r', j) raise MailFailure('Error sending via mailjet!') ================================================ FILE: scoreboard/main.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import flask from flask import logging as flask_logging import logging import os from werkzeug import exceptions from werkzeug import utils as werkzeug_utils import flask_scss from scoreboard import logger # Singleton app instance _app_singleton = None def on_appengine(): """Returns true if we're running on AppEngine.""" runtime = os.environ.get('SERVER_SOFTWARE', '') gae_env = os.environ.get('GAE_ENV', '') return ((gae_env != '') or runtime.startswith('Development/') or runtime.startswith('Google App Engine/')) def create_app(config=None): app = flask.Flask( 'scoreboard', static_folder='../static', template_folder='../templates', ) app.config.from_object('scoreboard.config_defaults.Defaults') if config is not None: app.config.update(**config) if not on_appengine(): # Configure Scss to watch the files scss_compiler = flask_scss.Scss( app, static_dir='static/css', asset_dir='static/scss') scss_compiler.update_scss() for c in exceptions.default_exceptions.keys(): app.register_error_handler(c, api_error_handler) setup_logging(app) return app def load_config_file(app=None): app = app or get_app() try: app.config.from_object('config') except werkzeug_utils.ImportStringError: pass app.config.from_envvar('SCOREBOARD_CONFIG', silent=True) setup_logging(app) # reset logs def setup_logging(app): log_formatter = logger.Formatter( '%(asctime)s %(levelname)8s [%(filename)s:%(lineno)d] ' '%(client)s %(message)s') # log to files unless on AppEngine if not on_appengine(): # Main logger if not (app.debug or app.config.get('TESTING')): handler = logging.FileHandler( app.config.get('LOGFILE', '/tmp/scoreboard.wsgi.log')) handler.setLevel(logging.INFO) handler.setFormatter(log_formatter) app.logger.addHandler(handler) else: app.logger.handlers[0].setFormatter(log_formatter) # Challenge logger handler = logging.FileHandler( app.config.get('CHALLENGELOG', '/tmp/scoreboard.challenge.log')) handler.setLevel(logging.INFO) handler.setFormatter(logger.Formatter( '%(asctime)s %(client)s %(message)s')) local_logger = logging.getLogger('scoreboard') local_logger.addHandler(handler) app.challenge_log = local_logger else: app.challenge_log = app.logger try: import google.cloud.logging from google.cloud.logging import handlers client = google.cloud.logging.Client() client.setup_logging() handler = handlers.CloudLoggingHandler(client) app.logger.addHandler(handler) handler.setLevel(logging.INFO) return app except ImportError as ex: logging.error('Failed setting up logging: %s', ex) if not app.logger.handlers: app.logger.addHandler(flask_logging.default_handler) app.logger.handlers[0].setFormatter(log_formatter) logging.getLogger().handlers[0].setFormatter(log_formatter) return app def api_error_handler(ex): """Handle errors as appropriate depending on path.""" error_titles = { 401: 'Unauthorized', 403: 'Forbidden', 500: 'Internal Error', } try: status_code = ex.code except AttributeError: status_code = 500 if flask.request.path.startswith('/api/'): app = get_app() app.logger.error(str(ex)) if app.config.get('DEBUG', False): resp = flask.jsonify(message=str(ex)) else: resp = flask.jsonify(message='Internal Server Error') resp.status_code = status_code return resp return flask.make_response( flask.render_template( 'error.html', exc=ex, title=error_titles.get(status_code, 'Error')), status_code) def get_app(): global _app_singleton if _app_singleton is None: _app_singleton = create_app() return _app_singleton ================================================ FILE: scoreboard/models.py ================================================ # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import datetime import flask import flask_sqlalchemy import hashlib import hmac import json import logging import math import os import pbkdf2 import re import sqlalchemy as sqlalchemy_base import time from sqlalchemy import exc from sqlalchemy import func from sqlalchemy import orm from sqlalchemy.ext import hybrid from scoreboard import attachments from scoreboard import errors from scoreboard import main from scoreboard import utils app = main.get_app() db = flask_sqlalchemy.SQLAlchemy(app) class Team(db.Model): """A Team of Players (Team of 1 if not using Teams).""" tid = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), unique=True, nullable=False, index=True) score = db.Column(db.Integer, default=0) # Denormalized last_solve = db.Column(db.DateTime, nullable=True) players = db.relationship( 'User', backref=db.backref('team', lazy='joined'), lazy='dynamic') answers = db.relationship('Answer', backref='team', lazy='select', cascade='delete') score_history = db.relationship( 'ScoreHistory', backref=db.backref('team', lazy='joined'), lazy='select', cascade='delete') def __repr__(self): return '' % self.name.encode('utf-8') def __str__(self): return self.name @property def code(self): secret_key = (app.config.get('TEAM_SECRET_KEY') or app.config.get('SECRET_KEY')) return hmac.new(utils.to_bytes(secret_key), self.name.encode('utf-8'), hashlib.sha256).hexdigest()[:12] @property def solves(self): return len(self.answers) def update_score(self): old_score = self.score self.score = sum(a.current_points for a in self.answers) if self.score != old_score: # Add score history entry if not getattr(self, '_pending_sh', False): ScoreHistory.add_entry(self) self._pending_sh = True def can_access(self, user=None): """Check if player can access team.""" user = user or User.current() if user.admin: return True return user.team == self @classmethod def create(cls, name): team = cls() db.session.add(team) team.name = name return team @classmethod def get_by_name(cls, name): try: return cls.query.filter_by(name=name).one() except exc.InvalidRequestError: return None @classmethod def enumerate(cls, with_history=False, above_zero=False): if with_history: base = cls.query.options(orm.joinedload(cls.score_history)) else: base = cls.query if above_zero: base = base.filter(cls.score > 0) sorting = base.order_by(cls.score.desc(), cls.last_solve) return enumerate(sorting.all(), 1) @classmethod def all(cls, with_history=True): if with_history: base = cls.query.options(orm.joinedload(cls.score_history)) else: base = cls.query base = base.options(orm.joinedload(cls.answers)) base = base.order_by(cls.name) return base.all() @classmethod def current(cls): try: return flask.g.team except AttributeError: user = User.current() if user: flask.g.team = user.team return user.team else: flask.g.team = None class ScoreHistory(db.Model): team_tid = db.Column(db.Integer, db.ForeignKey('team.tid'), nullable=False, primary_key=True) when = db.Column(db.DateTime, nullable=False, primary_key=True, default=datetime.datetime.utcnow) score = db.Column(db.Integer, default=0, nullable=False) @classmethod def add_entry(cls, team): entry = cls() entry.team = team entry.score = team.score db.session.merge(entry) class User(db.Model): """A single User for login. Player or admin.""" uid = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(120), unique=True, nullable=False, index=True) nick = db.Column(db.String(80), unique=True, nullable=False, index=True) pwhash = db.Column(db.String(48)) # pbkdf2.crypt == 48 bytes admin = db.Column(db.Boolean, default=False, index=True) team_tid = db.Column(db.Integer, db.ForeignKey('team.tid')) create_ip = db.Column(db.String(45)) # max 45 bytes for IPv6 last_login_ip = db.Column(db.String(45)) api_key = db.Column(db.String(32), index=True) api_key_updated = db.Column(db.DateTime) def set_password(self, password): self.pwhash = pbkdf2.crypt(password) def __repr__(self): return '>' % (self.nick.encode('utf-8'), self.email) def __str__(self): return self.nick def promote(self): """Promote a user to admin.""" empty_team = self.team and set(self.team.players.all()) == set([self]) if self.team and len(self.team.answers): raise AssertionError( 'Cannot promote player whose team has solved answers!') self.admin = True team = self.team self.team = None if empty_team: db.session.delete(team) def get_token(self, token_type='pwreset', expires=None): """Generate a user-specific token.""" expires = expires or int(time.time()) + 7200 # 2 hours token_plain = '%d:%d:%s:%s' % ( self.uid, expires, token_type, self.pwhash) mac = hmac.new( utils.to_bytes(app.config.get('SECRET_KEY')), utils.to_bytes(token_plain), hashlib.sha1).digest() token = utils.to_bytes('%d:' % expires) + mac return base64.urlsafe_b64encode(token) def verify_token(self, token, token_type='pwreset'): """Verify a user-specific token.""" token = utils.to_bytes(token) try: decoded = base64.urlsafe_b64decode(token) expires, mac = decoded.split(b':', 1) except ValueError: raise errors.ValidationError('Invalid token.') if float(expires) < time.time(): raise errors.ValidationError('Expired token.') expected = self.get_token(token_type=token_type, expires=int(expires)) if not utils.compare_digest(expected, token): raise errors.ValidationError('Invalid token.') return True def reset_api_key(self): """Reset a user's api key.""" new_key = os.urandom(16) try: self.api_key = new_key.hex() # Python 3 except AttributeError: self.api_key = new_key.encode('hex') # Python 2 self.api_key_update = datetime.datetime.now() @classmethod def get_by_email(cls, email): try: return cls.query.filter_by(email=email).one() except exc.InvalidRequestError: return None @classmethod def get_by_nick(cls, nick): try: return cls.query.filter_by(nick=nick).one() except exc.InvalidRequestError: return None @classmethod def get_by_api_key(cls, token): if not token: return None try: return cls.query.filter_by(api_key=token).one() except exc.InvalidRequestError: return None @classmethod def login_user(cls, email, password): try: user = cls.query.filter_by(email=email).one() except exc.InvalidRequestError: return None if pbkdf2.crypt(password, user.pwhash) == user.pwhash: if flask.has_request_context(): user.last_login_ip = flask.request.remote_addr db.session.commit() return user return None @classmethod def create(cls, email, nick, password, team=None): first_user = True if not cls.query.count() else False user = cls() db.session.add(user) user.email = email user.nick = nick user.set_password(password) if not first_user: user.team = team else: user.promote() if flask.has_request_context(): user.create_ip = flask.request.remote_addr return user @classmethod def current(cls): try: return flask.g.user except AttributeError: uid = flask.session.get('user') if uid is not None: # For some reason, .get() does not join! user = cls.query.options(orm.joinedload(cls.team)).filter( cls.uid == uid).first() flask.g.user = user flask.g.team = user.team if user: # Bump expiration time on session utils.session_for_user(user) return user @classmethod def all(cls): return cls.query.order_by( cls.admin.desc(), cls.nick).all() tag_challenge_association = db.Table( 'tag_chall_association', db.Model.metadata, db.Column('challenge_cid', db.BigInteger, db.ForeignKey('challenge.cid')), db.Column('tag_tagslug', db.String(100), db.ForeignKey('tag.tagslug'))) class Tag(db.Model): """A Tag to be Applied to Challenges""" tagslug = db.Column(db.String(100), unique=True, primary_key=True, nullable=False, index=True) name = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text) challenges = db.relationship('Challenge', backref=db.backref('tags', lazy='joined'), secondary='tag_chall_association', lazy='joined') def __repr__(self): return '' % (self.tagslug, self.name) def slugify(self): self.tagslug = '-'.join(w.lower() for w in re.split(r'\W+', self.name)) @classmethod def create(cls, name, description): tag = cls() tag.name = name tag.description = description tag.slugify() db.session.add(tag) return tag def get_challenges(self, unlocked_only=True, sort=True, force_query=False): if (force_query or 'challenges' in sqlalchemy_base.inspect(self).unloaded): return self._get_challenges_query( unlocked_only=unlocked_only, sort=sort) return self._get_challenges_cached( unlocked_only=unlocked_only, sort=sort) def _get_challenges_cached(self, unlocked_only=True, sort=True): challenges = self.challenges if unlocked_only: challenges = [c for c in challenges if c.unlocked] if sort: challenges = sorted(challenges, key=lambda c: c.weight) return challenges def _get_challenges_query(self, unlocked_only=True, sort=True): challenges = Challenge.query.filter( Challenge.tags.any(tagslug=self.tagslug)) if unlocked_only: unlocked_identity = True challenges = challenges.filter( Challenge.unlocked == unlocked_identity) if not sort: return challenges return challenges.order_by(Challenge.weight) class Challenge(db.Model): """A single challenge to be played.""" cid = db.Column(db.BigInteger, primary_key=True, autoincrement=False) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text, nullable=False) points = db.Column(db.Integer, nullable=False) min_points = db.Column(db.Integer, nullable=True) validator = db.Column(db.String(24), nullable=False, default='static_pbkdf2') answer_hash = db.Column(db.String(48)) # Protect answers unlocked = db.Column(db.Boolean, default=False) weight = db.Column(db.Integer, nullable=False) # Order for display prerequisite = db.Column(db.Text, nullable=False) # Prerequisite Metadata cur_points = db.Column(db.Integer, nullable=True) answers = db.relationship('Answer', backref=db.backref('challenge', lazy='joined'), lazy='select') def __repr__(self): return '' % (self.cid, self.name) def is_answered(self, team=None, answers=None): if team is None: team = Team.current() if not team: return False if answers is not None: for a in answers: if a.team_tid == team.tid and a.challenge_cid == self.cid: return True return False return bool(Answer.query.filter(Answer.challenge == self, Answer.team == team).count()) @hybrid.hybrid_property def solves(self): try: return self._solves except AttributeError: self._solves = len(self.answers) return self._solves @solves.expression def solves(cls): return func.count(cls.answers) @property def answered(self): if not Team.current(): return False return self.is_answered(answers=Team.current().answers) @property def teaser(self): if not app.config.get('TEASE_HIDDEN'): return False if not Team.current(): return False return not self.unlocked_for_team(Team.current()) @property def current_points(self): mode = app.config.get('SCORING', 'plain') value = self.points if mode == 'plain': self.cur_points = value elif mode == 'progressive': speed = app.config.get('SCORING_SPEED', 12) min_points = 0 if self.min_points is None else self.min_points self.cur_points = self.log_score( value, min_points, speed, self.solves) return self.cur_points @staticmethod def log_score(max_points, min_points, midpoint, solves): # Algorithm designed by symmetric # logit(u, l, m, s, x) = # (u - l) * ((1.0 / (1.0 + exp((1.0/s) * (x - m)))) / # (1.0 / (1.0 + exp((1.0/s) * (1 - m))))) + l if solves == 0: return max_points def log_func(midpoint, solves): spread = midpoint / 3.0 delta = solves - midpoint return ( 1.0 / (1.0 + math.exp((1.0 / spread) * delta))) max_delta = (max_points - min_points) base_point = log_func(midpoint, 1.0) cur_point = log_func(midpoint, solves) return math.ceil(max_delta * cur_point / base_point + min_points) def unlocked_for_team(self, team): """Checks if prerequisites are met for this team.""" if not self.unlocked: return False if not self.prerequisite: return True try: prereq = json.loads(self.prerequisite) except ValueError: logging.error('Unable to parse prerequisite data for challenge %d', self.cid) return False if prereq['type'] == 'None': return True if not team: return False try: eval_func = getattr(self, 'prereq_' + prereq['type']) except AttributeError: logging.error( 'Could not find prerequisite function for challenge %d', self.cid) return False return eval_func(prereq, team) def prereq_solved(self, prereq, team): """Require that another challenge be solved first.""" chall = Challenge.query.get(int(prereq['challenge'])) if not chall: logging.error('Challenge %d prerequisite depends on ' 'non-existent challenge %d.', self.cid, int(prereq['challenge'])) return False return chall.is_answered(team=team, answers=team.answers) @classmethod def create(cls, name, description, points, answer, unlocked=False, validator='static_pbkdf2'): challenge = cls() challenge.name = name challenge.description = description challenge.cid = utils.generate_id() challenge.points = points challenge.answer_hash = answer challenge.unlocked = unlocked challenge.validator = validator weight = db.session.query(db.func.max(Challenge.weight)).scalar() challenge.weight = (weight + 1) if weight else 1 challenge.prerequisite = '' db.session.add(challenge) return challenge def add_tags(self, tags): for tag in tags: self.tags.append(tag) def delete(self): db.session.delete(self) def set_attachments(self, attachments): aid_set = set() old_attachments = list(self.attachments) for a in attachments: aid_set.add(a['aid']) attachment = Attachment.query.get(a['aid']) if not attachment: logging.warning( 'Trying to add attachment %s that does not exist: %s' % (a['filename'], a['aid'])) self.attachments.append(attachment) for a in old_attachments: if a.aid not in aid_set: self.attachments.remove(a) def set_prerequisite(self, prerequisite): if not prerequisite: self.prerequisite = '' return if 'type' in prerequisite and prerequisite['type'] == 'None': self.prerequisite = '' else: self.prerequisite = json.dumps(prerequisite) def set_tags(self, tags): tag_set = set() old_tags = list(self.tags) for t in tags: tag_set.add(t['tagslug']) tag = Tag.query.get(t['tagslug']) if tag: self.tags.append(tag) else: app.logger.warning('Skipping tag %s which does not exist' % t['tagslug']) for t in old_tags: if t.tagslug not in tag_set: self.tags.remove(t) def update_answers(self, exclude_team=None): """Update answers for variable scoring.""" mode = app.config.get('SCORING') if mode == 'plain': return if mode == 'progressive': for a in self.answers: if a.team == exclude_team: continue a.team.update_score() @classmethod def get_joined_query(cls): """Get a prejoined-query with answers and teams.""" return cls.query.options( orm.joinedload(cls.answers).joinedload(Answer.team)) attach_challenge_association = db.Table( 'attach_chall_association', db.Model.metadata, db.Column( 'challenge_cid', db.BigInteger, db.ForeignKey('challenge.cid')), db.Column( 'attachment_aid', db.String(64), db.ForeignKey('attachment.aid'))) class Attachment(db.Model): """Attachment to a challenge.""" aid = db.Column(db.String(64), primary_key=True) filename = db.Column(db.String(100), nullable=False) content_type = db.Column(db.String(100)) storage_path = db.Column(db.String(256)) challenges = db.relationship( 'Challenge', backref=db.backref('attachments', lazy='joined'), secondary='attach_chall_association', lazy='joined') def __str__(self): return repr(self) def __repr__(self): return '' % self.aid def delete(self, from_disk=True): if from_disk: try: attachments.backend.delete(self) except IOError as ex: app.logger.exception("Couldn't delete: %s", str(ex)) db.session.delete(self) def set_challenges(self, challenges): cid_set = set() old_challenges = list(self.challenges) for a in challenges: cid_set.add(a['cid']) challenge = Challenge.query.get(a['cid']) if not challenge: app.logger.warning('No challenge found with cid %d' % a['cid']) continue self.challenges.append(challenge) for a in old_challenges: if a.cid not in cid_set: self.challenges.remove(a) @classmethod def create(cls, aid, filename, content_type): attachment = cls() attachment.aid = aid attachment.filename = filename attachment.content_type = content_type db.session.add(attachment) return attachment class Answer(db.Model): """Log a successfully submitted answer.""" challenge_cid = db.Column( db.BigInteger, db.ForeignKey('challenge.cid'), primary_key=True) team_tid = db.Column( db.Integer, db.ForeignKey('team.tid'), primary_key=True) timestamp = db.Column(db.DateTime) answer_hash = db.Column(db.String(48)) # Store hash of team+answer submit_ip = db.Column(db.String(45)) # Source IP for submission first_blood = db.Column(db.Integer, default=0, nullable=False) @classmethod def create(cls, challenge, team, answer_text): answer = cls() answer.first_blood = 0 if not challenge.solves: if app.config.get('FIRST_BLOOD_MIN', 0) <= challenge.points: answer.first_blood = app.config.get('FIRST_BLOOD', 0) answer.challenge = challenge answer.team = team answer.timestamp = datetime.datetime.utcnow() if answer_text: answer.answer_hash = pbkdf2.crypt(team.name + answer_text) if flask.request: answer.submit_ip = flask.request.remote_addr db.session.add(answer) # remove cache here del challenge._solves return answer @property def current_points(self): if utils.GameTime.state(self.timestamp) == "AFTER": return 0 return self.challenge.current_points + self.first_blood class News(db.Model): """News updates & broadcasts.""" NEWS_TYPES = [ 'Broadcast', # Admin broadcast 'Unicast', # Team-specific update ] nid = db.Column(db.Integer, primary_key=True) news_type = db.Column(db.Enum(*NEWS_TYPES), nullable=False) timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow) author = db.Column(db.String(100)) message = db.Column(db.Text) audience_team_tid = db.Column(db.Integer, db.ForeignKey('team.tid')) audience_team = db.relationship('Team') @classmethod def broadcast(cls, author, message): news = cls( news_type='Broadcast', author=author, message=message) db.session.add(news) return news @classmethod def game_broadcast(cls, author=None, message=None): if message is None: raise ValueError('Missing message.') author = author or app.config.get('SYSTEM_NAME') if not utils.GameTime.open(): return return cls.broadcast(author, message) @classmethod def unicast(cls, team, author, message): news = cls( news_type='Unicast', author=author, message=message) if isinstance(team, Team): news.audience_team = team elif isinstance(team, int): news.audience_team_tid = team else: raise ValueError('Invalid value for team.') db.session.add(news) return news @classmethod def for_team(cls, team, limit=10): return cls.query.filter( ((cls.news_type != 'Unicast') | (cls.audience_team == team)) ).order_by(cls.timestamp.desc()).limit(limit) @classmethod def for_public(cls, limit=10): return cls.query.filter( cls.news_type != 'Unicast' ).order_by(cls.timestamp.desc()).limit(limit) class Page(db.Model): """Represent static pages to be rendered with Markdown.""" path = db.Column(db.String(100), primary_key=True) title = db.Column(db.String(100), nullable=False) contents = db.Column(db.Text, nullable=False) class NonceFlagUsed(db.Model): """Single-time used flags.""" challenge_cid = db.Column(db.BigInteger, db.ForeignKey('challenge.cid'), primary_key=True) nonce = db.Column(db.BigInteger, primary_key=True) team_tid = db.Column(db.Integer, db.ForeignKey('team.tid')) @classmethod def create(cls, challenge, nonce, team): entity = cls() entity.challenge_cid = challenge.cid entity.nonce = nonce entity.team_tid = team.tid db.session.add(entity) # Shortcut for commiting def commit(): db.session.commit() ================================================ FILE: scoreboard/rest.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import datetime import flask import flask_restful from flask_restful import fields import json import pytz from scoreboard import attachments from scoreboard import auth from scoreboard import cache from scoreboard import controllers from scoreboard import context from scoreboard import csrfutil from scoreboard import errors from scoreboard import main from scoreboard import models from scoreboard import utils from scoreboard import validators app = main.get_app() api = flask_restful.Api(app) context.ensure_setup() # Custom fields class ISO8601DateTime(fields.Raw): """Show datetimes as ISO8601.""" def format(self, value): if value is None: return None if isinstance(value, (int, float)): value = datetime.fromtimestamp(value) if isinstance(value, (datetime.datetime, datetime.date)): if getattr(value, 'tzinfo', True) is None: value = value.replace(tzinfo=pytz.UTC) return value.isoformat() raise ValueError('Unable to convert %s to ISO8601.' % str(type(value))) class PrerequisiteField(fields.Raw): """Prerequisite data.""" def format(self, value): try: data = json.loads(value) except ValueError: return {'type': 'None'} return data # Utility functions @api.representation('application/json') def output_json(data, code, headers=None): """Custom JSON output with JSONP buster.""" settings = {} if app.debug: settings['indent'] = 4 settings['sort_keys'] = True dumped = json.dumps(data, **settings) if not (headers and headers.pop('X-No-XSSI', None)): dumped = ")]}',\n" + dumped + "\n" resp = flask.make_response(dumped, code) resp.headers.extend(headers or {}) return resp def get_field(name, *args): data = flask.request.get_json() try: return data[name] except KeyError: if args: return args[0] raise errors.ValidationError( 'Required field {} not given.'.format(name)) class User(flask_restful.Resource): """Wrap User model.""" decorators = [utils.login_required] resource_fields = { 'uid': fields.Integer, 'email': fields.String, 'nick': fields.String, 'admin': fields.Boolean, 'team_tid': fields.Integer, } @flask_restful.marshal_with(resource_fields) def get(self, user_id): if not flask.g.uid == user_id and not flask.g.admin: raise errors.AccessDeniedError('No access to that user.') return models.User.query.get_or_404(user_id) @flask_restful.marshal_with(resource_fields) def put(self, user_id): if not flask.g.uid == user_id and not flask.g.admin: raise errors.AccessDeniedError('No access to that user.') user = models.User.query.get_or_404(user_id) data = flask.request.get_json() if utils.is_admin() and 'admin' in data: if data['admin'] and not user.admin: try: user.promote() except AssertionError: raise errors.ValidationError( 'Error promoting. Has player solved challenges?') else: user.admin = False if data.get('password'): user.set_password(data['password']) if utils.is_admin(): user.nick = data['nick'] if not app.config.get('TEAMS') and user.team: user.team.name = data['nick'] try: models.commit() except AssertionError: raise errors.ValidationError( 'Error in updating user. Details are logged.') return user class UserList(flask_restful.Resource): """Registration and listing of users.""" resource_fields = { 'users': fields.Nested(User.resource_fields), } @utils.admin_required @flask_restful.marshal_with(resource_fields) def get(self): return dict(users=models.User.all()) @flask_restful.marshal_with(User.resource_fields) def post(self): """Register a new user.""" if utils.is_logged_in(): raise errors.ValidationError('Cannot register while logged in.') data = flask.request.get_json() if not data.get('nick', ''): raise errors.ValidationError('Need a player nick.') if (app.config.get('TEAMS') and not data.get('team_name', '') and not data.get('team_id', 0)): app.logger.warning('User attempted to register without team.') raise errors.ValidationError('Need a team name.') if (app.config.get('INVITE_KEY') and data.get('invite_key', '').strip() != app.config.get('INVITE_KEY')): app.logger.warning( 'Attempted invite-only registration with invalid ' 'invite key: %s', data.get('invite_key', '')) raise errors.ValidationError('Invalid invite key!') app.logger.debug('Passed registration validation for new user.') user = auth.register(flask.request) utils.session_for_user(user) return user class Team(flask_restful.Resource): """Manage single team.""" decorators = [utils.login_required] history_fields = { 'when': ISO8601DateTime(), 'score': fields.Integer, } team_fields = { 'tid': fields.Integer, 'name': fields.String, 'score': fields.Integer, 'solves': fields.Integer, } solved_challenges = { 'cid': fields.Integer, 'name': fields.String, 'solved': ISO8601DateTime(), 'points': fields.Integer, } resource_fields = team_fields.copy() resource_fields['players'] = fields.Nested(User.resource_fields) resource_fields['score_history'] = fields.Nested(history_fields) resource_fields['solved_challenges'] = fields.Nested(solved_challenges) @flask_restful.marshal_with(resource_fields) def get(self, team_id): # TODO: this takes too many queries, fix to 1 team = models.Team.query.get_or_404(team_id) return self._marshal_team(team, extended=True) def _marshal_team(self, team, extended=False): result = {} for k in self.team_fields: result[k] = getattr(team, k) if extended: challenges = [] for answer in team.answers: challenges.append({ 'solved': answer.timestamp, 'points': answer.current_points, 'name': answer.challenge.name, 'cid': answer.challenge_cid, }) result['solved_challenges'] = challenges result['score_history'] = team.score_history else: result['solved_challenges'] = [] result['score_history'] = [] if team.can_access(): result['players'] = list(team.players.all()) else: result['players'] = [] return result @utils.admin_required @flask_restful.marshal_with(resource_fields) def put(self, team_id): team = models.Team.query.get_or_404(team_id) app.logger.info('Update of team %r by %r.', team, models.User.current()) data = flask.request.get_json() # Writable fields for field in ('name', 'score'): setattr(team, field, data.get(field, getattr(team, field))) models.commit() cache.delete_team('team/%d') return self._marshal_team(team) class TeamList(flask_restful.Resource): """Get a list of all teams.""" resource_fields = { 'teams': fields.Nested(Team.team_fields), } @flask_restful.marshal_with(resource_fields) def get(self): return dict(teams=models.Team.all(with_history=False)) class TeamChange(flask_restful.Resource): """Endpoint for changing teams.""" resource_fields = { 'uid': fields.Integer, 'team_tid': fields.Integer, 'code': fields.String, } @utils.login_required @flask_restful.marshal_with(resource_fields) def put(self): current = models.User.current() if not (current.admin or current.uid == get_field('uid')): raise errors.AccessDeniedError('Cannot Modify this User') controllers.change_user_team( get_field('uid'), get_field('team_tid'), get_field('code')) class Session(flask_restful.Resource): """Represents a logged-in session, used for login/logout.""" team_fields = { 'tid': fields.Integer, 'name': fields.String, 'score': fields.Integer, 'code': fields.String, } resource_fields = { 'user': fields.Nested(User.resource_fields), 'team': fields.Nested(team_fields), 'redirect': fields.String, } @utils.login_required @flask_restful.marshal_with(resource_fields) def get(self): """Get the current session.""" return dict( user=models.User.current(), team=models.Team.current()) @flask_restful.marshal_with(resource_fields) def post(self): """Login a user.""" user = auth.login_user(flask.request) if not user: redir = auth.get_login_uri() if redir: return dict(redirect=redir) return {} app.logger.info('%r logged in.', user) utils.session_for_user(user) return dict(user=user, team=user.team) def delete(self): auth.logout() if flask.session.get('user', None): app.logger.info('%r logging out.', models.User.current()) flask.session.clear() try: del flask.g.user except: # noqa: E722 pass try: del flask.g.team except: # noqa: E722 pass return {'message': 'OK'} class PasswordReset(flask_restful.Resource): """Setup for password reset.""" def get(self, email): """Send a password reset email.""" user = models.User.get_by_email(email) if not user: flask.abort(404) controllers.offer_password_reset(user) app.logger.info('Request password reset for %r.', user) return {'message': 'Reset email sent.'} def post(self, email): """Verify reset and set new password.""" # TODO: Move to controller data = flask.request.get_json() user = models.User.get_by_email(email) if not user: flask.abort(404) token = data.get('token', '') try: user.verify_token(token) except errors.ValidationError as ex: app.logger.warning('Error validating password reset: %s', str(ex)) raise except Exception as ex: app.logger.exception( 'Unhandled exception during password reset: %s', str(ex)) raise if data['password'] != data['password2']: raise errors.ValidationError("Passwords don't match.") user.set_password(data['password']) app.logger.info('Password reset for %r.', user) models.commit() utils.session_for_user(user) return {'message': 'Password reset.'} api.add_resource(UserList, '/api/users') api.add_resource(User, '/api/users/') api.add_resource(TeamChange, '/api/teams/change') api.add_resource(TeamList, '/api/teams') api.add_resource(Team, '/api/teams/') api.add_resource(Session, '/api/session') if app.config.get('LOGIN_METHOD') == 'local': api.add_resource(PasswordReset, '/api/pwreset/') class Challenge(flask_restful.Resource): """A single challenge.""" decorators = [utils.admin_required] challenge_fields = { 'cid': fields.Integer, 'name': fields.String, 'points': fields.Integer, 'min_points': fields.Integer, 'current_points': fields.Integer, 'description': fields.String, 'unlocked': fields.Boolean, 'answered': fields.Boolean, 'solves': fields.Integer, 'weight': fields.Integer, 'prerequisite': PrerequisiteField, 'teaser': fields.Boolean, 'validator': fields.String, } attachment_fields = { 'aid': fields.String, 'filename': fields.String, } tags_fields = { 'tagslug': fields.String, 'name': fields.String, } team_fields = { 'name': fields.String, 'tid': fields.Integer, } answers_fields = { 'timestamp': fields.DateTime, 'team': fields.Nested(team_fields), } resource_fields = challenge_fields.copy() resource_fields['attachments'] = fields.List( fields.Nested(attachment_fields)) resource_fields['tags'] = fields.List( fields.Nested(tags_fields)) resource_fields['answers'] = fields.List( fields.Nested(answers_fields)) @flask_restful.marshal_with(resource_fields) def get(self, challenge_id): return models.Challenge.query.get_or_404(challenge_id) @flask_restful.marshal_with(resource_fields) def put(self, challenge_id): challenge = models.Challenge.query.get_or_404(challenge_id) data = flask.request.get_json() old_unlocked = challenge.unlocked for field in ( 'name', 'description', 'points', 'min_points', 'unlocked', 'weight'): setattr( challenge, field, data.get(field, getattr(challenge, field))) if 'validator' in data: if not validators.IsValidator(data['validator']): raise errors.ValidationError('Invalid validator.') challenge.validator = data['validator'] if 'answer' in data and data['answer']: answer = utils.normalize_input(data['answer']) validator = validators.GetValidatorForChallenge(challenge) validator.change_answer(answer) if 'attachments' in data: challenge.set_attachments(data['attachments']) if 'prerequisite' in data: challenge.set_prerequisite(data['prerequisite']) else: challenge.prerequisite = '' if 'tags' in data: challenge.set_tags(data['tags']) if challenge.unlocked and not old_unlocked: news = 'Challenge "%s" unlocked!' % challenge.name models.News.game_broadcast(message=news) app.logger.info('Challenge %s updated by %r.', challenge, models.User.current()) models.commit() cache.clear() return challenge def delete(self, challenge_id): challenge = models.Challenge.query.get_or_404(challenge_id) models.db.session.delete(challenge) models.commit() cache.clear() class ChallengeList(flask_restful.Resource): """Bulk challenge management, includes: - Create & manage challenges for admins. - View challenge list for players. """ decorators = [utils.require_started] resource_fields = { 'challenges': fields.Nested(Challenge.resource_fields) } @staticmethod def _tease_challenge(chall): """Hide parts to be teased.""" res = {k: getattr(chall, k) for k in Challenge.resource_fields} for f in ('description', 'attachments'): del res[f] return res @flask_restful.marshal_with(resource_fields) def get(self): q = models.Challenge.get_joined_query() challs = [] t = models.Team.current() for chall in q.all(): if utils.is_admin() or chall.unlocked_for_team(t): challs.append(chall) elif chall.teaser: challs.append(self._tease_challenge(chall)) return {'challenges': challs} @utils.admin_required @flask_restful.marshal_with(Challenge.resource_fields) def post(self): data = flask.request.get_json() unlocked = data.get('unlocked', False) answer = utils.normalize_input(data['answer']) if not validators.IsValidator(data.get('validator', None)): raise errors.ValidationError('Invalid validator.') chall = models.Challenge.create( data['name'], data['description'], data['points'], '', unlocked, data.get('validator', validators.GetDefaultValidator())) validator = validators.GetValidatorForChallenge(chall) validator.change_answer(answer) if 'min_points' in data: chall.min_points = data['min_points'] else: chall.min_points = chall.points if 'attachments' in data: chall.set_attachments(data['attachments']) if 'prerequisite' in data: chall.set_prerequisite(data['prerequisite']) if 'tags' in data: chall.set_tags(data['tags']) if unlocked and utils.GameTime.open(): news = 'New challenge created: "%s"' % chall.name models.News.game_broadcast(message=news) models.commit() app.logger.info('Challenge %s created by %r.', chall, models.User.current()) return chall class Tag(flask_restful.Resource): """Single tag for challenges.""" decorators = [utils.login_required, utils.require_started] tag_fields = { 'name': fields.String, 'tagslug': fields.String, 'description': fields.String } resource_fields = tag_fields.copy() resource_fields['challenges'] = fields.Nested(Challenge.resource_fields) @flask_restful.marshal_with(resource_fields) def get(self, tag_slug): tag = models.Tag.query.get_or_404(tag_slug) return self.get_challenges(tag) @utils.admin_required @flask_restful.marshal_with(resource_fields) def put(self, tag_slug): tag = models.Tag.query.get_or_404(tag_slug) tag.name = get_field('name') tag.description = get_field('description', tag.description) app.logger.info('Tag %s updated by %r', tag, models.User.current()) models.commit() cache.clear() return self.get_challenges(tag) @utils.admin_required def delete(self, tag_slug): tag = models.Tag.query.get_or_404(tag_slug) models.db.session.delete(tag) cache.clear() models.commit() @classmethod def get_challenges(cls, tag): if models.User.current() and models.User.current().admin: challenges = tag.challenges else: raw = tag.get_challenges() challenges = [] for ch in raw: if ch.unlocked_for_team(models.Team.current()): challenges.append(ch) elif ch.teaser: challenges.append(ChallengeList._tease_challenge(ch)) res = {k: getattr(tag, k) for k in cls.tag_fields} res['challenges'] = list(challenges) return res class TagList(flask_restful.Resource): """List of all tags""" decorators = [utils.require_started] resource_fields = { 'tags': fields.Nested(Tag.tag_fields) } @cache.rest_team_cache('tags/%d') @flask_restful.marshal_with(resource_fields) def get(self): q = models.Tag.query.all() return dict(tags=q) @utils.admin_required @flask_restful.marshal_with(Tag.tag_fields) def post(self): tag = models.Tag.create( get_field('name'), get_field('description', '')) models.commit() app.logger.info('Tag %s created by %r.', tag, models.User.current()) cache.clear() return tag class Answer(flask_restful.Resource): """Submit an answer.""" decorators = [utils.login_required, utils.require_submittable] # TODO: get answers for admin? def post(self): data = flask.request.get_json() if utils.is_admin(): return self.post_admin(data) return self.post_player(data) @utils.admin_required def post_admin(self, data): cid = data.get('cid', None) tid = data.get('tid', None) if not cid or not tid: raise errors.ValidationError('Requires team and challenge.') challenge = models.Challenge.query.get(data['cid']) team = models.Team.query.get(data['tid']) if not challenge or not team: raise errors.ValidationError('Requires team and challenge.') user = models.User.current() app.challenge_log.info( 'Admin %s <%s> submitting flag for challenge %s <%d>, ' 'team %s <%d>', user.nick, user.email, challenge.name, challenge.cid, team.name, team.tid) try: points = controllers.save_team_answer(challenge, team, None) except (errors.IntegrityError, errors.FlushError) as ex: app.logger.exception( 'Unable to save answer for %s/%s: %s', str(data['tid']), str(data['tid']), str(ex)) models.db.session.rollback() raise errors.AccessDeniedError( 'Unable to save answer for team. See log for details.') cache.delete('cats/%d' % tid) cache.delete('scoreboard') return dict(points=points) def post_player(self, data): answer = utils.normalize_input(data['answer']) try: points = controllers.submit_answer( data['cid'], answer, data.get('token')) except (errors.IntegrityError, errors.FlushError) as exc: app.logger.exception('Exception when saving answer: %s', exc) models.db.session.rollback() raise errors.AccessDeniedError( 'Previously solved or flag already used.') cache.delete_team('cats/%d') cache.delete('scoreboard') return dict(points=points) class Validator(flask_restful.Resource): """Allow admins to test an answer.""" decorators = [utils.admin_required] def post(self): data = flask.request.get_json() answer = utils.normalize_input(data['answer']) try: correct = controllers.test_answer(data['cid'], answer) except errors.IntegrityError: raise errors.InvalidAnswerError('Invalid answer.') if not correct: raise errors.InvalidAnswerError('Invalid answer.') return dict(message='Answer OK.') api.add_resource(Tag, '/api/tags/') api.add_resource(TagList, '/api/tags') api.add_resource(ChallengeList, '/api/challenges') api.add_resource(Challenge, '/api/challenges/') api.add_resource(Answer, '/api/answers') api.add_resource(Validator, '/api/validator') class APIScoreboard(flask_restful.Resource): """Retrieve the scoreboard.""" line_fields = { 'position': fields.Integer, 'tid': fields.Integer, 'name': fields.String, 'score': fields.Integer, 'history': fields.Nested(Team.history_fields), } resource_fields = { 'scoreboard': fields.Nested(line_fields), } @cache.rest_cache('scoreboard') @flask_restful.marshal_with(resource_fields) def get(self): opts = { 'with_history': True, 'above_zero': not app.config.get('SCOREBOARD_ZEROS'), } return dict(scoreboard=[ {'position': i, 'name': v.name, 'tid': v.tid, 'score': v.score, 'history': v.score_history} for i, v in models.Team.enumerate(**opts)]) api.add_resource(APIScoreboard, '/api/scoreboard') class Config(flask_restful.Resource): """Get basic app.config for the scoreboard. This should not change often as it is highly-cached on the client. """ def get(self): datefmt = ISO8601DateTime() config = dict( teams=app.config.get('TEAMS'), sbname=app.config.get('TITLE'), news_mechanism='poll', news_poll_interval=app.config.get('NEWS_POLL_INTERVAL'), csrf_token=csrfutil.get_csrf_token(), rules=app.config.get('RULES'), game_start=datefmt.format(utils.GameTime.start), game_end=datefmt.format(utils.GameTime.end), login_url=auth.get_login_uri(), register_url=auth.get_register_uri(), login_method=app.config.get('LOGIN_METHOD'), scoring=app.config.get('SCORING'), validators=validators.ValidatorMeta(), proof_of_work_bits=int(app.config.get('PROOF_OF_WORK_BITS')), invite_only=app.config.get('INVITE_KEY') is not None, ) return config api.add_resource(Config, '/api/config') class News(flask_restful.Resource): """Display and manage news.""" resource_fields = { 'nid': fields.Integer, 'news_type': fields.String, 'timestamp': ISO8601DateTime, 'author': fields.String, 'message': fields.String, } @flask_restful.marshal_with(resource_fields) def get(self): if models.Team.current(): news = models.News.for_team(models.Team.current()) else: news = models.News.for_public() return list(news) @utils.admin_required @flask_restful.marshal_with(resource_fields) def post(self): data = flask.request.get_json() tid = None if 'tid' in data: try: tid = int(data['tid']) except ValueError: pass author = models.User.current().nick if tid: item = models.News.unicast(tid, author, data['message']) else: item = models.News.broadcast(author, data['message']) models.commit() return item api.add_resource(News, '/api/news') class Page(flask_restful.Resource): """Create and retrieve static pages.""" resource_fields = { 'path': fields.String, 'title': fields.String, 'contents': fields.String, } @cache.rest_cache_path @flask_restful.marshal_with(resource_fields) def get(self, path): app.logger.info('Path: %s', path) return models.Page.query.get_or_404(path) @utils.admin_required @flask_restful.marshal_with(resource_fields) def post(self, path): data = flask.request.get_json() page = models.Page.query.get(path) if not page: page = models.Page() page.path = path models.db.session.add(page) page.path = data.get('path', path) page.title = data.get('title', page.title) page.contents = data.get('contents', page.contents) models.commit() return page @utils.admin_required def delete(self, path): page = models.Page.query.get_or_404(path) models.db.session.delete(page) models.commit() return {} class PageList(flask_restful.Resource): """Retrieve all pages available""" resource_fields = { 'pages': fields.Nested({ 'path': fields.String, 'title': fields.String, }) } @flask_restful.marshal_with(resource_fields) def get(self): return dict(pages=models.Page.query.all()) api.add_resource(Page, '/api/page/') api.add_resource(PageList, '/api/page') class Attachment(flask_restful.Resource): """"Allow updating and deleting of individual files""" attachment_fields = { 'aid': fields.String, 'filename': fields.String, } challenge_fields = { 'name': fields.String, 'cid': fields.Integer, } resource_fields = attachment_fields.copy() resource_fields['challenges'] = fields.List( fields.Nested(challenge_fields)) decorators = [utils.admin_required] @flask_restful.marshal_with(resource_fields) def get(self, aid): return models.Attachment.query.get_or_404(aid) @flask_restful.marshal_with(resource_fields) def put(self, aid): attachment = models.Attachment.query.get_or_404(aid) attachment.filename = get_field('filename') attachment.set_challenges(get_field('challenges')) app.logger.info('Attachment %s updated by %r.', attachment, models.User.current()) models.commit() cache.clear() return attachment def delete(self, aid): attachment = models.Attachment.query.get_or_404(aid) # Probably do not need to delete from disk attachment.delete() app.logger.info('Attachment %s deleted by %r.', attachment, models.User.current()) models.commit() cache.clear() class AttachmentList(flask_restful.Resource): """Allow uploading of files.""" resource_fields = { 'attachments': fields.Nested(Attachment.resource_fields) } decorators = [utils.admin_required] def post(self): app.logger.info('Uploading a new file.') fp = flask.request.files['file'] app.logger.info('Using backend: %r', attachments.backend) aid, fpath = attachments.backend.upload(fp) app.logger.info('File uploaded to backend, got aid %s', aid) attachment = models.Attachment.query.get(aid) if not attachment: models.Attachment.create(aid, fp.filename, fp.mimetype) models.commit() cache.clear() return dict(aid=aid, fpath=fpath, content_type=fp.mimetype) @flask_restful.marshal_with(resource_fields) def get(self): return dict(attachments=list(models.Attachment.query.all())) api.add_resource(Attachment, '/api/attachments/') api.add_resource(AttachmentList, '/api/attachments') class APIKey(flask_restful.Resource): """Get/set API key for admins.""" decorators = [utils.admin_required] resource_fields = { 'api_key': fields.String, 'api_key_updated': ISO8601DateTime(), } @flask_restful.marshal_with(resource_fields) def post(self): user = models.User.current() app.logger.info('Resetting API key for %r.', user) user.reset_api_key() models.commit() return user @flask_restful.marshal_with(resource_fields) def get(self): return models.User.current() def delete(self, keyid=None): if keyid is None: return self._delete_all() user = models.User.current() if keyid != user.api_key: raise errors.AccessDeniedError('Cannot delete that key.') user.api_key = None user.api_key_updated = datetime.datetime.now() models.commit() return dict(status='OK') def _delete_all(self): for u in models.User.query.filter( models.User.api_key != None).all(): # noqa: E711 u.api_key = None u.api_key_updated = datetime.datetime.now() models.commit() return dict(status='OK') api.add_resource(APIKey, '/api/apikey', '/api/apikey/') class BackupRestore(flask_restful.Resource): """Control for backup and restore.""" decorators = [utils.admin_required] def get(self): # TODO: refactor, this is messy rv = { 'challenges': list(models.Challenge.query.all()), 'tags': list(models.Challenge.query.all()), } return ( rv, 200, {'Content-Disposition': 'attachment; filename=challenges.json'}) def post(self): # TODO: refactor, this is messy raise NotImplementedError('Restore not implemented.') challs = [] models.commit() cache.clear() return {'message': '%d Challenges imported.' % (len(challs),)} api.add_resource(BackupRestore, '/api/backup') class CTFTimeScoreFeed(flask_restful.Resource): """Provide a JSON feed to CTFTime. At this time, it is only intended to cover the mandatory fields in the feed: https://ctftime.org/json-scoreboard-feed """ def get(self): standings = [{'pos': i, 'team': v.name, 'score': v.score} for i, v in models.Team.enumerate()] data = {'standings': standings} return data, 200, {'X-No-XSSI': 1} api.add_resource(CTFTimeScoreFeed, '/api/ctftime/scoreboard') class Configz(flask_restful.Resource): """Dump the config.""" decorators = [utils.admin_required] def get(self): return repr(app.config) api.add_resource(Configz, '/api/configz') class ToolsRecalculate(flask_restful.Resource): """Recalculate the scores.""" decorators = [utils.admin_required] def post(self): changed = 0 for team in models.Team.query.all(): old = team.score team.update_score() changed += 1 if team.score != old else 0 models.commit() cache.clear() return {'message': ('Recalculated, %d changed.' % changed)} api.add_resource(ToolsRecalculate, '/api/tools/recalculate') class DBReset(flask_restful.Resource): """Reset various parts of the database.""" decorators = [utils.admin_required] def post(self): data = flask.request.get_json() if data.get('ack') != 'ack': raise ValueError('Requires ack!') op = data.get('op', '') if op == 'scores': app.logger.info('Score reset requested by %r.', models.User.current()) models.ScoreHistory.query.delete() models.Answer.query.delete() models.NonceFlagUsed.query.delete() for team in models.Team.query.all(): team.score = 0 elif op == 'players': app.logger.info('Player reset requested by %r.', models.User.current()) models.User.query.filter( models.User.admin == False).delete() # noqa: E712 models.Team.query.delete() else: raise ValueError('Unknown operation %s' % op) models.commit() cache.clear() return {'message': 'Done'} api.add_resource(DBReset, '/api/tools/reset') ================================================ FILE: scoreboard/tests/__init__.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: scoreboard/tests/base.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Base test module, MUST be imported first.""" import contextlib import copy import functools import json import logging import os import os.path import pbkdf2 import time import unittest import flask from flask import testing import flask_testing from sqlalchemy import event from scoreboard import attachments from scoreboard import cache from scoreboard import main from scoreboard import models from scoreboard import utils class BaseTestCase(flask_testing.TestCase): """Base TestCase for scoreboard. Monkey-patches the app and db objects. """ TEST_CONFIG = dict( PRESERVE_CONTEXT_ON_EXCEPTION=False, SECRET_KEY='testing-session-key', SQLALCHEMY_DATABASE_URI="sqlite://", TEAMS=True, TEAM_SECRET_KEY='different-secret', TESTING=True, DEBUG=False, ATTACHMENT_BACKEND='test://volatile', ) def create_app(self): """Called by flask_testing.""" app = main.get_app() app.config.update(self.TEST_CONFIG) attachments.patch("test") main.setup_logging(app) return app def setUp(self): """Re-setup the DB to ensure a fresh instance.""" super(BaseTestCase, self).setUp() # Reset config on each call try: app = main.get_app() app.config = copy.deepcopy(self.app._SAVED_CONFIG) except AttributeError: self.app._SAVED_CONFIG = copy.deepcopy(app.config) models.db.init_app(app) models.db.create_all() cache.global_cache = cache.cache.NullCache() # Reset cache def tearDown(self): models.db.session.remove() models.db.drop_all() super(BaseTestCase, self).tearDown() def queryLimit(self, limit=None): return MaxQueryBlock(self, limit) def assertItemsEqual(self, a, b, msg=None): a = list(a) b = list(b) a.sort() b.sort() if len(a) == len(b): success = True for c, d in zip(a, b): if c != d: success = False break if success: return None if msg is not None: raise AssertionError(msg) raise AssertionError('Items not equal: %r != %r', a, b) class RestTestCase(BaseTestCase): """Special features for testing rest handlers.""" def setUp(self): super(RestTestCase, self).setUp() # Monkey patch pbkdf2 for speed self._orig_pbkdf2 = pbkdf2.crypt pbkdf2.crypt = self._pbkdf2_dummy # Setup some special clients self.admin_client = AdminClient( self.app, self.app.response_class) self.authenticated_client = AuthenticatedClient( self.app, self.app.response_class) def tearDown(self): super(RestTestCase, self).tearDown() pbkdf2.crypt = self._orig_pbkdf2 def postJSON(self, path, data, client=None): client = client or self.client return client.post( path, data=json.dumps(data), content_type='application/json') def putJSON(self, path, data, client=None): client = client or self.client return client.put( path, data=json.dumps(data), content_type='application/json') @contextlib.contextmanager def swapClient(self, client): old_client = self.client self.client = client yield self.client = old_client @staticmethod def _pbkdf2_dummy(value, *unused_args): return value class AuthenticatedClient(testing.FlaskClient): """Like TestClient, but authenticated.""" def __init__(self, *args, **kwargs): super(AuthenticatedClient, self).__init__(*args, **kwargs) self.team = models.Team.create('team') self.password = 'hunter2' self.user = models.User.create( 'auth@example.com', 'Authenticated', self.password, team=self.team) models.db.session.commit() self.uid = self.user.uid self.tid = self.team.tid def open(self, *args, **kwargs): with self.session_transaction() as sess: sess['user'] = self.uid sess['team'] = self.tid sess['expires'] = time.time() + 3600 return super(AuthenticatedClient, self).open(*args, **kwargs) class AdminClient(testing.FlaskClient): """Like TestClient, but admin.""" def __init__(self, *args, **kwargs): super(AdminClient, self).__init__(*args, **kwargs) self.user = models.User.create('admin@example.net', 'Admin', 'hunter2') self.user.admin = True models.db.session.commit() self.uid = self.user.uid def open(self, *args, **kwargs): with self.session_transaction() as sess: sess['user'] = self.uid sess['admin'] = True sess['expires'] = time.time() + 3600 return super(AdminClient, self).open(*args, **kwargs) class MaxQueryBlock(object): """Run a certain block with a maximum number of queries.""" def __init__(self, test=None, max_count=None): self.max_count = max_count self.queries = [] self._sql_listen_args = ( models.db.engine, 'before_cursor_execute', self._count_query) self.test_id = test.id() if test else '' def __enter__(self): event.listen(*self._sql_listen_args) return self def __exit__(self, exc_type, exc_value, exc_traceback): event.remove(*self._sql_listen_args) if exc_type is not None: return False if self.test_id: limit_msg = ((' Limit: %d.' % self.max_count) if self.max_count is not None else '') logging.info('%s executed %d queries.%s', self.test_id, len(self.queries), limit_msg) if self.max_count is None: return if len(self.queries) > self.max_count: message = ('Maximum query count exceeded: limit %d, executed %d.\n' '----QUERIES----\n%s\n----END----') % ( self.max_count, len(self.queries), '\n'.join(self.queries)) raise AssertionError(message) @property def query_count(self): return len(self.queries) def _count_query(self, unused_conn, unused_cursor, statement, parameters, unused_context, unused_executemany): statement = '%s (%s)' % ( statement, ', '.join(str(x) for x in parameters)) self.queries.append(statement) logging.debug('SQLAlchemy: %s', statement) def authenticated_test(f): """Swaps out the client for an authenticated client.""" @functools.wraps(f) def wrapped_test(self): with self.swapClient(self.authenticated_client): return f(self) return wrapped_test def admin_test(f): """Swaps out the client for an admin client.""" @functools.wraps(f) def wrapped_test(self): with self.swapClient(self.admin_client): return f(self) return wrapped_test def run_all_tests(pattern='*_test.py'): """This loads and runs all tests in scoreboard.tests.""" if os.getenv("DEBUG_TESTS"): logging.getLogger().setLevel(logging.DEBUG) else: logging.getLogger().setLevel(logging.INFO) test_dir = os.path.dirname(os.path.realpath(__file__)) top_dir = os.path.abspath(os.path.join(test_dir, '..')) suite = unittest.defaultTestLoader.discover( test_dir, pattern=pattern, top_level_dir=top_dir) result = unittest.TextTestRunner().run(suite) return result.wasSuccessful() def json_monkeypatch(): """Automatically strip our XSSI header.""" def new_loads(data, *args, **kwargs): try: prefix = utils.to_bytes(")]}',\n") if data.startswith(prefix): data = data[len(prefix):] return json.loads(data, *args, **kwargs) except Exception as exc: logging.exception('JSON monkeypatch failed: %s', exc) flask.json.loads = new_loads json_monkeypatch() ================================================ FILE: scoreboard/tests/cache_test.py ================================================ # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Cache test module.""" import flask import mock from scoreboard.tests import base from scoreboard import cache class BaseCacheTest(base.BaseTestCase): """Test core caching functionality.""" def setUp(self): super(BaseCacheTest, self).setUp() cache.global_cache = cache.cache.SimpleCache() def makeMockGet(self, cache_type, cache_host=None): orig_config = self.app.config def mock_get(key): if key == 'CACHE_TYPE': return cache_type elif key == 'MEMCACHE_HOST': return cache_host return orig_config.get(key) return mock_get def testBuildCaches(self): """Test that we can build the various types of caches.""" for ctype in ('memcached', 'local'): with mock.patch.object(self.app, 'config') as m: m.get = self.makeMockGet(ctype, 'localhost') c = cache.CacheWrapper(self.app) with self.assertRaises(AttributeError): c._non_existent_attribute_really def testRestCache_Basic(self): m = mock.Mock() m.__name__ = 'mockMethod' m.return_value = 5 wrapped = cache.rest_cache(m) self.assertEqual(5, wrapped()) self.assertEqual(5, wrapped()) # called twice for caching m.assert_called_once() def testRestCache_Override(self): m = mock.Mock() m.__name__ = 'mockMethod' m.return_value = 8 wrapped = cache.rest_cache('key')(m) self.assertEqual(8, wrapped()) self.assertEqual(8, wrapped()) # called twice for caching m2 = mock.Mock() m2.__name__ = 'mockMethod2' m2.return_value = 42 wrapped2 = cache.rest_cache('key')(m2) # same key self.assertEqual(8, wrapped2()) m.assert_called_once() m2.assert_not_called() def testRestCachePath(self): m = mock.Mock() m.__name__ = 'mockMethod' m.return_value = 1337 wrapped = cache.rest_cache_path(m) with self.app.test_request_context('/foo/bar'): self.assertEqual(1337, wrapped()) m.return_value = 1338 with self.app.test_request_context('/foo/bar?baz=1'): self.assertEqual(1337, wrapped()) with self.app.test_request_context('/foo/baz'): self.assertEqual(1338, wrapped()) def testRestTeamCache_Basic(self): m = mock.Mock() m.__name__ = 'mockMethod' m.return_value = 5 wrapped = cache.rest_team_cache(m) with mock.patch.object(flask, 'g'): flask.g.tid = 111 self.assertEqual(5, wrapped()) m.return_value = 555 self.assertEqual(5, wrapped()) # called twice for caching m.assert_called_once() flask.g.tid = 123 self.assertEqual(555, wrapped()) # different team? def testRestTeamCache_Override(self): m = mock.Mock() m.__name__ = 'mockMethod' m.return_value = 5 with self.assertRaises(ValueError): cache.rest_team_cache('foo')(m) wrapped = cache.rest_team_cache('foo-%d')(m) with mock.patch.object(flask, 'g'): flask.g.tid = 111 self.assertEqual(5, wrapped()) m.return_value = 555 self.assertEqual(5, wrapped()) # called twice for caching m.assert_called_once() flask.g.tid = 123 self.assertEqual(555, wrapped()) # different team? def testRestCacheCaller_NonSerializable(self): m = mock.Mock() m.return_value = mock.Mock() m.return_value.foo = 5 self.assertEqual(5, cache._rest_cache_caller(m, 'foo').foo) m.assert_called_once() def testRestCacheCaller_NonLoadable(self): cache.global_cache.set('foo', '{ not valid json') m = mock.Mock() m.return_value = 5 self.assertEqual(5, cache._rest_cache_caller(m, 'foo')) self.assertEqual(5, cache._rest_cache_caller(m, 'foo')) m.assert_called_once() def testRestAddCacheHeader(self): foo = 'foo' rv = cache._rest_add_cache_header((foo,)) self.assertEqual(foo, rv[0]) self.assertEqual(200, rv[1]) self.assertTrue('X-Cache-Hit' in rv[2]) rv = cache._rest_add_cache_header((foo, 404)) self.assertEqual(foo, rv[0]) self.assertEqual(404, rv[1]) self.assertTrue('X-Cache-Hit' in rv[2]) rv = cache._rest_add_cache_header((foo, 404, None)) self.assertEqual(foo, rv[0]) self.assertEqual(404, rv[1]) self.assertTrue('X-Cache-Hit' in rv[2]) rv = cache._rest_add_cache_header((foo, 404, {foo: foo})) self.assertEqual(foo, rv[0]) self.assertEqual(404, rv[1]) self.assertTrue('X-Cache-Hit' in rv[2]) rv = cache._rest_add_cache_header({foo: foo}) self.assertTrue(foo in rv[0]) self.assertEqual(200, rv[1]) self.assertTrue('X-Cache-Hit' in rv[2]) rv = cache._rest_add_cache_header(foo) self.assertEqual(foo, rv[0]) self.assertEqual(200, rv[1]) self.assertTrue('X-Cache-Hit' in rv[2]) # Passthrough cases bar = mock.Mock() self.assertEqual(bar, cache._rest_add_cache_header(bar)) baz = (1, 2, 3, 4) self.assertEqual(baz, cache._rest_add_cache_header(baz)) bang = (1, 2, 3) self.assertEqual(bang, cache._rest_add_cache_header(bang)) ================================================ FILE: scoreboard/tests/controllers_test.py ================================================ # Copyright 2019 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from scoreboard.tests import base from scoreboard import controllers from scoreboard import errors class RegisterTest(base.BaseTestCase): """Test register_user controller.""" def testRegister_Normal(self): rv = controllers.register_user('foo@bar.com', 'foo', 'pass') self.assertIsNotNone(rv) def testRegister_BadEmail(self): """Test variations on bad emails.""" for email in ('', 'frob', '//', ''): with self.assertRaises(errors.ValidationError): controllers.register_user(email, 'foo', 'pass') def testRegister_DupeNick(self): self.app.config['TEAMS'] = False controllers.register_user('foo@bar.com', 'foo', 'pass') with self.assertRaises(errors.ValidationError): controllers.register_user('bar@bar.com', 'foo', 'pass') def testRegister_DupeTeam(self): self.app.config['TEAMS'] = True controllers.register_user( 'foo@bar.com', 'foo', 'pass', team_id='new', team_name='faketeam') with self.assertRaises(errors.ValidationError): controllers.register_user( 'bar@bar.com', 'foo', 'pass', team_id='new', team_name='faketeam') def testRegister_DupeEmail(self): self.app.config['TEAMS'] = False controllers.register_user('foo@bar.com', 'foo', 'pass') with self.assertRaises(errors.ValidationError): controllers.register_user('foo@bar.com', 'sam', 'pass') ================================================ FILE: scoreboard/tests/csrfutil_test.py ================================================ # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import struct import time import jinja2 from werkzeug import exceptions from scoreboard.tests import base from scoreboard import csrfutil try: import mock except ImportError: from unittest import mock class CSRFUtilTest(base.BaseTestCase): """Test CSRF protection.""" base_clock = 1523481076.571611 valid_token = 'dMvPWgUhaJbF18mqeuMyWspjUplsvb1x4Z139GbPFAjlzhLO' test_user = 'user' @mock.patch.object(time, 'time') def testGetCSRFToken(self, mock_time): mock_time.return_value = self.base_clock self.assertEqual(self.valid_token, csrfutil.get_csrf_token(self.test_user)) mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyCSRFToken_Valid(self, mock_time): mock_time.return_value = self.base_clock self.assertTrue(csrfutil.verify_csrf_token( self.valid_token, self.test_user)) mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyCSRFToken_Expired(self, mock_time): mock_time.return_value = self.base_clock + (60 * 60 * 60) self.assertFalse(csrfutil.verify_csrf_token( self.valid_token, self.test_user)) mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyCSRFToken_InvalidSig(self, mock_time): mock_time.return_value = self.base_clock token = self.valid_token.replace('svb', 'xxx') self.assertFalse(csrfutil.verify_csrf_token(token, self.test_user)) mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyCSRFToken_TamperedTime(self, mock_time): mock_time.return_value = self.base_clock token = self.valid_token.replace('dMv', 'xxx') self.assertFalse(csrfutil.verify_csrf_token(token, self.test_user)) mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyCSRFToken_Truncated(self, mock_time): mock_time.return_value = self.base_clock token = self.valid_token[:4] with self.assertRaises(struct.error): csrfutil.verify_csrf_token(token, self.test_user) mock_time.reset_mock() token = 'a' self.assertFalse(csrfutil.verify_csrf_token(token, self.test_user)) mock_time.assert_not_called() def testDecorator_GET(self): called = mock.Mock() called.__name__ = 'called' wrapped = csrfutil.csrf_protect(called) with self.app.test_request_context('/'): wrapped() called.assert_called_once() @mock.patch.object(csrfutil, 'verify_csrf_token') def testDecorator_Passes(self, mock_verify): mock_verify.return_value = True called = mock.Mock() called.__name__ = 'called' wrapped = csrfutil.csrf_protect(called) with self.app.test_request_context('/?csrftoken=x', method='POST'): wrapped() called.assert_called_once() @mock.patch.object(csrfutil, 'verify_csrf_token') def testDecorator_Fails(self, mock_verify): mock_verify.return_value = False called = mock.Mock() called.__name__ = 'called' wrapped = csrfutil.csrf_protect(called) with self.app.test_request_context('/', method='POST'): with self.assertRaises(exceptions.Forbidden): wrapped() called.assert_not_called() @mock.patch.object(csrfutil, 'get_csrf_token') def testGetCSRFField(self, mock_get_csrf_token): mock_value = 'abcdef' mock_get_csrf_token.return_value = mock_value rv = csrfutil.get_csrf_field(user='foo') mock_get_csrf_token.assert_called_once_with(user='foo') self.assertTrue(isinstance(rv, jinja2.Markup)) self.assertTrue(mock_value in str(rv)) @mock.patch.object(csrfutil, 'verify_csrf_token') def testCSRFProtectionMiddleware_HeaderValid(self, mock_verify_csrf_token): headers = [('X-XSRF-TOKEN', 'foo')] mock_verify_csrf_token.return_value = True with self.app.test_request_context( '/', method='POST', headers=headers): with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = False csrfutil.csrf_protection_request() mock_get.assert_called_once() mock_verify_csrf_token.assert_called_once_with('foo') @mock.patch.object(csrfutil, 'verify_csrf_token') def testCSRFProtectionMiddleware_HeaderInvalid( self, mock_verify_csrf_token): headers = [('X-XSRF-TOKEN', 'foo')] mock_verify_csrf_token.return_value = False with self.app.test_request_context( '/', method='POST', headers=headers): with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = False with self.assertRaises(exceptions.Forbidden): csrfutil.csrf_protection_request() mock_get.assert_called_once() mock_verify_csrf_token.assert_called_once_with('foo') @mock.patch.object(csrfutil, 'verify_csrf_token') def testCSRFProtectionMiddleware_FormValid(self, mock_verify_csrf_token): mock_verify_csrf_token.return_value = True with self.app.test_request_context( '/?csrftoken=foo', method='POST'): with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = False csrfutil.csrf_protection_request() mock_get.assert_called_once() mock_verify_csrf_token.assert_called_once_with('foo') @mock.patch.object(csrfutil, 'verify_csrf_token') def testCSRFProtectionMiddleware_GET(self, mock_verify_csrf_token): mock_verify_csrf_token.return_value = True with self.app.test_request_context('/'): with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = False csrfutil.csrf_protection_request() mock_get.assert_not_called() mock_verify_csrf_token.assert_not_called() ================================================ FILE: scoreboard/tests/data.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import datetime import json import random from scoreboard import models def make_admin(): u = models.User.create('admin@example.com', 'admin', 'admin') u.promote() return u def make_teams(): teams = [] for name in ('QQQ', 'Light Cats', 'Siberian Nopsled', 'PPP', 'Raelly', 'Toast', 'csh', 'ByTeh', 'See Sure', 'Skinniest', '213374U'): teams.append(models.Team.create(name)) return teams def make_players(teams): players = [] for name in ('Ritam', 'Dr34dc0d3', 'alpha', 'beta', 'gamma', 'delta', 'Dade', 'Kate', 'zwad3', 'strikerkid', 'redpichu', 'n0pe', '0xcdb'): team = random.choice(teams) players.append(models.User.create( name.lower() + '@example.com', name, 'password', team=team)) return players def make_tags(): tags = [] for name in ('x86', 'x64', 'MIPS', 'RISC', 'Fun'): tags.append(models.Tag.create(name, 'Problems involving '+name)) return tags def make_challenges(tags): challs = [] chall_words = ( 'Magic', 'Grand', 'Fast', 'Hash', 'Table', 'Password', 'Crypto', 'Alpha', 'Beta', 'Win', 'Socket', 'Ball', 'Stego', 'Word', 'Gamma', 'Native', 'Mine', 'Dump', 'Tangled', 'Hackers', 'Book', 'Delta', 'Shadow', 'Lose', 'Draw', 'Long', 'Pointer', 'Free', 'Not', 'Only', 'Live', 'Secret', 'Agent', 'Hax0r', 'Whiskey', 'Tango', 'Foxtrot') for _ in range(25): title = random.sample(chall_words, 3) random.shuffle(title) title = ' '.join(title) flag = '_'.join(random.sample(chall_words, 4)).lower() # Choose a random subset of tags numtags = random.randint(0, len(tags)-1) local_tags = random.sample(tags, numtags) points = random.randint(1, 20) * 100 desc = 'Flag: ' + flag ch = models.Challenge.create( title, desc, points, flag, unlocked=True) ch.add_tags(local_tags) if len(challs) % 8 == 7: ch.prerequisite = json.dumps( {'type': 'solved', 'challenge': challs[-1].cid}) # TODO: attachments challs.append(ch) models.commit() return challs def make_answers(teams, challs): for team in teams: times = sorted( [random.randint(0, 24*60) for _ in range(16)], reverse=True) for ch in random.sample(challs, random.randint(4, 16)): a = models.Answer.create(ch, team, '') ago = datetime.timedelta(minutes=times.pop(0)) a.timestamp = datetime.datetime.utcnow() - ago team.score += ch.points h = models.ScoreHistory() h.team = team h.score = team.score h.when = a.timestamp models.db.session.add(h) def create_all(): make_admin() # Teams and players teams = make_teams() make_players(teams) # Challenges tags = make_tags() models.commit() # Need IDs allocated challs = make_challenges(tags) # Submitted answers make_answers(teams, challs) models.commit() ================================================ FILE: scoreboard/tests/models_test.py ================================================ # Copyright 2019 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for models.""" import mock import os import time from scoreboard.tests import base from scoreboard import errors from scoreboard import models class TeamTest(base.BaseTestCase): def setUp(self): super(TeamTest, self).setUp() self.team = models.Team.create('Test Team') def testCreateTeam(self): foo = 'Some team name' rv = models.Team.create(foo) self.assertTrue(isinstance(rv, models.Team)) self.assertEqual(foo, rv.name) self.assertEqual(foo, str(rv)) self.assertTrue(foo in repr(rv)) def testUpdateScore(self): foo = 'team' t = models.Team.create(foo) t.update_score() self.assertEqual(0, t.score) t.answers = [mock.MagicMock(), mock.MagicMock()] t.answers[0].current_points = 100 t.answers[1].current_points = 200 t.update_score() self.assertEqual(300, t.score) def testGetByName(self): foo = 'team' models.Team.create(foo) rv = models.Team.get_by_name(foo) self.assertEqual(foo, rv.name) self.assertIsNone(models.Team.get_by_name('does-not-exist')) class UserTest(base.BaseTestCase): def setUp(self): super(UserTest, self).setUp() self.team = models.Team.create('Test Team') self.user = models.User.create('test@test.com', 'test', '', self.team) models.commit() def testGetByNick(self): nick = 'test' self.assertEqual(nick, models.User.get_by_nick(nick).nick) self.assertIsNone(models.User.get_by_nick(nick*2)) def testStr(self): self.assertEqual('test', str(self.user)) def testResetApiKey(self): self.assertIsNone(self.user.api_key) self.user.reset_api_key() self.assertNotEqual('', self.user.api_key) old_key = self.user.api_key self.user.reset_api_key() self.assertNotEqual(old_key, self.user.api_key) with mock.patch.object(os, 'urandom') as mock_urandom: mock_urandom.return_value = b'\x41' * 16 self.user.reset_api_key() self.assertEqual('41'*16, self.user.api_key) mock_urandom.assert_called_once_with(16) @mock.patch.object(time, 'time') def testGetToken(self, mock_time): mock_time.return_value = 12345.678 self.user.pwhash = '$1$foo' with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = 'foo' token = self.user.get_token() mock_get.assert_called_once_with('SECRET_KEY') mock_time.assert_called_once_with() self.assertEqual(b'MTk1NDU6P0O68xiZ-H9gOLWPLzFkW8fhAQ8=', token) @mock.patch.object(time, 'time') def testVerifyToken_full(self, mock_time): good_token = 'MTk1NDU6P0O68xiZ-H9gOLWPLzFkW8fhAQ8=' mock_time.return_value = 12348.678 self.user.pwhash = '$1$foo' with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = 'foo' self.assertTrue(self.user.verify_token(good_token)) mock_get.assert_called_once_with('SECRET_KEY') mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyToken_wrongType(self, mock_time): good_token = 'MTk1NDU6P0O68xiZ-H9gOLWPLzFkW8fhAQ8=' mock_time.return_value = 12348.678 self.user.pwhash = '$1$foo' with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = 'foo' with self.assertRaises(errors.ValidationError): self.user.verify_token(good_token, token_type='non') mock_get.assert_called_once_with('SECRET_KEY') mock_time.assert_called_once_with() def testVerifyToken_badFormat(self): with self.assertRaises(errors.ValidationError): self.user.verify_token('!!!') @mock.patch.object(time, 'time') def testVerifyToken_expired(self, mock_time): good_token = 'MTk1NDU6P0O68xiZ-H9gOLWPLzFkW8fhAQ8=' mock_time.return_value = 99912345.678 self.user.pwhash = '$1$foo' with self.assertRaises(errors.ValidationError): self.user.verify_token(good_token) mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyToken_invalidSig(self, mock_time): good_token = 'MTk1NDU6P0O68xiZ-H9gOLWPLzgkW8fhAQ8=' mock_time.return_value = 12345.678 self.user.pwhash = '$1$foo' with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = 'foo' with self.assertRaises(errors.ValidationError): self.user.verify_token(good_token) mock_get.assert_called_once_with('SECRET_KEY') mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyToken_perUser(self, mock_time): good_token = 'MTk1NDU6P0O68xiZ-H9gOLWPLzFkW8fhAQ8=' mock_time.return_value = 12345.678 self.user.pwhash = '$1$foo' self.user.uid = 55 with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = 'foo' with self.assertRaises(errors.ValidationError): self.user.verify_token(good_token) mock_get.assert_called_once_with('SECRET_KEY') mock_time.assert_called_once_with() @mock.patch.object(time, 'time') def testVerifyToken_perPass(self, mock_time): good_token = 'MTk1NDU6P0O68xiZ-H9gOLWPLzFkW8fhAQ8=' mock_time.return_value = 12345.678 self.user.pwhash = '$1$foobar' with mock.patch.object(self.app.config, 'get') as mock_get: mock_get.return_value = 'foo' with self.assertRaises(errors.ValidationError): self.user.verify_token(good_token) mock_get.assert_called_once_with('SECRET_KEY') mock_time.assert_called_once_with() def testGetByEmail(self): self.assertEqual( self.user.nick, models.User.get_by_email(self.user.email).nick) self.assertIsNone(models.User.get_by_email('foo')) def testGetByApiKey(self): token = 'a'*32 self.user.api_key = token models.commit() self.assertEqual( self.user.nick, models.User.get_by_api_key(token).nick) self.assertIsNone(models.User.get_by_api_key(token[:-1])) ================================================ FILE: scoreboard/tests/rest_test.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import flask import json import io import mock from werkzeug import datastructures from scoreboard.tests import base from scoreboard.tests import data from scoreboard import models from scoreboard import rest from scoreboard import utils from scoreboard import views # Needed imports _ = (rest, views) def makeTestUser(): u = models.User.create('email@example.com', 'Nick', 'hunter2') models.db.session.commit() return u def makeTestTeam(user): t = models.Team.create('Test') user.team = t models.db.session.commit() return t def makeTestChallenges(): tags = data.make_tags() challs = data.make_challenges(tags) models.db.session.commit() return challs class ConfigzTest(base.RestTestCase): PATH = '/api/configz' def testGetFails(self): with self.queryLimit(0): response = self.client.get(self.PATH) self.assert403(response) testGetFailsNonAdmin = base.authenticated_test(testGetFails) @base.admin_test def testAdmin(self): with self.queryLimit(0): response = self.client.get(self.PATH) self.assert200(response) class PageTest(base.RestTestCase): PATH = '/api/page/home' PATH_NEW = '/api/page/new' PATH_404 = '/api/page/404' def setUp(self): super(PageTest, self).setUp() page = models.Page() page.path = 'home' page.title = 'Home' page.contents = 'Home Page' models.db.session.add(page) models.db.session.commit() self.page = page def testGetAnonymous(self): with self.queryLimit(1): response = self.client.get(self.PATH) self.assert200(response) self.assertEqual(self.page.title, response.json['title']) self.assertEqual(self.page.contents, response.json['contents']) def testGetNonExistent(self): with self.queryLimit(1): self.assert404(self.client.get(self.PATH_404)) @base.admin_test def testDeletePage(self): with self.client as c: self.assert200(c.get(self.PATH)) with self.queryLimit(1): self.assert200(c.delete(self.PATH)) self.assert404(c.get(self.PATH)) @base.admin_test def testCreatePage(self): page_data = dict( title='Test', contents='Test Page Contents', ) with self.queryLimit(3): resp = self.postJSON(self.PATH_NEW, page_data) self.assert200(resp) self.assertEqual(page_data['title'], resp.json['title']) self.assertEqual(page_data['contents'], resp.json['contents']) @base.authenticated_test def testCreatePageNonAdmin(self): page_data = dict( title='Test', contents='Test Page Contents', ) with self.queryLimit(0): resp = self.postJSON(self.PATH_NEW, page_data) self.assert403(resp) @base.admin_test def testUpdatePage(self): page_data = dict( title='Test', contents='Test Page Contents', ) with self.queryLimit(3): resp = self.postJSON(self.PATH, page_data) self.assert200(resp) self.assertEqual(page_data['title'], resp.json['title']) self.assertEqual(page_data['contents'], resp.json['contents']) class UpdateTeam(base.RestTestCase): PATH = '/api/teams/change' _state = None def createTeam(self, teamname): team = models.Team.create(teamname) models.db.session.commit() return team def changeTeam(self, tid, code): return self.putJSON(self.PATH, { 'uid': self.authenticated_client.uid, 'team_tid': tid, 'code': code }) def patchState(self, time='BEFORE'): self._state = utils.GameTime.state utils.GameTime.state = staticmethod(lambda: time) def restoreState(self): if self._state: utils.GameTime.state = self._state @base.authenticated_test def testChangeTeam(self): self.patchState() test_team = self.createTeam('test') resp = self.changeTeam(test_team.tid, test_team.code) self.assert200(resp) self.restoreState() @base.authenticated_test def testTeamChangeWorked(self): self.patchState() test_team = self.createTeam('test2') self.changeTeam(test_team.tid, test_team.code) tid = self.authenticated_client.user.team.tid self.assertEqual(tid, test_team.tid) self.restoreState() @base.authenticated_test def testEmptyTeamIsDeleted(self): self.patchState() test_team_first = self.createTeam('first') test_team_second = self.createTeam('second') self.changeTeam(test_team_first.tid, test_team_first.code) self.changeTeam(test_team_second.tid, test_team_second.code) tid = self.authenticated_client.user.team.tid self.assertEqual(tid, test_team_second.tid) self.assertIsNone(models.Team.query.get(test_team_first.tid)) self.restoreState() @base.authenticated_test def testTeamWithSolvesNotDeleted(self): self.patchState() test_team_first = self.createTeam('first') test_team_second = self.createTeam('second') self.changeTeam(test_team_first.tid, test_team_first.code) chall = models.Challenge.create('Foo', 'Foo', 1, 'Foo') models.Answer.create(chall, test_team_first, 'Foo') self.changeTeam(test_team_second.tid, test_team_second.code) tid = self.authenticated_client.user.team.tid self.assertEqual(tid, test_team_second.tid) self.assertIsNotNone(models.Team.query.get(test_team_first.tid)) self.restoreState() @base.authenticated_test def testCantSwitchAfterStart(self): self.patchState('DURING') test_team = self.createTeam('test') resp = self.changeTeam(test_team.tid, test_team.code) self.assert403(resp) self.restoreState() class AttachmentTest(base.RestTestCase): PATH = '/api/attachments/%s' ATTACHMENT_FIELDS = ('aid', 'filename', 'challenges') text = b"This is a test" name = "test.txt" def uploadFile(self, filename, text): with self.admin_client as c: string = io.BytesIO() string.write(text) string.seek(0) return c.post('/api/attachments', data={ 'file': (string, filename) }) def fetchFile(self, aid): with self.admin_client as c: return c.get(self.PATH % aid) def testUploadFile(self): resp = self.uploadFile(self.name, self.text) self.assert200(resp) # Calculated using an external sha256 tool self.assertEquals( resp.json['aid'], "c7be1ed902fb8dd4d48997c6452f5d7e" "509fbcdbe2808b16bcf4edce4c07d14e") def testQueryFile(self): postresp = self.uploadFile(self.name, self.text) self.app.logger.info('Your JSON: %s', postresp.json) getresp = self.fetchFile(postresp.json['aid']) self.assert200(getresp) def testFileQueryAID(self): postresp = self.uploadFile(self.name, self.text) getresp = self.fetchFile(postresp.json['aid']) self.assertEqual(getresp.json['aid'], postresp.json['aid']) def testFileQueryName(self): postresp = self.uploadFile(self.name, self.text) getresp = self.fetchFile(postresp.json['aid']) self.assertEqual(getresp.json['filename'], self.name) def testFileChallengesEmpty(self): postresp = self.uploadFile(self.name, self.text) getresp = self.fetchFile(postresp.json['aid']) self.assertEqual(len(getresp.json['challenges']), 0) def testRetrieveFile(self): postresp = self.uploadFile(self.name, self.text) with self.admin_client as c: getresp = c.get('/attachments/%s' % postresp.json['aid']) self.assert200(getresp) def testFileRetrievalValue(self): postresp = self.uploadFile(self.name, self.text) with self.admin_client as c: getresp = c.get('/attachment/%s' % postresp.json['aid']) self.assertEqual(getresp.get_data(), self.text) def testFileDelete(self): postresp = self.uploadFile(self.name, self.text) with self.admin_client as c: delresp = c.delete('/api/attachments/%s' % postresp.json['aid']) self.assert200(delresp) def testDeletionRemovesFile(self): postresp = self.uploadFile(self.name, self.text) with self.admin_client as c: c.delete('/api/attachments/%s' % postresp.json['aid']) with self.admin_client as c: getresp = c.get('/api/attachments/%s' % postresp.json['aid']) self.assert404(getresp) def testFileUpdate(self): new_name = "file.png" postresp = self.uploadFile(self.name, self.text) with self.admin_client as c: putresp = c.put( '/api/attachments/%s' % postresp.json['aid'], data=json.dumps({ 'filename': new_name, 'aid': postresp.json['aid'], 'challenges': [], }), content_type="application/json") self.assert200(putresp) def testUpdateChangesName(self): new_name = "file.png" postresp = self.uploadFile(self.name, self.text) with self.admin_client as c: c.put('/api/attachments/%s' % postresp.json['aid'], data=json.dumps({ 'filename': new_name, 'aid': postresp.json['aid'], 'challenges': [], }), content_type="application/json") getresp = c.get('/api/attachments/%s' % postresp.json['aid']) self.assertEqual(getresp.json['filename'], new_name) class UserTest(base.RestTestCase): PATH = '/api/users/%d' USER_FIELDS = ('admin', 'nick', 'email', 'team_tid', 'uid') def testGetAnonymous(self): path = self.PATH % makeTestUser().uid with self.queryLimit(0): self.assert403(self.client.get(path)) def testGetNonExistent(self): path = self.PATH % 999 with self.queryLimit(0): resp = self.client.get(path) self.assert403(resp) testGetNonExistentAuth = base.authenticated_test( testGetNonExistent) @base.admin_test def testGetNonExistentAdmin(self): path = self.PATH % 999 with self.queryLimit(1): resp = self.client.get(path) self.assert404(resp) @base.authenticated_test def testGetSelf(self): user = self.authenticated_client.user with self.queryLimit(1): resp = self.client.get(self.PATH % user.uid) self.assert200(resp) self.assertEqual(user.email, resp.json['email']) self.assertEqual(user.nick, resp.json['nick']) self.assertEqual(user.admin, resp.json['admin']) @base.authenticated_test def testUpdateUser(self): user = self.authenticated_client.user data = {'password': 'hunter3'} # for security with self.queryLimit(2): self.assert200(self.putJSON( self.PATH % user.uid, data)) @base.authenticated_test def testUpdateUserNoAccess(self): uid = self.admin_client.user.uid data = {'password': 'hunter3'} with self.queryLimit(0): resp = self.putJSON(self.PATH % uid, data) self.assert403(resp) @base.admin_test def testUpdateUserAdmin(self): uid = self.authenticated_client.user.uid data = {'nick': 'Lame'} with self.queryLimit(2): resp = self.putJSON(self.PATH % uid, data) self.assert200(resp) self.assertEqual('Lame', resp.json['nick']) self.assertNotEqual( 'Lame', self.authenticated_client.user.team.name) @base.admin_test def testUpdateUsersNoTeams(self): uid = self.authenticated_client.user.uid self.app.config['TEAMS'] = False data = {'nick': 'Lame'} with self.queryLimit(3): resp = self.putJSON(self.PATH % uid, data) self.assert200(resp) self.assertEqual('Lame', resp.json['nick']) self.assertEqual('Lame', self.authenticated_client.user.team.name) @base.admin_test def testUpdateUserPromote(self): user = self.authenticated_client.user data = {'nick': user.nick, 'admin': True} # yes, this is a lot, but promoting is infrequent with self.queryLimit(7): resp = self.putJSON(self.PATH % user.uid, data) self.assert200(resp) self.assertTrue(resp.json['admin']) @base.admin_test def testUpdateUserDemote(self): user = self.admin_client.user data = {'nick': user.nick, 'admin': False} with self.queryLimit(3): resp = self.putJSON(self.PATH % user.uid, data) self.assert200(resp) self.assertFalse(resp.json['admin']) @base.authenticated_test def testUpdateUserNoSelfPromotion(self): uid = self.authenticated_client.user.uid data = {'admin': True} with self.queryLimit(1): resp = self.putJSON(self.PATH % uid, data) self.assert200(resp) self.assertFalse(resp.json['admin']) @base.admin_test def testUpdateUserNoAnswers(self): user = self.authenticated_client.user team = self.authenticated_client.user.team chall = models.Challenge.create('Foo', 'Foo', 1, 'Foo') models.Answer.create(chall, team, 'Foo') models.db.session.commit() data = {'nick': user.nick, 'admin': True} with self.queryLimit(3): resp = self.putJSON(self.PATH % user.uid, data) self.assert400(resp) user = models.User.query.get(user.uid) self.assertFalse(user.admin) @base.authenticated_test def testGetUsersNoAccess(self): with self.queryLimit(0): resp = self.client.get('/api/users') self.assert403(resp) @base.admin_test def testGetUsers(self): self.admin_client.user with self.queryLimit(1): resp = self.client.get('/api/users') self.assert200(resp) self.assertIsInstance(resp.json, dict) self.assertIn('users', resp.json) self.assertIsInstance(resp.json['users'], list) users = resp.json['users'] self.assertEqual(2, len(users)) for u in users: self.assertItemsEqual(self.USER_FIELDS, u.keys()) @staticmethod def default_data(): return { 'email': 'test@example.com', 'nick': 'test3', 'password': 'test3', 'team_id': 'new', 'team_name': 'New Team', 'team_code': None, } def testRegisterUserNewTeam(self): data = self.default_data() # TODO: maybe optimize? with self.client: with self.queryLimit(9): resp = self.postJSON('/api/users', data) self.assert200(resp) self.assertItemsEqual(self.USER_FIELDS, resp.json.keys()) self.assertEqual(resp.json['uid'], flask.session['user']) self.assertEqual(resp.json['admin'], flask.session['admin']) self.assertEqual(resp.json['team_tid'], flask.session['team']) def testRegisterUserExistingTeam(self): team = self.authenticated_client.team data = self.default_data() data.update({ 'team_id': team.tid, 'team_name': None, 'team_code': team.code, }) with self.client: with self.queryLimit(8): resp = self.postJSON('/api/users', data) self.assert200(resp) self.assertItemsEqual(self.USER_FIELDS, resp.json.keys()) self.assertEqual(resp.json['uid'], flask.session['user']) self.assertEqual(resp.json['admin'], flask.session['admin']) self.assertEqual(resp.json['team_tid'], flask.session['team']) self.assertEqual(team.tid, resp.json['team_tid']) def testRegisterUserTeamWrongCode(self): team = self.authenticated_client.team data = self.default_data() data.update({ 'team_id': team.tid, 'team_name': None, 'team_code': 'xxx', }) with self.client: with self.queryLimit(1): resp = self.postJSON('/api/users', data) self.assert400(resp) @base.authenticated_test def testRegisterUserLoggedInFails(self): data = self.default_data() with self.queryLimit(0): resp = self.postJSON('/api/users', data) self.assert400(resp) def testRegisterUserNoNick(self): data = self.default_data() del data['nick'] with self.queryLimit(0): self.assert400(self.postJSON('/api/users', data)) def testRegisterUserNoTeam(self): data = self.default_data() del data['team_name'] del data['team_id'] with self.queryLimit(0): self.assert400(self.postJSON('/api/users', data)) def testRegisterUserInviteKey(self): self.app.config['INVITE_KEY'] = 'foobar' data = self.default_data() data['invite_key'] = self.app.config['INVITE_KEY'] with self.client: resp = self.postJSON('/api/users', data) self.assert200(resp) def testRegisterUserNoInviteKey(self): self.app.config['INVITE_KEY'] = 'foobar' data = self.default_data() with self.client: with self.queryLimit(0): resp = self.postJSON('/api/users', data) self.assert400(resp) def testRegisterUserWrongInviteKey(self): self.app.config['INVITE_KEY'] = 'foobar' data = self.default_data() data['invite_key'] = 'notright' with self.client: with self.queryLimit(0): resp = self.postJSON('/api/users', data) self.assert400(resp) class TeamTest(base.RestTestCase): LIST_URL = '/api/teams' def setUp(self): super(TeamTest, self).setUp() self.user = makeTestUser() self.team = makeTestTeam(self.user) self.team_path = '/api/teams/%d' % self.team.tid @base.authenticated_test def testGetTeam(self): with self.queryLimit(4): resp = self.client.get(self.team_path) self.assert200(resp) self.assertEqual(0, len(resp.json['players'])) self.assertEqual(self.team.name, resp.json['name']) # TODO: check other fields def testGetTeamAnonymous(self): with self.queryLimit(0): self.assert403(self.client.get(self.team_path)) @base.admin_test def testGetTeamAdmin(self): with self.queryLimit(4): resp = self.client.get(self.team_path) self.assert200(resp) self.assertEqual(1, len(resp.json['players'])) @base.admin_test def testUpdateTeamAdmin(self): data = {'name': 'Updated'} with self.queryLimit(6): resp = self.putJSON(self.team_path, data) self.assert200(resp) self.assertEqual('Updated', resp.json['name']) team = models.Team.query.get(self.team.tid) self.assertEqual('Updated', team.name) def testGetTeamList(self): with self.client as c: with self.queryLimit(3) as ctr: resp = c.get(self.LIST_URL) n_queries = ctr.query_count self.assert200(resp) models.Team.create('Test 2') models.Team.create('Test 3') models.Team.create('Test 4') models.db.session.commit() with self.queryLimit(n_queries): resp = c.get(self.LIST_URL) self.assert200(resp) class SessionTest(base.RestTestCase): PATH = '/api/session' def testGetSessionAnonymous(self): self.assert403(self.client.get(self.PATH)) @base.authenticated_test def testGetSessionAuthenticated(self): with self.queryLimit(1): resp = self.client.get(self.PATH) self.assert200(resp) self.assertEqual( self.authenticated_client.user.nick, resp.json['user']['nick']) self.assertEqual( self.authenticated_client.team.name, resp.json['team']['name']) @base.admin_test def testGetSessionAdmin(self): with self.queryLimit(1): resp = self.client.get(self.PATH) self.assert200(resp) self.assertEqual( self.admin_client.user.nick, resp.json['user']['nick']) self.assertTrue(resp.json['user']['admin']) self.assertItemsEqual( {'tid': 0, 'score': 0, 'name': None, 'code': None}, resp.json['team']) def testSessionLoginSucceeds(self): data = { 'email': self.authenticated_client.user.email, 'password': self.authenticated_client.password, } with self.client: with self.queryLimit(4): resp = self.postJSON(self.PATH, data) self.assert200(resp) self.assertEqual( flask.session['user'], self.authenticated_client.user.uid) self.assertEqual( flask.session['team'], self.authenticated_client.team.tid) self.assertFalse(flask.session['admin']) self.assertEqual( flask.g.user.email, self.authenticated_client.user.email) def testSessionLoginFailsBadPassword(self): data = { 'email': self.authenticated_client.user.email, 'password': 'wrong', } with self.client: with self.queryLimit(1): resp = self.postJSON(self.PATH, data) self.assert403(resp) self.assertIsNone(flask.session.get('user')) self.assertIsNone(flask.session.get('team')) self.assertIsNone(flask.session.get('admin')) def testSessionLoginFailsBadUser(self): data = { 'email': 'no@example.com', 'password': 'wrong', } with self.client: with self.queryLimit(1): resp = self.postJSON(self.PATH, data) self.assert403(resp) self.assertIsNone(flask.session.get('user')) self.assertIsNone(flask.session.get('team')) self.assertIsNone(flask.session.get('admin')) @base.admin_test def testSessionLoginAlreadyLoggedIn(self): data = { 'email': self.authenticated_client.user.email, 'password': self.authenticated_client.password, } # This makes sure admin->non-admin downgrades properly with self.client: with self.queryLimit(4): resp = self.postJSON(self.PATH, data) self.assert200(resp) self.assertEqual( flask.session['user'], self.authenticated_client.user.uid) self.assertEqual( flask.session['team'], self.authenticated_client.team.tid) self.assertFalse(flask.session['admin']) self.assertEqual( flask.g.user.email, self.authenticated_client.user.email) @base.authenticated_test def testSessionLogout(self): with self.client as c: with self.queryLimit(1): resp = c.delete(self.PATH) self.assert200(resp) self.assertIsNone(flask.session.get('user')) self.assertIsNone(flask.session.get('team')) self.assertIsNone(flask.session.get('admin')) def testSessionLogoutAnonymous(self): with self.client as c: with self.queryLimit(0): resp = c.delete(self.PATH) self.assert200(resp) self.assertIsNone(flask.session.get('user')) self.assertIsNone(flask.session.get('team')) self.assertIsNone(flask.session.get('admin')) def testGetSessionWithApiKey(self): """Test that an API Key can be used to make requests.""" key = '41'*16 headers = datastructures.Headers() headers.add('X-SCOREBOARD-API-KEY', key) with self.client as c: with self.queryLimit(1): with mock.patch.object( models.User, 'get_by_api_key') as getter: getter.return_value = self.admin_client.user resp = c.get(self.PATH, headers=headers) getter.assert_called_once_with(key) self.assert200(resp) self.assertEqual(flask.g.user.email, self.admin_client.user.email) self.assertEqual(flask.g.uid, self.admin_client.user.uid) self.assertTrue(flask.g.admin) self.assertEqual( self.admin_client.user.nick, resp.json['user']['nick']) self.assertTrue(resp.json['user']['admin']) def testGetSessionWithBadApiKey(self): """Test that an API Key with the wrong value does not work.""" key = '41'*16 for key in ('41'*16, '41'*18, '41'*15, '55'*16, ''): headers = datastructures.Headers() headers.add('X-SCOREBOARD-API-KEY', key) with self.client as c: with self.queryLimit(1): with mock.patch.object( models.User, 'get_by_api_key') as getter: getter.return_value = None resp = c.get(self.PATH, headers=headers) if len(key) == 32: getter.assert_called_once_with(key) else: getter.assert_not_called() self.assert403(resp) with self.assertRaises(AttributeError): _ = flask.g.user self.assertIsNone(flask.g.uid) class ChallengeTest(base.RestTestCase): PATH_LIST = '/api/challenges' PATH_SINGLE = '/api/challenges/%d' def setUp(self): super(ChallengeTest, self).setUp() self.challs = makeTestChallenges() self.chall = self.challs[0] self.PATH_SINGLE %= self.chall.cid def testGetListAnonymous(self): with self.queryLimit(3): resp = self.client.get(self.PATH_LIST) self.assert200(resp) @base.authenticated_test def testGetListAuthenticated(self): with self.queryLimit(3): resp = self.client.get(self.PATH_LIST) self.assert200(resp) self.assertEqual(len(self.challs), len(resp.json['challenges'])) @base.admin_test def testGetListAdmin(self): with self.queryLimit(3): resp = self.client.get(self.PATH_LIST) self.assert200(resp) self.assertEqual(len(self.challs), len(resp.json['challenges'])) def newChallengeData(self): return { 'name': 'Chall 1', 'description': 'Challenge 1', 'points': 200, 'answer': 'abc', 'unlocked': True, 'validator': 'static_pbkdf2', } def testCreateChallengeAnonymous(self): data = self.newChallengeData() with self.queryLimit(0): self.assert403(self.postJSON( self.PATH_LIST, data)) testCreateChallengeAuthenticated = base.authenticated_test( testCreateChallengeAnonymous) @base.admin_test def testCreateChallenge(self): # TODO: variants data = self.newChallengeData() # TODO: optimize count with self.queryLimit(9): resp = self.postJSON(self.PATH_LIST, data) self.assert200(resp) for field in ('name', 'description', 'points', 'unlocked'): self.assertEqual(data[field], resp.json[field]) def getUpdateData(self): return { 'name': 'Renamed', 'points': 1, 'weight': 12, 'unlocked': False } @base.admin_test def testUpdateChallenge(self): data = self.getUpdateData() with self.queryLimit(7): resp = self.putJSON(self.PATH_SINGLE, data) self.assert200(resp) for k in data.keys(): self.assertEqual(data[k], resp.json[k]) def testUpdateChallengeAnonymous(self): data = self.getUpdateData() with self.queryLimit(0): self.assert403(self.putJSON(self.PATH_SINGLE, data)) testUpdateChallengeAuthenticated = base.authenticated_test( testUpdateChallengeAnonymous) @base.admin_test def testGetSingleton(self): with self.queryLimit(6): resp = self.client.get(self.PATH_SINGLE) self.assert200(resp) for field in ('name', 'points', 'description', 'unlocked'): self.assertEqual(getattr(self.chall, field), resp.json[field]) def testGetSingletonAnonymous(self): with self.queryLimit(0): self.assert403(self.client.get(self.PATH_SINGLE)) testGetSingletonAuthenticated = base.authenticated_test( testGetSingletonAnonymous) @base.admin_test def testDeleteChallenge(self): with self.queryLimit(5): self.assert200(self.client.delete(self.PATH_SINGLE)) def testDeleteChallengeAnonymous(self): with self.queryLimit(0): self.assert403(self.client.delete(self.PATH_SINGLE)) testDeleteChallengeAuthenticated = base.authenticated_test( testDeleteChallengeAnonymous) class ScoreboardTest(base.RestTestCase): PATH = '/api/scoreboard' def setUp(self): super(ScoreboardTest, self).setUp() data.create_all() # Make a bunch of data for scoreboard def testGetScoreboard(self): resp = self.client.get(self.PATH) self.assert200(resp) # TODO: check contents class AnswerTest(base.RestTestCase): PATH = '/api/answers' def setUp(self): super(AnswerTest, self).setUp() self.answer = 'foobar' self.points = 100 self.chall = models.Challenge.create( 'test', 'test', self.points, self.answer, unlocked=True) self.cid = self.chall.cid models.db.session.commit() def testSubmitAnonymous(self): with self.queryLimit(0): self.assert403(self.postJSON(self.PATH, { 'cid': self.cid, 'answer': self.answer, })) @base.admin_test def testSubmitAdmin_Regular(self): with self.queryLimit(0): self.assert400(self.postJSON(self.PATH, { 'cid': self.cid, 'answer': self.answer, })) @base.admin_test def testSubmitAdmin_Override(self): team = models.Team.create('crash_override') models.db.session.commit() with self.queryLimit(13): resp = self.postJSON(self.PATH, { 'cid': self.cid, 'tid': team.tid, }) self.assert200(resp) self.assertEqual(self.points, resp.json['points']) @base.authenticated_test def testSubmitCorrect(self): with self.queryLimit(14): resp = self.postJSON(self.PATH, { 'cid': self.cid, 'answer': self.answer, }) self.assert200(resp) self.assertEqual(self.points, resp.json['points']) @base.authenticated_test def testSubmitIncorrect(self): old_score = self.client.team.score with self.queryLimit(2): resp = self.postJSON(self.PATH, { 'cid': self.cid, 'answer': 'incorrect', }) self.assert403(resp) team = models.Team.query.get(self.client.team.tid) self.assertEqual(old_score, team.score) @base.authenticated_test def testSubmitDouble(self): models.Answer.create(self.chall, self.client.team, '') old_score = self.client.team.score with self.queryLimit(5): resp = self.postJSON(self.PATH, { 'cid': self.cid, 'answer': self.answer, }) self.assert403(resp) team = models.Team.query.get(self.client.team.tid) self.assertEqual(old_score, team.score) @base.authenticated_test def testSubmit_ProofOfWork(self): test_nbits = 12 # TODO: patch this too self.app.config['PROOF_OF_WORK_BITS'] = test_nbits with mock.patch.object( utils, 'validate_proof_of_work', return_value=True) as mock_pow: with self.queryLimit(14): resp = self.postJSON(self.PATH, { 'cid': self.cid, 'answer': self.answer, 'token': 'foo' }) mock_pow.assert_called_once_with(self.answer, 'foo', test_nbits) self.assert200(resp) self.assertEqual(self.points, resp.json['points']) @base.authenticated_test def testSubmit_ProofOfWorkFails(self): test_nbits = 12 self.app.config['PROOF_OF_WORK_BITS'] = test_nbits old_score = self.client.team.score with mock.patch.object( utils, 'validate_proof_of_work', return_value=False) as mock_pow: with self.queryLimit(2): resp = self.postJSON(self.PATH, { 'cid': self.cid, 'answer': self.answer, 'token': 'foo' }) mock_pow.assert_called_once_with(self.answer, 'foo', test_nbits) self.assert403(resp) team = models.Team.query.get(self.client.team.tid) self.assertEqual(old_score, team.score) class ConfigTest(base.RestTestCase): PATH = '/api/config' def makeTestGetConfig(extra_keys=None): extra_keys = set(extra_keys or []) def testGetConfig(self): with self.queryLimit(0): resp = self.client.get(self.PATH) self.assert200(resp) expected_keys = set(( 'teams', 'sbname', 'news_mechanism', 'news_poll_interval', 'csrf_token', 'rules', 'game_start', 'game_end', 'login_url', 'register_url', 'login_method', 'scoring', 'validators', 'proof_of_work_bits', 'invite_only', )) expected_keys |= extra_keys self.assertEqual(expected_keys, set(resp.json.keys())) self.assertIsInstance(resp.json['invite_only'], bool) return testGetConfig testGetConfig = makeTestGetConfig() testGetConfigAuthenticated = base.authenticated_test( makeTestGetConfig()) testGetConfigAdmin = base.admin_test( makeTestGetConfig()) class APIKeyTest(base.RestTestCase): PATH = '/api/apikey' def setUp(self): super(APIKeyTest, self).setUp() self.admin_client.user.api_key = None models.commit() @base.admin_test def testGetApiKey(self): key = '44'*16 self.admin_client.user.api_key = key models.commit() with self.queryLimit(1): resp = self.client.get(self.PATH) self.assert200(resp) self.assertEqual(key, resp.json['api_key']) @base.admin_test def testUpdateApiKey(self): key = '55'*16 with mock.patch.object( self.admin_client.user, 'reset_api_key') as mock_reset: def mock_side_effect(): self.admin_client.user.api_key = key mock_reset.side_effect = mock_side_effect with self.queryLimit(3): resp = self.postJSON(self.PATH, {}) mock_reset.assert_called_once() self.assert200(resp) self.assertEqual(key, resp.json['api_key']) @base.admin_test def testDelete_Own(self): key = '55'*16 self.admin_client.user.api_key = key models.commit() with self.queryLimit(2): resp = self.client.delete(self.PATH + '/' + key) self.assert200(resp) self.assertIsNone(self.admin_client.user.api_key) @base.admin_test def testDelete_All(self): self.admin_client.user.api_key = '55'*16 other_admin = models.User.create('foo@foo.com', 'foo', 'foo') other_admin.promote() other_admin.api_key = '44'*16 models.commit() with self.queryLimit(4): resp = self.client.delete(self.PATH) self.assert200(resp) self.assertIsNone(self.admin_client.user.api_key) self.assertIsNone(other_admin.api_key) def testGetApiKey_Denied(self): with self.queryLimit(0): resp = self.client.get(self.PATH) self.assert403(resp) testGetApiKey_Denied_Authenticated = base.authenticated_test( testGetApiKey_Denied) def testUpdateApiKey_Denied(self): with self.queryLimit(0): resp = self.postJSON(self.PATH, {}) self.assert403(resp) testUpdateApiKey_Denied_Authenticated = base.authenticated_test( testUpdateApiKey_Denied) def testDeleteApiKey_All_Denied(self): with self.queryLimit(0): resp = self.client.delete(self.PATH) self.assert403(resp) testDeleteApiKey_All_Denied_Auth = base.authenticated_test( testDeleteApiKey_All_Denied) class NewsTest(base.RestTestCase): PATH = '/api/news' def setUp(self): super(NewsTest, self).setUp() models.News.broadcast('test', 'Test message.') models.News.unicast( self.authenticated_client.team.tid, 'test', 'Test team message.') models.commit() def testGetNews(self): with self.queryLimit(2): resp = self.client.get(self.PATH) self.assert200(resp) self.assertEqual(1, len(resp.json)) testGetNewsAdmin = base.admin_test(testGetNews) @base.authenticated_test def testGetNewsAuthenticated(self): with self.queryLimit(2): resp = self.client.get(self.PATH) self.assert200(resp) self.assertEqual(2, len(resp.json)) def testCreateNews(self): with self.queryLimit(0): resp = self.postJSON(self.PATH, { 'message': 'some message', }) self.assert403(resp) testCreateNewsAuthenticated = base.authenticated_test(testCreateNews) @base.admin_test def testCreateNewsAdmin(self): msg = 'some message' with self.queryLimit(3): resp = self.postJSON(self.PATH, { 'message': msg, }) self.assert200(resp) self.assertEqual(self.client.user.nick, resp.json['author']) self.assertEqual(msg, resp.json['message']) @base.admin_test def testCreateTeamNewsAdmin(self): msg = 'some message' tid = self.authenticated_client.team.tid with self.queryLimit(3): resp = self.postJSON(self.PATH, { 'message': msg, 'tid': tid, }) self.assert200(resp) self.assertEqual(self.client.user.nick, resp.json['author']) self.assertEqual(msg, resp.json['message']) self.assertEqual('Unicast', resp.json['news_type']) news = models.News.query.get(resp.json['nid']) self.assertEqual(tid, news.audience_team_tid) class CTFTimeTest(base.RestTestCase): PATH = '/api/ctftime/scoreboard' def testGetScoreboard(self): with self.queryLimit(1): resp = self.client.get(self.PATH) self.assert200(resp) self.assertIn('standings', resp.json) standings = resp.json['standings'] required = set(('pos', 'team', 'score')) for i in standings: self.assertEqual(required, required & set(i.keys())) ================================================ FILE: scoreboard/tests/utils_test.py ================================================ # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from scoreboard.tests import base from scoreboard import utils class NormalizeInputTest(base.BaseTestCase): def testNormalizeInput(self): ni = utils.normalize_input # Shorthand self.assertEqual(ni("hello"), "hello") self.assertEqual(ni("Hello World"), "Hello World") self.assertEqual(ni(" foo "), "foo") class ProofOfWorkTest(base.BaseTestCase): def testValidateProofOfWork_Succeeds(self): val = "foo" key = "N77manQK9CvjvPRXB8U7ftJxys1d36xVfcBkGvM-jqM" nbits = 12 self.assertTrue(utils.validate_proof_of_work(val, key, nbits)) def testValidateProofOfWork_SucceedsUnicode(self): val = u"foo" key = u"N77manQK9CvjvPRXB8U7ftJxys1d36xVfcBkGvM-jqM" nbits = 12 self.assertTrue(utils.validate_proof_of_work(val, key, nbits)) def testValidateProofOfWork_FailsWrongVal(self): val = "bar" key = "N77manQK9CvjvPRXB8U7ftJxys1d36xVfcBkGvM-jqM" nbits = 12 self.assertFalse(utils.validate_proof_of_work(val, key, nbits)) def testValidateProofOfWork_FailsWrongKey(self): val = "foo" key = "N77manQK9CvjvPRXB8U7ftJxys1d36xVfcBkGvM-jq" nbits = 12 self.assertFalse(utils.validate_proof_of_work(val, key, nbits)) def testValidateProofOfWork_FailsMoreBits(self): val = "foo" key = "N77manQK9CvjvPRXB8U7ftJxys1d36xVfcBkGvM-jq" nbits = 16 self.assertFalse(utils.validate_proof_of_work(val, key, nbits)) def testValidateProofOfWork_FailsInvalidBase64(self): val = "foo" key = "!!" nbits = 12 self.assertFalse(utils.validate_proof_of_work(val, key, nbits)) ================================================ FILE: scoreboard/tests/validators_test.py ================================================ # Copyright 2017 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from scoreboard.tests import base from scoreboard import errors from scoreboard import models from scoreboard import validators class ChallengeStub(object): def __init__(self, answer, validator='static_pbkdf2'): self.answer_hash = answer self.validator = validator class StaticValidatorTest(base.BaseTestCase): def testStaticValidator(self): chall = ChallengeStub(None) validator = validators.GetValidatorForChallenge(chall) self.assertFalse(validator.validate_answer('fooabc', None)) validator.change_answer('fooabc') self.assertTrue(validator.validate_answer('fooabc', None)) self.assertFalse(validator.validate_answer('abcfoo', None)) class CaseStaticValidatorTest(base.BaseTestCase): def testCaseStaticValidator(self): chall = ChallengeStub(None, validator='static_pbkdf2_ci') validator = validators.GetValidatorForChallenge(chall) self.assertFalse(validator.validate_answer('foo', None)) validator.change_answer('FooBar') for test in ('FooBar', 'foobar', 'FOOBAR', 'fooBAR'): self.assertTrue( validator.validate_answer(test, None), msg='Case failed: {}'.format(test)) for test in ('barfoo', 'bar', 'foo', None): self.assertFalse( validator.validate_answer(test, None), msg='Case failed: {}'.format(test)) class RegexValidatorTest(base.BaseTestCase): def makeValidator(self, regex): """Construct a validator.""" chall = ChallengeStub(regex, validator='regex') return validators.GetValidatorForChallenge(chall) def testRegexWorks(self): v = self.makeValidator('[abc]+') self.assertTrue(v.validate_answer('aaa', None)) self.assertTrue(v.validate_answer('abc', None)) self.assertFalse(v.validate_answer('ddd', None)) self.assertFalse(v.validate_answer('aaad', None)) self.assertFalse(v.validate_answer('AAA', None)) def testRegexChangeWorks(self): v = self.makeValidator('[abc]+') self.assertTrue(v.validate_answer('a', None)) self.assertFalse(v.validate_answer('foo', None)) v.change_answer('fo+') self.assertTrue(v.validate_answer('foo', None)) self.assertFalse(v.validate_answer('a', None)) class RegexCaseValidatorTest(base.BaseTestCase): def makeValidator(self, regex): """Construct a validator.""" chall = ChallengeStub(regex, validator='regex_ci') return validators.GetValidatorForChallenge(chall) def testRegexWorks(self): v = self.makeValidator('[abc]+') self.assertTrue(v.validate_answer('aaa', None)) self.assertTrue(v.validate_answer('abc', None)) self.assertFalse(v.validate_answer('ddd', None)) self.assertFalse(v.validate_answer('aaad', None)) self.assertTrue(v.validate_answer('AAA', None)) def testRegexChangeWorks(self): v = self.makeValidator('[abc]+') self.assertTrue(v.validate_answer('a', None)) self.assertFalse(v.validate_answer('foo', None)) v.change_answer('fo+') self.assertTrue(v.validate_answer('Foo', None)) self.assertFalse(v.validate_answer('a', None)) class NonceValidatorTest(base.BaseTestCase): def setUp(self): super(NonceValidatorTest, self).setUp() self.chall = models.Challenge.create( 'foo', 'bar', 100, '', unlocked=True, validator='nonce_166432') self.validator = validators.GetValidatorForChallenge(self.chall) self.validator.change_answer('secret123') self.team = models.Team.create('footeam') models.commit() def testNonceValidator_Basic(self): answer = self.validator.make_answer(1) self.assertTrue(self.validator.validate_answer(answer, self.team)) def testNonceValidator_Dupe(self): answer = self.validator.make_answer(5) self.assertTrue(self.validator.validate_answer(answer, self.team)) models.commit() self.assertTrue(self.validator.validate_answer(answer, self.team)) self.assertRaises(errors.IntegrityError, models.commit) ================================================ FILE: scoreboard/utils.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import datetime import flask import functools import hashlib import hmac import pytz import sys import time try: import urlparse except ImportError: from urllib import parse as urlparse from random import SystemRandom from scoreboard import errors from scoreboard import main app = main.get_app() random = SystemRandom() # Use dateutil if available try: from dateutil import parser as dateutil except ImportError: dateutil = None def is_logged_in(): try: return flask.g.uid is not None except AttributeError: return False def login_required(f): """Decorator to require login for a method.""" @functools.wraps(f) def wrapper(*args, **kwargs): if not is_logged_in(): raise errors.AccessDeniedError('You must be logged in.') return f(*args, **kwargs) return wrapper def admin_required(f): """Decorator to require admin for a method.""" @functools.wraps(f) def wrapper(*args, **kwargs): try: if not flask.g.admin: app.logger.error( 'Attempt by non-admin to access ' '@admin_required resource.') flask.abort(403) except AttributeError: app.logger.error( 'AttributeError by non-admin to access ' '@admin_required resource.') flask.abort(403) return f(*args, **kwargs) return login_required(wrapper) def team_required(f): """Require that they are a member of a team.""" @functools.wraps(f) def wrapper(*args, **kwargs): if not flask.g.tid: app.logger.warning( 'Team request received for player without team.') flask.abort(400) return f(*args, **kwargs) return login_required(wrapper) def is_admin(): """Check if current user is an admin.""" try: return flask.g.admin except AttributeError: return False def session_for_user(user): """Construct session for current user.""" flask.g.user = user flask.g.team = user.team flask.g.uid = user.uid flask.g.tid = user.team.tid if user.team else None flask.g.admin = user.admin flask.session['user'] = user.uid flask.session['team'] = user.team.tid if user.team else None flask.session['admin'] = user.admin expires = app.config.get('SESSION_EXPIRATION_SECONDS', 0) if expires: flask.session['expires'] = int(time.time() + expires) def get_required_field(name, verbose_name=None): """Retrieve a field or raise an error.""" try: return flask.request.form[name] except KeyError: verbose_name = verbose_name or name raise errors.ValidationError('%s is a required field.' % verbose_name) def parse_bool(b): b = b.lower() return b in ('true', '1') def compare_digest(a, b): """Intended to be a constant-time comparison.""" if hasattr(hmac, 'compare_digest'): return hmac.compare_digest(a, b) return hashlib.sha1(a).digest() == hashlib.sha1(b).digest() def absolute_url(path): """Build an absolute URL. Not safe for untrusted input.""" return urlparse.urljoin(flask.request.host_url, path) def generate_id(): """Generate a unique identifier for the database""" return int(random.getrandbits(48)) def normalize_input(answer): """Take a string and normalize it to a standard format.""" return answer.strip() def validate_proof_of_work(val, key, nbits): """Assert that the proof of work function has nbits 0s. The key should be urlsafe-base64 encoded. """ key = urlsafe_b64decode_nopadding(key) if len(key) < 32: return False val = to_bytes(val) mac = hmac.new(key, val, digestmod=hashlib.sha256).digest() def _ord(v): if isinstance(v, int): return v return ord(v) while nbits >= 8: if _ord(mac[0]) != 0: return False nbits -= 8 mac = mac[1:] if nbits: mask = 2**nbits - 1 if _ord(mac[0]) & mask: return False return True def urlsafe_b64decode_nopadding(val): """Deal with unpadded urlsafe base64.""" # Yes, it accepts extra = characters. return base64.urlsafe_b64decode(str(val) + '===') def to_bytes(val): if sys.version_info.major == 3: if isinstance(val, str): return bytes(val, 'utf-8') if isinstance(val, bytes): return val else: if isinstance(val, unicode): # noqa: F821 return val.encode('utf-8') return val class GameTime(object): """Manage start/end times for the game.""" @classmethod def setup(cls): """Get start and end time.""" cls.start, cls.end = app.config.get('GAME_TIME') if isinstance(cls.start, str): cls.start = cls._parsedate(cls.start) if isinstance(cls.end, str): cls.end = cls._parsedate(cls.end) @classmethod def countdown(cls, end=False): """Time remaining to start or end.""" until = cls.end if end else cls.start if until is None: return None return until - datetime.datetime.utcnow() @classmethod def state(cls, now=None): if not now: now = datetime.datetime.utcnow() if cls.start and cls.start > now: return 'BEFORE' if cls.end and cls.end < now: return 'AFTER' return 'DURING' @classmethod def open(cls, after_end=False): """Is the game open? If after_end, keeps open.""" state = cls.state() if state == 'DURING' or (after_end and state == 'AFTER'): return True return False @classmethod def over(cls): """Has the game ended?""" state = cls.state() return state == 'AFTER' @classmethod def require_open(cls, f, after_end=False, or_admin=True): """Decorator for requiring the game is open.""" @functools.wraps(f) def wrapper(*args, **kwargs): if (cls.open(after_end) or (or_admin and flask.g.admin)): return f(*args, **kwargs) raise errors.AccessDeniedError(cls.message()) return wrapper @classmethod def require_started(cls, f): """Decorator for requiring the game has started.""" return cls.require_open(f, after_end=True) @classmethod def require_not_started(cls, f): """Decorator for requiring the game has not started.""" @functools.wraps(f) def wrapper(*args, **kwargs): if cls.state() == "BEFORE": return f(*args, **kwargs) raise errors.AccessDeniedError(cls.message()) return wrapper @classmethod def require_submittable(cls, f): """Decorator for requiring that the game may be submitted to.""" return cls.require_open( f, after_end=app.config.get('SUBMIT_AFTER_END')) @classmethod def message(cls): state = cls.state() if state == 'BEFORE': return 'Game begins in %s.' % str(cls.countdown()) if state == 'AFTER': return 'Game is over.' return '%s left in the game.' % str(cls.countdown(end=True)) @staticmethod def _parsedate(datestr): """Return a UTC non-TZ-aware datetime from a string.""" if dateutil: dt = dateutil.parse(datestr) if dt.tzinfo: dt = dt.astimezone(pytz.UTC).replace(tzinfo=None) return dt # TODO: parse with strptime raise RuntimeError('No parser available.') GameTime.setup() require_gametime = GameTime.require_open require_started = GameTime.require_started require_not_started = GameTime.require_not_started require_submittable = GameTime.require_submittable ================================================ FILE: scoreboard/validators/__init__.py ================================================ # Copyright 2017 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from . import static_pbkdf2 from . import per_team from . import nonce from . import regex _Validators = { 'static_pbkdf2': static_pbkdf2.StaticPBKDF2Validator, 'static_pbkdf2_ci': static_pbkdf2.CaseStaticPBKDF2Validator, 'per_team': per_team.PerTeamValidator, 'nonce_166432': nonce.Nonce_16_64_Base32_Validator, 'nonce_245632': nonce.Nonce_24_56_Base32_Validator, 'nonce_328832': nonce.Nonce_32_88_Base32_Validator, 'regex': regex.RegexValidator, 'regex_ci': regex.RegexCaseValidator, } def GetDefaultValidator(): return 'static_pbkdf2' def GetValidatorForChallenge(challenge): cls = _Validators[challenge.validator] return cls(challenge) def ValidatorNames(): return {k: getattr(v, 'name', k) for k, v in _Validators.items()} def ValidatorMeta(): meta = {} for k, v in _Validators.items(): meta[k] = { 'name': v.name, 'per_team': v.per_team, 'flag_gen': v.flag_gen, } return meta def IsValidator(name): return name in _Validators __all__ = [GetValidatorForChallenge, ValidatorNames] ================================================ FILE: scoreboard/validators/base.py ================================================ # Copyright 2017 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. class BaseValidator(object): # Can we generate these flags? flag_gen = False # Is this flag per team? per_team = False def __init__(self, challenge): self.challenge = challenge def validate_answer(self, answer, team): """Validate the answer for the team.""" raise NotImplementedError( '%s does not implement validate_answer.' % type(self).__name__) def change_answer(self, answer): """Change the answer for the challenge.""" self.challenge.answer_hash = answer ================================================ FILE: scoreboard/validators/nonce.py ================================================ # Copyright 2019 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import division import base64 import hashlib import hmac import struct from scoreboard import main from scoreboard import utils from scoreboard import models from . import base app = main.get_app() class BaseNonceValidator(base.BaseValidator): # Bits to use for each of the nonce and authenticator NONCE_BITS = 0 AUTHENTICATOR_BITS = 0 HASH = hashlib.sha256 def __init__(self, *args, **kwargs): super(BaseNonceValidator, self).__init__(*args, **kwargs) if not self.NONCE_BITS or self.NONCE_BITS % 8: raise ValueError('NONCE_BITS must be non-0 and a multiple of 8.') if not self.AUTHENTICATOR_BITS or self.AUTHENTICATOR_BITS % 8: raise ValueError( 'AUTHENTICATOR_BITS must be non-0 and a multiple of 8.') @staticmethod def _decode(buf): raise NotImplementedError('Must implement decode.') @staticmethod def _encode(buf): raise NotImplementedError('Must implement encode.') def validate_answer(self, answer, team): """Validate the nonce-based flag.""" try: decoded_answer = self._decode(answer) except TypeError: app.logger.error('Invalid padding for answer.') return False if len(decoded_answer) != ( self.NONCE_BITS + self.AUTHENTICATOR_BITS) // 8: app.logger.error('Invalid length of decoded answer in %s', type(self).__name__) return False nonce = decoded_answer[:self.NONCE_BITS//8] authenticator = decoded_answer[self.NONCE_BITS//8:] if not utils.compare_digest(authenticator, self.compute_authenticator(nonce)): app.logger.error('Invalid nonce flag: %s', answer) return False # At this point, it's a valid flag, but need to check for reuse. # We do this by inserting and primary key checks will fail in the # commit phase. if team: models.NonceFlagUsed.create( self.challenge, self.unpack_nonce(nonce), team) return True def compute_authenticator(self, nonce): """Compute the authenticator part for a nonce.""" mac = hmac.new( self.challenge.answer_hash.encode('utf-8'), nonce, digestmod=self.HASH).digest() return mac[:self.AUTHENTICATOR_BITS//8] def make_answer(self, nonce): """Compute the whole answer for a nonce.""" if isinstance(nonce, int): nonce = struct.pack('>Q', nonce) nonce = nonce[8 - (self.NONCE_BITS // 8):] if len(nonce) != self.NONCE_BITS // 8: raise ValueError('nonce is wrong length!') return self._encode(nonce + self.compute_authenticator(nonce)) @classmethod def unpack_nonce(cls, nonce): pad = b'\x00' * (8 - cls.NONCE_BITS // 8) return struct.unpack('>Q', pad + nonce)[0] class Base32Validator(BaseNonceValidator): def __init__(self, *args, **kwargs): if (self.NONCE_BITS + self.AUTHENTICATOR_BITS) % 5 != 0: raise ValueError('Length must be a mulitple of 5 bits.') super(Base32Validator, self).__init__(*args, **kwargs) @staticmethod def _encode(buf): return base64.b32encode(buf) @staticmethod def _decode(buf): buf = utils.to_bytes(buf) return base64.b32decode(buf, casefold=True, map01='I') class Nonce_16_64_Base32_Validator(Base32Validator): name = 'Nonce: 16 bits, 64 bit validator, Base32 encoded' NONCE_BITS = 16 AUTHENTICATOR_BITS = 64 class Nonce_24_56_Base32_Validator(Base32Validator): name = 'Nonce: 24 bits, 56 bit validator, Base32 encoded' NONCE_BITS = 24 AUTHENTICATOR_BITS = 56 class Nonce_32_88_Base32_Validator(Base32Validator): name = 'Nonce: 32 bits, 88 bit validator, Base32 encoded' NONCE_BITS = 32 AUTHENTICATOR_BITS = 88 ================================================ FILE: scoreboard/validators/per_team.py ================================================ # Copyright 2017 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import hashlib import hmac from scoreboard import utils from scoreboard.validators import base class PerTeamValidator(base.BaseValidator): """Creates a flag that's per-team.""" name = 'Per-Team' flag_gen = True per_team = True def validate_answer(self, answer, team): if not team: return False return utils.compare_digest( self.construct_mac(team), answer) def construct_mac(self, team): if not isinstance(team, str): if not isinstance(team, int): team = team.tid team = str(team) mac = hmac.new( self.challenge.answer_hash, team, digestmod=hashlib.sha1) return mac.hexdigest() ================================================ FILE: scoreboard/validators/regex.py ================================================ # Copyright 2018 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import re from scoreboard.validators import base class RegexValidator(base.BaseValidator): """Regex-based validator. Note that validation based on a regex is inherently subject to timing attacks. If this is important to you, you should use a validator like static_pbkdf2. """ name = 'Regular Expression' re_flags = 0 def validate_answer(self, answer, unused_team): m = re.match(self.challenge.answer_hash, answer, flags=self.re_flags) if m: return m.group(0) == answer return False class RegexCaseValidator(RegexValidator): """Case-insensitive regex match.""" name = 'Regular Expression (Case Insensitive)' re_flags = re.IGNORECASE ================================================ FILE: scoreboard/validators/static_pbkdf2.py ================================================ # Copyright 2017 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import pbkdf2 from scoreboard import utils from scoreboard.validators import base class StaticPBKDF2Validator(base.BaseValidator): """PBKDF2-based secrets, everyone gets the same flag.""" name = 'Static' def validate_answer(self, answer, unused_team): if not self.challenge.answer_hash: return False return utils.compare_digest( pbkdf2.crypt(answer, self.challenge.answer_hash), self.challenge.answer_hash) def change_answer(self, answer): self.challenge.answer_hash = pbkdf2.crypt(answer) class CaseStaticPBKDF2Validator(StaticPBKDF2Validator): """PBKDF2-based secrets, case insensitive.""" name = 'Static (Case Insensitive)' def validate_answer(self, answer, team): if not isinstance(answer, str): return False return super(CaseStaticPBKDF2Validator, self).validate_answer( answer.lower(), team) def change_answer(self, answer): return super(CaseStaticPBKDF2Validator, self).change_answer( answer.lower()) ================================================ FILE: scoreboard/views.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import flask import os from werkzeug import exceptions from scoreboard import attachments from scoreboard import main from scoreboard import models app = main.get_app() _VIEW_CACHE = {} @app.errorhandler(404) def handle_404(ex): """Handle 404s, sending index.html for unhandled paths.""" path = flask.request.path[1:] try: return app.send_static_file(path) except (exceptions.NotFound, UnicodeEncodeError): if '.' not in path and not path.startswith('api/'): app.logger.info('%s -> index.html', path) return render_index() return '404 Not Found', 404 # Needed because emails with a "." in them prevent 404 handler from working @app.route('/pwreset/') def render_pwreset(unused): return render_index() @app.route('/') @app.route('/index.html') def render_index(): """Render index. Do not include any user-controlled content to avoid XSS! """ try: tmpl = _VIEW_CACHE['index'] except KeyError: minify = not app.debug and os.path.exists( os.path.join(app.static_folder, 'js/app.min.js')) tmpl = flask.render_template('index.html', minify=minify) _VIEW_CACHE['index'] = tmpl resp = flask.make_response(tmpl, 200) if flask.request.path.startswith('/scoreboard'): resp.headers.add('X-FRAME-OPTIONS', 'ALLOW') return resp @app.route('/attachment/') def download(filename): """Download an attachment.""" attachment = models.Attachment.query.get_or_404(filename) cuser = models.User.current() valid = cuser and cuser.admin for ch in attachment.challenges: if ch.unlocked: valid = True break if not valid: flask.abort(404) app.logger.info('Download of %s by %r.', attachment, cuser or "Anonymous") return attachments.backend.send(attachment) @app.route('/createdb') def createdb(): """Create database schema without CLI access. Useful for AppEngine and other container environments. Should be safe to be exposed, as operation is idempotent and does not clear any data. """ try: models.db.create_all() return 'Tables created.' except Exception as ex: app.logger.exception('Failed creating tables: %s', str(ex)) return 'Failed creating tables: see log.' ================================================ FILE: scoreboard/wsgi.py ================================================ # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from scoreboard import main app = main.get_app() main.load_config_file(app) # These must be after config loading from scoreboard import rest # noqa: E402 from scoreboard import views # noqa: E402 # Used here to catch accidental removal _modules_for_views = (rest, views) ================================================ FILE: static/css/.keep ================================================ ================================================ FILE: static/js/Chart.Step.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Step chart derived from Chart.Scatter.js (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define(['Chart'], factory); } else if (typeof exports === 'object') { // Node. Does not work with strict CommonJS, but // only CommonJS-like environments that support module.exports, // like Node. factory(require('Chart')); } else { // Browser globals (root is window) factory(root.Chart); } }(this, function (chartjs) { "use strict"; var helpers = chartjs.helpers, hlp = { formatDateValue: function (date, tFormat, dFormat, useUtc) { date = new Date(+date); var ms = useUtc ? date.getUTCMilliseconds() : date.getMilliseconds(); if (ms) { return ('000' + ms).slice(-3); } var hasTime = useUtc ? date.getUTCHours() + date.getUTCMinutes() + date.getUTCSeconds() : date.getHours() + date.getMinutes() + date.getSeconds(); if (hasTime) { return dateFormat(date, tFormat || "h:MM", useUtc); } else { return dateFormat(date, dFormat || "mmm d", useUtc); } }, getElementOrDefault: function (array, index, defaultValue) { return index >= 0 && index < array.length ? array[index] : defaultValue; }, applyRange: function (value, min, max) { return value > max ? max : value < min ? min : value; }, StepPoint: chartjs.Point.extend({ inRange: function (chartX, chartY) { var hitDetectionRange = this.hitDetectionRadius + this.radius * this.size; return ((Math.pow(chartX - this.x, 2) + Math.pow(chartY - this.y, 2)) < Math.pow(hitDetectionRange, 2)); }, draw: function () { if (this.display && this.size > 0) { var ctx = this.ctx; ctx.beginPath(); ctx.arc(this.x, this.y, this.size * this.radius, 0, Math.PI * 2); ctx.closePath(); ctx.strokeStyle = this.strokeColor; ctx.lineWidth = this.strokeWidth; ctx.fillStyle = this.fillColor; ctx.fill(); ctx.stroke(); } } }) }; var dateFormat = function () { var token = /d{1,4}|m{1,4}|yy(?:yy)?|([HhMsTt])\1?|[LloSZ]|"[^"]*"|'[^']*'/g, timezone = /\b(?:[PMCEA][SDP]T|(?:Pacific|Mountain|Central|Eastern|Atlantic) (?:Standard|Daylight|Prevailing) Time|(?:GMT|UTC)(?:[-+]\d{4})?)\b/g, timezoneClip = /[^-+\dA-Z]/g, pad = function (val, len) { val = String(val); len = len || 2; while (val.length < len) val = "0" + val; return val; }, masks = { "default": "ddd mmm dd yyyy HH:MM:ss" }, i18n = { dayNames: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ], monthNames: [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ] }; // Regexes and supporting functions are cached through closure return function (date, mask, utc) { // You can't provide utc if you skip other args (use the "UTC:" mask prefix) if (arguments.length == 1 && Object.prototype.toString.call(date) == "[object String]" && !/\d/.test(date)) { mask = date; date = undefined; } // Passing date through Date applies Date.parse, if necessary date = date ? new Date(date) : new Date; if (isNaN(date)) throw SyntaxError("invalid date"); mask = String(masks[mask] || mask || masks["default"]); // Allow setting the utc argument via the mask if (mask.slice(0, 4) == "UTC:") { mask = mask.slice(4); utc = true; } var _ = utc ? "getUTC" : "get", d = date[_ + "Date"](), D = date[_ + "Day"](), m = date[_ + "Month"](), y = date[_ + "FullYear"](), H = date[_ + "Hours"](), M = date[_ + "Minutes"](), s = date[_ + "Seconds"](), L = date[_ + "Milliseconds"](), o = utc ? 0 : date.getTimezoneOffset(), flags = { d: d, dd: pad(d), ddd: i18n.dayNames[D], dddd: i18n.dayNames[D + 7], m: m + 1, mm: pad(m + 1), mmm: i18n.monthNames[m], mmmm: i18n.monthNames[m + 12], yy: String(y).slice(2), yyyy: y, h: H % 12 || 12, hh: pad(H % 12 || 12), H: H, HH: pad(H), M: M, MM: pad(M), s: s, ss: pad(s), l: pad(L, 3), L: pad(L > 99 ? Math.round(L / 10) : L), t: H < 12 ? "a" : "p", tt: H < 12 ? "am" : "pm", T: H < 12 ? "A" : "P", TT: H < 12 ? "AM" : "PM", Z: utc ? "UTC" : (String(date).match(timezone) || [""]).pop().replace(timezoneClip, ""), o: (o > 0 ? "-" : "+") + pad(Math.floor(Math.abs(o) / 60) * 100 + Math.abs(o) % 60, 4), S: ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 != 10) * d % 10] }; return mask.replace(token, function ($0) { return $0 in flags ? flags[$0] : $0.slice(1, $0.length - 1); }); }; }(); var defaultConfig = { // INHERIT // showScale: true, // Boolean - If we should show the scale at all // scaleLineColor: "rgba(0,0,0,.1)", // String - Colour of the scale line // scaleLineWidth: 1, // Number - Pixel width of the scale line // scaleShowLabels: true, // Boolean - Whether to show labels on the scale // scaleLabel: "<%=value%>", // Interpolated JS string - can access value scaleArgLabel: "<%=value%>", // Interpolated JS string - can access value scaleSizeLabel: "<%=value%>", // Interpolated JS string - can access value emptyDataMessage: "chart has no data", // String - Message for empty data // SCALE scaleShowGridLines: true, //Boolean - Whether grid lines are shown across the chart scaleGridLineWidth: 1, //Number - Width of the grid lines scaleGridLineColor: "rgba(0,0,0,.05)", //String - Colour of the grid lines scaleShowHorizontalLines: true, //Boolean - Whether to show horizontal lines (except X axis) scaleShowVerticalLines: true, //Boolean - Whether to show vertical lines (except Y axis) // DATE SCALE scaleType: "number", useUtc: true, scaleDateFormat: "mmm d", scaleTimeFormat: "h:MM", scaleDateTimeFormat: "mmm d, yyyy, hh:MM", // LINES datasetStroke: true, // Boolean - Whether to show a stroke for datasets datasetStrokeWidth: 2, // Number - Pixel width of dataset stroke datasetStrokeColor: '#007ACC', // String - Color of dataset stroke datasetPointStrokeColor: 'white', // String - Color of dataset stroke continueToEnd: true, // Keep lines across to end // POINTS pointDot: true, // Boolean - Whether to show a dot for each point pointDotStrokeWidth: 1, // Number - Pixel width of point dot stroke pointDotRadius: 4, // Number - Radius of each point dot in pixels pointHitDetectionRadius: 4, // Number - amount extra to add to the radius to cater for hit detection outside the drawn point multiTooltipTemplate: "<%=argLabel%>; <%=valueLabel%>", tooltipTemplate: "<%if (datasetLabel){%><%=datasetLabel%>: <%}%><%=argLabel%>; <%=valueLabel%>", legendTemplate: "
    -legend\"><%for(var i=0;i
  • -legend-marker\" style=\"background-color:<%=datasets[i].strokeColor%>\"><%=datasets[i].label%>
  • <%}%>
" }; chartjs.StepNumberScale = chartjs.Element.extend({ initialize: function () { this.font = helpers.fontString(this.fontSize, this.fontStyle, this.fontFamily); this.padding = this.fontSize / 2; }, setDataRange: function (dataRange) { this.dataRange = dataRange; }, api: { generateLabels: function (templateString, numberOfSteps, graphMin, stepValue) { var labelsArray = new Array(numberOfSteps + 1), stepDecimalPlaces = helpers.getDecimalPlaces(stepValue); if (templateString) { helpers.each(labelsArray, function (val, index) { labelsArray[index] = helpers.template(templateString, { value: (graphMin + (stepValue * (index))).toFixed(stepDecimalPlaces) }); }); } return labelsArray; } }, calculateYscaleRange: function () { if (this.scaleOverride) { this.yScaleRange = { steps: this.scaleSteps, stepValue: this.scaleStepWidth, min: this.scaleStartValue, max: this.scaleStartValue + (this.scaleSteps * this.scaleStepWidth) }; } else { this.yScaleRange = helpers.calculateScaleRange( [this.dataRange.ymin, this.dataRange.ymax], this.chart.height, this.fontSize, this.beginAtZero, // beginAtZero, this.integersOnly); // integersOnly } }, calculateXscaleRange: function () { if (this.xScaleOverride) { this.xScaleRange = { steps: this.xScaleSteps, stepValue: this.xScaleStepWidth, min: this.xScaleStartValue, max: this.xScaleStartValue + (this.xScaleSteps * this.xScaleStepWidth) }; } else { this.xScaleRange = helpers.calculateScaleRange( [this.dataRange.xmin, this.dataRange.xmax], this.chart.width, this.fontSize, false, // beginAtZero, true); // integersOnly } }, generateYLabels: function () { this.yLabels = this.api.generateLabels( this.labelTemplate, this.yScaleRange.steps, this.yScaleRange.min, this.yScaleRange.stepValue); }, generateXLabels: function () { this.xLabels = this.api.generateLabels( this.argLabelTemplate, this.xScaleRange.steps, this.xScaleRange.min, this.xScaleRange.stepValue); }, argToString: function (arg) { return +arg + ""; }, fit: function () { // labels & padding this.calculateYscaleRange(); this.calculateXscaleRange(); this.generateYLabels(); this.generateXLabels(); var xLabelMaxWidth = helpers.longestText(this.chart.ctx, this.font, this.xLabels); var yLabelMaxWidth = helpers.longestText(this.chart.ctx, this.font, this.yLabels); this.xPadding = this.display && this.showLabels ? yLabelMaxWidth + this.padding * 2 : this.padding; var xStepWidth = Math.floor((this.chart.width - this.xPadding) / this.xScaleRange.steps); var xLabelHeight = this.fontSize * 1.5; this.xLabelRotation = xLabelMaxWidth > xStepWidth; this.xPaddingRight = this.display && this.showLabels && !this.xLabelRotation ? xLabelMaxWidth / 2 : this.padding; this.yPadding = this.display && this.showLabels ? (this.xLabelRotation ? xLabelMaxWidth : xLabelHeight) + this.padding * 2 : this.padding; }, updatePoints: function (dataSetPoints, ease) { for (var i = 0; i < dataSetPoints.length; i++) { var current = dataSetPoints[i]; current.x = this.calculateX(current.arg); current.y = this.calculateY(current.value, ease); } }, calculateX: function (x) { return this.xPadding + ((x - this.xScaleRange.min) * (this.chart.width - this.xPadding - this.xPaddingRight) / (this.xScaleRange.max - this.xScaleRange.min)); }, calculateY: function (y, ease) { return this.chart.height - this.yPadding - ((y - this.yScaleRange.min) * (this.chart.height - this.yPadding - this.padding) / (this.yScaleRange.max - this.yScaleRange.min)) * (ease || 1); }, draw: function () { var ctx = this.chart.ctx, value, index; if (this.display) { var xpos1 = this.calculateX(this.xScaleRange.min); var xpos2 = this.chart.width; var ypos1 = this.calculateY(this.yScaleRange.min); var ypos2 = 0; // y axis for (index = 0, value = this.yScaleRange.min; index <= this.yScaleRange.steps; index++, value += this.yScaleRange.stepValue) { var ypos = this.calculateY(value); if (this.showLabels || this.showHorizontalLines) { // line color ctx.lineWidth = index == 0 ? this.lineWidth : this.gridLineWidth; ctx.strokeStyle = index == 0 ? this.lineColor : this.gridLineColor; ctx.beginPath(); ctx.moveTo(xpos1 - this.padding, ypos); ctx.lineTo(this.showHorizontalLines || index == 0 ? xpos2 : xpos1, ypos); ctx.stroke(); } // labels if (this.showLabels) { ctx.lineWidth = this.lineWidth; ctx.strokeStyle = this.lineColor; // text ctx.textAlign = "right"; ctx.textBaseline = "middle"; ctx.font = this.font; ctx.fillStyle = this.textColor; ctx.fillText(this.yLabels[index], xpos1 - this.padding * 1.4, ypos); } } // x axis for (index = 0, value = this.xScaleRange.min; index <= this.xScaleRange.steps; index++, value += this.xScaleRange.stepValue) { var xpos = this.calculateX(value); if (this.showLabels || this.showVerticalLines) { // line color ctx.lineWidth = index == 0 ? this.lineWidth : this.gridLineWidth; ctx.strokeStyle = index == 0 ? this.lineColor : this.gridLineColor; ctx.beginPath(); ctx.moveTo(xpos, ypos1 + this.padding); ctx.lineTo(xpos, this.showVerticalLines || index == 0 ? ypos2 : ypos1); ctx.stroke(); } // labels if (this.showLabels) { ctx.lineWidth = this.lineWidth; ctx.strokeStyle = this.lineColor; // text ctx.save(); ctx.translate(xpos, ypos1 + (this.padding * 1.4)); ctx.rotate(this.xLabelRotation ? -Math.PI / 2 : 0); ctx.textAlign = (this.xLabelRotation) ? "right" : "center"; ctx.textBaseline = (this.xLabelRotation) ? "middle" : "top"; ctx.font = this.font; ctx.fillStyle = this.textColor; ctx.fillText(this.xLabels[index], 0, 0); ctx.restore(); } } } } }); chartjs.StepDateScale = chartjs.StepNumberScale.extend({ _calculateDateScaleRange: function (valueMin, valueMax, drawingSize, fontSize) { // todo: move to global object var units = [ { u: 1, c: 1, t: 1, n: 'ms' }, { u: 1, c: 2, t: 2, n: 'ms' }, { u: 1, c: 5, t: 5, n: 'ms' }, { u: 1, c: 10, t: 10, n: 'ms' }, { u: 1, c: 20, t: 20, n: 'ms' }, { u: 1, c: 50, t: 50, n: 'ms' }, { u: 1, c: 100, t: 100, n: 'ms' }, { u: 1, c: 200, t: 200, n: 'ms' }, { u: 1, c: 500, t: 500, n: 'ms' }, { u: 1000, c: 1, t: 1000, n: 's' }, { u: 1000, c: 2, t: 2000, n: 's' }, { u: 1000, c: 5, t: 5000, n: 's' }, { u: 1000, c: 10, t: 10000, n: 's' }, { u: 1000, c: 15, t: 15000, n: 's' }, { u: 1000, c: 20, t: 20000, n: 's' }, { u: 1000, c: 30, t: 30000, n: 's' }, { u: 60000, c: 1, t: 60000, n: 'm' }, { u: 60000, c: 2, t: 120000, n: 'm' }, { u: 60000, c: 5, t: 300000, n: 'm' }, { u: 60000, c: 10, t: 600000, n: 'm' }, { u: 60000, c: 15, t: 900000, n: 'm' }, { u: 60000, c: 20, t: 1200000, n: 'm' }, { u: 60000, c: 30, t: 1800000, n: 'm' }, { u: 3600000, c: 1, t: 3600000, n: 'h' }, { u: 3600000, c: 2, t: 7200000, n: 'h' }, { u: 3600000, c: 3, t: 10800000, n: 'h' }, { u: 3600000, c: 4, t: 14400000, n: 'h' }, { u: 3600000, c: 6, t: 21600000, n: 'h' }, { u: 3600000, c: 8, t: 28800000, n: 'h' }, { u: 3600000, c: 12, t: 43200000, n: 'h' }, { u: 86400000, c: 1, t: 86400000, n: 'd' }, { u: 86400000, c: 2, t: 172800000, n: 'd' }, { u: 86400000, c: 4, t: 345600000, n: 'd' }, { u: 86400000, c: 5, t: 432000000, n: 'd' }, { u: 604800000, c: 1, t: 604800000, n: 'w' }]; var maxSteps = drawingSize / (fontSize * 3.3); var valueRange = +valueMax - valueMin, offset = this.useUtc ? 0 : new Date().getTimezoneOffset() * 60000, min = +valueMin - offset, max = +valueMax - offset; var xp = 0, f = [2, 3, 5, 7, 10]; while (valueRange / units[xp].t > maxSteps) { xp++; if (xp == units.length) { var last = units[units.length - 1]; for (var fp = 0; fp < f.length; fp++) { units.push({ u: last.u, c: last.c * f[fp], t: last.c * f[fp] * last.u, n: last.n }); } } } var stepValue = units[xp].t, start = Math.floor(min / stepValue) * stepValue, stepCount = Math.ceil((max - start) / stepValue), end = start + stepValue * stepCount; return { min: start + offset, max: end + offset, steps: stepCount, stepValue: stepValue }; }, calculateXscaleRange: function () { this.xScaleRange = this._calculateDateScaleRange( this.dataRange.xmin, this.dataRange.xmax, this.chart.width, this.fontSize ); }, argToString: function (arg) { return dateFormat(+arg, this.dateTimeFormat, this.useUtc); }, generateXLabels: function () { var graphMin = this.xScaleRange.min, stepValue = this.xScaleRange.stepValue, labelsArray = new Array(this.xScaleRange.steps + 1); helpers.each(labelsArray, function (val, index) { var value = graphMin + stepValue * index; labelsArray[index] = hlp.formatDateValue(value, this.timeFormat, this.dateFormat, this.useUtc); }, this); this.xLabels = labelsArray; } }); chartjs.StepDataSet = (function () { var datasetCtr = function (datasetOptions, chartOptions, chart, scale) { this.chart = chart; this.scale = scale; this.label = datasetOptions.label || null; this.strokeColor = datasetOptions.strokeColor || chartOptions.datasetStrokeColor; this.pointColor = datasetOptions.pointColor || datasetOptions.strokeColor || chartOptions.datasetStrokeColor; this.pointStrokeColor = datasetOptions.pointStrokeColor || chartOptions.datasetPointStrokeColor; this.pointDot = chartOptions.pointDot; this.pointDotRadius = chartOptions.pointDotRadius; this.pointHitDetectionRadius = chartOptions.pointHitDetectionRadius; this.pointDotStrokeWidth = chartOptions.pointDotStrokeWidth; this.scaleArgLabel = chartOptions.scaleArgLabel; this.scaleLabel = chartOptions.scaleLabel; this.scaleSizeLabel = chartOptions.scaleSizeLabel; this.points = []; }; datasetCtr.prototype.addPoint = function (x, y, r) { // default size r = arguments.length < 3 ? 1 : r; var point = this._createNewPoint(); this._setPointData(point, x, y, r); this.points.push(point); }; datasetCtr.prototype.setPointData = function (index, x, y, r) { // default size r = arguments.length < 4 ? 1 : r; var point = hlp.getElementOrDefault(this.points, index); if (point) { this._setPointData(point, x, y, r); } }; datasetCtr.prototype.removePoint = function (index) { if (index >= 0 && index < this.points.length) { this.points.splice(index, 1); } }; datasetCtr.prototype._createNewPoint = function () { return new hlp.StepPoint({ ctx: this.chart.ctx, datasetLabel: this.label, // point display: this.pointDot, radius: this.pointDotRadius, hitDetectionRadius: this.pointHitDetectionRadius, strokeWidth: this.pointDotStrokeWidth, // colors strokeColor: this.pointStrokeColor, highlightStroke: this.pointColor, fillColor: this.pointColor, highlightFill: this.pointStrokeColor }); }; datasetCtr.prototype._setPointData = function (point, x, y, r) { var formattedArg = this.scale.argToString(+x), formattedValue = +y + "", formattedSize = +r + ""; point.arg = +x; point.value = +y; point.size = +r; // for use in templates point.argLabel = helpers.template(this.scaleArgLabel, { value: formattedArg }), point.valueLabel = helpers.template(this.scaleLabel, { value: formattedValue }); point.sizeLabel = helpers.template(this.scaleSizeLabel, { value: formattedSize }); }; return datasetCtr; })(); chartjs.Type.extend({ name: "Step", defaults: defaultConfig, initialize: function (datasets) { this.hasData = false; this.datasets = []; this.scale = this._initScale(); // Compatibility layer if (datasets.datasets) { datasets = datasets.datasets; } //Iterate through each of the datasets, and build this into a property of the chart helpers.each(datasets, function (dataset) { var datasetObject = new chartjs.StepDataSet(dataset, this.options, this.chart, this.scale); this.datasets.push(datasetObject); this.hasData |= !!dataset.data.length; helpers.each(dataset.data, function (dataPoint) { datasetObject.addPoint(dataPoint.x, dataPoint.y, dataPoint.r || 1); }); }, this); //Set up tooltip events on the chart if (this.options.showTooltips) { helpers.bindEvents(this, this.options.tooltipEvents, function (evt) { var activePoints = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; this._forEachPoint(function (point) { point.restore(['fillColor', 'strokeColor']); }); helpers.each(activePoints, function (activePoint) { activePoint.fillColor = activePoint.highlightFill; activePoint.strokeColor = activePoint.highlightStroke; }); this.showTooltip(activePoints); }); } var dataRange = this._calculateRange(); this.scale.setDataRange(dataRange); this.update(); }, _initScale: function () { var scaleOptions = { chart: this.chart, textColor: this.options.scaleFontColor, fontSize: this.options.scaleFontSize, fontStyle: this.options.scaleFontStyle, fontFamily: this.options.scaleFontFamily, labelTemplate: this.options.scaleLabel, argLabelTemplate: this.options.scaleArgLabel, showLabels: this.options.scaleShowLabels, beginAtZero: this.options.scaleBeginAtZero, integersOnly: this.options.scaleIntegersOnly, gridLineWidth: (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, gridLineColor: (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", showHorizontalLines: this.options.scaleShowHorizontalLines, showVerticalLines: this.options.scaleShowVerticalLines, lineWidth: this.options.scaleLineWidth, lineColor: this.options.scaleLineColor, display: this.options.showScale, // y range xScaleOverride: this.options.xScaleOverride, xScaleSteps: this.options.xScaleSteps, xScaleStepWidth: this.options.xScaleStepWidth, xScaleStartValue: this.options.xScaleStartValue, // y range scaleOverride: this.options.scaleOverride, scaleSteps: this.options.scaleSteps, scaleStepWidth: this.options.scaleStepWidth, scaleStartValue: this.options.scaleStartValue, // dates useUtc: this.options.useUtc, dateFormat: this.options.scaleDateFormat, timeFormat: this.options.scaleTimeFormat, dateTimeFormat: this.options.scaleDateTimeFormat }; return this.options.scaleType === "date" ? new chartjs.StepDateScale(scaleOptions) : new chartjs.StepNumberScale(scaleOptions); }, // helpers getPointsAtEvent: function (e) { var pointsArray = [], eventPosition = helpers.getRelativePosition(e); helpers.each(this.datasets, function (dataset) { helpers.each(dataset.points, function (point) { if (point.inRange(eventPosition.x, eventPosition.y)) pointsArray.push(point); }); }, this); return pointsArray; }, showTooltip: function (elements) { this.draw(); if (elements.length > 0) { var firstElement = elements[0]; var tooltipPosition = firstElement.tooltipPosition(); if (elements.length == 1) { new chartjs.Tooltip({ x: Math.round(tooltipPosition.x), y: Math.round(tooltipPosition.y), xPadding: this.options.tooltipXPadding, yPadding: this.options.tooltipYPadding, fillColor: this.options.tooltipFillColor, textColor: this.options.tooltipFontColor, fontFamily: this.options.tooltipFontFamily, fontStyle: this.options.tooltipFontStyle, fontSize: this.options.tooltipFontSize, caretHeight: this.options.tooltipCaretSize, cornerRadius: this.options.tooltipCornerRadius, text: helpers.template(this.options.tooltipTemplate, firstElement), chart: this.chart, custom: this.options.customTooltips }).draw(); } else { var tooltipLabels = [], tooltipColors = []; helpers.each(elements, function (point) { tooltipLabels.push(helpers.template(this.options.multiTooltipTemplate, point)); tooltipColors.push({ fill: point._saved.fillColor || point.fillColor, stroke: point._saved.strokeColor || point.strokeColor }); }, this); new chartjs.MultiTooltip({ x: Math.round(tooltipPosition.x), y: Math.round(tooltipPosition.y), xPadding: this.options.tooltipXPadding, yPadding: this.options.tooltipYPadding, xOffset: this.options.tooltipXOffset, fillColor: this.options.tooltipFillColor, textColor: this.options.tooltipFontColor, fontFamily: this.options.tooltipFontFamily, fontStyle: this.options.tooltipFontStyle, fontSize: this.options.tooltipFontSize, titleTextColor: this.options.tooltipTitleFontColor, titleFontFamily: this.options.tooltipTitleFontFamily, titleFontStyle: this.options.tooltipTitleFontStyle, titleFontSize: this.options.tooltipTitleFontSize, cornerRadius: this.options.tooltipCornerRadius, labels: tooltipLabels, legendColors: tooltipColors, legendColorBackground: this.options.multiTooltipKeyBackground, title: '', chart: this.chart, ctx: this.chart.ctx, custom: this.options.customTooltips }).draw(); } } return this; }, _forEachPoint: function (callback) { helpers.each(this.datasets, function (dataset) { helpers.each(dataset.points, callback, this); }, this); }, _forEachDataset: function (callback) { helpers.each(this.datasets, callback, this); }, _calculateRange: function () { var xmin = undefined, xmax = undefined, ymin = undefined, ymax = undefined; this._forEachPoint(function (point) { // min x if (xmin === undefined || point.arg < xmin) { xmin = point.arg; } // max x if (xmax === undefined || point.arg > xmax) { xmax = point.arg; } // min y if (ymin === undefined || point.value < ymin) { ymin = point.value; } // max y if (ymax === undefined || point.value > ymax) { ymax = point.value; } }); return { xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax } }, _drawMessage: function (message) { var ctx = this.chart.ctx, width = this.chart.width, height = this.chart.height, fontSize = this.options.scaleFontSize, fontStyle = this.options.scaleFontStyle, fontFamily = this.options.scaleFontFamily, font = helpers.fontString(fontSize, fontStyle, fontFamily); // text ctx.save(); ctx.translate(width / 2, height / 2); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = font; ctx.fillStyle = this.options.scaleFontColor; ctx.fillText(message, 0, 0); ctx.restore(); }, _drawLine: function (dataset) { var ctx = this.chart.ctx, prev = undefined; ctx.lineJoin = "round"; ctx.lineWidth = this.options.datasetStrokeWidth; ctx.strokeStyle = dataset.strokeColor || this.options.datasetStrokeColor; ctx.beginPath(); helpers.each(dataset.points, function (point, index) { if (index === 0) { ctx.moveTo(point.x, point.y); } else { // First move in x, then y ctx.lineTo(point.x, prev.y); ctx.lineTo(point.x, point.y); } prev = point; }, this); if (this.options.continueToEnd) ctx.lineTo(this.chart.width, prev.y); ctx.stroke(); }, update: function () { var dataRange = this._calculateRange(); this.scale.setDataRange(dataRange); this.render(); }, draw: function (ease) { if (this.hasData) { // update view params this.scale.fit(); this._forEachDataset(function (dataset) { this.scale.updatePoints(dataset.points, ease); }); // draw this.clear(); this.scale.draw(); // draw lines if (this.options.datasetStroke) { helpers.each(this.datasets, this._drawLine, this); } // draw points if (this.options.pointDot) { this._forEachPoint(function (point) { point.draw(); }); } } else { this.clear(); this._drawMessage(this.options.emptyDataMessage); } } }); })); ================================================ FILE: static/js/app.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var scoreboardApp = angular.module('scoreboardApp', [ 'ngRoute', 'ngSanitize', 'adminChallengeCtrls', 'adminNewsCtrls', 'adminPageCtrls', 'adminTeamCtrls', 'adminToolCtrls', 'challengeCtrls', 'globalCtrls', 'pageCtrls', 'regCtrls', 'scoreboardCtrls', 'teamCtrls', 'sbDirectives', 'sbFilters' ]); scoreboardApp.config([ '$routeProvider', '$locationProvider', function($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider. when('/', { templateUrl: '/partials/page.html', controller: 'StaticPageCtrl' }). when('/login', { templateUrl: '/partials/login.html', controller: 'LoginCtrl' }). when('/logout', { templateUrl: '/partials/login.html', controller: 'LoginCtrl' }). when('/register', { templateUrl: '/partials/register.html', controller: 'RegistrationCtrl' }). when('/profile', { templateUrl: '/partials/profile.html', controller: 'ProfileCtrl' }). when('/challenges/', { templateUrl: '/partials/challenge_grid.html', controller: 'ChallengeGridCtrl' }). when('/scoreboard', { templateUrl: '/partials/scoreboard.html', controller: 'ScoreboardCtrl' }). when('/teams/:tid', { templateUrl: '/partials/team.html', controller: 'TeamPageCtrl' }). when('/pwreset/:email/:token', { templateUrl: '/partials/pwreset.html', controller: 'PasswordResetCtrl' }). when('/admin/tags', { templateUrl: '/partials/admin/tags.html', controller: 'AdminTagCtrl' }). when('/admin/attachments', { templateUrl: '/partials/admin/attachments.html', controller: 'AdminAttachmentCtrl' }). when('/admin/challenges/:cid?', { templateUrl: '/partials/admin/challenges.html', controller: 'AdminChallengesCtrl' }). when('/admin/challenge/:cid?', { templateUrl: '/partials/admin/challenge.html', controller: 'AdminChallengeCtrl' }). when('/admin/backups', { templateUrl: '/partials/admin/restore.html', controller: 'AdminRestoreCtrl' }). when('/admin/teams/:tid?', { templateUrl: '/partials/admin/teams.html', controller: 'AdminTeamsCtrl' }). when('/admin/users/:uid?', { templateUrl: '/partials/admin/users.html', controller: 'AdminUsersCtrl' }). when('/admin/news', { templateUrl: '/partials/admin/news.html', controller: 'AdminNewsCtrl' }). when('/admin/page/:path', { templateUrl: '/partials/admin/page.html', controller: 'AdminPageCtrl' }). when('/admin/pages', { templateUrl: '/partials/admin/pages.html', controller: 'AdminPagesCtrl' }). when('/admin/tools', { templateUrl: '/partials/admin/tools.html', controller: 'AdminToolCtrl' }). otherwise({ templateUrl: '/partials/page.html', controller: 'StaticPageCtrl' }); }]); scoreboardApp.run([ '$rootScope', 'loadingService', function($rootScope, loadingService) { $rootScope.$on('$locationChangeStart', function() { loadingService.start(); }); }]); var getInjector = function() { return angular.element('*[ng-app]').injector(); }; ================================================ FILE: static/js/controllers/admin/challenges.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var adminChallengeCtrls = angular.module('adminChallengeCtrls', [ 'ngResource', 'ngRoute', 'challengeServices', 'globalServices', 'sessionServices', 'uploadServices', ]); adminChallengeCtrls.controller('AdminTagCtrl', [ '$scope', 'tagService', 'errorService', 'sessionService', 'loadingService', function($scope, tagService, errorService, sessionService, loadingService) { if (!sessionService.requireAdmin()) return; $scope.tags = []; $scope.updateTag = function(tag) { errorService.clearErrors(); tagService.save({tagslug: tag.tagslug}, tag, function(data) { errorService.error(tag.name + ' updated.', 'success'); }, function(data) { errorService.error(data); }); }; $scope.deleteTag = function(tag) { errorService.clearErrors(); var name = tag.name; tagService.delete({tagslug: tag.tagslug}, function(data) { var idx = $scope.tags.indexOf(tag); $scope.tags.splice(idx, 1); errorService.error(name + ' deleted.', 'success'); }, function(data) { errorService.error(data); }); }; $scope.addTag = function() { errorService.clearErrors(); tagService.create({}, $scope.newTag, function(data) { $scope.tags.push(data); $scope.newTag = {}; }, function(data) { errorService.error(data); }); }; $scope.newTag = {}; $scope.invalidForm = function(idx) { var form = $(document.getElementsByName('adminTagForm[' + idx + ']')); return form.hasClass('ng-invalid'); }; sessionService.requireLogin(function() { errorService.clearErrors(); tagService.get( function(data) { $scope.tags = data.tags; loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); }); }]); adminChallengeCtrls.controller('AdminPagesCtrl', [ '$scope', 'pageService', 'errorService', 'sessionService', 'loadingService', function($scope, pageService, errorService, sessionService, loadingService) { if (!sessionService.requireAdmin()) return; $scope.active = {}; $scope.activate = function(p) { if (p.new == p.path) { $scope.active = p; $("#delete-confirm").modal("show"); } else { var oldPath = p.path; p.path = p.new; pageService.save({path: oldPath}, p) } } $scope.deleteActive = function() { pageService.delete({path: $scope.active.path}) $scope.pages.splice($scope.pages.indexOf($scope.active), 1) $scope.active = {} } sessionService.requireLogin(function() { errorService.clearErrors(); pageService.get( function(data) { $scope.pages = data.pages; for (var i = 0; i < $scope.pages.length; i++) { $scope.pages[i].new = $scope.pages[i].path; } loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); }); }]) adminChallengeCtrls.controller('AdminAttachmentCtrl', [ '$scope', 'attachService', 'errorService', 'sessionService', 'loadingService', 'uploadService', function($scope, attachService, errorService, sessionService, loadingService, uploadService) { if (!sessionService.requireAdmin()) return; $scope.attachments = []; $scope.updateAttachment = function(attachment, cb) { errorService.clearErrors(); attachService.save({aid: attachment.aid}, attachment, function(data) { errorService.error(attachment.filename + ' updated.', 'success'); if (cb) cb(data); }, function(data) { errorService.error(data); }); }; $scope.deleteAttachment = function(attachment) { errorService.clearErrors(); var filename = attachment.filename; attachService.delete({aid: attachment.aid}, function(data) { var idx = $scope.attachments.indexOf(attachment); $scope.attachments.splice(idx, 1); errorService.error(name + ' deleted.', 'success'); }, function(data) { errorService.error(data); }); }; $scope.addAttachment = function() { $scope.newAttachment.challenges = $scope.newAttachment.challenges || []; $scope.updateAttachment($scope.newAttachment, function(data) { $scope.newAttachment = {}; for (var i = 0; i < $scope.attachments.length; i++) { if ($scope.attachments[i].aid == data.aid) return; } $scope.attachments.push(data); }); } $scope.newAttachment = {}; $scope.invalidForm = function(idx) { var form = $(document.getElementsByName('adminAttachmentForm[' + idx + ']')); return form.hasClass('ng-invalid'); }; $scope.replace = function(a) { uploadService.request().then(uploadService.upload).then(function(newfile) { if (a.aid == newfile.aid) return; attachService.delete({aid: a.aid}); a.aid = newfile.aid; attachService.save({aid: a.aid}, a, function(d) {}, function(e) { console.error(e); }) }); } $scope.addfile = function() { uploadService.request().then(uploadService.upload).then(function(newfile) { $scope.newAttachment.aid = newfile.aid; }) } sessionService.requireLogin(function() { errorService.clearErrors(); attachService.get( function(data) { $scope.attachments = data.attachments; loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); }); }]); adminChallengeCtrls.controller('AdminChallengesCtrl', [ '$scope', '$filter', '$location', '$routeParams', 'challengeService', 'errorService', 'sessionService', 'loadingService', function($scope, $filter, $location, $routeParams, challengeService, errorService, sessionService, loadingService) { if (!sessionService.requireAdmin()) return; var updateChallenges = function(challenges) { $scope.challenges = $filter('orderBy')(challenges, function(item) { return item.weight; }); }; $scope.lockChallenge = function(challenge, locked) { var copy = {}; angular.forEach(challenge, function(v, k) { copy[k] = v; }); copy.unlocked = !locked; challengeService.save({cid: challenge.cid}, copy, function(data) { challenge.unlocked = data.unlocked; }, function(data) { errorService.error(data); }); }; $scope.deleteChallenge = function(challenge) { var name = challenge.name; challengeService.delete({cid: challenge.cid}, function(){ var idx = $scope.challenges.indexOf(challenge); $scope.challenges.splice(idx, 1); errorService.error(name + ' deleted.', 'success'); }, function(data) { errorService.error(data); }); }; $scope.newChallenge = function() { $location.path('/admin/challenge'); }; // Ordering things var swapChallenges = function(idx, offset) { var temp = $scope.challenges[idx]; $scope.challenges[idx] = $scope.challenges[idx + offset]; $scope.challenges[idx + offset] = temp; $scope.weightsChanged = true; }; $scope.moveUp = function(challenge) { var idx = $scope.challenges.indexOf(challenge); if (idx < 1) { console.log('Attempt to moveUp non-existent or first item.'); return; } swapChallenges(idx, -1); }; $scope.moveDown = function(challenge) { var idx = $scope.challenges.indexOf(challenge); if (idx > ($scope.challenges.length - 1) || idx == -1) { console.log('Attempt to moveDown non-existent or last item.'); return; } swapChallenges(idx, 1); }; $scope.weightsChanged = false; $scope.saveBulk = function() { loadingService.start(); var failed = false; // Set new weights var weight = 0; // Make a copy to avoid overwriting other challenge updates challengeService.get(function(data) { angular.forEach($scope.challenges, function(mod_chall) { weight += 1; angular.forEach(data.challenges, function(chall) { if (mod_chall.cid == chall.cid) { if (weight == chall.weight) return; chall.weight = weight; challengeService.save({cid: chall.cid}, chall, function() {}, function(data) { failed = true; errorService.error(data); }); } }); }); updateChallenges(data.challenges); loadingService.stop(); }, function (data) { errorService.error(data); loadingService.stop(); }); }; sessionService.requireLogin(function() { challengeService.get(function(data) { updateChallenges(data.challenges); loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); }); }]); adminChallengeCtrls.controller('AdminChallengeCtrl', [ '$scope', '$location', '$routeParams', 'challengeService', 'errorService', 'sessionService', 'uploadService', 'loadingService', 'tagService', 'attachService', 'configService', function($scope, $location, $routeParams, challengeService, errorService, sessionService, uploadService, loadingService, tagService, attachService, configService) { if (!sessionService.requireAdmin()) return; $scope.cid = $routeParams.cid; $scope.newAttachment = {}; $scope.addNewAttachment = false; $scope.action = 'New'; $scope.editing = false; // New or editing? $scope.config = configService.get(); var goEdit = function() { $scope.action = 'Edit'; $scope.answerPlaceholder = 'Enter answer; leave blank to ' + 'leave unchanged.'; $scope.editing = true; if (!$routeParams.cid) { $location.path($location.path() + '/' + $scope.challenge.cid); $scope.cid = $scope.challenge.cid; } }; $scope.saveChallenge = function() { errorService.clearErrors(); // TODO: Check attachments if ($scope.challenge.prerequisite && ( $scope.challenge.prerequisite.type == 'None')) { $scope.challenge.prerequisite = null; }; var save_func; if ($scope.challenge.cid) { save_func = challengeService.save; } else { save_func = challengeService.create; } save_func({cid: $scope.challenge.cid}, $scope.challenge, function(data) { $scope.challenge = data; goEdit(); errorService.error('Saved.', 'success'); }, function(data) { errorService.error(data); }); }; $scope.attachmentType = 'new'; attachService.get(function(data) { $scope.allAttachments = data.attachments; $scope.updateAttachments(); }, function(e) { errorService.error(e); }) var setSubtract = function(a, b, key) { if (!a) return b; if (!b) return []; var isIn = function(val) { for (var i = 0; i < a.length; i++) { if (a[i][key] == val) return true; } return false; } var out = [] for (var i = 0; i < b.length; i++) { if (!isIn(b[i][key])) { out.push(b[i]); } } return out; }; $scope.updateAttachments = function () { if (!$scope.challenge) return; $scope.attachments = setSubtract( $scope.challenge.attachments, $scope.allAttachments, 'aid'); $scope.attachmentType = 'new'; }; $scope.$watch('challenge.attachments', $scope.updateAttachments, true); var addAttachment = function(aid) { for (var i = 0; i < $scope.attachments.length; i++) { if ($scope.attachments[i].aid == aid) { $scope.challenge.attachments.push($scope.attachments[i]); return; } } errorService.error('Could not add attachment: '+aid); }; $scope.addAttachment = function() { if ($scope.attachmentType == 'new') { uploadService.request().then(uploadService.upload).then(function (data) { $scope.challenge.attachments.push(data); }) } else { addAttachment($scope.attachmentType); } }; $scope.verifyFile = function() { // Verify existance by hash // TODO }; $scope.deleteAttachment = function(attachment) { var idx = $scope.challenge.attachments.indexOf(attachment); $scope.challenge.attachments.splice(idx, 1); }; // Prerequisite handlers $scope.updatePrerequisite = function() { var type = $scope.challenge.prerequisite.type || 'None'; if (type == 'None') return; if (type == 'solved') { // Load the challenge list loadingService.start(); challengeService.get(function(data) { $scope.challengeList = []; angular.forEach(data.challenges, function(c) { $scope.challengeList.push({'cid': c.cid, 'name': c.name}); }) loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); } }; $scope.hasTag = function(tag) { if (!$scope.challenge) { return false; } for (var i = 0; i < $scope.challenge.tags.length; i++) { if (tag == $scope.challenge.tags[i].tagslug) { return true; } } return false; } $scope.toggleTag = function(tagslug) { for (var i = 0; i < $scope.challenge.tags.length; i++) { if ($scope.challenge.tags[i].tagslug == tagslug) { $scope.challenge.tags.splice(i,1); return; } } for (var i = 0; i < $scope.tags.length; i++) { if ($scope.tags[i].tagslug == tagslug) { $scope.challenge.tags.push($scope.tags[i]); return; } } } /* Setup on load */ sessionService.requireLogin(function() { if ($routeParams.cid) { // Editing challengeService.get({cid: $routeParams.cid}, function(data) { $scope.challenge = data; goEdit(); $scope.updatePrerequisite(); loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); } else { // New $scope.challenge = { 'tags': [], 'attachments': [], 'prerequisite': { 'type': 'None' }, 'validator': 'static_pbkdf2' }; } tagService.getList(function(data) { $scope.tags = data.tags; }); loadingService.stop(); }); }]); adminChallengeCtrls.controller('AdminRestoreCtrl', [ '$scope', '$resource', 'errorService', 'sessionService', 'loadingService', function($scope, $resource, errorService, sessionService, loadingService) { if (!sessionService.requireAdmin()) return; $scope.replace = false; $scope.ready = false; $scope.fileData = null; $scope.fileName = 'No file chosen.'; $scope.chooseRestoreFile = function() { $scope.ready = false; $('#restore-file-chooser').click(); }; var fileChooserChange = function(evt) { $scope.$apply(function() { var file = evt.target.files[0]; if (!file) { $scope.fileName = 'No file chosen.'; return; } $scope.fileName = file.name; var reader = new FileReader(); reader.onload = function(e) { $scope.$apply(function() { var contents = e.target.result; if (contents.substr(0,6) == ")]}',\n") contents = contents.substr(6); $scope.fileData = angular.fromJson(contents); $scope.ready = true; }); }; reader.onerror = function(e) { $scope.$apply(function() { errorService.error('Failed to load file!'); }); }; reader.readAsText(file); }); }; $('#restore-file-chooser').change(fileChooserChange); $scope.$on('$destroy', function() { $('#restore-file-chooser').unbind('change', fileChooserChange); }); $scope.submitRestore = function() { if (!$scope.ready) { // Shouldn't even be here! errorService.error('Not ready to submit!'); return; } $resource('/api/backup').save({}, { challenges: $scope.fileData.challenges, replace: $scope.replace }, function(data) { errorService.error(data.message, 'success'); var chooser = $('#restore-file-chooser'); chooser.replaceWith(chooser.clone(true)); }, function(data) { errorService.error(data); }); }; loadingService.stop(); }]); ================================================ FILE: static/js/controllers/admin/news.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var adminNewsCtrls = angular.module('adminNewsCtrls', [ 'globalServices', 'sessionServices', 'teamServices', ]); adminNewsCtrls.controller('AdminNewsCtrl', [ '$scope', 'errorService', 'newsService', 'sessionService', 'teamService', 'loadingService', function($scope, errorService, newsService, sessionService, teamService, loadingService) { if (!sessionService.requireAdmin()) return; var makeNewsItem = function() { return { 'news_type': 'Broadcast' } }; $scope.newsItem = makeNewsItem(); $scope.teams = teamService.get(); $scope.submitNews = function() { errorService.clearErrors(); newsService.save($scope.newsItem, function() { $scope.newsItem = makeNewsItem(); newsService.poll(); errorService.success('News item saved.'); }, function(msg) { errorService.error(msg); }); }; loadingService.stop(); }]); ================================================ FILE: static/js/controllers/admin/page.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var adminPageCtrls = angular.module('adminPageCtrls', [ 'globalServices', 'sessionServices', 'pageServices', 'ngRoute', ]); adminPageCtrls.controller('AdminPageCtrl', [ '$scope', '$routeParams', 'errorService', 'pageService', 'sessionService', 'loadingService', function($scope, $routeParams, errorService, pageService, sessionService, loadingService) { if (!sessionService.requireAdmin()) return; var path = $routeParams.path; $scope.action = 'New Page: ' + path; var goEdit = function() { $scope.action = 'Edit Page: ' + path; }; $scope.save = function() { errorService.clearErrors(); pageService.save({path: path}, $scope.page, function(data) { $scope.page = data; errorService.success('Saved.'); goEdit(); }, function(data) { errorService.error(data); }); }; $scope.page = {path: path}; pageService.get({path: path}, function(data) { goEdit(); $scope.page = data; loadingService.stop(); }, function(data) { loadingService.stop(); if (data.status == 404) // Don't care, creating a new page? return; errorService.error(data); }); }]); ================================================ FILE: static/js/controllers/admin/teams.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var adminTeamCtrls = angular.module('adminTeamCtrls', [ 'ngResource', 'ngRoute', 'challengeServices', 'globalServices', 'sessionServices', 'teamServices', 'userServices' ]); adminTeamCtrls.controller('AdminTeamsCtrl', [ '$scope', '$routeParams', 'answerService', 'challengeService', 'errorService', 'sessionService', 'teamService', 'loadingService', function($scope, $routeParams, answerService, challengeService, errorService, sessionService, teamService, loadingService) { if (!sessionService.requireAdmin()) return; $scope.teams = []; $scope.team = null; $scope.unsolved = []; $scope.grantee = null; $scope.updateTeamModal = function() { $("#team-rename").modal("show"); }; $scope.updateTeam = function() { errorService.clearErrors(); $scope.team.$save({tid: $scope.team.tid}, function(data) { $scope.team = data; errorService.error('Saved.', 'success'); }, function(data) { errorService.error(data); }); }; $scope.grantFlag = function(chall) { $scope.grantee = chall; $("#team-grant").modal("show"); }; $scope.grantFlagConfirm = function() { answerService.create( { cid: $scope.grantee.cid, tid: $scope.team.tid }, function() { errorService.error('Flag granted.', 'success'); refreshTeam($scope.team.tid); }, errorService.error); }; var refreshTeam = function(tid) { $scope.team = teamService.get({tid: tid}, teamLoaded, function(data) { errorService.error(data); loadingService.stop(); }); }; var teamLoaded = function() { var tagData = {}; var solved = []; angular.forEach($scope.team.solved_challenges, function(chall) { solved.push(chall.cid); angular.forEach(chall.tags, function(tag) { if (!(tag.tagslug in tagData)) tagData[tag.tagslug] = chall.points; else tagData[tag.tagslug] += chall.points; }); }); $scope.tagData = tagData; $scope.scoreHistory = {}; $scope.scoreHistory[$scope.team.name] = $scope.team.score_history; $scope.unsolved = []; challengeService.get(function(challs) { angular.forEach(challs.challenges, function(ch) { if (solved.indexOf(ch.cid) < 0) { $scope.unsolved.push(ch); } }); loadingService.stop(); }); }; sessionService.requireLogin(function() { var tid = $routeParams.tid; if (tid) { refreshTeam(tid); } else { teamService.get(function(data) { $scope.teams = data.teams; loadingService.stop(); }); } }); }]); adminTeamCtrls.controller('AdminUsersCtrl', [ '$scope', '$routeParams', 'configService', 'errorService', 'sessionService', 'teamService', 'userService', 'loadingService', function($scope, $routeParams, configService, errorService, sessionService, teamService, userService, loadingService) { if (!sessionService.requireAdmin()) return; $scope.users = []; $scope.teams = []; $scope.user = null; $scope.config = configService.get(); $scope.updateUser = function() { errorService.clearErrors(); $scope.user.$save({uid: $scope.user.uid}, function(data) { $scope.user = data; errorService.error('Saved.', 'success'); }, function(data) { errorService.error(data); }); }; var getTeam = function(tid) { var team = null; angular.forEach($scope.teams, function(t) { if (t.tid == tid) { team = t; } }); return team; }; sessionService.requireLogin(function() { teamService.get(function(data) { $scope.teams = data.teams; var uid = $routeParams.uid; if (uid) { $scope.user = userService.get({uid: uid}, function() { loadingService.stop(); $scope.user.team = getTeam($scope.user.team_tid); }); } else { userService.get(function(data) { $scope.users = data.users; angular.forEach($scope.users, function(u) { u.team = getTeam(u.team_tid); }); loadingService.stop(); }); } }); }); // end requireLogin block }]); ================================================ FILE: static/js/controllers/admin/tools.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var adminToolCtrls = angular.module('adminToolCtrls', [ 'adminServices', 'sessionServices', 'userServices', ]); adminToolCtrls.controller('AdminToolCtrl', [ '$scope', 'adminToolsService', 'errorService', 'sessionService', 'loadingService', 'apiKeyService', function($scope, adminToolsService, errorService, sessionService, loadingService, apiKeyService) { if (!sessionService.requireAdmin()) return; $scope.recalculateScores = adminToolsService.recalculateScores; $scope.resetScores = function() { adminToolsService.resetScores( errorService.success, errorService.error); }; $scope.resetPlayers = function() { adminToolsService.resetPlayers( errorService.success, errorService.error); }; $scope.clearApiKeys = function() { apiKeyService.deleteAll(function() { errorService.success("Cleared"); }, function() { errorService.error("Failed clearing API keys."); }); }; loadingService.stop(); }]); ================================================ FILE: static/js/controllers/challenges.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var challengeCtrls = angular.module('challengeCtrls', [ 'ngResource', 'ngRoute', 'challengeServices', 'globalServices', ]); challengeCtrls.controller('ChallengeGridCtrl', [ '$rootScope', '$scope', '$location', 'challengeService', 'configService', 'loadingService', 'scoreService', 'tagService', function($rootScope, $scope, $location, challengeService, configService, loadingService, scoreService, tagService) { $scope.currChall = null; $scope.shownTags = {}; $scope.config = configService.get(); $scope.challenges = []; var compareChallenges = function(a, b) { return (a.weight - b.weight); }; var refresh = function(cb) { console.log('Refresh grid.'); challengeService.get(function(data) { data.challenges.sort(compareChallenges); $scope.challenges = data.challenges; if (cb !== undefined && cb !== null) { cb(); } }); }; tagService.getList(function(tags) { $scope.allTags = tags.tags; for (var i = 0; i < $scope.allTags.length; i++) { $scope.shownTags[$scope.allTags[i].tagslug] = 1; } }) $scope.goChallenge = function(chall) { $scope.currChall = chall; $('#challenge-modal').modal('show'); }; $scope.flipSide = function(chall) { if (chall.answered) return "Solved! (" + scoreService.getCurrentPoints(chall) + " points)"; else return scoreService.getCurrentPoints(chall) + " points"; }; $scope.tagsAllowed = function(chall) { var containsTag = function(chall, tagslug) { for (var i = 0; i < chall.tags.length; i++) { if (chall.tags[i].tagslug == tagslug) return true; } return false; } //Check for prohibition for (var i = 0; i < chall.tags.length; i++) { var type = $scope.shownTags[chall.tags[i].tagslug]; if (type == 0) { return false; } } //Check for inclusion for (var i in $scope.shownTags) { if ($scope.shownTags[i] == 2 && !containsTag(chall, i)) { return false; } } return true; } $scope.toggleTag = function(t, click) { var tindex = $scope.shownTags[t]; //Return next permutation if (click == 0) { tindex += 1; } else { tindex += 3-1; } $scope.shownTags[t] = tindex % 3; } $scope.getSentiment = function(tag) { var sentiments = [ 'sentiment_dissatisfied', 'sentiment_neutral', 'sentiment_satisfied']; return sentiments[$scope.shownTags[tag.tagslug]]; } refresh(loadingService.stop); $rootScope.$on('correctAnswer', (e) => refresh()); }]); ================================================ FILE: static/js/controllers/global.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var globalCtrls = angular.module('globalCtrls', [ 'globalServices', 'sessionServices', ]); globalCtrls.controller('GlobalCtrl', [ '$scope', 'configService', function($scope, configService) { $scope.config = configService.get(); }]); globalCtrls.controller('LoggedInCtrl', [ '$scope', 'sessionService', function($scope, sessionService) { $scope.session = sessionService.session; $scope.loggedIn = function(){ return !!sessionService.session.user; }; $scope.isAdmin = function(){ return (!!sessionService.session.user && sessionService.session.user.admin); }; }]); globalCtrls.controller('ErrorCtrl', [ '$scope', 'errorService', function($scope, errorService) { $scope.errors = errorService.errors; $scope.$on('$locationChangeStart', function(ev) { errorService.clearErrors(); }); }]); globalCtrls.controller('NewsCtrl', [ '$scope', 'newsService', function($scope, newsService) { $scope.latest = 0; var updateNews = function(newsItems) { var latest = 0; angular.forEach(newsItems, function(item) { var d = Date.parse(item.timestamp); if (d > latest) latest = d; }); if (latest > $scope.latest) { // TODO: call attention to new news $scope.latest = latest; $scope.newsItems = newsItems; } }; newsService.registerClient(updateNews); newsService.start(); }]); ================================================ FILE: static/js/controllers/page.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var pageCtrls = angular.module('pageCtrls', [ 'globalServices', 'pageServices', ]); pageCtrls.controller('StaticPageCtrl', [ '$scope', 'pageService', 'errorService', 'loadingService', function($scope, pageService, errorService, loadingService) { $scope.path = pageService.pagePath(); if ($scope.path == "") { $scope.path = "home"; } pageService.get({path: $scope.path}, function(data) { $scope.page = data; loadingService.stop(); }, function(data) { // TODO: better handling here errorService.error(data); loadingService.stop(); }); }]); ================================================ FILE: static/js/controllers/registration.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var regCtrls = angular.module('regCtrls', [ 'globalServices', 'sessionServices', 'teamServices', 'userServices' ]); regCtrls.controller('LoginCtrl', [ '$scope', '$location', '$window', 'configService', 'errorService', 'sessionService', 'passwordResetService', 'loadingService', function($scope, $location, $window, configService, errorService, sessionService, passwordResetService, loadingService) { if ($location.path().indexOf('/logout') == 0) { sessionService.logout(function() { $window.location.href = '/'; }); return; } // Check if we should redirect configService.get(function(c) { if (c.login_method == "local") return; $window.location.href = c.login_url; }); $scope.email = ''; $scope.password = ''; $scope.login = function() { errorService.clearErrors(); sessionService.login($scope.email, $scope.password, function() { $location.path('/challenges'); }, function(errData) { errorService.error(errData); $scope.password = ''; }); }; $scope.pwreset = function() { errorService.clearErrors(); passwordResetService.get({email: $scope.email}, function(data) { errorService.success(data); }, function(data) { errorService.error(data); }); }; loadingService.stop(); }]); regCtrls.controller('RegistrationCtrl', [ '$scope', '$location', 'configService', 'errorService', 'sessionService', 'teamService', 'userService', 'loadingService', function($scope, $location, configService, errorService, sessionService, teamService, userService, loadingService) { $scope.config = configService.get(); $scope.teams = []; teamService.get(function(resp) { $scope.teams = resp.teams; }); var search = $location.search(); if (search['team'] && search['code']) { $scope.team_code = search['code']; $scope.team = search['team']; } $scope.register = function() { errorService.clearErrors(); userService.create({ email: $scope.email, nick: $scope.nick, password: $scope.password, team_id: $scope.team, team_name: $scope.team_name, team_code: $scope.team_code, invite_key: $scope.invite_key }, function(data) { sessionService.refresh(); $location.url('/challenges'); }, function(errData) { // TODO: more verbose errorService.error(errData); }); }; loadingService.stop(); }]); regCtrls.controller('ProfileCtrl', [ '$scope', '$location', 'configService', 'errorService', 'sessionService', 'userService', 'loadingService', 'gameTimeService', 'teamService', 'apiKeyService', function($scope, $location, configService, errorService, sessionService, userService, loadingService, gameTimeService, teamService, apiKeyService) { $scope.user = null; $scope.started = gameTimeService.started; $scope.changing = false; $scope.startJoin = function() { $scope.changing = true; $scope.team.code = ""; $("#team").focus(); } $scope.cancel = function() { $scope.changing = false; $scope.team.name = $scope.team.originalname; $scope.team.code = $scope.team.originalcode; } $scope.$watch('team.name', function() { if (!($scope.teams && $scope.team && $scope.team.name)) return; for (var i = 0; i < $scope.teams.length; i++) { if ($scope.teams[i].name == $scope.team.name) { return $scope.team.tid = $scope.teams[i].tid; } } $scope.team.tid = -1; }) $scope.switchTeams = function() { teamService.change({ 'uid': $scope.user.uid, 'team_tid': $scope.team.tid, 'code': $scope.team.code, }, function() { $scope.team.originalname = $scope.team.name; $scope.team.originalcode = $scope.team.code; $scope.cancel(); }, function(data) { errorService.error(data); }) } var build_team_link = function(team) { var link = $location.absUrl().replace($location.url(), ''); return link + '/register?' + $.param( {team: team.tid, code: team.code}); }; sessionService.requireLogin(function() { $scope.user = sessionService.session.user; configService.get(function(c) { if (c.teams) { $scope.team = sessionService.session.team; $scope.team.originalname = $scope.team.name; $scope.team.originalcode = $scope.team.code; $scope.team_link = build_team_link($scope.team); } loadingService.stop(); }); teamService.get(function(c) { $scope.teams = c.teams; }) apiKeyService.get(function(r) { $scope.apiKey = r; }); $scope.apiKeyNew = function() { apiKeyService.create(function(r) { $scope.apiKey = r; errorService.success('Created new key.'); }, errorService.error); }; }); $scope.updateProfile = function() { userService.save({uid: $scope.user.uid}, $scope.user, function(data) { $scope.user = data; sessionService.refresh(); }, function(data) { errorService.error(data); }); }; }]); regCtrls.controller('PasswordResetCtrl', [ '$scope', '$routeParams', '$location', 'passwordResetService', 'errorService', 'sessionService', 'loadingService', function($scope, $routeParams, $location, passwordResetService, errorService, sessionService, loadingService) { $scope.email = $routeParams.email; $scope.pwreset = function() { errorService.clearErrors(); passwordResetService.save({email: $routeParams.email}, { 'token': $routeParams.token, 'password': $scope.password, 'password2': $scope.password2 }, function(data) { errorService.clearAndInhibit(); errorService.success(data); sessionService.refresh(); $location.path('/'); }, function(data) { errorService.error(data); $scope.password = ''; $scope.password2 = ''; }); }; loadingService.stop(); }]); ================================================ FILE: static/js/controllers/scoreboard.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var scoreboardCtrls = angular.module('scoreboardCtrls', [ 'ngResource', 'globalServices' ]); scoreboardCtrls.controller('ScoreboardCtrl', [ '$scope', '$resource', '$interval', 'configService', 'errorService', 'loadingService', function($scope, $resource, $interval, configService, errorService, loadingService) { $scope.config = configService.get(); var topTeams = function(scoreboard, numTeams) { // Scoreboard data is sorted by backend var numTeams = numTeams || 10; return scoreboard.slice(0, numTeams); }; var getHistory = function(scoreboard) { var histories = {}; angular.forEach(topTeams(scoreboard), function(entry) { histories[entry.name] = entry.history; }); return histories; }; var refresh = function() { errorService.clearErrors(); $resource('/api/scoreboard').get( function(data) { $scope.scoreboard = data.scoreboard; $scope.scoreHistory = getHistory(data.scoreboard); loadingService.stop(); }, function(data) { errorService.error(data); loadingService.stop(); }); }; refresh(); var iprom = $interval(refresh, 60000); $scope.$on('$destroy', function() { $interval.cancel(iprom); }); }]); ================================================ FILE: static/js/controllers/teams.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var teamCtrls = angular.module('teamCtrls', [ 'teamServices', 'globalServices', ]); teamCtrls.controller('TeamPageCtrl', [ '$scope', '$routeParams', 'teamService', 'errorService', 'loadingService', function($scope, $routeParams, teamService, errorService, loadingService) { var tid = $routeParams.tid; teamService.get({tid: tid}, function(team) { $scope.team = team; var tagData = {}; angular.forEach(team.solved_challenges, function(chall) { angular.forEach(chall.tags, function(tag) { if (!(tag.tagslug in tagData)) tagData[tag.tagslug] = chall.points; else tagData[tag.tagslug] += chall.points; }); }); $scope.tagData = tagData; $scope.scoreHistory = {}; $scope.scoreHistory[team.name] = team.score_history; $scope.scoreHistory[team.name].sort(function(a, b) { return (Date.parse(a.solved) - Date.parse(b.solved)); }); loadingService.stop(); }, function(err) { errorService.error('Unable to load team info.'); loadingService.stop(); }); }]); ================================================ FILE: static/js/directives.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var sbDirectives = angular.module('sbDirectives', [ 'challengeServices', 'globalServices', 'ngSanitize' ]); sbDirectives.directive('highlightActive', [ '$location', function($location) { return { restrict: 'A', link: function(scope, element, attrs) { scope.$watch(function() { return $location.path(); }, function() { if (element[0].pathname == $location.path()) { element.addClass('active is-active'); } else { element.removeClass('active is-active'); } }); } }; }]); sbDirectives.directive('countdownTimer', [ '$interval', 'gameTimeService', function($interval, gameTimeService) { return { restrict: 'AE', scope: true, templateUrl: '/partials/components/countdown.html', link: function(scope) { var iprom = null; var splitTime = function(time) { var t = {}; t.seconds = time % 60; time = Math.floor(time/60); t.minutes = time % 60; t.hours = Math.floor(time / 60); return t; }; var refresh = function() { var timeleft = gameTimeService.toStart(); if (timeleft > 0) { // Not yet started scope.to = "starts"; scope.time = splitTime(timeleft); return; } timeleft = gameTimeService.toEnd(); if (timeleft > 0) { // During game scope.to = "ends"; scope.time = splitTime(timeleft); return; } // Game over or no end if (iprom) { $interval.cancel(iprom); iprom = null; } if (!gameTimeService.end) scope.message = "Game on!"; else scope.message = "Game over."; }; gameTimeService.then(function() { if (!gameTimeService.start && !gameTimeService.end) return; scope.display = true; refresh(); iprom = $interval(refresh, 1000); }); scope.display = false; } }; }]); sbDirectives.directive('loadingOverlay', [ 'loadingService', function (loadingService) { return { restrict: 'A', link: function(scope, element, attrs) { scope.$watch(function() { return loadingService.getState(); }, function() { if (loadingService.getState()) element.show(); else element.hide(); }); } }; }]); /* Score over time charts based on Chart.Scatter.js * chartData should be an object with structure like: * {"label": [{time: datestring, score: value}...], ...} */ sbDirectives.directive('scoreChart', [ '$filter', function($filter) { return { restrict: 'AE', replace: false, scope: { chartData: '=', startDate: '@', endDate: '@' }, link: function(scope, element, attrs) { if (!Chart || Chart === undefined) { console.log('Chart.js is not available.'); element.remove(); return; } var padding = 5; var colorScheme = [ '#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99', '#e31a1c', '#fdbf6f', '#ff7f00', '#cab2d6', '#6a3d9a', '#ffff99', '#b15928']; var withLegend = (attrs.withLegend !== undefined); var getDate = function(d) { if (d === undefined) return null; return new Date(d); }; scope.$watch('chartData', function() { if (scope.chartData === undefined) return; element.empty(); var legendWidth = Math.min(100, Math.floor(element.width() * 0.2)); var startDate = getDate(scope.startDate); var endDate = getDate(scope.endDate); // Transform data var datasets = []; angular.forEach(scope.chartData, function(series, label) { var color = colorScheme[datasets.length % colorScheme.length]; var set = { label: $filter('escapeHtml')(label), strokeColor: color, data: [] }; var rawData = []; angular.forEach(series, function(point) { rawData.push({x: new Date(point.when), y: point.score}); }); if (rawData.length == 0) return; rawData.sort(function(a, b) { if (a.x < b.x) return -1; if (a.x > b.x) return 1; return 0; }); // Trim for start and end date if (startDate != null || endDate != null) { var startValue = null; var endValue = null; angular.forEach(rawData, function(point) { if (startDate !== null && point.x < startDate) { startValue = point.y; } else if (endDate !== null && point.x > endDate) { if (endValue !== null) { set.data.push({x: endDate, y: endValue}); endValue = null; } } else { if (startValue !== null) { set.data.push({x: startDate, y: startValue}); startValue = null; } set.data.push(point); } }); } else { set.data = rawData; } // end pruning data // Nothing after pruning if (set.data.length == 0) { if (startValue != null) { set.data.push({x: startDate, y: startValue}); } else { return; } } // Extend to present var endPointDate = endDate || (new Date()); var last = set.data[set.data.length - 1]; if (last.x < endPointDate) set.data.push({x: endPointDate, y: last.score}); datasets.push(set); }); var options = { pointDot: false, scaleType: "date", useUtc: false, scaleTimeFormat: "HH:MM", scaleDateTimeFormat: "mmm d, HH:MM" }; // Create canvas inside our element var canvas = document.createElement("canvas"); canvas.height = element.height(); if (withLegend) // Leave space for legend canvas.width = element.width() - legendWidth - padding; else canvas.width = element.width() - padding; var legend; if (withLegend) { // Prepare a legend legend = document.createElement("div"); element.append(legend); legend.style.width = legendWidth; legend.style.maxWidth = legendWidth; $(legend).addClass('sbchart-legend'); } // canvas comes after legend element.append(canvas); var ctx = canvas.getContext("2d"); var stepChart = new Chart(ctx).Step(datasets, options); if (withLegend) legend.innerHTML = stepChart.generateLegend(); }); } }; }]); /* Draw a donut chart of, well, anything. * Expects data like: * {tag: value} */ sbDirectives.directive('donutChart', [ '$filter', function($filter) { return { restrict: 'AE', replace: false, scope: { chartData: '=' }, link: function(scope, element, attrs) { if (!Chart || Chart === undefined) { console.log('Chart.js is not available.'); element.remove(); return; } var colorScheme = [ '#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99', '#e31a1c', '#fdbf6f', '#ff7f00', '#cab2d6', '#6a3d9a', '#ffff99', '#b15928']; var withLegend = (attrs.withLegend !== undefined); scope.$watch('chartData', function() { /* TODO: add a legend */ if (scope.chartData === undefined) return; element.empty(); // Massage the data var dataset = []; var numElements = 0; angular.forEach(scope.chartData, function() { numElements++; }); var getColors; var colorIdx = 0; if ((numElements * 2) < colorScheme.length) { getColors = function() { var rv = [colorScheme[colorIdx * 2], colorScheme[colorIdx * 2 + 1]]; colorIdx ++; return rv; }; } else { getColors = function() { var rv = [colorScheme[colorIdx], colorScheme[colorIdx]]; colorIdx ++; return rv; }; } angular.forEach(scope.chartData, function(value, key) { var colors = getColors(); dataset.push({ value: value, color: colors[0], highlight: colors[1], label: $filter('escapeHtml')(key) }); }); // Create our canvas var canvas = document.createElement("canvas"); canvas.height = element.height(); canvas.width = element.width(); element.append(canvas); var options = { percentageInnerCutout: 30 }; var ctx = canvas.getContext("2d"); var donutChart = new Chart(ctx).Doughnut(dataset, options); }); } }; }]); /* * Do a single challenge. */ sbDirectives.directive('challengeBox', [ '$resource', '$location', '$rootScope', 'answerService', 'errorService', 'loadingService', 'proofOfWorkService', 'scoreService', 'sessionService', 'validatorService', function($resource, $location, $rootscope, answerService, errorService, loadingService, proofOfWorkService, scoreService, sessionService, validatorService) { return { restrict: 'AE', templateUrl: '/partials/components/challenge.html', scope: { chall: '=challenge' }, link: function(scope, iElement, iAttrs) { var isModal = iElement.parents('.modal').length > 0; scope.isModal = isModal; scope.minteams = 4 scope.numteams = scope.minteams; scope.loggedIn = (!!sessionService.session.user); var closeModal = function(href) { if (isModal) { iElement.parents('.modal').modal('hide'); if (href) { $('.modal').on('hidden.bs.modal', function(e) { $location.path(href) if (!$rootscope.$$phase) $rootscope.$apply() }) } } else if (href) { $location.path(href) } }; scope.closeModal = closeModal; scope.$watch('chall', function() { // Current points scope.currentPoints = scoreService.getCurrentPoints(scope.chall); // Update loggedIn scope.loggedIn = (!!sessionService.session.user); // Recent solves scope.recent = function() { if (!scope.chall) return [] var answers = scope.chall.answers.map(function(e, i) { e.date = (new Date(e.timestamp)).valueOf(); return e; }) answers.sort(function(a, b) { if (a.date > b.date) return 1 return -1 }) var num = scope.numteams if (num < 0) { return answers } return answers.slice(0, num) } }); // Setup submit handler scope.submitChallenge = function() { loadingService.start(); errorService.clearErrors(); var done = function() { loadingService.stop(); closeModal(); }; var answer = $.trim(scope.chall.answer); if (scope.isAdmin()) { validatorService.create( {cid: scope.chall.cid, answer: answer}, function(resp) { errorService.error(resp.message, 'success'); done(); }, function(resp) { errorService.error(resp); done(); }); return; } proofOfWorkService.proofOfWork(answer) .then(function (k) { answerService.create( { cid: scope.chall.cid, answer: answer, token: k }, function(resp) { scope.chall.answered = true; errorService.error( 'Congratulations, ' + resp.points + ' points awarded!', 'success'); done(); }, function(resp){ errorService.error(resp); done(); }); }) .catch(function (e) { errorService.error('Error in proof of work: '+e); done(); }); }; scope.timeFormat = function(timestamp) { var time = moment(timestamp); var duration = moment.duration(time.diff(moment.now())) //Time ago in ms var msdiff = duration.valueOf() var week = 60 * 60 * 24 * 7 * 1000 if (msdiff < week) { return duration.humanize(true); } else { return time.format("ddd, MMM Do") } }; // isAdmin, similar to global controller scope.isAdmin = function() { return (!!sessionService.session.user && sessionService.session.user.admin); }; } // Link function } }]); sbDirectives.directive('ngAnyClick', [ "$parse", function($parse) { return function(scope, element, attr) { var call = $parse(attr.ngAnyClick); element.bind('click', function(e) { scope.$apply(function() { e.preventDefault(); call(scope, {$event:event, $click: 0}); }) }) element.bind('contextmenu', function(e) { scope.$apply(function() { e.preventDefault(); call(scope, {$event:event, $click: 1}); }) }) } }]) ================================================ FILE: static/js/filters.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var sbFilters = angular.module('sbFilters', []); sbFilters.filter('markdown', [ '$sce', function($sce) { return function(input) { if (typeof input != "string") return ""; if (typeof Markdown == "undefined" || typeof Markdown.getSanitizingConverter == "undefined") { console.log('Markdown not available!'); return input; } var converter = Markdown.getSanitizingConverter(); return $sce.trustAsHtml(converter.makeHtml(input)); }; }]); sbFilters.filter('padint', function() { return function(n, len) { if (!len) len = 2; else len = parseInt(len); n = '' + n; while(n.length < len) n = '0' + n; return n; }; }); sbFilters.filter('escapeHtml', [ function() { return function(input) { return $("
").text(input).html(); }; }]); ================================================ FILE: static/js/services/admin.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* Admin-only services */ var adminServices = angular.module('adminServices', ['ngResource']); adminServices.service('adminToolsService', [ '$resource', function($resource) { this.recalculateScores = $resource('/api/tools/recalculate').save; this.resetScores = function(cb, err) { return $resource('/api/tools/reset').save( {op: "scores", ack: "ack"}, cb, err); }; this.resetPlayers = function(cb, err) { return $resource('/api/tools/reset').save( {op: "players", ack: "ack"}, cb, err); }; }]); ================================================ FILE: static/js/services/challenges.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var challengeServices = angular.module('challengeServices', [ 'ngResource', 'globalServices']); challengeServices.service('challengeService', [ '$resource', '$rootScope', '$cacheFactory', '$timeout', function($resource, $rootScope, $cacheFactory, $timeout) { var cache = $cacheFactory('challengeCache'); var cacheTimeout = 30000; var res = $resource('/api/challenges/:cid', {}, { 'get': {method: 'GET', cache: cache}, 'save': {method: 'PUT'}, 'create': {method: 'POST'}, 'delete': {method: 'DELETE'}, }); this.get = res.get; this.delete = res.delete; this.save = function() { cache.removeAll(); return res.save.apply(res, arguments); }; this.create = function() { cache.removeAll(); return res.create.apply(res, arguments); }; this.flush = cache.removeAll; $rootScope.$on('correctAnswer', cache.removeAll); return this; }]); challengeServices.service('tagService', [ '$resource', '$rootScope', '$cacheFactory', '$timeout', function($resource, $rootScope, $cacheFactory, $timeout) { var tagCache = $cacheFactory('tagCache'); this.res = $resource('/api/tags/:tagslug', {}, { 'get': {method: 'GET', tagCache}, 'save': {method: 'PUT'}, 'create': {method: 'POST'}, }) this.get = this.res.get; this.save = this.res.save; this.create = this.res.create; this.delete = this.res.delete; this.getList = function(callback) { if (this.taglist) { callback(this.taglist); return; } this.res.get(angular.bind(this, function(data) { this.taglist = data; $timeout( angular.bind(this, function() { this.taglist = null; tagCache.removeAll(); }), 30000, false); callback(data); })) $rootScope.$on('$locationChangeSuccess', function() { this.taglist = null; tagCache.removeAll(); }); } } ]) challengeServices.service('attachService', [ '$resource', '$rootScope', '$cacheFactory', '$timeout', function($resource, $rootScope, $cacheFactory, $timeout) { var attachCache = $cacheFactory('attachCache'); this.res = $resource('/api/attachments/:aid', {}, { 'get': {method: 'GET', attachCache}, 'save': {method: 'PUT'}, }) this.get = this.res.get; this.create = this.res.create; this.save = this.res.save; this.delete = this.res.delete; this.getList = function(callback) { if (this.attachlist) { callback(this.attachlist); return; } this.res.get(angular.bind(this, function(data) { this.attachlist = data; $timeout( angular.bind(this, function() { this.attachlist = null; attachCache.removeAll(); }), 30000, false); callback(data); })) $rootScope.$on('$locationChangeSuccess', function() { this.attachlist = null; attachCache.removeAll(); }); } } ]) challengeServices.service('answerService', [ '$resource', '$rootScope', function($resource, $rootScope) { this.res = $resource('/api/answers/:aid', {}, { 'create': {method: 'POST'} }); this.create = function(what, success, failure) { this.res.create(what, function(resp) { success(resp); $rootScope.$broadcast('correctAnswer'); }, failure); }; }]); challengeServices.service('validatorService', [ '$resource', '$rootScope', function($resource, $rootScope) { this.res = $resource('/api/validator', {}, { 'create': {method: 'POST'} }); this.create = function(what, success, failure) { this.res.create(what, function(resp) { success(resp); }, failure); }; }]); challengeServices.service('scoreService', [ 'configService', function(configService) { this.scoring = 'plain'; configService.get(angular.bind(this, function(cfg) { this.scoring = cfg.scoring; })); this.getCurrentPoints = function(challenge) { if (!challenge) return 0; return challenge.current_points; }; }]) ================================================ FILE: static/js/services/global.js ================================================ /** * Copyright 2018 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* Global services */ var globalServices = angular.module('globalServices', ['ngResource']); globalServices.service('configService', [ '$resource', function($resource) { return $resource('/api/config', {}, { 'get': {cache: true} }); }]); globalServices.service('errorService', function() { var inhibit = false; this.errors = []; this.clearErrors = angular.bind(this, function() { if (inhibit) { inhibit = false; return; } this.errors.length = 0; }); this.error = angular.bind(this, function(msg, severity) { severity = severity || 'danger'; if (typeof msg == "object") { msg = (msg.data && msg.data.message) || msg.message || "Request Error"; } this.errors.push({severity: severity, msg: msg}); }); this.success = angular.bind(this, function(msg) { this.error(msg, 'success'); }); this.inhibitClear = function() { inhibit = true; }; this.clearAndInhibit = angular.bind(this, function() { inhibit = false; this.clearErrors(); this.inhibitClear(); }); }); globalServices.service('proofOfWorkService', [ 'configService', function(configService) { //angular.injector(['globalServices']).get('proofOfWorkService') var subtle = window.crypto.subtle; // Returns a promise with the key this.proofOfWork = function(instr) { return new Promise(function(resolve, reject) { configService.get(function(cfg) { var nbits = cfg.proof_of_work_bits; if (nbits == 0) { resolve(''); return; } _proofOfWork(instr, nbits).then(resolve).catch(reject); }); }); }; // Internal implementation var _proofOfWork = function(instr, nbits) { var start = Date.now(); return new Promise(function(resolve, reject) { var resolver = function(k) { var end = Date.now(); console.log('Proof of work took ' + (end - start) + ' ms'); resolve(k); }; // Sortof recursive -- not great, but best I can come up. // Patches welcome. var callTry = function() { return _tryProofOfWork(instr, nbits) .then(function(k) { if (k == null) { callTry(); return; } resolver(k); }).catch(function(e) { console.error('Error in proof of work: ' + e); reject(e); }); }; callTry(); }); }; // HMAC with random key // Promise is fulfilled with args (key, signature) var _hmacRandom = function(instr) { var buf = new TextEncoder("utf-8").encode(instr); return new Promise(function(resolve, reject) { subtle.generateKey( { name: 'HMAC', hash: {name: 'SHA-256'}, length: 256 }, true, ['sign']) .then(function(key) { subtle.sign( {name: 'HMAC'}, key, buf ) .then(function(signature) { resolve({key: key, signature: new Uint8Array(signature)}); }) .catch(reject); }) .catch(reject); }); }; var testbits = function(arr, nbits) { while (nbits >= 8) { if (arr[0] != 0) return false; nbits -= 8; arr = arr.slice(1); } var mask = Math.round(Math.pow(2, nbits)) - 1; return ((arr[0] & mask) == 0); }; // Try to find a key with low bits set to 0 var _tryProofOfWork = function(instr, nbits) { return new Promise(function(resolve, reject) { _hmacRandom(instr) .then(function(params) { if (testbits(params.signature, nbits)) { subtle.exportKey('jwk', params.key) .then(function(k) { resolve(k.k); }) .catch(reject); } else { // Not a match resolve(null); } }) .catch(reject); }); }; // Useful for tuning window._proofOfWork = _proofOfWork; }]); globalServices.service('loadingService', [ '$timeout', function($timeout) { // Basically just keeps a loading flag var loading = false; var loadTimer = null; var debounce = 250; // Debounce ms this.getState = function() { return loading; }; this.start = function() { if (loadTimer || loading) return; loadTimer = $timeout( function() { loading = true; }, debounce); }; this.end = function() { if (loadTimer) { $timeout.cancel(loadTimer); loadTimer = null; } loading = false; }; this.stop = this.end; }]); globalServices.service('gameTimeService', [ '$q', 'configService', 'errorService', function($q, configService, errorService) { var future = $q.defer(); this.start = null; this.end = null; configService.get( angular.bind(this, function(config) { this.start = config.game_start && Date.parse(config.game_start); this.end = config.game_end && Date.parse(config.game_end); future.resolve(); }), function(data) { errorService.error(data); }); this.toStart = function() { // Time in seconds to start of game, or null if no start specified if (!this.start) return null; return Math.round((this.start - Date.now()) / 1000); }; this.toEnd = function() { // Time in seconds to end of game, or null if no end specified if (!this.end) return null; return Math.round((this.end - Date.now()) / 1000); }; this.duringGame = function(opt_callback) { // Return true or execute callback if in the game if (this.start != null && this.toStart() > 0) return false; if (this.end != null && this.toEnd() < 0) return false; if (opt_callback) return opt_callback(); return true; }; this.started = angular.bind(this, function() { return !this.start || this.toStart() < 0 }) this.then = function(callback) { future.promise.then(callback); }; }]); globalServices.service('newsService', [ '$resource', '$interval', 'configService', function($resource, $interval, configService) { this.newsResource = $resource('/api/news'); this.get = this.newsResource.get; this.query = this.newsResource.query; this.save = this.newsResource.save; this.pollPromise_ = undefined; this.inFlight_ = false; // Callbacks to be called on new news this.clients_ = []; this.registerClient = function(client) { this.clients_.push(client); }; // Polling handler this.poll = function() { if (this.inFlight_) return; this.inFlight_ = true; this.newsResource.query(angular.bind(this, function(data) { angular.forEach(this.clients_, function(cb) { cb(data); }); this.inFlight_ = false; }), angular.bind(this, function() { this.inFlight_ = false })); }; // Set up polling this.start = function() { if (this.pollPromise_) return; this.poll(); configService.get(angular.bind(this, function(config) { if (config.news_mechanism != 'poll') return; var interval = config.news_poll_interval || 60000; // 60 seconds this.pollPromise_ = $interval(angular.bind(this, this.poll), interval); })); }; // Shutdown this.stop = function() { $interval.cancel(this.pollPromise_); this.pollPromise_ = undefined; }; }]); ================================================ FILE: static/js/services/page.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* page services */ var pageServices = angular.module('pageServices', ['ngResource']); pageServices.service('pageService', [ '$resource', '$location', function($resource, $location) { this.pagelist = []; this.resource = $resource('/api/page/:path'); this.get = this.resource.get; this.save = this.resource.save; this.delete = this.resource.delete; /** Return path to page with prefix stripped. */ this.pagePath = function(prefix) { prefix = prefix || '/'; var path = $location.path(); if (path.substr(0, prefix.length) == prefix) { path = path.substr(prefix.length); } return path; }; this.getList = function(callback) { if (this.pagelist) { callback(this.pagelist); return; } this.res.get(angular.bind(this, function(data) { this.pagelist = data; $timeout( angular.bind(this, function() { this.pagelist = null; }), 60000, false); callback(data); })) $rootScope.$on('$locationChangeSuccess', function() { this.pagelist = null; }); } }]); ================================================ FILE: static/js/services/session.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var sessionServices = angular.module('sessionServices', [ 'ngResource', 'globalServices', ]); sessionServices.service('sessionService', [ '$resource', '$location', '$rootScope', 'errorService', function($resource, $location, $rootScope, errorService) { this.sessionData = $resource('/api/session'); this.session = { user: null, team: null }; this.login = function(email, password, successCallback, errorCallback) { this.sessionData.save({email: email, password: password}, angular.bind(this, function(data) { this.session.user = data.user; this.session.team = data.team; if (successCallback) successCallback(); $rootScope.$broadcast('sessionLogin'); }), errorCallback || function() {}); }; this.logout = function(callback) { this.sessionData.remove(function() { $rootScope.$broadcast('sessionLogout'); callback(); }); this.session.user = null; this.session.team = null; }; this.refresh = function(successCallback, errorCallback) { // Attempt to load this.sessionData.get(angular.bind(this, function(data) { var currUser = this.session.user && this.session.user.nick; this.session.user = data.user; this.session.team = data.team; if (currUser && !this.session.user) $rootScope.$broadcast('sessionLogout'); if (!currUser && this.session.user) $rootScope.$broadcast('sessionLogin'); if (successCallback) successCallback(); }), errorCallback || function() {}); }; this.requireLogin = function(callback, no_redirect) { /* If the user is logged in, execute the callback. Otherwise, redirect * to the login. */ if (this.session.user !== null) { return callback(); } return this.refresh(callback, function() { if (no_redirect) return; errorService.clearAndInhibit(); errorService.error('You must be logged in.', 'info'); $location.path('/login'); }); }; this.requireAdmin = function(opt_callback) { var cb = angular.bind(this, function() { if (this.session.user && this.session.user.admin) { if (opt_callback) opt_callback(); return true; } errorService.clearAndInhibit(); errorService.error('You are not an admin!'); $location.path('/'); return false; }); if (this.session.user != null) { return cb(); } this.requireLogin(cb); return true; }; this.refresh(); }]); function getss(){ return angular.element(document).injector().get('sessionService'); } ================================================ FILE: static/js/services/teams.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var teamServices = angular.module('teamServices', ['ngResource']); teamServices.service('teamService', ['$resource', function($resource) { var resource = $resource('/api/teams/:tid', {}, { save: {method: 'PUT'}, create: {method: 'POST'} }); resource.change = function(data, cb, error) { return resource.save({tid: "change"}, data, cb, error); } return resource; }]); ================================================ FILE: static/js/services/upload.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* upload services */ var uploadServices = angular.module('uploadServices', ['ngResource']); uploadServices.service('uploadService', ['$http', '$q', function($http, $q) { var basename = function(path) { return path.split('/').reverse()[0]; }; this.request = function() { return $q(function(resolve) { var form = $('#new-attachment'); form.click(); form.off('change'); form.on('change', function() { resolve(form.get(0).files[0]); }) }) } this.upload = function(file) { // Returns a promise with the file hash var filename = basename(file.name); // Construct the promise var promise = $q.defer(); // HTTP Config var config = { transformRequest: angular.identity, 'headers': { 'Content-type': undefined } }; // Setup form data var fd = new FormData(); fd.append('file', file); // Request $http.post('/api/attachments', fd, config). success(function(data) { data.filename = filename; promise.resolve(data); }). error(function(data, status) { if (data) promise.reject(data); else promise.reject('Unknown upload error.'); }); return promise.promise; }; }]); ================================================ FILE: static/js/services/users.js ================================================ /** * Copyright 2016 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var userServices = angular.module('userServices', ['ngResource']); userServices.service('userService', [ '$resource', function($resource) { return $resource('/api/users/:uid', {}, { 'save': {method: 'PUT'}, 'create': {method: 'POST'} }); }]); userServices.service('passwordResetService', [ '$resource', function($resource) { return $resource('/api/pwreset/:email'); }]); userServices.service('apiKeyService', [ '$resource', function($resource) { return $resource('/api/apikey/:keyid', {}, { 'create': {method: 'POST'}, 'deleteAll': {method: 'DELETE', params:{}} }); }]); ================================================ FILE: static/partials/admin/attachments.html ================================================

Attachments

================================================ FILE: static/partials/admin/challenge.html ================================================

Challenge

Tags

Tag Name Tag Description
{{tag.name}}{{tag.description}}

Attachments

FileFilehash
{{attachment.filename}} {{attachment.aid}}

Prerequisite

================================================ FILE: static/partials/admin/challenges.html ================================================
NamePointsSolvesActions
{{challenge.name}} {{challenge.points}} {{challenge.solves}} Locked Unlocked Edit Delete
NewSave Order
================================================ FILE: static/partials/admin/news.html ================================================

Submit News

================================================ FILE: static/partials/admin/page.html ================================================

================================================ FILE: static/partials/admin/pages.html ================================================

Pages

================================================ FILE: static/partials/admin/restore.html ================================================

Backup/Restore Challenges

Backup Backup Challenges
Restore
{{fileName}}
  • {{chall.name}}
================================================ FILE: static/partials/admin/tags.html ================================================

Tags

================================================ FILE: static/partials/admin/teams.html ================================================
Team Solves Score
{{team.name}} {{team.solves}}{{team.score}}

Solved Challenges ({{team.score}} points)

This team has not solved any challenges.

NamePointsSolved

Team Members

HandleEmail

Unsolved Challenges

NamePoints
================================================ FILE: static/partials/admin/tools.html ================================================

Admin Tools

================================================ FILE: static/partials/admin/users.html ================================================
NickE-Mail Team Solves
{{user.nick}} (Admin) {{user.email}} {{user.team.name}} {{user.team.solves}}
================================================ FILE: static/partials/challenge_grid.html ================================================

Challenges

left or right click to filter tags
{{getSentiment(tag)}} {{tag.name}}
Solved