diff --git a/.browserslistrc b/.browserslistrc index de555672c..e94f8140c 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1 +1 @@ -> 1% +defaults diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 000000000..afb3526e4 --- /dev/null +++ b/.envrc.example @@ -0,0 +1,32 @@ +# This file is just an example. +# cp it to .envrc then use `source` to apply its contents. +# .envrc is gitignored, so you won't accidentally commit your creds. :) +# Once copied, check out direnv (https://github.com/direnv/direnv) to automatically source it + +# Google +# - Required for Google sign-in & linking +# - Create one at https://console.developers.google.com/apis/credentials +# - Set redirect URI to http://localhost:3000/auth/google/callback +export GOOGLE_CLIENT_ID=fafaf +export GOOGLE_CLIENT_SECRET=fafaf + +# Patreon +# - Required for Patreon linking +# - Create one at https://www.patreon.com/portal/registration/register-clients +# - Set redirect URI to http://localhost:3000/auth/patreon/callback +export PATREON_CLIENT_ID=fafaf +export PATREON_CLIENT_SECRET=fafaf + +# Splits.io +# - Required for some JavaScript->Rails API calls to work, including WebSockets features like races +# - Create one at http://localhost:3000/settings/applications/new +# - Set redirect URI to http://localhost:3000/auth/splitsio/callback +export SPLITSIO_CLIENT_ID=fafaf +export SPLITSIO_CLIENT_SECRET=fafaf + +# Twitch +# - Required for Twitch sign-in & linking +# - Create one at https://dev.twitch.tv/dashboard/apps +# - Set redirect URI to http://localhost:3000/auth/twitch/callback +export TWITCH_CLIENT_ID=fafaf +export TWITCH_CLIENT_SECRET=fafaf diff --git a/.example.env b/.example.env deleted file mode 100644 index a20a110e6..000000000 --- a/.example.env +++ /dev/null @@ -1,18 +0,0 @@ -# This file is just an example. -# Try `cp`ing it to .env and then using `source` to export its contents from there. -# Do note that .env is gitignored, so you won't accidentally commit your creds. :) -# Check out direnv (https://github.com/direnv/direnv) to automatically source this file when you enter this directory. - -# Fill in your Twitch or Google client information if you want login/signup to work. -# You can make a Google client at https://console.developers.google.com/apis/credentials -# You can make a Patreon client at https://www.patreon.com/portal/registration/register-clients -# You can make a Twitch client at https://dev.twitch.tv/dashboard/apps - -export GOOGLE_CLIENT_ID=fafaf -export GOOGLE_CLIENT_SECRET=fafaf - -export PATREON_CLIENT_ID=fafaf -export PATREON_CLIENT_SECRET=fafaf - -export TWITCH_CLIENT_ID=fafaf -export TWITCH_CLIENT_SECRET=fafaf diff --git a/.gitignore b/.gitignore index d1e272062..c10453521 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ yarn-debug.log* .envrc .DS_Store + +# local activestorage +storage diff --git a/.travis.yml b/.travis.yml index 7c2b73a37..328d92525 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,5 +10,4 @@ before_install: # Update docker-compose; Travis's built-in one is old install: - docker-compose build script: - - docker-compose run -e RAILS_ENV=test web bundle exec rake db:migrate - - docker-compose run -e RAILS_ENV=test web bundle exec rspec + - docker-compose run -e RAILS_ENV=test web bash -c "bundle exec rake db:migrate && bundle exec rspec" diff --git a/Gemfile b/Gemfile index 21a04e260..ae4e9cecf 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } group :test, :development do gem 'pry-rails' + gem 'rspec-rails', '~> 4.0.0.beta2' end group :test do @@ -13,10 +14,6 @@ group :test do gem 'json-schema' gem 'json-schema-rspec' gem 'rails-controller-testing' - gem 'rspec-rails', require: false, github: 'rspec/rspec-rails', branch: '4-0-dev' - %w[rspec rspec-core rspec-expectations rspec-mocks rspec-support].each do |lib| - gem lib, github: "rspec/#{lib}", branch: 'master' - end gem 'simplecov', require: false end @@ -83,7 +80,7 @@ gem 'strong_migrations' # errors+logging gem 'newrelic_rpm' -gem 'skylight', '~> 4.0.0.beta2' +gem 'skylight', '~> 4.0.0' # external communication gem 'httparty' @@ -93,7 +90,7 @@ gem 'rest-client' gem 'nilify_blanks' # parsing -gem 'descriptive_statistics' +gem 'descriptive_statistics', require: 'descriptive_statistics/safe' gem 'moving_average' # profiling @@ -104,7 +101,7 @@ gem 'stackprof' # server/environment gem 'puma' -gem 'rails', '~> 6.0.0.beta3' +gem 'rails', '~> 6.0.0.rc1' # see https://github.com/faye/websocket-driver-ruby/issues/58#issuecomment-394611125 gem 'websocket-driver', github: 'faye/websocket-driver-ruby', ref: 'ee39af83d03ae3059c775583e4c4b291641257b8' diff --git a/Gemfile.lock b/Gemfile.lock index f3c44170f..9f3e54e1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,124 +22,68 @@ GIT purecss-rails (0.6.0) railties (>= 3.2.6) -GIT - remote: https://github.com/rspec/rspec-core.git - revision: 7727a078e01df1677fe8ce787e3a751111364e00 - branch: master - specs: - rspec-core (3.9.0.pre) - rspec-support (= 3.9.0.pre) - -GIT - remote: https://github.com/rspec/rspec-expectations.git - revision: 7a5c9f7fdaeec189b9856e4cedbad70ce86cba69 - branch: master - specs: - rspec-expectations (3.9.0.pre) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (= 3.9.0.pre) - -GIT - remote: https://github.com/rspec/rspec-mocks.git - revision: 55f0b116dc8c15a7acef9e7619bb0a3925bd18fa - branch: master - specs: - rspec-mocks (3.9.0.pre) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (= 3.9.0.pre) - -GIT - remote: https://github.com/rspec/rspec-rails.git - revision: a8078b8729a9062161118f440f7d49da9b2de134 - branch: 4-0-dev - specs: - rspec-rails (4.0.0.pre) - actionpack (>= 4.2) - activesupport (>= 4.2) - railties (>= 4.2) - rspec-core (= 3.9.0.pre) - rspec-expectations (= 3.9.0.pre) - rspec-mocks (= 3.9.0.pre) - rspec-support (= 3.9.0.pre) - -GIT - remote: https://github.com/rspec/rspec-support.git - revision: e972ee6594ccc7ea3f8717ababcf6295c58aec33 - branch: master - specs: - rspec-support (3.9.0.pre) - -GIT - remote: https://github.com/rspec/rspec.git - revision: afd67e30946543fb71889cf60d332c28291878b8 - branch: master - specs: - rspec (3.9.0.pre) - rspec-core (= 3.9.0.pre) - rspec-expectations (= 3.9.0.pre) - rspec-mocks (= 3.9.0.pre) - GEM remote: https://rubygems.org/ specs: - actioncable (6.0.0.beta3) - actionpack (= 6.0.0.beta3) + actioncable (6.0.0.rc1) + actionpack (= 6.0.0.rc1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.0.beta3) - actionpack (= 6.0.0.beta3) - activejob (= 6.0.0.beta3) - activerecord (= 6.0.0.beta3) - activestorage (= 6.0.0.beta3) - activesupport (= 6.0.0.beta3) + actionmailbox (6.0.0.rc1) + actionpack (= 6.0.0.rc1) + activejob (= 6.0.0.rc1) + activerecord (= 6.0.0.rc1) + activestorage (= 6.0.0.rc1) + activesupport (= 6.0.0.rc1) mail (>= 2.7.1) - actionmailer (6.0.0.beta3) - actionpack (= 6.0.0.beta3) - actionview (= 6.0.0.beta3) - activejob (= 6.0.0.beta3) + actionmailer (6.0.0.rc1) + actionpack (= 6.0.0.rc1) + actionview (= 6.0.0.rc1) + activejob (= 6.0.0.rc1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.0.beta3) - actionview (= 6.0.0.beta3) - activesupport (= 6.0.0.beta3) + actionpack (6.0.0.rc1) + actionview (= 6.0.0.rc1) + activesupport (= 6.0.0.rc1) rack (~> 2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actiontext (6.0.0.beta3) - actionpack (= 6.0.0.beta3) - activerecord (= 6.0.0.beta3) - activestorage (= 6.0.0.beta3) - activesupport (= 6.0.0.beta3) + actiontext (6.0.0.rc1) + actionpack (= 6.0.0.rc1) + activerecord (= 6.0.0.rc1) + activestorage (= 6.0.0.rc1) + activesupport (= 6.0.0.rc1) nokogiri (>= 1.8.5) - actionview (6.0.0.beta3) - activesupport (= 6.0.0.beta3) + actionview (6.0.0.rc1) + activesupport (= 6.0.0.rc1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) active_record_union (1.3.0) activerecord (>= 4.0) - activejob (6.0.0.beta3) - activesupport (= 6.0.0.beta3) + activejob (6.0.0.rc1) + activesupport (= 6.0.0.rc1) globalid (>= 0.3.6) - activemodel (6.0.0.beta3) - activesupport (= 6.0.0.beta3) - activerecord (6.0.0.beta3) - activemodel (= 6.0.0.beta3) - activesupport (= 6.0.0.beta3) + activemodel (6.0.0.rc1) + activesupport (= 6.0.0.rc1) + activerecord (6.0.0.rc1) + activemodel (= 6.0.0.rc1) + activesupport (= 6.0.0.rc1) activerecord-import (1.0.1) activerecord (>= 3.2) - activestorage (6.0.0.beta3) - actionpack (= 6.0.0.beta3) - activerecord (= 6.0.0.beta3) + activestorage (6.0.0.rc1) + actionpack (= 6.0.0.rc1) + activejob (= 6.0.0.rc1) + activerecord (= 6.0.0.rc1) marcel (~> 0.3.1) - activesupport (6.0.0.beta3) + activesupport (6.0.0.rc1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - zeitwerk (~> 1.3, >= 1.3.1) + zeitwerk (~> 2.1, >= 2.1.4) addressable (2.6.0) public_suffix (>= 2.0.2, < 4.0) administrate (0.11.0) @@ -158,9 +102,9 @@ GEM authie (3.2.0) autoprefixer-rails (9.5.1) execjs - aws-eventstream (1.0.2) - aws-partitions (1.151.0) - aws-sdk-core (3.48.4) + aws-eventstream (1.0.3) + aws-partitions (1.152.0) + aws-sdk-core (3.48.5) aws-eventstream (~> 1.0, >= 1.0.2) aws-partitions (~> 1.0) aws-sigv4 (~> 1.1) @@ -188,7 +132,7 @@ GEM binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) blueprinter (0.16.0) - bootsnap (1.4.3) + bootsnap (1.4.4) msgpack (~> 1.0) bootstrap4-kaminari-views (1.0.1) kaminari (>= 0.13) @@ -385,20 +329,20 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.0.beta3) - actioncable (= 6.0.0.beta3) - actionmailbox (= 6.0.0.beta3) - actionmailer (= 6.0.0.beta3) - actionpack (= 6.0.0.beta3) - actiontext (= 6.0.0.beta3) - actionview (= 6.0.0.beta3) - activejob (= 6.0.0.beta3) - activemodel (= 6.0.0.beta3) - activerecord (= 6.0.0.beta3) - activestorage (= 6.0.0.beta3) - activesupport (= 6.0.0.beta3) + rails (6.0.0.rc1) + actioncable (= 6.0.0.rc1) + actionmailbox (= 6.0.0.rc1) + actionmailer (= 6.0.0.rc1) + actionpack (= 6.0.0.rc1) + actiontext (= 6.0.0.rc1) + actionview (= 6.0.0.rc1) + activejob (= 6.0.0.rc1) + activemodel (= 6.0.0.rc1) + activerecord (= 6.0.0.rc1) + activestorage (= 6.0.0.rc1) + activesupport (= 6.0.0.rc1) bundler (>= 1.3.0) - railties (= 6.0.0.beta3) + railties (= 6.0.0.rc1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.4) actionpack (>= 5.0.1.x) @@ -418,9 +362,9 @@ GEM json (>= 1.7, < 3) rails (>= 3.1) rubyzip (~> 1) - railties (6.0.0.beta3) - actionpack (= 6.0.0.beta3) - activesupport (= 6.0.0.beta3) + railties (6.0.0.rc1) + actionpack (= 6.0.0.rc1) + activesupport (= 6.0.0.rc1) method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) @@ -438,6 +382,27 @@ GEM netrc (~> 0.8) rollbar (2.19.3) multi_json + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-rails (4.0.0.beta2) + actionpack (>= 4.2) + activesupport (>= 4.2) + railties (>= 4.2) + rspec-core (~> 3.8) + rspec-expectations (~> 3.8) + rspec-mocks (~> 3.8) + rspec-support (~> 3.8) + rspec-support (3.8.0) rubocop (0.67.2) jaro_winkler (~> 1.5.1) parallel (~> 1.10) @@ -471,9 +436,9 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - skylight (4.0.0.beta2) - skylight-core (= 4.0.0.beta2) - skylight-core (4.0.0.beta2) + skylight (4.0.1) + skylight-core (= 4.0.1) + skylight-core (4.0.1) activesupport (>= 4.2.0) slim (4.0.1) temple (>= 0.7.6, < 0.9) @@ -503,7 +468,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 4.2) websocket-extensions (0.1.3) - zeitwerk (1.4.3) + zeitwerk (2.1.5) PLATFORMS ruby @@ -561,23 +526,18 @@ DEPENDENCIES purecss-rails! rack-cors rack-mini-profiler - rails (~> 6.0.0.beta3) + rails (~> 6.0.0.rc1) rails-controller-testing rails-erd rails_real_favicon redis rest-client rollbar - rspec! - rspec-core! - rspec-expectations! - rspec-mocks! - rspec-rails! - rspec-support! + rspec-rails (~> 4.0.0.beta2) rubocop sass-rails (~> 5.0.7) simplecov - skylight (~> 4.0.0.beta2) + skylight (~> 4.0.0) slim stackprof strong_migrations diff --git a/README.md b/README.md index fd6eab881..fcb01fecd 100644 --- a/README.md +++ b/README.md @@ -60,22 +60,18 @@ Once the output looks settled (you should see `* Listening on tcp://0.0.0.0:3000 [localhost]: http://localhost:3000/ -#### Accounts (Optional) -Splits.io accounts are built on top of Twitch accounts, so if you want sign up / sign in to work, you will need to -register a Twitch application at [dev.twitch.tv/dashboard](https://dev.twitch.tv/dashboard). -Use this redirect URI when asked: -```http -http://localhost:3000/auth/twitch/callback -``` -Twitch will give you a client ID and a client secret. Put them in `.env` in the same format as `.example.env`. Then run +#### Optional Features +Some features are built on top of links with other platforms, like Twitch sign-in. If you want these features to work, +you need to register developer applications with the appropriate services. See `.envrc.example` for details on which +features require which platforms. + +After changing or creating `.envrc`, run ```sh -source .env -make build +source .envrc && make build run ``` -before starting the server again and you're set! +to rebuild the server with your new environment variables, or use [direnv][direnv] to automate this step! -(If you want to do the source step automatically in the future, use something -like [`autoenv`](https://github.com/kennethreitz/autoenv).) +[direnv]: https://github.com/direnv/direnv ### Debugging #### Getting Up and Running @@ -212,8 +208,8 @@ Config for this generation is at [`config/favicon.json`][favicon-config]. ### Theme Splits.io runs [Bootstrap 4][bootstrap] on a paid theme called [Dashboard][dashboard]. Its license does not allow its -source to be included in this repository; however there are three modifications we make to the source before producing -the included final build. This is within the terms of the license. +unminified source to be included in this repository; for posterity there are three modifications we make to the source +before producing the included final build. This is within the terms of the license. - In `/v4/scss/variables.scss`: - Change `$theme-colors[primary]` to `#489BE7` diff --git a/app/assets/stylesheets/application.sass b/app/assets/stylesheets/application.sass index 415e7f9c5..1842101f5 100644 --- a/app/assets/stylesheets/application.sass +++ b/app/assets/stylesheets/application.sass @@ -5,6 +5,7 @@ @import headings @import "font-awesome-sprockets" @import "font-awesome" +@import "vue-multiselect/dist/vue-multiselect.min" @import colors @@ -17,6 +18,7 @@ @import cursor @import faq @import footer +@import glow @import jumbotron @import links @import navigation @@ -30,6 +32,7 @@ @import tips @import times @import twitch +@import vue @import vendor/toolkit-inverse.min diff --git a/app/assets/stylesheets/colors.sass b/app/assets/stylesheets/colors.sass index 85b15c406..a387ac4b5 100644 --- a/app/assets/stylesheets/colors.sass +++ b/app/assets/stylesheets/colors.sass @@ -17,6 +17,8 @@ $light-black: rgba(80, 80, 80, 1) $light-white: rgba(255, 255, 255, .5) $gold: #F5BA46 +$silver: rgba(150, 150, 150, 1) +$bronze: rgba(160, 100, 40, 1) $text-color: rgba(235, 235, 235, 1) $inverted-text-color: rgba(20, 20, 20, 1) @@ -96,3 +98,18 @@ $inverted-text-color: rgba(20, 20, 20, 1) .text-gold color: $gold + +.text-silver + color: $silver + +.text-bronze + color: $bronze + +.bg-gold + background-color: $gold + +.bg-silver + background-color: $silver + +.bg-gold + background-color: $bronze diff --git a/app/assets/stylesheets/glow.sass b/app/assets/stylesheets/glow.sass new file mode 100644 index 000000000..ae3de7f92 --- /dev/null +++ b/app/assets/stylesheets/glow.sass @@ -0,0 +1,12 @@ +@-webkit-keyframes glow + from + -webkit-box-shadow: 0 0 5px rgba(255, 255, 255, 0.1) + 50% + -webkit-box-shadow: 0 0 15px rgba(255, 255, 255, 0.5) + to + -webkit-box-shadow: 0 0 5px rgba(255, 255, 255, 0.1) + +.glow + -webkit-animation-name: glow + -webkit-animation-duration: 2s + -webkit-animation-iteration-count: infinite diff --git a/app/assets/stylesheets/spinner.sass b/app/assets/stylesheets/spinner.sass index babeb4e28..9f7c75d11 100644 --- a/app/assets/stylesheets/spinner.sass +++ b/app/assets/stylesheets/spinner.sass @@ -1,7 +1,9 @@ .sk-cube-grid - width: 40px - height: 40px - margin: 100px auto + display: inline-block + height: 1em + line-height: 1em + margin: .11em 0 -.11em + width: 1em .sk-cube-grid .sk-cube width: 33% diff --git a/app/assets/stylesheets/text.sass b/app/assets/stylesheets/text.sass index fcdf12d02..90f55523a 100644 --- a/app/assets/stylesheets/text.sass +++ b/app/assets/stylesheets/text.sass @@ -1,2 +1,6 @@ .nowrap white-space: nowrap + +// pulled from Bootstrap's .text-monospace (added upstream but not in our Bootstrap theme) +.text-monospace + font-family: Anonymous Pro, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace diff --git a/app/assets/stylesheets/times.sass b/app/assets/stylesheets/times.sass index b06a2588c..1ded859bd 100644 --- a/app/assets/stylesheets/times.sass +++ b/app/assets/stylesheets/times.sass @@ -5,3 +5,6 @@ span.green span.red background: none color: $red + +[data-abstime] + @extend .text-monospace diff --git a/app/assets/stylesheets/vue.sass b/app/assets/stylesheets/vue.sass new file mode 100644 index 000000000..4cc7f5bcb --- /dev/null +++ b/app/assets/stylesheets/vue.sass @@ -0,0 +1,2 @@ +[v-cloak] + display: none diff --git a/app/blueprints/api/v4/chat_message_blueprint.rb b/app/blueprints/api/v4/chat_message_blueprint.rb new file mode 100644 index 000000000..67015bba7 --- /dev/null +++ b/app/blueprints/api/v4/chat_message_blueprint.rb @@ -0,0 +1,5 @@ +class Api::V4::ChatMessageBlueprint < Blueprinter::Base + fields :body, :from_entrant, :created_at, :updated_at + + association :user, blueprint: Api::V4::UserBlueprint +end diff --git a/app/blueprints/api/v4/entry_blueprint.rb b/app/blueprints/api/v4/entry_blueprint.rb new file mode 100644 index 000000000..49132c468 --- /dev/null +++ b/app/blueprints/api/v4/entry_blueprint.rb @@ -0,0 +1,7 @@ +class Api::V4::EntryBlueprint < Blueprinter::Base + fields :id, :ghost, :readied_at, :finished_at, :forfeited_at, :created_at, :updated_at + + association :runner, blueprint: Api::V4::UserBlueprint + association :creator, blueprint: Api::V4::UserBlueprint + association :run, blueprint: Api::V4::RunBlueprint +end diff --git a/app/blueprints/api/v4/game_blueprint.rb b/app/blueprints/api/v4/game_blueprint.rb index e4768b6cf..cf685deb4 100644 --- a/app/blueprints/api/v4/game_blueprint.rb +++ b/app/blueprints/api/v4/game_blueprint.rb @@ -1,6 +1,10 @@ class Api::V4::GameBlueprint < Blueprinter::Base fields :name, :created_at, :updated_at + field :id do |game, _| + game.id.to_s + end + field :shortname do |game, _| game.srdc.try(:shortname) || game.srl.try(:shortname) end diff --git a/app/blueprints/api/v4/race_blueprint.rb b/app/blueprints/api/v4/race_blueprint.rb new file mode 100644 index 000000000..4faab7513 --- /dev/null +++ b/app/blueprints/api/v4/race_blueprint.rb @@ -0,0 +1,35 @@ +class Api::V4::RaceBlueprint < Blueprinter::Base + fields :id, :visibility, :notes, :started_at, :created_at, :updated_at + field :path do |race, _| + Rails.application.routes.url_helpers.race_path(race) + end + + field :join_token do |race, options| + options[:join_token] ? race.join_token : nil + end + + association :owner, blueprint: Api::V4::UserBlueprint + association :entries, blueprint: Api::V4::EntryBlueprint + association :chat_messages, blueprint: Api::V4::ChatMessageBlueprint do |race, options| + options[:chat] ? race.chat_messages.order(created_at: :desc).limit(100) : [] + end + + association :game, blueprint: Api::V4::GameBlueprint + association :category, blueprint: Api::V4::CategoryBlueprint + + field :attachments do |race, _| + race.attachments.map do |attachment| + { + id: attachment.id, + created_at: attachment.created_at, + filename: attachment.filename.to_s, + url: Rails.application.routes.url_helpers.rails_blob_url( + attachment, + protocol: 'https', + host: 'splits.io', + disposition: 'attachment' + ) + } + end + end +end diff --git a/app/blueprints/api/v4/run_blueprint.rb b/app/blueprints/api/v4/run_blueprint.rb index bf7097066..cb4eec13e 100644 --- a/app/blueprints/api/v4/run_blueprint.rb +++ b/app/blueprints/api/v4/run_blueprint.rb @@ -14,6 +14,10 @@ class Api::V4::RunBlueprint < Blueprinter::Base ([] << run.user).compact end + # API v4 promises non-null for these fields + field(:realtime_duration_ms) { |run, _| run.realtime_duration_ms || 0 } + field(:gametime_duration_ms) { |run, _| run.gametime_duration_ms || 0 } + view :convert do field :id do |_, _| nil diff --git a/app/blueprints/api/v4/segment_blueprint.rb b/app/blueprints/api/v4/segment_blueprint.rb index 8709609ef..9ddac48a6 100644 --- a/app/blueprints/api/v4/segment_blueprint.rb +++ b/app/blueprints/api/v4/segment_blueprint.rb @@ -1,9 +1,15 @@ class Api::V4::SegmentBlueprint < Blueprinter::Base fields :id, :segment_number, :name, - :realtime_start_ms, :realtime_end_ms, :realtime_duration_ms, :realtime_shortest_duration_ms, - :realtime_gold, :realtime_reduced, :realtime_skipped, - :gametime_start_ms, :gametime_end_ms, :gametime_duration_ms, :gametime_shortest_duration_ms, - :gametime_gold, :gametime_reduced, :gametime_skipped + :realtime_shortest_duration_ms, :realtime_gold, :realtime_reduced, :realtime_skipped, + :gametime_shortest_duration_ms, :gametime_gold, :gametime_reduced, :gametime_skipped association :histories, blueprint: Api::V4::SegmentHistoryBlueprint, if: ->(_, options) { options[:historic] } + + # API v4 promises non-null for these fields + field(:realtime_start_ms) { |run, _| run.realtime_start_ms || 0 } + field(:realtime_end_ms) { |run, _| run.realtime_end_ms || 0 } + field(:realtime_duration_ms) { |run, _| run.realtime_duration_ms || 0 } + field(:gametime_start_ms) { |run, _| run.gametime_start_ms || 0 } + field(:gametime_end_ms) { |run, _| run.gametime_end_ms || 0 } + field(:gametime_duration_ms) { |run, _| run.gametime_duration_ms || 0 } end diff --git a/app/blueprints/api/v4/user_blueprint.rb b/app/blueprints/api/v4/user_blueprint.rb index f3a7bfa67..a69450987 100644 --- a/app/blueprints/api/v4/user_blueprint.rb +++ b/app/blueprints/api/v4/user_blueprint.rb @@ -1,6 +1,10 @@ class Api::V4::UserBlueprint < Blueprinter::Base fields :name, :avatar, :created_at, :updated_at + field :id do |user, _| + user.id.to_s + end + field :display_name do |user, _| user.to_s end @@ -8,4 +12,8 @@ class Api::V4::UserBlueprint < Blueprinter::Base field :twitch_id do |user, _| user.twitch.try(:twitch_id) end + + field :twitch_name do |user, _| + user.twitch.try(:name) + end end diff --git a/app/channels/api/v4/application_channel.rb b/app/channels/api/v4/application_channel.rb new file mode 100644 index 000000000..d55392296 --- /dev/null +++ b/app/channels/api/v4/application_channel.rb @@ -0,0 +1,6 @@ +class Api::V4::ApplicationChannel < ApplicationCable::Channel + def transmit_user(type, msg, **extra) + ws_msg = Api::V4::WebsocketMessage.new(type, message: msg, **extra) + transmit(ws_msg.to_h) + end +end diff --git a/app/channels/api/v4/global_race_channel.rb b/app/channels/api/v4/global_race_channel.rb new file mode 100644 index 000000000..ee484557a --- /dev/null +++ b/app/channels/api/v4/global_race_channel.rb @@ -0,0 +1,21 @@ +class Api::V4::GlobalRaceChannel < Api::V4::ApplicationChannel + def subscribed + stream_from('v4:global_race_channel') + return unless params[:state] == '1' + + ws_msg = Api::V4::WebsocketMessage.new( + 'global_state', + message: 'Global race state', + races: Api::V4::RaceBlueprint.render_as_hash(Race.active.not_secret_visibility), + ) + transmit(ws_msg.to_h) + rescue StandardError => e + Rails.logger.error([e.message, *e.backtrace].join($RS)) + Rollbar.error(e, 'Uncaught error for Api::V4::GlobalRaceChannel#subscribed') + transmit_user('fatal_error', 'A fatal error occurred while processing your subscription request') + end + + def unsubscribed + stop_all_streams + end +end diff --git a/app/channels/api/v4/race_channel.rb b/app/channels/api/v4/race_channel.rb new file mode 100644 index 000000000..67ce18635 --- /dev/null +++ b/app/channels/api/v4/race_channel.rb @@ -0,0 +1,35 @@ +class Api::V4::RaceChannel < Api::V4::ApplicationChannel + def subscribed + @race = Race.find_by(id: params[:race_id]) + if @race.nil? + transmit_user('race_not_found', "No race found with id: #{params[:race_id]}") + reject + return + end + + if @race.secret_visibility? && !@race.joinable?(user: current_user, token: params[:join_token]) + transmit_user('race_invalid_join_token', 'The join token provided is not valid for this race') + reject + else + stream_for(@race) + stream_from("api:v4:race:#{@race.to_gid_param}:onsite") if onsite + return unless params[:state] == '1' + + ws_msg = Api::V4::WebsocketMessage.new( + 'race_state', + message: 'Race state', + race: Api::V4::RaceBlueprint.render_as_hash(@race, view: @race.type, chat: true) + ) + + transmit(ws_msg.to_h) + end + rescue StandardError => e + Rails.logger.error([e.message, *e.backtrace].join($RS)) + Rollbar.error(e, 'Uncaught error for Api::V4::RaceChannel#subscribed') + transmit_user('fatal_error', 'A fatal error occurred while processing your subscription request') + end + + def unsubscribed + stop_all_streams + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442f4..66865265a 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,4 +1,37 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + identified_by :current_user, :onsite + + def connect + # Identify if the user is 'onsite' so that pre-rendered html can be sent + self.current_user, self.onsite = find_verified_user + log_tag = current_user.try(:name) || SecureRandom.uuid + logger.add_tags('ActionCable', log_tag) + end + + protected + + def find_verified_user + onsite = ['splits.io', 'localhost'].include?(request.domain(1)) + passed_token = request.query_parameters[:access_token] + return [nil, onsite] if passed_token.nil? + + # If a token is explicitly passed in then error out instead of going into anonymous mode if it isn't valid + access_token = Doorkeeper::AccessToken.by_token(passed_token) + unless access_token + transmit(Api::V4::WebsocketMessage.new('connection_error', message: 'No access token found for passed token').to_h) + reject_unauthorized_connection unless access_token + end + + if access_token.expired? || !access_token.includes_scope?(:manage_race) + transmit(Api::V4::WebsocketMessage.new('connection_error', message: 'Token expired or does not include "manage_race" scope').to_h) + reject_unauthorized_connection + end + + user = User.find_by(id: access_token.try(:resource_owner_id)) + reject_unauthorized_connection if user.nil? + + [user, onsite] + end end end diff --git a/app/channels/run_channel.rb b/app/channels/run_channel.rb deleted file mode 100644 index 03b2022a5..000000000 --- a/app/channels/run_channel.rb +++ /dev/null @@ -1,6 +0,0 @@ -class RunChannel < ApplicationCable::Channel - def subscribed - run = Run.find36(params[:run_id]) - stream_for(run) - end -end diff --git a/app/controllers/api/v4/application_controller.rb b/app/controllers/api/v4/application_controller.rb index 43613440a..48ca93f31 100644 --- a/app/controllers/api/v4/application_controller.rb +++ b/app/controllers/api/v4/application_controller.rb @@ -10,15 +10,18 @@ def options end def read_only_mode - write_actions = %w[create edit destroy] - write_methods = %w[POST PUT DELETE PATCH] - if write_actions.include?(action_name) || write_methods.include?(request.method) - render template: 'pages/read_only_mode' - end + write_actions = %w[create edit destroy].freeze + write_methods = %w[POST PUT DELETE PATCH].freeze + return unless write_actions.include?(action_name) || write_methods.include?(request.method) + + render template: 'pages/read_only_mode' end private + # override authie's current_user methods for API, so we don't set or obey cookies + attr_accessor :current_user + def build_link_headers(links) links.map do |link| "<#{link[:url]}>; rel=\"#{link[:rel]}\"" @@ -27,9 +30,20 @@ def build_link_headers(links) def not_found(collection_name) { - status: 404, + status: :not_found, + json: { + status: 404, + error: "No #{collection_name} with ID #{params[collection_name] || params["#{collection_name}_id"] || params[:id]} found." + } + } + end + + # Add response body to unauthorized requests + def doorkeeper_unauthorized_render_options(error: nil) + { json: { - error: "No #{collection_name} with ID #{params[collection_name]} found." + status: 401, + error: 'Not authorized' } } end @@ -69,4 +83,37 @@ def verify_ownership! head 403 end end + + def set_user + return unless request.headers['Authorization'].present? || params[:access_token].present? + + doorkeeper_authorize!(:manage_race) + self.current_user = User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token + rescue ActiveRecord::RecordNotFound + render status: :unauthorized, json: { + status: 401, + error: 'No user found for this token' + } + end + + def validate_user + return unless current_user.nil? + + render status: :unauthorized, json: { + status: 401, + error: 'A user is required for this action' + } + end + + def set_race(param: :race_id) + @race = Race.find(params[param]) + return unless @race.secret_visibility? && !@race.joinable?(user: current_user, token: params[:join_token]) + + render status: :forbidden, json: { + status: 403, + error: 'Must be invited to see this race' + } + rescue ActiveRecord::RecordNotFound + render not_found(:race) + end end diff --git a/app/controllers/api/v4/games_controller.rb b/app/controllers/api/v4/games_controller.rb index 1fe3cf771..3411afa8f 100644 --- a/app/controllers/api/v4/games_controller.rb +++ b/app/controllers/api/v4/games_controller.rb @@ -3,10 +3,10 @@ class Api::V4::GamesController < Api::V4::ApplicationController def index if params[:search].blank? - render status: :bad_request, json: {status: 400, message: 'You must supply a `search` term.'} - return + @games = Game.joins(:srdc).includes(:categories) + else + @games = Game.search(params[:search]).includes(:categories) end - @games = Game.search(params[:search]).includes(:categories) render json: Api::V4::GameBlueprint.render(@games, root: :games, toplevel: :game) end diff --git a/app/controllers/api/v4/races/chat_messages_controller.rb b/app/controllers/api/v4/races/chat_messages_controller.rb new file mode 100644 index 000000000..3e73cc08f --- /dev/null +++ b/app/controllers/api/v4/races/chat_messages_controller.rb @@ -0,0 +1,41 @@ +class Api::V4::Races::ChatMessagesController < Api::V4::ApplicationController + before_action :set_user, only: %i[create] + before_action :validate_user, only: %i[create] + before_action :set_race, only: %i[index create] + before_action :set_messages, only: %i[index] + + def index + render status: :ok, json: Api::V4::ChatMessageBlueprint.render(paginate(@messages), root: :chat_messages) + end + + def create + chat_message = @race.chat_messages.new(chat_message_params.merge( + user: current_user, + from_entrant: @race.entries.find_for(current_user).present? + )) + if chat_message.save + render status: :created, json: Api::V4::ChatMessageBlueprint.render(chat_message, root: :chat_message) + Api::V4::MessageBroadcastJob.perform_later(@race, chat_message) + else + render status: :bad_request, json: { + status: 400, + error: chat_message.errors.full_messages.to_sentence + } + end + rescue ActionController::ParameterMissing + render status: :bad_request, json: { + status: 400, + error: 'Must specify a body like {"chat_message": {"body": "Hello world!"}}.' + } + end + + private + + def chat_message_params + params.require(:chat_message).permit(:body) + end + + def set_messages + @messages = @race.chat_messages.order(created_at: :desc) + end +end diff --git a/app/controllers/api/v4/races/entries_controller.rb b/app/controllers/api/v4/races/entries_controller.rb new file mode 100644 index 000000000..d1e5263fe --- /dev/null +++ b/app/controllers/api/v4/races/entries_controller.rb @@ -0,0 +1,115 @@ +class Api::V4::Races::EntriesController < Api::V4::ApplicationController + before_action :set_time, only: %i[update] + before_action :set_user + before_action :validate_user + before_action :set_race + before_action :check_permission, only: %i[create] + before_action :massage_params + before_action :set_entry, only: %i[show update destroy] + after_action :update_race, only: %i[update] + + def show + render status: :ok, json: Api::V4::EntryBlueprint.render(@entry, root: :entry) + end + + def create + entry = @race.entries.new(entry_params) + entry.runner = current_user # if this is a ghost, the validator will correct the runner + entry.creator = current_user + if entry.save + render status: :created, json: Api::V4::EntryBlueprint.render(entry, root: :entry) + Api::V4::RaceBroadcastJob.perform_later(@race, 'race_entries_updated', 'A user has joined the race') + Api::V4::GlobalRaceUpdateJob.perform_later(@race, 'race_entries_updated', 'A user has joined a race') + else + render status: :bad_request, json: { + status: 400, + error: entry.errors.full_messages.to_sentence + } + end + rescue ActionController::ParameterMissing + render status: :bad_request, json: { + status: 400, + error: 'Specifying at least one entry param is required, e.g. {"entry": {"readied_at": "now"}}' + } + end + + def update + if @entry.update(entry_params) + render status: :ok, json: Api::V4::EntryBlueprint.render(@entry, root: :entry) + updated = @entry.saved_changes.keys.reject { |k| k == 'updated_at' }.to_sentence + Api::V4::RaceBroadcastJob.perform_later( + @race, + 'race_entries_updated', + "An entry's #{updated} has changed" + ) + else + render status: :bad_request, json: { + status: 400, + error: @entry.errors.full_messages.to_sentence + } + end + rescue ActionController::ParameterMissing + render status: :bad_request, json: { + status: 400, + error: 'Missing parameter: "entry"' + } + end + + def destroy + if @entry.destroy + render status: :ok, json: {status: 200} + Api::V4::RaceBroadcastJob.perform_later(@race, 'race_entries_updated', 'A user has left the race') + Api::V4::GlobalRaceUpdateJob.perform_later(@race, 'race_entries_updated', 'An user has left a race') + else + render status: :conflict, json: { + status: 409, + error: @entry.errors.full_messages.to_sentence + } + end + end + + private + + def set_time + @now = Time.now.utc + end + + def check_permission + return if @race.joinable?(token: params[:join_token], user: current_user) + + render status: :forbidden, json: { + status: 403, + error: 'Must be invited to this race' + } + end + + def set_entry + @entry = @race.entries.find(params[:id]) + return if @entry.creator == current_user + + render status: :forbidden, json: { + status: 403, + error: 'Entry does not belong to current user' + } + rescue ActiveRecord::RecordNotFound + render not_found(:entry) + end + + def massage_params + params[:entry][:run_id] = params[:entry][:run_id].to_i(36) if params[:entry].try(:[], :run_id).present? + end + + def entry_params + params.select { |k, _| k[-3, -1] == '_at' && v == 'now' }.each do |k, _| + params[k] = @now + end + params[:entry].present? ? params.require(:entry).permit(:readied_at, :finished_at, :forfeited_at, :run_id) : {} + end + + def update_race + return unless response.status == 200 + + @race.maybe_start! + @race.maybe_end! + end +end diff --git a/app/controllers/api/v4/races_controller.rb b/app/controllers/api/v4/races_controller.rb new file mode 100644 index 000000000..68adb536c --- /dev/null +++ b/app/controllers/api/v4/races_controller.rb @@ -0,0 +1,90 @@ +class Api::V4::RacesController < Api::V4::ApplicationController + before_action :set_user, only: %i[create show update] + before_action :validate_user, only: %i[create] + before_action :set_race, only: %i[show update] + before_action :set_races, only: %i[index] + before_action :check_permission, only: %i[show] + before_action :check_owner, only: %i[update] + + def index + render json: Api::V4::RaceBlueprint.render(@races, root: :races) + end + + def create + @race = Race.create(race_params.merge(owner: current_user)) + if @race.persisted? + Api::V4::GlobalRaceUpdateJob.perform_later(@race, 'race_created', 'A new race has been created') + render status: :created, json: Api::V4::RaceBlueprint.render(@race, root: :race, join_token: true) + else + render status: :bad_request, json: { + status: 400, + error: @race.errors.full_messages.to_sentence + } + end + end + + def show + render json: Api::V4::RaceBlueprint.render(@race, root: :race, chat: true) + end + + def update + if params[:race][:attachments].present? + @race.attachments.attach(params[:race][:attachments]) + Api::V4::RaceBroadcastJob.perform_later(@race, 'new_attachment', 'The race has a new attachment') + end + + if @race.update(race_params) + updated = @race.saved_changes.keys.reject { |k| k == 'updated_at' }.to_sentence + Api::V4::RaceBroadcastJob.perform_later(@race, 'race_updated', "The race's #{updated} has changed") if updated.present? + render status: :ok, json: Api::V4::RaceBlueprint.render(@race, root: :race, chat: true) + else + render status: :bad_request, json: { + status: 400, + error: @race.errors.full_messages.to_sentence + } + end + rescue ActionController::ParameterMissing + render status: :bad_request, json: { + status: 400, + error: 'Missing parameter: "race"' + } + end + + private + + def race_params + params.require(:race).permit(:visibility, :notes, :category_id, :game_id) + end + + def set_race + super(param: :id) + end + + def set_races + if params[:historic] == '1' + @races = paginate(Race.finished.not_secret_visibility.order(started_at: :desc)) + else + @races = paginate(Race.active.not_secret_visibility) + end + + @races.includes(:category, :user, game: :srdc, entries: {runner: [:google, :twitch], creator: [:google, :twitch]}) + end + + def check_permission + return unless @race.secret_visibility? && !@race.joinable?(token: params[:join_token], user: current_user) + + render status: :unauthorized, json: { + status: 403, + error: 'Invalid join token for secret race lookup.' + } + end + + def check_owner + return if @race.belongs_to?(current_user) + + render :unauthorized, json: { + status: 401, + error: "User must own race to perform this action" + } + end +end diff --git a/app/controllers/api/v4/runners_controller.rb b/app/controllers/api/v4/runners_controller.rb index 9574c1dcb..e09b3c27c 100644 --- a/app/controllers/api/v4/runners_controller.rb +++ b/app/controllers/api/v4/runners_controller.rb @@ -3,7 +3,7 @@ class Api::V4::RunnersController < Api::V4::ApplicationController def index if params[:search].blank? - render status: :bad_request, json: {status: :bad_request, message: 'You must supply a `search` term.'} + render status: :bad_request, json: {status: 400, message: 'You must supply a `search` term.'} return end @runners = User.search(params[:search]) diff --git a/app/controllers/api/v4/runs/source_files_controller.rb b/app/controllers/api/v4/runs/source_files_controller.rb new file mode 100644 index 000000000..74f632ecd --- /dev/null +++ b/app/controllers/api/v4/runs/source_files_controller.rb @@ -0,0 +1,50 @@ +class Api::V4::Runs::SourceFilesController < Api::V4::ApplicationController + before_action :set_run, only: [:show, :update] + before_action only: [:update] do + # If an OAuth token is supplied, use it (and fail if it's invalid). + doorkeeper_authorize! :upload_run + self.current_user = User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token + end + + def show + render( + status: :see_other, + location: $s3_bucket_internal.object("splits/#{@run.s3_filename}").presigned_url( + :get, + response_content_disposition: "attachment; filename=\"#{@run.filename}\"" + ), + json: { + status: :see_other, + message: 'The run source file is located at the URL in the Location header. The link is valid for 15 minutes.', + } + ) + end + + def update + presigned_request = $s3_bucket_external.presigned_post( + key: "splits/#{@run.s3_filename}", + content_length_range: 1..(25 * 1024 * 1024), + ) + + render status: :accepted, location: api_v4_run_url(@run), json: { + status: :accepted, + message: 'Run file ready to be replaced. Use the included presigned request to upload the file to S3, with an additional `file` field containing the run file.', + id: @run.id36, + uris: { + api_uri: api_v4_run_url(@run), + public_uri: run_url(@run), + }, + presigned_request: { + method: 'POST', + uri: presigned_request.url, + fields: presigned_request.fields, + } + } + end + + private + + def source_file_params + params.permit(:run) + end +end diff --git a/app/controllers/api/v4/runs_controller.rb b/app/controllers/api/v4/runs_controller.rb index a07104596..e0819f28d 100644 --- a/app/controllers/api/v4/runs_controller.rb +++ b/app/controllers/api/v4/runs_controller.rb @@ -132,7 +132,10 @@ def render_run_to_string(timer) if timer == Run.program(@run.timer) @run.file else - render_to_string(file: "runs/exports/#{timer.to_sym}.html.erb", layout: false) + render_to_string( + file: Rails.root.join('app', 'views', 'runs', 'exports', "#{timer.to_sym}.html.erb"), + layout: false + ) end end diff --git a/app/controllers/api/v4/time_controller.rb b/app/controllers/api/v4/time_controller.rb new file mode 100644 index 000000000..f17f821d8 --- /dev/null +++ b/app/controllers/api/v4/time_controller.rb @@ -0,0 +1,9 @@ +class Api::V4::TimeController < Api::V4::ApplicationController + def create + render status: :ok, json: { + status: 200, + id: params[:id], + result: Time.now.utc.to_f * 1000 + } + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 463b06b7f..457345e56 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -50,7 +50,7 @@ def set_gon gon.user = if current_user.nil? nil else - {id: current_user.id, name: current_user.name} + {id: current_user.id.to_s, name: current_user.name} end end diff --git a/app/controllers/games_controller.rb b/app/controllers/games_controller.rb index d122e4154..c67289dd6 100644 --- a/app/controllers/games_controller.rb +++ b/app/controllers/games_controller.rb @@ -1,14 +1,14 @@ class GamesController < ApplicationController - before_action :set_game, only: %i[show edit update] - before_action :set_games, only: %i[index] - before_action :authorize, only: %i[edit update] + before_action :set_game, only: %i[show edit update] + before_action :set_games, only: %i[index] + before_action :authorize, only: %i[edit update] def index end def show @on_game_page = true - @category = @game.categories.joins(:runs).group('categories.id').order('count(runs.id) desc').first + @category = @game.categories.joins(:runs).group('categories.id').order(Arel.sql('count(runs.id) desc')).first if @category.nil? render :not_found, status: :not_found return @@ -34,10 +34,7 @@ def set_game redirect_to game_path(@game) if @game.srdc.try(:shortname).present? && params[:game] == @game.id.to_s rescue ActiveRecord::RecordNotFound - if @game.nil? - redirect_to games_path(q: params[:game]) - return - end + redirect_to games_path(q: params[:game]) if @game.nil? end def set_games diff --git a/app/controllers/races_controller.rb b/app/controllers/races_controller.rb new file mode 100644 index 000000000..9e73a04d9 --- /dev/null +++ b/app/controllers/races_controller.rb @@ -0,0 +1,62 @@ +class RacesController < ApplicationController + before_action :set_races, only: [:index] + before_action :set_race, only: [:show, :update] + before_action :check_permission, only: [:show] + before_action :set_race_gon, only: [:show] + before_action :shorten_url, only: [:show] + + def index + end + + def show + return unless @race.abandoned? && !@race.secret_visibility? + + flash.now.alert = 'This race is abandoned, it will not show up in listings! Readying, joining, or leaving will list it again.' + end + + private + + def set_races + @races = Race.active.not_secret_visibility + end + + def check_permission + return unless @race.secret_visibility? + return if @race.joinable?(token: race_params[:join_token], user: current_user) + + render :unauthorized, status: :unauthorized + end + + def set_race_gon + return if @race.locked? + + token = @race.entries.find_for(current_user).present? ? @race.join_token : nil + gon.race = { + id: @race.id, + join_token: token || race_params[:join_token] + } + end + + def race_params + params.permit(:join_token, :attachment) + end + + def set_race + @race = Race.friendly_find!(params[:id]) + rescue ActiveRecord::RecordNotFound + render 'application/not_found' + end + + # shorten_url cuts the race ID down to its shortest unique form, and strips the join token param if it's not needed + # (public race or the user already joined). + def shorten_url + if @race.visibility == :public || @race.entries.find_for(current_user).present? + desired_fullpath = race_path(@race) + redirect_to desired_fullpath if desired_fullpath != request.fullpath + return + end + + desired_fullpath = race_path(@race, race_params.to_hash.slice('join_token')) + redirect_to desired_fullpath if desired_fullpath != request.fullpath + end +end diff --git a/app/controllers/users/pbs_controller.rb b/app/controllers/users/pbs_controller.rb index 877f6f49b..32deb233a 100644 --- a/app/controllers/users/pbs_controller.rb +++ b/app/controllers/users/pbs_controller.rb @@ -8,7 +8,7 @@ def show if params[:trailing_path].nil? redirect_to run_path(@user.pb_for(Run::REAL, @category)) else - redirect_to "#{run_path(@user.pb_for(@category))}/#{params[:trailing_path]}" + redirect_to "#{run_path(@user.pb_for(Run::REAL, @category))}/#{params[:trailing_path]}" end else redirect_to user_path(@user), alert: 'Redirectors are not enabled for this account.' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index df87e9a2a..7399f8bf8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,6 @@ module ApplicationHelper def site_title - ENV['SITE_TITLE'] || 'Splits I/O' + ENV['SITE_TITLE'] || 'Splits.io' end def order_runs(runs) @@ -31,33 +31,33 @@ def user_badge(user, override: nil) if override == :gold badge = 'badge-warning' - title = "#{user} is a Splits I/O patron!" + title = "#{user} is a Splits.io patron!" end if override == :silver badge = 'badge-secondary' - title = "#{user} is a Splits I/O patron!" + title = "#{user} is a Splits.io patron!" end - return link_to(user, user_path(user), class: ['badge', badge, ('tip-top' if title.present?)], title: title) + return link_to(user, user_path(user), class: ['badge', badge, ('tip-top' if title.present?)], title: title, 'v-tippy' => true, ':title' => "'#{title}'") end if user.patron? badge = 'badge-secondary' - title = "#{user} is a Splits I/O patron!" + title = "#{user} is a Splits.io patron!" end if user.patron?(tier: 2) badge = 'badge-warning' - title = "#{user} is a Splits I/O patron!" + title = "#{user} is a Splits.io patron!" end if user.admin? badge = 'badge-danger' - title = "#{user} is a Splits I/O staff member!" + title = "#{user} is a Splits.io staff member!" end - link_to(user, user_path(user), class: ['badge', badge, ('tip-top' if title.present?)], title: title) + link_to(user, user_path(user), class: ['badge', badge, ('tip-top' if title.present?)], title: title, 'v-tippy' => true, ':title' => "'#{title}'") end def game_badge(game) @@ -66,10 +66,10 @@ def game_badge(game) link_to(game.srdc.try(:shortname), game_path(game), class: 'badge badge-primary', title: game.name) end - # patreon_url returns the URL for the Splits I/O Patreon page. If checkout is true, it returns the URL for the + # patreon_url returns the URL for the Splits.io Patreon page. If checkout is true, it returns the URL for the # checkout page -- use this if the UX of your situation implies the user already decided to contribute. If checkout is # :bronze, :silver, or :gold, it returns the URL for the checkout flow for the corresponding tier (i.e. one click past - # a checkout of true; two clicks past the Splits I/O Patreon page). + # a checkout of true; two clicks past the Splits.io Patreon page). def patreon_url(checkout: false) return 'https://www.patreon.com/join/glacials/checkout?rid=493467' if checkout == :bronze return 'https://www.patreon.com/join/glacials/checkout?rid=493468' if checkout == :silver diff --git a/app/helpers/entries_helper.rb b/app/helpers/entries_helper.rb new file mode 100644 index 000000000..dae38c3f4 --- /dev/null +++ b/app/helpers/entries_helper.rb @@ -0,0 +1,30 @@ +module EntriesHelper + def entry_color(entry) + if entry.forfeited? + 'text-danger' + elsif entry.finished? + case entry.place + when 1 + return 'text-gold' + when 2 + return 'text-silver' + when 3 + return 'text-bronze' + else + return 'text-light' + end + else + '' + end + end + + def entry_place(entry) + if entry.forfeited? + icon('fas', 'times') + elsif entry.finished? && entry.finished_at < Time.now.utc + entry.place.ordinalize + else + '-' + end + end +end diff --git a/app/helpers/races_helper.rb b/app/helpers/races_helper.rb new file mode 100644 index 000000000..7deb48073 --- /dev/null +++ b/app/helpers/races_helper.rb @@ -0,0 +1,19 @@ +module RacesHelper + def statcard_class(race) + case + when !race.started? + 'text-success' + when race.in_progress? + 'text-warning' + when race.finished? + nil + else + raise 'invalid status' + end + end + + # Overrides the default helper because Race#to_param returns a user-friendly ID + def api_v4_race_path(race) + super(race.id) + end +end diff --git a/app/helpers/runs_helper.rb b/app/helpers/runs_helper.rb index 08cb613a6..a19bef5ae 100644 --- a/app/helpers/runs_helper.rb +++ b/app/helpers/runs_helper.rb @@ -65,14 +65,14 @@ def pretty_duration(seconds) "#{Duration.new(ms).format}".html_safe end - def pretty_difference(my_ms, their_ms) - diff_ms = (my_ms - their_ms) + def pretty_difference(my_duration, their_duration) + diff_duration = my_duration - their_duration - return "-#{Duration.new(diff_ms.abs).format_casual}".html_safe if diff_ms.negative? + return "-#{diff_duration.abs.format_casual}".html_safe if diff_duration.negative? - return "+#{Duration.new(diff_ms).format_casual}".html_safe if diff_ms.positive? + return "+#{diff_duration.format_casual}".html_safe if diff_duration.positive? - "+#{Duration.new(diff_ms).format_casual}".html_safe + "+#{diff_duration.format_casual}".html_safe end # format_ms is deprecated. Use Duration.new(milliseconds).format instead. diff --git a/app/javascript/channels/consumer.js b/app/javascript/channels/consumer.js index b2c922bbb..872ef614f 100644 --- a/app/javascript/channels/consumer.js +++ b/app/javascript/channels/consumer.js @@ -1,3 +1,19 @@ -import { createConsumer } from '@rails/actioncable' +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `rails generate channel` command. -export default createConsumer() +import { createConsumer } from "@rails/actioncable" + +import { getAccessToken } from '../token' + +function getWebsocketURL() { + const token = getAccessToken() + let url = '/api/cable' + if (token) { + url += `?access_token=${token}` + } + + return url + +} + +export default createConsumer(getWebsocketURL) diff --git a/app/javascript/channels/run_channel.js b/app/javascript/channels/run_channel.js deleted file mode 100644 index d69afe94a..000000000 --- a/app/javascript/channels/run_channel.js +++ /dev/null @@ -1,17 +0,0 @@ -import consumer from './consumer' - -document.addEventListener('turbolinks:load', () => { - if (!window.gon || !window.gon.run) { - return - } - - const runSubscription = consumer.subscriptions.create({channel: 'RunChannel', run_id: window.gon.run.id}, { - received(payload) { - document.getElementById('time-since-upload').textContent = payload.time_since_upload - } - }) - - document.addEventListener('turbolinks:visit', () => { - consumer.subscriptions.remove(runSubscription) - }, {once: true}) -}) diff --git a/app/javascript/count.js b/app/javascript/count.js new file mode 100644 index 000000000..58f700769 --- /dev/null +++ b/app/javascript/count.js @@ -0,0 +1,31 @@ +import {setDriftlessInterval} from 'driftless' +const moment = require('moment') +require('moment-duration-format')(moment) + +// In this file, we attach to load instead of turbolinks:load because the functions re-find DOM elements every tick, so +// we can re-use the same listener rather than attaching a new one every turbolinks:load. We choose to re-find DOM +// elements every tick because ActionCable can replace our element with a new one rendered server-side at any moment. + +// Use data-abstime= on elements you want to tick up like "01:23:45.678". +window.addEventListener('load', () => { + setDriftlessInterval(() => { + Array.from(document.querySelectorAll('[data-abstime]')).forEach(el => { + const diffMS = moment().diff(moment(el.dataset.abstime)) + el.textContent = moment.duration(diffMS).format('HH:mm:ss.SS', {trim: false}) + if (diffMS < 0) { + el.classList.add('bg-danger') + } else { + el.classList.remove('bg-danger') + } + }) + }, 10) +}) + +// Use data-reltime= on elements you want to tick up like "3 minutes ago". +window.addEventListener('load', () => { + setInterval(() => { + Array.from(document.querySelectorAll('[data-reltime]')).forEach(el => { + el.textContent = moment(el.dataset.reltime).fromNow(true) + }) + }, 1000) +}) diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 6a8510547..f1180caf4 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -8,6 +8,7 @@ // layout file, like app/views/layouts/application.html.erb import "jquery" +require("@rails/activestorage").start() require("@rails/ujs").start() require("turbolinks").start() require("channels") @@ -24,10 +25,12 @@ window.Chartkick = Chartkick import "../ad_cleanup.js" import "../analytics.js" import "../convert.js" +import "../count.js" import "../highchart_theme.js" import "../chart_builder.js" import "../landing.js" import "../like.js" +import "../race_attach.js" import "../run_claim.js" import '../run_delete.js' import '../run_disown.js' diff --git a/app/javascript/packs/race.js b/app/javascript/packs/race.js new file mode 100644 index 000000000..ab2c479d7 --- /dev/null +++ b/app/javascript/packs/race.js @@ -0,0 +1,22 @@ +/* eslint no-console: 0 */ +import Vue from 'vue/dist/vue.esm' + +import TurbolinksAdapter from 'vue-turbolinks' +import VueTippy from 'vue-tippy' + +import race from '../vue/race.js' +import raceCreate from '../vue/race-create.js' + +Vue.use(TurbolinksAdapter) +Vue.use(VueTippy) + +document.addEventListener('turbolinks:load', () => { + if (document.getElementById('vue-race') === null) { + return + } + + const app = new Vue({ + el: '#vue-race', + components: { race, raceCreate }, + }) +}) diff --git a/app/javascript/race_attach.js b/app/javascript/race_attach.js new file mode 100644 index 000000000..a123036f1 --- /dev/null +++ b/app/javascript/race_attach.js @@ -0,0 +1,9 @@ +document.addEventListener('direct-uploads:end', () => { + Array.from(document.querySelectorAll('form > input[type=file]')).forEach(input => { + input.value = null + input.disabled = false + }) + Array.from(document.querySelectorAll('form > input[type=submit]')).forEach(input => { + input.disabled = false + }) +}) diff --git a/app/javascript/run_claim.js b/app/javascript/run_claim.js index 4475b7d1e..7f945e9a2 100644 --- a/app/javascript/run_claim.js +++ b/app/javascript/run_claim.js @@ -9,7 +9,7 @@ // e.g. "dismissed_claim_tokens/gcb": "wYJm4S9uAAra6TMyzLkvhC5y" const activateClaimLink = function(claimToken) { - if(window.gon.user !== null) { + if(window.gon.user !== null && document.getElementById('claim-nav-link') !== null) { document.getElementById('claim-nav-link').href = `/${window.gon.run.id}?claim_token=${claimToken}` } diff --git a/app/javascript/search.js b/app/javascript/search.js index b969066b1..7aa93df1f 100644 --- a/app/javascript/search.js +++ b/app/javascript/search.js @@ -1,4 +1,5 @@ -import Bloodhound from 'typeahead.js' +require('corejs-typeahead') +import Bloodhound from 'corejs-typeahead' import Handlebars from 'handlebars' document.addEventListener("turbolinks:before-cache", function() { diff --git a/app/javascript/time.js b/app/javascript/time.js new file mode 100644 index 000000000..920def218 --- /dev/null +++ b/app/javascript/time.js @@ -0,0 +1,15 @@ +const timesync = require('timesync/dist/timesync') + +let ts + +document.addEventListener('turbolinks:load', () => { + if (window.gon && window.gon.race && !ts) { + ts = timesync.create({ + server: '/api/v4/timesync', + // 10 minute refresh interval + interval: 10 * 60 * 1000 + }) + } +}) + +export { ts } diff --git a/app/javascript/token.js b/app/javascript/token.js index be812aaa9..69bb89b3f 100644 --- a/app/javascript/token.js +++ b/app/javascript/token.js @@ -1,3 +1,5 @@ +import consumer from 'channels/consumer' + const accessTokenKey = 'splitsio_access_token' const accessTokenExpiryKey = 'splitsio_access_token_expiry' @@ -7,9 +9,30 @@ const urlHashToObject = function(hash) { return params } +// Returns undefined if token is not set, null if expired, or the token as a string +const getAccessToken = function() { + const accessToken = localStorage.getItem(accessTokenKey) + const expireTime = localStorage.getItem(accessTokenExpiryKey) + if (accessToken === undefined || expireTime === undefined) { + return undefined + } + + if (new Date() > new Date(expireTime)) { + // If the token is expired return null to indicate the token shouldn't be used + return null + } + + return accessToken +} + document.addEventListener('turbolinks:load', () => { // Protect dev modes that haven't set up a client yet - if (process.env.SPLITSIO_CLIENT_ID === undefined || gon.user === null) { + if (process.env.SPLITSIO_CLIENT_ID === undefined) { + console.warn("SPLITSIO_CLIENT_ID & SPLITSIO_CLIENT_SECRET not set in .envrc. Some features like races won't work.") + } + if (!gon.user) { + localStorage.removeItem(accessTokenKey) + localStorage.removeItem(accessTokenExpiryKey) return } @@ -19,7 +42,7 @@ document.addEventListener('turbolinks:load', () => { const accessTokenExpiry = localStorage.getItem(accessTokenExpiryKey) const expiry = Date.parse(accessTokenExpiry) - if (accessToken === null || accessTokenExpiry === null || isNaN(expiry)) { + if (accessToken === null || accessTokenExpiry === null || isNaN(expiry) || accessToken === 'undefined') { localStorage.removeItem(accessTokenKey) localStorage.removeItem(accessTokenExpiryKey) } @@ -33,7 +56,7 @@ document.addEventListener('turbolinks:load', () => { const iframe = document.createElement('iframe') const params = { response_type: 'token', - scope: 'upload_run', + scope: 'upload_run+manage_race', redirect_uri: `${window.location.origin}/auth/splitsio/callback`, client_id: process.env.SPLITSIO_CLIENT_ID } @@ -45,8 +68,30 @@ document.addEventListener('turbolinks:load', () => { const expiry = new Date() expiry.setSeconds(expiry.getSeconds() + Number(hash.expires_in)) + if (accessToken === 'undefined') { + console.warn("Can't get a Splits.io access token. Your SPLITSIO_CLIENT_ID/SPLITSIO_CLIENT_SECRET may be wrong.") + } + localStorage.setItem(accessTokenKey, hash.access_token) localStorage.setItem(accessTokenExpiryKey, expiry) document.body.removeChild(iframe) + consumer.connection.close() + consumer.connection.open() } }) + +// Add a hidden 'access_token' field to form.auth-me elements. The value is the browser's locally stored Splits.io +// access token. +document.addEventListener('turbolinks:load', () => { + Array.from(document.querySelectorAll('form.auth-me')).forEach(el => { + el.classList.remove('auth-me') + + const tokenField = document.createElement('input') + tokenField.type = 'hidden' + tokenField.name = 'access_token' + tokenField.value = localStorage.getItem(accessTokenKey) + el.appendChild(tokenField) + }) +}) + +export { getAccessToken } diff --git a/app/javascript/tooltips.js b/app/javascript/tooltips.js index 264f43ec2..e6384e2fa 100644 --- a/app/javascript/tooltips.js +++ b/app/javascript/tooltips.js @@ -1,10 +1,14 @@ import tippy from "tippy.js" -document.addEventListener('turbolinks:load', function() { +document.addEventListener('turbolinks:load', applyTips) + +function applyTips() { tippy('.gold-split', {placement: 'left'}) tippy('.tip', {placement: 'top'}) tippy('.tip-top', {placement: 'top'}) tippy('.tip-bottom', {placement: 'bottom'}) tippy('.tip-right', {placement: 'right'}) tippy('.tip-left', {placement: 'left'}) -}) +} + +export { applyTips } diff --git a/app/javascript/vue/race-chat.js b/app/javascript/vue/race-chat.js new file mode 100644 index 000000000..524459327 --- /dev/null +++ b/app/javascript/vue/race-chat.js @@ -0,0 +1,46 @@ +import { getAccessToken } from '../token' + +export default { + data: () => ({ + body: '', + error: null, + loading: false, + }), + methods: { + chat: async function() { + this.error = false + this.loading = true + + const headers = new Headers() + headers.append('Content-Type', 'application/json') + const accessToken = getAccessToken() + if (accessToken) { + headers.append('Authorization', `Bearer ${accessToken}`) + } + + try { + const response = fetch(`/api/v4/races/${this.race.id}/chat`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + body: this.body, + }) + }) + + this.body = '' + + if (!(await response).ok) { + throw (await response.json()).error || response.statusText + } + + } catch(error) { + this.error = error + } finally { + this.loading = false + document.getElementById('input-chat-text').focus() + } + }, + }, + name: 'race-chat', + props: ['race'], +} diff --git a/app/javascript/vue/race-create.js b/app/javascript/vue/race-create.js new file mode 100644 index 000000000..523a36d0f --- /dev/null +++ b/app/javascript/vue/race-create.js @@ -0,0 +1,54 @@ +import { getAccessToken } from '../token' + +export default { + data: () => ({ + error: null, + loading: false, + }), + methods: { + createPublic: async function() { + return this.create('public') + }, + createSecret: async function() { + return this.create('secret') + }, + createInviteOnly: async function() { + return this.create('invite_only') + }, + create: async function(visibility) { + try { + this.loading = true + this.error = null + + const headers = new Headers() + headers.append('Content-Type', 'application/json') + const accessToken = getAccessToken() + if (accessToken) { + headers.append('Authorization', `Bearer ${accessToken}`) + } + + const response = await fetch(`/api/v4/races`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + game_id: this.gameId, + category_id: this.categoryId, + visibility: visibility, + }) + }) + + if (!response.ok) { + throw (await response.json()).error || response.statusText + } + + Turbolinks.visit((await response.json()).race.path) + } catch(error) { + this.error = `Error: ${error}` + } finally { + this.loading = false + } + }, + }, + name: 'race-create', + props: ['game-id', 'category-id'], +} diff --git a/app/javascript/vue/race-disclaimer.js b/app/javascript/vue/race-disclaimer.js new file mode 100644 index 000000000..c70884bc2 --- /dev/null +++ b/app/javascript/vue/race-disclaimer.js @@ -0,0 +1,11 @@ +export default { + data: () => ({ + accepted: false, + }), + methods: { + accept: function() { + this.accepted = true + } + }, + name: 'race-disclaimer', +} diff --git a/app/javascript/vue/race-nav.js b/app/javascript/vue/race-nav.js new file mode 100644 index 000000000..66a429757 --- /dev/null +++ b/app/javascript/vue/race-nav.js @@ -0,0 +1,162 @@ +import { ts } from '../time' +import { getAccessToken } from '../token' + +export default { + created: function() { + this.currentUser = gon.user + if (this.currentUser === null) { + return + } + + this.entry = this.race.entries.find(entry => (entry.runner.id === this.currentUser.id) && !entry.ghost) + }, + data: () => ({ + currentUser: null, + entry: null, + errors: {}, + loading: { + finish: false, + forfeit: false, + join: false, + leave: false, + ready: false, + unfinish: false, + unforfeit: false, + unready: false, + }, + }), + methods: { + finish: async function() { + this.errors.finish = false + this.loading.finish = true + try { + await this.updateEntry({finished_at: new Date(ts.now())}) + } catch(error) { + this.errors.finish = `Error: ${error}` + } finally { + this.loading.finish = false + } + }, + forfeit: async function() { + this.errors.forfeit = false + this.loading.forfeit = true + try { + await this.updateEntry({forfeited_at: new Date(ts.now())}) + } catch(error) { + this.errors.forfeit = `Error: ${error}` + } finally { + this.loading.forfeit = false + } + }, + join: async function() { + this.errors.join = false + this.loading.join = true + try { + await this.updateEntry({}, 'POST') + } catch(error) { + this.errors.join = `Error: ${error}` + } finally { + this.loading.join = false + } + }, + leave: async function() { + this.errors.leave = false + this.loading.leave = true + try { + await this.updateEntry({}, 'DELETE') + this.entry = null + } catch(error) { + this.errors.leave = `Error: ${error}` + } finally { + this.loading.leave = false + } + }, + ready: async function() { + this.errors.ready = false + this.loading.ready = true + try { + await this.updateEntry({readied_at: new Date(ts.now())}) + } catch(error) { + this.errors.ready = `Error: ${error}` + } finally { + this.loading.ready = false + } + }, + unfinish: async function() { + this.errors.unfinish = false + this.loading.unfinish = true + try { + await this.updateEntry({finished_at: null}) + } catch(error) { + this.errors.unfinish = `Error: ${error}` + } finally { + this.loading.unfinish = false + } + }, + unforfeit: async function() { + this.errors.unforfeit = false + this.loading.unforfeit = true + try { + await this.updateEntry({forfeited_at: null}) + } catch(error) { + this.errors.unforfeit = `Error: ${error}` + } finally { + this.loading.unforfeit = false + } + }, + unready: async function() { + this.errors.unready = false + this.loading.unready = true + try { + await this.updateEntry({readied_at: null}) + } catch(error) { + this.errors.unready = `Error: ${error}` + } finally { + this.loading.unready = false + } + }, + updateEntry: async function(params, method = 'PATCH') { + // Protect against disconnections ruining times -- save the request for later if we're offline + if (!navigator.onLine) { + await new Promise(function(resolve, reject) { + window.setInterval(() => { + if (navigator.onLine) { + resolve() + } + }, 1000) + }) + } + + let path + if (method === 'POST') { + path = `/api/v4/races/${this.race.id}/entries` + } else { + path = `/api/v4/races/${this.race.id}/entries/${this.entry.id}` + } + + const headers = new Headers() + headers.append('Content-Type', 'application/json') + const accessToken = getAccessToken() + if (accessToken) { + headers.append('Authorization', `Bearer ${accessToken}`) + } + + const response = await fetch(path, { + method: method, + headers: headers, + body: JSON.stringify({ + entry: params, + join_token: (new URLSearchParams(window.location.search)).get('join_token') + }), + }) + + if (!response.ok) { + throw (await response.json()).error || response.statusText + } + + this.entry = (await response.json()).entry + }, + }, + name: 'race-nav', + props: ['race'], +} diff --git a/app/javascript/vue/race-streams.js b/app/javascript/vue/race-streams.js new file mode 100644 index 000000000..9f8eec272 --- /dev/null +++ b/app/javascript/vue/race-streams.js @@ -0,0 +1,59 @@ +import Multiselect from 'vue-multiselect' + +export default { + components: { + Multiselect + }, + computed: { + options: function() { + return this.race.entries.filter(entry => !entry.ghost && entry.creator.twitch_name !== null).map(entry => { + return {name: entry.creator.display_name, id: entry.creator.twitch_name} + }) + }, + + finished: function() { + return this.race.entries.every(entry => entry.forfeited_at || entry.finished_at) + } + }, + watch: { + options: function(entries) { + const currentEntries = entries.map(entry => entry.id) + this.value = this.value.filter(entry => currentEntries.includes(entry.id)) + } + }, + created: function() { + + }, + data: () => ({ + value: [] + }), + methods: { + ratioHeight: function(div) { + const width = div.offsetWidth + + return (width / 16) * 9 + } + }, + + updated: function() { + this.value.forEach(stream => { + const div = document.getElementById(`twitch-${stream.id}`) + const child = div.firstChild + if (div.dataset.loaded === '1') { + child.height = this.ratioHeight(div) + return + } + + div.dataset.loaded = '1' + new Twitch.Player(div.id, { + autoplay: true, + channel: stream.id, + muted: true, + height: this.ratioHeight(div), + width: '100%' + }) + }) + }, + name: 'race-streams', + props: ['race'] +} diff --git a/app/javascript/vue/race-title.js b/app/javascript/vue/race-title.js new file mode 100644 index 000000000..e6e09ef29 --- /dev/null +++ b/app/javascript/vue/race-title.js @@ -0,0 +1,99 @@ +import raceNav from './race-nav.js' +import { getAccessToken } from '../token' + +export default { + components: { + raceNav, + }, + computed: { + categories: function() { + if (!this.game) { + return [] + } + + return [{id: null, name: ''}, ...this.game.categories] + }, + category: function() { + return this.categories.find(category => category.id === this.categoryId) + }, + game: function() { + return this.games.find(game => game.id === this.gameId) + }, + title: function() { + if (this.race === null) { + return '' + } + if (this.race.game === null && this.race.category === null && this.race.notes === null) { + return 'Untitled race' + } + return `${(this.race.game || {}).name} ${(this.race.category || {name: ''}).name} ${(this.race.notes || '').split('\n')[0]}` + }, + }, + created: async function() { + this.notes = this.race.notes + this.games = (await (fetch('/api/v4/games').then(response => response.json()))).games + + this.gameId = (this.race.game || {id: null}).id + this.categoryId = (this.race.category || {id: null}).id + }, + data: () => ({ + categoryId: null, + editing: false, + error: null, + gameId: null, + games: [], + loading: false, + notes: '', + }), + methods: { + cancel: function() { + this.editing = false + }, + edit: async function() { + this.editing = true + }, + save: async function() { + this.error = false + this.loading = true + this.editing = false + + const headers = new Headers() + headers.append('Content-Type', 'application/json') + const accessToken = getAccessToken() + if (accessToken) { + headers.append('Authorization', `Bearer ${accessToken}`) + } + + try { + const response = fetch(`/api/v4/races/${this.race.id}`, { + method: 'PATCH', + headers: headers, + body: JSON.stringify({ + category_id: this.categoryId, + game_id: this.game.id, + notes: this.notes, + }) + }) + + if (!(await response).ok) { + throw (await response.json()).error || response.statusText + } + + } catch(error) { + this.error = error + } finally { + this.loading = false + document.getElementById('input-chat-text').focus() + } + }, + }, + name: 'race-title', + props: ['race'], + watch: { + gameId: function() { + if (this.game.categories.find(category => category.id === this.categoryId) === undefined) { + this.categoryId = null + } + }, + }, +} diff --git a/app/javascript/vue/race.js b/app/javascript/vue/race.js new file mode 100644 index 000000000..5db93107e --- /dev/null +++ b/app/javascript/vue/race.js @@ -0,0 +1,131 @@ +const moment = require('moment') +require("moment-duration-format")(moment) + +import { applyTips } from '../tooltips' +import { getAccessToken } from '../token' + +import consumer from '../channels/consumer' +import raceChat from './race-chat.js' +import raceDisclaimer from './race-disclaimer.js' +import raceTitle from './race-title.js' +import raceStreams from './race-streams.js' + +export default { + components: { + raceChat, + raceDisclaimer, + raceTitle, + raceStreams + }, + created: async function() { + this.error = false + + const headers = new Headers() + const accessToken = getAccessToken() + if (accessToken) { + headers.append('Authorization', `Bearer ${accessToken}`) + } + + let url = `/api/v4/races/${this.raceId}` + const joinToken = (window.gon.race || {}).join_token + if (joinToken) { + url += `?join_token=${joinToken}` + } + + const response = await fetch(url, { + headers: headers + }) + if (!response.ok) { + throw (await response.json()).error || response.statusText + } + + this.globalSubscription = consumer.subscriptions.create('Api::V4::GlobalRaceChannel', { + connection() {}, + + disconnected() {}, + + received(data) { + switch(data.type) { + // TODO: update races on game page with this info + case '...': + '' + break; + } + } + }) + + this.raceSubscription = consumer.subscriptions.create({ + channel: 'Api::V4::RaceChannel', + race_id: this.raceId, + join_token: (window.gon.race || {}).join_token + }, { + connected: () => { + // Clean up disconnect if its shown + // Maybe utilize state to update the page? + }, + + disconnected: () => { + // Maybe show disconnects? + }, + + received: (data) => { + switch(data.type) { + case 'race_entries_updated:html': + document.getElementById('entries-table').innerHTML = data.data.entries_html + document.getElementById('stats-box').innerHTML = data.data.stats_html + applyTips() + break + case 'race_entries_updated': + this.race = data.data.race + break + + case 'race_start_scheduled:html': + document.getElementById('stats-box').innerHTML = data.data.stats_html + break + case 'race_start_scheduled': + this.race = data.data.race + break + + case 'race_updated': + this.race = data.data.race + document.getElementById('attachments').innerHTML = data.data.attachments_html + break + + case 'race_ended': + this.race = data.data.race + break + + case 'race_ended:html': + document.getElementById('entries-table').innerHTML = data.data.entries_html + document.getElementById('stats-box').innerHTML = data.data.stats_html + break + + case 'new_message:html': + document.getElementById('input-list-item').insertAdjacentHTML('afterend', data.data.chat_html) + applyTips() + break + + case 'new_attachment:html': + document.getElementById('attachments').innerHTML = data.data.attachments_html + break + } + } + }) + + document.addEventListener('turbolinks:visit', () => { + consumer.subscriptions.remove(this.raceSubscription) + }, {once: true}) + + this.race = (await response.json()).race + this.loading = false + }, + data: () => ({ + error: false, + globalSubscription: null, + loading: true, + race: null, + raceSubscription: null, + }), + name: 'race', + props: ['race-id'], +} diff --git a/app/jobs/api/v4/global_race_update_job.rb b/app/jobs/api/v4/global_race_update_job.rb new file mode 100644 index 000000000..e4747ad00 --- /dev/null +++ b/app/jobs/api/v4/global_race_update_job.rb @@ -0,0 +1,15 @@ +class Api::V4::GlobalRaceUpdateJob < ApplicationJob + queue_as :v4_races + + def perform(race, status, message) + ws_msg = Api::V4::WebsocketMessage.new( + status, + message: message, + race: Api::V4::RaceBlueprint.render_as_hash(race) + ) + ActionCable.server.broadcast( + 'v4:global_race_channel', + ws_msg.to_h + ) + end +end diff --git a/app/jobs/api/v4/message_broadcast_job.rb b/app/jobs/api/v4/message_broadcast_job.rb new file mode 100644 index 000000000..add33a73f --- /dev/null +++ b/app/jobs/api/v4/message_broadcast_job.rb @@ -0,0 +1,19 @@ +class Api::V4::MessageBroadcastJob < ApplicationJob + queue_as :v4_races + + def perform(race, chat_message) + msg = { + message: 'A new message has been created', + chat_message: Api::V4::ChatMessageBlueprint.render_as_hash(chat_message) + } + onsite_msg = { + message: 'New html', + chat_html: ApplicationController.render(partial: 'chat_messages/show', locals: {chat_message: chat_message}) + } + ws_msg = Api::V4::WebsocketMessage.new('new_message', msg) + ws_onsite_msg = Api::V4::WebsocketMessage.new('new_message:html', onsite_msg) + + Api::V4::RaceChannel.broadcast_to(race, ws_msg.to_h) + ActionCable.server.broadcast("api:v4:race:#{race.to_gid_param}:onsite", ws_onsite_msg.to_h) + end +end diff --git a/app/jobs/api/v4/race_broadcast_job.rb b/app/jobs/api/v4/race_broadcast_job.rb new file mode 100644 index 000000000..6a05b4085 --- /dev/null +++ b/app/jobs/api/v4/race_broadcast_job.rb @@ -0,0 +1,21 @@ +class Api::V4::RaceBroadcastJob < ApplicationJob + queue_as :v4_races + + def perform(race, status, message) + msg = { + message: message, + race: Api::V4::RaceBlueprint.render_as_hash(race) + } + onsite_msg = { + message: 'Updated html', + entries_html: ApplicationController.render(partial: 'races/entries_table', locals: {race: race}), + stats_html: ApplicationController.render(partial: 'races/stats', locals: {race: race}), + attachments_html: ApplicationController.render(partial: 'races/attachments', locals: {race: race}) + } + + ws_msg = Api::V4::WebsocketMessage.new(status, msg) + ws_onsite_msg = Api::V4::WebsocketMessage.new("#{status}:html", onsite_msg) + Api::V4::RaceChannel.broadcast_to(race, ws_msg.to_h) + ActionCable.server.broadcast("api:v4:race:#{race.to_gid_param}:onsite", ws_onsite_msg.to_h) + end +end diff --git a/app/jobs/broadcast_upload_job.rb b/app/jobs/broadcast_upload_job.rb deleted file mode 100644 index 0795f8962..000000000 --- a/app/jobs/broadcast_upload_job.rb +++ /dev/null @@ -1,12 +0,0 @@ -class BroadcastUploadJob < ApplicationJob - # Adding or deleting a job? Reflect the change in the QUEUES environment variable in docker-compose.yml and - # docker-compose-production.yml. - queue_as :broadcast_upload - - def perform(run) - RunChannel.broadcast_to( - run, - time_since_upload: ApplicationController.render(partial: 'runs/time_since_upload', locals: {run: run}) - ) - end -end diff --git a/app/models/api/v4/websocket_message.rb b/app/models/api/v4/websocket_message.rb new file mode 100644 index 000000000..16a4f223a --- /dev/null +++ b/app/models/api/v4/websocket_message.rb @@ -0,0 +1,43 @@ +class Api::V4::WebsocketMessage + attr_reader :type, :data + + TYPES = %w[ + connection_error + fatal_error + global_state + race_created + race_started + race_updated + + race_not_found + race_invalid_join_token + race_state + + race_start_scheduled + race_ended + + race_entries_updated + + new_message + + new_attachment + new_card + ].freeze + + HTML_TYPES = TYPES.map { |t| "#{t}:html" }.freeze + + def initialize(type, **data) + raise "Invalid type #{type}" unless TYPES.include?(type) || HTML_TYPES.include?(type) + raise '"message" required as keyword argument' if data[:message].blank? + + @type = type + @data = data + end + + def to_h + { + type: @type, + data: @data + } + end +end diff --git a/app/models/category.rb b/app/models/category.rb index bf619ed88..21260ddf5 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -3,6 +3,7 @@ class Category < ApplicationRecord has_many :runs, dependent: :nullify has_many :rivalries, dependent: :destroy + has_many :races, dependent: :nullify, class_name: 'Race' has_many :users, through: :runs diff --git a/app/models/chat_message.rb b/app/models/chat_message.rb new file mode 100644 index 000000000..778942f97 --- /dev/null +++ b/app/models/chat_message.rb @@ -0,0 +1,9 @@ +class ChatMessage < ApplicationRecord + belongs_to :race + belongs_to :user + + validates :body, presence: true + validate do + errors.add(:base, 'Race locked') if race.locked? + end +end diff --git a/app/models/concerns/completed_run.rb b/app/models/concerns/completed_run.rb index e5674cea6..374511fcf 100644 --- a/app/models/concerns/completed_run.rb +++ b/app/models/concerns/completed_run.rb @@ -7,6 +7,8 @@ module CompletedRun # duration returns the total duration of the run, from the beginning of the first segment to the end of the last # segment. def duration(timing = default_timing) + return Duration.new(nil) if segments.any? && segments.last.duration(timing).nil? + case timing when Run::REAL Duration.new(realtime_duration_ms) @@ -70,9 +72,9 @@ def longest_segment(timing) def median_segment_duration(timing) case timing when Run::REAL - Duration.new(segments.pluck(:realtime_duration_ms).median.truncate) + Duration.new(segments.pluck(:realtime_duration_ms).extend(DescriptiveStatistics).median.truncate) when Run::GAME - Duration.new(segments.pluck(:gametime_duration_ms).median.truncate) + Duration.new(segments.pluck(:gametime_duration_ms).extend(DescriptiveStatistics).median.truncate) end end @@ -80,9 +82,9 @@ def median_segment_duration(timing) def median_segment_duration_ms(timing) case timing when Run::REAL - segments.pluck(:realtime_duration_ms).median.truncate + segments.pluck(:realtime_duration_ms).extend(DescriptiveStatistics).median.truncate when Run::GAME - segments.pluck(:gametime_duration_ms).median.truncate + segments.pluck(:gametime_duration_ms).extend(DescriptiveStatistics).median.truncate end end diff --git a/app/models/concerns/forgetful_persons_run.rb b/app/models/concerns/forgetful_persons_run.rb index 4106957cc..7868ac781 100644 --- a/app/models/concerns/forgetful_persons_run.rb +++ b/app/models/concerns/forgetful_persons_run.rb @@ -4,10 +4,13 @@ module ForgetfulPersonsRun extend ActiveSupport::Concern included do - # Returns segments, but with skipped segments rolled into the soonest future segment that wasn't skipped. + # Returns segments, but with skipped segments rolled into the soonest future segment that wasn't skipped. If the run + # is not complete (i.e. the last segment and any # segments directly before it have nil durations), we don't + # return that last stretch of segments at all. def collapsed_segments(timing) - segments.reduce([]) do |segs, seg| - if segs.last.try(:duration_ms, timing) == 0 + in_progress_segments = segments.reverse.take_while { |segment| segment.duration(timing).nil? } + segments[0..segments.count - 1 - in_progress_segments.count].reduce([]) do |segs, seg| + if segs.any? && segs.last.try(:duration, timing).nil? skipped_seg = segs.last segs + [Segment.new( segs.pop.attributes.merge( @@ -34,7 +37,7 @@ def collapsed_segments(timing) else segs + [seg] end - end + end.push(*in_progress_segments) end def skipped_splits(timing) diff --git a/app/models/concerns/unparsed_run.rb b/app/models/concerns/unparsed_run.rb index 81e6e6d24..7a266d854 100644 --- a/app/models/concerns/unparsed_run.rb +++ b/app/models/concerns/unparsed_run.rb @@ -47,11 +47,11 @@ def parse_into_db attempts: parse_result[:attempts], srdc_id: srdc_id || parse_result[:metadata][:srdc_id], - realtime_duration_ms: parse_result[:realtime_duration_ms] || 0, - realtime_sum_of_best_ms: parse_result[:realtime_sum_of_best_ms], + realtime_duration_ms: zero_to_nil(parse_result[:realtime_duration_ms]), + realtime_sum_of_best_ms: zero_to_nil(parse_result[:realtime_sum_of_best_ms]), - gametime_duration_ms: parse_result[:gametime_duration_ms] || 0, - gametime_sum_of_best_ms: parse_result[:gametime_sum_of_best_ms], + gametime_duration_ms: zero_to_nil(parse_result[:gametime_duration_ms]), + gametime_sum_of_best_ms: zero_to_nil(parse_result[:gametime_sum_of_best_ms]), total_playtime_ms: parse_result[:total_playtime_ms], default_timing: default_timing, @@ -88,19 +88,19 @@ def write_segments(parsed_segments) segment_number: parsed_segment[:segment_number], name: parsed_segment[:name], - realtime_start_ms: parsed_segment[:realtime_start_ms], - realtime_end_ms: parsed_segment[:realtime_end_ms], - realtime_duration_ms: parsed_segment[:realtime_duration_ms], - realtime_shortest_duration_ms: parsed_segment[:realtime_best_ms], + realtime_start_ms: parsed_segment[:realtime_start_ms], # starts can be 0 (for the first segment) + realtime_end_ms: zero_to_nil(parsed_segment[:realtime_end_ms]), + realtime_duration_ms: zero_to_nil(parsed_segment[:realtime_duration_ms]), + realtime_shortest_duration_ms: zero_to_nil(parsed_segment[:realtime_best_ms]), realtime_skipped: parsed_segment[:realtime_skipped], realtime_reduced: false, realtime_gold: parsed_segment[:realtime_gold], - gametime_start_ms: parsed_segment[:gametime_start_ms], - gametime_end_ms: parsed_segment[:gametime_end_ms], - gametime_duration_ms: parsed_segment[:gametime_duration_ms], - gametime_shortest_duration_ms: parsed_segment[:gametime_best_ms], + gametime_start_ms: parsed_segment[:gametime_start_ms], # starts can be 0 (for the first segment) + gametime_end_ms: zero_to_nil(parsed_segment[:gametime_end_ms]), + gametime_duration_ms: zero_to_nil(parsed_segment[:gametime_duration_ms]), + gametime_shortest_duration_ms: zero_to_nil(parsed_segment[:gametime_best_ms]), gametime_skipped: parsed_segment[:gametime_skipped], gametime_reduced: false, @@ -157,5 +157,10 @@ def write_segment_histories(segs) SegmentHistory.import(histories) end + + def zero_to_nil(parsed_number) + parsed_number = parsed_number.presence + parsed_number.zero? ? nil : parsed_number + end end end diff --git a/app/models/duration.rb b/app/models/duration.rb index 511e6a91d..4c5cd53fa 100644 --- a/app/models/duration.rb +++ b/app/models/duration.rb @@ -54,7 +54,9 @@ def format_casual(num_units: 2, sign: :negatives) end def ==(duration) - return false if nil? || duration.nil? + # Normally we'd call Duration#nil? here, but it treats 0 as nil to deal with old durations in the db where 0 means + # absent. When those are cleaned up, we can change Duration#nil? and this. + return false if duration == nil || to_ms.nil? || (duration.respond_to?(:to_ms) && duration.to_ms.nil?) return false unless duration.respond_to?(:to_ms) to_ms == duration.to_ms @@ -68,7 +70,9 @@ def !=(duration) end def <(duration) - return false if nil? || duration.nil? + # Normally we'd call Duration#nil? here, but it treats 0 as nil to deal with old durations in the db where 0 means + # absent. When those are cleaned up, we can change Duration#nil? and this. + return false if duration == nil || to_ms.nil? || (duration.respond_to?(:to_ms) && duration.to_ms.nil?) # duration can be a Duration or a number of milliseconds ms = duration.respond_to?(:to_ms) ? duration.to_ms : duration @@ -81,7 +85,9 @@ def <=(duration) end def >(duration) - return false if nil? || duration.nil? + # Normally we'd call Duration#nil? here, but it treats 0 as nil to deal with old durations in the db where 0 means + # absent. When those are cleaned up, we can change Duration#nil? and this. + return false if duration == nil || to_ms.nil? || (duration.respond_to?(:to_ms) && duration.to_ms.nil?) # duration can be a Duration or a number of milliseconds ms = duration.respond_to?(:to_ms) ? duration.to_ms : duration @@ -99,6 +105,12 @@ def <=>(duration) to_ms <=> duration.to_ms end + def +(duration) + return Duration.new(nil) if nil? || duration.nil? + + Duration.new(to_ms + duration.to_ms) + end + def -(duration) return Duration.new(nil) if nil? || duration.nil? @@ -145,16 +157,20 @@ def to_ms @ms end + def to_sec + @ms / 1000 + end + def abs Duration.new(to_ms.try(:abs)) end def positive? - self >= 0 + self >= Duration.new(0) end def negative? - self < 0 + self < Duration.new(0) end private diff --git a/app/models/entry.rb b/app/models/entry.rb new file mode 100644 index 000000000..c354e89de --- /dev/null +++ b/app/models/entry.rb @@ -0,0 +1,62 @@ +class Entry < ApplicationRecord + belongs_to :race, touch: true + belongs_to :runner, class_name: 'User' + belongs_to :creator, class_name: 'User' + belongs_to :run, dependent: :destroy, optional: true + + validates_with EntryValidator + # Validators are not called before destroy's, so manually hook and prevent if race is started + before_destroy :validate_destroy + + scope :ready, -> { where.not(readied_at: nil) } + scope :nonready, -> { where(readied_at: nil) } + + scope :active, -> { where(finished_at: nil, forfeited_at: nil) } + scope :inactive, -> { where(finished_at: nil, forfeited_at: nil) } + + scope :ghosts, -> { where(ghost: true) } + scope :nonghosts, -> { where(ghost: false) } + + # Returns this race's entry for the given user, not including ghosts. + def self.find_for(user) + find_by(runner: user, ghost: false) + end + + def ready? + readied_at.present? + end + + def finished? + finished_at.present? + end + + def forfeited? + forfeited_at.present? + end + + def done? + finished? || forfeited? + end + + def place + return nil unless finished? + + race.entries.order(finished_at: :asc).pluck(:id).index(id) + 1 + end + + def duration + return Duration.new((finished_at - race.started_at) * 1000) if finished? + return Duration.new((forfeited_at - race.started_at) * 1000) if forfeited? + + Duration.new(nil) + end + + private + + def validate_destroy + return unless race.started? + + errors[:base] << 'Cannot leave race once it has started' + throw(:abort) + end +end diff --git a/app/models/game.rb b/app/models/game.rb index 87f3ff549..eeb8fc14d 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -13,6 +13,9 @@ class Game < ApplicationRecord has_many :runners, -> { distinct }, through: :runs, class_name: 'User' has_many :aliases, class_name: 'GameAlias', dependent: :destroy + has_many :races + + has_one :srdc, class_name: 'SpeedrunDotComGame', dependent: :destroy has_one :srl, class_name: 'SpeedRunsLiveGame', dependent: :destroy diff --git a/app/models/highlight_suggestion.rb b/app/models/highlight_suggestion.rb index 8ba3c2612..2abc9feef 100644 --- a/app/models/highlight_suggestion.rb +++ b/app/models/highlight_suggestion.rb @@ -12,7 +12,7 @@ def from_run(run) return run.highlight_suggestion if run.highlight_suggestion.present? - pb = run.histories.where.not(started_at: nil, ended_at: nil).find_by( + pb = run.histories.where.not(started_at: nil).where.not(ended_at: nil).find_by( realtime_duration_ms: run.duration_ms(Run::REAL), gametime_duration_ms: run.duration_ms(Run::GAME) ) diff --git a/app/models/race.rb b/app/models/race.rb new file mode 100644 index 000000000..5ba37901c --- /dev/null +++ b/app/models/race.rb @@ -0,0 +1,164 @@ +class Race < ApplicationRecord + validates_with RaceValidator + + ABANDON_TIME = 1.hour.freeze + + belongs_to :user + + belongs_to :game, optional: true + belongs_to :category, optional: true + + + enum visibility: {public: 0, invite_only: 1, secret: 2}, _suffix: true + + belongs_to :owner, foreign_key: :user_id, class_name: 'User' + has_many :entries, dependent: :destroy + has_many :runners, through: :entries + has_many :chat_messages, dependent: :destroy + + has_many_attached :attachments + + has_secure_token :join_token + + scope :started, -> { where.not(started_at: nil) } + scope :unstarted, -> { where(started_at: nil) } + scope :ongoing, -> { started.unfinished } + + after_create { entries.create(runner: owner, creator: owner) } + + def self.unfinished + # Distinct call will not return races with no entries, so union all races with 0 entries + joins(:entries).where(entries: {finished_at: nil, forfeited_at: nil}).distinct.union( + left_outer_joins(:entries).where(entries: {id: nil}) + ) + end + + def self.finished + joins(:entries).where.not(entries: {finished_at: nil}).or(joins(:entries).where.not(entries: {forfeited_at: nil})).group(:id) + end + + # unabandoned returns races that have had activity (e.g. creation, new entry, etc.) in the last hour + # or have more than 2 entries. this includes races that have finished + def self.unabandoned + where('races.updated_at > ?', ABANDON_TIME.ago).or( + where(id: Entry.having('count(*) > 1').group(:race_id).select(:race_id)) + ) + end + + # active returns all non-finished unabandoned races + def self.active + unabandoned.unfinished + end + + def self.friendly_find!(slug) + raise ActiveRecord::RecordNotFound if slug.nil? + + race = where('LEFT(id::text, ?) = ?', slug.length, slug).order(created_at: :asc).first + raise ActiveRecord::RecordNotFound if race.nil? + + race + end + + def to_s + "#{game} #{category} #{title}".presence || 'Untitled race' + end + + def abandoned? + updated_at < ABANDON_TIME.ago && entries.count < 2 + end + + def started? + started_at.present? + end + + def in_progress? + started? && !finished? + end + + def finished? + started? && entries.where(finished_at: nil, forfeited_at: nil).none? + end + + def finished_at + [ + entries.where.not(finished_at: nil).maximum(:finished_at), + entries.where.not(forfeited_at: nil).maximum(:forfeited_at) + ].compact.max + end + + # Races are "locked" 30 minutes after they end to stop new messages coming in + def locked? + finished? && Time.now.utc > finished_at + 30.minutes + end + + # potentially starts the race if all entries are now ready + def maybe_start! + return if started? || entries.where(readied_at: nil).any? || entries.count < 2 + + update(started_at: Time.now.utc + 20.seconds) + + # Schedule ghost splits and finishes + entries.ghosts.find_each do |entry| + entry.update(finished_at: started_at + (entry.run.duration(Run::REAL).to_sec)) + Api::V4::RaceBroadcastJob.set(wait_until: started_at + entry.run.duration(Run::REAL).to_sec).perform_later( + self, 'race_entries_updated', 'A ghost has finished' + ) + entry.run.segments.with_ends.each do |segment| + Api::V4::RaceBroadcastJob.set(wait_until: started_at + segment.end(Run::REAL).to_sec).perform_later( + self, 'race_entries_updated', 'A ghost has split' + ) + end + end + + Api::V4::RaceBroadcastJob.perform_later(self, 'race_start_scheduled', 'The race is starting soon') + Api::V4::GlobalRaceUpdateJob.perform_later(self, 'race_start_scheduled', 'A race is starting soon') + end + + # potentially send race end broadcast if all entries are finished + # note: this can potentially send multiple ending messages if called on an already finished race + def maybe_end! + return if !started? || entries.where(finished_at: nil, forfeited_at: nil).any? + + Api::V4::RaceBroadcastJob.perform_later(self, 'race_ended', 'The race has ended') + Api::V4::GlobalRaceUpdateJob.perform_later(self, 'race_ended', 'A race has ended') + end + + # checks if a given user should be able to act on a given race, returning true if any of the following pass + # the user is an entrant in the race or is the race creator, or + # the race visibility is not public and the provided token is correct, or + # the race is public + def joinable?(user: nil, token: nil) + result = false + result = true if entries.find_for(user).present? || belongs_to?(user) + result = true if (invite_only_visibility? || secret_visibility?) && token == join_token + result = true if public_visibility? + + result + end + + # belongs_to? returns true if the given user owns this race, or false otherwise. Use this method instead of direct + # comparison to prevent nil users from editing races without owners (e.g. logged-out users & races whose owners + # deleted their accounts). + def belongs_to?(user) + return nil if user.nil? + + owner == user + end + + def to_param + (0..id.length).each do |length| + return id[0..length] if self.class.where('LEFT(id::text, ?) = ?', length + 1, id[0..length]).count == 1 + end + end + + def title + notes.try(:lines).try(:first) + end + + def duration + return Duration.new((finished_at - started_at) * 1000) if finished_at.present? + return Duration.new((Time.now.utc - started_at) * 1000) if in_progress? + + Duration.new(nil) + end +end diff --git a/app/models/run.rb b/app/models/run.rb index f95e8f64e..453143a08 100644 --- a/app/models/run.rb +++ b/app/models/run.rb @@ -27,11 +27,11 @@ class Run < ApplicationRecord has_many :histories, dependent: :delete_all, class_name: 'RunHistory' has_one :highlight_suggestion, dependent: :destroy has_many :likes, dependent: :destroy, class_name: 'RunLike' + has_one :entry, dependent: :nullify has_secure_token :claim_token after_create :discover_runner - after_create :publish_aging validates_with RunValidator @@ -92,8 +92,10 @@ def duration_type(timing) end end + # Return a random run. As a special case for development setups, if no runs exist a fake run with a fake ID is + # returned. def random - Run.offset(rand(Run.count)).first + Run.offset(rand(Run.count)).first || Run.new(id: 0) end end @@ -174,12 +176,6 @@ def previous_pb(timing) end end - def publish_aging - publish_age_every(1.minute, 60) - publish_age_every(1.hour, 24) - publish_age_every(1.day, 30) - end - # Calculate the various statistical information about each segments history once in the database for the whole run # instead of individually for each segment (N queries) def segment_history_stats(timing) @@ -207,7 +203,7 @@ def segment_history_stats(timing) end def recommended_comparison(timing) - query = Run.where(category: category).where.not(user: nil, category: nil) + query = Run.where(category: category).where.not(user: nil).where.not(category: nil) case timing when Run::REAL @@ -227,12 +223,6 @@ def possible_timesave(timing) private - def publish_age_every(period, cycles) - cycles.times do |i| - BroadcastUploadJob.set(wait: (period * (i + 1))).perform_later(self) - end - end - def stats_select_query(timing) case timing when Run::REAL diff --git a/app/models/segment.rb b/app/models/segment.rb index 27e56f960..d46073f16 100644 --- a/app/models/segment.rb +++ b/app/models/segment.rb @@ -4,6 +4,37 @@ class Segment < ApplicationRecord # If SegmentHistory is changed to have child records, change this back to just :destroy has_many :histories, -> { order(attempt_number: :asc) }, class_name: 'SegmentHistory', dependent: :delete_all + validates :name, presence: true + validates :segment_number, presence: true, numericality: {only_integer: true} + + # with_ends modifies the returned Segments to have realtime_end_ms and gametime_end_ms fields, which represent the + # duration into the run when that specific segment ended. + # + # It does this using a SQL window function to sum the attempts for the segments leading up to this one, for each one. + # This is multiple orders of magnitude more efficient than loading these into Rails and doing it there, for n attempts + # and m segments. + def self.with_ends + select(' + segments.*, + CAST( + sum(segments.realtime_duration_ms) OVER(ORDER BY segments.segment_number) + AS BIGINT + ) AS realtime_end_ms, + CAST( + sum(segments.gametime_duration_ms) OVER(ORDER BY segments.segment_number) + AS BIGINT + ) AS gametime_end_ms + '.squish) + end + + # Returns the Segment the runner would be on at the given run time. A run time is a Duration since the start of the + # run. + # + # If the Duration takes place outside the run (i.e. is greater than Run#duration), nil is returned. + def self.at_run_time(run_time) + with_ends.order(segment_number: :asc).where('realtime_end_ms > ?', run_time.to_ms).first + end + # start returns the Duration between the start of the run and the start of this segment. For example, the first # segment of the run would have a start of 0. The second segment would have a start equal to the duration of the first # segment. The last segment would have a start equal to the duration of the run minus the duration of the last @@ -11,9 +42,13 @@ class Segment < ApplicationRecord def start(timing) case timing when Run::REAL - Duration.new(realtime_start_ms) + Duration.new( + realtime_start_ms || run.segments.find_by(segment_number: segment_number - 1).try(:end, timing).try(:to_ms) + ) when Run::GAME - Duration.new(gametime_start_ms) + Duration.new( + gametime_start_ms || run.segments.find_by(segment_number: segment_number - 1).try(:end, timing).try(:to_ms) + ) end end @@ -97,8 +132,16 @@ def last? segment_number == run.segments.count - 1 end + # proportion returns a number from 0 to 1 representing the segment's proportion of the run it should represent, mostly + # for display purposes. + def proportion(timing, scale_to = run.duration(timing)) + return (1.0 / run.segments.count) if scale_to.nil? + + duration(timing) / scale_to + end + def second_half?(timing) - (end_ms(timing) - (duration_ms(timing) / 2)) > (run.duration_ms(timing) / 2) + (self.end(timing) - (duration(timing) / 2)) > (run.duration(timing) / 2) end # gold? returns something truthy if this segment's PB time is the fastest (or tied for the fastest) ever recorded by diff --git a/app/models/user.rb b/app/models/user.rb index d76249d14..ef74fe64b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,6 +10,10 @@ class User < ApplicationRecord has_many :run_likes, dependent: :destroy + has_many :entries, foreign_key: 'runner_id' + has_many :created_entries, foreign_key: 'creator_id' + has_many :races, through: :entries + has_many :rivalries, foreign_key: :from_user_id, dependent: :destroy, inverse_of: 'from_user' has_many :incoming_rivalries, foreign_key: :to_user_id, dependent: :destroy, inverse_of: 'to_user', class_name: 'Rivalry' @@ -96,7 +100,7 @@ def patron?(tier: 0) case tier when 0 - patreon.pledge_cents > 0 + patreon.pledge_cents.positive? when 1 patreon.pledge_cents >= 200 when 2 @@ -118,6 +122,10 @@ def likes?(run) RunLike.find_by(user: self, run: run) end + def in_race? + entries.where(finished_at: nil, forfeited_at: nil, ghost: false).any? + end + # comprable_runs returns some runs by this user that could be usefully compared to the given run. def comparable_runs(timing, run) case timing diff --git a/app/validators/entry_validator.rb b/app/validators/entry_validator.rb new file mode 100644 index 000000000..cea3ec136 --- /dev/null +++ b/app/validators/entry_validator.rb @@ -0,0 +1,69 @@ +class EntryValidator < ActiveModel::Validator + def validate(record) + # validate_run_id needs to be before `if record.new_record?` so we can check if the entry is a ghost before we check + # if the runner is joining multiple races. (Someone using my ghost shouldn't prevent me from joining a race.) + validate_run_id(record) if record.run_id_changed? + validate_new_record(record) if record.new_record? + validate_pre_start_race(record) unless record.race.started? + validate_times(record) + validate_in_progress_race(record) if record.race.in_progress? + record.errors[:base] << 'Cannot modify entry in finished race' if record.race.finished? + end + + private + + def validate_run_id(record) + run = Run.find(record.run_id) + return unless run.completed?(Run::REAL) # If we are a ghost + + record.assign_attributes( + ghost: true, + runner: run.user, + readied_at: Time.now.utc + ) + rescue ActiveRecord::RecordNotFound + record.errors[:run_id] << 'No run with that ID exists' + end + + def validate_new_record(record) + # Reject new entry if race has already started + if record.race.started? + record.errors[:base] << 'Cannot join race that has already started' + end + + # Reject if entry's user is in another active race + if !record.ghost? && record.runner.in_race? + record.errors[:base] << 'Cannot join more than one race at a time' + end + end + + # Rejects setting finish/forfeit times before a race starts + def validate_pre_start_race(record) + return unless record.finished_at_changed? || record.forfeited_at_changed? + + record.errors[:base] << 'Cannot finish/forfeit before a race starts' + end + + # Rejects times before the race start time + def validate_times(record) + return unless record.race.started_at.present? + + [record.finished_at, record.forfeited_at].each do |time| + next unless time.present? && time < record.race.started_at + + record.errors[:base] << "#{time} cannot be before race start time" + end + end + + def validate_in_progress_race(record) + # Reject changing ready time after race has started + if record.readied_at_changed? + record.errors[:base] << 'Cannot change ready status once race has started' + end + + # Reject both finished_at and forfeited_at being set + if record.finished_at.present? && record.forfeited_at.present? + record.errors[:base] << 'Cannot finish and forfeit from the same race' + end + end +end diff --git a/app/validators/race_validator.rb b/app/validators/race_validator.rb new file mode 100644 index 000000000..ce32d1c83 --- /dev/null +++ b/app/validators/race_validator.rb @@ -0,0 +1,30 @@ +class RaceValidator < ActiveModel::Validator + def validate(record) + game_from_category(record) + validate_game(record) + validate_category(record) + end + + private + + def game_from_category(record) + return unless record.game_id.nil? && record.category_id.present? + return unless Category.find_by(id: record.category_id) + + record.game_id = record.category.game_id + end + + def validate_game(record) + return if record.game_id.nil? + return if Game.find_by(id: record.game_id) + + record.errors[:base] << "Game with id '#{record.game_id}' does not exist" + end + + def validate_category(record) + return if record.category_id.nil? + return if record.game_id.present? && Game.find(record.game_id).categories.find_by(id: record.category_id) + + record.errors[:base] << "Category with id '#{record.category_id}' inside game with id '#{record.game_id}' does not exist" + end +end diff --git a/app/views/application/not_found.slim b/app/views/application/not_found.slim new file mode 100644 index 000000000..372daf774 --- /dev/null +++ b/app/views/application/not_found.slim @@ -0,0 +1,11 @@ +- content_for(:title, 'Not Found') +- content_for(:header) do + h2 + | 404 + small Not Found +article + p This page either never existed or was deleted. + p + | If you have reason to believe this is a mistake, please + a<> href="mailto:bugs@splits.io" email us + | this URL and why you think so. diff --git a/app/views/chat_messages/_show.slim b/app/views/chat_messages/_show.slim new file mode 100644 index 000000000..c72ec8e40 --- /dev/null +++ b/app/views/chat_messages/_show.slim @@ -0,0 +1,8 @@ +.list-group-item.p-0 style=('filter: grayscale(1)' unless chat_message.from_entrant?) + .media + img.mr-3 src=chat_message.user.avatar width=25 height=25 + .media-body + span.mr-2 = user_badge(chat_message.user) + = chat_message.body + .float-right.pr-2 + = render partial: 'shared/relative_time', locals: {time: chat_message.created_at} diff --git a/app/views/games/categories/show.slim b/app/views/games/categories/show.slim index 8b54c1667..715d7d258 100644 --- a/app/views/games/categories/show.slim +++ b/app/views/games/categories/show.slim @@ -9,20 +9,33 @@ / Specify the full partial path because this view is used by different controllers = render partial: 'games/categories/title', locals: {category: @category} -article data-turbolinks-temporary=true + +#vue-race.row.mx-2 - route = @category.route - if @category.route.present? - .card.mb-4 style='width: 18rem' + .col-md-4: .card.mb-4 + h5.card-header #{@category.name} Route .card-body - h5.card-title #{@category.name} Route p.card-text Automatically determined based on popularity - = render partial: 'runs/export_button', locals: {run: route, button_text: 'Download', force_route_only: true} - .card - .card-header - = render partial: 'shared/category_tabs', locals: {game: @game, current_category: @category, link_type: :normal} - = render partial: 'shared/run_table', locals: { \ - runs: @category.runs.nonempty.unarchived, \ - cols: %i[runner time name video uploaded], \ - }.merge(sorting_info) - center - small = link_to 'sum of best leaderboard', game_category_sum_of_bests_path(@game, @category), class: 'leaderboard-link' + = render partial: 'runs/export_button', locals: {run: route, button_text: 'Download splits', force_route_only: true} + + - if current_user.present? + = render partial: 'races/create', locals: {game: @game, category: @category} + +.row.mx-2 + .col-md-6.mb-3: .card + = render partial: 'shared/race_table', locals: { \ + races: @game.races.finished.not_secret_visibility.order(created_at: :desc), \ + active_races: @game.races.active.not_secret_visibility.order(created_at: :desc) \ + } + + .col-md-6 + .card + .card-header + = render partial: 'shared/category_tabs', locals: {game: @game, current_category: @category, link_type: :normal} + = render partial: 'shared/run_table', locals: { \ + runs: @category.runs.nonempty.unarchived, \ + cols: %i[runner time name video uploaded], \ + }.merge(sorting_info) + center.my-2 + small = link_to 'sum of best leaderboard', game_category_sum_of_bests_path(@game, @category), class: 'leaderboard-link' diff --git a/app/views/layouts/application.slim b/app/views/layouts/application.slim index 59b8ae055..7490e3f75 100644 --- a/app/views/layouts/application.slim +++ b/app/views/layouts/application.slim @@ -2,11 +2,12 @@ doctype html html head title = content_for?(:title) ? "#{yield(:title)} - #{site_title}" : site_title - link href='//fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css' + link href='//fonts.googleapis.com/css?family=Open+Sans|Anonymous+Pro' rel='stylesheet' type='text/css' link type='text/plain' rel='author' href='https://splits.io/humans.txt' meta name='viewport' content='width=device-width, initial-scale=1' = render partial: 'shared/rollbarjs' = javascript_pack_tag 'application', data: {turbolinks_track: 'reload'}, defer: true + = javascript_pack_tag 'race', data: {turbolinks_track: 'reload'}, defer: true script src='https://player.twitch.tv/js/embed/v1.js' defer='defer' = stylesheet_link_tag :application, media: :all, data: {turbolinks_track: 'reload'} = csrf_meta_tag @@ -36,11 +37,15 @@ html li.nav-item class=('active' if request.path == new_run_path) a.nav-link.px-3 href=new_run_path => icon('fas', 'cloud-upload-alt') - | Upload + span.d-xs-inline.d-md-none.d-xl-inline Upload li.nav-item class=('active' if request.path == games_path) a.nav-link.px-3 href=games_path => icon('fas', 'gamepad') - | Games + span.d-xs-inline.d-md-none.d-xl-inline Games + li.nav-item class=('active' if request.path == races_path) + a.nav-link.px-3 href=races_path + => icon('fas', 'flag-checkered') + span.d-xs-inline.d-md-none.d-xl-inline Races - if ENV['READ_ONLY_MODE'] == '1' li.nav-item a.nav-link.px-3 href=read_only_mode_path @@ -113,6 +118,12 @@ html small: #hide-survey-button.text-secondary style='cursor: pointer' no thanks a#survey-button.btn.btn-outline-warning.btn-block.mb-0.text-center href='https://forms.gle/MXbpmLTPdJnVLDdc8' ' Help us out by taking the 2019 Splits.io survey! + - entry = current_user.present? ? current_user.entries.nonghosts.active.first : nil + - if entry.present? && request.path != race_path(entry.race) + article + a.btn.btn-outline-success.btn-block.mb-0.text-center.glow href=race_path(entry.race) + => icon('fas', 'flag-checkered') + | Return to race = render partial: 'shared/ad' header#header = yield(:header) article diff --git a/app/views/races/_attachments.slim b/app/views/races/_attachments.slim new file mode 100644 index 000000000..08069e2d1 --- /dev/null +++ b/app/views/races/_attachments.slim @@ -0,0 +1,5 @@ +- race.attachments.each do |file| + li.list-group-item + = file.filename + a.ml-2 href=rails_blob_path(file, disposition: 'attachment') + = icon('fas', 'file-download') diff --git a/app/views/races/_create.slim b/app/views/races/_create.slim new file mode 100644 index 000000000..748826db9 --- /dev/null +++ b/app/views/races/_create.slim @@ -0,0 +1,31 @@ +- game = local_assigns.fetch(:game, Game.new) +- category = local_assigns.fetch(:category, Category.new) + +// Make sure to have #vue-race be defined before calling this partial +race-create inline-template=true game-id=game.id category-id=category.id + .col-md-4 v-cloak=true + .card.mb-4 + h5.card-header Create race + .card-body + p.card-text Race #{game.to_s || ''} against others + .btn-group + button.btn.btn-primary( + type='button' + @click='createPublic' + :disabled='loading' + :title='error' + v-tippy=true + ) + template v-if="loading" = render partial: 'shared/spinner' + span.text-danger v-else-if='error' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'flag-checkered') + ' Create public race + button.btn.btn-primary.dropdown-toggle.dropdown-toggle-split( + type='button' + data={toggle: 'dropdown'} + aria={haspopup: true, expanded: false} + ) + span.sr-only Toggle dropdown + .dropdown-menu.bg-dark + button.dropdown-item.text-secondary @click='createInviteOnly' Create invite-only race + button.dropdown-item.text-secondary @click='createSecret' Create secret race diff --git a/app/views/races/_entries_table.slim b/app/views/races/_entries_table.slim new file mode 100644 index 000000000..f854359c6 --- /dev/null +++ b/app/views/races/_entries_table.slim @@ -0,0 +1,85 @@ +- timing = Run::REAL +- add_ghost = local_assigns.fetch(:add_ghost, true) + +h6.card-header Entries +- if race.entries.none? + .card-body: i No one here +- else + .table-responsive: table.card-body.table.mb-0 + thead: tr + th.nowrap Name + th.nowrap.text-center + - if race.started? + ' Position + - else + ' Ready + th.w-100 Time + - unless race.finished? + th.nowrap.text-center Current split + th.nowrap.text-center Stats + th.nowrap.text-center Links + tbody + - race.entries.includes(:runner).order('finished_at ASC NULLS LAST, forfeited_at ASC NULLS FIRST, readied_at ASC NULLS LAST, created_at ASC').each do |entry| + tr style=('filter: grayscale(100%)' if entry.ghost?) + td.nowrap + - if entry.ghost? + big.mr-2 v-tippy=true title="Ghost of #{entry.runner}'s past run 😱" + = icon('fas', 'ghost') + span.mr-2 = user_badge(entry.runner) + td.nowrap.text-center + - if race.started? + - if entry.done? + b class=entry_color(entry) = entry_place(entry) + - else + b = entry_place(entry) + - else + = entry.ready? ? icon('fas', 'check', class: 'text-success') : icon('fas', 'times', class: 'text-danger') + td.nowrap.text-monospace + - if entry.done? && (entry.finished_at.nil? || entry.finished_at < Time.now.utc) + b class=entry_color(entry) = entry.duration.format(precise: true) + - elsif race.started? + span data={abstime: race.started_at.rfc3339(3)} - + - unless race.finished? + td.nowrap + - if entry.run.present? && race.started? + - segment = entry.run.segments.at_run_time(Duration.new((Time.now.utc - race.started_at) * 1000)) + - if segment.present? + .badge.badge-dark.mr-2 #{segment} + span.count data={abstime: (race.started_at + (segment.start(timing).to_ms / 1000)).rfc3339(3)} - + td.nowrap.text-center + - entries = entry.runner.entries + - places = entries.map(&:place) + span.badge.badge-dark.mr-2 + => icon('fas', 'medal') + span.text-gold = places.count(1) + span.text-secondary = '/' + span.text-silver = places.count(2) + span.text-secondary = '/' + span.text-bronze = places.count(3) + span.badge.badge-dark + => icon('fas', 'flag-checkered') + b.text-success = entries.count(:finished_at) + span.text-secondary = '/' + span.text-danger = entries.count(:forfeited_at) + td.nowrap.text-center + - if entry.runner.twitch.present? + a.text-light.tip.mr-2 href=entry.runner.twitch.url title='Watch on Twitch' + = icon('fab', 'twitch') + - if entry.run.present? + a.text-light.tip.mr-2 href=run_path(entry.run) title='See associated run' + = icon('fas', 'scroll') +- if !race.started? && add_ghost + .card-footer.clearfix: .float-right + .dropdown.mr-2 + button.btn.btn-outline-light.dropdown-toggle#ghost-btn( + type='button' + data={toggle: 'dropdown'} + aria={haspopup: true, expanded: false} + onclick='window.setTimeout(() => document.getElementById("ghost-input").focus(), 100)' + ) + ' Add ghost + .dropdown-menu.bg-dark.p-4 aria={labelledby: 'ghost-btn'} style='width: 18rem' + = form_for(:entry, remote: true, url: api_v4_race_entries_path(race.id), html: {class: 'auth-me'}) do |f| + .form-group + = f.text_field(:run_id, class: 'form-control', placeholder: 'Run ID; e.g. g23b', id: 'ghost-input') + = f.submit('Add ghost', class: 'btn btn-outline-light') diff --git a/app/views/races/_nav.slim b/app/views/races/_nav.slim new file mode 100644 index 000000000..4439a5362 --- /dev/null +++ b/app/views/races/_nav.slim @@ -0,0 +1,84 @@ +race-nav inline-template=true v-if='race' :race='race' v-cloak=true + div v-if='race.entries && (race.entries.length === 0 || race.entries.some(entry => entry.finished_at === null && entry.forfeited_at === null))' + .btn-group.mr-2 v-if='entry && (!race.started_at || entry.finished_at || entry.forfeited_at)' + button.btn.btn-secondary( + :disabled='loading.leave' + :title='errors.leave' + @click='leave' + v-if='entry && !entry.readied_at' + v-tippy=true + ) + template v-if="loading.leave" = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.leave' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'undo') + button.btn.btn-secondary( + :disabled='loading.unready' + :title='errors.unready' + @click='unready' + v-if='entry && entry.readied_at && !race.started_at' + v-tippy=true + ) + template v-if='loading.unready' = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.unready' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'undo') + button.btn.btn-secondary( + :disabled='loading.unfinish' + :title='errors.unfinish' + @click="unfinish" + v-if='entry && entry.finished_at' + v-tippy=true + ) + template v-if="loading.unfinish" = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.unfinish' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'undo') + button.btn.btn-secondary( + :disabled='loading.unforfeit' + :title='errors.unforfeit' + @click='unforfeit' + v-if='entry && entry.forfeited_at' + v-tippy=true + ) + template v-if="loading.unforfeit" = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.unforfeit' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'undo') + + .btn-group.mr-2 v-if='!entry && !race.started_at' + button.btn.btn-outline-light v-tippy=true :title='errors.join' :disabled='loading.join || !currentUser' @click='join' + template v-if="loading.join" = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.join' => icon('fas', 'exclamation-triangle') + template v-if='currentUser' Join race + template v-else=true Sign in to join + + template v-if="entry && !race.started_at" + .btn-group.mr-2 + button.btn.btn-outline-secondary disabled=true Joined + + .btn-group.mr-2 v-if='entry && !race.started_at && !entry.readied_at' + button.btn.btn-outline-light.glow v-tippy=true :title='errors.ready' :disabled='loading.ready' @click='ready' + template v-if='loading.ready' = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.ready' => icon('fas', 'exclamation-triangle') + ' Set ready + + template v-if="entry && !race.started_at && entry.readied_at" + .btn-group.mr-2 + button.btn.btn-outline-secondary disabled=true Readied + + template v-if="entry && race.started_at && !entry.finished_at && !entry.forfeited_at" + .btn-group.mr-2 + button.btn.btn-outline-light v-tippy=true :title="errors.forfeit" :disabled="loading.forfeit || loading.finish" @click="forfeit" + template v-if="loading.forfeit" = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.forfeit' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'heart-broken') + ' Set forfeited + .btn-group.mr-2 + button.btn.btn-outline-light v-tippy=true :title="errors.finish" :disabled="loading.forfeit || loading.finish" @click="finish" + template v-if="loading.finish" = render partial: 'shared/spinner' + span.text-danger v-else-if='errors.finish' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'flag-checkered') + ' Set finished + + .btn-group.mr-2 v-if="entry && race.started_at && entry.finished_at" + button.btn.btn-outline-light disabled=true Finished + + .btn-group.mr-2 v-if="entry && race.started_at && entry.forfeited_at" + button.btn.btn-outline-light disabled=true Forfeited diff --git a/app/views/races/_stats.slim b/app/views/races/_stats.slim new file mode 100644 index 000000000..4dd2dd3e8 --- /dev/null +++ b/app/views/races/_stats.slim @@ -0,0 +1,34 @@ +.row + .col-sm-6.col-xl-4 + .statcard.p-3.rounded + - if !race.started? + h2.statcard-number class=statcard_class(race) + ' Accepting entries + - if race.entries.count <= 1 + span.statcard-desc class=statcard_class(race) + ' Need #{2 - race.entries.count} more + - else + span.statcard-desc class=statcard_class(race) + ' #{race.entries.ready.count} / #{race.entries.count} ready + - if race.started? && !race.finished? + h2.statcard-number class=statcard_class(race) data={abstime: race.started_at.rfc3339(3)} - + span.statcard-desc class=statcard_class(race) In progress + - if race.finished? + h2.statcard-number class=statcard_class(race) Finished + span.statcard-desc class=statcard_class(race) title=race.finished_at + = render partial: 'shared/relative_time', locals: {time: race.finished_at, ago: true} + .col-sm-6.col-xl-4: .statcard.p-3 + h2.statcard-number = race.visibility.humanize + span.statcard-desc Visibility + .col-sm-6.col-xl-4: .statcard.p-3 + h2.statcard-number = race.entries.count + span.statcard-desc Entries + - if race.entries.any? + .col-sm-6.col-xl-4: .statcard.p-3 + h2.statcard-number + = (Entry.where(runner: race.runners).where.not(forfeited_at: nil).count / Entry.where(runner: race.runners).count * race.entries.count).floor + span.statcard-desc Predicted forfeits + .col-sm-6.col-xl-4: .statcard.p-3 + h2.statcard-number + = race.entries.joins(:runner).order('users.created_at ASC').first.runner + span.statcard-desc Predicted winner diff --git a/app/views/races/_title.slim b/app/views/races/_title.slim new file mode 100644 index 000000000..0214afbad --- /dev/null +++ b/app/views/races/_title.slim @@ -0,0 +1,67 @@ +race-title inline-template=true v-if='race' :race='race' + div v-cloak=true + .row: .col-md-12 + h1 v-if='!editing' = '{{title}}' + = form_for race, url: race_path(race), html: {'v-if' => 'editing'} do |f| + .form-row + .col-md-6.my-1 + label.sr-only for='game-selector' + select.form-control v-model='gameId' + option v-for='game in games' :value='game.id' {{game.name}} + .col-md-6.my-1 + label.sr-only for='category-selector' + select.form-control v-model='categoryId' + option v-for='category in categories' :value='category.id' {{category.name}} + .row + .col-md-12 + - if race.has_attribute?(:card_url) + h5#card-url = "Card: #{race.card_url || 'None'}" + - if race.has_attribute?(:seed) + h5#seed-name = "Seed: #{race.seed || 'None'}" + + h5 + span + ' created by + = user_badge(race.owner) + - if race.belongs_to?(current_user) + .btn-group.ml-2 + button.btn.btn-outline-light.btn-sm.clipboard-btn( + data-clipboard-text=race_url(race, join_token: race.join_token) + title='Copy invite link' + v-tippy=true + ) + span#copy = icon('fas', 'share-alt') + span#copied style='display: none;' = icon('fas', 'check') + - if !race.started? + button.btn.btn-outline-light.btn-sm( + :disabled='loading' + :title='error || "Edit"' + @click='edit' + v-if='!editing' + v-tippy=true + ) + template v-if='loading' = render partial: 'shared/spinner' + template v-else-if='error' = icon('fas', 'times') + template v-else=true = icon('fas', 'edit') + button.btn.btn-outline-light @click='cancel' v-if='editing' + = icon('fas', 'times') + button.btn.btn-outline-light( + :disabled='loading' + :title='error' + @click='save' + v-if='editing && !race.started_at' + v-tippy=true + ) + template v-if='loading' = render partial: 'shared/spinner' + template v-else-if='error' = icon('fas', 'times') + template v-else=true = icon('fas', 'check') + + .col-md-12 + div v-if="false" = render partial: 'shared/spinner' + = render partial: 'races/nav' + .col-md-12.mt-2 + pre.text-light v-show='!editing' + ' {{(race.notes || '').split('\n').splice(1).filter(s => s !== '').join('\n')}} + template v-if='editing' + small.form-text.text-muted.mb-2.mb-2.mb-2.mb-2.mb-2.mb-2.mb-2.mb-2.mb-2 Use the first line as a title supplement. + textarea.bg-dark.text-light style='font-family: monospace; width: 100%' rows=10 v-model='notes' diff --git a/app/views/races/index.slim b/app/views/races/index.slim new file mode 100644 index 000000000..d29deab37 --- /dev/null +++ b/app/views/races/index.slim @@ -0,0 +1,24 @@ +- content_for(:title, 'Races') +- content_for(:header) do + ol.breadcrumb.shadow + li.breadcrumb-item = link_to(site_title, root_path) + +- if params[:historic] == '1' + - races = Race.finished.not_secret_visibility.order(started_at: :desc) +- else + - races = Race.active.not_secret_visibility.order('started_at ASC NULLS FIRST') + +article + #vue-race.row + - if current_user.present? + = render partial: 'races/create' + + .card.shadow + .card-header + ul.nav.nav-bordered + - historic = params[:historic] == '1' + li.nav-item + = link_to('Active', races_path, class: "nav-link #{historic ? '' : 'active'}") + li.nav-item + = link_to('Historic', races_path(historic: '1'), class: "nav-link #{historic ? 'active' : ''}") + = render partial: 'shared/race_table', locals: {races: races} diff --git a/app/views/races/show.slim b/app/views/races/show.slim new file mode 100644 index 000000000..478526a95 --- /dev/null +++ b/app/views/races/show.slim @@ -0,0 +1,112 @@ +- content_for(:title, @race) + +- content_for(:header) do + ol.breadcrumb.shadow + li.breadcrumb-item = link_to(site_title, root_path) + li.breadcrumb-item = link_to('Races', races_path) + li.breadcrumb-item.active = link_to(@race.to_param, race_path(@race)) + +#vue-race + race inline-template=true race-id=@race.id + div + .text-center v-if="loading" = render partial: 'shared/spinner' + div v-show="!loading" + .row.mx-2 + .col-md-6 = render partial: 'races/title', locals: {race: @race} + #stats-box.col-md-6 = render partial: 'races/stats', locals: {race: @race} + + .card-deck.m-2 + #entries-table.card.shadow + = render partial: 'races/entries_table', locals: {race: @race} + .card-deck.m-2 + .card.shadow + .list-group.list-group-flush style='max-height: 500px; overflow-y: scroll' + - unless @race.locked? + race-chat inline-template=true v-if='race' :race='race' + .list-group-item#input-list-item.p-0 + = form_for(:chat, html: {onsubmit: 'event.preventDefault()'}) do |f| + .input-group + input.form-control#input-chat-text( + autocomplete='off' + autofocus=true + disabled=current_user.nil? + name='body' + placeholder='Chat...' + type='text' + v-model='body' + ) + .input-group-append + button.btn.btn-dark#chat-submit( + disabled=current_user.nil? + type='submit' + @click='chat' + :disabled='body === ""' + :title='error' + v-tippy=true + ) + template v-if='loading' = render partial: 'shared/spinner' + span.text-danger v-else-if='error' => icon('fas', 'exclamation-triangle') + template v-else=true => icon('fas', 'comment') + - @race.chat_messages.includes(user: [:twitch, :google]).order(created_at: :desc).each do |msg| + = render partial: 'chat_messages/show', locals: {chat_message: msg} + .list-group-item.p-0 + .media + img.mr-3 src=asset_path('logo-darkbg-breathingroom.png') width=25 height=25 + .media-body + span.mr-2: .badge.badge-success Splits.io + i Race created + .float-right.pr-2 + = render partial: 'shared/relative_time', locals: {time: @race.created_at} + race-disclaimer.card.shadow inline-template=true + div + .bg-dark.p-4 v-if='!accepted' style='height: 100%' + p + ' Attachments are uploaded by the race creator. Do not download any attachments + ul + li that you do not recognize as necessary for your race, + li that are executable files, or + li if you do not both know and trust the race creator. + ' Splits.io is not affiliated with the race creator or attachments and is not responsible for + ' attachments or their effects on you and your device. + button.btn.btn-outline-light @click='accept' I understand + div v-show='accepted' v-cloak=true + h6.card-header Attachments + ul#attachments.list-group.list-group-flush = render partial: 'races/attachments', locals: {race: @race} + - if @race.belongs_to?(current_user) && !@race.started? + .card-footer + = form_with(model: @race, url: api_v4_race_path(@race), method: 'PATCH', html: { \ + class: 'w-100 d-flex justify-content-between auth-me' \ + }) do |f| + = f.file_field(:attachments, multiple: true, direct_upload: true, class: 'form-control-file') + = f.submit('Attach', class: 'btn btn-outline-light') + + .card-deck.m-2 + race-streams inline-template=true v-if='race' :race='race' v-cloak=true + .card v-if='!finished' + .card-header: .row.d-flex.align-items-center + h6.col-md-6 Stream Deck + .col-md-6 + multiselect( + v-model="value" + :options="options" + :multiple='true' + :clear-on-select='false' + :close-on-select='false' + :searchable='false' + :preselect-first='false' + placeholder="Streams" + label="name" + track-by="name" + ) + template slot="selection" slot-scope="{ values, search, isOpen }" + span class="multiselect__single" v-if="values.length && !isOpen" + ' {{ value.length }}/{{ options.length }} options selected + .card-body + .card-deck + template v-for='(stream, index) in value' + .card: .card-body.p-0 :id='`twitch-${stream.id}`' + .w-100.d-sm-none + .w-100.d-none.d-md-block.d-lg-none v-if='(index + 1) % 2 === 0' + .w-100.d-none.d-lg-block.d-xl-none v-if='(index + 1) % 3 === 0' + .w-100.d-none.d-xl-block v-if='(index + 1) % 4 === 0' + .center v-if='value.length === 0' Select streams to show from the box above diff --git a/app/views/runs/_stats.slim b/app/views/runs/_stats.slim index 3580a464c..cda17d41e 100644 --- a/app/views/runs/_stats.slim +++ b/app/views/runs/_stats.slim @@ -40,6 +40,5 @@ = delta(run.total_playtime, compare_run.total_playtime, subject: subject, better: :different) span.statcard-desc Life playtime .col-sm-3: .statcard.p-3 - h3.statcard-number#time-since-upload title=run.created_at - = render partial: 'runs/time_since_upload', locals: {run: run} + h3.statcard-number = render partial: 'shared/relative_time', locals: {time: run.created_at} span.statcard-desc Time since upload diff --git a/app/views/runs/_time_since_upload.slim b/app/views/runs/_time_since_upload.slim deleted file mode 100644 index 1127ed191..000000000 --- a/app/views/runs/_time_since_upload.slim +++ /dev/null @@ -1,3 +0,0 @@ -// This line is in a partial because it is rendered over Action Cable periodically after the run page is already -// rendered. See RunChannel#broadcast_time_since_upload for more info. -= time_ago_in_words(run.created_at) diff --git a/app/views/runs/_timeline.slim b/app/views/runs/_timeline.slim index ca0fbf47a..b1acf3fbf 100644 --- a/app/views/runs/_timeline.slim +++ b/app/views/runs/_timeline.slim @@ -1,5 +1,5 @@ - scale_to ||= run.duration(timing).to_ms -- if !run.completed?(timing) +- if run.duration(timing).present? && !run.completed?(timing) .card: .card-body: i This run doesn't have #{timing}time splits. - else - if run.video_url.present? && URI.parse(run.video_url).host.match?(/^(www\.)?twitch\.tv$/) @@ -10,15 +10,19 @@ .pure-u.split( id="#{run.id36}-split-#{index}" data={start_ms: segment.start(timing).to_ms, run_id: run.id36, segment_number: index+1} - class=next_timeline_color(run.id36) - style="width: #{segment.duration(timing) / run.duration(timing) * 100.0}%; z-index: #{index}; #{'cursor: pointer;' if run.video_url.present?}" + class="#{next_timeline_color(run.id36)} #{'progress-bar-striped progress-bar-animated' if segment.duration(timing).nil?}" + style="width: #{segment.proportion(timing) * 100.0}%; z-index: #{index}; #{'cursor: pointer;' if run.video_url.present?}" ) .p-2 .text-light = segment.name.presence || '-' - .text-light-50 = segment.duration(timing).format(precise: run.short?(timing)) + .text-light-50 + - if segment.duration(timing).present? + = segment.duration(timing).format(precise: run.short?(timing)) + - else + b In progress .gold.timeline style="width: #{run.duration(timing) / scale_to * 100.0}%" - run.collapsed_segments(timing).each_with_index do |segment, index| - div style="width: #{segment.duration(timing) / run.duration(timing) * 100.0}%" + div style="width: #{segment.proportion(timing) * 100.0}%" - if segment.shortest_duration(timing).present? .gold-split style="width: #{segment.shortest_duration(timing) / segment.duration(timing) * 100.0}%" - else diff --git a/app/views/runs/_timeline_inspector.slim b/app/views/runs/_timeline_inspector.slim index 75dbea3c4..a594e2128 100644 --- a/app/views/runs/_timeline_inspector.slim +++ b/app/views/runs/_timeline_inspector.slim @@ -9,7 +9,7 @@ .pure-u.inspect( class='segment-inspect' data={run_id: run.id36, segment_number: index+1} - style="width: #{segment.duration(timing) / scale_to * 100.0}%; visibility: hidden;" + style="width: #{segment.proportion(timing) * 100.0}%; visibility: hidden;" ) .bar p diff --git a/app/views/runs/_title.slim b/app/views/runs/_title.slim index 871075e5e..08e8356e7 100644 --- a/app/views/runs/_title.slim +++ b/app/views/runs/_title.slim @@ -10,12 +10,18 @@ h5 - if run.srdc_id.present? a.badge.badge-dark.tip title='See on Speedrun.com' href=run.srdc_url = image_tag(asset_path('srdc.png'), style: 'height: 0.8em') + - if run.entry.present? + a.badge.badge-dark.tip title='Recorded as part of a race' href=race_path(run.entry.race) + = icon('fas', 'flag-checkered') - if run.video_url.present? - uri = URI.parse(run.video_url) - if uri.host.match?(/^(www\.)?(youtube\.com|youtu\.be)$/) a.badge.badge-dark.tip href=run.video_url title='Watch on YouTube' => icon('fab', 'youtube') - elsif !uri.host.match?(/^(www\.)?(twitch\.tv)$/) a.badge.badge-dark.tip href=run.video_url title='Watch video' => icon('fas', 'video') + - if run.segments.last.duration(timing).nil? + a.badge.badge-dark.tip.p-0 title='In progress' + .text-danger = icon('fas', 'satellite-dish') .btn-toolbar role='toolbar' aria={label: 'Run navigation'} .btn-group.m-2 role='group' aria={label: 'Run navigation'} diff --git a/app/views/runs/index.slim b/app/views/runs/index.slim index b5508dd90..50e718bf9 100644 --- a/app/views/runs/index.slim +++ b/app/views/runs/index.slim @@ -21,9 +21,19 @@ span.statcard-desc Runs stored .col-sm-3: .statcard.p-3 h2.statcard-number - span> title=current_user.created_at = time_ago_in_words(current_user.created_at) + = render partial: 'shared/relative_time', locals: {time: current_user.created_at} span.statcard-desc Account age - .card.shadow + + .row.mx-2 + .col-md-6.mb-3: .card.shadow + = render partial: 'shared/race_table', locals: { \ + races: current_user.races.finished.order(created_at: :desc), \ + user: current_user, \ + active_races: current_user.races.unfinished, \ + description: 'My Races' \ + } + + .col-md-6.mb-3: .card.shadow = render partial: 'shared/run_table', locals: { \ runs: current_user.pbs, \ cols: %i[time name video uploaded owner_controls rival], \ diff --git a/app/views/runs/show.slim b/app/views/runs/show.slim index c7b193c0f..1d37939fd 100644 --- a/app/views/runs/show.slim +++ b/app/views/runs/show.slim @@ -38,8 +38,9 @@ .col-md-6 - if @run.completed?(timing) && @run.histories.any? #chart-spinner - = render partial: 'shared/spinner' - h5.text-success.text-center Loading charts + .text-success.text-center + span.mr-2 = render partial: 'shared/spinner' + h5.d-inline.text-success Loading charts #chart-alert.alert.alert-danger.my-3 hidden=true #chart-holder hidden=true #run-duration-chart.card.my-3 diff --git a/app/views/shared/_race_table.slim b/app/views/shared/_race_table.slim new file mode 100644 index 000000000..51c5a26b2 --- /dev/null +++ b/app/views/shared/_race_table.slim @@ -0,0 +1,74 @@ +- description ||= nil +- cols ||= [:creator, :time, :entries, :name, :status, :created, :results] +- races = races.page(params[:page]).includes(:owner) +- active_races ||= Race.none + +- if races.none? && active_races.none? + - if description.present? + .card-header.text-white = description + .center.p-4 + i Nothing to show! +- else + - if description.present? + .card-header.text-white = description + .table-responsive + table.card-body.table.table-striped.table-hover.mb-0 + thead + tr + - if cols.include?(:creator) + th.align-right.nowrap Creator + - if cols.include?(:time) + th.align-right.nowrap Time + - if cols.include?(:entries) + th.align-right.nowrap Entries + - if cols.include?(:name) + th width='100%' Title + - if cols.include?(:status) + th.align-right.nowrap Status + - if cols.include?(:created) + th.align-right.nowrap Created + - if cols.include?(:results) + th.align-right.nowrap Results + tbody + - (active_races + races).uniq.each do |race| + tr class=('bg-dark' if active_races.include?(race)) + - if cols.include?(:creator) + td.align-right.nowrap = user_badge(race.owner) + - if cols.include?(:time) + td.align-right.nowrap.text-monospace + - entry = race.entries.find_for(user) if local_assigns[:user].present? + - if entry.present? && entry.done? + .text-success = entry.duration.format + - elsif race.in_progress? + div data={abstime: race.started_at.rfc3339(3)} - + - else + = race.duration.format + - if cols.include?(:entries) + td.align-right.nowrap = race.entries.count + - if cols.include?(:name) + td width='100%' = link_to(race, race_path(race), class: 'run-link') + - if cols.include?(:status) + td.align-right.nowrap + - if race.finished? + .text-success Completed + - elsif race.in_progress? + .text-warning In Progress + - else + - if race.public_visibility? + .text-success Open + - elsif race.invite_only_visibility? + .text-warning Invite Only + - else + - raise # raise if we get an unexpected visibility + - if cols.include?(:created) + td.align-right.nowrap + = render partial: 'shared/relative_time', locals: {time: race.created_at, ago: true} + - if cols.include?(:results) + td.align-right.text-primary.cursor-pointer data={toggle: 'collapse', target: "#accordion-#{race.id}"} + = icon('fas', 'list-ol') + - if cols.include?(:results) + tr + td.m-0.p-0 colspan='999' + .collapse id="accordion-#{race.id}" + .card + = render partial: 'races/entries_table', locals: {race: race, add_ghost: false} diff --git a/app/views/shared/_relative_time.slim b/app/views/shared/_relative_time.slim new file mode 100644 index 000000000..76398b14b --- /dev/null +++ b/app/views/shared/_relative_time.slim @@ -0,0 +1,6 @@ +- if local_assigns[:prefix].present? + => prefix +span data={reltime: time.rfc3339} title=time + = time_ago_in_words(time).sub('about ', '') +- if local_assigns[:ago] + =< 'ago' diff --git a/app/views/shared/_run_table.slim b/app/views/shared/_run_table.slim index 9dc482241..c556c974f 100644 --- a/app/views/shared/_run_table.slim +++ b/app/views/shared/_run_table.slim @@ -4,9 +4,11 @@ - col_options = cols.to_h { |col| [col, []] }.merge(local_assigns.fetch(:col_options, {})) - runs = order_runs(runs).page(params[:page]).includes(:user, :game) - non_pb_runs = user.non_pbs(runs.map(&:category_id)).group_by(&:category_id) if col_options[:time].include?(:archived) -- unless runs.any? - center - i No runs matched! +- if runs.none? + - if description.present? + .card-header.text-white = description + center.p-4 + i Nothing to show! - else - if description.present? .card-header.text-white = description @@ -25,7 +27,7 @@ - if cols.include?(:rival) th.align-right.nowrap Rival - if cols.include?(:uploaded) - th.align-right.nowrap = th_sorter('Uploaded', 'created_at') + th.align-right.nowrap = th_sorter('Created', 'created_at') - if cols.include?(:owner_controls) th.header.align-right.nowrap tbody @@ -61,11 +63,12 @@ - if rivalry.present? && rivalry.to_user.pb_for(timing, run.category).present? - rival_run = rivalry.to_user.pb_for(timing, run.category) = link_to run_path(run, compare: rival_run), class: 'run-link stealth-link' do - = pretty_difference(run.duration(run.default_timing).to_ms, rival_run.duration(run.default_timing).to_ms) + = pretty_difference(run.duration(run.default_timing), rival_run.duration(run.default_timing)) ' against = user_badge(rivalry.to_user) - if cols.include?(:uploaded) - td.align-right.nowrap title=run.created_at #{time_ago_in_words(run.created_at)} ago + td.align-right.nowrap + = render partial: 'shared/relative_time', locals: {time: run.created_at, ago: true} - if cols.include?(:owner_controls) td.nowrap: .dropleft.text-center .kill-run-button id="dropdown_#{run.id36}" data={toggle: :dropdown} diff --git a/app/views/users/show.slim b/app/views/users/show.slim index c93cd0941..205314d36 100644 --- a/app/views/users/show.slim +++ b/app/views/users/show.slim @@ -15,14 +15,22 @@ a.btn.btn-srdc.tip title='Visit on Speedrun.com' href=@user.srdc.url => image_tag(asset_path('srdc.png'), style: 'height: 0.8em') -article - .row - .col-lg-12 - .card.shadow - = render partial: 'shared/run_table', locals: { \ - runs: @user.pbs, \ - cols: %i[time video name uploaded], \ - col_options: {time: [:archived]}, \ - description: 'Personal Bests', \ - user: @user \ - }.merge(sorting_info) +.row.mx-2 + .col-md-6.mb-3 + .card.shadow + = render partial: 'shared/race_table', locals: { \ + races: @user.races.finished.not_secret_visibility.order(created_at: :desc), \ + user: @user, \ + active_races: @user.races.active.not_secret_visibility, \ + description: "#{@user}'s Races" \ + } + + .col-md-6.mb-3 + .card.shadow + = render partial: 'shared/run_table', locals: { \ + runs: @user.pbs, \ + cols: %i[time video name uploaded], \ + col_options: {time: [:archived]}, \ + description: 'Personal Bests', \ + user: @user \ + }.merge(sorting_info) diff --git a/config/application.rb b/config/application.rb index 986558e84..b4c4d9d18 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,5 +24,9 @@ class Application < Rails::Application config.to_prepare do Doorkeeper::AuthorizationsController.layout 'application' end + + config.action_cable.disable_request_forgery_protection = true + config.action_cable.url = '/api/cable' + config.action_cable.mount_path = '/api/cable' end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 47d6563ed..e23f30564 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -35,13 +35,12 @@ # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + config.active_storage.service = :amazon # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil - # config.action_cable.url = 'wss://example.com/cable' - config.action_cable.allowed_request_origins = ['https://splits.io', 'https://*.splits.io'] + # config.action_cable.url = '/api/cable' + # config.action_cable.allowed_request_origins = ['https://splits.io', 'https://*.splits.io'] # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = ENV.fetch('USE_SSL', '1') == '1' diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 1b79e310e..07e52e220 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -4,7 +4,7 @@ # For further information see the following documentation # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy -# Rails.application.config.content_security_policy do |policy| +Rails.application.config.content_security_policy do |policy| # policy.default_src :self, :https # policy.font_src :self, :https, :data # policy.img_src :self, :https, :data @@ -16,7 +16,16 @@ # # Specify URI for violation reports # # policy.report_uri "/csp-violation-report-endpoint" -# end + + # from https://github.com/rails/webpacker#vue + Rails.application.config.content_security_policy do |policy| + if Rails.env.development? + policy.script_src :self, :https, :unsafe_eval + else + policy.script_src :self, :https + end + end +end # If you are using UJS then enable automatic nonce generation # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } diff --git a/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb new file mode 100644 index 000000000..5b81c9feb --- /dev/null +++ b/config/initializers/delayed_job.rb @@ -0,0 +1 @@ +Delayed::Worker.sleep_delay = 0.5 diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 30a836397..c7a303696 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -12,7 +12,7 @@ end authorization_code_expires_in 10.minutes - access_token_expires_in 2.hours + access_token_expires_in 48.hours use_refresh_token @@ -25,7 +25,7 @@ # For more information go to # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes # default_scopes :public - optional_scopes :upload_run, :delete_run + optional_scopes :upload_run, :delete_run, :manage_race # Change the way client credentials are retrieved from the request object. # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then diff --git a/config/initializers/rollbar.rb b/config/initializers/rollbar.rb index 3c66911ca..b2a1932ca 100644 --- a/config/initializers/rollbar.rb +++ b/config/initializers/rollbar.rb @@ -1,6 +1,7 @@ require 'rollbar' Rollbar.configure do |config| config.access_token = ENV['ROLLBAR_ACCESS_TOKEN'] + config.environment = ENV['ROLLBAR_ENV'] config.enabled = (Rails.env == 'production') config.js_enabled = false # Somehow adds a '<' to the top of every page when on ¯\_(ツ)_/¯ diff --git a/config/initializers/s3.rb b/config/initializers/s3.rb index 2822ceb2d..969cd8213 100644 --- a/config/initializers/s3.rb +++ b/config/initializers/s3.rb @@ -11,7 +11,7 @@ retry_limit: 2 } -# When users upload runs, they do it directly to S3 using a presigned request we generate. When running Splits I/O +# When users upload runs, they do it directly to S3 using a presigned request we generate. When running Splits.io # locally in Docker, the hostname for that presigned request (localhost) is different than the one the server uses to # interact with S3 (s3), so we need one S3 client for each hostname even though they both point to the same bucket. if ENV['AWS_REGION'] == 'local' diff --git a/config/routes.rb b/config/routes.rb index eba300d2f..127e5ad06 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -90,6 +90,9 @@ to: 'games/categories/leaderboards/sum_of_bests#index', as: :game_category_sum_of_bests + get '/races', to: 'races#index', as: :races + get '/races/:id', to: 'races#show', as: :race + get '/tools', to: 'tools#index' get '/settings', to: 'settings#index', as: :settings @@ -151,9 +154,29 @@ put '/runs/:run', to: 'runs#update' delete '/runs/:run', to: 'runs#destroy' + get '/runs/:run/source_file', to: 'runs/source_files#show', as: 'run_source_file' + put '/runs/:run/source_file', to: 'runs/source_files#update' + delete '/runs/:run/user', to: 'runs/users#destroy' post '/convert', to: 'converts#create' + + get '/races', to: 'races#index', as: 'races' + post '/races', to: 'races#create' + get '/races/:id', to: 'races#show', as: 'race' + patch '/races/:id', to: 'races#update' + + get '/races/:race_id/entries/:id', to: 'races/entries#show', as: 'race_entry' + post '/races/:race_id/entries', to: 'races/entries#create', as: 'race_entries' + patch '/races/:race_id/entries/:id', to: 'races/entries#update' + delete '/races/:race_id/entries/:id', to: 'races/entries#destroy' + + get '/races/:race_id/chat', to: 'races/chat_messages#index', as: 'race_chat_messages' + post '/races/:race_id/chat', to: 'races/chat_messages#create' + + post '/races/:race_id/ghosts', to: 'races/ghosts#create' + + post '/timesync', to: 'time#create' end namespace :v3 do diff --git a/config/storage.yml b/config/storage.yml index d32f76e8f..311711b07 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -1,34 +1,14 @@ test: service: Disk - root: <%= Rails.root.join("tmp/storage") %> + root: <%= Rails.root.join('tmp/storage') %> local: service: Disk - root: <%= Rails.root.join("storage") %> - -# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) -# amazon: -# service: S3 -# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> -# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> -# region: us-east-1 -# bucket: your_own_bucket - -# Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# bucket: your_own_bucket - -# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name - -# mirror: -# service: Mirror -# primary: local -# mirrors: [ amazon, google, microsoft ] + root: <%= Rails.root.join('storage') %> + +amazon: + service: S3 + access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %> + secret_access_key: <%= ENV['AWS_SECRET_KEY'] %> + region: <%= ENV['AWS_REGION'] %> + bucket: <%= ENV['S3_BUCKET'] %> diff --git a/config/webpack/environment.js b/config/webpack/environment.js index 1cc3f5037..507877a23 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -1,4 +1,6 @@ const { environment } = require('@rails/webpacker') +const { VueLoaderPlugin } = require('vue-loader') +const vue = require('./loaders/vue') const webpack = require('webpack') environment.config.set('resolve.alias', { @@ -29,4 +31,6 @@ environment.loaders.append('expose', { }] }) +environment.plugins.prepend('VueLoaderPlugin', new VueLoaderPlugin()) +environment.loaders.prepend('vue', vue) module.exports = environment diff --git a/config/webpack/loaders/vue.js b/config/webpack/loaders/vue.js new file mode 100644 index 000000000..509c742b5 --- /dev/null +++ b/config/webpack/loaders/vue.js @@ -0,0 +1,6 @@ +module.exports = { + test: /\.vue(\.erb)?$/, + use: [{ + loader: 'vue-loader' + }] +} diff --git a/config/webpacker.yml b/config/webpacker.yml index a6601a878..141859b98 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -34,6 +34,7 @@ default: &default - .woff2 extensions: + - .vue - .mjs - .js - .sass diff --git a/db/migrate/20190425121602_add_racing.rb b/db/migrate/20190425121602_add_racing.rb new file mode 100644 index 000000000..01a11aba8 --- /dev/null +++ b/db/migrate/20190425121602_add_racing.rb @@ -0,0 +1,67 @@ +class AddRacing < ActiveRecord::Migration[6.0] + def change + create_table :races, id: :uuid do |t| + t.belongs_to :category, foreign_key: true, null: false + t.belongs_to :user, foreign_key: true, null: false + + t.integer :visibility, null: false, default: 0 + t.string :join_token, null: false + t.string :notes, null: true + + # The limit param changes the precision of the datetime; 3 is millisecond accuracy + t.datetime :started_at, limit: 3, null: true + t.datetime :created_at, limit: 3, null: false + t.datetime :updated_at, limit: 3, null: false + end + + create_table :bingos, id: :uuid do |t| + t.belongs_to :game, foreign_key: true, null: false + t.belongs_to :user, foreign_key: true, null: false + + t.integer :visibility, null: false, default: 0 + t.string :join_token, null: false + t.string :notes, null: true + t.string :card_url, null: true + + t.datetime :started_at, limit: 3, null: true + t.datetime :created_at, limit: 3, null: false + t.datetime :updated_at, limit: 3, null: false + end + + create_table :randomizers, id: :uuid do |t| + t.belongs_to :game, foreign_key: true, null: false + t.belongs_to :user, foreign_key: true, null: false + + t.integer :visibility, null: false, default: 0 + t.string :join_token, null: false + t.string :notes, null: true + t.string :seed, null: true + + t.datetime :started_at, limit: 3, null: true + t.datetime :created_at, limit: 3, null: false + t.datetime :updated_at, limit: 3, null: false + end + + + create_table :entries, id: :uuid do |t| + t.references :raceable, polymorphic: true, index: true, type: :uuid + t.belongs_to :user, foreign_key: true, index: true + + t.datetime :readied_at, limit: 3, null: true + t.datetime :finished_at, limit: 3, null: true + t.datetime :forfeited_at, limit: 3, null: true + t.datetime :created_at, limit: 3, null: false + t.datetime :updated_at, limit: 3, null: false + end + + create_table :chat_messages, id: :uuid do |t| + t.belongs_to :raceable, polymorphic: true, index: true, type: :uuid + t.belongs_to :user, foreign_key: true, null: false + + t.boolean :from_entrant, null: false + t.text :body, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20190527190820_create_active_storage_tables.active_storage.rb b/db/migrate/20190527190820_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..4939cae99 --- /dev/null +++ b/db/migrate/20190527190820_create_active_storage_tables.active_storage.rb @@ -0,0 +1,31 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs, id: :uuid do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: :uuid do |t| + t.string :name, null: false + + # These three lines are changed from Rails's default to support UUIDs; + # see https://www.wrburgess.com/posts/2018-02-03-1.html + t.uuid :record_id, null: false + t.string :record_type, null: false + t.uuid :blob_id, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/db/migrate/20190617183547_add_run_to_entries.rb b/db/migrate/20190617183547_add_run_to_entries.rb new file mode 100644 index 000000000..d47d9d4e7 --- /dev/null +++ b/db/migrate/20190617183547_add_run_to_entries.rb @@ -0,0 +1,21 @@ +class AddRunToEntries < ActiveRecord::Migration[6.0] + disable_ddl_transaction! + + def change + add_reference :entries, :run, index: false, null: true + add_index :entries, :run_id, algorithm: :concurrently + + # Add null: true to allow segments that haven't been completed yet (in-progress runs). Segment gametime columns and + # columns in the runs table are already nullable. + change_column_null :segments, :realtime_start_ms, null: true + change_column_null :segments, :realtime_end_ms, null: true + change_column_null :segments, :realtime_duration_ms, null: true + change_column_null :segments, :realtime_shortest_duration_ms, null: true + + # Just piggybacking this here, gametime versions of these columns have a default: false and realtime ones don't, so + # just making them consistent. + change_column_default :segments, :realtime_gold, from: nil, to: false + change_column_default :segments, :realtime_reduced, from: nil, to: false + change_column_default :segments, :realtime_skipped, from: nil, to: false + end +end diff --git a/db/migrate/20190703182227_consolidate_races.rb b/db/migrate/20190703182227_consolidate_races.rb new file mode 100644 index 000000000..1fd3203d9 --- /dev/null +++ b/db/migrate/20190703182227_consolidate_races.rb @@ -0,0 +1,41 @@ +class ConsolidateRaces < ActiveRecord::Migration[6.0] + def change + drop_table 'randomizers', id: :uuid do |t| + t.belongs_to :game, foreign_key: true, null: false + t.belongs_to :user, foreign_key: true, null: false + + t.integer :visibility, null: false, default: 0 + t.string :join_token, null: false + t.string :notes, null: true + t.string :seed, null: true + + t.datetime :started_at, limit: 3, null: true + t.datetime :created_at, limit: 3, null: false + t.datetime :updated_at, limit: 3, null: false + end + + drop_table 'bingos', id: :uuid do |t| + t.belongs_to :game, foreign_key: true, null: false + t.belongs_to :user, foreign_key: true, null: false + + t.integer :visibility, null: false, default: 0 + t.string :join_token, null: false + t.string :notes, null: true + t.string :card_url, null: true + + t.datetime :started_at, limit: 3, null: true + t.datetime :created_at, limit: 3, null: false + t.datetime :updated_at, limit: 3, null: false + end + + safety_assured { add_reference :races, :game, null: true } + + safety_assured { rename_column :entries, :raceable_id, :race_id } + safety_assured { rename_column :chat_messages, :raceable_id, :race_id } + + safety_assured { remove_column :chat_messages, :raceable_type, :string } + safety_assured { remove_column :entries, :raceable_type, :string } + + change_column_null :races, :category_id, true + end +end diff --git a/db/migrate/20190708204019_add_ghost_to_entries.rb b/db/migrate/20190708204019_add_ghost_to_entries.rb new file mode 100644 index 000000000..9046afdfa --- /dev/null +++ b/db/migrate/20190708204019_add_ghost_to_entries.rb @@ -0,0 +1,12 @@ +class AddGhostToEntries < ActiveRecord::Migration[6.0] + def change + safety_assured { rename_column :entries, :user_id, :runner_id } + safety_assured { add_column :entries, :ghost, :boolean, null: false, default: false } + + safety_assured { add_reference :entries, :creator, null: true } + Entry.find_each do |entry| + entry.update(creator_id: entry.runner_id) + end + change_column_null :entries, :creator_id, null: false + end +end diff --git a/db/migrate/20190729221148_add_racing_foreign_keys_and_indexes.rb b/db/migrate/20190729221148_add_racing_foreign_keys_and_indexes.rb new file mode 100644 index 000000000..e75e98c5e --- /dev/null +++ b/db/migrate/20190729221148_add_racing_foreign_keys_and_indexes.rb @@ -0,0 +1,12 @@ +class AddRacingForeignKeysAndIndexes < ActiveRecord::Migration[6.0] + def change + safety_assured do + add_foreign_key :races, :games + add_foreign_key :entries, :races + add_foreign_key :chat_messages, :races + + add_index :entries, :race_id + add_index :chat_messages, :race_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index dfdfa3b04..555b75bff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_05_11_153212) do +ActiveRecord::Schema.define(version: 2019_07_29_221148) do # These are extensions that must be enabled in order to support this database enable_extension "citext" @@ -19,6 +19,26 @@ enable_extension "plpgsql" enable_extension "uuid-ossp" + create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false + t.uuid "blob_id", null: false + t.datetime "created_at", null: false + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + create_table "authie_sessions", id: :serial, force: :cascade do |t| t.string "token" t.string "browser_id" @@ -58,6 +78,17 @@ t.index ["name"], name: "index_categories_on_name" end + create_table "chat_messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "race_id" + t.bigint "user_id", null: false + t.boolean "from_entrant", null: false + t.text "body", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["race_id"], name: "index_chat_messages_on_race_id" + t.index ["user_id"], name: "index_chat_messages_on_user_id" + end + create_table "delayed_jobs", id: :serial, force: :cascade do |t| t.integer "priority", default: 0, null: false t.integer "attempts", default: 0, null: false @@ -73,6 +104,23 @@ t.index ["priority", "run_at"], name: "delayed_jobs_priority" end + create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "race_id" + t.bigint "runner_id" + t.datetime "readied_at", precision: 3 + t.datetime "finished_at", precision: 3 + t.datetime "forfeited_at", precision: 3 + t.datetime "created_at", precision: 3, null: false + t.datetime "updated_at", precision: 3, null: false + t.bigint "run_id" + t.boolean "ghost", default: false, null: false + t.bigint "creator_id" + t.index ["creator_id"], name: "index_entries_on_creator_id" + t.index ["race_id"], name: "index_entries_on_race_id" + t.index ["run_id"], name: "index_entries_on_run_id" + t.index ["runner_id"], name: "index_entries_on_runner_id" + end + create_table "game_aliases", id: :serial, force: :cascade do |t| t.integer "game_id" t.citext "name" @@ -169,6 +217,21 @@ t.index ["user_id"], name: "index_patreon_users_on_user_id" end + create_table "races", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "category_id" + t.bigint "user_id", null: false + t.integer "visibility", default: 0, null: false + t.string "join_token", null: false + t.string "notes" + t.datetime "started_at", precision: 3 + t.datetime "created_at", precision: 3, null: false + t.datetime "updated_at", precision: 3, null: false + t.bigint "game_id" + t.index ["category_id"], name: "index_races_on_category_id" + t.index ["game_id"], name: "index_races_on_game_id" + t.index ["user_id"], name: "index_races_on_user_id" + end + create_table "rivalries", id: :serial, force: :cascade do |t| t.integer "category_id" t.integer "from_user_id" @@ -242,14 +305,14 @@ create_table "segments", id: :uuid, default: -> { "uuid_generate_v4()" }, force: :cascade do |t| t.integer "run_id", null: false t.integer "segment_number", null: false - t.bigint "realtime_duration_ms", null: false - t.bigint "realtime_start_ms", null: false - t.bigint "realtime_end_ms", null: false - t.bigint "realtime_shortest_duration_ms", null: false + t.bigint "realtime_duration_ms" + t.bigint "realtime_start_ms" + t.bigint "realtime_end_ms" + t.bigint "realtime_shortest_duration_ms" t.string "name", null: false - t.boolean "realtime_gold", null: false - t.boolean "realtime_reduced", null: false - t.boolean "realtime_skipped", null: false + t.boolean "realtime_gold", default: false, null: false + t.boolean "realtime_reduced", default: false, null: false + t.boolean "realtime_skipped", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "gametime_start_ms" @@ -373,12 +436,20 @@ t.index ["name"], name: "index_users_on_name", unique: true end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "chat_messages", "races" + add_foreign_key "chat_messages", "users" + add_foreign_key "entries", "races" + add_foreign_key "entries", "users", column: "runner_id" add_foreign_key "game_aliases", "games", on_delete: :cascade add_foreign_key "google_users", "users" add_foreign_key "highlight_suggestions", "runs" add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "patreon_users", "users" + add_foreign_key "races", "categories" + add_foreign_key "races", "games" + add_foreign_key "races", "users" add_foreign_key "run_histories", "runs", on_delete: :cascade add_foreign_key "run_likes", "runs" add_foreign_key "run_likes", "users" diff --git a/docker-compose-production.yml b/docker-compose-production.yml index 91ddda7fd..894a65d70 100644 --- a/docker-compose-production.yml +++ b/docker-compose-production.yml @@ -15,7 +15,7 @@ x-environment: - PATREON_CLIENT_ID - PATREON_CLIENT_SECRET - PATREON_WEBHOOK_SECRET - - QUEUES=broadcast_upload,discover_runner,highlight_cleanup,refresh_game,sync_user_follows # keep this in sync with every job in app/jobs + - QUEUES=discover_runner,highlight_cleanup,refresh_game,sync_user_follows,v4_races # keep this in sync with every job in app/jobs - RACK_ENV=production - RAILS_LOG_TO_STDOUT=true # Log to stdout so docker/docker-compose can take over logs - RAILS_SERVE_STATIC_FILES=1 @@ -24,6 +24,7 @@ x-environment: - READ_ONLY_MODE=0 - REDIS_URL - ROLLBAR_ACCESS_TOKEN + - ROLLBAR_ENV - S3_BUCKET - SECRET_KEY_BASE - SITE_TITLE diff --git a/docker-compose.yml b/docker-compose.yml index 78bac7df2..4746b2166 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ x-environment: - NODE_ENV=development - PATREON_CLIENT_ID - PATREON_CLIENT_SECRET - - QUEUES=broadcast_upload,discover_runner,highlight_cleanup,refresh_game,sync_user_follows # this is here only to make you also update docker-compose-production.yml + - QUEUES=discover_runner,highlight_cleanup,refresh_game,sync_user_follows,v4_races # also update docker-compose-production.yml - RAILS_LOG_TO_STDOUT=true # Log to stdout so docker/docker-compose can take over logs - RAILS_ENV - RAILS_ROOT="/app" @@ -84,6 +84,7 @@ services: depends_on: - db - s3 + - redis environment: *server-environment image: worker links: diff --git a/docs/api.md b/docs/api.md index f64406b54..6774acfc9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,280 +1,60 @@ # API v4 -The Splits I/O API supports retrieving runs, runners, games, and categories, as well as uploading, disowning, and deleting -runs. If you only want to upload runs, skip to [Uploading][uploading]. +The Splits.io API supports retrieving runs, runners, games, and categories, as well as uploading, disowning, and deleting +runs and managing races. ## IDs -Resources are identifyable *only* by the following attributes: +Resources are identifiable by the following attributes: -| Resource type | Key attribute | Description of key attribute | Examples of key attribute | -|:---------------------|:--------------|:-----------------------------|:--------------------------------------| -| [Run][run] | ID | A base 36 number | 1b, 3nm, 1vr | -| [Runner][runner] | Name | A Twitch username | glacials, batedurgonnadie, snarfybobo | -| [Game][game] | Shortname | An SRL abbreviation | sms, sm64, portal | -| [Category][category] | ID | A base 10 number | 312, 1456, 11 | +| Resource type | Key attribute | Type | Description | Value example(s) | +|:---------------------|:--------------|:-------|:----------------|:------------------------------------------------| +| [Run][run] | ID | String | Base 36 number | `"1b"` `"3nm"` `"1vr"` | +| [Runner][runner] | Name | String | Twitch username | `"glacials"` `"batedurgonnadie"` `"snarfybobo"` | +| [Game][game] | Shortname | String | Shortname | `"sms"` `"sm64"` `"portal"` | +| [Category][category] | ID | String | Base 10 number | `"312"` `"1456"` `"11"` | +| [Race][race] | ID | String | UUID | `"c198a25f-9f8a-43cd-92ab-472a952f9336"` | +| [Entry][Entry] | ID | String | UUID | `"61db2b30-e024-45c5-b188-e9986ff1c89c"` | -Your code shouldn't care too much about what these attributes actually are, as they're all represented as unique -strings. But of course as a human it's nice to be able to glean some meaning out of them. +Your code shouldn't care too much about what these attributes actually are. They're all represented as opaque +unique strings. -## Run -```sh -curl https://splits.io/api/v4/runs/10x -curl https://splits.io/api/v4/runs/10x?historic=1 -``` -A Run maps 1:1 to an uploaded splits file. - -| Field | Type | Can it be null? | Description | -|--------------------------:|:-----------------------------|:------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | never | Unique ID for identifying the run on Splits I/O. This can be used to construct a user-facing URL or an API-facing one. | -| `srdc_id` | string | when no associated speedrun.com run | Unique ID for identifying the run on speedrun.com. This is typically supplied by the runner manually. | -| `realtime_duration_ms` | number | never | Realtime duration in milliseconds of the run. | -| `realtime_sum_of_best_ms` | number | never | Realtime sum of best in milliseconds of the run. | -| `gametime_duration_ms` | number | never | Gametime duration in milliseconds of the run. | -| `gametime_sum_of_best_ms` | number | never | Gametime sum of best in milliseconds of the run. | -| `default_timing` | string | never | The timing used for the run. Will be either `real` or `game`. | -| `program` | string | never | The name of the timer with which the run was recorded. This is typically an all lowercase, no-spaces version of the program name. | -| `attempts` | number | when not supported by the source timer | The number of run attempts recorded by the timer that generated the run's source file. | -| `image_url` | string | when not supplied by runner | A screenshot of the timer after a finished run. This is typically supplied automatically by timers which support auto-uploading runs to Splits I/O. | -| `created_at` | string | never | The time and date at which this run's source file was uploaded to Splits I/O. This field conforms to [ISO 8601][iso8601]. | -| `updated_at` | string | never | The time and date at which this run was most recently modified on Splits I/O (modify events include disowning, adding a video or speedrun.com association, and changing the run's game/category). This field conforms to [ISO 8601][iso8601]. | -| `video_url` | string | when not supplied by runner | A URL for a Twitch, YouTube, or Hitbox video which can be used as proof of the run. This is supplied by the runner. | -| `game` | [Game][game] | when unable to be determined / not supplied by runner | The game which was run. An attempt is made at autodetermining this from the source file, but it can be later changed by the runner. | -| `category` | [Category][cagegory] | when unable to be determined / not supplied by runner | The category which was run. An attempt is made at autodetermining this from the source file, but it can be later changed by the runner. | -| `runners` | array of [Runners][runner] | never | The runner(s) who performed the run, if they claim credit. | -| `segments` | array of [Segments][segment] | never | The associated segments for the run. - -If a `historic=1` param is included in the request, one additional field will be present: +
+Why are even numerical IDs strings? -| Field | Type | Null when... | Description | -|---------------:|:---------------------------------------------|:------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `histories` | array of History objects | never | Ordered history objects of all previous runs. The first item is the first run recorded by the runner's timer into the source file. The last item is the most recent one. This field is only nonempty if the source timer records history. | +IDs are opaque, and string is a more opaque primitive. It doesn't matter what the actual value is because the only +purpose it +serves is be given back to Splits.io later (as a string!) or to be compared for exact equality to another ID. -### Segment -Segment objects have the following format: - -| Field | Type | Can it be null? | Description | -|--------------------------------:|:--------------|:----------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `name` | string | never | Name of the segment. This value is an exact copy of timers' fields. | -| `segment_number` | number | never | The segment number of the run. (This value starts at 0) | -| `realtime_start_ms` | number | never | The total elapsed time of the run at the moment when this segment was started in realtime. Provided in milliseconds. | -| `realtime_duration_ms` | number | never | Realtime duration in milliseconds of the segment. | -| `realtime_end_ms` | number | never | The total elapsed time of the run at the moment when this segment was finished in realtime (such that the run's duration is equal to the final split's finish time). Provided in milliseconds. | -| `realtime_shortest_duration_ms` | number | when not known | The shortest duration the runner has ever gotten on this segment in realtime. Provided in milliseconds | -| `realtime_gold` | boolean | never | Whether or not this split *was* the shortest duration the runner has ever gotten on this segment in realtime. This field is shorthand for `realtime_duration_ms == realtime_shortest_duration_ms`. | -| `realtime_skipped` | boolean | never | Whether or not this split was skipped in realtime -- some timers let the runner skip over a split in case they forgot to hit their split button on time. Beware that a skipped split's duration is considered `0`, and instead is rolled into the following split. | -| `realtime_reduced` | boolean | never | Whether or not this segment was "reduced" in realtime; that is, had its duration affected by previous splits being skipped. | -| `gametime_start_ms` | number | never | The total elapsed time of the run at the moment when this segment was started in gametime. Provided in milliseconds. | -| `gametime_duration_ms` | number | never | Gametime duration in milliseconds of the segment. | -| `gametime_end_ms` | number | never | The total elapsed time of the run at the moment when this segment was finished in gametime (such that the run's duration is equal to the final split's finish time). Provided in milliseconds. | -| `gametime_shortest_duration_ms` | number | when not known | The shortest duration the runner has ever gotten on this segment in gametime. Provided in milliseconds | -| `gametime_gold` | boolean | never | Whether or not this split *was* the shortest duration the runner has ever gotten on this segment in gametime. This field is shorthand for `duration == best`. | -| `gametime_skipped` | boolean | never | Whether or not this split was skipped in gametime -- some timers let the runner skip over a split in case they forgot to hit their split button on time. Beware that a skipped split's duration is considered `0`, and instead is rolled into the following split. | -| `gametime_reduced` | boolean | never | Whether or not this segment was "reduced" in gametime; that is, had its duration affected by previous splits being skipped. - -If a `historic=1` param is included in the request, one additional field will be present: +You don't need to look inside the ID, perform arithmetic on it, round it, floor it, rid it of leading zeroes, or convert +it between a float and an integer. You don't need to do any numbery things to it. And if you do, you've probably broken +it. It's a negligible hit to store them as strings, and you'd be converting them to strings for use in API requests +anyway. +
-| Field | Type | Null when... | Description | -|---------------:|:---------------------------------------------|:------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `histories` | array of History objects | never | Ordered history objects of all previous runs. The first item is the first run recorded by the runner's timer into the source file. The last item is the most recent one. This field is only nonempty if the source timer records history. | +**Note**: In the below documentation, a path component like `:run` is a substitution for a real run ID. -History objects have the following format. +## Authentication & authorization -| Field | Type | Null when... | Description | -|-----------------------:|:---------------------------------------------|:------------------------------------------------------|:---------------------------------------------------------| -| `attempt_number` | number | never | The correpsonding attempt number this attempt was. | -| `realtime_duration_ms` | number | never | The realtime duration this attempt took in milliseconds. | -| `gametime_duration_ms` | number | never | The gametime duration this attempt took in milliseconds. | - -A note when passing `historic=1` along with your request: Adding historical data to the response can take a long time to -render, so please only request it if you are actually using it. Be prepared for your request to time out for runs that -have a lot of historical information present. - -If an `Accept` header is present, Splits I/O will try to render the run file in the format specified rather than JSON. A full list of valid values is located below. -If the `Accept` header is valid, the `Content-Type` header in the response will be set appropriately and the run will be rendered in the specified format. If -an invalid `Accept` header is supplied, the response `Content-Type` header will be `application/json`, and the status code will be a 406. -In the 406 reponse there will be an array of values that can be rendered. - -| `Accept` Headers Supported | Return Format | Return `Content-Type` | -|---------------------------------:|:---------------------------|:--------------------------------------| -| None | JSON | `application/json` | -| `application/json` | JSON | `application/json` | -| `application/splitsio` | Splits I/O Exchange Format | `application/splitsio` | -| `application/wsplit` | WSplit | `application/wsplit` | -| `application/time-split-tracker` | Time Split Tracker | `application/time-split-tracker` | -| `application/splitterz` | SplitterZ | `application/splitterz` | -| `application/livesplit` | LiveSplit | `application/livesplit` | -| `application/urn` | Urn | `application/urn` | -| `application/llanfair-gered` | Llanfair-Gered | `application/llanfair-gered` | -| `application/original-timer` | Original Run File | One of the following `Content-Type`'s | - -If the accept header is `application/original-timer` then the original file uploaded will be returned as is. Thus it is possible to get back -any of the following `Content-Type`s. -* `application/shitsplit` -* `application/splitty` -* `application/llanfair2` -* `application/facesplit` -* `application/portal-2-live-timer` -* `application/llanfair-gered` -* `application/llanfair` -* `application/urn` -* `application/livesplit` -* `application/source-live-timer` -* `application/splitsio` -* `application/splitterz` -* `application/time-split-tracker` -* `application/worstrun` -* `application/wsplit` +### Simple auth (run uploads only) +If the only thing you need to do on behalf of users is upload runs, you can do so anonymously then send the user to the +"claim URI" in the response's `uris.claim_uri` field. Sending a logged-in user here will cause the run to be claimed to +their account. Sending a logged-out user here will save the secret to browser local storage and prompt the user to sign +in to claim the run. They may do so immediately or at any later time. -## Runner -```sh -curl https://splits.io/api/v4/runners?search=glacials -curl https://splits.io/api/v4/runners/glacials -curl https://splits.io/api/v4/runners/glacials/runs -curl https://splits.io/api/v4/runners/glacials/pbs -curl https://splits.io/api/v4/runners/glacials/games -curl https://splits.io/api/v4/runners/glacials/categories -``` -A Runner is a user who has at least one run tied to their account. Users with zero runs are not discoverable using the -API. +The user must open the claim URI in their web browser for it to become theirs. If you need to upload runs without this +user intervention or want to do more with auth than uploading runs, use the OAuth option. -| Field | Type | Can it be null? | Description | -|---------------:|:-------|:----------------|:---------------------------------------------------------------------------------------------------------------------------| -| `twitch_id` | string | never | The numeric Twitch ID of the user. | -| `name` | string | never | The Twitch name of the user. | -| `display_name` | string | never | The Twitch display name of the user. | -| `avatar` | string | never | The Twitch avatar of the user. | -| `created_at` | string | never | The time and date at which this user first authenticated with Splits I/O. This field conforms to [ISO 8601][iso8601]. | -| `updated_at` | string | never | The time and date at which this user was most recently modified on Splits I/O. This field conforms to [ISO 8601][iso8601]. | - -## Game -```sh -curl https://splits.io/api/v4/games?search=mario -curl https://splits.io/api/v4/games/sms -curl https://splits.io/api/v4/games/sms/categories -curl https://splits.io/api/v4/games/sms/runs -curl https://splits.io/api/v4/games/sms/runners -``` -Most timers allow users to specify a "game" field. A Game is a collection of information about the video game specified -in this field. By definition, Games have at least one run and have an associated shortname, usually scraped from SRL or SRDC, but -sometimes specified manually. - -| Field | Type | Can it be null? | Description | -|-------------:|:--------------------------------|:----------------|:-------------------------------------------------------------------------------------------------------------------------------------| -| `name` | string | never | The full title of the game, like "Super Mario Sunshine". | -| `shortname` | string | when not known | A shortened title of the game, like "sms". Where possible, this name tries to match with those on SpeedRunsLive and/or Speedrun.com. | -| `created_at` | string | never | The time and date at which this game was created on Splits I/O. This field conforms to [ISO 8601][iso8601]. | -| `updated_at` | string | never | The time and date at which this game was most recently modified on Splits I/O. This field conforms to [ISO 8601][iso8601]. | -| `categories` | array of [Categories][category] | never | The known speedrun categories for this game. | - -## Category -```sh -curl https://splits.io/api/v4/categories/40 -curl https://splits.io/api/v4/categories/40/runners -curl https://splits.io/api/v4/categories/40/runs -``` -Some timers allow users to specify a "category" or similar field (any%, 100%, MST, etc.). A Category is a collection of -information about the type of run performed, more specific than a Game. Each Category belongs to a Game. Any number of -Categories can be associated with a Game. - -| Field | Type | Can it be null? | Description | -|-------------:|:-------|:----------------|:-------------------------------------------------------------------------------------------------------------------------------| -| `id` | string | never | The numeric ID of the category. | -| `name` | string | never | The name of the category. | -| `created_at` | string | never | The time and date at which this category was created on Splits I/O. This field conforms to [ISO 8601][iso8601]. | -| `updated_at` | string | never | The time and date at which this category was most recently modified on Splits I/O. This field conforms to [ISO 8601][iso8601]. | - -## Uploading -```sh -curl -X POST https://splits.io/api/v4/runs # then... -curl -X POST https://s3.amazonaws.com/splits.io --form file=@/path/to/file # some fields not shown; see below -``` -Uploading runs is a two-step process. Our long-term storage for runs is on [S3][s3], so in the first request you'll tell -Splits I/O that you're about to upload a run, then in the second you'll upload it directly to S3 using some parameters -returned from the first. The two-request system is faster for you (we don't have to receive your whole run then make you -wait for us to put it on S3) and more resilient for us (we don't have to spend a bunch of CPU time waiting on uploads). - -The first request will return a body like -```json -{ - "status": 201, - "message": "Run reserved. Use the included presigned request to upload the file to S3, with an additional `file` field containing the run file.", - "id": "rez", - "claim_token": "pBeUPBM9IaWqbaF11ocUksXS", - "uris": { - "api_uri": "https://splits.io/api/v4/runs/rez", - "public_uri": "https://splits.io/rez", - "claim_uri": "https://splits.io/rez?claim_token=pBeUPBM9IaWqbaF11ocUksXS" - }, - "presigned_request": { - "method": "POST", - "uri": "https://s3.amazonaws.com/splits.io", - "fields": { - "key": "splits/rez", - "policy": "gibberish", - "x-amz-credential": "other gibberish", - "x-amz-algorithm": "more gibberish", - "x-amz-date": "even more gibberish", - "x-amz-signature": "most gibberish", - } - } -} -``` -The above example would have your second request look like -```sh -curl -X POST https://s3.amazonaws.com/splits.io \ - --form key="splits/rez" \ - --form policy="gibberish" \ - --form x-amz-credential="other gibberish" \ - --form x-amz-algorithm="more gibberish" \ - --form x-amz-date="even more gibberish" \ - --form x-amz-signature="most gibberish" \ - --form file='Your run here, e.g. a JSON object if using the Splits.io Exchange Format' -``` -Or if your run is a file on disk, the last line would be: -``` - --form file=@/path/to/file -``` -**Note**: Order of the parameters matters! Follow the order above if you're getting errors. - -This is called a presigned request. Each field above -- except `file`, that's yours -- is directly copied from the -response of the first request. You don't need to inspect or care about the contents of the fields, as long as you -include them. They serve as authorization for you to upload a file to S3 with Splits I/O's permission. - -Each presigned request can only be made once, and expires if not made within an hour. - -[s3]: https://aws.amazon.com/s3 - -### File Format -The preferred format for uploading run files is the [Splits I/O Exchange Format][exchange-format], which is a standard -JSON schema not specific to any one timer. Splits I/O also knows how to parse some proprietary timer formats via -livesplit-core, all documented in the [FAQ][faq]. - -[exchange-format]: /public/schema -[faq]: https://splits.io/faq#programs - -## User Authentication and Authorization -If you want to upload, disown, or delete runs for a user (e.g. from within a timer), you have two options. -If you only need to know who a user is on Splits I/O, skip to advanced. - -### Simple Option -Upload the run without auth and direct the user to the URL in the response body's `uris.claim_uri`. If they are logged -in when they visit it, their account will automatically claim the run. If they are not logged in, their browser will -save the claim token in LocalStorage and show a prompt allowing them to claim the run after logging in, immediately or -at any later time. - -This is the far easier method to implement, but the user must open the run in their web browser for it to become theirs. -If you prefer to upload runs in the background, this method isn't for you. - -### Advanced Option -The advanced option is a standard OAuth2 flow. You can request permission from the user to upload runs to their account -on their behalf. If they accept, you will receive an OAuth token which you can include in your run requests in -order to perform actions as that user. +### OAuth (general purpose) +Splits.io supports the standard OAuth2 flow. You can request permission from the user to upload runs to their account or +perform other actions on their behalf. If they accept, you will receive an OAuth token which you can include in your API +requests in order to perform actions as that user. The following instructions go into naive-case details about implementing this OAuth support in your application. If you -want to learn more about OAuth or need general OAuth troubleshooting help, you can [research OAuth2 -online][oauth2-simplified]. Especially if your application is a website, it's likely that the language you're using has -well-established libraries that handle much of the below OAuth flow for you. +want to learn more about OAuth or need general OAuth troubleshooting help, you can +[research OAuth2 online][oauth2-simplified]. Especially if your application is a website, it's likely that the language +you're using has well-established libraries that handle much of the below OAuth flow for you. Look into those solutions +first to take the gruntwork out. -In all cases, you'll need to first go to your Splits I/O account's [settings page][1] and create an application, then +In all cases, you'll need to first go to your Splits.io account's [settings page][1] and create an application, then refer to the relevant section below. *Note: Once you have an OAuth token, you can use a request like this to retrieve information about it:* @@ -288,25 +68,28 @@ GET https://splits.io/oauth/token/info?access_token=YOUR_TOKEN Below is a list of all the possible scopes your application can request along with a brief description. You can specify multiple scopes by separating them with spaces in the auth token request. -| Scope | Description | Endpoints | -|--------------|:---------------------------------------------|:--------------------------------------------------------------------------------------------------------------------| -| `upload_run` | Upload runs on behalf of the user | `POST https://splits.io/api/v4/runs` | -| `delete_run` | Delete or disown runs on behalf of the user | `DELETE https://splits.io/api/v4/runs/:run_id` and `DELETE https://splits.io/api/v4/runs/:run_id/user` respectively | +| Scope | Description | Endpoints | +|:--------------|:----------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------| +| `upload_run` | Upload runs on behalf of the user | `POST https://splits.io/api/v4/runs` | +| `delete_run` | Delete or disown runs on behalf of the user | `DELETE https://splits.io/api/v4/runs/:run_id` and `DELETE https://splits.io/api/v4/runs/:run_id/user` respectively | +| `manage_race` | Participate in races and chat on behalf of the user | See [Race][race] | + +
+Example 1: My application is a local program that runs on the user's computer -#### Example 1: My application is a local program that runs on the user's computer If your application runs locally as a program on a user's computer, you should use OAuth's **authorization code grant -flow**. This means your application will open the Splits I/O authorization page in the user's default browser, and if -the user accepts the authorization, Splits I/O will give your application a `code` which you should immediately exchange -for an OAuth token using a secure API request. +flow**. This means your application will open the Splits.io authorization page in the user's default browser, and if the +user accepts the authorization, Splits.io will give your application a `code` which you should immediately exchange for +an OAuth token using a secure API request. 1. Configure your program to run a small web server on a port of your choosing, and listen for `GET` requests to a path of your choosing. In this example, let's say you're listening on port 8000 for requests to `/auth/splitsio`. -2. On your Splits I/O [settings page][1], set your `redirect_uri` to something like +2. On your Splits.io [settings page][1], set your `redirect_uri` to something like ```http http://localhost:8000/auth/splitsio ``` *Hint: Set this to "debug" for now if you don't yet have a page to redirect yourself to.* -3. When a user wants to grant authorization to your application for their Splits I/O account, send them to a URL like +3. When a user wants to grant authorization to your application for their Splits.io account, send them to a URL like this: ```http https://splits.io/oauth/authorize?response_type=code&scope=upload_run&redirect_uri=http://localhost:8000/auth/splitsio&client_id=YOUR_CLIENT_ID @@ -345,6 +128,8 @@ for an OAuth token using a secure API request. ```http Authorization: Bearer YOUR_ACCESS_TOKEN ``` + or by including an `access_token=YOUR_ACCESS_TOKEN` parameter. + The access token expires after the duration specified in `expires_in` (measured in seconds). After it expires, you can retrieve a new one with no user intervention using the returned `refresh_token`: ```http @@ -363,17 +148,21 @@ for an OAuth token using a secure API request. making it obvious when a user's stolen credentials are in use. See [RFC 6749][rfc6749-6] for more information on refresh tokens. -#### Example 2: My application is an all-JavaScript website +
+ +
+Example 2: My application is an all-JavaScript website + If your application is an in-browser JavaScript application with little or no logic performed by a backend server, you should use OAuth's **implicit grant flow**. -1. On your Splits I/O [settings page][1], set your `redirect_uri` to where you want users to land after going through +1. On your Splits.io [settings page][1], set your `redirect_uri` to where you want users to land after going through the authorization flow. For this example, we'll use ```http https://YOUR_WEBSITE/auth/splitsio ``` *Hint: Set this to "debug" for now if you don't yet have a page to redirect yourself to.* -2. When a user wants to grant authorization to your application for their Splits I/O account, send them to a URL like +2. When a user wants to grant authorization to your application for their Splits.io account, send them to a URL like this: ```http https://splits.io/oauth/authorize?response_type=token&scope=upload_run&redirect_uri=https://YOUR_WEBSITE/auth/splitsio&client_id=YOUR_CLIENT_ID @@ -394,20 +183,23 @@ should use OAuth's **implicit grant flow**. This style of expiring access tokens periodically improves security by limiting the usability of any stolen credentials. +
+ +
+Example 3: My application is a website -#### Example 3: My application is a website If your application is a website with a backend component, you should use OAuth's **authorization code grant flow**. -This means your website will link the user to the Splits I/O authorization page, and if the user accepts the -authorization, Splits I/O will give your application a `code` which it will immediately exchange for an OAuth token -using a secure API request. +This means your website will link the user to the Splits.io authorization page, and if the user accepts the +authorization, Splits.io will give your application a `code` which it will immediately exchange for an OAuth token using +a secure API request. -1. On your Splits I/O [settings page][1], set your `redirect_uri` to where you want users to land after going through +1. On your Splits.io [settings page][1], set your `redirect_uri` to where you want users to land after going through the authorization flow. For this example, we'll use ```http https://YOUR_WEBSITE/auth/splitsio ``` *Hint: Set this to "debug" for now if you don't yet have a page to redirect yourself to.* -2. When a user wants to grant authorization to your application for their Splits I/O account, send them to a URL like +2. When a user wants to grant authorization to your application for their Splits.io account, send them to a URL like this: ```http https://splits.io/oauth/authorize?response_type=code&scope=upload_run&redirect_uri=https://YOUR_WEBSITE/auth/splitsio&client_id=YOUR_CLIENT_ID @@ -465,33 +257,865 @@ using a secure API request. [1]: https://splits.io/settings [rfc6749-6]: https://tools.ietf.org/html/rfc6749#section-6 +[race]: #race +
+ +## Resource types +### Run -## Converting ```sh +curl https://splits.io/api/v4/runs/:run +curl https://splits.io/api/v4/runs/:run?historic=1 +``` +A Run maps 1:1 to an uploaded splits file. + +
+Structure of a Run + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/run.json) + +| Field | Type | Null? | Description | +|:--------------------------|:-----------------------------|:------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | Unique ID for identifying the run on Splits.io. This can be used to construct a user-facing URL or an API-facing one. | +| `srdc_id` | string | when no associated speedrun.com run | Unique ID for identifying the run on speedrun.com. This is typically supplied by the runner manually. | +| `realtime_duration_ms` | number | never | Realtime duration in milliseconds of the run. | +| `realtime_sum_of_best_ms` | number | never | Realtime sum of best in milliseconds of the run. | +| `gametime_duration_ms` | number | never | Gametime duration in milliseconds of the run. | +| `gametime_sum_of_best_ms` | number | never | Gametime sum of best in milliseconds of the run. | +| `default_timing` | string | never | The timing used for the run. Will be either `real` or `game`. | +| `program` | string | never | The name of the timer with which the run was recorded. This is typically an all lowercase, no-spaces version of the program name. | +| `attempts` | number | when not supported by the source timer | The number of run attempts recorded by the timer that generated the run's source file. | +| `image_url` | string | when not supplied by runner | A screenshot of the timer after a finished run. This is typically supplied automatically by timers which support auto-uploading runs to Splits.io. | +| `created_at` | string | never | The time and date at which this run's source file was uploaded to Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this run was most recently modified on Splits.io (modify events include disowning, adding a video or speedrun.com association, and changing the run's game/category). This field conforms to [ISO 8601][iso8601]. | +| `video_url` | string | when not supplied by runner | A URL for a Twitch, YouTube, or Hitbox video which can be used as proof of the run. This is supplied by the runner. | +| `game` | [Game][game] | when unable to be determined / not supplied by runner | The game which was run. An attempt is made at autodetermining this from the source file, but it can be later changed by the runner. | +| `category` | [Category][cagegory] | when unable to be determined / not supplied by runner | The category which was run. An attempt is made at autodetermining this from the source file, but it can be later changed by the runner. | +| `runners` | array of [Runners][runner] | never | The runner(s) who performed the run, if they claim credit. | +| `segments` | array of [Segments][segment] | never | The associated segments for the run. | + +If a `historic=1` param is included in the request, one additional field will be present: + +| Field | Type | Null? | Description | +|------------:|:-------------------------|:------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `histories` | array of History objects | never | Ordered history objects of all previous runs. The first item is the first run recorded by the runner's timer into the source file. The last item is the most recent one. This field is only nonempty if the source timer records history. | + +#### Segment +Segment objects have the following format: + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/segment.json) + +| Field | Type | Null? | Description | +|:--------------------------------|:--------|:---------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | Internal ID of the segment. | +| `name` | string | never | Name of the segment. This value is an exact copy of timers' fields. | +| `segment_number` | number | never | The segment number of the run. (This value starts at 0) | +| `realtime_start_ms` | number | never | The total elapsed time of the run at the moment when this segment was started in realtime. Provided in milliseconds. | +| `realtime_duration_ms` | number | never | Realtime duration in milliseconds of the segment. | +| `realtime_end_ms` | number | never | The total elapsed time of the run at the moment when this segment was finished in realtime (such that the run's duration is equal to the final split's finish time). Provided in milliseconds. | +| `realtime_shortest_duration_ms` | number | when not known | The shortest duration the runner has ever gotten on this segment in realtime. Provided in milliseconds | +| `realtime_gold` | boolean | never | Whether or not this split *was* the shortest duration the runner has ever gotten on this segment in realtime. This field is shorthand for `realtime_duration_ms == realtime_shortest_duration_ms`. | +| `realtime_skipped` | boolean | never | Whether or not this split was skipped in realtime -- some timers let the runner skip over a split in case they forgot to hit their split button on time. Beware that a skipped split's duration is considered `0`, and instead is rolled into the following split. | +| `realtime_reduced` | boolean | never | Whether or not this segment was "reduced" in realtime; that is, had its duration affected by previous splits being skipped. | +| `gametime_start_ms` | number | never | The total elapsed time of the run at the moment when this segment was started in gametime. Provided in milliseconds. | +| `gametime_duration_ms` | number | never | Gametime duration in milliseconds of the segment. | +| `gametime_end_ms` | number | never | The total elapsed time of the run at the moment when this segment was finished in gametime (such that the run's duration is equal to the final split's finish time). Provided in milliseconds. | +| `gametime_shortest_duration_ms` | number | when not known | The shortest duration the runner has ever gotten on this segment in gametime. Provided in milliseconds | +| `gametime_gold` | boolean | never | Whether or not this split *was* the shortest duration the runner has ever gotten on this segment in gametime. This field is shorthand for `duration == best`. | +| `gametime_skipped` | boolean | never | Whether or not this split was skipped in gametime -- some timers let the runner skip over a split in case they forgot to hit their split button on time. Beware that a skipped split's duration is considered `0`, and instead is rolled into the following split. | +| `gametime_reduced` | boolean | never | Whether or not this segment was "reduced" in gametime; that is, had its duration affected by previous splits being skipped. | + +If a `historic=1` param is included in the request, one additional field will be present: + +| Field | Type | Null? | Description | +|------------:|:-------------------------|:------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `histories` | array of History objects | never | Ordered history objects of all previous runs. The first item is the first run recorded by the runner's timer into the source file. The last item is the most recent one. This field is only nonempty if the source timer records history. | + +#### History +History objects have the following format. + +| Field | Type | Null? | Description | +|:-----------------------|:-------|:------|:---------------------------------------------------------| +| `attempt_number` | number | never | The correpsonding attempt number this attempt was. | +| `realtime_duration_ms` | number | never | The realtime duration this attempt took in milliseconds. | +| `gametime_duration_ms` | number | never | The gametime duration this attempt took in milliseconds. | +
+ +
+Getting runs in specific formats +Splits.io can render many different formats other than JSON. To get one, pass an Accept header with the format you want. + +| `Accept` Headers Supported | Return Format | Return `Content-Type` | +|:---------------------------------|:--------------------------|:--------------------------------------| +| None | JSON | `application/json` | +| `application/json` | JSON | `application/json` | +| `application/splitsio` | Splits.io Exchange Format | `application/splitsio` | +| `application/wsplit` | WSplit | `application/wsplit` | +| `application/time-split-tracker` | Time Split Tracker | `application/time-split-tracker` | +| `application/splitterz` | SplitterZ | `application/splitterz` | +| `application/livesplit` | LiveSplit | `application/livesplit` | +| `application/urn` | Urn | `application/urn` | +| `application/llanfair-gered` | Llanfair-Gered | `application/llanfair-gered` | +| `application/original-timer` | Original Run File | One of the following `Content-Type`'s | + +If the Accept header is `application/original-timer`, the source file for the run will be returned as-is, in whatever +type it was in at the time of upload. This may even be a format not listed above, as Splits.io can recognize and parse +some types that it can't convert into. In only this case, the Content-Type may be one of these addditional values: + +* `application/shitsplit` +* `application/splitty` +* `application/llanfair2` +* `application/facesplit` +* `application/portal-2-live-timer` +* `application/llanfair` +* `application/source-live-timer` +* `application/worstrun` +
+ +
+Uploading runs + +```sh +curl -X POST https://splits.io/api/v4/runs # then... +curl -X POST https://s3.amazonaws.com/splits.io --form file=@/path/to/file # some fields not shown; see below +``` +Uploading runs is a two-step process. Splits.io stores runs on [S3][s3], so in the first request you'll tell Splits.io +you're about to upload a run, then in the second you'll upload it directly to S3 using some parameters returned from the +first. The two-request system is faster for you (the run is transferred once, not twice) and more resilient for us. + +The first request will return a body like +```json +{ + "status": 201, + "message": "Run reserved. Use the included presigned request to upload the file to S3, with an additional `file` field containing the run file.", + "id": "rez", + "claim_token": "pBeUPBM9IaWqbaF11ocUksXS", + "uris": { + "api_uri": "https://splits.io/api/v4/runs/rez", + "public_uri": "https://splits.io/rez", + "claim_uri": "https://splits.io/rez?claim_token=pBeUPBM9IaWqbaF11ocUksXS" + }, + "presigned_request": { + "method": "POST", + "uri": "https://s3.amazonaws.com/splits.io", + "fields": { + "key": "splits/rez", + "policy": "gibberish", + "x-amz-credential": "other gibberish", + "x-amz-algorithm": "more gibberish", + "x-amz-date": "even more gibberish", + "x-amz-signature": "most gibberish" + } + } +} +``` +The above example would have your second request look like +```sh +# Uploads must be multipart requests (in curl: -F or --form, NOT -d or --data) +curl -X POST https://s3.amazonaws.com/splits.io \ + --form key="splits/rez" \ + --form policy="gibberish" \ + --form x-amz-credential="other gibberish" \ + --form x-amz-algorithm="more gibberish" \ + --form x-amz-date="even more gibberish" \ + --form x-amz-signature="most gibberish" \ + --form file='Your run here, e.g. a JSON object if using the Splits.io Exchange Format' +``` +Or if your run is a file on disk, the last line would instead be: +``` + --form file=@/path/to/file +``` +**Note**: Parameter order matters! Follow the order above if you're getting errors. + +This is called a presigned request. Each field above except `file` is directly copied from the response of the first +request. You don't need to care about the contents of the fields; they serve as authorization for you to upload a file +to S3. Each presigned request can only be made once. + +[s3]: https://aws.amazon.com/s3 + +#### File Format +The preferred format for uploading run files is the [Splits.io Exchange Format][exchange-format], which is a standard +JSON schema not specific to any one timer. Splits.io also knows how to parse some proprietary timer formats via +livesplit-core, documented in the [FAQ][faq]. + +[exchange-format]: /public/schema +[faq]: https://splits.io/faq#programs + +#### Replacing source files +Occasionally you may want to update an existing run's source file to a newer version, such as when a runner splits and +you are reporting splits in realtime (like in a race). You can do this using the same two-request system as above but +changing your method and path to +```http +PUT https://splits.io/api/v4/run/:run/source_file +``` +and following the same steps as above to upload to S3. Splits.io will automatically parse the new file and update the +run. + +**Note**: When uploading in-progress source files in the Splits.io Exchange Format include all splits as normal, but do +not include the `endedAt` field for unreached segments. +
+ +
+Converting runs + +```sh +# Uploads must be multipart requests (-F or --form, not -d or --data) curl -X POST https://splits.io/api/v4/convert?format=livesplit --form file=@/path/to/file ``` When converting between timer formats, the file and program parameters must be included. If you are converting a LiveSplit file and would like to include the history, then the `historic=1` parameter can also be included. The JSON format is outputted as described above in the form of a .json file. -## FAQ +
-### Why are IDs strings? -IDs are opaque. To you, the consumer, it doesn't matter what the actual number itself is because the only purpose it -serves is be given back to Splits I/O later. You don't need to look inside it, you don't need to perform arithmetic on -it, you don't need it rounded or floored or rid of leading zeroes. You don't need to do any numbery things to them. And -in fact if you do do numbery things to them, even accidentally, there's a good chance you've broken them in the process. +### Runner +```sh +curl https://splits.io/api/v4/runners?search=:runner +curl https://splits.io/api/v4/runners/:runner +curl https://splits.io/api/v4/runners/:runner/runs +curl https://splits.io/api/v4/runners/:runner/pbs +curl https://splits.io/api/v4/runners/:runner/games +curl https://splits.io/api/v4/runners/:runner/categories +``` +A Runner is a user who has at least one run tied to their account. Users with zero runs are not discoverable using the +API. -It doesn't matter that on Splits I/O's end they happen to be auto-incrementing base 10 numbers, because none of that -matters to you. By giving you a number type, we'd be implying that it did. Strings are opaque. Strings are what you'd -end up casting your IDs to anyway in order to hit the API with them again. So let's just take the negligible hit and -save you a step. +
+Structure of a Runner -[iso8601]: https://en.wikipedia.org/wiki/ISO_8601 +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/runner.json) +| Field | Type | Null? | Description | +|:---------------|:-------|:------|:--------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | The unique ID of the user. | +| `twitch_id` | string | never | The unique Twitch ID of the user. | +| `twitch_name` | string | never | The unique Twitch name of the user. | +| `name` | string | never | The Twitch name of the user. | +| `display_name` | string | never | The Twitch display name of the user. | +| `avatar` | string | never | The Twitch avatar of the user. | +| `created_at` | string | never | The time and date at which this user first authenticated with Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this user was most recently modified on Splits.io. This field conforms to [ISO 8601][iso8601]. | +
+ +### Game +```sh +curl https://splits.io/api/v4/games?search=:game +curl https://splits.io/api/v4/games/:game +curl https://splits.io/api/v4/games/:game/categories +curl https://splits.io/api/v4/games/:game/runs +curl https://splits.io/api/v4/games/:game/runners +``` +A Game is a collection of information about a game, and a container for Categories. Games are created automatically when +a run is uploaded with an unidentified game name. They try to associate themselves with a Speedrun.com game when +created, but the association is not guaranteed. + +
+Structure of a Game + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/game.json) + +| Field | Type | Null? | Description | +|:-------------|:--------------------------------|:---------------|:-------------------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | The unique ID of the game. | +| `name` | string | never | The full title of the game, like "Super Mario Sunshine". | +| `shortname` | string | when not known | A shortened title of the game, like "sms". Where possible, this name tries to match with those on SpeedRunsLive and/or Speedrun.com. | +| `created_at` | string | never | The time and date at which this game was created on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this game was most recently modified on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `categories` | array of [Categories][category] | never | The known speedrun categories for this game. | +
+ +### Category +```sh +curl https://splits.io/api/v4/categories/:category +curl https://splits.io/api/v4/categories/:category/runners +curl https://splits.io/api/v4/categories/:category/runs +``` +A Category is a ruleset for a Game (Any%, 100%, MST, etc.) and an optional container for Runs. Each Category necessarily +belongs to a Game. Any number of Categories can be associated with a Game. + +
+Structure of a Category + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/category.json) + +| Field | Type | Null? | Description | +|:-------------|:-------|:------|:------------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | The unique ID of the category. | +| `name` | string | never | The name of the category. | +| `created_at` | string | never | The time and date at which this category was created on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this category was most recently modified on Splits.io. This field conforms to [ISO 8601][iso8601]. | +
+ +### Race +```sh +curl https://splits.io/api/v4/races +curl https://splits.io/api/v4/races?historic=1 +curl https://splits.io/api/v4/races/:race +curl https://splits.io/api/v4/races/:race/entries +curl https://splits.io/api/v4/races/:race/chat +``` +A Race is a live competition between multiple Runners who share a start time for their run. + +Nearly all race endpoints require user authorization based on the flow described below in the +[Authentication & authorization][authentication] section. + +
+Structure of a Race + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/race.json) + +| Field | Type | Null? | Description | +|:----------------|:---------------------------------------|:---------------------------------|:--------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | The unique ID of the Race. | +| `path` | string | never | The user-friendly URL to the Race, to be given to a user when necessary. | +| `game` | [Game][game] | when not provided by the creator | The game being raced. | +| `category` | [Category][category] | when not provided by the creator | The category being raced. | +| `visibility` | number | never | The permission set for the Race. (`"public"`, `"invite_only"`, or `"secret"`) | +| `join_token` | string | always, except creation response | The token needed to join the race if it's invite-only or secret. Only provided to the owner as a response to creation. | +| `notes` | string | when not provided by creator | Any notes associatied with the Race. | +| `owner` | [Runner][runner] | never | The Runner who created the Race. | +| `entries` | array of [Entries][entry] | never | All Entries currently in the Race. | +| `chat_messages` | array of [Chat Messages][chat-message] | never | Chat messages for the Race. Only present when fetching the Race individually. | +| `attachments` | array of [Attachments][attachment] | never | Any attachments supplied by the race creator for the benefit of other entrants (e.g. for randomizers). | +| `started_at` | string | when the race has not started | The time and date at which this Race was started on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `created_at` | string | never | The time and date at which this Race was created on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this Race was most recently modified on Splits.io. This field conforms to [ISO 8601][iso8601]. | + +#### Attachment +Attachments have the following structure: + +| Field | Type | Can it be null? | Description | +|:-------------|:-------|:----------------|:-----------------------------------------------------------------------------------------------------------------| +| `id` | string | never | The unique ID of the attachment. | +| `created_at` | string | never | The time and date at which this attachment was created on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `filename` | string | never | The filename of the attachment. | +| `url` | string | never | The URL in which to download the attachment. | +
+ +
+Fetching Races + +```sh +curl https://splits.io/api/v4/races +``` +This endpoint return a list of active Races of their type. A Race is active if it +1. is in progress, or +2. has had some activity in the last 30 minutes, or +3. has at least two entries. + +If you wish to retrieve a listing of previous completed races, pass `historic=1` to the request. This response will be +paginated unlike the active races version. +
+ +
+Fetching a single Race + +```sh +curl https://splits.io/api/v4/races/:race +``` +Get information about a Race. To view information about secret Races, a `join_token` parameter must also be +provided. + +| Status Codes | Success? | Description | +|:-------------|:---------|:------------------------------------------------------------------------------------| +| 202 | Yes | Race schema will be returned. | +| 403 | No | This Race is not viewable by the current user because they lack a valid join token. | +| 404 | No | No Race found with the provided id. | +
+ +
+Creating a new Race + +```sh +curl -X POST https://splits.io/api/v4/races \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{"category_id": "40", "notes": "Notes go here"}' +``` +Create a new Race. + +Invite-only Races can be seen by anyone but only joined with a `join_token`; secret Races can only be seen or +joined with a `join_token`. The join token is returned after creation. You can build it into a user-friendly link: +```http +https://splits.io/races/:race?join_token=:join_token +``` +This link is effectively the password for the Race. The Race owner can always view this link on the Race's page on +Splits.io. + +The only required parameter between all types is the Game or Category being raced. Attachments cannot be specified at +creation and must take place as a separate action afterwards. + +| Status Codes | Success? | Description | +|:-------------|:---------|:-----------------------------------------------------------------------------------------------| +| 201 | Yes | Successfully created, a Race schema will be returned. | +| 400 | No | An error occured while creating the Race. `error` will contain a human-readable error message. | +| 401 | No | Access token is either blank, expired, invalid, or not attached to a user. | +
+ +
+Updating a Race + +```sh +curl -X PATCH https://splits.io/api/v4/races/:race +``` +Update one or more fields of the Race. This endpoint requires that the authenticated user is the creator of the Race. + +| Status Codes | Success? | Description | +|:-------------|:---------|:----------------------------------------------------------------------------------------------------| +| 200 | Yes | Successfully updated. A Race schema will be returned. | +| 400 | No | An error occured while saving the Race. `error` will contain a human-readable error message. | +| 401 | No | Access token is either blank, expired, invalid, or not attached to a user or the owner of the race. | +| 403 | No | The Race has already been started and cannot be updated. | + +Races cannot be deleted. Once one becomes inactive for 30 minutes it will naturally disappear from the listings. +
+ +#### Entry +```sh +curl https://splits.io/api/v4/races/:race/entries/:entry +curl -X PUT https://splits.io/api/v4/races/:race/entries +curl -X PATCH https://splits.io/api/v4/races/:race/entries/:entry +curl -X DELETE https://splits.io/api/v4/races/:race/entries/:entry +``` +An Entry represents a Runner's participation in a Race or a ghost of a past Run. + +All Entry endpoints require an access token. + +
+Structure of an Entry + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/entry.json) + +| Field | Type | Null? | Description | +|:---------------|:-----------------|:---------------------------------|:------------------------------------------------------------------------------------------------------------------------------------| +| `id` | string | never | The unique ID of the Entry. | +| `runner` | [Runner][runner] | never | The user represented by this Entry. | +| `creator` | [Runner][runner] | never | The user that created this Entry; can be different from `runner` if the Entry is a ghost. | +| `run` | [Run][run] | when not supplied by the timer | The Run linked to the current Entry. It has more detailed info about this runner's run, such as splits and history. | +| `ghost` | boolean | never | Whether the Entry represents a past recording of a run (`true`) or a real user that has entered into the race explicitly (`false`). | +| `readied_at` | string | when the Entry isn't ready | The time and date at which this Entry readied up in the Race. This field conforms to [ISO 8601][iso8601]. | +| `finished_at` | string | when the Entry has not finished | The time and date at which this Entry finished this Race. This field conforms to [ISO 8601][iso8601]. | +| `forfeited_at` | string | when the Entry has not forfeited | The time and date at which this Entry forfeited from this Race. This field conforms to [ISO 8601][iso8601]. | +| `created_at` | string | never | The time and date at which this Entry was created on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this Entry was most recently modified on Splits.io. This field conforms to [ISO 8601][iso8601]. | +
+ +
+Fetching an Entry + +```sh +curl -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' https://splits.io/api/v4/races/:race/entry +``` +Get information about the authenticated user's involvement in a given Race. + +| Possible Status Codes | Success? | Description | +|:----------------------|:---------|:--------------------------------------------------------------------------------| +| 200 | Yes | The authenticated user is entered in the given Race; returns an [Entry][entry]. | +| 401 | No | Access token is either blank, expired, invalid, or not attached to a user. | +| 404 | No | The authenticated user is not entered into the given Race. | +
+ +
+Creating an Entry + +```sh +curl -X PUT https://splits.io/api/v4/races/:race/entry \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ +``` +Join a Race. There are no required arguments, however you can supply any parameters specified below in *Updating an +Entry*, e.g. +```json +{"entry": {"run_id": "gcb"}} +``` + +To make a ghost entry, simply supply a `run_id` of a Run on Splits.io that has already completed. The Entry will +automatically become a ghost, inheriting the Run's time, splits, and runner. The authenticated user will be assigned as +the Entry's `creator`. + +If the Race is invite-only or secret, you must supply a `join_token`. The `join_token` should be at the top-level, not +within an `entry` object. + +| Status Codes | Success? | Description | +|:-------------|:---------|:-------------------------------------------------------------------------------------------------------| +| 201 | Yes | Successfully created; returns an [Entry][entry]. | +| 400 | No | An error occured while creating the Entry. The `error` key will contain a user-friendly error message. | +| 401 | No | Access token is either blank, expired, invalid, or not attached to a user. | +| 403 | No | This Race is not joinable by the current user because they lack a valid join token. | +
+ +
+Updating an Entry + +```sh +curl -X PATCH https://splits.io/api/v4/races/:race/entry \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{"entry": {"readied_at": "2019-06-17T03:40:48.123Z"}}' +``` +Change an Entry. Valid parameters are `entry.readied_at`, `entry.finished_at`, `entry.forfeited_at`, and `entry.run_id`. + +| Field | Type | Null? | Description | +|:---------------|:---------------------------|:---------------------------------|:--------------------------------------------------------------------------------------------------------------------| +| `run_id` | string | when not set by you | The [Run][run] ID corresponding to the splits for this Race. See: [replacing source files][replacing-source-files]. | +| `readied_at` | [ISO 8601][iso8601] string | when the runner isn't ready | The timestamp when this runner readied up, if at all. | +| `finished_at` | [ISO 8601][iso8601] string | when the runner hasn't finished | The timestamp when this runner finished the Race, if at all. | +| `forfeited_at` | [ISO 8601][iso8601] string | when the runner hasn't forfeited | The timestamp when this runner forfeited the Race, if at all. | + +An attached `entry.run_id` (optional) will associate the given Run with the Entry in order to display splits. The run +should not be a completed run when you attach it. You can [continuously update the Run][replacing-source-files] as the +user splits to keep the race page up-to-date for stats and comparison purposes. + +The timestamps support three decimal places of precision. They serve as pseudo-booleans; they are the source of truth +for whether a runner is ready/finished/forfeited (`null` for no; non-`null` for yes). + +You may optionally set these by passing the string `"now"`; this is a special string which will make the backend use the +current time. The travel time from you to Splits.io will affect the timestamp, so we don't recommend doing this for +`finished_at` where accuracy is important. + +To unset one of these fields (e.g. to unready the runner), simply set it to `null`. Make sure your JSON encoder does not +filter the key out, as this is different from not passing the key at all. + +| Status Codes | Success? | Description | +|:-------------|:---------|:-------------------------------------------------------------------------------------------------------| +| 200 | Yes | Successfully updated. An Entry schema will be returned. | +| 400 | No | An error occured while updating the Entry. The `error` key will contain a user-friendly error message. | +| 401 | No | Access token is either blank, expired, invalid, or not attached to a user. | +| 403 | No | Access token is valid but its user does not have access to this Entry. | +| 404 | No | No Race found or Entry found for the associated user. | +
+ +
+Deleting an Entry + +```sh +curl -X DELETE -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' https://splits.io/api/v4/races/:race/entry +``` +Leave a Race. A Race that has already started cannot be left, only finished or forfeited. + +| Status Codes | Success? | Description | +|:-------------|:---------|:-------------------------------------------------------------------------------------------------------| +| 200 | Yes | Successfully deleted. | +| 401 | No | Access token is either blank, expired, invalid, or not attached to a user. | +| 403 | No | Access token is valid but its user does not have access to this Entry. | +| 404 | No | No Race found or Entry found for the associated user. | +| 409 | No | An error occured while deleting the Entry. The `error` key will contain a user-friendly error message. | +
+ +#### Chat Message +```sh +curl https://splits.io/api/v4/races/:race/chat +``` +A Chat Message is a shortform message sent by a user to a Race. The user does not have to be entered into the Race in +order to send a Chat Message to it. + +
+Structure of a Chat Message + +[Autogenerated JSON Schema documentation](http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/spec/support/models/api/v4/chat_message.json) + +| Field | Type | Null? | Description | +|:---------------|:-----------------|:------|:-----------------------------------------------------------------------------------------------------------------------------| +| `body` | string | never | The contents of the message. | +| `from_entrant` | boolean | never | Boolean indicating wether the sender was in the race when the message was sent. | +| `created_at` | string | never | The time and date at which this message was created on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `updated_at` | string | never | The time and date at which this message was most recently modified on Splits.io. This field conforms to [ISO 8601][iso8601]. | +| `user` | [Runner][runner] | never | The Runner that sent the message. | +
+ +
+Fetching chat for a race + +```sh +curl https://splits.io/api/v4/races/:race/chat +``` + +| Status Codes | Success? | Description | +|:-------------|:---------|:-----------------------------------------------------------| +| 200 | Yes | A paginated array of all the chat messages for the Race. | +| 403 | No | User does not have permission to read chat from this Race. | +| 404 | No | No Race found for the ID given. | + +
+ +
+Creating a Chat Message + +```sh +curl -X POST https://splits.io/api/v4/races/:race/chat \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"chat_message": {"body": "a message body here"}}' +``` +Send a Chat Message to a Race. All fields except `body` are inferred from your access token. + +| Status Codes | Success? | Description | +|:-------------|:---------|:---------------------------------------------------------------------------------------------------------| +| 201 | Yes | Successfully created; returns a [Chat Message][chat-message] | +| 400 | No | An error occured while creating the message. The `error` key will contain a user-friendly error message. | +| 403 | No | User does not have permission to send chat to this Race. | +| 404 | No | No Race found for the ID given. | + +
+ +### WebSockets +Splits.io broadcasts updates to [Race][race] in realtime over WebSockets. We use WebSockets only to push +changes from Splits.io to clients; to send data the other way, you must use the REST APIs above. + +
+Why can't I use WebSockets to write data? + +To use WebSockets bidirectionally, clients need to do a lot of legwork. They have to nonce request messages to tell +responses apart. They need to wait indefinitely for a response to every message they send. They have to handle the +possibilities that a sent response might not arrive; that a delivered request might not be processed; that a response +can error because of them, or because of the server; that they get rate limited; that their authentication expires; that +a user expects to see any issues in the console; and many more. + +**All** these needs are solved by HTTP. It's free. It doesn't sound like much, but trust us -- we built Races to be +handled 100% over WebSockets, and got so many headaches re-implementing what were effectively basic features of HTTP or +REST that we switched the nearly-complete implementation to the read-only WebSockets version you see today. + +As for the advantages of a persistent connection, [HTTP/2][1] solves this problem transparently! It keeps the TCP socket +between client and server open during multiple requests. (Yes, we support HTTP/2!) + +[1]: https://en.wikipedia.org/wiki/HTTP/2 +
+ +We use a light layer on top of WebSockets called Action Cable, which is part of Ruby on Rails. This layer is so light +that you can use vanilla WebSockets without noticing it's there; but if you happen to be using JavaScript, you might opt +to use the [Action Cable JavaScript library][actioncable-npm] to simplify your code a bit. + +In all examples below, we'll provide instructions for consuming Splits.io WebSockets using vanilla JavaScript as well as +the Action Cable library. The vanilla JS instructions can roughly translate to whatever language you're using. + +**Note**: To assist languages that use strongly-typed schemas, Splits.io WebSocket fields that can hold one of multiple +object types are double-encoded (its value is a string containing more JSON). What you might expect to look like +```json +{ + "type": "confirm_subscription", + "identifier": {"channel": "Api::V4::GlobalRaceChannel"}, +} +``` +might instead look like +```json +{ + "type": "confirm_subscription", + "identifier": "{\"channel\":\"Api::V4::GlobalRaceChannel\"}", +} +``` +If you're not using a language with strongly-typed schemas, just decode the embedded JSON again. + +[actioncable-npm]: https://www.npmjs.com/package/actioncable + +#### Connecting + +
+Connecting with vanilla JavaScript + +```javascript +const websocket = new WebSocket("wss://splits.io/api/cable") +// Splits.io's reply: {"type": "welcome"} + +websocket.onmessage = function(event) { + const msg = JSON.parse(event.data) + switch(msg.type) { + case 'welcome': + console.log('Connected!') + break; + case 'ping': + // ... + break; + } +} +``` +Connecting is as easy as opening the socket and listening for messages. + +**Note**: Supply an `access_token` field in the URL to access the authenticated user's secret races. + +Once connected you'll receive a timestamped ping every ~3 seconds: +```json +{ + "type": "ping", + "message": 1561095929 +} +``` +You do not need to reply to pings. However if you don't get one for an extended period of time you should assume network +conditions have killed your connection, and attempt to re-establish it. +
+ +
+Connecting with Action Cable + +```javascript +import actioncable from "@rails/actioncable" + +const cable = actioncable.createConsumer("wss://splits.io/api/cable") +``` +This initiates the socket; it will be lazily connected in the next step. +
+ + + +#### Subscribing to channels +To receive updates from Splits.io, you first have to tell it what you want updates about. You do this by subscribing to +**channels**. + +There are two channel types: + +| Channel | Required params | Optional params | Description | +|:------------------|:----------------|:----------------------|:---------------------------------------| +| GlobalRaceChannel | *none* | `state` | high-level information about all Races | +| RaceChannel | `race_id` | `state`, `join_token` | detailed information about one Race | + +There is one GlobalRaceChannel and `n` RaceChannels (one for each Race). You can be subscribed to any number of channels +at once, and they all stream over the same WebSocket connection. + +If you pass `state=1` when subscribing, you will get a dump of the current state of the world for that channel. You can +use this to e.g. populate UIs when they first load. + +
+Subscribing to a channel with vanilla JavaScript + +```javascript +websocket.send(JSON.stringify({ + command: 'subscribe', + identifier: JSON.stringify({ + channel: 'Api::V4::GlobalRaceChannel' + }) +})) + +/* Splits.io's reply: +{ + "type": "confirm_subscription", + "identifier": "{ + \"channel\": \"Api::V4::GlobalRaceChannel\" + }" +} +*/ +``` + +```javascript +websocket.send(JSON.stringify({ + command: 'subscribe', + identifier: JSON.stringify({ + channel: "Api::V4::RaceChannel", + race_id: "11902182-aead-44c6-a7b8-e526951564b1", + join_token: "hzT5Fp6tX96wt2omLmRn4RHT" + }) +})) + +/* Splits.io's reply: +{ + "type": "confirm_subscription", + "identifier": "{ + \"channel\": \"Api::V4::RaceChannel\", + \"race_id\": \"11902182-aead-44c6-a7b8-e526951564b1\", + \"join_token\": \"hzT5Fp6tX96wt2omLmRn4RHT\" + }", +} +*/ +``` +
+ +
+Subscribing to a channel with Action Cable + +```javascript +cable.subscriptions.create("Api::V4::GlobalRaceChannel", { + connection() { + // Called when the subscription is ready + }, + + disconnected() { + // Called when the subscription has been terminated by the server + }, + + received(data) { + switch(data.type) { + // See below for GlobalRaceChannel message types + case '...': + // ... + break; + } + } +}) + +cable.subscriptions.create( + { + channel: "Api::V4::RaceChannel", + race_id: "c198a25f-9f8a-43cd-92ab-472a952f9336", + }, + { + connected: () => { + // Called when the subscription is ready + }, + + disconnected: () => { + // Called when the subscription has been terminated by the server + }, + + received: data => { + switch(data.type) { + // See below for RaceChannel message types + case '...': + // ... + break + } + } + } +) +``` +
+ +
+Message content & types + +`identifier` is the exact string you received when initiating the subscription, so you can compare it directly to +determine how the message needs to be handled. + +`message` is an object that contains the changes Splits.io is notifying you about. Note that the `message` object does +not require extra deserialization. +```json +{ + "identifier": "...", + "message": { + "type": "string identifier", + "data": { + "message": "human-readable description of what changed", + ... + } + } +} +``` + +`data` contains fields specific to the type of message (`message.type`): + +| Message type | Applicable channels | Description | Extra Fields | +|:----------------------------|:--------------------|:--------------------------------------------------------------|:-------------------------------| +| `"race_created"` | GlobalRaceChannel | A new Race was created | [`race`][race] | +| `"global_state"` | GlobalRaceChannel | State of the world (in response to `state=1`) | [`races`][race] | +| `"race_updated"` | RaceChannel | A property of the race has changed | [`race`][race] | +| `"new_message"` | RaceChannel | A chat message was sent to the Race | [`chat_message`][chat-message] | +| `"new_attachment"` | RaceChannel | An attachment was added to the Race | [`race`][race] | +| `"race_state"` | RaceChannel | State of the Race (in response to `state=1`) | [`race`][race] | +| `"race_not_found"` | RaceChannel | No Race found for the given ID | *none* | +| `"race_invalid_join_token"` | RaceChannel | The join token is not valid for the Race | *none* | +| `"race_start_scheduled"` | both | The/a Race is starting in a few seconds | [`race`] | +| `"race_ended"` | both | The/a Race has finished | [`race`] | +| `"race_entries_updated" ` | both | An entry was created, changed, or deleted | [`race`] | +| `"fatal_error"` | both | An error occured when processing the message | *none* | +| `"connection_error"` | both | Received when connecting to cable with an invalid oauth token | *none* | +
+ +[attachment]: #attachment +[authentication]: #authentication--authorization +[category]: #category +[chat-message]: #chat-message +[entry]: #entry +[game]: #game +[iso8601]: https://en.wikipedia.org/wiki/ISO_8601 +[race]: #race +[replacing-source-files]: #replacing-source-files [run]: #run -[segment]: #segment [runner]: #runner -[game]: #game -[category]: #category +[segment]: #segment [uploading]: #uploading diff --git a/docs/api_v3.md b/docs/api_v3.md index 9773912fd..d22e24b06 100644 --- a/docs/api_v3.md +++ b/docs/api_v3.md @@ -1,5 +1,5 @@ # splits i/o API -These docs are for the v3 API. +**These docs are for the v3 API. v3 is deprecated; please use [v4](./api.md) instead.** ## Jump to section diff --git a/package.json b/package.json index 07bf8abd5..9d8270b40 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,21 @@ "name": "splits-io", "private": true, "dependencies": { - "@rails/actioncable": "6.0.0-beta3", - "@rails/ujs": "6.0.0-beta3", - "@rails/webpacker": "^4.0.0-rc.7", + "@rails/actioncable": "6.0.0-rc1", + "@rails/activestorage": "6.0.0-rc1", + "@rails/ujs": "6.0.0-rc1", + "@rails/webpacker": "^4.0.2", "async": "^2.6.2", + "babel-loader": "^8.0.5", "caniuse-lite": "^1.0.30000792", "chartkick": "^3.0.2", "clipboard": "^1.7.1", "coffee-loader": "^0.9.0", "coffeescript": "1.12.7", + "corejs-typeahead": "^1.2.1", + "css-loader": "^2.1.1", "deep-defaults": "^1.0.5", + "driftless": "^2.0.3", "expose-loader": "^0.7.5", "file-saver": "^1.3.3", "handlebars": "^4.1.2", @@ -23,15 +28,21 @@ "moment-duration-format": "^2.2.2", "popper.js": "^1.14.3", "spin.js": "^3.1.0", + "timesync": "^1.0.3", "tippy.js": "^2.5.2", "turbolinks": "^5.2.0", - "typeahead.js": "^0.11.1", "underscore": "^1.8.3", + "vue": "^2.6.10", + "vue-loader": "^15.7.0", + "vue-multiselect": "^2.1.6", + "vue-template-compiler": "^2.6.10", + "vue-tippy": "^2.1.2", + "vue-turbolinks": "^2.0.4", "webpack": "^4.29.3", "webpack-merge": "^4.1.1" }, "devDependencies": { "webpack-cli": "^3.2.3", - "webpack-dev-server": "^3.1.14" + "webpack-dev-server": "^3.3.1" } } diff --git a/public/schema/README.md b/public/schema/README.md index c14a4d7c8..d31445f56 100644 --- a/public/schema/README.md +++ b/public/schema/README.md @@ -2,16 +2,17 @@ The Splits.io Exchange Format is a JSON schema denoting a standard way of arranging run information for passing between timers and other programs (like Splits.io). -[**Documentation**][1] +The best way to digest the schema is the [**autogenerated documentation**][1]. -[**Raw JSON Schema**][2] +You can also use the [**raw JSON schema**][2] for easy implementation, validation, or testing of the runs you generate +or consume. ## Examples The schema is large, but many fields are optional -- a run at its heart resembles the following: ```json { - "_schemaVersion": "v1.0.0", + "_schemaVersion": "v1.0.1", "timer": { "shortname": "livesplit", "longname": "LiveSplit", @@ -34,6 +35,16 @@ information. For details around all possible fields, see the [documentation][1]. schema itself into your program for validation -- it's written in [json-schema][3], so any json-schema library will be able to handle it. -[1]: http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/public/schema/run_v1.0.0.json -[2]: https://raw.githubusercontent.com/glacials/splits-io/master/public/schema/run_v1.0.0.json +## Changelog +We try to change the schema as little as possible. When we do, we post why here. + +*Note*: All schemas accept any patch of an equivalent major-minor version, and any lesser minor version of an equivalent +major version (e.g. `v1.1.1` accepts `v1.0.0`, `v1.1.0`, and `v1.1.2`, but not `v1.2.0` or `v2.0.0`). The exception is +schema `v1.0.0`, which only accepts `v1.0.0` runs. + +- v1.0.1 (2019-07) Corrected a malformed `examples` key; updated some property names and descriptions; format now allows any future v1.0.x patch +- v1.0.0 (2018-09) Schema released + +[1]: http://lbovet.github.io/docson/index.html#https://raw.githubusercontent.com/glacials/splits-io/master/public/schema/run_v1.0.1.json +[2]: https://raw.githubusercontent.com/glacials/splits-io/master/public/schema/run_v1.0.1.json [3]: http://json-schema.org/ diff --git a/public/schema/run_v1.0.1.json b/public/schema/run_v1.0.1.json new file mode 100644 index 000000000..7ca74049f --- /dev/null +++ b/public/schema/run_v1.0.1.json @@ -0,0 +1,425 @@ +{ + "$id": "https://splits.io/schema/run_v1.0.1.json", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "Splits.io Exchange Format", + "description": "The Splits.io Exchange Format is a specification for the transmission of runs between services and programs.", + "required": ["_schemaVersion", "timer"], + "additionalProperties": false, + "definitions": { + "duration": { + "type": "object", + "title": "Duration", + "description": "Duration holds a realtime duration and a gametime duration.", + "additionalProperties": false, + "properties": { + "realtimeMS": { + "type": "number", + "title": "Realtime (Milliseconds)", + "description": "Realtime (Milliseconds) is a duration of milliseconds in real-world time.", + "examples": [123456, 36000000], + "multipleOf": 1, + "minimum": 0 + }, + "gametimeMS": { + "type": "number", + "title": "Gametime (Milliseconds)", + "description": "Gametime (Milliseconds) is a duration of milliseconds in game-world time.", + "examples": [123456, 36000000], + "multipleOf": 1, + "minimum": 0 + } + } + }, + "runTime": { + "type": "object", + "title": "Run Time", + "description": "Run Time represents a moment inside a run, and indicates the duration of the run so far at that moment. It holds a realtime run duration so far and a gametime run duration so far.", + "additionalProperties": false, + "properties": { + "realtimeMS": { + "type": "number", + "title": "Realtime (Milliseconds)", + "description": "Realtime (Milliseconds) is a duration of a run so far in milliseconds.", + "examples": [123456, 36000000], + "multipleOf": 1, + "minimum": 0 + }, + "gametimeMS": { + "type": "number", + "title": "Gametime (Milliseconds)", + "description": "Gametime (Milliseconds) is a duration a run so far in milliseconds.", + "examples": [123456, 36000000], + "multipleOf": 1, + "minimum": 0 + } + } + } + }, + "properties": { + "_schemaVersion": { + "description": "Specifies which version of the Splits.io Exchange Format the run was produced for. If you try to parse a run and this field doesn't validate, you probably need to update your Splits.io Exchange Format schema. All schemas accept any patch of an equivalent major-minor version, and any lesser minor version of an equivalent major version (e.g. v1.1.1 accepts v1.0.0, v1.1.0, and v1.1.2, but not v1.2.0 or v2.0.0). The exception is definition v1.0.0, which only accepts v1.0.0 runs.", + "examples": ["v1.0.0", "v1.0.1"], + "pattern": "v1\.0\.\d+", + "title": "Splits.io Exchange Format Version", + "type": "string" + }, + "links": { + "type": "object", + "title": "Links", + "description": "Links specifies the run's identity in other services.", + "additionalProperties": false, + "properties": { + "speedruncomID": { + "type": "string", + "title": "Speedrun.com ID", + "description": "Speedrun.com ID is the run's ID on Speedrun.com. This can be used to communicate with the Speedrun.com API.", + "examples": ["8y8dronz"] + }, + "splitsioID": { + "type": "string", + "title": "Splits.io ID", + "description": "Splits.io ID is the run's ID on Splits.io. This can be used to communicate with the Splits.io API.", + "examples": ["oqt"] + } + } + }, + "timer": { + "type": "object", + "title": "Timer", + "description": "Timer holds information about the timer used to record the run.", + "required": ["shortname", "longname", "version"], + "additionalProperties": false, + "properties": { + "shortname": { + "type": "string", + "title": "Shortname", + "description": "Shortname is a machine-readable timer name, intended for use in APIs, databases, URLs, and filenames.", + "examples": ["livesplit", "wsplit", "timesplittracker", "llanfair", "llanfair_gered"] + }, + "longname": { + "type": "string", + "title": "Longname", + "description": "Longname is a human-readable timer name, intended for display to users.", + "examples": ["LiveSplit", "WSplit", "Time Split Tracker", "Llanfair", "Llanfair (Gered's Fork)"] + }, + "version": { + "type": "string", + "title": "Version", + "description": "Version is the version of the timer used to record this run. Semantic Versioning is strongly recommended but not enforced.", + "examples": ["v1.0", "v3.1.4", "v2.3.4-alpha1"] + }, + "website": { + "type": "string", + "title": "Website", + "description": "Website is the URL for the timer's website.", + "examples": ["http://livesplit.org"], + "format": "uri" + } + } + }, + "attempts": { + "type": "object", + "title": "Attempts", + "description": "Attempts contains historical information about previous runs by this runner in this category.", + "additionalProperties": false, + "properties": { + "total": { + "type": "integer", + "title": "Total", + "description": "Total holds the total number of attempts for this category.", + "examples": [1, 12, 241, 1023], + "multipleOf": 1, + "minimum": 0 + }, + "histories": { + "type": "array", + "title": "Histories", + "description": "Histories is an array of previous attempts by this runner of this category.", + "default": [], + "items": { + "type": "object", + "title": "History", + "description": "History is a single recorded attempt of this category by this runner.", + "required": ["attemptNumber"], + "additionalProperties": false, + "properties": { + "attemptNumber": { + "type": "integer", + "title": "Attempt Number", + "description": "Attempt Number is the number of lifetime attempts the runner will have made after this one. The Attempt Number for an attempt is a label, not an index; the first attempt for a category has an Attempt Number of 1 (not 0)." + }, + "duration": {"$ref": "#/definitions/duration"} + } + } + } + } + }, + "imageURL": { + "type": "string", + "title": "Image URL", + "description": "Image URL is the location of an image associated with this run. Often this is a screenshot of the timer at run completion, but can be anything the runner wants displayed alongside the run.", + "examples": ["https://i.imgur.com/ebyjwLJ.jpg"], + "format": "uri" + }, + "videoURL": { + "type": "string", + "title": "Video URL", + "description": "Video URL is the location of a VOD of the run.", + "examples": ["https://www.youtube.com/watch?v=0No0y9C1tmM", "https://www.twitch.tv/videos/180016373"], + "format": "uri" + }, + "startedAt": { + "type": "string", + "title": "Started At", + "description": "Started At is the date and time at which the run was started, specified in RFC 3339 format.", + "examples": ["2002-10-02T10:00:00-05:00", "2002-10-02T15:00:00Z", "2002-10-02T15:00:00.05Z"], + "format": "date-time" + }, + "endedAt": { + "type": "string", + "title": "Ended At", + "description": "Ended At is the date and time at which the run was ended, specified in RFC 3339 format.", + "examples": ["2002-10-02T11:00:00-05:00", "2002-10-02T16:00:00Z", "2002-10-02T16:00:00.05Z"], + "format": "date-time" + }, + "pauses": { + "type": "array", + "title": "Pauses", + "description": "Pauses holds runner-caused pauses that took place during the run.", + "items": { + "type": "object", + "title": "Pause", + "description": "Pause is a single pause that took place during the run.", + "required": ["startedAt"], + "additionalProperties": false, + "properties": { + "startedAt": { + "type": "string", + "title": "Started At", + "description": "Started At is the date and time at which the pause was started, specified in RFC 3339 format.", + "examples": ["2002-10-02T10:00:00-05:00", "2002-10-02T15:00:00Z", "2002-10-02T15:00:00.05Z"], + "format": "date-time" + }, + "endedAt": { + "type": "string", + "title": "Ended At", + "description": "Ended At is the date and time at which the pause was ended, specified in RFC 3339 format.", + "examples": ["2002-10-02T11:00:00-05:00", "2002-10-02T16:00:00Z", "2002-10-02T16:00:00.05Z"], + "format": "date-time" + } + } + } + }, + "game": { + "type": "object", + "title": "Game", + "description": "Game specifies information about the game being run.", + "required": ["longname"], + "additionalProperties": false, + "properties": { + "longname": { + "type": "string", + "title": "Longname", + "description": "Longname is a human-readable game name, intended for display to users.", + "examples": ["Super Mario Sunshine", "The Legend of Zelda: Ocarina of Time", "Portal"] + }, + "shortname": { + "type": "string", + "title": "Shortname", + "description": "Shortname is a machine-readable game name, intended for use in APIs, databases, URLs, and filenames.", + "examples": ["sms", "oot", "portal"] + }, + "links": { + "type": "object", + "title": "Links", + "description": "Links specifies the game's identity in other services.", + "additionalProperties": false, + "default": {}, + "properties": { + "splitsioID": { + "type": "string", + "title": "Splits.io ID", + "description": "Splits.io ID specifies the game's Splits.io ID.", + "examples": ["234"] + }, + "speedruncomID": { + "type": "string", + "title": "Speedrun.com ID", + "description": "Speedrun.com ID specifies the game's Speedrun.com ID.", + "examples": ["kjpdr4jq"] + } + } + } + } + }, + "category": { + "type": "object", + "title": "Category", + "description": "Category specifies information about the category being run.", + "required": ["longname"], + "additionalProperties": false, + "properties": { + "longname": { + "type": "string", + "title": "Longname", + "description": "Longname is a human-readable category name, intended for display to users.", + "examples": ["Any%", "100%", "Any% Hoverless"] + }, + "shortname": { + "type": "string", + "title": "Shortname", + "description": "Shortname is a machine-readable category name, intended for use in APIs, databases, URLs, and filenames.", + "examples": ["anypct", "100pct", "anypct-hoverless"] + }, + "links": { + "type": "object", + "title": "Links", + "description": "Links specifies the category's identity in other services.", + "additionalProperties": false, + "default": {}, + "properties": { + "splitsioID": { + "type": "string", + "title": "Splits.io ID", + "description": "Splits.io ID specifies the category's Splits.io ID.", + "examples": ["234"] + }, + "speedruncomID": { + "type": "string", + "title": "Speedrun.com ID", + "description": "Speedrun.com ID specifies the category's Speedrun.com ID.", + "examples": ["kjpdr4jq"] + } + } + } + } + }, + "runners": { + "type": "array", + "title": "Runners", + "description": "Runners is an array of people who participated in this run. Some games and categories call for cooperative play, but otherwise this will usually be just one person.", + "default": [], + "items": { + "type": "object", + "title": "Runner", + "description": "Runner describes one participant in the recorded run.", + "required": ["shortname"], + "additionalProperties": false, + "properties": { + "longname": { + "type": "string", + "title": "Longname", + "description": "Longname is a human-readable runner name, intended for display to users.", + "examples": ["Glacials"] + }, + "shortname": { + "type": "string", + "title": "Shortname", + "description": "Shortname is a machine-readable runner name, intended for use in APIs, databases, URLs, and filenames.", + "examples": ["glacials"] + }, + "links": { + "type": "object", + "title": "Links", + "description": "Links specifies the runner's identity in other services.", + "additionalProperties": false, + "default": {}, + "properties": { + "twitchID": { + "type": "string", + "title": "Twitch ID", + "description": "Twitch ID specifies the runner's Twitch ID.", + "examples": ["29798286"] + }, + "splitsioID": { + "type": "string", + "title": "Splits.io ID", + "description": "Splits.io ID specifies the runner's Splits.io ID.", + "examples": ["234"] + }, + "speedruncomID": { + "type": "string", + "title": "Speedrun.com ID", + "description": "Speedrun.com ID specifies the runner's Speedrun.com ID.", + "examples": ["kjpdr4jq"] + }, + "twitterID": { + "type": "string", + "title": "Twitter ID", + "description": "Twitter ID specifies the runner's Twitter ID.", + "examples": ["119476949"] + } + } + } + } + } + }, + "segments": { + "type": "array", + "title": "Segments", + "description": "Segments is an array of all segments for this run, ordered from first to last.", + "items": { + "type": "object", + "title": "Segment", + "description": "Segment is one segment in this run.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name is the runner-provided name of this segment", + "examples": ["World 1-2", "Epilogue", "Bottle"] + }, + "endedAt": {"$ref": "#/definitions/runTime"}, + "bestDuration": {"$ref": "#/definitions/duration"}, + "isSkipped": { + "type": "boolean", + "title": "Is Skipped", + "description": "Is Skipped should be true if the runner skipped over the split that ends this segment, rather than splitting. If so, this segment's Ended At is ignored.", + "default": false + }, + "isReset": { + "type": "boolean", + "title": "Is Reset", + "description": "Is Reset should be true if the runner reset the run during this segment. If so, this and all future segments' Ended Ats for this run are ignored.", + "default": false + }, + "histories": { + "type": "array", + "title": "Histories", + "description": "Histories is an array of previous completions of this segment by this runner.", + "default": [], + "items": { + "type": "object", + "title": "History", + "description": "History is a single recorded attempt of this segment by this runner.", + "required": ["attemptNumber"], + "additionalProperties": false, + "properties": { + "attemptNumber": { + "type": "integer", + "title": "Attempt Number", + "description": "Attempt Number is the number of lifetime attempts the runner will have made on this category after this one. Generally these attempt numbers should correspond to those in Attempts -> History, although a number given here may not be present there if the run was reset before completion." + }, + "endedAt": {"$ref": "#/definitions/runTime"}, + "isSkipped": { + "type": "boolean", + "title": "Is Skipped", + "description": "Is Skipped should be true if the runner skipped over the split that ends this segment, rather than splitting. If so, this segment's Ended At is ignored.", + "default": false + }, + "isReset": { + "type": "boolean", + "title": "Is Reset", + "description": "Is Reset should be true if the runner reset the run during this segment. If so, this and all future segments' Ended Ats for this run are ignored.", + "default": false + } + } + } + } + } + } + } + } +} diff --git a/spec/controllers/api/v4/races/chat_messages_controller_spec.rb b/spec/controllers/api/v4/races/chat_messages_controller_spec.rb new file mode 100644 index 000000000..c93d7f45b --- /dev/null +++ b/spec/controllers/api/v4/races/chat_messages_controller_spec.rb @@ -0,0 +1,115 @@ +require 'rails_helper' + +RSpec.describe Api::V4::Races::ChatMessagesController do + describe '#index' do + context 'with no race found' do + subject(:response) { get :index, params: {race_id: '!@#$'} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with a public race' do + let(:race) { FactoryBot.create(:race) } + subject(:response) { get :index, params: {race_id: race.id} } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders the index schema' do + expect(response.body).to match_json_schema(:chat_messages) + end + end + + context 'with a secret race' do + let(:race) { FactoryBot.create(:race, visibility: :secret) } + subject(:response) { get :index, params: {race_id: race.id} } + + context 'with no authorization header present' do + it 'returns a 403' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'with a valid join token' do + subject(:response) { get :index, params: {race_id: race.id, join_token: race.join_token} } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders the index schema' do + expect(response.body).to match_json_schema(:chat_messages) + end + end + end + end + + describe '#create' do + let(:race) { FactoryBot.create(:race) } + subject(:response) { post :create, params: {race_id: race.id, chat_message: {body: 'test message here'}} } + + context 'with no authorization header' do + it 'returns a 401' do + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a valid authorization header' do + let(:user) { FactoryBot.create(:user) } + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + + before { request.headers['Authorization'] = "Bearer #{token.token}" } + + context 'with no race found' do + subject(:response) { post :create, params: {race_id: '!@#$', chat_message: {body: 'test message here'}} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with the user not in the race' do + it 'returns a 201' do + expect(response).to have_http_status(:created) + end + + it 'renders a chat message schema' do + expect(response.body).to match_json_schema(:chat_message) + end + + it 'sets from_entry to false' do + expect(JSON.parse(response.body)['chat_message']['from_entrant']).to be(false) + end + end + + context 'with the user present in the race' do + let(:entry) { FactoryBot.create(:entry, runner: user, creator: user, race: race) } + + before { entry } + + it 'returns a 201' do + expect(response).to have_http_status(:created) + end + + it 'renders a chat message schema' do + expect(response.body).to match_json_schema(:chat_message) + end + + it 'sets from_entry to true' do + expect(JSON.parse(response.body)['chat_message']['from_entrant']).to be(true) + end + end + + context 'with no body' do + subject(:response) { post :create, params: {race_id: race.id} } + + it 'returns a 400' do + expect(response).to have_http_status(:bad_request) + end + end + end + end +end diff --git a/spec/controllers/api/v4/races/entries_controller_spec.rb b/spec/controllers/api/v4/races/entries_controller_spec.rb new file mode 100644 index 000000000..91433ff3b --- /dev/null +++ b/spec/controllers/api/v4/races/entries_controller_spec.rb @@ -0,0 +1,342 @@ +require 'rails_helper' + +RSpec.describe Api::V4::Races::EntriesController do + describe '#show' do + let(:user) { FactoryBot.create(:user) } + let(:race) { FactoryBot.create(:race) } + let(:entry) { FactoryBot.create(:entry, race: race, runner: user, creator: user) } + + context 'with no authorization header' do + subject(:response) { get :show, params: {race_id: race.id, id: entry.id} } + + it 'returns a 401' do + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a valid authorization header' do + let(:user) { FactoryBot.create(:user) } + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + + before { request.headers['Authorization'] = "Bearer #{token.token}" } + + context 'with no race found' do + subject(:response) { get :show, params: {race_id: '!@#$%', id: entry.id} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with an entry present' do + subject(:response) { get :show, params: {race_id: entry.race.id, id: entry.id} } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders an entry schema' do + expect(response.body).to match_json_schema(:entry) + end + end + end + end + + describe '#create' do + let(:race) { FactoryBot.create(:race) } + + context 'with no authorization header' do + subject(:response) { put :create, params: {race_id: race.id} } + + it 'returns a 401' do + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a valid authorization header' do + let(:user) { FactoryBot.create(:user) } + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + subject(:response) { put :create, params: {race_id: race.id} } + + before { request.headers['Authorization'] = "Bearer #{token.token}" } + + context 'with no race found' do + subject(:response) { put :create, params: {race_id: '!@#$%'} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'as a ghost' do + subject(:response) { put :create, params: {race_id: '!@#$%', entry: {run_id: FactoryBot.create(:run).id}} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with the user in another race' do + let(:secondary_race) { FactoryBot.create(:race) } + let(:entry) { FactoryBot.create(:entry, runner: user, creator: user, race: secondary_race) } + + it 'returns a 400' do + entry # Touch entry so that it exists + expect(response).to have_http_status(:bad_request) + end + end + + context 'with the user available to join a race' do + context 'with a secret race' do + let(:race) { FactoryBot.create(:race, visibility: :secret) } + + context 'with no join token' do + it 'returns a 403' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'with an invalid join token' do + subject(:response) { put :create, params: {race_id: race.id, join_token: '!@#$%'} } + + it 'returns a 403' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'with a valid join token' do + subject(:response) { put :create, params: {race_id: race.id, join_token: race.join_token} } + + it 'returns a 201' do + expect(response).to have_http_status(:created) + end + + it 'renders an entry schema' do + expect(response.body).to match_json_schema(:entry) + end + end + end + + context 'with a public race' do + it 'returns a 201' do + expect(response).to have_http_status(:created) + end + + it 'renders an entry schema' do + expect(response.body).to match_json_schema(:entry) + end + end + end + end + end + + describe '#update' do + let(:race) { FactoryBot.create(:race) } + let(:user) { FactoryBot.create(:user) } + let(:entry) { FactoryBot.create(:entry, race: race, runner: user, creator: user) } + + context 'with no authorization header' do + subject(:response) { patch :update, params: {race_id: race.id, id: entry.id, entry: {readied_at: Time.now.utc}} } + + it 'returns a 403' do + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a valid authorization header' do + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + subject(:response) { patch :update, params: {race_id: race.id, id: entry.id} } + + before { request.headers['Authorization'] = "Bearer #{token.token}" } + + context 'with no race found' do + subject(:response) { patch :update, params: {race_id: '!@#$', id: entry.id} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with no entry found' do + subject(:response) { patch :update, params: {race_id: race.id, id: 'beepy beeperson'} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with an entry present' do + before { entry } + + context 'with no parameters' do + subject(:response) { patch :update, params: {race_id: race.id, id: entry.id} } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + end + + context 'with 1 parameter to update' do + let(:time) { Time.now.utc } + + subject(:response) do + patch :update, params: {race_id: race.id, id: entry.id, entry: {readied_at: time.iso8601(3)}, format: :json} + end + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders an entry schema' do + expect(response.body).to match_json_schema(:entry) + end + + it 'matches the given time' do + expect(JSON.parse(response.body)['entry']['readied_at']).to eq(time.iso8601(3)) + end + end + + context 'who unreadies' do + context 'before the race starts' do + subject(:response) do + patch :update, params: { + race_id: race.id, + id: entry.id, + entry: {readied_at: nil}, format: :json + } + end + + before { entry.update(readied_at: Time.now.utc) } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders an entry schema' do + expect(response.body).to match_json_schema(:entry) + end + + it 'has a null readied_at' do + expect(JSON.parse(response.body)['entry']['readied_at']).to eq(nil) + end + end + + context 'after the race starts' do + before do + entry.update(readied_at: Time.now.utc - 10.minutes) + race.update(started_at: Time.now.utc) + end + + subject(:response) do + patch :update, params: { + race_id: race.id, + id: entry.id, + entry: {readied_at: nil}, format: :json + } + end + + it 'returns a 400' do + expect(response).to have_http_status(:bad_request) + end + end + end + + context 'setting both forfeited and finished' do + let(:time) { Time.now.utc } + subject(:response) do + patch :update, params: { + race_id: race.id, + id: entry.id, + entry: {forfeited_at: time.iso8601(3), finished_at: time.iso8601(3)}, + format: :json + } + end + + it 'returns a 400' do + expect(response).to have_http_status(:bad_request) + end + end + + context 'setting run_id' do + let(:run) { FactoryBot.create(:run, user: user) } + subject(:response) do + patch :update, params: { + race_id: race.id, + id: entry.id, + entry: {run_id: run.id36}, + format: :json + } + end + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders an entry schema' do + expect(response.body).to match_json_schema(:entry) + end + + it 'sets the correct run' do + expect(JSON.parse(response.body)['entry']['run']['id']).to eq(run.id36) + end + end + end + end + end + + describe '#destroy' do + let(:race) { FactoryBot.create(:race) } + let(:user) { FactoryBot.create(:user) } + let(:entry) { FactoryBot.create(:entry, race: race, runner: user, creator: user) } + + context 'with no authorization header' do + subject(:response) { delete :destroy, params: {race_id: race.id, id: entry.id} } + + it 'returns a 401' do + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a valid authorization header' do + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + + subject(:response) { delete :destroy, params: {race_id: race.id, id: entry.id} } + + before { request.headers['Authorization'] = "Bearer #{token.token}" } + + context 'with no race found' do + subject(:response) { delete :destroy, params: {race_id: 'beep', id: entry.id} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with no entry' do + before { entry.destroy } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with an entry present' do + before { entry } + + context 'before the race starts' do + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + end + + context 'after the race starts' do + before { race.update(started_at: Time.now.utc) } + + it 'returns a 409' do + expect(response).to have_http_status(409) + end + end + end + end + end +end diff --git a/spec/controllers/api/v4/races_controller_spec.rb b/spec/controllers/api/v4/races_controller_spec.rb new file mode 100644 index 000000000..b6479b0dc --- /dev/null +++ b/spec/controllers/api/v4/races_controller_spec.rb @@ -0,0 +1,157 @@ +require 'rails_helper' + +RSpec.describe Api::V4::RacesController do + describe '#index' do + subject(:response) { get :index } + + context 'with no active races' do + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders the index schema' do + expect(response.body).to match_json_schema(:races) + end + end + + context 'with active races' do + let(:races) { FactoryBot.create_list(:race, 10) } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders the index schema' do + expect(response.body).to match_json_schema(:races) + end + end + end + + describe '#create' do + context 'with no authorization header' do + subject(:response) { post :create, params: {race: {category_id: 1}} } + + it 'returns a 403' do + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a valid authorization header' do + let(:user) { FactoryBot.create(:user) } + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + let(:category) { FactoryBot.create(:category) } + subject(:response) { post :create, params: {category_id: category.id} } + + before { request.headers['Authorization'] = "Bearer #{token.token}" } + + context 'with a nonsense category id' do + subject(:response) { post :create, params: {race: {category_id: 'bleepy blooperson'}} } + + it 'returns a 400' do + expect(response).to have_http_status(:bad_request) + end + end + + context 'with a nonsense category id' do + subject(:response) { post :create, params: {race: {category_id: '1234567890'}} } + + it 'returns a 400' do + expect(response).to have_http_status(:bad_request) + end + end + + context 'with a valid category id' do + subject(:response) { post :create, params: {race: {category_id: category.id}} } + + it 'returns a 201' do + expect(response).to have_http_status(:created) + end + + it 'renders a race schema' do + expect(response.body).to match_json_schema(:race) + end + end + + context 'with a nil category id' do + subject(:response) { post :create, params: {race: {category_id: nil}} } + + it 'returns a 201' do + expect(response).to have_http_status(:created) + end + + it 'renders a race schema' do + expect(response.body).to match_json_schema(:race) + end + end + end + end + + describe '#show' do + context 'with a bad id' do + subject(:response) { get :show, params: {id: '!@#$'} } + + it 'returns a 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with a valid id' do + context 'with a public race' do + let(:race) { FactoryBot.create(:race) } + subject(:response) { get :show, params: {id: race.id} } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders a race schema' do + expect(response.body).to match_json_schema(:race) + end + end + + context 'with a secret race' do + let(:race) { FactoryBot.create(:race, visibility: :secret) } + + context 'with no join token' do + subject(:response) { get :show, params: {id: race.id} } + + it 'returns a 403' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'with a valid join token' do + subject(:response) { get :show, params: {id: race.id, join_token: race.join_token} } + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders a race schema' do + expect(response.body).to match_json_schema(:race) + end + end + + context 'with a entrants authorization header' do + let(:user) { FactoryBot.create(:user) } + let(:token) { FactoryBot.create(:access_token, resource_owner_id: user.id) } + let(:entry) { FactoryBot.create(:entry, race: race, runner: user, creator: user) } + subject(:response) { get :show, params: {id: race.id} } + + before do + request.headers['Authorization'] = "Bearer #{token.token}" + entry + end + + it 'returns a 200' do + expect(response).to have_http_status(:ok) + end + + it 'renders a race schema' do + expect(response.body).to match_json_schema(:race) + end + end + end + end + end +end diff --git a/spec/controllers/api/v4/runs/source_files_controller_spec.rb b/spec/controllers/api/v4/runs/source_files_controller_spec.rb new file mode 100644 index 000000000..74f3b29db --- /dev/null +++ b/spec/controllers/api/v4/runs/source_files_controller_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +describe Api::V4::Runs::SourceFilesController do + describe '#show' do + let(:run) { create(:run, :parsed) } + subject { get :show, params: {run: run.id36} } + + it 'returns a 303' do + expect(subject).to have_http_status :see_other + end + end + + describe '#update' do + context 'with no OAuth token' do + let(:run) { create(:run, :parsed) } + subject { put :update, params: {run: run.id36} } + + it 'returns a 401' do + expect(subject).to have_http_status(:unauthorized) + end + end + + context 'with an invalid OAuth token' do + let(:run) { FactoryBot.create(:run, :parsed) } + subject do + request.headers.merge!('Authorization' => "Bearer beepboop") + put :update, params: {run: run.id36} + end + + it 'returns a 401' do + expect(subject).to have_http_status(:unauthorized) + end + end + + context 'with a valid OAuth token' do + let(:authorization) do + Doorkeeper::AccessToken.create( + application: Doorkeeper::Application.create( + name: 'Test Application Please Ignore', + redirect_uri: 'debug', + owner: FactoryBot.create(:user) + ), + resource_owner_id: FactoryBot.create(:user).id, + scopes: 'upload_run', + ) + end + + let(:run) { FactoryBot.create(:run, :parsed) } + subject do + request.headers.merge!('Authorization' => "Bearer #{authorization.token}") + put :update, params: {run: run.id36} + end + + it 'returns a 202' do + expect(subject).to have_http_status(:accepted) + end + end + end +end diff --git a/spec/controllers/runs_controller_spec.rb b/spec/controllers/runs_controller_spec.rb index 60005863d..0acd4b082 100644 --- a/spec/controllers/runs_controller_spec.rb +++ b/spec/controllers/runs_controller_spec.rb @@ -26,7 +26,7 @@ let(:response) { get(:show, params: {run: id}) } context 'for a nonexisting run' do - let(:id) { 'an impossible run id' } + let(:id) { '-1' } it 'returns a 404' do expect(response).to have_http_status(404) diff --git a/spec/factories/access_token_factory.rb b/spec/factories/access_token_factory.rb new file mode 100644 index 000000000..ff7e25521 --- /dev/null +++ b/spec/factories/access_token_factory.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :access_token, class: Doorkeeper::AccessToken do + sequence(:resource_owner_id) { |n| n } + application + + scopes { %i[upload_run delete_run manage_race] } + expires_in { 2.hours } + end +end diff --git a/spec/factories/entry_factory.rb b/spec/factories/entry_factory.rb new file mode 100644 index 000000000..6fdb9530f --- /dev/null +++ b/spec/factories/entry_factory.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :entry do + runner + creator + race + end +end diff --git a/spec/factories/race_factory.rb b/spec/factories/race_factory.rb new file mode 100644 index 000000000..e5a76744d --- /dev/null +++ b/spec/factories/race_factory.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :race do + category + owner factory: :user + end +end diff --git a/spec/factories/run_factory.rb b/spec/factories/run_factory.rb index 7893c75fc..a1a12b045 100644 --- a/spec/factories/run_factory.rb +++ b/spec/factories/run_factory.rb @@ -58,7 +58,7 @@ total_playtime_ms { 10_000 } after(:create) do |run| - FactoryBot.create_list(:segment, 10, run: run) + FactoryBot.create_list(:segment, 10, run: run, segment_number: rand(1..10)) end end diff --git a/spec/factories/segment_factory.rb b/spec/factories/segment_factory.rb index 99a9b8d93..c7b0053d0 100644 --- a/spec/factories/segment_factory.rb +++ b/spec/factories/segment_factory.rb @@ -3,7 +3,7 @@ run name { SecureRandom.uuid } - segment_number { 0 } + segment_number { 1 } realtime_start_ms { 0 } realtime_end_ms { 1000 } diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb index 87a28481f..500378291 100644 --- a/spec/factories/user_factory.rb +++ b/spec/factories/user_factory.rb @@ -1,5 +1,5 @@ FactoryBot.define do - factory :user do + factory :user, aliases: %i[runner creator] do name { SecureRandom.uuid.split('-')[0] } created_at { Time.now } updated_at { Time.now } diff --git a/spec/fixtures/files/seed_file b/spec/fixtures/files/seed_file new file mode 100644 index 000000000..5b99b491a --- /dev/null +++ b/spec/fixtures/files/seed_file @@ -0,0 +1 @@ +Test seed file for ActiveStorage diff --git a/spec/models/run_spec.rb b/spec/models/run_spec.rb index 90a0f63c3..7ca0e0db7 100644 --- a/spec/models/run_spec.rb +++ b/spec/models/run_spec.rb @@ -540,7 +540,7 @@ end end - context 'from the Splits I/O Exchange Format' do + context 'from the Splits.io Exchange Format' do let(:run) do r = FactoryBot.create(:splitsio_exchange_run) r.parse_into_db diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 939454c43..cc255312e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -70,7 +70,12 @@ runner_runs: 'spec/support/views/api/v4/runners/runs/index.json', runner_pbs: 'spec/support/views/api/v4/runners/pbs/index.json', run: 'spec/support/views/api/v4/runs/show.json', - runner: 'spec/support/views/api/v4/runners/show.json' + runner: 'spec/support/views/api/v4/runners/show.json', + race: 'spec/support/views/api/v4/races/show.json', + races: 'spec/support/views/api/v4/races/index.json', + entry: 'spec/support/views/api/v4/races/entries/show.json', + chat_message: 'spec/support/views/api/v4/races/messages/show.json', + chat_messages: 'spec/support/views/api/v4/races/messages/index.json' } # The settings below are suggested to provide a good initial experience diff --git a/spec/support/models/api/v4/chat_message.json b/spec/support/models/api/v4/chat_message.json new file mode 100644 index 000000000..88adc503a --- /dev/null +++ b/spec/support/models/api/v4/chat_message.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "required": [ + "body", + "from_entrant", + "created_at", + "updated_at", + "user" + ], + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "from_entrant": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "../../../models/api/v4/runner.json" + } + } +} diff --git a/spec/support/models/api/v4/entry.json b/spec/support/models/api/v4/entry.json new file mode 100644 index 000000000..46de45f1a --- /dev/null +++ b/spec/support/models/api/v4/entry.json @@ -0,0 +1,73 @@ +{ + "type": "object", + "required": [ + "created_at", + "creator", + "ghost", + "id", + "finished_at", + "forfeited_at", + "readied_at", + "run", + "runner", + "updated_at" + ], + "additionalProperties": false, + "properties": { + "created_at": { + "description": "The time this entry was created.", + "format": "date-time", + "type": "string" + }, + "creator": { + "description": "The user who created this entry. If the entry is a ghost, this can differ from the runner.", + "$ref": "./runner.json" + }, + "finished_at": { + "description": "The time at which the runner finished, if at all.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "forfeited_at": { + "description": "The time at which the runner forfeited, if at all.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "The unchanging unique ID of this entry.", + "type": "string", + "format": "uuid" + }, + "ghost": { + "description": "Whether the entry is a real user (false) or a ghost of user's past run (true).", + "type": "boolean" + }, + "readied_at": { + "description": "The time at which the runner readied, if at all.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "run": { + "description": "The Run linked to this Entry, if any. A linked Run will let the Race show realtime splits and extra stats on the Race page.", + "oneOf": [{"type": "null"}, {"$ref": "./run.json"}] + }, + "runner": { + "description": "The user participating in the race. If the entry is a ghost, this can differ from the creator.", + "$ref": "./runner.json" + }, + "updated_at": { + "description": "The time this entry was most recently changed.", + "format": "date-time", + "type": "string" + } + } +} diff --git a/spec/support/models/api/v4/race.json b/spec/support/models/api/v4/race.json new file mode 100644 index 000000000..9a2e3f4a4 --- /dev/null +++ b/spec/support/models/api/v4/race.json @@ -0,0 +1,112 @@ +{ + "type": "object", + "required": [ + "attachments", + "category", + "chat_messages", + "created_at", + "entries", + "game", + "id", + "join_token", + "notes", + "owner", + "path", + "started_at", + "updated_at", + "visibility" + ], + "additionalProperties": false, + "properties": { + "attachments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "created_at", + "filename", + "id", + "url" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "filename": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string", + "format": "uri" + } + } + } + }, + "category": { + "anyOf": [ + {"$ref": "../../../models/api/v4/category.json"}, + {"type": "null"} + ] + }, + "chat_messages": { + "type": "array", + "items": { + "$ref": "../../../models/api/v4/chat_message.json" + } + }, + "created_at": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "$ref": "../../../models/api/v4/entry.json" + } + }, + "game": { + "anyOf": [ + {"$ref": "../../../models/api/v4/game.json"}, + {"type": "null"} + ] + }, + "id": { + "type": "string" + }, + "join_token": { + "type": [ + "string", + "null" + ] + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "$ref": "../../../models/api/v4/runner.json" + }, + "path": { + "type": "string", + "pattern": "^/.*$" + }, + "started_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "type": "string" + } + } +} diff --git a/spec/support/models/api/v4/runner.json b/spec/support/models/api/v4/runner.json index f436599e2..b20757b27 100644 --- a/spec/support/models/api/v4/runner.json +++ b/spec/support/models/api/v4/runner.json @@ -1,7 +1,9 @@ { "type": "object", "required": [ + "id", "twitch_id", + "twitch_name", "name", "avatar", "created_at", @@ -9,9 +11,16 @@ ], "additionalProperties": false, "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "twitch_id": { "type": "string" }, + "twitch_name": { + "type": "string" + }, "name": { "type": "string" }, diff --git a/spec/support/views/api/v4/races/entries/show.json b/spec/support/views/api/v4/races/entries/show.json new file mode 100644 index 000000000..4d7ab4476 --- /dev/null +++ b/spec/support/views/api/v4/races/entries/show.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": [ + "entry" + ], + "additionalProperties": false, + "properties": { + "entry": { + "$ref": "../../../../../models/api/v4/entry.json" + } + } +} diff --git a/spec/support/views/api/v4/races/index.json b/spec/support/views/api/v4/races/index.json new file mode 100644 index 000000000..23d674e4e --- /dev/null +++ b/spec/support/views/api/v4/races/index.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": [ + "races" + ], + "additionalProperties": false, + "properties": { + "races": { + "type": "array", + "items": { + "$ref": "../../../../models/api/v4/race.json" + } + } + } +} diff --git a/spec/support/views/api/v4/races/messages/index.json b/spec/support/views/api/v4/races/messages/index.json new file mode 100644 index 000000000..090c6aded --- /dev/null +++ b/spec/support/views/api/v4/races/messages/index.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": [ + "chat_messages" + ], + "additionalProperties": false, + "properties": { + "chat_messages": { + "type": "array", + "items": { + "$ref": "../../../../../models/api/v4/chat_message.json" + } + } + } +} diff --git a/spec/support/views/api/v4/races/messages/show.json b/spec/support/views/api/v4/races/messages/show.json new file mode 100644 index 000000000..fabb15e7c --- /dev/null +++ b/spec/support/views/api/v4/races/messages/show.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": [ + "chat_message" + ], + "additionalProperties": false, + "properties": { + "chat_message": { + "$ref": "../../../../../models/api/v4/chat_message.json" + } + } +} diff --git a/spec/support/views/api/v4/races/show.json b/spec/support/views/api/v4/races/show.json new file mode 100644 index 000000000..0048c7e4d --- /dev/null +++ b/spec/support/views/api/v4/races/show.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": [ + "race" + ], + "additionalProperties": false, + "properties": { + "race": { + "$ref": "../../../../models/api/v4/race.json" + } + } +} diff --git a/yarn.lock b/yarn.lock index aef121359..a784a5d85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -651,17 +651,24 @@ resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" integrity sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== -"@rails/actioncable@6.0.0-beta3": - version "6.0.0-beta3" - resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.0.0-beta3.tgz#c55b5bee2e6a79934d32c0ffaf18d05c97aca939" - integrity sha512-rKZRrJh/RaK/ltDm0UjvPSoNebh3187jrq0xIfO95oKEa17YtMQ7vI9A1ees2VBVOX26yD6ntNOJP+D6DC5zbg== +"@rails/actioncable@6.0.0-rc1": + version "6.0.0-rc1" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.0.0-rc1.tgz#283287df5a7344bfc67c5116b6d476ae6efa5a92" + integrity sha512-Yu+GYe87tTYcu7N2usTPAIdcTo9Flwp7/6BE7eaEJIF1IWGGvI4nLQ1wcA1RsLPO5tZ5jT+olZYFlu+QxdQo1g== -"@rails/ujs@6.0.0-beta3": - version "6.0.0-beta3" - resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.0.0-beta3.tgz#e5e38a695311cf1a902553ba0a4e7334c5086bbc" - integrity sha512-a6ot+hUF2+eVmFhLlfNoI5cTKS1379rAEB2rGNZyxqbf0fPMG3RRxeDgmbLXxxxG8LFDz3c4MlAW58ab+m4vnA== +"@rails/activestorage@6.0.0-rc1": + version "6.0.0-rc1" + resolved "https://registry.yarnpkg.com/@rails/activestorage/-/activestorage-6.0.0-rc1.tgz#ed29fa14309c74352d5b68b6388c5899d46b009f" + integrity sha512-WPxMaYJkfWByIR1jeLxAe79dXocAz6+odoPS44t466+Pf+zh1/eckAsucP7egAgcRAczsrrHKtXWqCLEvzcAyA== + dependencies: + spark-md5 "^3.0.0" + +"@rails/ujs@6.0.0-rc1": + version "6.0.0-rc1" + resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.0.0-rc1.tgz#2707eb9c91f0c8f0c02e943c91974207c1f73a8d" + integrity sha512-BpqXn2fTygDPRatrSVJ+DVaQnRJrwU82x2VvtEyY9crYmeUj0diJOrR/30MAXZ7u62zglniMEZFoajRB7JANLg== -"@rails/webpacker@^4.0.0-rc.7": +"@rails/webpacker@^4.0.2": version "4.0.2" resolved "https://registry.yarnpkg.com/@rails/webpacker/-/webpacker-4.0.2.tgz#2c2e96527500b060a84159098449ddb1615c65e8" integrity sha512-TDj/+UHnWaEg8X21E3cGKvptm3BbW1aUtOAXtrYwpK9tkiWq+Dc40Gm2RIZW7rU3jxDDBZgPRiqvr5B0dorIVw== @@ -704,11 +711,50 @@ webpack-cli "^3.2.3" webpack-sources "^1.3.0" +"@types/events@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" + integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== + +"@types/glob@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" + integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== + dependencies: + "@types/events" "*" + "@types/minimatch" "*" + "@types/node" "*" + +"@types/minimatch@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" + integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== + +"@types/node@*": + version "11.13.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.8.tgz#e5d71173c95533be9842b2c798978f095f912aab" + integrity sha512-szA3x/3miL90ZJxUCzx9haNbK5/zmPieGraZEe4WI+3srN0eGLiT22NXeMHmyhNEopn+IrxqMc7wdVwvPl8meg== + "@types/q@^1.5.1": version "1.5.2" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@vue/component-compiler-utils@^2.5.1": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-2.6.0.tgz#aa46d2a6f7647440b0b8932434d22f12371e543b" + integrity sha512-IHjxt7LsOFYc0DkTncB7OXJL7UzwOLPPQCfEUNyxL2qt+tF12THV+EO33O1G2Uk4feMSWua3iD39Itszx0f0bw== + dependencies: + consolidate "^0.15.1" + hash-sum "^1.0.2" + lru-cache "^4.1.2" + merge-source-map "^1.1.0" + postcss "^7.0.14" + postcss-selector-parser "^5.0.0" + prettier "1.16.3" + source-map "~0.6.1" + vue-template-es2015-compiler "^1.9.0" + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -871,11 +917,11 @@ abbrev@1: integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== accepts@~1.3.4, accepts@~1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" - integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I= + version "1.3.6" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.6.tgz#27de8682f0833e966dde5c5d7a63ec8523106e4b" + integrity sha512-QsaoUD2dpVpjENy8JFpQnXP9vyzoZPmAoKrE3S6HtSB7qzSebkJNnmdY4p004FQUSSiHXPueENpoeuUW/7a8Ig== dependencies: - mime-types "~2.1.18" + mime-types "~2.1.24" negotiator "0.6.1" acorn-dynamic-import@^4.0.0: @@ -888,6 +934,11 @@ acorn@^6.0.5: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== +after@0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" + integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8= + ajv-errors@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" @@ -1025,6 +1076,16 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +arraybuffer.slice@~0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" + integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog== + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + asn1.js@^4.0.0: version "4.10.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" @@ -1059,15 +1120,20 @@ assign-symbols@^1.0.0: integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= async-each@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.2.tgz#8b8a7ca2a658f927e9f307d6d1a42f4199f0f735" - integrity sha512-6xrbvN0MOBKSJDdonmSSz2OwFSgxRaVtBDes26mj9KIGtDo+g9xosFRSC+i1gQh2oAN/tQ62AI/pGZGQjVOiRg== + version "1.0.3" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== async-foreach@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI= +async-limiter@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" + integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== + async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -1137,16 +1203,31 @@ babel-plugin-macros@^2.5.0: cosmiconfig "^5.0.5" resolve "^1.8.1" +backo2@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" + integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-arraybuffer@0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" + integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg= + base64-js@^1.0.2: version "1.3.0" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw== +base64id@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6" + integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY= + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -1172,15 +1253,27 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +better-assert@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" + integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI= + dependencies: + callsite "1.0.0" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== binary-extensions@^1.0.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.0.tgz#9523e001306a32444b907423f1de2164222f6ab1" - integrity sha512-EgmjVLMn22z7eGGv3kcnHwSnJXmFHjISTY9E/S5lIcTD3Oxw05QTcBLNkJFzcb3cNueUdF/IN4U+d78V0zO8Hw== + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +blob@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683" + integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig== block-stream@*: version "0.0.9" @@ -1189,16 +1282,37 @@ block-stream@*: dependencies: inherits "~2.0.0" +bluebird@^3.1.1: + version "3.5.5" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f" + integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w== + bluebird@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" - integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw== + version "3.5.4" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.4.tgz#d6cc661595de30d5b3af5fcedd3c0b3ef6ec5714" + integrity sha512-FG+nFEZChJrbQ9tIccIfZJBz3J7mLrAhxakAbnrJWn8d7aKOC+LWifa0G+p4ZqKp4y13T7juYvdhq9NzKdsrjw== bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== +body-parser@1.18.2: + version "1.18.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" + integrity sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ= + dependencies: + bytes "3.0.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.1" + http-errors "~1.6.2" + iconv-lite "0.4.19" + on-finished "~2.3.0" + qs "6.5.1" + raw-body "2.3.2" + type-is "~1.6.15" + body-parser@1.18.3: version "1.18.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" @@ -1412,6 +1526,11 @@ caller-path@^2.0.0: dependencies: caller-callsite "^2.0.0" +callsite@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" + integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA= + callsites@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" @@ -1435,12 +1554,12 @@ camelcase@^3.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= -camelcase@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" - integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^5.0.0, camelcase@^5.2.0: +camelcase@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.2.0.tgz#e7522abda5ed94cc0489e1b8466610e88404cf45" integrity sha512-IXFsBS2pC+X0j0N/GE7Dm7j3bsEBp+oTpb7F50dwEVX7rf3IgwO9XatnegTsDtniKCUtEJH4fSU6Asw7uoVLfQ== @@ -1455,11 +1574,16 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000939, caniuse-lite@^1.0.30000947, caniuse-lite@^1.0.30000949: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000939, caniuse-lite@^1.0.30000947, caniuse-lite@^1.0.30000949: version "1.0.30000951" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000951.tgz#c7c2fd4d71080284c8677dd410368df8d83688fe" integrity sha512-eRhP+nQ6YUkIcNQ6hnvdhMkdc7n3zadog0KXNRxAZTT2kHjUb1yGn71OrPhSn8MOvlX97g5CR97kGVj8fMsXWg== +caniuse-lite@^1.0.30000792: + version "1.0.30000963" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000963.tgz#5be481d5292f22aff5ee0db4a6c049b65b5798b1" + integrity sha512-n4HUiullc7Lw0LyzpeLa2ffP8KxFBGdxqD/8G3bSL6oB758hZ2UE2CVK+tQN958tJIi0/tfpjAc67aAtoHgnrQ== + case-sensitive-paths-webpack-plugin@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.2.0.tgz#3371ef6365ef9c25fa4b81c16ace0e9c7dc58c3e" @@ -1495,10 +1619,10 @@ chartkick@^3.0.2: resolved "https://registry.yarnpkg.com/chartkick/-/chartkick-3.0.2.tgz#8857299595296bdbbcfbe8c028540c58b5e15992" integrity sha512-EJmf7ikPKisW0/naKckCKXq1DW+K5VeCOpqXtWqsCauR+3DhnK/xV1/PEtJovCxH3vPuo/aUU7GwirU6FzBxdw== -chokidar@^2.0.0, chokidar@^2.0.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.2.tgz#9c23ea40b01638439e0513864d362aeacc5ad058" - integrity sha512-IwXUx0FXc5ibYmPC2XeEj5mpXoV66sR+t3jqu2NS2GYwCktt3KF1/Qqjws/NkegajBA4RbZ5+DDwlOiJsxDHEg== +chokidar@^2.0.2, chokidar@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.5.tgz#0ae8434d962281a5f56c72869e79cb6d9d86ad4d" + integrity sha512-i0TprVWp+Kj4WRPtInjexJ8Q+BqTE909VpH8xVhXrJkoc5QC8VO9TryGOqTr+2hljzc1sC62t22h5tZePodM/A== dependencies: anymatch "^2.0.0" async-each "^1.0.1" @@ -1510,7 +1634,7 @@ chokidar@^2.0.0, chokidar@^2.0.2: normalize-path "^3.0.0" path-is-absolute "^1.0.0" readdirp "^2.2.1" - upath "^1.1.0" + upath "^1.1.1" optionalDependencies: fsevents "^1.2.7" @@ -1655,27 +1779,42 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@^2.19.0, commander@~2.19.0: - version "2.19.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" - integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== +commander@^2.19.0, commander@~2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" + integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= -component-emitter@^1.2.1: +component-bind@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" + integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E= + +component-emitter@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +component-inherit@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" + integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM= + compressible@~2.0.16: - version "2.0.16" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.16.tgz#a49bf9858f3821b64ce1be0296afc7380466a77f" - integrity sha512-JQfEOdnI7dASwCuSPWIeVYwc/zMsu/+tRhoUvEfXz2gxOA2DNjmG5vhtFdBlhWPPGo+RdT9S3tgc/uH5qgDiiA== + version "2.0.17" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.17.tgz#6e8c108a16ad58384a977f3a482ca20bff2f38c1" + integrity sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw== dependencies: - mime-db ">= 1.38.0 < 2" + mime-db ">= 1.40.0 < 2" compression-webpack-plugin@^2.0.0: version "2.0.0" @@ -1689,7 +1828,7 @@ compression-webpack-plugin@^2.0.0: serialize-javascript "^1.4.0" webpack-sources "^1.0.1" -compression@^1.5.2: +compression@^1.7.4: version "1.7.4" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== @@ -1717,7 +1856,7 @@ concat-stream@^1.5.0: readable-stream "^2.2.2" typedarray "^0.0.6" -connect-history-api-fallback@^1.3.0: +connect-history-api-fallback@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== @@ -1734,6 +1873,13 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +consolidate@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7" + integrity sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw== + dependencies: + bluebird "^3.1.1" + constants-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -1813,6 +1959,13 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +corejs-typeahead@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/corejs-typeahead/-/corejs-typeahead-1.2.1.tgz#345a8afe664cc494075b59b64777807f0b3f132b" + integrity sha1-NFqK/mZMxJQHW1m2R3eAfws/Eys= + dependencies: + jquery ">=1.11" + cosmiconfig@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc" @@ -1929,7 +2082,7 @@ css-has-pseudo@^0.10.0: postcss "^7.0.6" postcss-selector-parser "^5.0.0-rc.4" -css-loader@^2.1.0: +css-loader@^2.1.0, css-loader@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-2.1.1.tgz#d8254f72e412bb2238bb44dd674ffbef497333ea" integrity sha512-OcKJU/lt232vl1P9EEDamhoO9iKY3tIjY5GU+XDLblAykTdgs6Ux9P1hTHve8nFKy5KPpOXOsVI/hIwi3841+w== @@ -2113,13 +2266,25 @@ date-now@^0.1.4: resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= -debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3: +de-indent@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" + integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= + +debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@~2.6.4, debug@~2.6.6: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" +debug@3.1.0, debug@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + debug@^3.2.5, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -2139,13 +2304,6 @@ decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= -decamelize@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-2.0.0.tgz#656d7bbc8094c4c788ea53c5840908c9c7d063c7" - integrity sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg== - dependencies: - xregexp "4.0.0" - decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -2168,7 +2326,7 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -default-gateway@^4.0.1: +default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" integrity sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA== @@ -2205,17 +2363,18 @@ define-property@^2.0.2: is-descriptor "^1.0.2" isobject "^3.0.1" -del@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" - integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU= +del@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/del/-/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4" + integrity sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ== dependencies: + "@types/glob" "^7.1.1" globby "^6.1.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - p-map "^1.1.1" - pify "^3.0.0" - rimraf "^2.2.8" + is-path-cwd "^2.0.0" + is-path-in-cwd "^2.0.0" + p-map "^2.0.0" + pify "^4.0.1" + rimraf "^2.6.3" delayed-stream@~1.0.0: version "1.0.0" @@ -2232,7 +2391,12 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -depd@~1.1.2: +depd@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" + integrity sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k= + +depd@~1.1.1, depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= @@ -2327,6 +2491,13 @@ dot-prop@^4.1.1: dependencies: is-obj "^1.0.0" +driftless@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/driftless/-/driftless-2.0.3.tgz#4378cade7aa8e39dee33faa94413bdc67850d90b" + integrity sha512-hSDKsQphnL4O0XLAiyWQ8EiM9suXH0Qd4gMtwF86b5wygGV8r95w0JcA38FOmx9N3LjFCIHLG2winLPNken4Tg== + dependencies: + present "^0.0.3" + duplexify@^3.4.2, duplexify@^3.6.0: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" @@ -2373,7 +2544,7 @@ emojis-list@^2.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= -encodeurl@~1.0.2: +encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= @@ -2385,6 +2556,48 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: dependencies: once "^1.4.0" +engine.io-client@~3.1.0: + version "3.1.6" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.6.tgz#5bdeb130f8b94a50ac5cbeb72583e7a4a063ddfd" + integrity sha512-hnuHsFluXnsKOndS4Hv6SvUrgdYx1pk2NqfaDMW+GWdgfU3+/V25Cj7I8a0x92idSpa5PIhJRKxPvp9mnoLsfg== + dependencies: + component-emitter "1.2.1" + component-inherit "0.0.3" + debug "~3.1.0" + engine.io-parser "~2.1.1" + has-cors "1.1.0" + indexof "0.0.1" + parseqs "0.0.5" + parseuri "0.0.5" + ws "~3.3.1" + xmlhttprequest-ssl "~1.5.4" + yeast "0.1.2" + +engine.io-parser@~2.1.0, engine.io-parser@~2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6" + integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA== + dependencies: + after "0.8.2" + arraybuffer.slice "~0.0.7" + base64-arraybuffer "0.1.5" + blob "0.0.5" + has-binary2 "~1.0.2" + +engine.io@~3.1.0: + version "3.1.5" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.1.5.tgz#0e7ef9d690eb0b35597f1d4ad02a26ca2dba3845" + integrity sha512-D06ivJkYxyRrcEe0bTpNnBQNgP9d3xog+qZlLbui8EsMr/DouQpf5o9FzJnWYHEYE0YsFHllUv2R1dkgYZXHcA== + dependencies: + accepts "~1.3.4" + base64id "1.0.0" + cookie "0.3.1" + debug "~3.1.0" + engine.io-parser "~2.1.0" + ws "~3.3.1" + optionalDependencies: + uws "~9.14.0" + enhanced-resolve@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" @@ -2542,7 +2755,43 @@ expose-loader@^0.7.5: resolved "https://registry.yarnpkg.com/expose-loader/-/expose-loader-0.7.5.tgz#e29ea2d9aeeed3254a3faa1b35f502db9f9c3f6f" integrity sha512-iPowgKUZkTPX5PznYsmifVj9Bob0w2wTHVkt/eYNPSzyebkUgIedmskf/kcfEIWpiWjg3JRjnW+a17XypySMuw== -express@^4.16.2: +express@4.16.2: + version "4.16.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.16.2.tgz#e35c6dfe2d64b7dca0a5cd4f21781be3299e076c" + integrity sha1-41xt/i1kt9ygpc1PIXgb4ymeB2w= + dependencies: + accepts "~1.3.4" + array-flatten "1.1.1" + body-parser "1.18.2" + content-disposition "0.5.2" + content-type "~1.0.4" + cookie "0.3.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~1.1.1" + encodeurl "~1.0.1" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.1.0" + fresh "0.5.2" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.2" + path-to-regexp "0.1.7" + proxy-addr "~2.0.2" + qs "6.5.1" + range-parser "~1.2.0" + safe-buffer "5.1.1" + send "0.16.1" + serve-static "1.13.1" + setprototypeof "1.1.0" + statuses "~1.3.1" + type-is "~1.6.15" + utils-merge "1.0.1" + vary "~1.1.2" + +express@^4.16.4: version "4.16.4" resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" integrity sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg== @@ -2674,6 +2923,19 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" +finalhandler@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.0.tgz#ce0b6855b45853e791b2fcc680046d88253dd7f5" + integrity sha1-zgtoVbRYU+eRsvzGgARtiCU91/U= + dependencies: + debug "2.6.9" + encodeurl "~1.0.1" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.2" + statuses "~1.3.1" + unpipe "~1.0.0" + finalhandler@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" @@ -2825,12 +3087,12 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + version "1.2.8" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.8.tgz#57ea5320f762cd4696e5e8e87120eccc8b11cacf" + integrity sha512-tPvHgPGB7m40CZ68xqFGkKuzN+RnpGmSV+hgeKxhRpbxdqKXUFJGC3yonBOLzQBcJyGpdZFDfCsdOC2KFsXzeA== dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" + nan "^2.12.1" + node-pre-gyp "^0.12.0" fstream@^1.0.0, fstream@^1.0.2: version "1.0.12" @@ -3022,6 +3284,18 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" +has-binary2@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d" + integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw== + dependencies: + isarray "2.0.1" + +has-cors@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" + integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk= + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -3083,6 +3357,11 @@ hash-base@^3.0.0: inherits "^2.0.1" safe-buffer "^5.0.1" +hash-sum@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04" + integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ= + hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" @@ -3091,6 +3370,11 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.1" +he@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + hex-color-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" @@ -3101,9 +3385,9 @@ highcharts-regression@streamlinesocial/highcharts-regression: resolved "https://codeload.github.com/streamlinesocial/highcharts-regression/tar.gz/b84143a4e78a8e128374fe485c711b09a1062750" highcharts@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-7.0.3.tgz#0c8edb578bae28774b9f0d49cf1ae4887b126305" - integrity sha512-ubfHLDqKZkGLfDGWYPaa9txLwiJDSWphMZ15xdC0RKKKxZ2ZBc13+MjDfz5ARpYGQvCHmytGOGFUgRWEvkOhNA== + version "7.1.1" + resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-7.1.1.tgz#8c4433e39d5e7dbdc064685d9548181a35e12c19" + integrity sha512-BQtWDQmH4AweQNFLGJCHBQwv9tj9kyp35bp2FFpmNBm7LOecCQdLjvZNgUKvCsKzBzJJIywcwWu4QEcAkPGCjg== hmac-drbg@^1.0.0: version "1.0.1" @@ -3151,7 +3435,7 @@ html-comment-regex@^1.1.0: resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== -html-entities@^1.2.0: +html-entities@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= @@ -3161,6 +3445,16 @@ http-deceiver@^1.2.7: resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= +http-errors@1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" + integrity sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY= + dependencies: + depd "1.1.1" + inherits "2.0.3" + setprototypeof "1.0.3" + statuses ">= 1.3.1 < 2" + http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" @@ -3209,6 +3503,11 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= +iconv-lite@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" + integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ== + iconv-lite@0.4.23: version "0.4.23" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" @@ -3236,9 +3535,9 @@ icss-utils@^4.1.0: postcss "^7.0.14" ieee754@^1.1.4: - version "1.1.12" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" - integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA== + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== iferr@^0.1.5: version "0.1.5" @@ -3333,11 +3632,11 @@ ini@^1.3.4, ini@~1.3.0: integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== internal-ip@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.2.0.tgz#46e81b638d84c338e5c67e42b1a17db67d0814fa" - integrity sha512-ZY8Rk+hlvFeuMmG5uH1MXhhdeMntmIaxaInvAmzMq/SHV8rv4Kh+6GiQNNDQd0wZFrcO+FiTBo8lui/osKOyJw== + version "4.3.0" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" + integrity sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg== dependencies: - default-gateway "^4.0.1" + default-gateway "^4.2.0" ipaddr.js "^1.9.0" interpret@^1.1.0: @@ -3372,12 +3671,7 @@ ip@^1.1.0, ip@^1.1.5: resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= -ipaddr.js@1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e" - integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4= - -ipaddr.js@^1.9.0: +ipaddr.js@1.9.0, ipaddr.js@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65" integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA== @@ -3526,9 +3820,9 @@ is-glob@^3.1.0: is-extglob "^2.1.0" is-glob@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" - integrity sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A= + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== dependencies: is-extglob "^2.1.1" @@ -3544,24 +3838,24 @@ is-obj@^1.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= -is-path-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" - integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0= +is-path-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.1.0.tgz#2e0c7e463ff5b7a0eb60852d851a6809347a124c" + integrity sha512-Sc5j3/YnM8tDeyCsVeKlm/0p95075DyLmDEIkSgQ7mXkrOX+uTCtmQFm0CYzVyJwcCCmO3k8qfJt17SxQwB5Zw== -is-path-in-cwd@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" - integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ== +is-path-in-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz#bfe2dca26c69f397265a4009963602935a053acb" + integrity sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ== dependencies: - is-path-inside "^1.0.0" + is-path-inside "^2.1.0" -is-path-inside@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" - integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= +is-path-inside@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" + integrity sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg== dependencies: - path-is-inside "^1.0.1" + path-is-inside "^1.0.2" is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" @@ -3626,6 +3920,11 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= +isarray@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" + integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4= + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -3648,10 +3947,10 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= -jquery@>=1.7, jquery@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" - integrity sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg== +jquery@>=1.11, jquery@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.0.tgz#8de513fa0fa4b2c7d2e48a530e26f0596936efdf" + integrity sha512-ggRCXln9zEqv6OqAGXFEcshF5dSBvCkzj6Gm2gzuR5fWawaX8t7cxKVkkygKODrDAzKdoYw3l/e3pm3vlT4IbQ== js-base64@^2.1.8: version "2.5.1" @@ -3745,7 +4044,7 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" -killable@^1.0.0: +killable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" integrity sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== @@ -3894,7 +4193,7 @@ lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.5, lodash@~4.17.10 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== -loglevel@^1.4.1: +loglevel@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" integrity sha1-4PyVEztu8nbNyIh82vJKpvFW+Po= @@ -3914,7 +4213,7 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" -lru-cache@^4.0.1: +lru-cache@^4.0.1, lru-cache@^4.1.2: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== @@ -3986,9 +4285,9 @@ media-typer@0.3.0: integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= mem@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.2.0.tgz#5ee057680ed9cb8dad8a78d820f9a8897a102025" - integrity sha512-5fJxa68urlY0Ir8ijatKa3eRz5lwXnRCTvo9+TbTGAuTFJOwpGcY0X05moBd0nW45965Njt4CDI2GFQoG8DvqA== + version "4.3.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" + integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== dependencies: map-age-cleaner "^0.1.1" mimic-fn "^2.0.0" @@ -4023,6 +4322,13 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= +merge-source-map@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" + integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw== + dependencies: + source-map "^0.6.1" + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -4055,32 +4361,44 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -"mime-db@>= 1.38.0 < 2", mime-db@~1.38.0: +mime-db@1.40.0, "mime-db@>= 1.40.0 < 2": + version "1.40.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" + integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== + +mime-db@~1.38.0: version "1.38.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.38.0.tgz#1a2aab16da9eb167b49c6e4df2d9c68d63d8e2ad" integrity sha512-bqVioMFFzc2awcdJZIzR3HjZFX20QhilVS7hytkKrv7xFAn8bM1gzc/FOX2awLISvWe0PV8ptFKcon+wZ5qYkg== -mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.19: +mime-types@^2.1.12, mime-types@~2.1.19: version "2.1.22" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.22.tgz#fe6b355a190926ab7698c9a0556a11199b2199bd" integrity sha512-aGl6TZGnhm/li6F7yx82bJiBZwgiEa4Hf6CNr8YO+r5UHr53tSTYZb102zyU50DOWWKeOv0uQLRL0/9EiKWCog== dependencies: mime-db "~1.38.0" +mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.24: + version "2.1.24" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" + integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== + dependencies: + mime-db "1.40.0" + mime@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== mime@^2.3.1: - version "2.4.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.0.tgz#e051fd881358585f3279df333fe694da0bcffdd6" - integrity sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w== + version "2.4.2" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.2.tgz#ce5229a5e99ffc313abac806b482c10e7ba6ac78" + integrity sha512-zJBfZDkwRu+j3Pdd2aHsR5GfH2jIWhmL1ZzBoc+X+3JEti2hbArWcyJ+1laC1D2/U/W1a/+Cegj0/OnEU2ybjg== mimic-fn@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.0.0.tgz#0913ff0b121db44ef5848242c38bbb35d44cabde" - integrity sha512-jbex9Yd/3lmICXwYT6gA/j2mNQGU48wCh/VzRd+/Y/PjYQtlg1gLMdZqvu9s/xH7qKvngxRObl56XZR609IMbA== + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== mini-css-extract-plugin@^0.5.0: version "0.5.0" @@ -4222,11 +4540,16 @@ multicast-dns@^6.0.1: dns-packet "^1.3.1" thunky "^1.0.2" -nan@^2.10.0, nan@^2.9.2: +nan@^2.10.0: version "2.13.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.1.tgz#a15bee3790bde247e8f38f1d446edcdaeb05f2dd" integrity sha512-I6YB/YEuDeUZMmhscXKxGgZlFnhsn5y0hgOZBadkzfTRrZBtJDZeg6eQf7PYMIEclwmorTKK8GztsyOUSVBREA== +nan@^2.12.1: + version "2.13.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7" + integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -4245,11 +4568,11 @@ nanomatch@^1.2.9: to-regex "^3.0.1" needle@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" - integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== + version "2.3.1" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.1.tgz#d272f2f4034afb9c4c9ab1379aabc17fc85c9388" + integrity sha512-CaLXV3W8Vnbps8ZANqDGz7j4x7Yj1LW4TWF/TQuDfj7Cfx4nAPTvw98qgTevtto1oHDrh3pQkaODbqupXlsWTg== dependencies: - debug "^2.1.2" + debug "^4.1.0" iconv-lite "^0.4.4" sax "^1.2.4" @@ -4320,10 +4643,10 @@ node-libs-browser@^2.0.0: util "^0.11.0" vm-browserify "0.0.4" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== +node-pre-gyp@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149" + integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A== dependencies: detect-libc "^1.0.2" mkdirp "^0.5.1" @@ -4472,6 +4795,11 @@ object-assign@^4.0.1, object-assign@^4.1.0: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= +object-component@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291" + integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE= + object-copy@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" @@ -4552,7 +4880,7 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -opn@^5.1.0: +opn@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" integrity sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== @@ -4632,9 +4960,9 @@ p-finally@^1.0.0: integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= p-is-promise@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.0.0.tgz#7554e3d572109a87e1f3f53f6a7d85d1b194f4c5" - integrity sha512-pzQPhYMCAgLAKPWD2jC3Se9fEfrD9npNos0y150EeqZll7akhEgGhTW/slB6lHku8AvYGiJ+YJ5hfHKePPgFWg== + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" + integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== p-limit@^2.0.0: version "2.2.0" @@ -4650,15 +4978,15 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" -p-map@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" - integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== p-try@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.1.0.tgz#c1a0f1030e97de018bb2c718929d2af59463e505" - integrity sha512-H2RyIJ7+A3rjkwKC2l5GGtU4H1vkxKCAGsWasNVd0Set+6i4znxbWy6/j16YDPJDWxhsgZiKAstMEP8wCdSpjA== + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== pako@~1.0.5: version "1.0.10" @@ -4706,10 +5034,24 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= +parseqs@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" + integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0= + dependencies: + better-assert "~1.0.0" + +parseuri@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a" + integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo= + dependencies: + better-assert "~1.0.0" + parseurl@~1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" - integrity sha1-/CidTtiZMRlGDBViUyYs3I3mW/M= + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== pascalcase@^0.1.1: version "0.1.1" @@ -4748,7 +5090,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.1: +path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -4835,11 +5177,11 @@ pnp-webpack-plugin@^1.3.1: ts-pnp "^1.0.0" popper.js@^1.14.3: - version "1.14.7" - resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e" - integrity sha512-4q1hNvoUre/8srWsH7hnoSJ5xVmIL4qgz+s4qf2TnJIMyZFUFMGH+9vE7mXynAlHSZ/NdTmmow86muD0myUkVQ== + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== -portfinder@^1.0.9: +portfinder@^1.0.20: version "1.0.20" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.20.tgz#bea68632e54b2e13ab7b0c4775e9b41bf270e44a" integrity sha512-Yxe4mTyDzTd59PZJY4ojZR8F+E5e97iq2ZOHPz3HDgSvYC5siNad2tLooQ5y5QHyQhc3xVqvyk/eNA3wuoa7Sw== @@ -5492,6 +5834,16 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.5, source-map "^0.6.1" supports-color "^6.1.0" +present@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/present/-/present-0.0.3.tgz#5aefb8a5ddf6b34c65743bf1cde53523aac1c05a" + integrity sha1-Wu+4pd32s0xldDvxzeU1I6rBwFo= + +prettier@1.16.3: + version "1.16.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.3.tgz#8c62168453badef702f34b45b6ee899574a6a65d" + integrity sha512-kn/GU6SMRYPxUakNXhpP0EedT/KmaPzr0H5lIsDogrykbaxOpOfAFfk5XA7DZrJyMAv1wlMV3CPcZruGXVVUZw== + private@^0.1.6: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -5512,13 +5864,20 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -proxy-addr@~2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.4.tgz#ecfc733bf22ff8c6f407fa275327b9ab67e48b93" - integrity sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA== +promise@8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.1.tgz#e45d68b00a17647b6da711bf85ed6ed47208f450" + integrity sha1-5F1osAoXZHttpxG/he1u1HII9FA= + dependencies: + asap "~2.0.3" + +proxy-addr@~2.0.2, proxy-addr@~2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" + integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ== dependencies: forwarded "~0.1.2" - ipaddr.js "1.8.0" + ipaddr.js "1.9.0" prr@~1.0.1: version "1.0.1" @@ -5592,6 +5951,11 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qs@6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" + integrity sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A== + qs@6.5.2, qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -5607,10 +5971,10 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -querystringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.0.tgz#7ded8dfbf7879dcc60d0a644ac6754b283ad17ef" - integrity sha512-sluvZZ1YiTLD5jsqZcDmFyV2EwToyXZBfpoVOmktMmW+VEnhgakFHnasVph65fOjGPTWN0Nw3+XQaSeMayr0kg== +querystringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" + integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" @@ -5632,6 +5996,16 @@ range-parser@^1.0.3, range-parser@~1.2.0: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= +raw-body@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" + integrity sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k= + dependencies: + bytes "3.0.0" + http-errors "1.6.2" + iconv-lite "0.4.19" + unpipe "1.0.0" + raw-body@2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" @@ -5690,9 +6064,9 @@ read-pkg@^1.0.0: util-deprecate "~1.0.1" readable-stream@^3.0.6: - version "3.2.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.2.0.tgz#de17f229864c120a9f56945756e4f32c4045245d" - integrity sha512-RV20kLjdmpZuTF1INEb9IA3L68Nmi+Ri7ppZqo78wj//Pn62fCoJyV9zalccNzDD/OuJpMG4f+pfMl8+L6QdGw== + version "3.3.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.3.0.tgz#cb8011aad002eb717bf040291feba8569c986fb9" + integrity sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw== dependencies: inherits "^2.0.3" string_decoder "^1.1.1" @@ -5891,7 +6265,7 @@ rgba-regex@^1.0.0: resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2: +rimraf@2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== @@ -5913,6 +6287,11 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" +safe-buffer@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" + integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg== + safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -5984,23 +6363,52 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= -selfsigned@^1.9.1: +selfsigned@^1.10.4: version "1.10.4" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.4.tgz#cdd7eccfca4ed7635d47a08bf2d5d3074092e2cd" integrity sha512-9AukTiDmHXGXWtWjembZ5NDmVvP2695EtpgbCsxCa68w3c88B+alqbmZ4O3hZ4VWGXeGWzEVdvqgAJD8DQPCDw== dependencies: node-forge "0.7.5" -"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.1: version "5.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== +semver@^5.3.0, semver@^5.5.0, semver@^5.6.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" + integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== + +semver@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.0.0.tgz#05e359ee571e5ad7ed641a6eec1e547ba52dea65" + integrity sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ== + semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= +send@0.16.1: + version "0.16.1" + resolved "https://registry.yarnpkg.com/send/-/send-0.16.1.tgz#a70e1ca21d1382c11d0d9f6231deb281080d7ab3" + integrity sha512-ElCLJdJIKPk6ux/Hocwhk7NFHpI3pVm/IZOYWqUmoxcgeyM+MpxHHKhb8QmlJDX1pU6WrgaHBkVNm73Sv7uc2A== + dependencies: + debug "2.6.9" + depd "~1.1.1" + destroy "~1.0.4" + encodeurl "~1.0.1" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "~1.6.2" + mime "1.4.1" + ms "2.0.0" + on-finished "~2.3.0" + range-parser "~1.2.0" + statuses "~1.3.1" + send@0.16.2: version "0.16.2" resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1" @@ -6021,11 +6429,11 @@ send@0.16.2: statuses "~1.4.0" serialize-javascript@^1.4.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.6.1.tgz#4d1f697ec49429a847ca6f442a2a755126c4d879" - integrity sha512-A5MOagrPFga4YaKQSWHryl7AXvbQkEqpw4NNYMTNYUNV51bA8ABHgYFpqKx+YFFrw59xMV1qGH1R4AgoNIVgCw== + version "1.7.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.7.0.tgz#d6e0dfb2a3832a8c94468e6eb1db97e55a192a65" + integrity sha512-ke8UG8ulpFOxO8f8gRYabHQe/ZntKlcig2Mp+8+URDP1D8vJZ0KUt7LYo07q25Z/+JVSgpr/cui9PIp5H6/+nA== -serve-index@^1.7.2: +serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" integrity sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= @@ -6038,6 +6446,16 @@ serve-index@^1.7.2: mime-types "~2.1.17" parseurl "~1.3.2" +serve-static@1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.1.tgz#4c57d53404a761d8f2e7c1e8a18a47dbf278a719" + integrity sha512-hSMUZrsPa/I09VYFJwa627JJkNs0NrfL1Uzuup+GqHfToR2KcsXFymXSV90hoyw3M+msjFuQly+YzIH/q0MGlQ== + dependencies: + encodeurl "~1.0.1" + escape-html "~1.0.3" + parseurl "~1.3.2" + send "0.16.1" + serve-static@1.13.2: version "1.13.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" @@ -6078,6 +6496,11 @@ setimmediate@^1.0.4: resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= +setprototypeof@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" + integrity sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ= + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" @@ -6154,6 +6577,51 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" +socket.io-adapter@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b" + integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs= + +socket.io-client@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.0.4.tgz#0918a552406dc5e540b380dcd97afc4a64332f8e" + integrity sha1-CRilUkBtxeVAs4Dc2Xr8SmQzL44= + dependencies: + backo2 "1.0.2" + base64-arraybuffer "0.1.5" + component-bind "1.0.0" + component-emitter "1.2.1" + debug "~2.6.4" + engine.io-client "~3.1.0" + has-cors "1.1.0" + indexof "0.0.1" + object-component "0.0.3" + parseqs "0.0.5" + parseuri "0.0.5" + socket.io-parser "~3.1.1" + to-array "0.1.4" + +socket.io-parser@~3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.1.3.tgz#ed2da5ee79f10955036e3da413bfd7f1e4d86c8e" + integrity sha512-g0a2HPqLguqAczs3dMECuA1RgoGFPyvDqcbaDEdCWY9g59kdUAz3YRmaJBNKXflrHNwB7Q12Gkf/0CZXfdHR7g== + dependencies: + component-emitter "1.2.1" + debug "~3.1.0" + has-binary2 "~1.0.2" + isarray "2.0.1" + +socket.io@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.0.4.tgz#c1a4590ceff87ecf13c72652f046f716b29e6014" + integrity sha1-waRZDO/4fs8TxyZS8Eb3FrKeYBQ= + dependencies: + debug "~2.6.6" + engine.io "~3.1.0" + socket.io-adapter "~1.1.0" + socket.io-client "2.0.4" + socket.io-parser "~3.1.1" + sockjs-client@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.3.0.tgz#12fc9d6cb663da5739d3dc5fb6e8687da95cb177" @@ -6191,9 +6659,9 @@ source-map-resolve@^0.5.0: urix "^0.1.0" source-map-support@~0.5.10: - version "0.5.11" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.11.tgz#efac2ce0800355d026326a0ca23e162aeac9a4e2" - integrity sha512-//sajEx/fGL3iw6fltKMdPvy8kL3kJ2O3iuYlRoT3k9Kb4BjOoZ+BZzaNHeuaruSt+Kf3Zk9tnfAQg9/AJqUVQ== + version "0.5.12" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.12.tgz#b4f3b10d51857a5af0138d3ce8003b201613d599" + integrity sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ== dependencies: buffer-from "^1.0.0" source-map "^0.6.0" @@ -6220,6 +6688,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +spark-md5@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.0.tgz#3722227c54e2faf24b1dc6d933cc144e6f71bfef" + integrity sha1-NyIifFTi+vJLHcbZM8wUTm9xv+8= + spdx-correct@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" @@ -6321,11 +6794,16 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.4.0 < 2": +"statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2": version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +statuses@~1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" + integrity sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4= + statuses@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" @@ -6496,9 +6974,9 @@ svgo@^1.0.0: util.promisify "~1.0.0" tapable@^1.0.0, tapable@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.1.tgz#4d297923c5a72a42360de2ab52dadfaaec00018e" - integrity sha512-9I2ydhj8Z9veORCw5PRm4u9uebCn0mcCa6scWoNcbZ6dAtoo2618u9UUzxgmsCOreJpqDDuv61LvwofW7hLcBA== + version "1.1.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" + integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== tar@^2.0.0: version "2.2.1" @@ -6565,6 +7043,17 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +timesync@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/timesync/-/timesync-1.0.3.tgz#c1457fbffb0ba7420da897d4edf398e8851ef6a1" + integrity sha512-1arCkYIxTiHuT/aRidvciGMcBAEKB26sOeVadpIkrythnKAxK4sw1FedA+387oEiluiLCmv0GG3Vsaj53hjbjQ== + dependencies: + body-parser "1.18.2" + debug "3.1.0" + express "4.16.2" + promise "8.0.1" + socket.io "2.0.4" + timsort@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" @@ -6575,13 +7064,18 @@ tiny-emitter@^2.0.0: resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tippy.js@^2.5.2: +tippy.js@^2.5.2, tippy.js@^2.6.*: version "2.6.0" resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-2.6.0.tgz#5493cd478322ce75c08306e67a0107aa609a39c9" integrity sha512-hBcy6UXK3epiFwpkycy7Pn1SSLofUmawpPnlYg5ginbXMc/3EX2ivjzHfjvr/WPEpUg71/7ssiovhxDtCWvL2A== dependencies: popper.js "^1.14.3" +to-array@0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" + integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA= + to-arraybuffer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" @@ -6674,7 +7168,7 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= -type-is@~1.6.16: +type-is@~1.6.15: version "1.6.16" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194" integrity sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q== @@ -6682,12 +7176,13 @@ type-is@~1.6.16: media-typer "0.3.0" mime-types "~2.1.18" -typeahead.js@^0.11.1: - version "0.11.1" - resolved "https://registry.yarnpkg.com/typeahead.js/-/typeahead.js-0.11.1.tgz#4e64e671b22310a8606f4aec805924ba84b015b8" - integrity sha1-TmTmcbIjEKhgb0rsgFkkuoSwFbg= +type-is@~1.6.16: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== dependencies: - jquery ">=1.7" + media-typer "0.3.0" + mime-types "~2.1.24" typedarray@^0.0.6: version "0.0.6" @@ -6695,13 +7190,18 @@ typedarray@^0.0.6: integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= uglify-js@^3.1.4: - version "3.4.10" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f" - integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw== + version "3.5.9" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.5.9.tgz#372fbf95939555b1f460b1777d33a67d4a994ac9" + integrity sha512-WpT0RqsDtAWPNJK955DEnb6xjymR8Fn0OlK4TT4pS0ASYsVPqr5ELhgwOwLCP5J5vHeJ4xmMmz3DEgdqC10JeQ== dependencies: - commander "~2.19.0" + commander "~2.20.0" source-map "~0.6.1" +ultron@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" + integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og== + underscore@^1.8.3: version "1.9.1" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961" @@ -6782,7 +7282,7 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -upath@^1.1.0: +upath@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== @@ -6800,11 +7300,11 @@ urix@^0.1.0: integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= url-parse@^1.4.3: - version "1.4.4" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.4.tgz#cac1556e95faa0303691fec5cf9d5a1bc34648f8" - integrity sha512-/92DTTorg4JjktLNLe6GPS2/RvAd/RGr6LuktmWSMLEOa6rjnlrFXNgSbSmkNvCoL2T028A0a1JaJLzRMlFoHg== + version "1.4.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" + integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== dependencies: - querystringify "^2.0.0" + querystringify "^2.1.1" requires-port "^1.0.0" url@^0.11.0: @@ -6857,6 +7357,11 @@ uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== +uws@~9.14.0: + version "9.14.0" + resolved "https://registry.yarnpkg.com/uws/-/uws-9.14.0.tgz#fac8386befc33a7a3705cbd58dc47b430ca4dd95" + integrity sha512-HNMztPP5A1sKuVFmdZ6BPVpBQd5bUjNC8EFMFiICK+oho/OQsAJy5hnIx4btMHiOk8j04f/DbIlqnEZ9d72dqg== + v8-compile-cache@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.2.tgz#a428b28bb26790734c4fc8bc9fa106fccebf6a6c" @@ -6896,6 +7401,68 @@ vm-browserify@0.0.4: dependencies: indexof "0.0.1" +vue-hot-reload-api@^2.3.0: + version "2.3.3" + resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz#2756f46cb3258054c5f4723de8ae7e87302a1ccf" + integrity sha512-KmvZVtmM26BQOMK1rwUZsrqxEGeKiYSZGA7SNWE6uExx8UX/cj9hq2MRV/wWC3Cq6AoeDGk57rL9YMFRel/q+g== + +vue-loader@^15.7.0: + version "15.7.0" + resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.7.0.tgz#27275aa5a3ef4958c5379c006dd1436ad04b25b3" + integrity sha512-x+NZ4RIthQOxcFclEcs8sXGEWqnZHodL2J9Vq+hUz+TDZzBaDIh1j3d9M2IUlTjtrHTZy4uMuRdTi8BGws7jLA== + dependencies: + "@vue/component-compiler-utils" "^2.5.1" + hash-sum "^1.0.2" + loader-utils "^1.1.0" + vue-hot-reload-api "^2.3.0" + vue-style-loader "^4.1.0" + +vue-multiselect@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/vue-multiselect/-/vue-multiselect-2.1.6.tgz#5be5d811a224804a15c43a4edbb7485028a89c7f" + integrity sha512-s7jmZPlm9FeueJg1RwJtnE9KNPtME/7C8uRWSfp9/yEN4M8XcS/d+bddoyVwVnvFyRh9msFo0HWeW0vTL8Qv+w== + +vue-style-loader@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-4.1.2.tgz#dedf349806f25ceb4e64f3ad7c0a44fba735fcf8" + integrity sha512-0ip8ge6Gzz/Bk0iHovU9XAUQaFt/G2B61bnWa2tCcqqdgfHs1lF9xXorFbE55Gmy92okFT+8bfmySuUOu13vxQ== + dependencies: + hash-sum "^1.0.2" + loader-utils "^1.0.2" + +vue-template-compiler@^2.6.10: + version "2.6.10" + resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.10.tgz#323b4f3495f04faa3503337a82f5d6507799c9cc" + integrity sha512-jVZkw4/I/HT5ZMvRnhv78okGusqe0+qH2A0Em0Cp8aq78+NK9TII263CDVz2QXZsIT+yyV/gZc/j/vlwa+Epyg== + dependencies: + de-indent "^1.0.2" + he "^1.1.0" + +vue-template-es2015-compiler@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" + integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== + +vue-tippy@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/vue-tippy/-/vue-tippy-2.1.2.tgz#570a267b4bd7f13356e4fb46b509a540aac68930" + integrity sha512-2wDMqPtJG+fLryerYyeb2PMDfWXgdDIdcIrHjtSiSLAsNWdfMguUz+PJJoaHAWU7kEraxbCkcBl/dVoq0Q62cg== + dependencies: + popper.js "^1.14.3" + tippy.js "^2.6.*" + +vue-turbolinks@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/vue-turbolinks/-/vue-turbolinks-2.0.4.tgz#a64102b9641b2fc523570b84964620ea7b51aabb" + integrity sha512-UnozEGA+HCrETxnBGM3xrsevOOVEsLf1aHKapIANKAf5GdyA2nMXr3yfwlZ5o81PypSx73tfp9XW1nugMeydRA== + dependencies: + vue "^2.2.4" + +vue@^2.2.4, vue@^2.6.10: + version "2.6.10" + resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" + integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== + watchpack@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" @@ -6926,9 +7493,9 @@ webpack-assets-manifest@^3.1.1: webpack-sources "^1.0.0" webpack-cli@^3.2.3: - version "3.3.0" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.0.tgz#55c8a74cae1e88117f9dda3a801c7272e93ca318" - integrity sha512-t1M7G4z5FhHKJ92WRKwZ1rtvi7rHc0NZoZRbSkol0YKl4HvcC8+DsmGDmK7MmZxHSAetHagiOsjOB6MmzC2TUw== + version "3.3.1" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.1.tgz#98b0499c7138ba9ece8898bd99c4f007db59909d" + integrity sha512-c2inFU7SM0IttEgF7fK6AaUsbBnORRzminvbyRKS+NlbQHVZdCtzKBlavRL5359bFsywXGRAItA5di/IruC8mg== dependencies: chalk "^2.4.1" cross-spawn "^6.0.5" @@ -6942,51 +7509,51 @@ webpack-cli@^3.2.3: v8-compile-cache "^2.0.2" yargs "^12.0.5" -webpack-dev-middleware@^3.5.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.6.1.tgz#91f2531218a633a99189f7de36045a331a4b9cd4" - integrity sha512-XQmemun8QJexMEvNFbD2BIg4eSKrmSIMrTfnl2nql2Sc6OGAYFyb8rwuYrCjl/IiEYYuyTEiimMscu7EXji/Dw== +webpack-dev-middleware@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.6.2.tgz#f37a27ad7c09cd7dc67cd97655413abaa1f55942" + integrity sha512-A47I5SX60IkHrMmZUlB0ZKSWi29TZTcPz7cha1Z75yYOsgWh/1AcPmQEbC8ZIbU3A1ytSv1PMU0PyPz2Lmz2jg== dependencies: memory-fs "^0.4.1" mime "^2.3.1" range-parser "^1.0.3" webpack-log "^2.0.0" -webpack-dev-server@^3.1.14: - version "3.2.1" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.2.1.tgz#1b45ce3ecfc55b6ebe5e36dab2777c02bc508c4e" - integrity sha512-sjuE4mnmx6JOh9kvSbPYw3u/6uxCLHNWfhWaIPwcXWsvWOPN+nc5baq4i9jui3oOBRXGonK9+OI0jVkaz6/rCw== +webpack-dev-server@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.3.1.tgz#7046e49ded5c1255a82c5d942bcdda552b72a62d" + integrity sha512-jY09LikOyGZrxVTXK0mgIq9y2IhCoJ05848dKZqX1gAGLU1YDqgpOT71+W53JH/wI4v6ky4hm+KvSyW14JEs5A== dependencies: ansi-html "0.0.7" bonjour "^3.5.0" - chokidar "^2.0.0" - compression "^1.5.2" - connect-history-api-fallback "^1.3.0" + chokidar "^2.1.5" + compression "^1.7.4" + connect-history-api-fallback "^1.6.0" debug "^4.1.1" - del "^3.0.0" - express "^4.16.2" - html-entities "^1.2.0" + del "^4.1.0" + express "^4.16.4" + html-entities "^1.2.1" http-proxy-middleware "^0.19.1" import-local "^2.0.0" internal-ip "^4.2.0" ip "^1.1.5" - killable "^1.0.0" - loglevel "^1.4.1" - opn "^5.1.0" - portfinder "^1.0.9" + killable "^1.0.1" + loglevel "^1.6.1" + opn "^5.5.0" + portfinder "^1.0.20" schema-utils "^1.0.0" - selfsigned "^1.9.1" - semver "^5.6.0" - serve-index "^1.7.2" + selfsigned "^1.10.4" + semver "^6.0.0" + serve-index "^1.9.1" sockjs "0.3.19" sockjs-client "1.3.0" spdy "^4.0.0" - strip-ansi "^3.0.0" + strip-ansi "^3.0.1" supports-color "^6.1.0" url "^0.11.0" - webpack-dev-middleware "^3.5.1" + webpack-dev-middleware "^3.6.2" webpack-log "^2.0.0" - yargs "12.0.2" + yargs "12.0.5" webpack-log@^2.0.0: version "2.0.0" @@ -7011,7 +7578,37 @@ webpack-sources@^1.0.0, webpack-sources@^1.0.1, webpack-sources@^1.1.0, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.29.3, webpack@^4.29.6: +webpack@^4.29.3: + version "4.30.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.30.0.tgz#aca76ef75630a22c49fcc235b39b4c57591d33a9" + integrity sha512-4hgvO2YbAFUhyTdlR4FNyt2+YaYBYHavyzjCMbZzgglo02rlKi/pcsEzwCuCpsn1ryzIl1cq/u8ArIKu8JBYMg== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-module-context" "1.8.5" + "@webassemblyjs/wasm-edit" "1.8.5" + "@webassemblyjs/wasm-parser" "1.8.5" + acorn "^6.0.5" + acorn-dynamic-import "^4.0.0" + ajv "^6.1.0" + ajv-keywords "^3.1.0" + chrome-trace-event "^1.0.0" + enhanced-resolve "^4.1.0" + eslint-scope "^4.0.0" + json-parse-better-errors "^1.0.2" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + micromatch "^3.1.8" + mkdirp "~0.5.0" + neo-async "^2.5.0" + node-libs-browser "^2.0.0" + schema-utils "^1.0.0" + tapable "^1.1.0" + terser-webpack-plugin "^1.1.0" + watchpack "^1.5.0" + webpack-sources "^1.3.0" + +webpack@^4.29.6: version "4.29.6" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.29.6.tgz#66bf0ec8beee4d469f8b598d3988ff9d8d90e955" integrity sha512-MwBwpiE1BQpMDkbnUUaW6K8RFZjljJHArC6tWQJoFm0oQtfoSebtg4Y7/QHnJ/SddtjYLHaKGX64CFjG5rehJw== @@ -7103,10 +7700,19 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -xregexp@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020" - integrity sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg== +ws@~3.3.1: + version "3.3.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2" + integrity sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA== + dependencies: + async-limiter "~1.0.0" + safe-buffer "~5.1.0" + ultron "~1.1.0" + +xmlhttprequest-ssl@~1.5.4: + version "1.5.5" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" + integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4= xtend@^4.0.0, xtend@~4.0.1: version "4.0.1" @@ -7133,13 +7739,6 @@ yallist@^3.0.0, yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== -yargs-parser@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" - integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== - dependencies: - camelcase "^4.1.0" - yargs-parser@^11.1.1: version "11.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" @@ -7155,25 +7754,7 @@ yargs-parser@^5.0.0: dependencies: camelcase "^3.0.0" -yargs@12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.2.tgz#fe58234369392af33ecbef53819171eff0f5aadc" - integrity sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ== - dependencies: - cliui "^4.0.0" - decamelize "^2.0.0" - find-up "^3.0.0" - get-caller-file "^1.0.1" - os-locale "^3.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1 || ^4.0.0" - yargs-parser "^10.1.0" - -yargs@^12.0.5: +yargs@12.0.5, yargs@^12.0.5: version "12.0.5" resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== @@ -7209,3 +7790,8 @@ yargs@^7.0.0: which-module "^1.0.0" y18n "^3.2.1" yargs-parser "^5.0.0" + +yeast@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" + integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=