Merge remote-tracking branch 'discourse/master'

This commit is contained in:
Elliot Murphy 2013-02-07 00:39:11 -05:00
commit d5a2518fef
55 changed files with 623 additions and 95 deletions

View File

@ -15,7 +15,17 @@ Whenever you need ...
If you're interested in helping us develop Discourse, please start with our **[Discourse Developer Install Guide](https://github.com/discourse/discourse/blob/master/DEVELOPMENT.md)**, which includes instructions to get up and running in a development environment.
We also have a **[Discourse "Quick-and-Dirty" Install Guide](https://github.com/discourse/discourse/blob/master/INSTALL.md)**.
### The quick and easy setup
```
git clone git@github.com:discourse/discourse.git
cd discourse
rake db:create
rake db:migrate
rake db:seed_fu
redis-cli flushall
thin start
```
## Vision

View File

@ -7,6 +7,13 @@ window.Discourse.AdminFlagsController = Ember.Controller.extend
bootbox.alert("something went wrong")
)
deletePost: (item) ->
item.deletePost().then (=>
@content.removeObject(item)
), (->
bootbox.alert("something went wrong")
)
adminOldFlagsView: (->
@query == 'old'
).property('query')

View File

@ -28,6 +28,25 @@ window.Discourse.FlaggedPost = Discourse.Post.extend
@get('topic_visible') == 'f'
).property('topic_hidden')
deletePost: ->
promise = new RSVP.Promise()
if @get('post_number') == "1"
$.ajax "/t/#{@topic_id}",
type: 'DELETE'
cache: false
success: ->
promise.resolve()
error: (e)->
promise.reject()
else
$.ajax "/posts/#{@id}",
type: 'DELETE'
cache: false
success: ->
promise.resolve()
error: (e)->
promise.reject()
clearFlags: ->
promise = new RSVP.Promise()
$.ajax "/admin/flags/clear/#{@id}",

View File

