catching up with the master

Merge remote-tracking branch 'upstream/master'
This commit is contained in:
Kris Aubuchon 2013-10-28 12:26:12 -04:00
commit cafc1a088d
130 changed files with 2564 additions and 877 deletions

View File

@ -0,0 +1,64 @@
/**
A form to create an IP address that will be blocked or whitelisted.
Example usage:
{{screened-ip-address-form action="recordAdded"}}
where action is a callback on the controller or route that will get called after
the new record is successfully saved. It is called with the new ScreenedIpAddress record
as an argument.
@class ScreenedIpAddressFormComponent
@extends Ember.Component
@namespace Discourse
@module Discourse
**/
Discourse.ScreenedIpAddressFormComponent = Ember.Component.extend({
classNames: ['screened-ip-address-form'],
formSubmitted: false,
actionName: 'block',
actionNames: function() {
return [
{id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')},
{id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')}
];
}.property(),
actions: {
submit: function() {
if (!this.get('formSubmitted')) {
var self = this;
this.set('formSubmitted', true);
var screenedIpAddress = Discourse.ScreenedIpAddress.create({ip_address: this.get('ip_address'), action_name: this.get('actionName')});
screenedIpAddress.save().then(function(result) {
self.set('ip_address', '');
self.set('formSubmitted', false);
self.sendAction('action', Discourse.ScreenedIpAddress.create(result.screened_ip_address));
Em.run.schedule('afterRender', function() { self.$('.ip-address-input').focus(); });
}, function(e) {
self.set('formSubmitted', false);
var msg;
if (e.responseJSON && e.responseJSON.errors) {
msg = I18n.t("generic_error_with_reason", {error: e.responseJSON.errors.join('. ')});
} else {
msg = I18n.t("generic_error");
}
bootbox.alert(msg, function() { self.$('.ip-address-input').focus(); });
});
}
}
},
didInsertElement: function(e) {
var self = this;
this._super();
Em.run.schedule('afterRender', function() {
self.$('.ip-address-input').keydown(function(e) {
if (e.keyCode === 13) { // enter key
self.send('submit');
}
});
});
}
});

View File

@ -18,6 +18,12 @@ Discourse.AdminLogsScreenedIpAddressesController = Ember.ArrayController.extend(
self.set('content', result);
self.set('loading', false);
});
},
actions: {
recordAdded: function(arg) {
this.get("content").unshiftObject(arg);
}
}
});
@ -27,12 +33,12 @@ Discourse.AdminLogsScreenedIpAddressController = Ember.ObjectController.extend({
actions: {
allow: function(record) {
record.set('action', 'do_nothing');
record.set('action_name', 'do_nothing');
this.send('save', record);
},
block: function(record) {
record.set('action', 'block');
record.set('action_name', 'block');
this.send('save', record);
},

View File

@ -9,20 +9,20 @@
**/
Discourse.ScreenedIpAddress = Discourse.Model.extend({
actionName: function() {
return I18n.t("admin.logs.screened_ips.actions." + this.get('action'));
}.property('action'),
return I18n.t("admin.logs.screened_ips.actions." + this.get('action_name'));
}.property('action_name'),
isBlocked: function() {
return (this.get('action') === 'block');
}.property('action'),
return (this.get('action_name') === 'block');
}.property('action_name'),
actionIcon: function() {
if (this.get('action') === 'block') {
if (this.get('action_name') === 'block') {
return this.get('blockIcon');
} else {
return this.get('doNothingIcon');
}
}.property('action'),
}.property('action_name'),
blockIcon: function() {
return 'icon-ban-circle';
@ -33,9 +33,9 @@ Discourse.ScreenedIpAddress = Discourse.Model.extend({
}.property(),
save: function() {
return Discourse.ajax("/admin/logs/screened_ip_addresses/" + this.get('id') + ".json", {
type: 'PUT',
data: {ip_address: this.get('ip_address'), action_name: this.get('action')}
return Discourse.ajax("/admin/logs/screened_ip_addresses" + (this.id ? '/' + this.id : '') + ".json", {
type: this.id ? 'PUT' : 'POST',
data: {ip_address: this.get('ip_address'), action_name: this.get('action_name')}
});
},

View File

@ -1,5 +1,8 @@
<p>{{i18n admin.logs.screened_ips.description}}</p>
{{screened-ip-address-form action="recordAdded"}}
<br/>
{{#if loading}}
<div class='admin-loading'>{{i18n loading}}</div>
{{else}}

View File

@ -7,7 +7,7 @@
//= require ./env
// probe framework first
//= require ./discourse/components/probes.js
//= require ./discourse/lib/probes.js
// Externals we need to load first

View File

@ -1,5 +1,22 @@
Discourse.DiscourseBreadcrumbsComponent = Ember.Component.extend({
classNames: ['category-breadcrumb'],
tagName: 'ol',
parentCategory: Em.computed.alias('category.parentCategory')
parentCategory: Em.computed.alias('category.parentCategory'),
parentCategories: Em.computed.filter('categories', function(c) {
return !c.get('parentCategory');
}),
targetCategory: function() {
// Note we can't use Em.computed.or here because it returns a boolean not the object
return this.get('parentCategory') || this.get('category');
}.property('parentCategory', 'category'),
childCategories: function() {
var self = this;
return this.get('categories').filter(function (c) {
return c.get('parentCategory') === self.get('targetCategory');
});
}.property('targetCategory')
});

View File

@ -0,0 +1,54 @@
Discourse.DiscourseCategorydropComponent = Ember.Component.extend({
classNameBindings: ['category::no-category', 'categories:has-drop'],
tagName: 'li',
iconClass: function() {
if (this.get('expanded')) { return "icon icon-caret-down"; }
return "icon icon-caret-right";
}.property('expanded'),
badgeStyle: function() {
var category = this.get('category');
if (category) {
return Discourse.HTML.categoryStyle(category);
} else {
return "background-color: #eee; color: #333";
}
}.property('category'),
actions: {
expand: function() {
if (this.get('expanded')) {
this.close();
return;
}
if (this.get('categories')) {
this.set('expanded', true);
}
var self = this,
$dropdown = this.$()[0];
$('html').on('click.category-drop', function(e) {
var $target = $(e.target),
closest = $target.closest($dropdown);
return ($(e.currentTarget).hasClass('badge-category') || (closest.length && closest[0] === $dropdown)) ? true : self.close();
});
}
},
categoryChanged: function() {
this.close();
}.observes('category', 'parentCategory'),
close: function() {
$('html').off('click.category-drop');
this.set('expanded', false);
},
willDestroyElement: function() {
$('html').off('click.category-drop');
}
});

View File

@ -13,6 +13,12 @@ Discourse.EditCategoryController = Discourse.ObjectController.extend(Discourse.M
settingsSelected: Ember.computed.equal('selectedTab', 'settings'),
foregroundColors: ['FFFFFF', '000000'],
parentCategories: function() {
return Discourse.Category.list().filter(function (c) {
return !c.get('parentCategory');
});
}.property(),
onShow: function() {
this.changeSize();
this.titleChanged();
@ -122,17 +128,27 @@ Discourse.EditCategoryController = Discourse.ObjectController.extend(Discourse.M
},
saveCategory: function() {
var categoryController = this;
var self = this,
model = this.get('model'),
parentCategory = Discourse.Category.list().findBy('id', parseInt(model.get('parent_category_id'), 10));
this.set('saving', true);
model.set('parentCategory', parentCategory);
var newSlug = Discourse.Category.slugFor(this.get('model'));
this.get('model').save().then(function(result) {
// success
categoryController.send('closeModal');
Discourse.URL.redirectTo("/category/" + Discourse.Category.slugFor(result.category));
}, function(errors) {
// errors
if(errors.length === 0) errors.push(I18n.t("category.creation_error"));
categoryController.displayErrors(errors);
categoryController.set('saving', false);
self.send('closeModal');
Discourse.URL.redirectTo("/category/" + newSlug);
}, function(error) {
if (error && error.responseText) {
self.flash($.parseJSON(error.responseText).errors[0]);
} else {
self.flash(I18n.t('generic_error'));
}
self.set('saving', false);
});
},
@ -147,8 +163,14 @@ Discourse.EditCategoryController = Discourse.ObjectController.extend(Discourse.M
// success
self.send('closeModal');
Discourse.URL.redirectTo("/categories");
}, function(jqXHR){
// error
}, function(error){
if (error && error.responseText) {
self.flash($.parseJSON(error.responseText).errors[0]);
} else {
self.flash(I18n.t('generic_error'));
}
self.send('showModal');
self.displayErrors([I18n.t("category.delete_error")]);
self.set('deleting', false);

View File

@ -7,32 +7,34 @@
@module Discourse
**/
Discourse.ListController = Discourse.Controller.extend({
categoryBinding: 'topicList.category',
categoryBinding: "topicList.category",
canCreateCategory: false,
canCreateTopic: false,
needs: ['composer', 'modal', 'listTopics'],
needs: ["composer", "modal", "listTopics"],
availableNavItems: function() {
var loggedOn = !!Discourse.User.current();
var category = this.get("category");
return Discourse.SiteSettings.top_menu.split("|").map(function(i) {
return Discourse.NavItem.fromText(i, {
loggedOn: loggedOn
loggedOn: loggedOn,
category: category
});
}).filter(function(i) {
return i !== null;
return i !== null && !(category && i.get("name").indexOf("categor") === 0);
});
}.property(),
}.property("category"),
createTopicText: function() {
if (this.get('category.name')) {
if (this.get("category.name")) {
return I18n.t("topic.create_in", {
categoryName: this.get('category.name')
categoryName: this.get("category.name")
});
} else {
return I18n.t("topic.create");
}
}.property('category.name'),
}.property("category.name"),
/**
Refresh our current topic list
@ -132,7 +134,11 @@ Discourse.ListController = Discourse.Controller.extend({
} else {
return false;
}
}.property('category')
}.property('category'),
categories: function() {
return Discourse.Category.list();
}.property()
});

View File

@ -7,8 +7,15 @@
@module Discourse
**/
Discourse.StaticController = Discourse.Controller.extend({
needs: ['header'],
path: null,
showLoginButton: function() {
return this.get('path') === '/login';
}.property('path'),
loadPath: function(path) {
this.set('path', path);
var staticController = this;
this.set('content', null);

View File

@ -29,12 +29,32 @@ Handlebars.registerHelper('shorten', function(property, options) {
@for Handlebars
**/
Handlebars.registerHelper('topicLink', function(property, options) {
var title, topic;
topic = Ember.Handlebars.get(this, property, options);
title = topic.get('fancy_title') || topic.get('title');
return "<a href='" + (topic.get('lastUnreadUrl')) + "' class='title'>" + title + "</a>";
var topic = Ember.Handlebars.get(this, property, options),
title = topic.get('fancy_title') || topic.get('title');
return "<a href='" + topic.get('lastUnreadUrl') + "' class='title'>" + title + "</a>";
});
/**
Produces a link to a category given a category object and helper options
@method categoryLinkHTML
@param {Discourse.Category} category to link to
@param {Object} options standard from handlebars
**/
function categoryLinkHTML(category, options) {
var categoryOptions = {};
if (options.hash) {
if (options.hash.allowUncategorized) {
categoryOptions.allowUncategorized = true;
}
if (options.hash.categories) {
categoryOptions.categories = Em.Handlebars.get(this, options.hash.categories, options);
}
}
return new Handlebars.SafeString(Discourse.HTML.categoryLink(category, categoryOptions));
}
/**
Produces a link to a category
@ -42,21 +62,16 @@ Handlebars.registerHelper('topicLink', function(property, options) {
@for Handlebars
**/
Handlebars.registerHelper('categoryLink', function(property, options) {
var allowUncategorized = options.hash && options.hash.allowUncategorized;
var category = Ember.Handlebars.get(this, property, options);
return new Handlebars.SafeString(Discourse.Utilities.categoryLink(category, allowUncategorized));
return categoryLinkHTML(Ember.Handlebars.get(this, property, options), options);
});
/**
Produces a bound link to a category
@method boundCategoryLink
@for Handlebars
**/
Ember.Handlebars.registerBoundHelper('boundCategoryLink', function(category) {
return new Handlebars.SafeString(Discourse.Utilities.categoryLink(category));
});
Ember.Handlebars.registerBoundHelper('boundCategoryLink', categoryLinkHTML);
/**
Produces a link to a route with support for i18n on the title

View File

@ -0,0 +1,60 @@
/**
Helpers to build HTML strings such as rich links to categories and topics.
@class HTML
@namespace Discourse
@module Discourse
**/
Discourse.HTML = {
/**
Returns the CSS styles for a category
@method categoryStyle
@param {Discourse.Category} category the category whose link we want
**/
categoryStyle: function(category) {
var color = Em.get(category, 'color'),
textColor = Em.get(category, 'text_color');
if (!color && !textColor) { return; }
// Add the custom style if we need to
var style = "";
if (color) { style += "background-color: #" + color + "; "; }
if (textColor) { style += "color: #" + textColor + "; "; }
return style;
},
/**
Create a badge-like category link
@method categoryLink
@param {Discourse.Category} category the category whose link we want
@param {Object} opts The options for the category link
@param {Boolean} opts.allowUncategorized Whether we allow rendering of the uncategorized category
@returns {String} the html category badge
**/
categoryLink: function(category, opts) {
opts = opts || {};
if ((!category) ||
(!opts.allowUncategorized && Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id"))) return "";
var name = Em.get(category, 'name'),
description = Em.get(category, 'description'),
html = "<a href=\"" + Discourse.getURL("/category/") + Discourse.Category.slugFor(category) + "\" class=\"badge-category\" ";
// Add description if we have it
if (description) html += "title=\"" + Handlebars.Utils.escapeExpression(description) + "\" ";
var categoryStyle = Discourse.HTML.categoryStyle(category);
if (categoryStyle) {
html += "style=\"" + categoryStyle + "\" ";
}
html += ">" + name + "</a>";
return html;
}
};

View File

@ -33,29 +33,6 @@ Discourse.Utilities = {
}
},
/**
Create a badge-like category link
@method categoryLink
@param {Discourse.Category} category the category whose link we want
@returns {String} the html category badge
**/
categoryLink: function(category, allowUncategorized) {
if (!category) return "";
if (!allowUncategorized && Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) return "";
var color = Em.get(category, 'color'),
textColor = Em.get(category, 'text_color'),
name = Em.get(category, 'name'),
description = Em.get(category, 'description'),
html = "<a href=\"" + Discourse.getURL("/category/") + Discourse.Category.slugFor(category) + "\" class=\"badge-category\" ";
// Add description if we have it
if (description) html += "title=\"" + Handlebars.Utils.escapeExpression(description) + "\" ";
return html + "style=\"background-color: #" + color + "; color: #" + textColor + ";\">" + name + "</a>";
},
avatarUrl: function(template, size) {
if (!template) { return ""; }
var rawSize = Discourse.Utilities.getRawSize(Discourse.Utilities.translateSize(size));

View File

@ -34,6 +34,13 @@ Discourse.Category = Discourse.Model.extend({
return Discourse.getURL("/category/") + (this.get('slug'));
}.property('name'),
unreadUrl: function() {
return this.get('url') + '/unread';
}.property('url'),
newUrl: function() {
return this.get('url') + '/new';
}.property('url'),
style: function() {
return "background-color: #" + (this.get('category.color')) + "; color: #" + (this.get('category.text_color')) + ";";
@ -58,7 +65,8 @@ Discourse.Category = Discourse.Model.extend({
secure: this.get('secure'),
permissions: this.get('permissionsForUpdate'),
auto_close_days: this.get('auto_close_days'),
position: this.get('position')
position: this.get('position'),
parent_category_id: this.get('parent_category_id')
},
type: this.get('id') ? 'PUT' : 'POST'
});

View File

@ -31,18 +31,32 @@ Discourse.NavItem = Discourse.Model.extend({
// href from this item
href: function() {
return Discourse.getURL("/") + this.get('filterMode');
}.property('filterMode'),
// href from this item
filterMode: function() {
var name = this.get('name');
if( name.split('/')[0] === 'category' ) {
return Discourse.getURL("/") + 'category/' + this.get('categorySlug');
return 'category/' + this.get('categorySlug');
} else {
return Discourse.getURL("/") + name.replace(' ', '-');
var mode = "";
var category = this.get("category");
if(category){
mode += "category/";
var parentSlug = category.get('parentCategory.slug');
if (parentSlug) { mode += parentSlug + "/"; }
mode += category.get("slug") + "/l/";
}
return mode + name.replace(' ', '-');
}
}.property('name'),
count: function() {
var state = this.get('topicTrackingState');
if (state) {
return state.lookupCount(this.get('name'));
return state.lookupCount(this.get('name'), this.get('category'));
}
}.property('topicTrackingState.messageCount'),
@ -71,7 +85,8 @@ Discourse.NavItem.reopenClass({
opts = {
name: name,
hasIcon: name === "unread" || name === "favorited",
filters: split.splice(1)
filters: split.splice(1),
category: opts.category
};
return Discourse.NavItem.create(opts);

View File

@ -146,7 +146,6 @@ Discourse.TopicList.reopenClass({
return Ember.RSVP.resolve(list);
}
session.setProperties({topicList: null, topicListScrollPos: null});
return Discourse.TopicList.find(filter, menuItem.get('excludeCategory'));
}
});

View File

@ -159,15 +159,16 @@ Discourse.TopicTrackingState = Discourse.Model.extend({
return count;
},
lookupCount: function(name){
lookupCount: function(name, category){
var categoryName = Em.get(category, "name");
if(name==="new") {
return this.countNew();
return this.countNew(categoryName);
} else if(name==="unread") {
return this.countUnread();
return this.countUnread(categoryName);
} else {
var category = name.split("/")[1];
if(category) {
return this.countCategory(category);
categoryName = name.split("/")[1];
if(categoryName) {
return this.countCategory(categoryName);
}
}
},

View File

@ -26,8 +26,10 @@ Discourse.Route.buildRoutes(function() {
Discourse.ListController.filters.forEach(function(filter) {
router.route(filter, { path: "/" + filter });
router.route(filter, { path: "/" + filter + "/more" });
router.route(filter + "Category", { path: "/category/:slug/" + filter });
router.route(filter + "Category", { path: "/category/:slug/" + filter + "/more" });
router.route(filter + "Category", { path: "/category/:slug/l/" + filter });
router.route(filter + "Category", { path: "/category/:slug/l/" + filter + "/more" });
router.route(filter + "Category", { path: "/category/:parentSlug/:slug/l/" + filter });
router.route(filter + "Category", { path: "/category/:parentSlug/:slug/l/" + filter + "/more" });
});

View File

@ -22,11 +22,13 @@ Discourse.ListCategoryRoute = Discourse.FilteredListRoute.extend({
}
var listController = this.controllerFor('list'),
urlId = Discourse.Category.slugFor(category),
self = this;
categorySlug = Discourse.Category.slugFor(category),
self = this,
filter = this.filter || "latest",
url = "category/" + categorySlug + "/l/" + filter;
listController.set('filterMode', "category/" + urlId);
listController.load("category/" + urlId).then(function(topicList) {
listController.set('filterMode', url);
listController.load(url).then(function(topicList) {
listController.setProperties({
canCreateTopic: topicList.get('can_create_topic'),
category: category

View File

@ -1,17 +1,12 @@
<li>
<a href="/">{{title}}</a>
<i class='icon icon-caret-right first-caret'></i>
{{discourse-categorydrop parentCategory=category categories=parentCategories}}
</li>
<li>
{{discourse-categorydrop parentCategory=category category=targetCategory categories=childCategories}}
</li>
{{#if parentCategory}}
<li>
{{discourse-categorydrop category=parentCategory categories=categories}}
</li>
{{/if}}
{{#if category}}
<li>
{{discourse-categorydrop category=category}}
{{boundCategoryLink category}}
</li>
{{/if}}

View File

@ -1,4 +1,12 @@
{{categoryLink category}}
{{#if categories}}
<button {{action expand}}><i class='icon icon-caret-right'></i></button>
{{#if category}}
{{boundCategoryLink category allowUncategorized=true}}
{{else}}
<a href='/' class='badge-category home' {{bindAttr style="badgeStyle"}}><i class='icon icon-home'></i></a>
{{/if}}
{{#if categories}}
<a href='#' {{action expand}} class='badge-category category-dropdown-button' {{bindAttr style="badgeStyle"}}><i {{bindAttr class="iconClass"}}></i></a>
<section {{bindAttr class="expanded::hidden :category-dropdown-menu"}} class='chooser'>
{{#each categories}}<div class='cat'>{{categoryLink this allowUncategorized=true}}</div>{{/each}}
</section>
{{/if}}

View File

@ -0,0 +1,4 @@
<b>{{i18n admin.logs.screened_ips.form.label}}</b>
{{textField value=ip_address disabled=formSubmitted class="ip-address-input" placeholderKey="admin.logs.screened_ips.form.ip_address" autocorrect="off" autocapitalize="off"}}
{{combobox content=actionNames value=actionName}}
<button class="btn btn-small" {{action submit target="view"}} {{bindAttr disabled="formSubmitted"}}>{{i18n admin.logs.screened_ips.form.add}}</button>

View File

@ -144,7 +144,7 @@
{{#each categories}}
<li class='category'>
{{categoryLink this}}
{{categoryLink this allowUncategorized=true}}
<b>{{unbound topic_count}}</b></a>
</li>
{{/each}}

View File

@ -1,5 +1,10 @@
<div id='list-controls'>
<div class='list-controls'>
<div class="container">
{{#if category}}
{{discourse-breadcrumbs category=category categories=categories}}
{{/if}}
<ul class="nav nav-pills" id='category-filter'>
{{each availableNavItems itemViewClass="Discourse.NavItemView"}}
</ul>

View File

@ -41,9 +41,11 @@
{{/if}}
</td>
{{#unless controller.category}}
<td class='category'>
{{categoryLink category}}
</td>
{{/unless}}
<td class='posters'>
{{#each posters}}

View File

@ -10,10 +10,6 @@
</button>
{{/if}}
{{#if category}}
{{discourse-breadcrumbs title=Discourse.SiteSettings.title category=category categories=categories}}
{{/if}}
<table id='topic-list'>
<thead>
<tr>
@ -23,7 +19,9 @@
<th class='main-link'>
{{i18n topic.title}}
</th>
{{#unless category}}
<th class='category'>{{i18n category_title}}</th>
{{/unless}}
<th class='posters'>{{i18n top_contributors}}</th>
<th class='num posts'>{{i18n posts}}</th>
<th class='num likes'>{{i18n likes}}</th>

View File

@ -22,10 +22,10 @@
{{/if}}
{{categoryLink this allowUncategorized=true}}
{{#if unreadTopics}}
<a href={{unbound url}} class='badge new-posts badge-notification' title='{{i18n topic.unread_topics count="unreadTopics"}}'>{{unbound unreadTopics}}</a>
<a href={{unbound unreadUrl}} class='badge new-posts badge-notification' title='{{i18n topic.unread_topics count="unreadTopics"}}'>{{unbound unreadTopics}}</a>
{{/if}}
{{#if newTopics}}
<a href={{unbound url}} class='badge new-posts badge-notification' title='{{i18n topic.new_topics count="newTopics"}}'>{{unbound newTopics}} <i class='icon icon-asterisk'></i></a>
<a href={{unbound newUrl}} class='badge new-posts badge-notification' title='{{i18n topic.new_topics count="newTopics"}}'>{{unbound newTopics}} <i class='icon icon-asterisk'></i></a>
{{/if}}
<div class='featured-users'>
{{#each featured_users}}

View File

@ -21,6 +21,11 @@
{{textField value=name placeholderKey="category.name_placeholder" maxlength="50"}}
</section>
<section class='field'>
<label>{{i18n category.parent}}</label>
{{categoryChooser valueAttribute="id" value=parent_category_id categories=parentCategories}}
</section>
{{#unless isUncategorized}}
<section class='field'>
<label>{{i18n category.description}}</label>

View File

@ -1,2 +1,2 @@
<a href="#" class="close" {{action hide target="view"}}><i class="icon icon-remove-sign"></i></a>
<span class="close"><i class="icon icon-remove-sign"></i></span>
{{view.validation.reason}}

View File

@ -2,6 +2,10 @@
<div class='contents clearfix body-page'>
{{#if content}}
{{{content}}}
{{#if showLoginButton}}
<button class="btn btn-primary" {{action showLogin}}>{{i18n log_in}}</button>
{{/if}}
{{else}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}

View File

@ -73,7 +73,7 @@
<section class='controls'>
{{#if can_send_private_message_to_user}}
<button class='btn btn-primary right' {{action composePrivateMessage}}>
<button class='btn btn-primary' {{action composePrivateMessage}}>
<i class='icon icon-envelope'></i>
{{i18n user.private_message}}
</button>
@ -84,14 +84,14 @@
{{/if}}
{{#if currentUser.staff}}
<a {{bindAttr href="adminPath"}} class='btn'><i class="icon-wrench"></i>&nbsp;{{i18n admin.user.show_admin_profile}}</a>
<a {{bindAttr href="adminPath"}} class='btn right'><i class="icon-wrench"></i>&nbsp;{{i18n admin.user.show_admin_profile}}</a>
{{/if}}
{{#if can_edit}}
{{#link-to 'preferences' class="btn"}}<i class='icon icon-cog'></i>{{i18n user.preferences}}{{/link-to}}
{{#link-to 'preferences' class="btn right"}}<i class='icon icon-cog'></i>{{i18n user.preferences}}{{/link-to}}
{{/if}}
{{#link-to 'user.invited' class="btn"}}<i class='icon icon-envelope-alt'></i>{{i18n user.invited.title}}{{/link-to}}
{{#link-to 'user.invited' class="btn right"}}<i class='icon icon-envelope-alt'></i>{{i18n user.invited.title}}{{/link-to}}
</section>
</section>

View File

@ -12,14 +12,16 @@ Discourse.CategoryChooserView = Discourse.ComboboxView.extend({
dataAttributes: ['name', 'color', 'text_color', 'description_text', 'topic_count'],
valueBinding: Ember.Binding.oneWay('source'),
content: Em.computed.filter('categories', function(c) {
var uncategorized_id = Discourse.Site.currentProp("uncategorized_category_id");
return c.get('permission') === Discourse.PermissionType.FULL && c.get('id') !== uncategorized_id;
}),
init: function() {
this._super();
// TODO perhaps allow passing a param in to select if we need full or not
var uncategorized_id = Discourse.Site.currentProp("uncategorized_category_id");
this.set('content', _.filter(Discourse.Category.list(), function(c){
return c.permission === Discourse.PermissionType.FULL && c.id !== uncategorized_id;
}));
if (!this.get('categories')) {
this.set('categories', Discourse.Category.list());
}
},
none: function() {

View File

@ -27,12 +27,6 @@ Discourse.ModalBodyView = Discourse.View.extend({
}
},
// Pass the errors to our errors view
displayErrors: function(errors, callback) {
this.set('parentView.parentView.modalErrorsView.errors', errors);
if (typeof callback === "function") callback();
},
flashMessageChanged: function() {
var flashMessage = this.get('controller.flashMessage');
if (flashMessage) {

View File

@ -14,7 +14,7 @@ Discourse.NavItemView = Discourse.View.extend({
hidden: Em.computed.not('content.visible'),
count: Ember.computed.alias('content.count'),
shouldRerender: Discourse.View.renderIfChanged('count'),
active: Discourse.computed.propertyEqual('contentNameSlug', 'controller.filterMode'),
active: Discourse.computed.propertyEqual('content.filterMode', 'controller.filterMode'),
title: function() {
var categoryName, extra, name;
@ -27,13 +27,6 @@ Discourse.NavItemView = Discourse.View.extend({
return I18n.t("filters." + name + ".help", extra);
}.property("content.filter"),
contentNameSlug: function() {
return this.get("content.name").toLowerCase().replace(' ','-');
}.property('content.name'),
// active: function() {
// return (this.get("contentNameSlug") === this.get("controller.filterMode"));
// }.property("contentNameSlug", "controller.filterMode"),
name: function() {
var categoryName, extra, name;

View File

@ -17,6 +17,10 @@ Discourse.PopupInputTipView = Discourse.View.extend({
bouncePixels: 6,
bounceDelay: 100,
click: function(e) {
this.set('shownAt', false);
},
good: function() {
return !this.get('validation.failed');
}.property('validation'),
@ -25,10 +29,6 @@ Discourse.PopupInputTipView = Discourse.View.extend({
return this.get('validation.failed');
}.property('validation'),
hide: function() {
this.set('shownAt', false);
},
bounce: function() {
if( this.get('shownAt') ) {
var $elem = this.$();

View File

@ -46,7 +46,8 @@ Discourse.QuoteButtonView = Discourse.View.extend({
$(document)
.on("mousedown.quote-button", function(e) {
view.set('isMouseDown', true);
if ($(e.target).hasClass('quote-button') || $(e.target).hasClass('create')) return;
// we don't want to deselect when we click on the quote button or the reply button
if ($(e.target).hasClass('quote-button') || $(e.target).closest('.create').length > 0) return;
// deselects only when the user left click
// (allows anyone to `extend` their selection using shift+click)
if (e.which === 1 && !e.shiftKey) controller.deselectText();

View File

@ -330,7 +330,7 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
var category = this.get('controller.content.category');
if (category) {
opts.catLink = Discourse.Utilities.categoryLink(category);
opts.catLink = Discourse.HTML.categoryLink(category);
} else {
opts.catLink = "<a href=\"" + Discourse.getURL("/categories") + "\">" + (I18n.t("topic.browse_all_categories")) + "</a>";
}

View File

@ -43,11 +43,11 @@
// Stuff we need to load first
//= require ./discourse/mixins/scrolling
//= require_tree ./discourse/mixins
//= require ./discourse/components/markdown
//= require ./discourse/components/computed
//= require ./discourse/lib/markdown
//= require ./discourse/lib/computed
//= require ./discourse/views/view
//= require ./discourse/views/container_view
//= require ./discourse/components/debounce
//= require ./discourse/lib/debounce
//= require ./discourse/models/model
//= require ./discourse/models/user_action
//= require ./discourse/models/composer
@ -63,9 +63,10 @@
//= require ./discourse/dialects/dialect
//= require_tree ./discourse/dialects
//= require_tree ./discourse/controllers
//= require_tree ./discourse/components
//= require_tree ./discourse/lib
//= require_tree ./discourse/models
//= require_tree ./discourse/views
//= require_tree ./discourse/components
//= require_tree ./discourse/helpers
//= require_tree ./discourse/templates
//= require_tree ./discourse/routes

View File

@ -752,6 +752,14 @@ table.api-keys {
}
}
.screened-ip-address-form {
margin-left: 6px;
.combobox {
width: 130px;
top: 10px;
}
}
.screened-emails, .screened-urls {
.ip_address {
width: 110px;

View File

@ -30,6 +30,7 @@
}
}
// Notification badge
// --------------------------------------------------

View File

@ -1,5 +1,5 @@
@import "common/foundation/variables";
@import "common/foundation/mixins";
@import "foundation/variables";
@import "foundation/mixins";
.popup-tip {
position: absolute;
@ -16,14 +16,15 @@
&.hide, &.good {
display: none;
}
a.close {
.close {
float: right;
color: $black;
opacity: 0.5;
font-size: 15px;
margin-left: 4px;
cursor: pointer;
}
a.close:hover {
.close:hover {
opacity: 1.0;
}
}

View File

@ -164,6 +164,19 @@
border-bottom: 1px solid #bbb;
}
.category-combobox {
width: 430px;
.chzn-drop {
left: -9000px;
width: 428px;
}
.chzn-search input {
width: 378px;
}
}
&.hidden {
display: none;
}

View File

@ -8,7 +8,7 @@
// List controls
// --------------------------------------------------
#list-controls {
.list-controls {
.nav {
float: left;
margin-bottom: 15px;
@ -340,52 +340,102 @@
// Misc. stuff
// --------------------------------------------------
#main {
#list-controls {
.badge-category {
display: inline-block;
background-color: yellow;
margin: 8px 0 0 8px;
float: left;
}
clear: both;
.list-controls {
.home {
font-size: 20px;
font-weight: normal;
}
#list-area {
margin-bottom: 300px;
.badge-category {
padding: 4px 10px;
display: inline-block;
line-height: 24px;
float: left;
}
.category-dropdown-button {
padding: 4px 10px 3px 8px;
border-left: 1px solid rgba(0, 0, 0, 0.15);
font-size: 16px;
width: 10px;
text-align: center;
&:hover {
opacity: 0.8;
}
}
clear: both;
}
#list-area {
margin-bottom: 300px;
.topic-statuses .topic-status i {font-size: 15px;}
.empty-topic-list {
padding: 10px;
}
.unseen {
background-color: transparent;
padding: 0;
border: 0;
color: lighten($red, 10%);
font-size: 13px;
cursor: default;
}
.empty-topic-list {
padding: 10px;
}
#topic-list {
.alert {
margin-bottom: 0;
font-size: 14px;
}
.spinner {
margin-top: 40px;
}
}
span.posted {
display: inline-block;
text-indent: -9999em;
width: 15px;
height: 15px;
background: {
image: image-url("posted.png");
};
.unseen {
background-color: transparent;
padding: 0;
border: 0;
color: lighten($red, 10%);
font-size: 13px;
cursor: default;
}
}
#topic-list {
.alert {
margin-bottom: 0;
font-size: 14px;
}
.spinner {
margin-top: 40px;
}
}
span.posted {
display: inline-block;
text-indent: -9999em;
width: 15px;
height: 15px;
background: {
image: image-url("posted.png");
};
}
ol.category-breadcrumb {
display: block;
float: left;
list-style: none;
margin: 0 10px 0 0;
padding: 0;
li {
float: left;
margin-right: 5px;
}
}
.category-dropdown-menu {
overflow-x: hidden;
overflow-y: scroll;
position: absolute;
border: 1px solid #ccc;
background-color: white;
width: 200px;
height: 200px;
padding: 8px 5px 0 7px;
z-index: 100;
margin-top: 31px;
.badge-category {
font-size: 13px;
line-height: 26px;
padding: 0px 8px;
float: none;
}
div {
margin-bottom: 10px;
}
}

View File

@ -198,45 +198,4 @@ i {background: #e4f2f8;
z-index: 495
}
ol.category-breadcrumb {
list-style: none;
margin: 0 0 10px 0;
padding: 0;
.first-caret {
margin: 0 3px 0 3px;
color: #666;
font-size: 13px;
}
li {
float: left;
margin-right: 5px;
border: 1px solid transparent;
height: 21px;
padding-top: 2px;
button {
background: none;
border: 1px solid transparent;
padding: 0px 4px;
margin: 0;
color: #666;
}
&.has-drop:hover {
background-color: #eee;
border: 1px solid #bbb;
button {
color: black;
}
}
}
.clear {
clear: both;
}
}

View File

@ -205,9 +205,47 @@
background-color: #ddd;
margin-top: 10px;
padding: 5px;
height: 32px;
.right {
float: right;
margin-left: 5px;
}
}
}
.about.collapsed-info {
.controls {
margin-top: 0;
}
.details {
.secondary { display: none; }
.bio { display: none; }
.primary {
width: 100%;
text-align: left;
margin-top: 0;
.avatar {
float: left;
margin-right: 10px;
border: 2px solid white;
width: 45px;
height: 45px;
}
h1 {
font-size: 20px;
line-height: 22px;
}
h2 {
font-size: 17px;
line-height: 20px;
margin-top: 4px;
}
}
}
}

View File

@ -279,14 +279,15 @@ display: none;
display: inline;
}
.title-input .popup-tip {
width: 300px;
left: -8px;
margin-top: 8px;
width: 240px;
right: 5px;
}
.category-input .popup-tip {
width: 240px;
left: 432px;
top: -7px;
right: 5px;
}
.textarea-wrapper .popup-tip {
top: 28px;
}
button.btn.no-text {
margin: 7px 0 0 5px;

View File

@ -14,6 +14,7 @@
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 22px;
margin: 20px 15px;
// Consistent vertical spacing
blockquote,

View File

@ -1,29 +0,0 @@
@import "../common/foundation/variables";
@import "../common/foundation/mixins";
.popup-tip {
position: absolute;
display: block;
padding: 5px 10px;
z-index: 101;
@include border-radius-all(2px);
border: solid 1px #955;
&.bad {
background-color: #b66;
color: white;
box-shadow: 1px 1px 5px #777, inset 0 0 9px #b55;
}
&.hide, &.good {
display: none;
}
a.close {
float: right;
color: $black;
opacity: 0.5;
font-size: 15px;
margin-left: 4px;
}
a.close:hover {
opacity: 1.0;
}
}

View File

@ -8,7 +8,7 @@
// List controls
// --------------------------------------------------
#list-controls {
.list-controls {
margin: 5px;
.nav {
float: left;
@ -239,48 +239,64 @@
// Misc. stuff
// --------------------------------------------------
#main {
#list-controls {
.badge-category {
display: inline-block;
background-color: yellow;
margin: 8px 0 0 8px;
float: left;
}
clear: both;
.list-controls {
.home {
font-size: 20px;
font-weight: normal;
}
#list-area {
margin-bottom: 300px;
.empty-topic-list {
padding: 10px;
}
.unseen {
background-color: transparent;
padding: 0;
border: 0;
color: lighten($red, 10%);
font-size: 13px;
cursor: default;
}
}
#topic-list {
.alert {
margin-bottom: 0;
font-size: 14px;
}
.spinner {
margin-top: 40px;
}
}
span.posted {
.badge-category {
margin-top: 6px;
padding: 4px 10px;
display: inline-block;
text-indent: -9999em;
width: 15px;
height: 15px;
background: {
image: image-url("posted.png");
};
line-height: 24px;
float: left;
}
.category-dropdown-button {
padding: 4px 10px 3px 8px;
border-left: 1px solid rgba(0, 0, 0, 0.15);
font-size: 16px;
width: 10px;
text-align: center;
&:hover {
opacity: 0.8;
}
}
clear: both;
}
#list-area {
margin-bottom: 300px;
.empty-topic-list {
padding: 10px;
}
.unseen {
background-color: transparent;
padding: 0;
border: 0;
color: lighten($red, 10%);
font-size: 13px;
cursor: default;
}
}
#topic-list {
.alert {
margin-bottom: 0;
font-size: 14px;
}
.spinner {
margin-top: 40px;
}
}
span.posted {
display: inline-block;
text-indent: -9999em;
width: 15px;
height: 15px;
background: {
image: image-url("posted.png");
};
}
@ -364,3 +380,40 @@ clear: both;
opacity: 1;
}
ol.category-breadcrumb {
display: block;
float: left;
list-style: none;
margin: 0 10px 0 0;
padding: 0;
li {
float: left;
margin-right: 5px;
}
}
.category-dropdown-menu {
overflow-x: hidden;
overflow-y: scroll;
position: absolute;
border: 1px solid #ccc;
background-color: white;
width: 200px;
height: 200px;
padding: 8px 5px 0 7px;
z-index: 100;
margin-top: 31px;
.badge-category {
font-size: 13px;
line-height: 26px;
padding: 0px 8px;
float: none;
}
div {
margin-bottom: 10px;
}
}

View File

@ -7,6 +7,15 @@ class Admin::ScreenedIpAddressesController < Admin::AdminController
render_serialized(screened_ip_addresses, ScreenedIpAddressSerializer)
end
def create
screened_ip_address = ScreenedIpAddress.new(allowed_params)
if screened_ip_address.save
render_serialized(screened_ip_address, ScreenedIpAddressSerializer)
else
render_json_error(screened_ip_address)
end
end
def update
if @screened_ip_address.update_attributes(allowed_params)
render json: success_json

View File

@ -90,7 +90,7 @@ class CategoriesController < ApplicationController
end
end
params.permit(*required_param_keys, :position, :hotness, :auto_close_days, :permissions => [*p.try(:keys)])
params.permit(*required_param_keys, :position, :hotness, :parent_category_id, :auto_close_days, :permissions => [*p.try(:keys)])
end
end

View File

@ -10,14 +10,11 @@ class SessionController < ApplicationController
params.require(:login)
params.require(:password)
login = params[:login].strip
login = login[1..-1] if login[0] == "@"
login = params[:login].strip
password = params[:password]
login = login[1..-1] if login[0] == "@"
if login =~ /@/
@user = User.where(email: Email.downcase(login)).first
else
@user = User.where(username_lower: login.downcase).first
end
@user = User.find_by_username_or_email(login)
if @user.present?
@ -28,7 +25,7 @@ class SessionController < ApplicationController
end
# If their password is correct
if @user.confirm_password?(params[:password])
if @user.confirm_password?(password)
if @user.is_banned?
render json: { error: I18n.t("login.banned", {date: I18n.l(@user.banned_till, format: :date_only)}) }
@ -57,7 +54,7 @@ class SessionController < ApplicationController
def forgot_password
params.require(:login)
user = User.where('username_lower = :username or email = :email', username: params[:login].downcase, email: Email.downcase(params[:login])).first
user = User.find_by_username_or_email(params[:login])
if user.present?
email_token = user.email_tokens.create(email: user.email)
Jobs.enqueue(:user_email, type: :forgot_password, user_id: user.id, email_token: email_token.token)

View File

@ -29,8 +29,9 @@ class TopicsController < ApplicationController
return wordpress if params[:best].present?
opts = params.slice(:username_filters, :filter, :page, :post_number)
username_filters = opts[:username_filters]
opts[:username_filters] = [opts[:username_filters]] if opts[:username_filters].is_a?(String)
opts[:username_filters] = [username_filters] if username_filters.is_a?(String)
begin
@topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts)
@ -46,7 +47,7 @@ class TopicsController < ApplicationController
# render workaround pseudo-static HTML page for old crawlers which ignores <noscript>
# (see http://meta.discourse.org/t/noscript-tag-and-some-search-engines/8078)
return render 'topics/plain', layout: false if (SiteSetting.enable_escaped_fragments && params.has_key?('_escaped_fragment_'))
return render 'topics/plain', layout: false if (SiteSetting.enable_escaped_fragments && params.key?('_escaped_fragment_'))
track_visit_to_topic
@ -63,25 +64,21 @@ class TopicsController < ApplicationController
params.require(:best)
params.require(:topic_id)
params.permit(:min_trust_level, :min_score, :min_replies, :bypass_trust_level_score, :only_moderator_liked)
opts = { best: params[:best].to_i,
min_trust_level: params[:min_trust_level] ? 1 : params[:min_trust_level].to_i,
min_score: params[:min_score].to_i,
min_replies: params[:min_replies].to_i,
bypass_trust_level_score: params[:bypass_trust_level_score].to_i, # safe cause 0 means ignore
only_moderator_liked: params[:only_moderator_liked].to_s == "true"
}
@topic_view = TopicView.new(
params[:topic_id],
current_user,
best: params[:best].to_i,
min_trust_level: params[:min_trust_level].nil? ? 1 : params[:min_trust_level].to_i,
min_score: params[:min_score].to_i,
min_replies: params[:min_replies].to_i,
bypass_trust_level_score: params[:bypass_trust_level_score].to_i, # safe cause 0 means ignore
only_moderator_liked: params[:only_moderator_liked].to_s == "true"
)
@topic_view = TopicView.new(params[:topic_id], current_user, opts)
discourse_expires_in 1.minute
wordpress_serializer = TopicViewWordpressSerializer.new(@topic_view, scope: guardian, root: false)
render_json_dump(wordpress_serializer)
end
def posts
params.require(:topic_id)
params.require(:post_ids)
@ -97,41 +94,31 @@ class TopicsController < ApplicationController
def update
topic = Topic.where(id: params[:topic_id]).first
title, archetype = params[:title], params[:archetype]
guardian.ensure_can_edit!(topic)
topic.title = params[:title] if params[:title].present?
topic.title = params[:title] if title.present?
# TODO: we may need smarter rules about converting archetypes
if current_user.admin?
topic.archetype = "regular" if params[:archetype] == 'regular'
end
topic.archetype = "regular" if current_user.admin? && archetype == 'regular'
success = false
Topic.transaction do
success = topic.save
success = topic.change_category(params[:category]) if success
end
# this is used to return the title to the client as it may have been
# changed by "TextCleaner"
if success
render_serialized(topic, BasicTopicSerializer)
else
render_json_error(topic)
end
success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic)
end
def similar_to
params.require(:title)
params.require(:raw)
title, raw = params[:title], params[:raw]
raise Discourse::InvalidParameters.new(:title) if title.length < SiteSetting.min_title_similar_length
raise Discourse::InvalidParameters.new(:raw) if raw.length < SiteSetting.min_body_similar_length
[:title, :raw].each { |key| check_length_of(key, params[key]) }
# Only suggest similar topics if the site has a minimmum amount of topics present.
if Topic.count > SiteSetting.minimum_topics_similar
topics = Topic.similar_to(title, raw, current_user).to_a
end
topics = Topic.similar_to(title, raw, current_user).to_a if Topic.count_exceeds_minimum?
render_serialized(topics, BasicTopicSerializer)
end
@ -139,11 +126,13 @@ class TopicsController < ApplicationController
def status
params.require(:status)
params.require(:enabled)
status, topic_id = params[:status], params[:topic_id].to_i
enabled = (params[:enabled] == 'true')
raise Discourse::InvalidParameters.new(:status) unless %w(visible closed pinned archived).include?(params[:status])
@topic = Topic.where(id: params[:topic_id].to_i).first
check_for_status_presence(:status, status)
@topic = Topic.where(id: topic_id).first
guardian.ensure_can_moderate!(@topic)
@topic.update_status(params[:status], (params[:enabled] == 'true'), current_user)
@topic.update_status(status, enabled, current_user)
render nothing: true
end
@ -203,14 +192,7 @@ class TopicsController < ApplicationController
end
def invite
username_or_email = params[:user]
if username_or_email
# provides a level of protection for hashes
params.require(:user)
else
params.require(:email)
username_or_email = params[:email]
end
username_or_email = params[:user] ? fetch_username : fetch_email
topic = Topic.where(id: params[:topic_id]).first
guardian.ensure_can_invite_to!(topic)
@ -347,4 +329,27 @@ class TopicsController < ApplicationController
topic.move_posts(current_user, post_ids_including_replies, args)
end
def check_length_of(key, attr)
str = (key == :raw) ? "body" : key.to_s
invalid_param(key) if attr.length < SiteSetting.send("min_#{str}_similar_length")
end
def check_for_status_presence(key, attr)
invalid_param(key) unless %w(visible closed pinned archived).include?(attr)
end
def invalid_param(key)
raise Discourse::InvalidParameters.new(key.to_sym)
end
def fetch_username
params.require(:user)
params[:user]
end
def fetch_email
params.require(:email)
params[:email]
end
end

View File

@ -22,6 +22,7 @@ class AdminDashboardData
def problems
[ rails_env_check,
ruby_version_check,
host_names_check,
gc_checks,
sidekiq_check || queue_size_check,
@ -161,6 +162,10 @@ class AdminDashboardData
I18n.t('dashboard.notification_email_warning') if SiteSetting.notification_email.blank?
end
def ruby_version_check
I18n.t('dashboard.ruby_version_warning') if RUBY_VERSION == '2.0.0' and RUBY_PATCHLEVEL < 247
end
# TODO: generalize this method of putting i18n keys with expiry in redis
# that should be reported on the admin dashboard:

View File

@ -187,10 +187,10 @@ SQL
def parent_category_validator
if parent_category_id
errors.add(:parent_category_id, "You can't link a category to itself") if parent_category_id == id
errors.add(:parent_category_id, I18n.t("category.errors.self_parent")) if parent_category_id == id
grandfather_id = Category.where(id: parent_category_id).pluck(:parent_category_id).first
errors.add(:parent_category_id, "You can't have more than one level of subcategory") if grandfather_id
errors.add(:base, I18n.t("category.errors.depth")) if grandfather_id
end
end

View File

@ -59,7 +59,8 @@ class Topic < ActiveRecord::Base
validates :category_id, :presence => true ,:exclusion => {:in => [SiteSetting.uncategorized_category_id]},
:if => Proc.new { |t|
(t.new_record? || t.category_id_changed?) &&
!SiteSetting.allow_uncategorized_topics
!SiteSetting.allow_uncategorized_topics &&
(t.archetype.nil? || t.archetype == Archetype.default)
}
@ -106,7 +107,7 @@ class Topic < ActiveRecord::Base
# Return private message topics
scope :private_messages, lambda {
where(archetype: Archetype::private_message)
where(archetype: Archetype.private_message)
}
scope :listable_topics, lambda { where('topics.archetype <> ?', [Archetype.private_message]) }
@ -169,7 +170,7 @@ class Topic < ActiveRecord::Base
Jobs.cancel_scheduled_job(:close_topic, {topic_id: id})
true
end
if category_id.nil? && (archetype.nil? || archetype == "regular")
if category_id.nil? && (archetype.nil? || archetype == Archetype.default)
self.category_id = SiteSetting.uncategorized_category_id
end
end
@ -182,6 +183,10 @@ class Topic < ActiveRecord::Base
end
end
def self.count_exceeds_minimum?
count > SiteSetting.minimum_topics_similar
end
def best_post
posts.order('score desc').limit(1).first
end

View File

@ -124,19 +124,19 @@ class User < ActiveRecord::Base
end
def self.find_by_username_or_email(username_or_email)
conditions = if username_or_email.include?('@')
{ email: Email.downcase(username_or_email) }
if username_or_email.include?('@')
find_by_email(username_or_email)
else
{ username_lower: username_or_email.downcase }
find_by_username(username_or_email)
end
end
users = User.where(conditions).to_a
def self.find_by_email(email)
where(email: Email.downcase(email)).first
end
if users.size > 1
raise Discourse::TooManyMatches
else
users.first
end
def self.find_by_username(username)
where(username_lower: username.downcase).first
end
def enqueue_welcome_message(message_type)

View File

@ -1,12 +1,12 @@
class ScreenedIpAddressSerializer < ApplicationSerializer
attributes :id,
:ip_address,
:action,
:action_name,
:match_count,
:last_match_at,
:created_at
def action
def action_name
ScreenedIpAddress.actions.key(object.action_type).to_s
end

View File

@ -0,0 +1,45 @@
class SpamRule::AutoBlock
def initialize(user)
@user = user
end
def self.block?(user)
self.new(user).block?
end
def self.punish!(user)
self.new(user).block_user
end
def perform
block_user if block?
end
def block?
@user.blocked? or
(!@user.has_trust_level?(:basic) and
SiteSetting.num_flags_to_block_new_user > 0 and
SiteSetting.num_users_to_block_new_user > 0 and
num_spam_flags_against_user >= SiteSetting.num_flags_to_block_new_user and
num_users_who_flagged_spam_against_user >= SiteSetting.num_users_to_block_new_user)
end
def num_spam_flags_against_user
Post.where(user_id: @user.id).sum(:spam_count)
end
def num_users_who_flagged_spam_against_user
post_ids = Post.where('user_id = ? and spam_count > 0', @user.id).pluck(:id)
return 0 if post_ids.empty?
PostAction.spam_flags.where(post_id: post_ids).uniq.pluck(:user_id).size
end
def block_user
Post.transaction do
if UserBlocker.block(@user, nil, {message: :too_many_spam_flags}) and SiteSetting.notify_mods_when_user_blocked
GroupMessage.create(Group[:moderators].name, :user_automatically_blocked, {user: @user, limit_once_per: false})
end
end
end
end

View File

@ -0,0 +1,37 @@
class SpamRule::FlagSockpuppets
def initialize(post)
@post = post
end
def perform
if SiteSetting.flag_sockpuppets and reply_is_from_sockpuppet?
flag_sockpuppet_users
true
else
false
end
end
def reply_is_from_sockpuppet?
return false if @post.post_number and @post.post_number == 1
first_post = @post.topic.posts.by_post_number.first
return false if first_post.user.nil?
!first_post.user.staff? and !@post.user.staff? and
@post.user != first_post.user and
@post.user.ip_address == first_post.user.ip_address and
@post.user.new_user? and
!ScreenedIpAddress.is_whitelisted?(@post.user.ip_address)
end
def flag_sockpuppet_users
system_user = Discourse.system_user
PostAction.act(system_user, @post, PostActionType.types[:spam], message: I18n.t('flag_reason.sockpuppet')) rescue PostAction::AlreadyActed
if (first_post = @post.topic.posts.by_post_number.first).try(:user).try(:new_user?)
PostAction.act(system_user, first_post, PostActionType.types[:spam], message: I18n.t('flag_reason.sockpuppet')) rescue PostAction::AlreadyActed
end
end
end

View File

@ -2,8 +2,6 @@
# receive, their trust level, etc.
class SpamRulesEnforcer
include Rails.application.routes.url_helpers
# The exclamation point means that this method may make big changes to posts and users.
def self.enforce!(arg)
SpamRulesEnforcer.new(arg).enforce!
@ -15,73 +13,13 @@ class SpamRulesEnforcer
end
def enforce!
# TODO: once rules are in their own classes, invoke them from here in priority order
if @user
block_user if block?
SpamRule::AutoBlock.new(@user).perform
end
if @post
flag_sockpuppet_users if SiteSetting.flag_sockpuppets and reply_is_from_sockpuppet?
SpamRule::FlagSockpuppets.new(@post).perform
end
true
end
# TODO: move this sockpuppet code to its own class. We should be able to add more rules, like ActiveModel validators.
def reply_is_from_sockpuppet?
return false if @post.post_number and @post.post_number == 1
first_post = @post.topic.posts.by_post_number.first
return false if first_post.user.nil?
!first_post.user.staff? and !@post.user.staff? and
@post.user != first_post.user and
@post.user.ip_address == first_post.user.ip_address and
@post.user.new_user? and
!ScreenedIpAddress.is_whitelisted?(@post.user.ip_address)
end
def flag_sockpuppet_users
system_user = Discourse.system_user
PostAction.act(system_user, @post, PostActionType.types[:spam], message: I18n.t('flag_reason.sockpuppet')) rescue PostAction::AlreadyActed
if (first_post = @post.topic.posts.by_post_number.first).try(:user).try(:new_user?)
PostAction.act(system_user, first_post, PostActionType.types[:spam], message: I18n.t('flag_reason.sockpuppet')) rescue PostAction::AlreadyActed
end
end
# TODO: move all this auto-block code to another class:
def self.block?(user)
SpamRulesEnforcer.new(user).block?
end
def self.punish!(user)
SpamRulesEnforcer.new(user).block_user
end
def block?
@user.blocked? or
(!@user.has_trust_level?(:basic) and
SiteSetting.num_flags_to_block_new_user > 0 and
SiteSetting.num_users_to_block_new_user > 0 and
num_spam_flags_against_user >= SiteSetting.num_flags_to_block_new_user and
num_users_who_flagged_spam_against_user >= SiteSetting.num_users_to_block_new_user)
end
def num_spam_flags_against_user
Post.where(user_id: @user.id).sum(:spam_count)
end
def num_users_who_flagged_spam_against_user
post_ids = Post.where('user_id = ? and spam_count > 0', @user.id).pluck(:id)
return 0 if post_ids.empty?
PostAction.spam_flags.where(post_id: post_ids).uniq.pluck(:user_id).size
end
def block_user
Post.transaction do
if UserBlocker.block(@user, nil, {message: :too_many_spam_flags}) and SiteSetting.notify_mods_when_user_blocked
GroupMessage.create(Group[:moderators].name, :user_automatically_blocked, {user: @user, limit_once_per: false})
end
end
end
end

View File

@ -18,6 +18,14 @@ module Discourse
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.
# HACK!! regression in rubygems / bundler in ruby-head
if RUBY_VERSION == "2.1.0"
$:.map! do |path|
path = File.expand_path(path.sub("../../","../")) if path =~ /fast_xor/ && !File.directory?(File.expand_path(path))
path
end
end
require 'discourse'
require 'js_locale_helper'

View File

@ -1,7 +1,7 @@
production:
first_thing:
# 1. Permissions on postgres box
- source: /.cloud66/scripts/permissions.sh
- source: /config/cloud/cloud66/scripts/permissions.sh
destination: /tmp/scripts/permissions.sh
target: postgresql
apply_during: build_only
@ -16,25 +16,29 @@ production:
owner: postgres
after_checkout:
# 3. Copy Procfile
- source: /.cloud66/files/Procfile
- source: /config/cloud/cloud66/files/Procfile
destination: <%= ENV['RAILS_STACK_PATH'] %>/Procfile
target: rails
run_on: all_servers
# 4. Copy redis settings
- source: /.cloud66/files/redis.yml
- source: /config/cloud/cloud66/files/redis.yml
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/redis.yml
target: rails
parse: false
run_on: all_servers
# 5. Copy production.rb file
- source: /.cloud66/files/production.rb
- source: /config/cloud/cloud66/files/production.rb
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/environments/production.rb
target: rails
run_on: all_servers
# 6. Move thin config to server
- source: /.cloud66/files/thin.yml
- source: /config/cloud/cloud66/files/thin.yml
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/thin.yml
target: rails
run_on: all_servers
after_rails:
# 7. Set environment variables and allow PSQL user to access them
- source: /.cloud66/scripts/env_vars.sh
- source: /config/cloud/cloud66/scripts/env_vars.sh
destination: /tmp/scripts/env_vars.sh
target: rails
apply_during: build_only
@ -42,21 +46,21 @@ production:
sudo: true
last_thing:
# 8. KILL DB
- source: /.cloud66/scripts/kill_db.sh
- source: /config/cloud/cloud66/scripts/kill_db.sh
destination: /tmp/scripts/kill_db.sh
target: postgresql
apply_during: build_only
execute: true
sudo: true
# 9. DB:DROP & DB:CREATE
- source: /.cloud66/scripts/drop_create.sh
- source: /config/cloud/cloud66/scripts/drop_create.sh
destination: /tmp/scripts/drop_create.sh
target: rails
apply_during: build_only
execute: true
sudo: true
# 10. Import database image
- source: /.cloud66/scripts/import_prod.sh
- source: /config/cloud/cloud66/scripts/import_prod.sh
destination: /tmp/scripts/import_prod.sh
target: postgresql
apply_during: build_only
@ -64,14 +68,14 @@ production:
owner: postgres
run_as: postgres
# 11. Migrate database
- source: /.cloud66/scripts/migrate.sh
- source: /config/cloud/cloud66/scripts/migrate.sh
destination: /tmp/migrate.sh
target: rails
apply_during: build_only
execute: true
sudo: true
# 12. Curl script
- source: /.cloud66/scripts/curl.sh
- source: /config/cloud/cloud66/scripts/curl.sh
destination: /tmp/curl.sh
target: rails
apply_during: build_only
@ -80,7 +84,7 @@ production:
staging:
first_thing:
# 1. Permissions on postgres box
- source: /.cloud66/scripts/permissions.sh
- source: /config/cloud/cloud66/scripts/permissions.sh
destination: /tmp/scripts/permissions.sh
target: postgresql
apply_during: build_only
@ -95,25 +99,29 @@ staging:
owner: postgres
after_checkout:
# 3. Copy Procfile
- source: /.cloud66/files/Procfile
- source: /config/cloud/cloud66/files/Procfile
destination: <%= ENV['RAILS_STACK_PATH'] %>/Procfile
target: rails
run_on: all_servers
# 4. Rename redis.yml.sample file
- source: /.cloud66/files/redis.yml
- source: /config/cloud/cloud66/files/redis.yml
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/redis.yml
target: rails
parse: false
run_on: all_servers
# 5. Rename production.rb.sample file
- source: /.cloud66/files/production.rb
- source: /config/cloud/cloud66/files/production.rb
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/environments/production.rb
target: rails
run_on: all_servers
# 6. Move thin config to server
- source: /.cloud66/files/thin.yml
- source: /config/cloud/cloud66/files/thin.yml
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/thin.yml
target: rails
run_on: all_servers
after_rails:
# 7. Set environment variables and allow PSQL user to access them
- source: /.cloud66/scripts/env_vars.sh
- source: /config/cloud/cloud66/scripts/env_vars.sh
destination: /tmp/scripts/env_vars.sh
target: rails
apply_during: build_only
@ -121,21 +129,21 @@ staging:
sudo: true
last_thing:
# 8. KILL DB
- source: /.cloud66/scripts/kill_db.sh
- source: /config/cloud/cloud66/scripts/kill_db.sh
destination: /tmp/scripts/kill_db.sh
target: postgresql
apply_during: build_only
execute: true
sudo: true
# 9. DB:DROP & DB:CREATE
- source: /.cloud66/scripts/drop_create.sh
- source: /config/cloud/cloud66/scripts/drop_create.sh
destination: /tmp/scripts/drop_create.sh
target: rails
apply_during: build_only
execute: true
sudo: true
# 10. Import database image
- source: /.cloud66/scripts/import_prod.sh
- source: /config/cloud/cloud66/scripts/import_prod.sh
destination: /tmp/scripts/import_prod.sh
target: postgresql
apply_during: build_only
@ -143,14 +151,14 @@ staging:
owner: postgres
run_as: postgres
# 11. Migrate database
- source: /.cloud66/scripts/migrate.sh
- source: /config/cloud/cloud66/scripts/migrate.sh
destination: /tmp/migrate.sh
target: rails
apply_during: build_only
execute: true
sudo: true
# 12. Curl script
- source: /.cloud66/scripts/curl.sh
- source: /config/cloud/cloud66/scripts/curl.sh
destination: /tmp/curl.sh
target: rails
apply_during: build_only
@ -159,7 +167,7 @@ staging:
development:
first_thing:
# 1. Permissions on postgres box
- source: /.cloud66/scripts/permissions.sh
- source: /config/cloud/cloud66/scripts/permissions.sh
destination: /tmp/scripts/permissions.sh
target: postgresql
apply_during: build_only
@ -174,21 +182,24 @@ development:
owner: postgres
after_checkout:
# 3. Copy Procfile
- source: /.cloud66/files/Procfile
- source: /config/cloud/cloud66/files/Procfile
destination: <%= ENV['RAILS_STACK_PATH'] %>/Procfile
target: rails
run_on: all_servers
# 4. Rename redis.yml.sample file
- source: /.cloud66/files/redis.yml
- source: /config/cloud/cloud66/files/redis.yml
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/redis.yml
target: rails
parse: false
run_on: all_servers
# 5. Move thin config to server
- source: /.cloud66/files/thin.yml
- source: /config/cloud/cloud66/files/thin.yml
destination: <%= ENV['RAILS_STACK_PATH'] %>/config/thin.yml
target: rails
run_on: all_servers
after_rails:
# 6. Set environment variables and allow PSQL user to access them
- source: /.cloud66/scripts/env_vars.sh
- source: /config/cloud/cloud66/scripts/env_vars.sh
destination: /tmp/scripts/env_vars.sh
target: rails
apply_during: build_only
@ -196,21 +207,21 @@ development:
sudo: true
last_thing:
# 7. KILL DB
- source: /.cloud66/scripts/kill_db.sh
- source: /config/cloud/cloud66/scripts/kill_db.sh
destination: /tmp/scripts/kill_db.sh
target: postgresql
apply_during: build_only
execute: true
sudo: true
# 8. DB:DROP & DB:CREATE
- source: /.cloud66/scripts/drop_create.sh
- source: /config/cloud/cloud66/scripts/drop_create.sh
destination: /tmp/scripts/drop_create.sh
target: rails
apply_during: build_only
execute: true
sudo: true
# 9. Import database image
- source: /.cloud66/scripts/import_dev.sh
- source: /config/cloud/cloud66/scripts/import_dev.sh
destination: /tmp/scripts/import_dev.sh
target: postgresql
apply_during: build_only
@ -218,14 +229,14 @@ development:
owner: postgres
run_as: postgres
# 10. Migrate database
- source: /.cloud66/scripts/migrate.sh
- source: /config/cloud/cloud66/scripts/migrate.sh
destination: /tmp/migrate.sh
target: rails
apply_during: build_only
execute: true
sudo: true
# 11. Curl script
- source: /.cloud66/scripts/curl.sh
- source: /config/cloud/cloud66/scripts/curl.sh
destination: /tmp/curl.sh
target: rails
apply_during: build_only

View File

@ -11,7 +11,7 @@ production:
### If you change this setting you will need to
### - restart sidekiq if you change this setting
### - rebake all to posts using: `RAILS_ENV=production bundle exec rake posts:rebake`
- production.localhost # Update this to be the domain of your production site
- <%= ENV["DISCOURSE_HOSTNAME"] || "production.localhost" %> # Update this to be the domain of your production site
test:
adapter: postgresql

View File

@ -975,6 +975,7 @@ en:
add_permission: "Add Permission"
this_year: "this year"
position: "position"
parent: "Parent Category"
flagging:
title: 'Why are you flagging this post?'
@ -1267,11 +1268,15 @@ en:
url: "URL"
screened_ips:
title: "Screened IPs"
description: "IP addresses that are being watched."
description: 'IP addresses that are being watched. Use "Allow" to whitelist IP addresses.'
delete_confirm: "Are you sure you want to remove the rule for %{ip_address}?"
actions:
block: "Block"
do_nothing: "Allow"
form:
label: "New:"
ip_address: "IP address"
add: "Add"
impersonate:
title: "Impersonate User"

View File

@ -576,6 +576,7 @@ fr:
total: total messages
current: message courant
notifications:
title: ''
reasons:
'3_2': 'Vous recevrez des notifications car vous suivez attentivement cette discussion.'
'3_1': 'Vous recevrez des notifications car vous avez créé cette discussion.'
@ -858,7 +859,11 @@ fr:
security: "Sécurité"
auto_close_label: "Fermer automatiquement après :"
edit_permissions: "Editer les Permissions"
add_permission: "AJouter une Permission"
add_permission: "Ajouter une Permission"
this_year: "cette année"
position: "position"
parent: "Catégorie parente"
flagging:
title: 'Pourquoi voulez-vous signaler ce message ?'
action: 'Signaler ce message'

View File

@ -122,6 +122,7 @@ ru:
log_in: 'Войти'
age: Возраст
last_post: 'Последнее сообщение'
joined: Присоединен
admin_title: Админка
flags_title: Жалобы
show_more: 'показать еще'
@ -202,18 +203,25 @@ ru:
sent_by_user: 'Отправлено пользователем <a href=''{{userUrl}}''>{{user}}</a>'
sent_by_you: 'Отправлено <a href=''{{userUrl}}''>Вами</a>'
user_action_groups:
'1': 'Отдано симпатий'
'2': 'Получено симпатий'
'3': Закладки
'4': Темы
'5': Сообщения
'6': Ответы
'7': Упоминания
'9': Цитаты
'10': Избранное
'11': Изменения
'12': 'Отправленные'
'13': Входящие
"1": 'Отдал симпатий'
"2": 'Получил симпатий'
"3": Закладки
"4": Темы
"5": Сообщения
"6": Ответы
"7": Упоминания
"9": Цитаты
"10": Избранное
"11": Изменения
"12": 'Отправленные'
"13": Входящие
categories:
category: Категория
posts: Сообщения
topics: Темы
latest: Последние
latest_by: 'последние по'
toggle_ordering: 'изменить сортировку'
user:
said: '{{username}} писал(а):'
profile: Профайл
@ -294,7 +302,7 @@ ru:
title: 'Пароль еще раз'
last_posted: 'Последнее сообщение'
last_emailed: 'Последнее письмо'
last_seen: 'Посл. визит'
last_seen: 'Последний визит'
created: 'Регистрация'
log_out: 'Выйти'
website: 'Веб-сайт'
@ -487,8 +495,8 @@ ru:
link_optional_text: 'необязательное название'
quote_title: Цитата
quote_text: Цитата
code_title: рагмент кода'
code_text: 'вводите код здесь'
code_title: орматированный текст'
code_text: 'введите форматированный текст'
upload_title: Загрузить
upload_description: 'введите здесь описание загружаемого объекта'
olist_title: 'Нумерованный список'
@ -528,6 +536,8 @@ ru:
remote_tip_with_attachments: 'Введите адрес изображения или файла в формате http://example.com/file.ext (список доступных расширений: {{authorized_extensions}}).'
local_tip: 'кликните для выбора изображения с вашего устройства'
local_tip_with_attachments: 'кликните для выбора изображения с вашего устройства (доступные расширения: {{authorized_extensions}})'
hint: '(вы так же можете перетащить объект в редактор для его загрузки)'
hint_for_chrome: '(вы так же можете перетащить или вставить изображение в редактор для его загрузки)'
uploading: Загрузка
search:
title: 'поиск по темам, сообщениям, пользователям или категориям'
@ -576,6 +586,16 @@ ru:
private_message: 'Написать личное сообщение'
list: Темы
new: 'новая тема'
new_topics:
one: '1 новая тема'
other: '{{count}} новых тем'
few: '{{count}} новых темы'
many: '{{count}} новых тем'
unread_topics:
one: '1 непрочитанная тема'
other: '{{count}} непрочитанных тем'
few: '{{count}} непрочитанных темы'
many: '{{count}} непрочитанных тем'
title: Тема
loading_more: 'Загружаю темы...'
loading: 'Загружаю тему...'
@ -630,16 +650,16 @@ ru:
notifications:
title: '&nbsp;'
reasons:
'3_2': 'Вы будете получать уведомления, потому что вы наблюдаете за темой.'
'3_1': 'Вы будете получать уведомления, потому что вы создали тему.'
'3': 'Вы будете получать уведомления, потому что вы наблюдаете за темой.'
'2_4': 'Вы будете получать уведомления, потому что вы ответили в теме.'
'2_2': 'Вы будете получать уведомления, потому что вы отслеживаете тему.'
'2': 'Вы будете получать уведомления, потому что вы <a href="/users/{{username}}/preferences">читали тему</a>.'
'1': 'Вы получите уведомление, только если кто-нибудь упомянет вас по @name или ответит на ваше сообщение.'
'1_2': 'Вы получите уведомление, только если кто-нибудь упомянет вас по @name или ответит на ваше сообщение.'
'0': 'Вы не получаете уведомления по теме.'
'0_2': 'Вы не получаете уведомления по теме.'
"3_2": 'Вы будете получать уведомления, потому что вы наблюдаете за темой.'
"3_1": 'Вы будете получать уведомления, потому что вы создали тему.'
"3": 'Вы будете получать уведомления, потому что вы наблюдаете за темой.'
"2_4": 'Вы будете получать уведомления, потому что вы ответили в теме.'
"2_2": 'Вы будете получать уведомления, потому что вы отслеживаете тему.'
"2": 'Вы будете получать уведомления, потому что вы <a href="/users/{{username}}/preferences">читали тему</a>.'
"1": 'Вы получите уведомление, только если кто-нибудь упомянет вас по @name или ответит на ваше сообщение.'
"1_2": 'Вы получите уведомление, только если кто-нибудь упомянет вас по @name или ответит на ваше сообщение.'
"0": 'Вы не получаете уведомления по теме.'
"0_2": 'Вы не получаете уведомления по теме.'
watching:
title: Наблюдение
description: 'то же самое, что и режим отслеживания, но вы дополнительно будете получать уведомления обо всех новых сообщениях.'
@ -939,7 +959,7 @@ ru:
category:
can: 'может&hellip; '
none: '(без категории)'
choose: 'Select a category&hellip;'
choose: 'Выберете категорию&hellip;'
edit: изменить
edit_long: 'Изменить категорию'
view: 'Просмотр тем по категориям'
@ -970,13 +990,16 @@ ru:
auto_close_label: 'Закрыть тему через:'
edit_permissions: 'Изменить права доступа'
add_permission: 'Добавить права'
this_year: 'в год'
position: местоположение
parent: 'Родительская категория'
flagging:
title: 'Выберите действие над сообщением'
action: 'Пожаловаться'
take_action: 'Принять меры'
notify_action: Отправить
delete_spammer: 'Удалить спамера'
delete_confirm: 'Вы собираетесь удалить <b>%{posts}</b> сообщений и <b>%{topics}</b> тем этого пользователя, а так же удалить его учетную запись и добавить его почтовый адрес <b>%{email}</b> в черный список. Вы действительно уверены, что ваши помыслы чисты и действия не продиктованы гневом?'
delete_confirm: 'Вы собираетесь удалить <b>%{posts}</b> сообщений и <b>%{topics}</b> тем этого пользователя, а так же удалить его учетную запись, добавить его IP адрес <b>%{ip_address}</b> и его почтовый адрес <b>%{email}</b> в черный список. Вы действительно уверены, что ваши помыслы чисты и действия не продиктованы гневом?'
yes_delete_spammer: 'Да, удалить спамера'
cant: 'Извините, но вы не можете сейчас послать жалобу.'
custom_placeholder_notify_user: 'Почему это сообщение побудило вас обратиться к этому пользователю напрямую и в частном порядке? Будьте конкретны, будьте конструктивны и всегда доброжелательны.'
@ -1159,12 +1182,18 @@ ru:
delete_confirm: 'Удалить данную группу?'
delete_failed: 'Невозможно удалить группу. Если это автоматически созданная группа, то она не может быть удалена.'
api:
generate_master: 'Сгенерировать ключ API'
none: 'Отсутствует ключ API.'
user: Пользователь
title: API
long_title: 'Информация об API'
key: Ключ
generate: 'Сгенерировать ключ API'
regenerate: 'Сгенерировать ключ API заново'
key: 'Ключ API'
generate: Сгенерировать
regenerate: Перегенерировать
revoke: Отозвать
confirm_regen: 'Вы уверены, что хотите заменить ключ API?'
confirm_revoke: 'Вы уверены, что хотите отозвать этот ключ?'
info_html: 'Ваш API ключ позволит вам создавать и обновлять темы, используя JSON calls.'
all_users: 'Все пользователи'
note_html: 'Никому <strong>не сообщайте</strong> эти ключи, Тот, у кого они есть, сможет создавать сообщения, выдавая себя за любого пользователя форума.'
customize:
title: Оформление
@ -1210,6 +1239,9 @@ ru:
last_match_at: 'Последнее совпадение'
match_count: Совпадения
ip_address: IP
delete: Удалить
edit: Изменить
save: Сохранить
screened_actions:
block: блокировать
do_nothing: 'ничего не делать'
@ -1244,6 +1276,17 @@ ru:
title: 'Отслеживаемые ссылки'
description: 'Список ссылок от пользователей, которые были идентифицированы как спамеры.'
url: URL
screened_ips:
title: 'Наблюдаемые IP адреса'
description: 'IP адреса за которыми вести наблюдение. Используйте "Разрешить" для добавления IP адреса в белый список.'
delete_confirm: 'Вы уверены, что хотите удалить правило для %{ip_address}?'
actions:
block: Заблокировать
do_nothing: Разрешить
form:
label: 'Новые:'
ip_address: 'IP адрес'
add: Добавить
impersonate:
title: 'Представиться как пользователь'
username_or_email: 'Имя пользователя или Email'
@ -1325,7 +1368,7 @@ ru:
reputation: Репутация
permissions: Права
activity: Активность
like_count: 'Получено симпатий'
like_count: 'Получил симпатий'
private_topics_count: 'Частные темы'
posts_read_count: 'Прочитано сообщений'
post_count: 'Создано сообщений'
@ -1344,8 +1387,8 @@ ru:
few: 'Пользователь не может быть удален, если он зарегистрирован больше чем %{count} дня назад и у него есть сообщения. Удалите все сообщения перед удалением пользователя.'
many: 'Пользователь не может быть удален, если он зарегистрирован больше чем %{count} дней назад и у него есть сообщения. Удалите все сообщения перед удалением пользователя.'
delete_confirm: 'Вы уверены, что хотите удалить пользователя? Это действие необратимо!'
delete_and_block: '<b>Да</b>, и <b>заблокировать</b> повторную регистрацию с данного адреса'
delete_dont_block: '<b>Да</b>, но <b>разрешить</b> повторную регистрацию с данного адреса'
delete_and_block: '<b>Да</b>, и <b>запретить</b> регистрацию с данного email и IP адреса'
delete_dont_block: '<b>Да</b>, но <b>разрешить</b> регистрацию с данного email и IP адреса'
deleted: 'Пользователь удален.'
delete_failed: 'При удалении пользователя возникла ошибка. Для удаления пользователя необходимо сначала удалить все его сообщения.'
send_activation_email: 'Послать активационное письмо'

View File

@ -975,6 +975,7 @@ zh_CN:
add_permission: "添加权限"
this_year: "今年"
position: "位置"
parent: "上级分类"
flagging:
title: '为何要报告本帖?'
@ -1267,11 +1268,15 @@ zh_CN:
url: "URL"
screened_ips:
title: "被屏蔽的IP"
description: "受监视的IP地址。"
description: "受监视的IP地址使用“放行”可将IP地址加入白名单。"
delete_confirm: "确定要撤销对IP地址为 %{ip_address} 的规则?"
actions:
block: "阻挡"
do_nothing: "放行"
form:
label: "新:"
ip_address: "IP地址"
add: "添加"
impersonate:
title: "假冒用户"

View File

@ -159,7 +159,9 @@ en:
topic_prefix: "Category definition for %{category}"
replace_paragraph: "[Replace this first paragraph with a short description of your new category. This guidance will appear in the category selection area, so try to keep it below 200 characters.]"
post_template: "%{replace_paragraph}\n\nUse the following paragraphs for a longer description, as well as to establish any category guidelines or rules.\n\nSome things to consider in any discussion replies below:\n\n- What is this category for? Why should people select this category for their topic?\n\n- How is this different than the other categories we already have?\n\n- Do we need this category?\n\n- Should we merge this with another category, or split it into more categories?\n"
errors:
self_parent: "A subcategory's parent cannot be itself."
depth: "You can't nest a subcategory under another."
trust_levels:
newuser:
title: "new user"
@ -416,6 +418,7 @@ en:
dashboard:
rails_env_warning: "Your server is running in %{env} mode."
ruby_version_warning: "You are running a version of Ruby 2.0.0 that is known to have problems. Upgrade to patch level 247 or later."
host_names_warning: "Your config/database.yml file is using the default localhost hostname. Update it to use your site's hostname."
gc_warning: 'Your server is using default ruby garbage collection parameters, which will not give you the best performance. Read this topic on performance tuning: <a href="http://meta.discourse.org/t/tuning-ruby-and-rails-for-discourse/4126" target="_blank">Tuning Ruby and Rails for Discourse</a>.'
sidekiq_warning: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by sidekiq. Please ensure at least one sidekiq process is running. <a href="https://github.com/mperham/sidekiq" target="_blank">Learn about Sidekiq here</a>.'

View File

@ -699,7 +699,7 @@ fr:
Si cela vous intéresse, cliquez sur le lien ci-dessous pour suivre la discussion :
[Visit %{site_name}][1]
[Visiter %{site_name}][1]
Vous avez été invité(e) par un utilisateur de confiance, donc vous avez la possibilité de répondre sans même avoir besoin de vous connecter.

View File

@ -100,6 +100,8 @@ ru:
name: 'Название категории'
post:
raw: Текст сообщения
user:
ip_address: ''
errors:
messages:
is_invalid: 'слишком короткий'
@ -109,6 +111,10 @@ ru:
attributes:
archetype:
cant_send_pm: 'Извините, вы не можете посылать личные сообщения данному пользователю.'
user:
attributes:
ip_address:
signup_not_allowed: 'Регистрация с данной учетной записью запрещена.'
user_profile:
no_info_me: '<div class=''missing-profile''>Поле Обо мне в вашем профиле не заполнено, <a href=''/users/%{username_lower}/preferences/about-me''>не желаете ли что-нибудь написать в нем?</a></div>'
no_info_other: '<div class=''missing-profile''>%{name} еще не заполнил поле «Обо мне» в своём профайле. </div>'
@ -116,6 +122,9 @@ ru:
topic_prefix: 'Описание категории %{category}'
replace_paragraph: '[Замените данный текст кратким описанием новой категории. Это описание будет отображаться в списке категорий, поэтому постарайтесь сделать его коротким (не более 200 символов).]'
post_template: "%{replace_paragraph}\n\nВ данное поле введите более подробное описание категории, а также возможные правила опубликования в ней тем.\n\nНесколько аспектов, которые следует учитывать:\n\n- Для чего нужна данная категория? Почему люди выберут данную категорию для размещения своей темы?\n\n- Чем данная категория отличается от тех, которые у нас уже есть?\n\n- Нужна ли нам эта категория?\n\n- Стоит ли нам объединить ее с другой категорией или разбить на несколько?\n"
errors:
self_parent: 'Подкатегория не может быть родительской для самой себя.'
depth: 'Вы не можете иметь вложенные подкатегории.'
trust_levels:
newuser:
title: 'новый пользователь'
@ -408,6 +417,7 @@ ru:
num_clicks: Переходов
dashboard:
rails_env_warning: 'Ваш сервер работает в режиме %{env}.'
ruby_version_warning: 'Вы используете версию Ruby 2.0.0, у которой имеются известные проблемы. Обновите версию до patch-247 или более новую.'
host_names_warning: 'Ваш файл config/database.yml использует локальное имя хоста по умолчанию. Поменяйте его на имя хоста вашего сайта.'
gc_warning: 'Ваш сервер использует параметры ruby garbage collection по умолчанию, что ведет не к лучшей производительности. Прочитайте эту тему про настройку производительности: <a href="http://meta.discourse.org/t/tuning-ruby-and-rails-for-discourse/4126">Tuning Ruby and Rails for Discourse</a>.'
sidekiq_warning: 'Sidekiq не запущен. Сейчас многие задачи, такие как отправка электронных писем, выполняются асинхронно. Пожалуйста, убедитесь, что хотя бы один процесс sidekiq запущен. <a href="https://github.com/mperham/sidekiq">Узнайте больше о Sidekiq здесь</a>.'
@ -518,9 +528,12 @@ ru:
new_topic_duration_minutes: 'Глобальное количество минут по умолчанию, в течение которых тема оценивается как новая, пользователи могут переназначать (-1 - всегда, -2 для последнего посещения)'
flags_required_to_hide_post: 'Количество жалоб, по достижении которого сообщение автоматически скрывается, и личное сообщение об этом посылается автору (0 - никогда)'
cooldown_minutes_after_hiding_posts: 'Количество минут, которое должен подождать пользователь перед редактированием сообщения скрытого по жалобам'
max_topics_in_first_day: 'Максимальное количество тем, которое пользователь может создать в первый день на сайте'
max_replies_in_first_day: 'Максимальное количество ответов, которое пользователь может сделать в первый день на сайте'
num_flags_to_block_new_user: 'Если сообщения нового пользователя получат данное количество флагов от различных пользователей, скрыть все сообщения пользователя и отказать пользователю в публикации новых сообщений. 0 отключает функцию.'
num_users_to_block_new_user: 'Если сообщения нового пользователя получат флаги от данного количества различных пользователей, скрыть все сообщения пользователя и отказать пользователю в публикации новых сообщений. 0 отключает функцию.'
notify_mods_when_user_blocked: 'Отправить сообщение всем модераторам, если пользователь заблокирован автоматически.'
flag_sockpuppets: 'Если новый пользователь (например, зарегистрированный за последние 24 часа), который начал тему и новый пользователь, который ответил в теме, имеют одинаковые IP адреса, помечать оба сообщения как спам.'
traditional_markdown_linebreaks: 'Использовать стандартные разрывы строк в Markdown, вместо двух пробелов'
post_undo_action_window_mins: 'Количество секунд, в течение которых пользователь может отменить действие («Мне нравится», «Жалоба» и т.д.)'
must_approve_users: 'Администраторы должны одобрять учетные записи всех новых пользователей для того, чтобы они получили доступ'
@ -578,6 +591,8 @@ ru:
max_topics_per_day: 'Максимальное количество тем, которое пользователь может создать в день'
max_private_messages_per_day: 'Максимальное количество личных сообщений, которое пользователь может послать в день'
suggested_topics: 'Количество рекомендованных тем, отображаемых внизу текущей темы'
clean_up_uploads: 'Удалить неиспользуемые загрузки для предотвращения хранения нелегального контента. ВНИМАНИЕ: рекомендуется сделать резервную копию директории /uploads перед включением данной настройки.'
uploads_grace_period_in_hours: 'Период (в часах) после которого неопубликованные вложения удаляются.'
enable_s3_uploads: 'Размещать загруженные файлы на Amazon S3'
s3_upload_bucket: 'Наименование Amazon S3 bucket в который будут загружаться файлы. ВНИМАНИЕ: имя должно быть в нижнем регистре (см. http://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html)'
s3_access_key_id: 'Amazon S3 access key для загрузки и хранения изображений'
@ -613,6 +628,7 @@ ru:
min_title_similar_length: 'Минимальная длина названия темы, при которой тема будет проверена на наличие похожих'
min_body_similar_length: 'Минимальная длина тела сообщения, при которой оно будет проверено на наличие похожих тем'
category_colors: 'Разделенный чертой (|) список дозволенных hexadecimal цветов для категорий'
enable_wide_category_list: 'Включить традиционный полноразмерный список категорий.'
max_image_size_kb: 'Максимальный размер изображений для загрузки пользователем в КБ убедитесь, что вы так же настроили лимит в nginx (client_max_body_size) / apache или прокси.'
max_attachment_size_kb: 'Максимальный размер файлов для загрузки пользователем в кб убедитесь, что вы настроили лимит также в nginx (client_max_body_size) / apache или proxy.'
authorized_extensions: 'Список расширений файлов, разрешенных к загрузке, разделенный вертикальной чертой (|)'
@ -718,6 +734,8 @@ ru:
email:
not_allowed: 'недопустимый почтовый домен. Пожалуйста, используйте другой адрес.'
blocked: 'не разрешено.'
ip_address:
blocked: 'блокирован.'
invite_mailer:
subject_template: '[%{site_name}] %{invitee_name} пригласил вас присоединиться к обсуждению на сайте %{site_name}'
text_body_template: "%{invitee_name} пригласил вас в тему \"%{topic_title}\" на сайте %{site_name}.\n\nЕсли вам интересно, пройдите по ссылке ниже, чтобы попасть в обсуждение:\n\n[%{site_name}][1]\n\nВы приглашены доверенным пользователем, поэтому сразу сможете разместить свой ответ без входа на сайт.\n\n[1]: %{invite_link}\n"
@ -756,8 +774,8 @@ ru:
subject_template: 'Новый пользователь %{username} заблокирован по жалобам'
text_body_template: "Это автоматическое сообщение для информирования вас о том, что новый пользователь [%{username}](%{base_url}%{user_url}) был автоматически заблокирован из за жалоб других пользователей на сообщения %{username}.\n\nПожалуйста [проверьте жалобы](/admin/flags). Если пользователь %{username} был заблокирован неверно, нажмите кнопку разблокировки [на странице управления пользователем](%{base_url}%{user_url}).\n\nПорог блокировки может быть изменен в настройке сайта `block_new_user`.\n"
spam_post_blocked:
subject_template: 'Новый пользователь %{username} блокирован за повторяющиеся ссылки в сообщениях'
text_body_template: "Это автоматическое сообщение для информирования вас о том, что новый пользователь [%{username}](%{base_url}%{user_url}) пытался создать множество сообщений с ссылками на %{domains} и был заблокирован для предотвращения спам-рассылки.\n\nПожалуйста [проверьте действия пользователя](%{base_url}%{user_url}).\n\nПороговое значение может быть изменено в настройке сайта `newuser_spam_host_threshold`.\n"
subject_template: 'Сообщения нового пользователя %{username} блокированы за повторяющиеся ссылки'
text_body_template: "Это автоматическое сообщение для информирования вас о том, что новый пользователь [%{username}](%{base_url}%{user_url}) пытался создать множество сообщений с ссылками на %{domains}, однако они были заблокированы для предотвращения спама. Пользователь по прежнему может создавать новые сообщения, которые не ссылаются на %{domains}.\n\nПожалуйста [проверьте действия пользователя](%{base_url}%{user_url}).\n\nПороговое значение может быть изменено в настройке сайта `newuser_spam_host_threshold`.\n"
unblocked:
subject_template: 'Учетная запись разблокирована'
text_body_template: "Здравствуйте!\n\nЭто автоматическое сообщение сайта %{site_name}. Ваш аккаунт был разблокирован. Теперь вы можете создавать новые темы и отвечать в них.\n"
@ -839,3 +857,6 @@ ru:
fetch_failure: 'Извините, во время извлечения изображения произошла ошибка.'
unknown_image_type: 'Файл, который вы загружаете, не является изображением.'
size_not_found: 'Извините, мы не можем определить размер изображения. Возможно, изображение повреждено?'
flag_reason:
sockpuppet: 'Новый пользователь создал тему и другой новый пользователь с того же IP адреса ответил. Для получения подробностей см. настройку сайта flag_sockpuppets.'
spam_hosts: 'Пользователь пытался создать множество сообщений с ссылками на один и тот же домен. Для получения подробностей см. настройку сайта newuser_spam_host_threshold.'

View File

@ -2,6 +2,9 @@
"app/assets/javascripts/discourse/components/*.js": {
"command": "dcomponent"
},
"app/assets/javascripts/discourse/lib/*.js": {
"command": "dlib"
},
"app/assets/javascripts/discourse/routes/*.js": {
"command": "droute"
},

View File

@ -69,7 +69,7 @@ Discourse::Application.routes.draw do
scope '/logs' do
resources :staff_action_logs, only: [:index]
resources :screened_emails, only: [:index]
resources :screened_ip_addresses, only: [:index, :update, :destroy]
resources :screened_ip_addresses, only: [:index, :create, :update, :destroy]
resources :screened_urls, only: [:index]
end
@ -210,8 +210,10 @@ Discourse::Application.routes.draw do
get "#{filter}" => "list##{filter}"
get "#{filter}/more" => "list##{filter}"
get "category/:category/#{filter}" => "list##{filter}"
get "category/:category/#{filter}/more" => "list##{filter}"
get "category/:category/l/#{filter}" => "list##{filter}"
get "category/:category/l/#{filter}/more" => "list##{filter}"
get "category/:parent_category/:category/l/#{filter}" => "list##{filter}"
get "category/:parent_category/:category/l/#{filter}/more" => "list##{filter}"
end
get 'category/:parent_category/:category' => 'list#category', as: 'category_list_parent'

20
db/fixtures/categories.rb Normal file
View File

@ -0,0 +1,20 @@
SiteSetting.refresh!
if SiteSetting.uncategorized_category_id == -1
puts "Seeding uncategorized category!"
result = Category.exec_sql "SELECT 1 FROM categories WHERE name = 'uncategorized'"
name = 'uncategorized'
if result.count > 0
name << SecureRandom.hex
end
result = Category.exec_sql "INSERT INTO categories
(name,color,slug,description,text_color, user_id, created_at, updated_at, position)
VALUES ('#{name}', 'AB9364', 'uncategorized', '', 'FFFFFF', -1, now(), now(), 1 )
RETURNING id
"
category_id = result[0]["id"].to_i
Category.exec_sql "INSERT INTO site_settings(name, data_type, value, created_at, updated_at)
VALUES ('uncategorized_category_id', 3, #{category_id}, now(), now())"
end

Some files were not shown because too many files have changed in this diff Show More