diff --git a/app/assets/javascripts/admin/controllers/admin_api_controller.js b/app/assets/javascripts/admin/controllers/admin_api_controller.js new file mode 100644 index 00000000000..116595e6399 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_api_controller.js @@ -0,0 +1,3 @@ +Discourse.AdminApiController = Ember.Controller.extend({ + +}); diff --git a/app/assets/javascripts/admin/models/admin_api.js b/app/assets/javascripts/admin/models/admin_api.js new file mode 100644 index 00000000000..a6883a83a8a --- /dev/null +++ b/app/assets/javascripts/admin/models/admin_api.js @@ -0,0 +1,24 @@ +Discourse.AdminApi = Discourse.Model.extend({ + VALID_KEY_LENGTH: 64, + + keyExists: function(){ + var key = this.get('key') || ''; + return key && key.length === this.VALID_KEY_LENGTH; + }.property('key'), + + generateKey: function(){ + var _this = this; + + $.ajax(Discourse.getURL('/admin/api/generate_key'),{ + type: 'POST' + }).success(function(result){ + _this.set('key', result.key); + }); + } +}); + +Discourse.AdminApi.reopenClass({ + find: function(){ + return this.getAjax('/admin/api'); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin_api_route.js b/app/assets/javascripts/admin/routes/admin_api_route.js new file mode 100644 index 00000000000..f8500b2342a --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_api_route.js @@ -0,0 +1,17 @@ +/** + Handles routes related to api + + @class AdminApiRoute + @extends Discourse.Route + @namespace Discourse + @module Discourse +**/ +Discourse.AdminApiRoute = Discourse.Route.extend({ + renderTemplate: function() { + this.render({into: 'admin/templates/admin'}); + }, + + model: function(params) { + return Discourse.AdminApi.find(); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js index 7e6c0f4f746..b82e89d6483 100644 --- a/app/assets/javascripts/admin/routes/admin_routes.js +++ b/app/assets/javascripts/admin/routes/admin_routes.js @@ -10,6 +10,7 @@ Discourse.Route.buildRoutes(function() { this.route('site_settings', { path: '/site_settings' }); this.route('email_logs', { path: '/email_logs' }); this.route('customize', { path: '/customize' }); + this.route('api', {path: '/api'}); this.resource('adminReports', { path: '/reports/:type' }); diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars index 82995c926d6..c52dfa66253 100644 --- a/app/assets/javascripts/admin/templates/admin.js.handlebars +++ b/app/assets/javascripts/admin/templates/admin.js.handlebars @@ -9,6 +9,7 @@
  • {{#linkTo 'admin.email_logs'}}{{i18n admin.email_logs.title}}{{/linkTo}}
  • {{#linkTo 'adminFlags.active'}}{{i18n admin.flags.title}}{{/linkTo}}
  • {{#linkTo 'admin.customize'}}{{i18n admin.customize.title}}{{/linkTo}}
  • +
  • {{#linkTo 'admin.api'}}{{i18n admin.api.title}}{{/linkTo}}
  • diff --git a/app/assets/javascripts/admin/templates/api.js.handlebars b/app/assets/javascripts/admin/templates/api.js.handlebars new file mode 100644 index 00000000000..969f060b3ee --- /dev/null +++ b/app/assets/javascripts/admin/templates/api.js.handlebars @@ -0,0 +1,11 @@ + +

    API Information

    +{{#if content.keyExists}} + Key: {{content.key}} +

    Keep this key secret, all users that have it may create arbirary posts on the forum as any user.

    +{{else}} +

    Your API key will allow you to create and update topics using JSON calls.

    + +{{/if}} +

    + diff --git a/app/assets/javascripts/admin/views/admin_api_view.js b/app/assets/javascripts/admin/views/admin_api_view.js new file mode 100644 index 00000000000..f57d84cd1cf --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_api_view.js @@ -0,0 +1,3 @@ +Discourse.AdminApiView = Discourse.View.extend({ + templateName: 'admin/templates/api' +}); diff --git a/app/assets/javascripts/discourse/models/model.js b/app/assets/javascripts/discourse/models/model.js index f5fed188b2d..f0fb95bf0f3 100644 --- a/app/assets/javascripts/discourse/models/model.js +++ b/app/assets/javascripts/discourse/models/model.js @@ -48,7 +48,8 @@ Discourse.Model = Ember.Object.extend(Discourse.Presence, { _this.set(k, v); } }); - } + } + }); Discourse.Model.reopenClass({ diff --git a/app/controllers/admin/api_controller.rb b/app/controllers/admin/api_controller.rb new file mode 100644 index 00000000000..d55c13c2275 --- /dev/null +++ b/app/controllers/admin/api_controller.rb @@ -0,0 +1,10 @@ +class Admin::ApiController < Admin::AdminController + def index + render json: {key: SiteSetting.api_key} + end + + def generate_key + SiteSetting.generate_api_key! + render json: {key: SiteSetting.api_key} + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 74a4c2d36a1..e8ebc02cd4f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -250,6 +250,11 @@ class ApplicationController < ActionController::Base def check_xhr unless (controller_name == 'forums' || controller_name == 'user_open_ids') + # bypass xhr check on PUT / POST / DELETE provided api key is there, otherwise calling api is annoying + if !request.get? && request["api_key"] + return + end + raise RenderEmpty.new unless ((request.format && request.format.json?) || request.xhr?) end end diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 6d40d81571a..ad5b58a8973 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -15,6 +15,7 @@ class SiteSetting < ActiveRecord::Base setting(:company_full_name, 'My Unconfigured Forum Ltd.') setting(:company_short_name, 'Unconfigured Forum') setting(:company_domain, 'www.example.com') + setting(:api_key, '') client_setting(:traditional_markdown_linebreaks, false) client_setting(:top_menu, 'popular|new|unread|favorited|categories') client_setting(:post_menu, 'like|edit|flag|delete|share|bookmark|reply') @@ -168,6 +169,15 @@ class SiteSetting < ActiveRecord::Base setting(:max_similar_results, 7) + def self.generate_api_key! + self.api_key = SecureRandom.hex(32) + end + + def self.api_key_valid?(tested) + t = tested.strip + t.length == 64 && t == self.api_key + end + def self.call_discourse_hub? self.enforce_global_nicknames? && self.discourse_org_access_key.present? end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 8b8f7ed65a0..2e3c4a794ee 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -731,6 +731,8 @@ en: flagged_by: "Flagged by" error: "Something went wrong" + api: + title: "API" customize: title: "Customize" header: "Header" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c5d90fa3025..3ccfa0b8899 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -316,6 +316,7 @@ en: company_full_name: "The full name of the company that runs this site, used in legal documents like the /tos" company_short_name: "The short name of the company that runs this site, used in legal documents like the /tos" company_domain: "The domain name owned by the company that runs this site, used in legal documents like the /tos" + api_key: "The secure API key used to create and update topics, use the /admin/api section to set it up" access_password: "When restricted access is enabled, this password must be entered" queue_jobs: "Queue various jobs in sidekiq, if false queues are inline" crawl_images: "Enable retrieving images from third party sources to insert width and height dimensions" diff --git a/config/routes.rb b/config/routes.rb index e88fbf07328..813e66c906c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,11 @@ Discourse::Application.routes.draw do resources :export get 'version_check' => 'versions#show' resources :dashboard, only: [:index] + resources :api, only: [:index] do + collection do + post 'generate_key' + end + end end get 'email_preferences' => 'email#preferences_redirect' diff --git a/lib/current_user.rb b/lib/current_user.rb index e19cd460c7d..05136ff4c7c 100644 --- a/lib/current_user.rb +++ b/lib/current_user.rb @@ -52,6 +52,18 @@ module CurrentUser @current_user.update_last_seen! @current_user.update_ip_address!(request.remote_ip) end + + # possible we have an api call, impersonate + unless @current_user + if api_key = request["api_key"] + if api_username = request["api_username"] + if SiteSetting.api_key_valid?(api_key) + @current_user = User.where(username_lower: api_username.downcase).first + end + end + end + end + @current_user end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb new file mode 100644 index 00000000000..2513c49762d --- /dev/null +++ b/spec/controllers/application_controller_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe 'api' do + before do + fake_key = SecureRandom.hex(32) + SiteSetting.stubs(:api_key).returns(fake_key) + end + + describe PostsController do + let(:user) do + Fabricate(:user) + end + + let(:post) do + Fabricate(:post) + end + + # choosing an arbitrarily easy to mock trusted activity + it 'allows users with api key to bookmark posts' do + PostAction.expects(:act).with(user,post,PostActionType.types[:bookmark]).returns(true) + put :bookmark, bookmarked: "true" ,post_id: post.id , api_key: SiteSetting.api_key, api_username: user.username + end + + it 'disallows phonies to bookmark posts' do + lambda do + put :bookmark, bookmarked: "true" ,post_id: post.id , api_key: SecureRandom.hex(32), api_username: user.username + end.should raise_error Discourse::NotLoggedIn + end + + it 'disallows blank api' do + SiteSetting.stubs(:api_key).returns("") + lambda do + put :bookmark, bookmarked: "true" ,post_id: post.id , api_key: "", api_username: user.username + end.should raise_error Discourse::NotLoggedIn + end + end +end