@ -7,20 +7,20 @@ window.Discourse.SiteCustomization = Discourse.Model.extend
trackedProperties: ['enabled','name', 'stylesheet', 'header', 'override_default_style']
description: (->
"#{@.name}#{if @.enabled then ' (*)' else ''}"
"#{@name}#{if @enabled then ' (*)' else ''}"
).property('selected', 'name')
changed: (->
return false unless @.originals
return false unless @originals
@trackedProperties.any (p)=>
@.originals[p] != @get(p)
@originals[p] != @get(p)
).property('override_default_style','enabled','name', 'stylesheet', 'header', 'originals') # TODO figure out how to call with apply
startTrackingChanges: ->
@set('originals',{})
@trackedProperties.each (p)=>
@.originals[p] = @get(p)
@originals[p] = @get(p)
true
previewUrl: (->

View File

@ -21,14 +21,15 @@
<tbody>
{{#each content}}
<tr {{bindAttr class="hiddenClass"}}>
<td class='user'>{{avatar user imageSize="small"}}</td>
<td class='user'><a href="/admin{{unbound user.path}}">{{avatar user imageSize="small"}}</a></td>
<td class='excerpt'>{{#if topicHidden}}<i title='this topic is invisible' class='icon icon-eye-close'></i> {{/if}}<h3><a href='{{unbound url}}'>{{title}}</a></h3><br>{{{excerpt}}}
</td>
<td class='flaggers'>{{#each flaggers}}{{avatar this imageSize="small"}}{{/each}}</td>
<td class='flaggers'>{{#each flaggers}}<a href="/admin{{unbound path}}">{{avatar this imageSize="small"}}</a>{{/each}}</td>
<td class='last-flagged'>{{date lastFlagged}}</td>
<td class='action'>
{{#if controller.adminActiveFlagsView}}
<button title='dismiss all flags on this post (will unhide hidden posts)' class='btn' {{action clearFlags this}}>Clear Flags</button>
<button title='{{i18n admin.flags.clear_title}}' class='btn' {{action clearFlags this}}>{{i18n admin.flags.clear}}</button>
<button title='{{i18n admin.flags.delete_title}}' class='btn' {{action deletePost this}}>{{i18n admin.flags.delete}}</button>
{{/if}}
</td>
</tr>

View File

@ -256,9 +256,9 @@ window.Discourse = Ember.Application.createWithMixins
@rerender()
else
$('link').each ->
if @.href.match(me.name) and me.hash
$(@).data('orig', @.href) unless $(@).data('orig')
@.href = $(@).data('orig') + "&hash=" + me.hash
if @href.match(me.name) and me.hash
$(@).data('orig', @href) unless $(@).data('orig')
@href = $(@).data('orig') + "&hash=" + me.hash
window.Discourse.Router = Discourse.Router.reopen(location: 'discourse_location')

View File

@ -3,14 +3,14 @@
template = null
$.fn.autocomplete = (options)->
return if @.length == 0
if options && options.cancel && @.data("closeAutocomplete")
@.data("closeAutocomplete")()
return if @length == 0
if options && options.cancel && @data("closeAutocomplete")
@data("closeAutocomplete")()
return this
alert "only supporting one matcher at the moment" unless @.length == 1
alert "only supporting one matcher at the moment" unless @length == 1
autocompleteOptions = null
selectedOption = null
@ -47,27 +47,27 @@
if isInput
width = @.width()
height = @.height()
width = @width()
height = @height()
wrap = @wrap("<div class='ac-wrap clearfix'/>").parent()
wrap = @.wrap("<div class='ac-wrap clearfix'/>").parent()
wrap.width(width)
@.width(80)
@.attr('name', @.attr('name') + "-renamed")
@width(80)
@attr('name', @attr('name') + "-renamed")
vals = @val().split(",")
vals = @.val().split(",")
vals.each (x)->
unless x == ""
x = options.reverseTransform(x) if options.reverseTransform
addInputSelectedItem(x)
@.val("")
@val("")
completeStart = 0
wrap.click =>
@.focus()
@focus()
true

View File

@ -259,6 +259,9 @@ Discourse.TopicController = Ember.ObjectController.extend Discourse.Presence,
post.toggleProperty('bookmarked')
false
clearFlags: (actionType) ->
actionType.clearFlags()
# Who acted on a particular post / action type
whoActed: (actionType) ->
actionType.loadUsers()

View File

@ -18,7 +18,7 @@ window.Discourse.ActionSummary = Em.Object.extend Discourse.Presence,
@set('acted', false)
@set('count', @get('count') - 1)
@set('can_act', true)
@set('can_undo', false)
@set('can_undo', false)
# Perform this action
act: (opts) ->
@ -52,16 +52,28 @@ window.Discourse.ActionSummary = Em.Object.extend Discourse.Presence,
@removeAction()
# Remove our post action
jQuery.ajax
jQuery.ajax
url: "/post_actions/#{@get('post.id')}"
type: 'DELETE'
data:
post_action_type_id: @get('id')
post_action_type_id: @get('id')
clearFlags: ->
$.ajax
url: "/post_actions/clear_flags"
type: "POST"
data:
post_action_type_id: @get('id')
id: @get('post.id')
success: (result) =>
@set('post.hidden', result.hidden)
@set('count', 0)
loadUsers: ->
$.getJSON "/post_actions/users",
id: @get('post.id'),
post_action_type_id: @get('id')
(result) =>
(result) =>
@set('users', Em.A())
result.each (u) => @get('users').pushObject(Discourse.User.create(u))

View File

@ -190,9 +190,9 @@ window.Discourse.User.reopenClass
error: (xhr) -> promise.reject(xhr)
promise
createAccount: (name, email, password, username) ->
createAccount: (name, email, password, username, passwordConfirm, challenge) ->
$.ajax
url: '/users'
dataType: 'json'
data: {name: name, email: email, password: password, username: username}
data: {name: name, email: email, password: password, username: username, password_confirmation: passwordConfirm, challenge: challenge}
type: 'POST'

View File

@ -49,6 +49,14 @@
</tr>
{{/if}}
<tr class="password-confirmation">
<td><label for='new-account-password-confirmation'>Password Again</label></td>
<td>
{{view Ember.TextField valueBinding="view.accountPasswordConfirm" type="password" id="new-account-password-confirmation"}}
{{view Ember.TextField valueBinding="view.accountChallenge" id="new-account-challenge"}}
</td>
</tr>
</table>
</form>
</div>

View File

@ -28,8 +28,8 @@
<div class='topic-meta-data span2'>
<div class='contents'>
<a href='/users/{{unbound username}}' class='excerptable' data-excerpt-position="right" data-excerpt-size="small">{{avatar this imageSize="large"}}</a>
<h3><a href='/users/{{unbound username}}'>{{breakUp username}}</a></h3>
<a href='/users/{{unbound username}}' class='excerptable' data-excerpt-position="right" data-excerpt-size="small" >{{avatar this imageSize="large"}}</a>
<h3 {{bindAttr class="moderator"}}><a href='/users/{{unbound username}}'>{{breakUp username}}</a></h3>
<div class='post-info'>
<a href='#' class='post-date' {{bindAttr data-share-url="url"}}>{{date created_at}}</a>

View File

@ -35,12 +35,20 @@ window.Discourse.ActionsHistoryView = Em.View.extend Discourse.Presence,
if c.get('can_undo')
alsoName = Em.String.i18n("post.actions.undo", alsoName: c.get('actionType.alsoNameLower'))
buffer.push(" <a href='#' data-undo='#{c.get('id')}'>#{alsoName}</a>.")
buffer.push(" <a href='#' data-undo='#{c.get('id')}'>#{alsoName}</a>.")
if c.get('can_clear_flags')
buffer.push(" <a href='#' data-clear-flags='#{c.get('id')}'>#{Em.String.i18n("post.actions.clear_flags",count: c.count)}</a>.")
buffer.push("</div>")
click: (e) ->
$target = $(e.target)
if actionTypeId = $target.data('clear-flags')
@get('controller').clearFlags(@content.findProperty('id', actionTypeId))
return false
# User wants to know who actioned it
if actionTypeId = $target.data('who-acted')
@get('controller').whoActed(@content.findProperty('id', actionTypeId))
@ -54,4 +62,4 @@ window.Discourse.ActionsHistoryView = Em.View.extend Discourse.Presence,
@get('controller').undoAction(@content.findProperty('id', actionTypeId))
return false
false
false

View File

@ -192,7 +192,7 @@ window.Discourse.ComposerView = window.Discourse.View.extend
done: (e, data) =>
@set('loadingImage', false)
upload = data.result
html = "<img src='#{upload.url}' width='#{upload.width}' height='#{upload.height}'>"
html = "<img src=\"#{upload.url}\" width=\"#{upload.width}\" height=\"#{upload.height}\">"
@addMarkdown(html)
fail: (e, data) =>

View File

@ -3,6 +3,8 @@ window.Discourse.CreateAccountView = window.Discourse.ModalBodyView.extend Disco
title: Em.String.i18n('create_account.title')
uniqueUsernameValidation: null
complete: false
accountPasswordConfirm: 0
accountChallenge: 0
submitDisabled: (->
@ -22,6 +24,8 @@ window.Discourse.CreateAccountView = window.Discourse.ModalBodyView.extend Disco
# If blank, fail without a reason
return Discourse.InputValidation.create(failed: true) if @blank('accountName')
@fetchConfirmationValue() if @get('accountPasswordConfirm') == 0
# If too short
return Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.name.too_short')) if @get('accountName').length < 3
@ -120,13 +124,22 @@ window.Discourse.CreateAccountView = window.Discourse.ModalBodyView.extend Disco
).property('accountPassword')
fetchConfirmationValue: ->
$.ajax
url: '/users/hp.json',
success: (json) =>
@set('accountPasswordConfirm', json.value)
@set('accountChallenge', json.challenge.split("").reverse().join(""))
createAccount: ->
name = @get('accountName')
email = @get('accountEmail')
password = @get('accountPassword')
username = @get('accountUsername')
passwordConfirm = @get('accountPasswordConfirm')
challenge = @get('accountChallenge')
Discourse.User.createAccount(name, email, password, username).then (result) =>
Discourse.User.createAccount(name, email, password, username, passwordConfirm, challenge).then (result) =>
if result.success
@flash(result.message)

View File

@ -152,6 +152,9 @@
margin-bottom: 20px;
}
}
.password-confirmation {
display: none;
}
}
#move-selected {

View File

@ -320,6 +320,12 @@
font-size: 14px;
line-height: 18px;
}
h3.moderator a {
background-color: #ffe;
border: 1px solid #ffd;
}
div {
display: block;
}

View File

@ -1418,7 +1418,13 @@ body {
-moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
}
:-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder {
:-moz-placeholder {
color: #999999;
}
::-webkit-input-placeholder {
color: #999999;
}
:-ms-input-placeholder {
color: #999999;
}
.help-block, .help-inline {

View File

@ -3,7 +3,6 @@ require_dependency 'sql_builder'
class Admin::FlagsController < Admin::AdminController
def index
sql = SqlBuilder.new "select p.id, t.title, p.cooked, p.user_id, p.topic_id, p.post_number, p.hidden, t.visible topic_visible
from posts p
join topics t on t.id = topic_id
@ -72,7 +71,7 @@ from post_actions a
sql.where('deleted_at is null')
end
actions = sql.exec.each do |action|
sql.exec.each do |action|
p = map[action["post_id"]]
p[:post_actions] ||= []
p[:post_actions] << action
@ -92,7 +91,6 @@ where id in (?)"
}
render json: MultiJson.dump({users: users, posts: posts})
end
def clear

View File

@ -135,14 +135,33 @@ class ApplicationController < ActionController::Base
render json: MultiJson.dump(obj)
end
def can_cache_content?
# Don't cache unless we're in production mode
return false unless Rails.env.production?
# Don't cache logged in users
return false if current_user.present?
# Don't cache if there's restricted access
return false if SiteSetting.restrict_access?
true
end
# Our custom cache method
def discourse_expires_in(time_length)
return unless can_cache_content?
expires_in time_length, public: true
end
# Helper method - if no logged in user (anonymous), use Rails' conditional GET
# support. Should be very fast behind a cache.
def anonymous_etag(*args)
if current_user.blank? and Rails.env.production?
if can_cache_content?
yield if stale?(*args)
# Add a one minute expiry
expires_in 1.minute, :public => true
expires_in 1.minute, public: true
else
yield
end

View File

@ -58,8 +58,7 @@ class ListController < ApplicationController
draft = Draft.get(current_user, list.draft_key, list.draft_sequence) if current_user
list.draft = draft
# Add expiry of 1 minute for anonymous
expires_in 1.minute, :public => true if current_user.blank?
discourse_expires_in 1.minute
respond_to do |format|
format.html do

View File

@ -45,6 +45,21 @@ class PostActionsController < ApplicationController
render nothing: true
end
def clear_flags
requires_parameter(:post_action_type_id)
raise Discourse::InvalidAccess unless guardian.is_admin?
PostAction.clear_flags!(@post, current_user.id, params[:post_action_type_id].to_i)
@post.reload
if @post.is_flagged?
render json: {success: true, hidden: true}
else
@post.unhide!
render json: {success: true, hidden: false}
end
end
private
def fetch_post_from_params

View File

@ -123,6 +123,12 @@ class UsersController < ApplicationController
end
def create
if params[:password_confirmation] != honeypot_value or params[:challenge] != challenge_value.try(:reverse)
# Don't give any indication that we caught you in the honeypot
return render(:json => {success: true, active: false, message: I18n.t("login.activate_email", email: params[:email]) })
end
user = User.new
user.name = params[:name]
user.email = params[:email]
@ -183,6 +189,10 @@ class UsersController < ApplicationController
render json: {errors: [I18n.t("mothership.access_token_problem")]}
end
def get_honeypot_value
render json: {value: honeypot_value, challenge: challenge_value}
end
# all avatars are funneled through here
def avatar
@ -320,6 +330,14 @@ class UsersController < ApplicationController
private
def honeypot_value
Digest::SHA1::hexdigest("#{Discourse.current_hostname}:#{Discourse::Application.config.secret_token}")[0,15]
end
def challenge_value
'3019774c067cc2b'
end
def fetch_user_from_params
username_lower = params[:username].downcase
username_lower.gsub!(/\.json$/, '')

View File

@ -1,2 +0,0 @@
module ForumHelper
end

View File

@ -1,2 +0,0 @@
module ListHelper
end

View File

@ -1,2 +0,0 @@
module NotificationsHelper
end

View File

@ -1,3 +0,0 @@
module UserNotificationsHelper
end

View File

@ -38,6 +38,7 @@ class Post < ActiveRecord::Base
validates_presence_of :raw, :user_id, :topic_id
validates :raw, length: {in: SiteSetting.min_post_length..SiteSetting.max_post_length}
validate :raw_quality
validate :max_mention_validator
validate :max_images_validator
validate :max_links_validator
@ -68,6 +69,18 @@ class Post < ActiveRecord::Base
self.raw.strip! if self.raw.present?
end
def raw_quality
sentinel = TextSentinel.new(self.raw, min_entropy: SiteSetting.body_min_entropy)
if sentinel.valid?
# It's possible the sentinel has cleaned up the title a bit
self.raw = sentinel.text
else
errors.add(:raw, I18n.t(:is_invalid)) unless sentinel.valid?
end
end
# Stop us from posting the same thing too quickly
def unique_post_validator
return if SiteSetting.unique_posts_mins == 0
@ -250,6 +263,17 @@ class Post < ActiveRecord::Base
result
end
def is_flagged?
post_actions.where('post_action_type_id in (?) and deleted_at is null', PostActionType.FlagTypes).count != 0
end
def unhide!
self.hidden = false
self.hidden_reason_id = nil
self.topic.update_attributes(visible: true)
self.save
end
# Update the body of a post. Will create a new version when appropriate
def revise(updated_by, new_raw, opts={})

View File

@ -2,6 +2,8 @@ require_dependency 'rate_limiter'
require_dependency 'system_message'
class PostAction < ActiveRecord::Base
class AlreadyFlagged < StandardError; end
include RateLimiter::OnCreateRecord
attr_accessible :deleted_at, :post_action_type_id, :post_id, :user_id, :post, :user, :post_action_type, :message
@ -45,10 +47,14 @@ class PostAction < ActiveRecord::Base
user_actions
end
def self.clear_flags!(post, moderator_id)
def self.clear_flags!(post, moderator_id, action_type_id = nil)
# -1 is the automatic system cleary
actions = moderator_id == -1 ? PostActionType.AutoActionFlagTypes : PostActionType.FlagTypes
actions = if action_type_id
[action_type_id]
else
moderator_id == -1 ? PostActionType.AutoActionFlagTypes : PostActionType.FlagTypes
end
PostAction.exec_sql('update post_actions set deleted_at = ?, deleted_by = ?
where post_id = ? and deleted_at is null and post_action_type_id in (?)',
@ -115,6 +121,15 @@ class PostAction < ActiveRecord::Base
end
end
before_create do
if is_flag?
if PostAction.where('user_id = ? and post_id = ? and post_action_type_id in (?) and deleted_at is null',
self.user_id, self.post_id, PostActionType.FlagTypes).exists?
raise AlreadyFlagged
end
end
end
after_save do
# Update denormalized counts

View File

@ -1,4 +1,5 @@
class PostActionType < ActiveRecord::Base
attr_accessible :id, :is_flag, :name_key, :icon
def self.ordered

View File

@ -126,6 +126,11 @@ class SiteSetting < ActiveRecord::Base
setting(:basic_requires_read_posts, 100)
setting(:basic_requires_time_spent_mins, 30)
# Entropy checks
setting(:title_min_entropy, 10)
setting(:body_min_entropy, 7)
setting(:max_word_length, 30)
def self.call_mothership?
self.enforce_global_nicknames? and self.discourse_org_access_key.present?

View File

@ -2,6 +2,7 @@ require_dependency 'slug'
require_dependency 'avatar_lookup'
require_dependency 'topic_view'
require_dependency 'rate_limiter'
require_dependency 'text_sentinel'
class Topic < ActiveRecord::Base
include RateLimiter::OnCreateRecord
@ -18,12 +19,14 @@ class Topic < ActiveRecord::Base
rate_limit :limit_topics_per_day
rate_limit :limit_private_messages_per_day
validate :title_quality
validates_presence_of :title
validates :title, length: {in: SiteSetting.min_topic_title_length..SiteSetting.max_topic_title_length}
serialize :meta_data, ActiveRecord::Coders::Hstore
validate :unique_title
belongs_to :category
has_many :posts
@ -112,6 +115,23 @@ class Topic < ActiveRecord::Base
errors.add(:title, I18n.t(:has_already_been_used)) if finder.exists?
end
def title_quality
# We don't care about quality on private messages
return if private_message?
sentinel = TextSentinel.new(title,
min_entropy: SiteSetting.title_min_entropy,
max_word_length: SiteSetting.max_word_length,
remove_interior_spaces: true)
if sentinel.valid?
# It's possible the sentinel has cleaned up the title a bit
self.title = sentinel.text
else
errors.add(:title, I18n.t(:is_invalid)) unless sentinel.valid?
end
end
def new_version_required?
return true if title_changed?

View File

@ -37,6 +37,7 @@ class PostSerializer < ApplicationSerializer
:bookmarked,
:raw,
:actions_summary,
:moderator?,
:avatar_template,
:user_id,
:draft_sequence,
@ -45,6 +46,10 @@ class PostSerializer < ApplicationSerializer
:deleted_at
def moderator?
object.user.has_trust_level?(:moderator)
end
def avatar_template
object.user.avatar_template
end
@ -140,6 +145,8 @@ class PostSerializer < ApplicationSerializer
next if !action_summary[:can_act] && !scope.current_user
action_summary[:can_clear_flags] = scope.is_admin? && PostActionType.FlagTypes.include?(id)
if post_actions.present? and post_actions.has_key?(id)
action_summary[:acted] = true
action_summary[:can_undo] = scope.can_delete?(post_actions[id])

View File

@ -130,7 +130,7 @@
<article style="border: none" id="terms">
<h1>Terms of Service</h1>
<p>
Yes, legalese is boring, but we must protect ourselves (and by extension, you and your data) against unfriendly folks. So, like everyone else, we have a Terms of Service <a href="http://example.com/tos">TOS</a> describing your (and our) behavior and rights related to content, privacy, and laws. To use this service, you must agree to abide by the TOS.
Yes, legalese is boring, but we must protect ourselves (and by extension, you and your data) against unfriendly folks. So, like everyone else, we have a Terms of Service <a href="/tos">TOS</a> describing your (and our) behavior and rights related to content, privacy, and laws. To use this service, you must agree to abide by the TOS.
</p>
<div class="more">
</div>

View File

@ -93,10 +93,6 @@ module Discourse
# So open id logs somewhere sane
config.after_initialize do
OpenID::Util.logger = Rails.logger
# latest possible so earliest in the stack
# require 'rack/message_bus'
# config.middleware.insert(0, Rack::MessageBus)
end
end
end

View File

@ -8,6 +8,8 @@ en:
too_many_links: "has too many links"
just_posted_that: "is too similar to what you recently posted"
has_already_been_used: "has already been used"
invalid_characters: "contains invalid characters"
is_invalid: "is invalid; try to be a little more descriptive"
activerecord:
attributes:
@ -301,6 +303,10 @@ en:
email_time_window_mins: "How many minutes we wait before sending a user mail, to give them a chance to see it first."
flush_timings_secs: "How frequently we flush timing data to the server, in seconds."
max_word_length: "The maximum word length in a topic title"
title_min_entropy: "The minimum entropy for a topic title"
body_min_entropy: "The minimum entropy for post body"
# This section is exported to the javascript for i18n in the admin section
admin_js:
type_to_filter: "Type to Filter..."
@ -313,6 +319,10 @@ en:
title: "Flags"
old: "Old"
active: "Active"
clear: "Clear Flags"
clear_title: "dismiss all flags on this post (will unhide hidden posts)"
delete: "Delete Post"
delete_title: "delete post (if its the first post delete topic)"
customize:
title: "Customize"
@ -688,7 +698,7 @@ en:
no_posted: "You haven't posted in any topics yet."
no_popular: "There are no popular topics. That's sad."
topic:
topic:
create_in: 'Create {{categoryName}} Topic'
create: 'Create Topic'
create_long: 'Create a new Topic'
@ -852,6 +862,9 @@ en:
actions:
flag: 'Flag'
clear_flags:
one: "Clear flag"
other: "Clear flags"
it_too: "{{alsoName}} it too"
undo: "Undo {{alsoName}}"
by_you_and_others:

View File

@ -80,6 +80,7 @@ Discourse::Application.routes.draw do
put 'users/password-reset/:token' => 'users#password_reset'
get 'users/activate-account/:token' => 'users#activate_account'
get 'users/authorize-email/:token' => 'users#authorize_email'
get 'users/hp' => 'users#get_honeypot_value'
get 'user_preferences' => 'users#user_preferences_redirect'
get 'users/:username/private-messages' => 'user_actions#private_messages', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT}
@ -132,6 +133,7 @@ Discourse::Application.routes.draw do
resources :post_actions do
collection do
get 'users' => 'post_actions#users'
post 'clear_flags' => 'post_actions#clear_flags'
end
end
resources :user_actions

View File

@ -27,6 +27,13 @@ PostActionType.seed do |s|
s.position = 4
end
PostActionType.seed do |s|
s.id = PostActionType.Types[:vote]
s.name_key = 'vote'
s.is_flag = false
s.position = 5
end
PostActionType.seed do |s|
s.id = PostActionType.Types[:spam]
s.name_key = 'spam'

View File

@ -1,6 +1,6 @@
class AddMetaDataToForumThreads < ActiveRecord::Migration
def change
execute "CREATE EXTENSION hstore"
execute "CREATE EXTENSION IF NOT EXISTS hstore"
add_column :forum_threads, :meta_data, :hstore
end
end

View File

@ -1,4 +1,4 @@
# The guardian is responsible for confirming access to various site resources and opreations
# The guardian is responsible for confirming access to various site resources and operations
class Guardian
attr_reader :user

31
lib/multisite_i18n.rb Normal file
View File

@ -0,0 +1,31 @@
# Allow us to override i18n keys based on the current site you're viewing.
module MultisiteI18n
class << self
# It would be nice if there was an easier way to detect if a key is missing.
def translation_or_nil(key, opts)
missing_text = "missing multisite translation"
result = I18n.t(key, opts.merge(default: missing_text))
return nil if result == missing_text
result
end
def site_translate(current_site, key, opts=nil)
opts ||= {}
translation = MultisiteI18n.translation_or_nil("#{current_site || ""}.#{key}", opts)
if translation.blank?
return I18n.t(key, opts)
else
return translation
end
end
def t(*args)
MultisiteI18n.site_translate(RailsMultisite::ConnectionManagement.current_db, *args)
end
alias :translate :t
end
end

View File

@ -31,8 +31,8 @@ module SiteSettingExtension
end
# just like a setting, except that it is available in javascript via DiscourseSession
def client_setting(name, defualt = nil, type = nil)
setting(name,defualt,type)
def client_setting(name, default = nil, type = nil)
setting(name,default,type)
@@client_settings ||= []
@@client_settings << name
end

View File

@ -1,5 +1,6 @@
# Handle sending a message to a user from the system.
require_dependency 'post_creator'
require_dependency 'multisite_i18n'
class SystemMessage
@ -14,20 +15,20 @@ class SystemMessage
def create(type, params = {})
defaults = {site_name: SiteSetting.title,
username: @recipient.username,
user_preferences_url: "#{Discourse.base_url}/users/#{@recipient.username_lower}/preferences",
new_user_tips: I18n.t("system_messages.usage_tips.text_body_template"),
site_password: "",
base_url: Discourse.base_url}
username: @recipient.username,
user_preferences_url: "#{Discourse.base_url}/users/#{@recipient.username_lower}/preferences",
new_user_tips: MultisiteI18n.t("system_messages.usage_tips.text_body_template"),
site_password: "",
base_url: Discourse.base_url}
params = defaults.merge(params)
if SiteSetting.restrict_access?
params[:site_password] = I18n.t('system_messages.site_password', access_password: SiteSetting.access_password)
params[:site_password] = MultisiteI18n.t('system_messages.site_password', access_password: SiteSetting.access_password)
end
title = I18n.t("system_messages.#{type}.subject_template", params)
raw_body = I18n.t("system_messages.#{type}.text_body_template", params)
title = MultisiteI18n.t("system_messages.#{type}.subject_template", params)
raw_body = MultisiteI18n.t("system_messages.#{type}.text_body_template", params)
PostCreator.create(SystemMessage.system_user,
raw: raw_body,

56
lib/text_sentinel.rb Normal file
View File

@ -0,0 +1,56 @@
require 'iconv'
#
# Given a string, tell us whether or not is acceptable. Also, remove stuff we don't like
# such as leading / trailing space.
#
class TextSentinel
attr_accessor :text
def self.non_symbols_regexp
/[\ -\/\[-\`\:-\@\{-\~]/m
end
def initialize(text, opts=nil)
if text.present?
@text = Iconv.new('UTF-8//IGNORE', 'UTF-8').iconv(text.dup)
end
@opts = opts || {}
if @text.present?
@text.strip!
@text.gsub!(/ +/m, ' ') if @opts[:remove_interior_spaces]
end
end
# Entropy is a number of how many unique characters the string needs.
def entropy
return 0 if @text.blank?
@entropy ||= @text.each_char.to_a.uniq.size
end
def valid?
# Blank strings are not valid
return false if @text.blank?
# Entropy check if required
return false if @opts[:min_entropy].present? and (entropy < @opts[:min_entropy])
# We don't have a comprehensive list of symbols, but this will eliminate some noise
non_symbols = @text.gsub(TextSentinel.non_symbols_regexp, '').size
return false if non_symbols == 0
# Don't allow super long strings without spaces
return false if @opts[:max_word_length] and @text =~ /\w{#{@opts[:max_word_length]},}(\s|$)/
# We don't allow all upper case content
return false if @text == @text.upcase
true
end
end

View File

@ -0,0 +1,37 @@
require 'spec_helper'
require_dependency 'multisite_i18n'
describe MultisiteI18n do
before do
I18n.stubs(:t).with('test', {}).returns('default i18n')
MultisiteI18n.stubs(:translation_or_nil).with("default.test", {}).returns(nil)
MultisiteI18n.stubs(:translation_or_nil).with("other_site.test", {}).returns("overwritten i18n")
end
context "no value for a multisite key" do
it "it returns the default i18n key" do
MultisiteI18n.site_translate('default', 'test').should == "default i18n"
end
end
context "with a value for the multisite key" do
it "returns the overwritten value" do
MultisiteI18n.site_translate('other_site', 'test').should == "overwritten i18n"
end
end
context "when we call t, it uses the current site" do
it "returns the original" do
MultisiteI18n.t('test').should == 'default i18n'
end
it "returns the overwritten" do
RailsMultisite::ConnectionManagement.stubs(:current_db).returns('other_site')
MultisiteI18n.t('test').should == "overwritten i18n"
end
end
end

View File

@ -11,7 +11,7 @@ describe PostCreator do
context 'new topic' do
let(:category) { Fabricate(:category, user: user) }
let(:basic_topic_params) { {title: 'hello world', raw: 'my name is fred', archetype_id: 1} }
let(:basic_topic_params) { {title: 'hello world topic', raw: 'my name is fred', archetype_id: 1} }
let(:image_sizes) { {'http://an.image.host/image.jpg' => {'width' => 111, 'height' => 222}} }
let(:creator) { PostCreator.new(user, basic_topic_params) }
@ -83,7 +83,7 @@ describe PostCreator do
let(:target_user1) { Fabricate(:coding_horror) }
let(:target_user2) { Fabricate(:moderator) }
let(:post) do
PostCreator.create(user, title: 'hi there',
PostCreator.create(user, title: 'hi there welcome to my topic',
raw: 'this is my awesome message',
archetype: Archetype.private_message,
target_usernames: [target_user1.username, target_user2.username].join(','))

View File

@ -14,7 +14,7 @@ describe Search do
context 'post indexing observer' do
before do
@category = Fabricate(:category, name: 'america')
@topic = Fabricate(:topic, title: 'sam test', category: @category)
@topic = Fabricate(:topic, title: 'sam test topic', category: @category)
@post = Fabricate(:post, topic: @topic, raw: 'this <b>fun test</b> <img src="bla" title="my image">')
@indexed = Topic.exec_sql("select search_data from posts_search where id = #{@post.id}").first["search_data"]
end
@ -29,7 +29,7 @@ describe Search do
end
it "should pick up on title updates" do
@topic.title = "harpi"
@topic.title = "harpi is the new title"
@topic.save!
@indexed = Topic.exec_sql("select search_data from posts_search where id = #{@post.id}").first["search_data"]

View File

@ -0,0 +1,96 @@
# encoding: utf-8
require 'spec_helper'
require 'text_sentinel'
require 'iconv'
describe TextSentinel do
context "entropy" do
it "returns 0 for an empty string" do
TextSentinel.new("").entropy.should == 0
end
it "returns 0 for a nil string" do
TextSentinel.new(nil).entropy.should == 0
end
it "returns 1 for a string with many leading spaces" do
TextSentinel.new((" " * 10) + "x").entropy.should == 1
end
it "returns 1 for one char, even repeated" do
TextSentinel.new("a" * 10).entropy.should == 1
end
it "returns an accurate count of many chars" do
TextSentinel.new("evil trout is evil").entropy.should == 10
end
end
context "cleaning up" do
it "strips leading or trailing whitespace" do
TextSentinel.new(" \t test \t ").text.should == "test"
end
it "allows utf-8 chars" do
TextSentinel.new("йȝîûηыეமிᚉ⠛").text.should == "йȝîûηыეமிᚉ⠛"
end
context "interior spaces" do
let(:spacey_string) { "hello there's weird spaces here." }
it "ignores intra spaces by default" do
TextSentinel.new(spacey_string).text.should == spacey_string
end
it "fixes intra spaces when enabled" do
TextSentinel.new(spacey_string, remove_interior_spaces: true).text.should == "hello there's weird spaces here."
end
end
end
context "validity" do
let(:valid_string) { "This is a cool topic about Discourse" }
it "allows a valid string" do
TextSentinel.new(valid_string).should be_valid
end
it "doesn't allow all caps topics" do
TextSentinel.new(valid_string.upcase).should_not be_valid
end
it "enforces the minimum entropy" do
TextSentinel.new(valid_string, min_entropy: 16).should be_valid
end
it "enforces the minimum entropy" do
TextSentinel.new(valid_string, min_entropy: 17).should_not be_valid
end
it "doesn't allow a long alphanumeric string with no spaces" do
TextSentinel.new("jfewjfoejwfojeojfoejofjeo38493824jfkjewfjeoifijeoijfoejofjeojfoewjfo834988394032jfiejoijofijeojfeojfojeofjewojfojeofjeowjfojeofjeojfoe3898439849032jfeijfwoijfoiewj",
max_word_length: 30).should_not be_valid
end
it "doesn't except junk symbols as a string" do
TextSentinel.new("[[[").should_not be_valid
TextSentinel.new("<<<").should_not be_valid
TextSentinel.new("{{$!").should_not be_valid
end
end
end

View File

@ -11,11 +11,11 @@ describe TopicQuery do
let(:admin) { Fabricate(:moderator) }
context 'a bunch of topics' do
let!(:regular_topic) { Fabricate(:topic, title: 'regular', user: creator, bumped_at: 15.minutes.ago) }
let!(:pinned_topic) { Fabricate(:topic, title: 'pinned', user: creator, pinned: true, bumped_at: 10.minutes.ago) }
let!(:archived_topic) { Fabricate(:topic, title: 'archived', user: creator, archived: true, bumped_at: 6.minutes.ago) }
let!(:invisible_topic) { Fabricate(:topic, title: 'invisible', user: creator, visible: false, bumped_at: 5.minutes.ago) }
let!(:closed_topic) { Fabricate(:topic, title: 'closed', user: creator, closed: true, bumped_at: 1.minute.ago) }
let!(:regular_topic) { Fabricate(:topic, title: 'this is a regular topic', user: creator, bumped_at: 15.minutes.ago) }
let!(:pinned_topic) { Fabricate(:topic, title: 'this is a pinned topic', user: creator, pinned: true, bumped_at: 10.minutes.ago) }
let!(:archived_topic) { Fabricate(:topic, title: 'this is an archived topic', user: creator, archived: true, bumped_at: 6.minutes.ago) }
let!(:invisible_topic) { Fabricate(:topic, title: 'this is an invisible topic', user: creator, visible: false, bumped_at: 5.minutes.ago) }
let!(:closed_topic) { Fabricate(:topic, title: 'this is a closed topic', user: creator, closed: true, bumped_at: 1.minute.ago) }
context 'list_popular' do
let(:topics) { topic_query.list_popular.topics }

View File

@ -334,9 +334,9 @@ describe TopicsController do
end
it 'allows a change of title' do
xhr :put, :update, topic_id: @topic.id, slug: @topic.title, title: 'new title'
xhr :put, :update, topic_id: @topic.id, slug: @topic.title, title: 'this is a new title for the topic'
@topic.reload
@topic.title.should == 'new title'
@topic.title.should == 'this is a new title for the topic'
end
it 'triggers a change of category' do

View File

@ -2,6 +2,11 @@ require 'spec_helper'
describe UsersController do
before do
UsersController.any_instance.stubs(:honeypot_value).returns(nil)
UsersController.any_instance.stubs(:challenge_value).returns(nil)
end
describe '.show' do
let!(:user) { log_in }
@ -339,7 +344,41 @@ describe UsersController do
User.where(username: @user.username).first.active.should be_false
end
end
shared_examples_for 'honeypot fails' do
it 'should not create a new user' do
expect {
xhr :post, :create, create_params
}.to_not change { User.count }
end
it 'should not send an email' do
User.any_instance.expects(:enqueue_welcome_message).never
xhr :post, :create, create_params
end
it 'should say it was successful' do
xhr :post, :create, create_params
json = JSON::parse(response.body)
json["success"].should be_true
end
end
context 'when honeypot value is wrong' do
before do
UsersController.any_instance.stubs(:honeypot_value).returns('abc')
end
let(:create_params) { {:name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email, :password_confirmation => 'wrong'} }
it_should_behave_like 'honeypot fails'
end
context 'when challenge answer is wrong' do
before do
UsersController.any_instance.stubs(:challenge_value).returns('abc')
end
let(:create_params) { {:name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email, :challenge => 'abc'} }
it_should_behave_like 'honeypot fails'
end
end
context '.username' do

View File

@ -111,6 +111,13 @@ describe PostAction do
describe 'flagging' do
it 'does not allow you to flag stuff with 2 reasons' do
post = Fabricate(:post)
u1 = Fabricate(:evil_trout)
PostAction.act(u1, post, PostActionType.Types[:spam])
lambda { PostAction.act(u1, post, PostActionType.Types[:off_topic]) }.should raise_error(PostAction::AlreadyFlagged)
end
it 'should update counts when you clear flags' do
post = Fabricate(:post)
u1 = Fabricate(:evil_trout)

View File

@ -30,7 +30,7 @@ describe PostAlertObserver do
context 'when editing a post' do
it 'notifies a user of the revision' do
lambda {
post.revise(evil_trout, "world")
post.revise(evil_trout, "world is the new body of the message")
}.should change(post.user.notifications, :count).by(1)
end
end

View File

@ -54,12 +54,24 @@ describe Post do
topic.user.trust_level = TrustLevel.Levels[:moderator]
Fabricate.build(:post, post_args).should be_valid
end
end
end
describe 'flagging helpers' do
it 'isFlagged is accurate' do
post = Fabricate(:post)
user = Fabricate(:coding_horror)
PostAction.act(user, post, PostActionType.Types[:off_topic])
post.reload
post.is_flagged?.should == true
PostAction.remove_act(user, post, PostActionType.Types[:off_topic])
post.reload
post.is_flagged?.should == false
end
end
describe 'message bus' do
it 'enqueues the post on the message bus' do

View File

@ -1,11 +1,10 @@
# encoding: UTF-8
require 'spec_helper'
describe Topic do
it { should validate_presence_of :title }
it { should_not allow_value("x" * (SiteSetting.max_topic_title_length + 1)).for(:title) }
it { should_not allow_value("x").for(:title) }
it { should_not allow_value((" " * SiteSetting.min_topic_title_length) + "x").for(:title) }
it { should belong_to :category }
it { should belong_to :user }
@ -24,6 +23,30 @@ describe Topic do
it { should rate_limit }
context '.title_quality' do
it "strips a title when identifying length" do
Fabricate.build(:topic, title: (" " * SiteSetting.min_topic_title_length) + "x").should_not be_valid
end
it "doesn't allow a long title" do
Fabricate.build(:topic, title: "x" * (SiteSetting.max_topic_title_length + 1)).should_not be_valid
end
it "doesn't allow a short title" do
Fabricate.build(:topic, title: "x" * (SiteSetting.min_topic_title_length + 1)).should_not be_valid
end
it "allows a regular title with a few ascii characters" do
Fabricate.build(:topic, title: "hello this is my cool topic! welcome: all;").should be_valid
end
it "allows non ascii" do
Fabricate.build(:topic, title: "Iñtërnâtiônàlizætiøn").should be_valid
end
end
context 'topic title uniqueness' do
@ -816,7 +839,7 @@ describe Topic do
context 'changing title' do
before do
topic.title = "new title"
topic.title = "new title for the topic"
topic.save
end