diff --git a/Gemfile b/Gemfile index 6b5df9fd..01bb5f3a 100644 --- a/Gemfile +++ b/Gemfile @@ -4,10 +4,11 @@ source :rubygems gemspec gem 'travis-support', github: 'travis-ci/travis-support' -gem 'travis-core', github: 'travis-ci/travis-core' +gem 'travis-core', github: 'travis-ci/travis-core', branch: 'sf-more-services' gem 'hubble', github: 'roidrage/hubble' gem 'yard-sinatra', github: 'rkh/yard-sinatra' gem 'gh', github: 'rkh/gh' +gem 'bunny' group :test do gem 'rspec', '~> 2.11' diff --git a/Gemfile.lock b/Gemfile.lock index 2fb74178..c7f16036 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ GIT GIT remote: git://github.com/rkh/gh.git - revision: 5aa120dd493f1430fc1af9d97363daea5c4c3415 + revision: affde20a4fecb1023f2e7031734b9386a76d22c2 specs: gh (0.8.0) addressable @@ -25,14 +25,16 @@ GIT GIT remote: git://github.com/roidrage/hubble.git - revision: 5220415d5542a2868d54f7be9f35fc1d66126b8e + revision: 8972b940a4f927927d2a4bdb250b3c98c04692a6 specs: hubble (0.1.2) + faraday json (~> 1.6.5) GIT remote: git://github.com/travis-ci/travis-core.git - revision: 73679d7263ded28620dac7815e4aed253a8191d3 + revision: ea7a1678a0388e586ac4778a9b6ee56a11dfb0aa + branch: sf-more-services specs: travis-core (0.0.1) actionmailer (~> 3.2.3) @@ -53,7 +55,7 @@ GIT GIT remote: git://github.com/travis-ci/travis-support.git - revision: b150763d253331de9adadcb5b39f7df5efccb676 + revision: 06844d2db558d88be775ca1cf9cfff8ec36120fb specs: travis-support (0.0.1) @@ -65,6 +67,7 @@ PATH hubble (~> 0.1) newrelic_rpm (~> 3.3.0) pg (~> 0.13.2) + rack-contrib (~> 1.1) rack-ssl (~> 1.3) redcarpet (~> 2.1) sinatra (~> 1.3) @@ -76,60 +79,57 @@ PATH GEM remote: http://rubygems.org/ specs: - actionmailer (3.2.6) - actionpack (= 3.2.6) + actionmailer (3.2.8) + actionpack (= 3.2.8) mail (~> 2.4.4) - actionpack (3.2.6) - activemodel (= 3.2.6) - activesupport (= 3.2.6) + actionpack (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) builder (~> 3.0.0) erubis (~> 2.7.0) - journey (~> 1.0.1) + journey (~> 1.0.4) rack (~> 1.4.0) rack-cache (~> 1.2) rack-test (~> 0.6.1) sprockets (~> 2.1.3) - activemodel (3.2.6) - activesupport (= 3.2.6) + activemodel (3.2.8) + activesupport (= 3.2.8) builder (~> 3.0.0) - activerecord (3.2.6) - activemodel (= 3.2.6) - activesupport (= 3.2.6) + activerecord (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) arel (~> 3.0.2) tzinfo (~> 0.3.29) - activesupport (3.2.6) + activesupport (3.2.8) i18n (~> 0.6) multi_json (~> 1.0) addressable (2.3.2) arel (3.0.2) atomic (1.0.1) avl_tree (1.1.3) - backports (2.6.2) - builder (3.0.0) - daemons (1.1.8) + backports (2.6.4) + builder (3.0.3) + bunny (0.8.0) + daemons (1.1.9) data_migrations (0.0.1) activerecord rake diff-lcs (1.1.3) erubis (2.7.0) - eventmachine (0.12.10) + eventmachine (1.0.0) factory_girl (2.4.2) activesupport - faraday (0.8.1) + faraday (0.8.4) multipart-post (~> 1.1) - ffi (1.1.0) - foreman (0.53.0) + foreman (0.59.0) thor (>= 0.13.6) - hashr (0.0.21) + hashr (0.0.22) hike (1.2.1) hitimes (1.1.1) - i18n (0.6.0) + i18n (0.6.1) journey (1.0.4) json (1.6.7) - listen (0.4.7) - rb-fchange (~> 0.0.5) - rb-fsevent (~> 0.9.1) - rb-inotify (~> 0.8.8) + listen (0.5.1) mail (2.4.4) i18n (>= 0.4.0) mime-types (~> 1.16) @@ -140,7 +140,7 @@ GEM avl_tree (~> 1.1.2) hitimes (~> 1.1) mime-types (1.19) - mocha (0.12.3) + mocha (0.12.4) metaclass (~> 0.0.1) multi_json (1.3.6) multipart-post (1.1.5) @@ -162,25 +162,22 @@ GEM rack (1.4.1) rack-cache (1.2) rack (>= 0.4) + rack-contrib (1.1.0) + rack (>= 0.9.1) rack-protection (1.2.0) rack rack-ssl (1.3.2) rack rack-test (0.6.1) rack (>= 1.0) - railties (3.2.6) - actionpack (= 3.2.6) - activesupport (= 3.2.6) + railties (3.2.8) + actionpack (= 3.2.8) + activesupport (= 3.2.8) rack-ssl (~> 1.3.2) rake (>= 0.8.7) rdoc (~> 3.4) thor (>= 0.14.6, < 2.0) rake (0.9.2.2) - rb-fchange (0.0.5) - ffi - rb-fsevent (0.9.1) - rb-inotify (0.8.8) - ffi (>= 0.5.0) rdoc (3.12) json (~> 1.4) redcarpet (2.1.1) @@ -193,14 +190,14 @@ GEM rspec-expectations (~> 2.11.0) rspec-mocks (~> 2.11.0) rspec-core (2.11.1) - rspec-expectations (2.11.2) + rspec-expectations (2.11.3) diff-lcs (~> 1.1.3) - rspec-mocks (2.11.1) - signature (0.1.3) + rspec-mocks (2.11.3) + signature (0.1.4) simple_states (0.1.1) activesupport hashr (~> 0.0.10) - sinatra (1.3.2) + sinatra (1.3.3) rack (~> 1.3, >= 1.3.6) rack-protection (~> 1.2) tilt (~> 1.3, >= 1.3.3) @@ -231,6 +228,7 @@ PLATFORMS ruby DEPENDENCIES + bunny factory_girl (~> 2.4.0) foreman gh! diff --git a/docs/00_overview.md b/docs/00_overview.md new file mode 100644 index 00000000..7d041592 --- /dev/null +++ b/docs/00_overview.md @@ -0,0 +1,7 @@ +# Overview + +... some general docs here ... + +## Media Types + +The API is currently [JSON](http://en.wikipedia.org/wiki/JSON) only. \ No newline at end of file diff --git a/docs/01_cross_origin.md b/docs/01_cross_origin.md new file mode 100644 index 00000000..6d04b543 --- /dev/null +++ b/docs/01_cross_origin.md @@ -0,0 +1,45 @@ +# Web Clients + +When writing an in-browser client, you have to circumvent the browser's +[same origin policy](http://en.wikipedia.org/wiki/Same_origin_policy). +Generally, we offer two different approaches for this: +[Cross-Origin Resource Sharing](http://en.wikipedia.org/wiki/Cross-origin_resource_sharing) (aka CORS) +and [JSONP](http://en.wikipedia.org/wiki/JSONP). If you don't have any good +reason for using JSONP, we recommend you use CORS. + +## Cross-Origin Resource Sharing + +All API resources set appropriate headers to allow Cross-Origin requests. Be +aware that on Internet Explorer you might have to use a different interface to +send these requests. + + // using XMLHttpRequest or XDomainRequest to send an API request + var invocation = window.XDomainRequest ? new XDomainRequest() : new XMLHttpRequest(); + + if(invocation) { + invocation.open("GET", "https://api.travis-ci.org/", true); + invocation.onreadystatechange = function() { alert("it worked!") }; + invocation.send(); + } + +In contrast to JSONP, CORS does not lead to any execution of untrusted code. + +Most JavaScript frameworks, like [jQuery](http://jquery.com), take care of CORS +requests for you under the hood, so you can just do a normal *ajax* request. + + // using jQuery + $.get("https://api.travis-ci.org/", function() { alert("it worked!") }); + +Our current setup allows the headers `Content-Type`, `Authorization`, `Accept` and the HTTP methods `HEAD`, `GET`, `POST`, `PATCH`, `PUT`, `DELETE`. + +## JSONP + +You can disable the same origin policy by treating the response as JavaScript. +Supply a `callback` parameter to use this. + + + + +This has the potential of code injection, use with caution. diff --git a/lib/travis/api/app.rb b/lib/travis/api/app.rb index 36d1f1d8..7a06db03 100644 --- a/lib/travis/api/app.rb +++ b/lib/travis/api/app.rb @@ -6,6 +6,7 @@ require 'travis' require 'backports' require 'rack' require 'rack/protection' +require 'rack/contrib' require 'active_record' require 'redis' require 'gh' @@ -52,8 +53,13 @@ class Travis::Api::App @app = Rack::Builder.app do use Rack::Protection::PathTraversal use Rack::SSL if Endpoint.production? + use Rack::JSONP use ActiveRecord::ConnectionAdapters::ConnectionManagement + use Rack::Config do |env| + env['travis.global_prefix'] = env['SCRIPT_NAME'] + end + Middleware.subclasses.each { |m| use(m) } Endpoint.subclasses.each { |e| map(e.prefix) { run(e.new) } } end @@ -75,6 +81,10 @@ class Travis::Api::App def self.setup_travis Travis::Database.connect + + Travis::Services.constants.each do |name| + Travis.services[name.to_s.underscore.to_sym] = Travis::Services.const_get(name) unless name == :Base + end end def self.load_endpoints diff --git a/lib/travis/api/app/access_token.rb b/lib/travis/api/app/access_token.rb index f4944cf9..bae8436d 100644 --- a/lib/travis/api/app/access_token.rb +++ b/lib/travis/api/app/access_token.rb @@ -4,7 +4,7 @@ require 'securerandom' class Travis::Api::App class AccessToken DEFAULT_SCOPES = [:public, :private] - attr_reader :token, :scopes, :user_id + attr_reader :token, :scopes, :user_id, :app_id def self.create(options = {}) new(options).tap(&:save) @@ -12,22 +12,25 @@ class Travis::Api::App def self.find_by_token(token) user_id, app_id, *scopes = redis.lrange(key(token), 0, -1) - new(token: token, scopes: scopes, user_id: user_id) if user_id + new(token: token, scopes: scopes, user_id: user_id, app_id: app_id) if user_id end def initialize(options = {}) raise ArgumentError, 'must supply either user_id or user' unless options.key?(:user) ^ options.key?(:user_id) + raise ArgumentError, 'must supply app_id' unless options.key?(:app_id) - @token = options[:token] || SecureRandom.urlsafe_base64(64) + @app_id = Integer(options[:app_id]) @scopes = Array(options[:scopes] || options[:scope] || DEFAULT_SCOPES).map(&:to_sym) @user = options[:user] @user_id = Integer(options[:user_id] || @user.id) + @token = options[:token] || reuse_token || SecureRandom.urlsafe_base64(16) end def save key = key(token) redis.del(key) - redis.rpush(key, [user_id, '', *scopes].map(&:to_s)) + redis.rpush(key, [user_id, app_id, *scopes].map(&:to_s)) + redis.set(reuse_key, token) end def user @@ -55,5 +58,19 @@ class Travis::Api::App include Helpers extend Helpers + + private + + def reuse_token + redis.get(reuse_key) + end + + def reuse_key + @reuse_key ||= begin + keys = ["r", user_id, app_id] + keys.append(scopes.map(&:to_s).sort) if scopes != DEFAULT_SCOPES + keys.join(':') + end + end end end diff --git a/lib/travis/api/app/endpoint.rb b/lib/travis/api/app/endpoint.rb index 9d96435f..9bee2ff8 100644 --- a/lib/travis/api/app/endpoint.rb +++ b/lib/travis/api/app/endpoint.rb @@ -1,4 +1,5 @@ require 'travis/api/app' +require 'addressable/uri' class Travis::Api::App # Superclass for HTTP endpoints. Takes care of prefixing. @@ -10,5 +11,32 @@ class Travis::Api::App before { content_type :json } error(ActiveRecord::RecordNotFound, Sinatra::NotFound) { not_found } not_found { content_type =~ /json/ ? { 'file' => 'not found' } : 'file not found' } + + private + + def service(key, user = current_user) + const = Travis.services[key] || raise("no service registered for #{key}") + const.new(user) + end + + def current_user + env['travis.access_token'].user if env['travis.access_token'] + end + + def redis + Thread.current[:redis] ||= ::Redis.connect(url: Travis.config.redis.url) + end + + def endpoint(link, query_values = {}) + link = url(File.join(env['travis.global_prefix'], link), true, false) + uri = Addressable::URI.parse(link) + query_values = query_values.merge(uri.query_values) if uri.query_values + uri.query_values = query_values + uri.to_s + end + + def safe_redirect(url) + redirect(endpoint('/redirect', to: url), 301) + end end end diff --git a/lib/travis/api/app/endpoint/artifacts.rb b/lib/travis/api/app/endpoint/artifacts.rb index 88fae5a9..2a1d51b4 100644 --- a/lib/travis/api/app/endpoint/artifacts.rb +++ b/lib/travis/api/app/endpoint/artifacts.rb @@ -5,7 +5,9 @@ class Travis::Api::App # TODO: Add documentation. class Artifacts < Endpoint # TODO: Add documentation. - get('/:id') { |id| body Artifact.find(id) } + get('/:id') do |id| + body service(:artifacts).find_one(params) + end end end end diff --git a/lib/travis/api/app/endpoint/authorization.rb b/lib/travis/api/app/endpoint/authorization.rb index 2df888e9..f9be8f6a 100644 --- a/lib/travis/api/app/endpoint/authorization.rb +++ b/lib/travis/api/app/endpoint/authorization.rb @@ -1,6 +1,7 @@ require 'travis/api/app' require 'addressable/uri' require 'faraday' +require 'securerandom' class Travis::Api::App class Endpoint @@ -17,6 +18,7 @@ class Travis::Api::App # authorize) against GitHub. # # This is the recommended way for third-party web apps. + # The entry point is [/auth/authorize](#/auth/authorize). # # ## GitHub Token # @@ -28,13 +30,21 @@ class Travis::Api::App # This is the recommended way for GitHub applications that also want Travis # integration. # + # The entry point is [/auth/github](#/auth/github). + # # ## Cross-Origin Window Messages # # This is the recommended way for the official client. We might improve the # authorization flow to support third-party clients in the future, too. + # + # The entry point is [/auth/post_message](#/auth/post_message). class Authorization < Endpoint set prefix: '/auth' + enable :inline_templates + # Endpoint for retrieving an authorization code, which in turn can be used + # to generate an access token. + # # Parameters: # # * **client_id**: your App's client id (required) @@ -45,45 +55,68 @@ class Travis::Api::App raise NotImplementedError end + # Endpoint for generating an access token from an authorization code. + # # Parameters: # # * **client_id**: your App's client id (required) # * **client_secret**: your App's client secret (required) - # * **code**: code retrieved from redirect from [/authorize](#/authorize) (required) + # * **code**: code retrieved from redirect from [/auth/authorize](#/auth/authorize) (required) # * **redirect_uri**: URL to redirect to - # * **state**: same value sent to [/authorize](#/authorize) + # * **state**: same value sent to [/auth/authorize](#/auth/authorize) post '/access_token' do raise NotImplementedError end + # Endpoint for generating an access token from a GitHub access token. + # # Parameters: # # * **token**: GitHub token for checking authorization (required) post '/github' do - { 'access_token' => github_to_travis(params[:token]) } + { 'access_token' => github_to_travis(params[:token], app_id: 1) } end + # Endpoint for making sure user authorized Travis CI to access GitHub. + # There are no restrictions on where to redirect to after handshake. + # However, no information whatsoever is being sent with the redirect. + # + # Parameters: + # + # * **redirect_uri**: URI to redirect to after handshake. + get '/handshake' do + handshake do |*, redirect_uri| + safe_redirect redirect_uri + end + end + + # This endpoint is meant to be embedded in an iframe, popup window or + # similar. It will perform the handshake and, once done, will send an + # access token and user payload to the parent window via postMessage. + # + # However, the endpoint to send the payload to has to be explicitely + # whitelisted in production, as this is endpoint is only meant to be used + # with the official Travis CI client at the moment. + # + # Example usage: + # + # window.addEventListener("message", function(event) { + # alert("received token: " + event.data.token); + # }); + # + # var iframe = $('').hide(); + # iframe.appendTo('body'); + # iframe.attr('src', "https://api.travis-ci.org/auth/post_message"); + # + # Note that embedding it in an iframe will only work for users that are + # logged in at GitHub and already authorized Travis CI. It is therefore + # recommended to redirect to [/auth/handshake](#/auth/handshake) if no + # token is being received. get '/post_message' do - config = Travis.config.oauth2 - endpoint = Addressable::URI.parse(config.authorization_server) - values = { - client_id: config.client_id, - scope: config.scope, - redirect_uri: url - } - - if params[:code] - endpoint.path = config.access_token_path - values[:code] = params[:code] - values[:state] = params[:state] if params[:state] - values[:client_secret] = config.client_secret - - token = github_to_travis get_token(endpoint.to_s, values) - { 'access_token' => token } - else - endpoint.path = config.authorize_path - endpoint.query_values = values - redirect to(endpoint.to_s) + handshake do |user, token, target_origin| + halt 403, invalid_target(target_origin) unless target_ok? target_origin + rendered_user = Travis::Api.data(service(:user, user).find_one, type: :user, version: :v2) + post_message(token: token, user: rendered_user, target_origin: target_origin) end end @@ -93,19 +126,70 @@ class Travis::Api::App private - def github_to_travis(token) + def handshake + config = Travis.config.oauth2 + endpoint = Addressable::URI.parse(config.authorization_server) + values = { + client_id: config.client_id, + scope: config.scope, + redirect_uri: url + } + + if params[:code] and state_ok?(params[:state]) + endpoint.path = config.access_token_path + values[:state] = params[:state] + values[:code] = params[:code] + values[:client_secret] = config.client_secret + github_token = get_token(endpoint.to_s, values) + user = user_for_github_token(github_token) + token = generate_token(user: user, app_id: 0) + payload = params[:state].split(":::", 2)[1] + yield user, token, payload + else + values[:state] = create_state + endpoint.path = config.authorize_path + endpoint.query_values = values + redirect to(endpoint.to_s) + end + end + + def create_state + state = SecureRandom.urlsafe_base64(16) + redis.sadd('github:states', state) + redis.expire('github:states', 1800) + payload = params[:origin] || params[:redirect_uri] + state << ":::" << payload if payload + state + end + + def state_ok?(state) + redis.srem('github:states', state.split(":::", 1)) if state + end + + def github_to_travis(token, options = {}) + generate_token options.merge(user: user_for_github_token(token)) + end + + def user_info(data, misc = {}) + info = data.to_hash.slice('name', 'login', 'github_oauth_token', 'gravatar_id') + info.merge! misc + info['github_id'] ||= data['id'] + info + end + + def user_for_github_token(token) data = GH.with(token: token.to_s) { GH['user'] } scopes = parse_scopes data.headers['x-oauth-scopes'] - user = User.find_by_login(data['login']) + user = User.find_by_github_id(data['id']) + user ||= User.create! user_info(data, github_oauth_token: token) halt 403, 'not a Travis user' if user.nil? halt 403, 'insufficient access' unless acceptable? scopes - - generate_token(user) + user end - def get_token(endoint, value) - response = Faraday.get(endoint, value) + def get_token(endoint, values) + response = Faraday.post(endoint, values) parameters = Addressable::URI.form_unencode(response.body) parameters.assoc("access_token").last end @@ -114,13 +198,44 @@ class Travis::Api::App data.gsub(/\s/,'').split(',') if data end - def generate_token(user) - AccessToken.create(user: user).token + def generate_token(options) + AccessToken.create(options).token end def acceptable?(scopes) scopes.include? 'public_repo' or scopes.include? 'repo' end + + def post_message(payload) + content_type :html + erb(:post_message, locals: payload) + end + + def invalid_target(target_origin) + content_type :html + erb(:invalid_target, {}, target_origin: target_origin) + end + + def target_ok?(target_origin) + target_origin =~ %r{ + ^ http:// (localhost|127\.0\.0\.1)(:\d+)? $ | + ^ https:// (\w+\.)?travis-ci\.(org|com) $ + }x + end end end end + +__END__ + +@@ invalid_target + + +@@ post_message + diff --git a/lib/travis/api/app/endpoint/branches.rb b/lib/travis/api/app/endpoint/branches.rb index 39d48606..52d83a50 100644 --- a/lib/travis/api/app/endpoint/branches.rb +++ b/lib/travis/api/app/endpoint/branches.rb @@ -6,15 +6,8 @@ class Travis::Api::App class Branches < Endpoint # TODO: Add documentation. get('/') do - body repository, :type => "Branches" + body service(:branches).find_all(params), type: :branches end - - private - - def repository - pass if params.empty? - Repository.find_by(params) || not_found - end end end end diff --git a/lib/travis/api/app/endpoint/builds.rb b/lib/travis/api/app/endpoint/builds.rb index 911913cc..cd74c36e 100644 --- a/lib/travis/api/app/endpoint/builds.rb +++ b/lib/travis/api/app/endpoint/builds.rb @@ -6,23 +6,13 @@ class Travis::Api::App class Builds < Endpoint # TODO: Add documentation. get '/' do - scope = repository.builds.by_event_type(params[:event_type] || 'push') - scope = params[:after] ? scope.older_than(params[:after]) : scope.recent - scope + body service(:builds).find_all(params) end # TODO: Add documentation. get '/:id' do - one = params[:repository_id] ? repository.builds : Build - body one.includes(:commit, :matrix => [:commit, :log]).find(params[:id]) + body service(:builds).find_one(params) end - - private - - def repository - pass if params.empty? - Repository.find_by(params) || not_found - end end end end diff --git a/lib/travis/api/app/endpoint/documentation.rb b/lib/travis/api/app/endpoint/documentation.rb index e230142e..370612cc 100644 --- a/lib/travis/api/app/endpoint/documentation.rb +++ b/lib/travis/api/app/endpoint/documentation.rb @@ -7,11 +7,14 @@ class Travis::Api::App set prefix: '/docs', public_folder: File.expand_path('../documentation', __FILE__) enable :inline_templates, :static + # Don't cache general docs in development + configure(:development) { before { @@general_docs = nil } } + # HTML view for [/endpoints](#/endpoints/). get '/' do content_type :html endpoints = Endpoints.endpoints - erb :index, {}, :endpoints => endpoints.keys.sort.map { |k| endpoints[k] } + erb :index, {}, endpoints: endpoints.keys.sort.map { |k| endpoints[k] } end helpers do @@ -33,11 +36,38 @@ class Travis::Api::App end def docs_for(entry) - markdown(entry['doc']). - gsub('
/, ''). - gsub(/TODO:?/, 'TODO') + with_code_highlighting markdown(entry['doc']) end + + private + + def with_code_highlighting(str) + str. + gsub('/, ''). + gsub(/TODO:?/, 'TODO') + end + + def general_docs + @@general_docs ||= doc_files.map do |file| + header, content = File.read(file).split("\n", 2) + content = markdown(content) + subheaders = [] + + content.gsub!(/(.*)<\/h2>/) do + subheaders << $1 + "
#{$1}
" + end + + header.gsub! /^#* */, '' + { id: header, title: header, content: with_code_highlighting(content), subheaders: subheaders } + end + end + + def doc_files + pattern = File.expand_path('../../../../../../docs/*.md', __FILE__) + Dir[pattern].sort + end end end end @@ -95,6 +125,13 @@ __END__ + + ++ +
@@ -111,6 +148,13 @@ __END__