mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-30 10:58:31 -05:00
Support for per-user API keys
This commit is contained in:
parent
5e2d8dcf37
commit
348e2e3ef2
45 changed files with 670 additions and 87 deletions
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
This controller supports the interface for dealing with API keys
|
||||||
|
|
||||||
|
@class AdminApiController
|
||||||
|
@extends Ember.ArrayController
|
||||||
|
@namespace Discourse
|
||||||
|
@module Discourse
|
||||||
|
**/
|
||||||
|
Discourse.AdminApiController = Ember.ArrayController.extend({
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
Generates a master api key
|
||||||
|
|
||||||
|
@method generateMasterKey
|
||||||
|
@param {Discourse.ApiKey} the key to regenerate
|
||||||
|
**/
|
||||||
|
generateMasterKey: function(key) {
|
||||||
|
var self = this;
|
||||||
|
Discourse.ApiKey.generateMasterKey().then(function (key) {
|
||||||
|
self.get('model').pushObject(key);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Creates an API key instance with internal user object
|
||||||
|
|
||||||
|
@method regenerateKey
|
||||||
|
@param {Discourse.ApiKey} the key to regenerate
|
||||||
|
**/
|
||||||
|
regenerateKey: function(key) {
|
||||||
|
bootbox.confirm(I18n.t("admin.api.confirm_regen"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
|
||||||
|
if (result) {
|
||||||
|
key.regenerate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Revokes an API key
|
||||||
|
|
||||||
|
@method revokeKey
|
||||||
|
@param {Discourse.ApiKey} the key to revoke
|
||||||
|
**/
|
||||||
|
revokeKey: function(key) {
|
||||||
|
var self = this;
|
||||||
|
bootbox.confirm(I18n.t("admin.api.confirm_revoke"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
|
||||||
|
if (result) {
|
||||||
|
key.revoke().then(function() {
|
||||||
|
self.get('model').removeObject(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Has a master key already been generated?
|
||||||
|
|
||||||
|
@property hasMasterKey
|
||||||
|
@type {Boolean}
|
||||||
|
**/
|
||||||
|
hasMasterKey: function() {
|
||||||
|
return !!this.get('model').findBy('user', null);
|
||||||
|
}.property('model.@each')
|
||||||
|
|
||||||
|
});
|
|
@ -27,6 +27,28 @@ Discourse.AdminUserController = Discourse.ObjectController.extend({
|
||||||
});
|
});
|
||||||
|
|
||||||
this.send('toggleTitleEdit');
|
this.send('toggleTitleEdit');
|
||||||
|
},
|
||||||
|
|
||||||
|
generateApiKey: function() {
|
||||||
|
this.get('model').generateApiKey();
|
||||||
|
},
|
||||||
|
|
||||||
|
regenerateApiKey: function() {
|
||||||
|
var self = this;
|
||||||
|
bootbox.confirm(I18n.t("admin.api.confirm_regen"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
|
||||||
|
if (result) {
|
||||||
|
self.get('model').generateApiKey();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
revokeApiKey: function() {
|
||||||
|
var self = this;
|
||||||
|
bootbox.confirm(I18n.t("admin.api.confirm_revoke"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
|
||||||
|
if (result) {
|
||||||
|
self.get('model').revokeApiKey();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
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 adminApi = this;
|
|
||||||
Discourse.ajax('/admin/api/generate_key', {type: 'POST'}).then(function (result) {
|
|
||||||
adminApi.set('key', result.key);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
regenerateKey: function(){
|
|
||||||
alert(I18n.t('not_implemented'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Discourse.AdminApi.reopenClass({
|
|
||||||
find: function() {
|
|
||||||
var model = Discourse.AdminApi.create();
|
|
||||||
Discourse.ajax("/admin/api").then(function(data) {
|
|
||||||
model.setProperties(data);
|
|
||||||
});
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -8,6 +8,34 @@
|
||||||
**/
|
**/
|
||||||
Discourse.AdminUser = Discourse.User.extend({
|
Discourse.AdminUser = Discourse.User.extend({
|
||||||
|
|
||||||
|
/**
|
||||||
|
Generates an API key for the user. Will regenerate if they already have one.
|
||||||
|
|
||||||
|
@method generateApiKey
|
||||||
|
@returns {Promise} a promise that resolves to the newly generated API key
|
||||||
|
**/
|
||||||
|
generateApiKey: function() {
|
||||||
|
var self = this;
|
||||||
|
return Discourse.ajax("/admin/users/" + this.get('id') + "/generate_api_key", {type: 'POST'}).then(function (result) {
|
||||||
|
var apiKey = Discourse.ApiKey.create(result.api_key);
|
||||||
|
self.set('api_key', apiKey);
|
||||||
|
return apiKey;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Revokes a user's current API key
|
||||||
|
|
||||||
|
@method revokeApiKey
|
||||||
|
@returns {Promise} a promise that resolves when the API key has been deleted
|
||||||
|
**/
|
||||||
|
revokeApiKey: function() {
|
||||||
|
var self = this;
|
||||||
|
return Discourse.ajax("/admin/users/" + this.get('id') + "/revoke_api_key", {type: 'DELETE'}).then(function (result) {
|
||||||
|
self.set('api_key', null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
deleteAllPosts: function() {
|
deleteAllPosts: function() {
|
||||||
this.set('can_delete_all_posts', false);
|
this.set('can_delete_all_posts', false);
|
||||||
var user = this;
|
var user = this;
|
||||||
|
|
81
app/assets/javascripts/admin/models/api_key.js
Normal file
81
app/assets/javascripts/admin/models/api_key.js
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
Our data model for representing an API key in the system
|
||||||
|
|
||||||
|
@class ApiKey
|
||||||
|
@extends Discourse.Model
|
||||||
|
@namespace Discourse
|
||||||
|
@module Discourse
|
||||||
|
**/
|
||||||
|
Discourse.ApiKey = Discourse.Model.extend({
|
||||||
|
|
||||||
|
/**
|
||||||
|
Regenerates the api key
|
||||||
|
|
||||||
|
@method regenerate
|
||||||
|
@returns {Promise} a promise that resolves to the key
|
||||||
|
**/
|
||||||
|
regenerate: function() {
|
||||||
|
var self = this;
|
||||||
|
return Discourse.ajax('/admin/api/key', {type: 'PUT', data: {id: this.get('id')}}).then(function (result) {
|
||||||
|
self.set('key', result.api_key.key);
|
||||||
|
return self;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Revokes the current key
|
||||||
|
|
||||||
|
@method revoke
|
||||||
|
@returns {Promise} a promise that resolves when the key has been revoked
|
||||||
|
**/
|
||||||
|
revoke: function() {
|
||||||
|
var self = this;
|
||||||
|
return Discourse.ajax('/admin/api/key', {type: 'DELETE', data: {id: this.get('id')}});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
Discourse.ApiKey.reopenClass({
|
||||||
|
|
||||||
|
/**
|
||||||
|
Creates an API key instance with internal user object
|
||||||
|
|
||||||
|
@method create
|
||||||
|
@param {Object} the properties to create
|
||||||
|
@returns {Discourse.ApiKey} the ApiKey instance
|
||||||
|
**/
|
||||||
|
create: function(apiKey) {
|
||||||
|
var result = this._super(apiKey);
|
||||||
|
if (result.user) {
|
||||||
|
result.user = Discourse.AdminUser.create(result.user);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Finds a list of API keys
|
||||||
|
|
||||||
|
@method find
|
||||||
|
@returns {Promise} a promise that resolves to the array of `Discourse.ApiKey` instances
|
||||||
|
**/
|
||||||
|
find: function() {
|
||||||
|
return Discourse.ajax("/admin/api").then(function(keys) {
|
||||||
|
return keys.map(function (key) {
|
||||||
|
return Discourse.ApiKey.create(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
Generates a master api key and returns it.
|
||||||
|
|
||||||
|
@method generateMasterKey
|
||||||
|
@returns {Promise} a promise that resolves to a master `Discourse.ApiKey`
|
||||||
|
**/
|
||||||
|
generateMasterKey: function() {
|
||||||
|
return Discourse.ajax("/admin/api/key", {type: 'POST'}).then(function (result) {
|
||||||
|
return Discourse.ApiKey.create(result.api_key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
|
@ -9,7 +9,7 @@
|
||||||
Discourse.AdminApiRoute = Discourse.Route.extend({
|
Discourse.AdminApiRoute = Discourse.Route.extend({
|
||||||
|
|
||||||
model: function() {
|
model: function() {
|
||||||
return Discourse.AdminApi.find();
|
return Discourse.ApiKey.find();
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,33 @@
|
||||||
<h3>{{i18n admin.api.long_title}}</h3>
|
{{#if model}}
|
||||||
{{#if keyExists}}
|
<table class='api-keys'>
|
||||||
<strong>{{i18n admin.api.key}}:</strong> {{key}}
|
<tr>
|
||||||
<button class='btn' {{action regenerateKey target="model"}}>
|
<th>{{i18n admin.api.key}}</th>
|
||||||
{{i18n admin.api.regenerate}}
|
<th>{{i18n admin.api.user}}</th>
|
||||||
</button>
|
<th> </th>
|
||||||
<p>{{{i18n admin.api.note_html}}}</p>
|
</tr>
|
||||||
|
{{#each model}}
|
||||||
|
<tr>
|
||||||
|
<td class='key'>{{key}}</td>
|
||||||
|
<td>
|
||||||
|
{{#if user}}
|
||||||
|
{{#link-to 'adminUser' user}}
|
||||||
|
{{avatar user imageSize="small"}}
|
||||||
|
{{/link-to}}
|
||||||
|
{{else}}
|
||||||
|
{{i18n admin.api.all_users}}
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class='btn' {{action regenerateKey this}}>{{i18n admin.api.regenerate}}</button>
|
||||||
|
<button class='btn' {{action revokeKey this}}>{{i18n admin.api.revoke}}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>{{{i18n admin.api.info_html}}}</p>
|
<p>{{i18n admin.api.none}}</p>
|
||||||
<button class='btn' {{action generateKey target="model"}}>
|
|
||||||
{{i18n admin.api.generate}}
|
|
||||||
</button>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#unless hasMasterKey}}
|
||||||
|
<button class='btn' {{action generateMasterKey}}>{{i18n admin.api.generate_master}}</button>
|
||||||
|
{{/unless }}
|
||||||
|
|
|
@ -125,6 +125,25 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class='display-row'>
|
||||||
|
<div class='field'>{{i18n admin.api.key}}</div>
|
||||||
|
|
||||||
|
{{#if api_key}}
|
||||||
|
<div class='long-value'>
|
||||||
|
{{api_key.key}}
|
||||||
|
<button class='btn' {{action regenerateApiKey}}>{{i18n admin.api.regenerate}}</button>
|
||||||
|
<button {{action revokeApiKey}} class="btn">{{i18n admin.api.revoke}}</button>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class='value'>
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
<div class='controls'>
|
||||||
|
<button {{action generateApiKey}} class="btn">{{i18n admin.api.generate}}</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class='display-row'>
|
<div class='display-row'>
|
||||||
<div class='field'>{{i18n admin.user.admin}}</div>
|
<div class='field'>{{i18n admin.user.admin}}</div>
|
||||||
<div class='value'>{{admin}}</div>
|
<div class='value'>{{admin}}</div>
|
||||||
|
|
|
@ -206,6 +206,17 @@ table {
|
||||||
float: left;
|
float: left;
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
.long-value {
|
||||||
|
width: 800px;
|
||||||
|
float: left;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
.btn {
|
.btn {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
|
@ -374,6 +385,24 @@ table {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table.api-keys {
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.key {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-stats {
|
.dashboard-stats {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
width: 460px;
|
width: 460px;
|
||||||
|
|
|
@ -1,10 +1,28 @@
|
||||||
class Admin::ApiController < Admin::AdminController
|
class Admin::ApiController < Admin::AdminController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: {key: SiteSetting.api_key}
|
render_serialized(ApiKey.all, ApiKeySerializer)
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_key
|
def regenerate_key
|
||||||
SiteSetting.generate_api_key!
|
api_key = ApiKey.where(id: params[:id]).first
|
||||||
render json: {key: SiteSetting.api_key}
|
raise Discourse::NotFound.new if api_key.blank?
|
||||||
|
|
||||||
|
api_key.regenerate!(current_user)
|
||||||
|
render_serialized(api_key, ApiKeySerializer)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revoke_key
|
||||||
|
api_key = ApiKey.where(id: params[:id]).first
|
||||||
|
raise Discourse::NotFound.new if api_key.blank?
|
||||||
|
|
||||||
|
api_key.destroy
|
||||||
|
render nothing: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_master_key
|
||||||
|
api_key = ApiKey.create_master_key
|
||||||
|
render_serialized(api_key, ApiKeySerializer)
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,21 @@ require_dependency 'boost_trust_level'
|
||||||
|
|
||||||
class Admin::UsersController < Admin::AdminController
|
class Admin::UsersController < Admin::AdminController
|
||||||
|
|
||||||
before_filter :fetch_user, only: [:ban, :unban, :refresh_browsers, :revoke_admin, :grant_admin, :revoke_moderation, :grant_moderation, :approve, :activate, :deactivate, :block, :unblock, :trust_level]
|
before_filter :fetch_user, only: [:ban,
|
||||||
|
:unban,
|
||||||
|
:refresh_browsers,
|
||||||
|
:revoke_admin,
|
||||||
|
:grant_admin,
|
||||||
|
:revoke_moderation,
|
||||||
|
:grant_moderation,
|
||||||
|
:approve,
|
||||||
|
:activate,
|
||||||
|
:deactivate,
|
||||||
|
:block,
|
||||||
|
:unblock,
|
||||||
|
:trust_level,
|
||||||
|
:generate_api_key,
|
||||||
|
:revoke_api_key]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
query = ::AdminUserIndexQuery.new(params)
|
query = ::AdminUserIndexQuery.new(params)
|
||||||
|
@ -52,6 +66,16 @@ class Admin::UsersController < Admin::AdminController
|
||||||
render nothing: true
|
render nothing: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_api_key
|
||||||
|
api_key = @user.generate_api_key(current_user)
|
||||||
|
render_serialized(api_key, ApiKeySerializer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_api_key
|
||||||
|
@user.revoke_api_key
|
||||||
|
render nothing: true
|
||||||
|
end
|
||||||
|
|
||||||
def grant_admin
|
def grant_admin
|
||||||
guardian.ensure_can_grant_admin!(@user)
|
guardian.ensure_can_grant_admin!(@user)
|
||||||
@user.grant_admin!
|
@user.grant_admin!
|
||||||
|
|
|
@ -279,7 +279,7 @@ class ApplicationController < ActionController::Base
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def api_key_valid?
|
def api_key_valid?
|
||||||
request["api_key"] && SiteSetting.api_key_valid?(request["api_key"])
|
request["api_key"] && ApiKey.where(key: request["api_key"]).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
# returns an array of integers given a param key
|
# returns an array of integers given a param key
|
||||||
|
|
22
app/models/api_key.rb
Normal file
22
app/models/api_key.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class ApiKey < ActiveRecord::Base
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :created_by, class_name: User
|
||||||
|
|
||||||
|
validates_presence_of :key
|
||||||
|
validates_uniqueness_of :user_id
|
||||||
|
|
||||||
|
def regenerate!(updated_by)
|
||||||
|
self.key = SecureRandom.hex(32)
|
||||||
|
self.created_by = updated_by
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_master_key
|
||||||
|
api_key = ApiKey.where('user_id IS NULL').first
|
||||||
|
if api_key.blank?
|
||||||
|
api_key = ApiKey.create(key: SecureRandom.hex(32), created_by: Discourse.system_user)
|
||||||
|
end
|
||||||
|
api_key
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -18,7 +18,6 @@ class SiteSetting < ActiveRecord::Base
|
||||||
client_setting(:tos_url, '')
|
client_setting(:tos_url, '')
|
||||||
client_setting(:faq_url, '')
|
client_setting(:faq_url, '')
|
||||||
client_setting(:privacy_policy_url, '')
|
client_setting(:privacy_policy_url, '')
|
||||||
setting(:api_key, '')
|
|
||||||
client_setting(:traditional_markdown_linebreaks, false)
|
client_setting(:traditional_markdown_linebreaks, false)
|
||||||
client_setting(:top_menu, 'latest|new|unread|favorited|categories')
|
client_setting(:top_menu, 'latest|new|unread|favorited|categories')
|
||||||
client_setting(:post_menu, 'like|edit|flag|delete|share|bookmark|reply')
|
client_setting(:post_menu, 'like|edit|flag|delete|share|bookmark|reply')
|
||||||
|
@ -273,15 +272,6 @@ class SiteSetting < ActiveRecord::Base
|
||||||
|
|
||||||
setting(:dominating_topic_minimum_percent, 20)
|
setting(:dominating_topic_minimum_percent, 20)
|
||||||
|
|
||||||
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?
|
def self.call_discourse_hub?
|
||||||
self.enforce_global_nicknames? && self.discourse_org_access_key.present?
|
self.enforce_global_nicknames? && self.discourse_org_access_key.present?
|
||||||
end
|
end
|
||||||
|
|
|
@ -43,6 +43,7 @@ class User < ActiveRecord::Base
|
||||||
has_many :secure_categories, through: :groups, source: :categories
|
has_many :secure_categories, through: :groups, source: :categories
|
||||||
|
|
||||||
has_one :user_search_data, dependent: :destroy
|
has_one :user_search_data, dependent: :destroy
|
||||||
|
has_one :api_key, dependent: :destroy
|
||||||
|
|
||||||
belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy
|
belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy
|
||||||
|
|
||||||
|
@ -479,6 +480,19 @@ class User < ActiveRecord::Base
|
||||||
self.save!
|
self.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_api_key(created_by)
|
||||||
|
if api_key.present?
|
||||||
|
api_key.regenerate!(created_by)
|
||||||
|
api_key
|
||||||
|
else
|
||||||
|
ApiKey.create(user: self, key: SecureRandom.hex(32), created_by: created_by)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_api_key
|
||||||
|
ApiKey.where(user_id: self.id).delete_all
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def cook
|
def cook
|
||||||
|
@ -567,6 +581,7 @@ class User < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,6 +16,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer
|
||||||
:can_be_deleted
|
:can_be_deleted
|
||||||
|
|
||||||
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
|
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
|
||||||
|
has_one :api_key, serializer: ApiKeySerializer, embed: :objects
|
||||||
|
|
||||||
def can_revoke_admin
|
def can_revoke_admin
|
||||||
scope.can_revoke_admin?(object)
|
scope.can_revoke_admin?(object)
|
||||||
|
@ -49,4 +50,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
|
||||||
object.topics.count
|
object.topics.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_api_key?
|
||||||
|
api_key.present?
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
12
app/serializers/api_key_serializer.rb
Normal file
12
app/serializers/api_key_serializer.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class ApiKeySerializer < ApplicationSerializer
|
||||||
|
|
||||||
|
attributes :id,
|
||||||
|
:key
|
||||||
|
|
||||||
|
has_one :user, serializer: BasicUserSerializer, embed: :objects
|
||||||
|
|
||||||
|
def include_user_id?
|
||||||
|
!object.user_id.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -1169,12 +1169,18 @@ en:
|
||||||
delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed."
|
delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed."
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
generate_master: "Generate Master API Key"
|
||||||
|
none: "There are no active API keys right now."
|
||||||
|
user: "User"
|
||||||
title: "API"
|
title: "API"
|
||||||
long_title: "API Information"
|
key: "API Key"
|
||||||
key: "Key"
|
generate: "Generate"
|
||||||
generate: "Generate API Key"
|
regenerate: "Regenerate"
|
||||||
regenerate: "Regenerate API Key"
|
revoke: "Revoke"
|
||||||
|
confirm_regen: "Are you sure you want to replace that API Key with a new one?"
|
||||||
|
confirm_revoke: "Are you sure you want to revoke that key?"
|
||||||
info_html: "Your API key will allow you to create and update topics using JSON calls."
|
info_html: "Your API key will allow you to create and update topics using JSON calls."
|
||||||
|
all_users: "All Users"
|
||||||
note_html: "Keep this key <strong>secret</strong>, all users that have it may create arbitrary posts on the forum as any user."
|
note_html: "Keep this key <strong>secret</strong>, all users that have it may create arbitrary posts on the forum as any user."
|
||||||
|
|
||||||
customize:
|
customize:
|
||||||
|
|
|
@ -493,7 +493,6 @@ cs:
|
||||||
company_full_name: "Plné jméno společnosti, která provozuje tento web, používá se v dokumentech jako je /tos"
|
company_full_name: "Plné jméno společnosti, která provozuje tento web, používá se v dokumentech jako je /tos"
|
||||||
company_short_name: "Krátké jméno společnosti, která provozuje tento web, používá se v dokumentech jako je /tos"
|
company_short_name: "Krátké jméno společnosti, která provozuje tento web, používá se v dokumentech jako je /tos"
|
||||||
company_domain: "Doménové jméno vlastněné společností, která provozuje tento web, používá se v dokumentech jako je /tos"
|
company_domain: "Doménové jméno vlastněné společností, která provozuje tento web, používá se v dokumentech jako je /tos"
|
||||||
api_key: "Zabezpečený API klíč, který se používá pro vytváření a aktualizaci témat, použijte sekci /admin/api k nastavení"
|
|
||||||
queue_jobs: "Zařazovat úlohy do fronty v sidekiq, není-li nastaveno, jsou úlohy vyřizovány okamžitě"
|
queue_jobs: "Zařazovat úlohy do fronty v sidekiq, není-li nastaveno, jsou úlohy vyřizovány okamžitě"
|
||||||
crawl_images: "Povolit získávání obrázků z webů třetích stran"
|
crawl_images: "Povolit získávání obrázků z webů třetích stran"
|
||||||
ninja_edit_window: "Jak rychle smíte udělat změnu, aniž by se uložila jako nová verze"
|
ninja_edit_window: "Jak rychle smíte udělat změnu, aniž by se uložila jako nová verze"
|
||||||
|
|
|
@ -463,7 +463,6 @@ de:
|
||||||
company_full_name: "Voller Name des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
|
company_full_name: "Voller Name des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
|
||||||
company_short_name: "Kurzname des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
|
company_short_name: "Kurzname des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
|
||||||
company_domain: "Domainname des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
|
company_domain: "Domainname des Unternehmens, das diese Seite betreibt. Wird in rechtlich relevanten Dokumenten wie den Nutzungsbestimmungen (/tos) verwendet."
|
||||||
api_key: "Sicherer API-Schlüssel, um Themen zu erstellen und zu aktualisieren. Benutze /admin/api, um ihn einzurichten."
|
|
||||||
queue_jobs: "Benutze die Sidekiq-Queue, falls falsche Queues inline sind."
|
queue_jobs: "Benutze die Sidekiq-Queue, falls falsche Queues inline sind."
|
||||||
crawl_images: "Lade Bilder von Dritten herunter, um ihre Höhe und Breite zu bestimmen."
|
crawl_images: "Lade Bilder von Dritten herunter, um ihre Höhe und Breite zu bestimmen."
|
||||||
ninja_edit_window: "Sekunden nach Empfang eines Beitrag, in denen Bearbeitungen nicht als neue Version gelten."
|
ninja_edit_window: "Sekunden nach Empfang eines Beitrag, in denen Bearbeitungen nicht als neue Version gelten."
|
||||||
|
|
|
@ -495,7 +495,6 @@ en:
|
||||||
company_full_name: "The full name of the company that runs this site, used in legal documents like the /tos"
|
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_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"
|
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"
|
|
||||||
queue_jobs: "DEVELOPER ONLY! WARNING! By default, queue jobs in sidekiq. If disabled, your site will be broken."
|
queue_jobs: "DEVELOPER ONLY! WARNING! By default, queue jobs in sidekiq. If disabled, your site will be broken."
|
||||||
crawl_images: "Enable retrieving images from third party sources to insert width and height dimensions"
|
crawl_images: "Enable retrieving images from third party sources to insert width and height dimensions"
|
||||||
ninja_edit_window: "Number of seconds after posting where edits do not create a new version"
|
ninja_edit_window: "Number of seconds after posting where edits do not create a new version"
|
||||||
|
|
|
@ -460,7 +460,6 @@ fr:
|
||||||
company_full_name: "Le nom complet de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
|
company_full_name: "Le nom complet de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
|
||||||
company_short_name: "Le nom de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
|
company_short_name: "Le nom de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
|
||||||
company_domain: "Le nom de domaine de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
|
company_domain: "Le nom de domaine de la société qui gère ce site, utilisé dans les documents légaux, tels que /tos"
|
||||||
api_key: "La clé API sécurisé à utiliser pour créer et mettre à jour des discussions. Utilisez la section /admin/api pour la configurer."
|
|
||||||
queue_jobs: "DÉVELOPPEURS SEULEMENT ! ATTENTION ! Par défaut, empiler les travaux dans sidekiq. Si désactivé, votre site sera cassé."
|
queue_jobs: "DÉVELOPPEURS SEULEMENT ! ATTENTION ! Par défaut, empiler les travaux dans sidekiq. Si désactivé, votre site sera cassé."
|
||||||
crawl_images: "Permettre aux images provenant de sources tierces d'insérer la hauteur et la largeur de celles-ci"
|
crawl_images: "Permettre aux images provenant de sources tierces d'insérer la hauteur et la largeur de celles-ci"
|
||||||
ninja_edit_window: "Temps d'édition avant de sauvegarder une nouvelle version, en secondes."
|
ninja_edit_window: "Temps d'édition avant de sauvegarder une nouvelle version, en secondes."
|
||||||
|
|
|
@ -423,7 +423,6 @@ it:
|
||||||
company_full_name: "Il nome completo di chi gestisce il sito, usato in documenti legali come /tos"
|
company_full_name: "Il nome completo di chi gestisce il sito, usato in documenti legali come /tos"
|
||||||
company_short_name: "Il nome abbreviato di chi gestisce il sito, usato in documenti legali come /tos"
|
company_short_name: "Il nome abbreviato di chi gestisce il sito, usato in documenti legali come /tos"
|
||||||
company_domain: "Il dominio di chi gestisce il sito, usato in documenti legali come /tos"
|
company_domain: "Il dominio di chi gestisce il sito, usato in documenti legali come /tos"
|
||||||
api_key: "La chiave API segreta usata per creare e aggiornare topic, usa la sezione /admin/api per impostarla"
|
|
||||||
queue_jobs: "Metti in coda diversi job in sidekiq, se false le code sono inline"
|
queue_jobs: "Metti in coda diversi job in sidekiq, se false le code sono inline"
|
||||||
crawl_images: "Abilita la ricezione di immagini da sorgenti terze parti"
|
crawl_images: "Abilita la ricezione di immagini da sorgenti terze parti"
|
||||||
ninja_edit_window: "Numero di secondi trascorsi affinché una modifica del post appena inviato, non venga considerata come nuova revisione"
|
ninja_edit_window: "Numero di secondi trascorsi affinché una modifica del post appena inviato, non venga considerata come nuova revisione"
|
||||||
|
|
|
@ -417,7 +417,6 @@ ko:
|
||||||
company_full_name: "The full name of the company that runs this site, used in legal documents like the /tos"
|
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_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"
|
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"
|
access_password: "When restricted access is enabled, this password must be entered"
|
||||||
queue_jobs: "Queue various jobs in sidekiq, if false queues are inline"
|
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"
|
crawl_images: "Enable retrieving images from third party sources to insert width and height dimensions"
|
||||||
|
|
|
@ -465,7 +465,6 @@ nl:
|
||||||
company_full_name: "De volledige naam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
|
company_full_name: "De volledige naam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
|
||||||
company_short_name: "De korte naam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
|
company_short_name: "De korte naam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
|
||||||
company_domain: "De domeinnaam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
|
company_domain: "De domeinnaam van het bedrijf dat deze site draait. Wordt gebruikt in juridische delen van de site, zoals /tos"
|
||||||
api_key: "De beveiligde API-sleutel wordt gebruikt om topics te maken en bij te werken. Gebruik /admin/api om deze in te stellen"
|
|
||||||
queue_jobs: "DEVELOPERS ONLY! WARNING! Zet verschillende taken in een queue binnen sidekiq, bij 'false' worden taken ineens uitgevoerd"
|
queue_jobs: "DEVELOPERS ONLY! WARNING! Zet verschillende taken in een queue binnen sidekiq, bij 'false' worden taken ineens uitgevoerd"
|
||||||
crawl_images: Zet het ophalen van afbeeldingen van externe bronnen aan
|
crawl_images: Zet het ophalen van afbeeldingen van externe bronnen aan
|
||||||
ninja_edit_window: "Hoe snel je een aanpassing kan maken zonder dat er een nieuwe versie wordt opgeslagen, in seconden."
|
ninja_edit_window: "Hoe snel je een aanpassing kan maken zonder dat er een nieuwe versie wordt opgeslagen, in seconden."
|
||||||
|
|
|
@ -543,8 +543,6 @@ pseudo:
|
||||||
íɳ łéǧáł ďóčůɱéɳťš łíǩé ťĥé /ťóš ]]'
|
íɳ łéǧáł ďóčůɱéɳťš łíǩé ťĥé /ťóš ]]'
|
||||||
company_domain: '[[ Ťĥé ďóɱáíɳ ɳáɱé óŵɳéď ƀý ťĥé čóɱƿáɳý ťĥáť řůɳš ťĥíš šíťé,
|
company_domain: '[[ Ťĥé ďóɱáíɳ ɳáɱé óŵɳéď ƀý ťĥé čóɱƿáɳý ťĥáť řůɳš ťĥíš šíťé,
|
||||||
ůšéď íɳ łéǧáł ďóčůɱéɳťš łíǩé ťĥé /ťóš ]]'
|
ůšéď íɳ łéǧáł ďóčůɱéɳťš łíǩé ťĥé /ťóš ]]'
|
||||||
api_key: '[[ Ťĥé šéčůřé ÁРÍ ǩéý ůšéď ťó čřéáťé áɳď ůƿďáťé ťóƿíčš, ůšé ťĥé /áďɱíɳ/áƿí
|
|
||||||
šéčťíóɳ ťó šéť íť ůƿ ]]'
|
|
||||||
queue_jobs: '[[ Ƣůéůé νáříóůš ʲóƀš íɳ šíďéǩíƣ, íƒ ƒáłšé ƣůéůéš ářé íɳłíɳé ]]'
|
queue_jobs: '[[ Ƣůéůé νáříóůš ʲóƀš íɳ šíďéǩíƣ, íƒ ƒáłšé ƣůéůéš ářé íɳłíɳé ]]'
|
||||||
crawl_images: '[[ Éɳáƀłé řéťříéνíɳǧ íɱáǧéš ƒřóɱ ťĥířď ƿářťý šóůřčéš ťó íɳšéřť
|
crawl_images: '[[ Éɳáƀłé řéťříéνíɳǧ íɱáǧéš ƒřóɱ ťĥířď ƿářťý šóůřčéš ťó íɳšéřť
|
||||||
ŵíďťĥ áɳď ĥéíǧĥť ďíɱéɳšíóɳš ]]'
|
ŵíďťĥ áɳď ĥéíǧĥť ďíɱéɳšíóɳš ]]'
|
||||||
|
|
|
@ -489,7 +489,6 @@ pt_BR:
|
||||||
company_full_name: "Nome completo da companhia que mantém este site, usada nos documentos legais como o /tos"
|
company_full_name: "Nome completo da companhia que mantém este site, usada nos documentos legais como o /tos"
|
||||||
company_short_name: "Nome curto da companhia que mantém este site, usada nos documentos legais como o /tos"
|
company_short_name: "Nome curto da companhia que mantém este site, usada nos documentos legais como o /tos"
|
||||||
company_domain: "Nome de domínio pertencente a companhia que mantém este site, usada nos documentos legais como o /tos"
|
company_domain: "Nome de domínio pertencente a companhia que mantém este site, usada nos documentos legais como o /tos"
|
||||||
api_key: "A chave de API segura usada para criar e modificar tópicos, acesse a seção /admin/api para defini-lá"
|
|
||||||
queue_jobs: "APENAS DESENVOLVEDORES! ATENÇÃO! Por padrão, enfileira tarefas no sidekiq. Se desativado, seu site ficará defeituoso."
|
queue_jobs: "APENAS DESENVOLVEDORES! ATENÇÃO! Por padrão, enfileira tarefas no sidekiq. Se desativado, seu site ficará defeituoso."
|
||||||
crawl_images: "permitir mostrar imagens de sites terceiros"
|
crawl_images: "permitir mostrar imagens de sites terceiros"
|
||||||
ninja_edit_window: "quão rápido é possivél fazer uma alteração sem guardar uma nova versão, em segundos."
|
ninja_edit_window: "quão rápido é possivél fazer uma alteração sem guardar uma nova versão, em segundos."
|
||||||
|
|
|
@ -484,7 +484,6 @@ ru:
|
||||||
company_full_name: 'Полное название компании, которой принадлежит сайт, используется в правовой документации как /tos'
|
company_full_name: 'Полное название компании, которой принадлежит сайт, используется в правовой документации как /tos'
|
||||||
company_short_name: 'Короткое название компании, которой принадлежит сайт, используется в правовой документации как /tos'
|
company_short_name: 'Короткое название компании, которой принадлежит сайт, используется в правовой документации как /tos'
|
||||||
company_domain: 'Имя домена, принадлежащего компании, заведующей сайтом, используется в правовой документации как /tos'
|
company_domain: 'Имя домена, принадлежащего компании, заведующей сайтом, используется в правовой документации как /tos'
|
||||||
api_key: 'Секретный API ключ, используемый для создания и обновления тем. Зайдите в секцию /admin/api , чтобы его задать'
|
|
||||||
queue_jobs: 'ТОЛЬКО ДЛЯ РАЗРАБОТЧИКОВ! ВНИМАНИЕ! По умолчанию задачи обрабатываются асинхронно в очереди sidekiq. Если настройка выключена, ваш сайт может не работать.'
|
queue_jobs: 'ТОЛЬКО ДЛЯ РАЗРАБОТЧИКОВ! ВНИМАНИЕ! По умолчанию задачи обрабатываются асинхронно в очереди sidekiq. Если настройка выключена, ваш сайт может не работать.'
|
||||||
crawl_images: 'Разрешить извлечение изображений из сторонних источников, ширина и высота'
|
crawl_images: 'Разрешить извлечение изображений из сторонних источников, ширина и высота'
|
||||||
ninja_edit_window: 'Количество секунд после размещения сообщения, в течение которых внесение правок в сообщение не повлечет его изменение'
|
ninja_edit_window: 'Количество секунд после размещения сообщения, в течение которых внесение правок в сообщение не повлечет его изменение'
|
||||||
|
|
|
@ -346,7 +346,6 @@ sv:
|
||||||
company_full_name: "Det fullständiga namnet för företaget som driver denna webbplats, används i juridiska dokument så som /tos"
|
company_full_name: "Det fullständiga namnet för företaget som driver denna webbplats, används i juridiska dokument så som /tos"
|
||||||
company_short_name: "Det korta namnet för företaget som driver denna webbplats, används i juridiska dokument så som /tos"
|
company_short_name: "Det korta namnet för företaget som driver denna webbplats, används i juridiska dokument så som /tos"
|
||||||
company_domain: "Domännamnet som ägs av företaget som driver denna webbplats, används i juridiska dokument så som /tos"
|
company_domain: "Domännamnet som ägs av företaget som driver denna webbplats, används i juridiska dokument så som /tos"
|
||||||
api_key: "Den säkra API-nyckeln som används för att skapa och uppdatera trådar, använd /admin/api för att skapa en"
|
|
||||||
queue_jobs: "Köa diverse jobb i sidekiq, om urkryssat så körs köer infogat"
|
queue_jobs: "Köa diverse jobb i sidekiq, om urkryssat så körs köer infogat"
|
||||||
crawl_images: "Aktivera hämtning av bilder från tredjepartskällor för att infoga bredd och höjd"
|
crawl_images: "Aktivera hämtning av bilder från tredjepartskällor för att infoga bredd och höjd"
|
||||||
ninja_edit_window: "Antal sekunder efter ett inlägg när en ändring inte skapar en ny version"
|
ninja_edit_window: "Antal sekunder efter ett inlägg när en ändring inte skapar en ny version"
|
||||||
|
|
|
@ -119,8 +119,8 @@ zh_CN:
|
||||||
replace_paragraph: "[用一段简短的分类描述替换此第一段内容,请不要超过200个字符。]"
|
replace_paragraph: "[用一段简短的分类描述替换此第一段内容,请不要超过200个字符。]"
|
||||||
post_template: "%{replace_paragraph}\n\n使用下面的空间输入分类的详细描述信息,可包括在此分类下讨论的规则、内容导向等等。"
|
post_template: "%{replace_paragraph}\n\n使用下面的空间输入分类的详细描述信息,可包括在此分类下讨论的规则、内容导向等等。"
|
||||||
this_year: "今年"
|
this_year: "今年"
|
||||||
|
|
||||||
|
|
||||||
trust_levels:
|
trust_levels:
|
||||||
newuser:
|
newuser:
|
||||||
title: "访客"
|
title: "访客"
|
||||||
|
@ -442,7 +442,6 @@ zh_CN:
|
||||||
company_full_name: "运行本站点的公司全称,用于法律文档,例如服务条款 /tos"
|
company_full_name: "运行本站点的公司全称,用于法律文档,例如服务条款 /tos"
|
||||||
company_short_name: "运行本站点的公司短名,用于法律文档,例如服务条款 /tos"
|
company_short_name: "运行本站点的公司短名,用于法律文档,例如服务条款 /tos"
|
||||||
company_domain: "运行本站点的公司域名,用于法律文档,例如服务条款 /tos"
|
company_domain: "运行本站点的公司域名,用于法律文档,例如服务条款 /tos"
|
||||||
api_key: "加密的应用开发接口密钥(API key),用于创建和更新主题。使用 /admin/api 来对它进行设置。"
|
|
||||||
queue_jobs: "如果失败队列在排队,使用 Sidekiq 消息引擎对不同的工作排队"
|
queue_jobs: "如果失败队列在排队,使用 Sidekiq 消息引擎对不同的工作排队"
|
||||||
crawl_images: "允许从第三方获取图片来插入宽、高数值"
|
crawl_images: "允许从第三方获取图片来插入宽、高数值"
|
||||||
ninja_edit_window: "在多少秒钟之内,对帖子的多次编辑不生成新版本"
|
ninja_edit_window: "在多少秒钟之内,对帖子的多次编辑不生成新版本"
|
||||||
|
|
|
@ -423,7 +423,6 @@ zh_TW:
|
||||||
company_full_name: "運行本站點的公司全稱,用于法律文檔,例如服務條款 /tos"
|
company_full_name: "運行本站點的公司全稱,用于法律文檔,例如服務條款 /tos"
|
||||||
company_short_name: "運行本站點的公司短名,用于法律文檔,例如服務條款 /tos"
|
company_short_name: "運行本站點的公司短名,用于法律文檔,例如服務條款 /tos"
|
||||||
company_domain: "運行本站點的公司域名,用于法律文檔,例如服務條款 /tos"
|
company_domain: "運行本站點的公司域名,用于法律文檔,例如服務條款 /tos"
|
||||||
api_key: "加密的應用開發接口密鑰(API key),用于創建和更新主題。使用 /admin/api 來對它進行設置。"
|
|
||||||
queue_jobs: "如果失敗隊列在排隊,使用 Sidekiq 消息引擎對不同的工作排隊"
|
queue_jobs: "如果失敗隊列在排隊,使用 Sidekiq 消息引擎對不同的工作排隊"
|
||||||
crawl_images: "允許從第三方獲取圖片來插入寬、高數值"
|
crawl_images: "允許從第三方獲取圖片來插入寬、高數值"
|
||||||
ninja_edit_window: "在多少秒鍾之內,對帖子的多次編輯不生成新版本"
|
ninja_edit_window: "在多少秒鍾之內,對帖子的多次編輯不生成新版本"
|
||||||
|
|
|
@ -43,6 +43,8 @@ Discourse::Application.routes.draw do
|
||||||
put 'unban'
|
put 'unban'
|
||||||
put 'revoke_admin', constraints: AdminConstraint.new
|
put 'revoke_admin', constraints: AdminConstraint.new
|
||||||
put 'grant_admin', constraints: AdminConstraint.new
|
put 'grant_admin', constraints: AdminConstraint.new
|
||||||
|
post 'generate_api_key', constraints: AdminConstraint.new
|
||||||
|
delete 'revoke_api_key', constraints: AdminConstraint.new
|
||||||
put 'revoke_moderation', constraints: AdminConstraint.new
|
put 'revoke_moderation', constraints: AdminConstraint.new
|
||||||
put 'grant_moderation', constraints: AdminConstraint.new
|
put 'grant_moderation', constraints: AdminConstraint.new
|
||||||
put 'approve'
|
put 'approve'
|
||||||
|
@ -89,7 +91,9 @@ Discourse::Application.routes.draw do
|
||||||
end
|
end
|
||||||
resources :api, only: [:index], constraints: AdminConstraint.new do
|
resources :api, only: [:index], constraints: AdminConstraint.new do
|
||||||
collection do
|
collection do
|
||||||
post 'generate_key'
|
post 'key' => 'api#create_master_key'
|
||||||
|
put 'key' => 'api#regenerate_key'
|
||||||
|
delete 'key' => 'api#revoke_key'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
16
db/migrate/20131022151218_create_api_keys.rb
Normal file
16
db/migrate/20131022151218_create_api_keys.rb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
class CreateApiKeys < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
create_table :api_keys do |t|
|
||||||
|
t.string :key, limit: 64, null: false
|
||||||
|
t.integer :user_id, null: true
|
||||||
|
t.integer :created_by_id
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :api_keys, :key
|
||||||
|
add_index :api_keys, :user_id, unique: true
|
||||||
|
|
||||||
|
execute "INSERT INTO api_keys (key, created_at, updated_at) SELECT value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM site_settings WHERE name = 'api_key'"
|
||||||
|
execute "DELETE FROM site_settings WHERE name = 'api_key'"
|
||||||
|
end
|
||||||
|
end
|
|
@ -38,12 +38,17 @@ class Auth::DefaultCurrentUserProvider
|
||||||
|
|
||||||
# possible we have an api call, impersonate
|
# possible we have an api call, impersonate
|
||||||
unless current_user
|
unless current_user
|
||||||
if api_key = request["api_key"]
|
if api_key_value = request["api_key"]
|
||||||
if api_username = request["api_username"]
|
api_key = ApiKey.where(key: api_key_value).includes(:user).first
|
||||||
if SiteSetting.api_key_valid?(api_key)
|
if api_key.present?
|
||||||
@env[API_KEY] = true
|
@env[API_KEY] = true
|
||||||
|
|
||||||
|
if api_key.user.present?
|
||||||
|
current_user = api_key.user
|
||||||
|
elsif api_username = request["api_username"]
|
||||||
current_user = User.where(username_lower: api_username.downcase).first
|
current_user = User.where(username_lower: api_username.downcase).first
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
desc "generate api key if missing, return existing if already there"
|
desc "generate api key if missing, return existing if already there"
|
||||||
task "api_key:get" => :environment do
|
task "api_key:get" => :environment do
|
||||||
if SiteSetting.api_key.blank?
|
api_key = ApiKey.create_master_key
|
||||||
SiteSetting.generate_api_key!
|
|
||||||
end
|
|
||||||
|
|
||||||
puts SiteSetting.api_key
|
puts api_key.key
|
||||||
end
|
end
|
||||||
|
|
56
spec/controllers/admin/api_controller_spec.rb
Normal file
56
spec/controllers/admin/api_controller_spec.rb
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Admin::ApiController do
|
||||||
|
|
||||||
|
it "is a subclass of AdminController" do
|
||||||
|
(Admin::ApiController < Admin::AdminController).should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:user) { log_in(:admin) }
|
||||||
|
|
||||||
|
context '.index' do
|
||||||
|
it "succeeds" do
|
||||||
|
xhr :get, :index
|
||||||
|
response.should be_success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context '.regenerate_key' do
|
||||||
|
let(:api_key) { Fabricate(:api_key) }
|
||||||
|
|
||||||
|
it "returns 404 when there is no key" do
|
||||||
|
xhr :put, :regenerate_key, id: 1234
|
||||||
|
response.should_not be_success
|
||||||
|
response.status.should == 404
|
||||||
|
end
|
||||||
|
|
||||||
|
it "delegates to the api key's `regenerate!` method" do
|
||||||
|
ApiKey.any_instance.expects(:regenerate!)
|
||||||
|
xhr :put, :regenerate_key, id: api_key.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context '.revoke_key' do
|
||||||
|
let(:api_key) { Fabricate(:api_key) }
|
||||||
|
|
||||||
|
it "returns 404 when there is no key" do
|
||||||
|
xhr :delete, :revoke_key, id: 1234
|
||||||
|
response.should_not be_success
|
||||||
|
response.status.should == 404
|
||||||
|
end
|
||||||
|
|
||||||
|
it "delegates to the api key's `regenerate!` method" do
|
||||||
|
ApiKey.any_instance.expects(:destroy)
|
||||||
|
xhr :delete, :revoke_key, id: api_key.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context '.create_master_key' do
|
||||||
|
it "creates a record" do
|
||||||
|
lambda {
|
||||||
|
xhr :post, :create_master_key
|
||||||
|
}.should change(ApiKey, :count).by(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -62,6 +62,26 @@ describe Admin::UsersController do
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context '.generate_api_key' do
|
||||||
|
let(:evil_trout) { Fabricate(:evil_trout) }
|
||||||
|
|
||||||
|
it 'calls generate_api_key' do
|
||||||
|
User.any_instance.expects(:generate_api_key).with(@user)
|
||||||
|
xhr :post, :generate_api_key, user_id: evil_trout.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context '.revoke_api_key' do
|
||||||
|
|
||||||
|
let(:evil_trout) { Fabricate(:evil_trout) }
|
||||||
|
|
||||||
|
it 'calls revoke_api_key' do
|
||||||
|
User.any_instance.expects(:revoke_api_key)
|
||||||
|
xhr :delete, :revoke_api_key, user_id: evil_trout.id
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
context '.approve' do
|
context '.approve' do
|
||||||
|
|
||||||
let(:evil_trout) { Fabricate(:evil_trout) }
|
let(:evil_trout) { Fabricate(:evil_trout) }
|
||||||
|
|
|
@ -15,10 +15,18 @@ describe 'api' do
|
||||||
Fabricate(:post)
|
Fabricate(:post)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
let(:api_key) { user.generate_api_key(user) }
|
||||||
|
let(:master_key) { ApiKey.create_master_key }
|
||||||
|
|
||||||
# choosing an arbitrarily easy to mock trusted activity
|
# choosing an arbitrarily easy to mock trusted activity
|
||||||
it 'allows users with api key to bookmark posts' do
|
it 'allows users with api key to bookmark posts' do
|
||||||
PostAction.expects(:act).with(user, post, PostActionType.types[:bookmark]).once
|
PostAction.expects(:act).with(user, post, PostActionType.types[:bookmark]).once
|
||||||
put :bookmark, bookmarked: "true", post_id: post.id, api_key: SiteSetting.api_key, api_username: user.username, format: :json
|
put :bookmark, bookmarked: "true", post_id: post.id, api_key: api_key.key, format: :json
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows users with a master api key to bookmark posts' do
|
||||||
|
PostAction.expects(:act).with(user, post, PostActionType.types[:bookmark]).once
|
||||||
|
put :bookmark, bookmarked: "true", post_id: post.id, api_key: master_key.key, api_username: user.username, format: :json
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'disallows phonies to bookmark posts' do
|
it 'disallows phonies to bookmark posts' do
|
||||||
|
|
3
spec/fabricators/api_key_fabricator.rb
Normal file
3
spec/fabricators/api_key_fabricator.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Fabricator(:api_key) do
|
||||||
|
key '1dfb7d427400cb8ef18052fd412781af134cceca5725dd74f34bbc6b9e35ddc9'
|
||||||
|
end
|
16
spec/models/api_key_spec.rb
Normal file
16
spec/models/api_key_spec.rb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# encoding: utf-8
|
||||||
|
require 'spec_helper'
|
||||||
|
require_dependency 'api_key'
|
||||||
|
|
||||||
|
describe ApiKey do
|
||||||
|
it { should belong_to :user }
|
||||||
|
it { should belong_to :created_by }
|
||||||
|
|
||||||
|
it { should validate_presence_of :key }
|
||||||
|
|
||||||
|
it 'validates uniqueness of user_id' do
|
||||||
|
Fabricate(:api_key)
|
||||||
|
should validate_uniqueness_of(:user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -863,4 +863,55 @@ describe User do
|
||||||
expect(user.update_avatar(upload)).to be_true
|
expect(user.update_avatar(upload)).to be_true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'api keys' do
|
||||||
|
let(:admin) { Fabricate(:admin) }
|
||||||
|
let(:other_admin) { Fabricate(:admin) }
|
||||||
|
let(:user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
describe '.generate_api_key' do
|
||||||
|
|
||||||
|
it "generates an api key when none exists, and regenerates when it does" do
|
||||||
|
expect(user.api_key).to be_blank
|
||||||
|
|
||||||
|
# Generate a key
|
||||||
|
api_key = user.generate_api_key(admin)
|
||||||
|
expect(api_key.user).to eq(user)
|
||||||
|
expect(api_key.key).to be_present
|
||||||
|
expect(api_key.created_by).to eq(admin)
|
||||||
|
|
||||||
|
user.reload
|
||||||
|
expect(user.api_key).to eq(api_key)
|
||||||
|
|
||||||
|
# Regenerate a key. Keeps the same record, updates the key
|
||||||
|
new_key = user.generate_api_key(other_admin)
|
||||||
|
expect(new_key.id).to eq(api_key.id)
|
||||||
|
expect(new_key.key).to_not eq(api_key.key)
|
||||||
|
expect(new_key.created_by).to eq(other_admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.revoke_api_key' do
|
||||||
|
|
||||||
|
it "revokes an api key when exists" do
|
||||||
|
expect(user.api_key).to be_blank
|
||||||
|
|
||||||
|
# Revoke nothing does nothing
|
||||||
|
user.revoke_api_key
|
||||||
|
user.reload
|
||||||
|
expect(user.api_key).to be_blank
|
||||||
|
|
||||||
|
# When a key is present it is removed
|
||||||
|
user.generate_api_key(admin)
|
||||||
|
user.reload
|
||||||
|
user.revoke_api_key
|
||||||
|
user.reload
|
||||||
|
expect(user.api_key).to be_blank
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
9
test/fixtures/api_keys.yml
vendored
Normal file
9
test/fixtures/api_keys.yml
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
key: MyString
|
||||||
|
user_id: 1
|
||||||
|
|
||||||
|
two:
|
||||||
|
key: MyString
|
||||||
|
user_id: 1
|
30
test/javascripts/admin/models/admin_user_test.js
Normal file
30
test/javascripts/admin/models/admin_user_test.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
module("Discourse.AdminUser");
|
||||||
|
|
||||||
|
|
||||||
|
asyncTestDiscourse('generate key', function() {
|
||||||
|
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve({api_key: {id: 1234, key: 'asdfasdf'}}));
|
||||||
|
|
||||||
|
var adminUser = Discourse.AdminUser.create({id: 333});
|
||||||
|
|
||||||
|
blank(adminUser.get('api_key'), 'it has no api key by default');
|
||||||
|
adminUser.generateApiKey().then(function() {
|
||||||
|
start();
|
||||||
|
ok(Discourse.ajax.calledWith("/admin/users/333/generate_api_key", { type: 'POST' }), "it POSTed to the url");
|
||||||
|
present(adminUser.get('api_key'), 'it has an api_key now');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
asyncTestDiscourse('revoke key', function() {
|
||||||
|
|
||||||
|
var apiKey = Discourse.ApiKey.create({id: 1234, key: 'asdfasdf'}),
|
||||||
|
adminUser = Discourse.AdminUser.create({id: 333, api_key: apiKey});
|
||||||
|
|
||||||
|
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve());
|
||||||
|
|
||||||
|
equal(adminUser.get('api_key'), apiKey, 'it has the api key in the beginning');
|
||||||
|
adminUser.revokeApiKey().then(function() {
|
||||||
|
start();
|
||||||
|
ok(Discourse.ajax.calledWith("/admin/users/333/revoke_api_key", { type: 'DELETE' }), "it DELETEd to the url");
|
||||||
|
blank(adminUser.get('api_key'), 'it cleared the api_key');
|
||||||
|
});
|
||||||
|
});
|
45
test/javascripts/admin/models/api_key_test.js
Normal file
45
test/javascripts/admin/models/api_key_test.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
module("Discourse.ApiKey");
|
||||||
|
|
||||||
|
test('create', function() {
|
||||||
|
var apiKey = Discourse.ApiKey.create({id: 123, user: {id: 345}});
|
||||||
|
|
||||||
|
present(apiKey, 'it creates the api key');
|
||||||
|
present(apiKey.get('user'), 'it creates the user inside');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
asyncTestDiscourse('find', function() {
|
||||||
|
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve([]));
|
||||||
|
Discourse.ApiKey.find().then(function() {
|
||||||
|
start();
|
||||||
|
ok(Discourse.ajax.calledWith("/admin/api"), "it GETs the keys");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
asyncTestDiscourse('generateMasterKey', function() {
|
||||||
|
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve([]));
|
||||||
|
Discourse.ApiKey.generateMasterKey().then(function() {
|
||||||
|
start();
|
||||||
|
ok(Discourse.ajax.calledWith("/admin/api/key", {type: 'POST'}), "it POSTs to create a master key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
asyncTestDiscourse('regenerate', function() {
|
||||||
|
var apiKey = Discourse.ApiKey.create({id: 3456});
|
||||||
|
|
||||||
|
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve({api_key: {id: 3456}}));
|
||||||
|
apiKey.regenerate().then(function() {
|
||||||
|
start();
|
||||||
|
ok(Discourse.ajax.calledWith("/admin/api/key", {type: 'PUT', data: {id: 3456}}), "it PUTs the key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
asyncTestDiscourse('revoke', function() {
|
||||||
|
var apiKey = Discourse.ApiKey.create({id: 3456});
|
||||||
|
|
||||||
|
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve([]));
|
||||||
|
apiKey.revoke().then(function() {
|
||||||
|
start();
|
||||||
|
ok(Discourse.ajax.calledWith("/admin/api/key", {type: 'DELETE', data: {id: 3456}}), "it DELETES the key");
|
||||||
|
});
|
||||||
|
});
|
7
test/unit/api_key_test.rb
Normal file
7
test/unit/api_key_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require 'test_helper'
|
||||||
|
|
||||||
|
class ApiKeyTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Reference in a new issue