require 'travis/api/app'
require 'addressable/uri'
require 'faraday'
require 'securerandom'

class Travis::Api::App
  class Endpoint
    # You need to get hold of an access token in order to reach any
    # endpoint requiring authorization.
    # There are three ways to get hold of such a token: OAuth2, via a GitHub
    # token you may already have or with Cross-Origin Window Messages.
    #
    # ## OAuth2
    #
    # API authorization is done via a subset of OAuth2 and is largely compatible
    # with the [GitHub process](http://developer.github.com/v3/oauth/).
    # Be aware that Travis CI will in turn use OAuth2 to authenticate (and
    # authorize) against GitHub.
    #
    # This is the recommended way for third-party web apps.
    # The entry point is [/auth/authorize](#/auth/authorize).
    #
    # ## GitHub Token
    #
    # If you already have a GitHub token with the same or greater scope than
    # the tokens used by Travis CI, you can easily exchange it for a access
    # token. Travis will not store the GitHub token and only use it for a single
    # request to resolve the associated user and scopes.
    #
    # 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
      enable :inline_templates
      set prefix: '/auth'

      # Endpoint for retrieving an authorization code, which in turn can be used
      # to generate an access token.
      #
      # NOTE: This endpoint is not yet implemented.
      #
      # Parameters:
      #
      # * **client_id**: your App's client id (required)
      # * **redirect_uri**: URL to redirect to
      # * **scope**: requested access scope
      # * **state**: should be random string to prevent CSRF attacks
      get '/authorize' do
        raise NotImplementedError
      end

      # Endpoint for generating an access token from an authorization code.
      #
      # NOTE: This endpoint is not yet implemented.
      #
      # Parameters:
      #
      # * **client_id**: your App's client id (required)
      # * **client_secret**: your App's client secret (required)
      # * **code**: code retrieved from redirect from [/auth/authorize](#/auth/authorize) (required)
      # * **redirect_uri**: URL to redirect to
      # * **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:
      #
      # * **github_token**: GitHub token for checking authorization (required)
      post '/github' do
        { 'access_token' => github_to_travis(params[:github_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 |user, token, redirect_uri|
          if target_ok? redirect_uri
            content_type :html
            user = Travis::Api.data(user, version: :v2)
            data = { user: user, token: token, uri: redirect_uri }
            erb(:post_payload, locals: data)
          else
            safe_redirect redirect_uri
          end
        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) {
      #       console.log("received token: " + event.data.token);
      #     });
      #
      #     var iframe = $('<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', scope: :public do
        content_type :html
        erb :container
      end

      get '/post_message/iframe', scope: :public do
        handshake do |user, token, target_origin|
          halt 403, invalid_target(target_origin) unless target_ok? target_origin
          rendered_user = Travis::Api.data(user, version: :v2)
          travis_token  = user.tokens.first
          post_message(token: token, user: rendered_user, target_origin: target_origin,
                       travis_token: travis_token ? travis_token.token : nil)
        end
      end

      error Faraday::Error::ClientError do
        halt 401, 'could not resolve github token'
      end

      private

        def oauth_endpoint
          proxy = Travis.config.oauth2.proxy
          proxy ? File.join(proxy, request.fullpath) : url
        end

        def handshake
          config   = Travis.config.oauth2
          endpoint = Addressable::URI.parse(config.authorization_server)
          values   = {
            client_id:    config.client_id,
            scope:        config.scope,
            redirect_uri: oauth_endpoint
          }

          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

        class UserManager < Struct.new(:data, :token)
          def info(attributes = {})
            info = data.to_hash.slice('name', 'login', 'gravatar_id')
            info.merge! attributes.stringify_keys
            info['github_id'] ||= data['id']
            info
          end

          def fetch
            user   = ::User.find_by_github_id(data['id'])
            info   = info(github_oauth_token: token)

            if user
              user.update_attributes info
            else
              user = ::User.create! info
            end

            user
          end
        end

        def user_for_github_token(token)
          data   = GH.with(token: token.to_s) { GH['user'] }
          scopes = parse_scopes data.headers['x-oauth-scopes']
          halt 403, 'insufficient access' unless acceptable? scopes

          user   = UserManager.new(data, token).fetch
          halt 403, 'not a Travis user' if user.nil?
          user
        end

        def get_token(endoint, values)
          response   = Faraday.post(endoint, values)
          parameters = Addressable::URI.form_unencode(response.body)
          token_info = parameters.assoc("access_token")
          halt 401, 'could not resolve github token' unless token_info
          token_info.last
        end

        def parse_scopes(data)
          data.gsub(/\s/,'').split(',') if data
        end

        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)
          return unless uri = Addressable::URI.parse(target_origin)
          if uri.host =~ /\A(.+\.)?travis-ci\.(com|org)\Z/
            uri.scheme == 'https'
          elsif uri.host == 'localhost' or uri.host == '127.0.0.1'
            uri.port > 1023
          end
        end
    end
  end
end

__END__

@@ invalid_target
<script>
console.log('refusing to send a token to <%= target_origin.inspect %>, not whitelisted!');
</script>

@@ common
function tellEveryone(msg, win) {
  if(win == undefined) win = window;
  win.postMessage(msg, '*');
  if(win.parent != win) tellEveryone(msg, win.parent);
  if(win.opener) tellEveryone(msg, win.opener);
}

@@ container
<!DOCTYPE html>
<html><body><script>
// === THE FLOW ===

// every serious program has a main function
function main() {
  doYouHave(thirdPartyCookies,
    yesIndeed("third party cookies enabled, creating iframe",
      doYouHave(iframe(after(5)),
        yesIndeed("iframe succeeded", done),
        nopeSorry("iframe taking too long, creating pop-up",
          doYouHave(popup(after(5)),
            yesIndeed("pop-up succeeded", done),
            nopeSorry("pop-up failed, redirecting", redirect))))),
    nopeSorry("third party cookies disabled, creating pop-up",
      doYouHave(popup(after(8)),
        yesIndeed("popup succeeded", done),
        nopeSorry("popup failed", redirect))))();
}

// === THE LOGIC ===
var url = window.location.pathname + '/iframe' + window.location.search;

function thirdPartyCookies(yes, no) {
  window.cookiesCheckCallback = function(enabled) { enabled ? yes() : no() };
  var img      = document.createElement('img');
  img.src      = "https://third-party-cookies.herokuapp.com/set";
  img.onload   = function() {
    var script = document.createElement('script');
    script.src = "https://third-party-cookies.herokuapp.com/check";
    window.document.body.appendChild(script);
  }
}

function iframe(time) {
  return function(yes, no) {
    var iframe = document.createElement('iframe');
    iframe.src = url;
    timeout(time, yes, no);
    window.document.body.appendChild(iframe);
  }
}

function popup(time) {
  return function(yes, no) {
    if(popupWindow) {
      timeout(time, yes, function() {
        if(popupWindow.closed || popupWindow.innerHeight < 1) {
          no()
        } else {
          try {
            popupWindow.focus();
            popupWindow.resizeTo(900, 500);
          } catch(err) {
            no()
          }
        }
      });
    } else {
      no()
    }
  }
}

function done() {
  if(popupWindow && !popupWindow.closed) popupWindow.close();
}

function redirect() {
  tellEveryone('redirect');
}

function createPopup() {
  if(!popupWindow) popupWindow = window.open(url, 'Signing in...', 'height=50,width=50');
}

// === THE PLUMBING ===
<%= erb :common %>

function timeout(time, yes, no) {
  var timeout = setTimeout(no, time);
  onSuccess(function() {
    clearTimeout(timeout);
    yes()
  });
}

function onSuccess(callback) {
  succeeded ? callback() : callbacks.push(callback)
}

function doYouHave(feature, yes, no) {
  return function() { feature(yes, no) };
}

function yesIndeed(msg, callback) {
  if(console && console.log) console.log(msg);
  return callback;
}

function after(value) {
  return value*1000;
}

var nopeSorry = yesIndeed;
var timeoutes = [];
var callbacks = [];
var seconds   = 1000;
var succeeded = false;
var popupWindow;

window.addEventListener("message", function(event) {
  if(event.data === "done") {
    succeeded = true
    for(var i = 0; i < callbacks.length; i++) {
      (callbacks[i])();
    }
  }
});

// === READY? GO! ===
main();
</script>
</body>
</html>

@@ post_message
<script>
<%= erb :common %>
function uberParent(win) {
  return win.parent === win ? win : uberParent(win.parent);
}

function sendPayload(win) {
  var payload          = <%= user.to_json %>;
  payload.token        = <%= token.inspect %>;
  payload.travis_token = <%= travis_token ? travis_token.inspect : null %>;
  uberParent(win).postMessage(payload, <%= target_origin.inspect %>);
}

if(window.parent == window) {
  sendPayload(window.opener);
  window.close();
} else {
  tellEveryone('done');
  sendPayload(window.parent);
}
</script>

@@ post_payload
<body onload='document.forms[0].submit()'>
  <form action="<%= uri %>" method='post'>
    <input type='hidden' name='token'   value='<%= token %>'>
    <input type='hidden' name='user'    value="<%= user.to_json.gsub('"', '&quot;') %>">
    <input type='hidden' name='storage' value='localStorage'>
  </form>
</body>